diff --git a/.dockerignore b/.dockerignore index 1312290d..b58adf63 100644 --- a/.dockerignore +++ b/.dockerignore @@ -21,6 +21,14 @@ tmp nohup.out operator-tui/target +**/target +**/build +vibecrafted-app/target +vibecrafted-server/target +vibecrafted-app/shell-agent/build +build +dist +.loctree scripts/installer/.venv **/__pycache__ *.py[cod] diff --git a/.github/workflows/portable.yml b/.github/workflows/portable.yml index b900dd0c..f5b0bce0 100644 --- a/.github/workflows/portable.yml +++ b/.github/workflows/portable.yml @@ -90,41 +90,21 @@ jobs: run: bash scripts/check-portable.sh curl-bootstrap: - name: curl | bash bootstrap - if: github.event_name == 'merge_group' + name: Runtime Pack bootstrap (${{ matrix.os }}) needs: portable - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - runner: macos-latest + os: macOS + - runner: ubuntu-latest + os: Linux + runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - name: Install build dependencies - run: | - sudo apt-get update - # install.sh -> make install builds voc, whose arboard clipboard dep - # links gtk-sys/glib-sys on Linux. libgtk-3-dev transitively provides - # the glib-2.0 pkg-config files the build needs. (Rust is preinstalled - # on ubuntu-latest with a default toolchain, and this job uses the real - # HOME, so no rustup pin is needed here.) - sudo apt-get install -y \ - build-essential \ - cmake \ - libclang-dev \ - libgtk-3-dev \ - libxdo-dev \ - libayatana-appindicator3-dev \ - protobuf-compiler - - name: Provision leptos toolchain for the server shell build - shell: bash - run: | - # Same requirement as the portable job: install.sh -> make install - # -> install-bundle-tools hard-requires cargo-leptos once cargo is - # present, plus a Cargo.lock-matched wasm-bindgen-cli. - rustup target add wasm32-unknown-unknown - curl -L --proto '=https' --tlsv1.2 -sSf -o /tmp/install-binstall.sh https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh - bash /tmp/install-binstall.sh - cargo binstall -y cargo-leptos - lock_version="$(cargo tree --locked --manifest-path vibecrafted-server/Cargo.toml -p wasm-bindgen --depth 0 --prefix none | awk 'NR == 1 { sub(/^v/, "", $2); print $2 }')" - cargo binstall -y "wasm-bindgen-cli@${lock_version}" - - name: Serve install.sh locally and bootstrap + - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 + - name: Build canonical source side of the release tuple run: | archive="/tmp/vibecrafted-bootstrap.tar.gz" source_revision="$(git rev-parse HEAD)" @@ -134,26 +114,8 @@ jobs: --root-name vibecrafted-bootstrap \ --owner-repo "$GITHUB_REPOSITORY" \ --source-revision "$source_revision" - bash install.sh --archive-file "$archive" - - name: Verify bootstrap result + - name: Prove canonical carrier and bootstrap contract run: | - # install.sh stages the source snapshot under the RUNTIME home - # ($XDG_DATA_HOME/vibecrafted, default ~/.local/share/vibecrafted), - # not the skills home (~/.vibecrafted). Mirror install.sh's own - # tools-dir resolution (default_vibecrafted_runtime_home + - # VIBECRAFTED_TOOLS_HOME override) so verify checks the path the - # installer actually wrote. - if [ -n "${VIBECRAFTED_RUNTIME_HOME:-}" ]; then - runtime_home="$VIBECRAFTED_RUNTIME_HOME" - elif [ -n "${XDG_DATA_HOME:-}" ]; then - runtime_home="$XDG_DATA_HOME/vibecrafted" - else - runtime_home="$HOME/.local/share/vibecrafted" - fi - tools_dir="${VIBECRAFTED_TOOLS_HOME:-$runtime_home/tools}" - test -L "$tools_dir/vibecrafted-current" - test -f "$tools_dir/vibecrafted-current/vibecrafted-core/vibecrafted_core/runtime/scripts/codex_spawn.sh" - # Runtime contract: the default install lane does not wire legacy shell - # helpers (vc-skills.sh) — that is an explicit --with-shell opt-in — so - # the bootstrap verify no longer asserts the helper file here. - echo "Bootstrap OK" + env -u PYTHONPATH uv run --project vibecrafted-core --with pytest \ + python -m pytest tests/tui/test_runtime_pack_cli.py \ + tests/tui/test_install_bootstrap.py -q diff --git a/.github/workflows/release-dmg.yml b/.github/workflows/release-dmg.yml index e6ff1666..d51853d9 100644 --- a/.github/workflows/release-dmg.yml +++ b/.github/workflows/release-dmg.yml @@ -109,7 +109,6 @@ jobs: # Vibecrafted Server shell (leptos); version matches the operator machine cargo install --locked cargo-leptos@0.3.7 - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 - - name: Materialize signing keys from secrets env: VC_CERT_P12_B64: ${{ secrets.VC_CERT_P12_B64 }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f85da9ef..20bf22fb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,7 +70,7 @@ jobs: # later. It arrived in ef700e52 (3.7.1) and every tag since died # earlier, so it has never once executed. These are literal presence # assertions over enumerated files, which is what grep is for. - - name: Confirm publication boundary for both channels + - name: Confirm publication boundary for all carriers run: | set -euo pipefail test -x scripts/publish-vibecrafted-release.sh @@ -83,3 +83,9 @@ jobs: scripts/build-portable-release.sh \ scripts/publish-vibecrafted-release.sh \ docs/RELEASE_KICKOFF.md + grep -nE 'RUNTIME_PACK_NAME|RuntimePack_.*tar\.gz|install-runtime-pack\.sh' \ + scripts/build-vibecrafted-release.sh \ + scripts/package-runtime-pack.sh \ + scripts/install-runtime-pack.sh \ + scripts/publish-vibecrafted-release.sh \ + docs/RELEASE_KICKOFF.md diff --git a/.gitignore b/.gitignore index ccbced60..306c3fe1 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ vibecrafted-io-link !/.loctree/canary/ /.antigravitycli /vibecrafted-server/target +/vibecrafted-app/shell-agent/app/Vibecrafted.xcodeproj/ /docs/plans /.air /.venv/ diff --git a/.loctree/canary/JOURNAL.md b/.loctree/canary/JOURNAL.md index fbf717e4..f0469243 100644 --- a/.loctree/canary/JOURNAL.md +++ b/.loctree/canary/JOURNAL.md @@ -375,7 +375,7 @@ The map says otherwise: responsibilities already moved). `shell/lib` = the operator-terminal facade (self-described: "sourced only by the compatibility facade"; consumers: `vetcoders.sh`, `install-foundations.sh`, - `sync-vc-alias-runtime.sh`, the vc-* verbs). + `sync-vc-alias-runtime.sh`, the vc-\* verbs). - `frontier.sh` ×2 is a **FALSE_PARALLEL**: same name, different powers (spawn shell selection vs frontier config resolution). Rename candidate, not a cut. diff --git a/AGENTS.md b/AGENTS.md index 43087e14..ef7f4d13 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -754,7 +754,7 @@ We ship. ### Checks Requiring Secrets Or External Services -- DMG signing/notarization (Developer ID + notary credentials), `gh` for the release-gate probe, vibecrafted-io deploy — operator buttons, never run by workers. *** +- DMG signing/notarization (Developer ID + notary credentials), `gh` for the release-gate probe, vibecrafted-io deploy — operator buttons, never run by workers. \*\*\* ## Safety Boundaries diff --git a/CHANGELOG.md b/CHANGELOG.md index 552ddfc9..2d42ec65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). ## Unreleased +## 4.3.0 — prepared, not published + +### Changed + +- The root `VERSION` is the mechanical product-version authority for the core, + MCP, server, plugin and staged package metadata; the release gate rejects + drift before a carrier is built. +- `make install` and the non-interactive bootstrap lane consume a signed, + provenance-closed Runtime Pack on macOS, Linux and WSL2. Maintainers can + still choose the explicitly named `make install-source` compiler lane. +- Runtime Pack selection is platform/architecture specific and rejects a + carrier whose embedded version, platform, architecture or source/donor tuple + disagrees with the selected release asset. + +### Distribution status + +- v4.3.0 carrier metadata covers macOS plus Linux x86_64 and arm64. Publication, + production signing/notarization and the live channel update remain release + operator actions; this entry does not claim those outward steps occurred. + > **4.2.0 scope — measured truths, finished seams.** Release integrity from the > donor snapshot through to the payload a stranger downloads, and one identity > order shared by every surface that reads a run. diff --git a/Makefile b/Makefile index 27aeac1a..0034721b 100644 --- a/Makefile +++ b/Makefile @@ -36,13 +36,13 @@ if [ ! -d "$$stable_root/vibecrafted-core" ]; then \ fi endef -.PHONY: help help-dev vibecrafted app dmg dmg-signed release-local notarize release portable publish-release release-rehearsal gui-install wizard wizard-dev check test test-core test-skills test-install test-parity test-vc-frame test-iterm2-migrate test-memex test-aicx-sync test-hammerspoon test-keychain-session dispatch-test unified-product-contract-gate payload-hygiene install install-auto install-all install-python-tools install-bundle-tools install-tools install-tools-held install-vendored-binaries install-app-binaries install-hammerspoon skills helpers setup-dev dry-run doctor list update uninstall restore migrate migrate-dry init-hooks seed-commit-msg-hooks bundle bundle-check foundations foundations-check semgrep version version-show version-bump bump-patch bump-minor bump-major iterm-plugin iterm-plugin-refresh iterm-plugin-show iterm-plugin-uninstall iterm-plugin-migrate demo demo-full commit-safe test-race-protection skill-new server server-build build-server-release server-check server-test install-server install-server-payload install-server-service server-smoke +.PHONY: help help-dev vibecrafted app dmg dmg-signed release-local notarize release runtime-pack portable publish-release release-rehearsal gui-install wizard wizard-dev check test test-core test-skills test-install test-parity test-vc-frame test-iterm2-migrate test-memex test-aicx-sync test-hammerspoon test-keychain-session dispatch-test unified-product-contract-gate release-version-gate payload-hygiene install install-source install-auto install-all install-python-tools install-bundle-tools install-tools install-tools-held install-vendored-binaries install-app-binaries install-hammerspoon skills helpers setup-dev dry-run doctor list update uninstall restore migrate migrate-dry init-hooks seed-commit-msg-hooks bundle bundle-check foundations foundations-check semgrep version version-show version-bump bump-patch bump-minor bump-major iterm-plugin iterm-plugin-refresh iterm-plugin-show iterm-plugin-uninstall iterm-plugin-migrate demo demo-full commit-safe test-race-protection skill-new server server-build build-server-release server-check server-test install-server install-server-payload install-server-service server-smoke help: @printf "\n" @printf " \033[1m\033[38;5;173m⚒ 𝚅𝚒𝚋𝚎𝚌𝚛𝚊𝚏𝚝𝚎𝚍. %s\033[0m\n" "$$(cat $(VERSION_FILE) 2>/dev/null || echo dev)" @printf "\n" - @printf " make install \033[2mGuided install\033[0m\n" + @printf " make install \033[2mInstall the receipted Runtime Pack\033[0m\n" @printf " make doctor \033[2mHealth check\033[0m\n" @printf " make update \033[2mPull latest + reinstall\033[0m\n" @printf " make uninstall \033[2mReverse the install\033[0m\n" @@ -59,7 +59,7 @@ help-dev: @printf "\n" @printf " \033[1m\033[38;5;173m⚒ 𝚅𝚒𝚋𝚎𝚌𝚛𝚊𝚏𝚝𝚎𝚍. dev targets\033[0m\n" @printf "\n" - @printf " \033[1minstall\033[0m install · install-auto · install-all · install-python-tools · install-vendored-binaries · install-app-binaries · install-server · install-server-service · install-hammerspoon\n" + @printf " \033[1minstall\033[0m install · install-source · install-auto · install-all · install-python-tools · install-vendored-binaries · install-app-binaries · install-server · install-server-service · install-hammerspoon\n" @printf " skills · helpers · setup-dev · wizard · wizard-dev · gui-install · dry-run · restore\n" @printf " migrate · migrate-dry · foundations · foundations-check · bundle · bundle-check\n" @printf " \033[1mtests\033[0m test · test-core · test-skills · test-install · test-parity · test-vc-frame · test-iterm2-migrate\n" @@ -79,6 +79,9 @@ vibecrafted: install RELEASE_SCRIPT := scripts/build-vibecrafted-release.sh PORTABLE_SCRIPT := scripts/build-portable-release.sh +RUNTIME_PACK_INSTALLER := scripts/install-runtime-pack.sh +RUNTIME_PACK_PACKAGER := scripts/package-runtime-pack.sh +RUNTIME_PACK ?= KEYS ?= $(HOME)/.keys # Extra builder flags, e.g. RELEASE_FLAGS=--snapshot-donors to build from # detached worktrees at each donor HEAD instead of refusing a dirty donor. @@ -107,6 +110,19 @@ notarize: release: @VC_RELEASE_FLAGS='$(RELEASE_FLAGS)' zsh -ic 'cd "$(CURDIR)" && KEYS="$(KEYS)" exec bash "$(RELEASE_SCRIPT)" $${=VC_RELEASE_FLAGS}' +# Build the standalone macOS CLI carrier from the exact same Runtime Pack bytes +# embedded in Vibecrafted.app. The packager adds only the two native helpers +# that AppDelegate normally supplies from Contents/Helpers. +runtime-pack: app + @version="$$(tr -d '[:space:]' < VERSION)"; \ + revision="$$(git rev-parse --short=8 HEAD)"; \ + date="$${VIBECRAFTED_RELEASE_DATE:-$$(date -u +%Y%m%d)}"; \ + arch="$$(uname -m | sed 's/^arm64$$/arm64/; s/^aarch64$$/arm64/; s/^x86_64$$/x64/')"; \ + output="dist/Vibecrafted_RuntimePack_$${version}-$${date}-$${revision}-darwin-$${arch}.tar.gz"; \ + test -s "$$output" \ + || { echo 'release builder produced no standalone Runtime Pack' >&2; exit 1; }; \ + printf '%s\n' "$$output" + # The portable channel needs no signing identity and no notary account: it is a # provenance-bound source distribution, so it builds anywhere git and python3 do. portable: @@ -148,6 +164,7 @@ publish-release: @zsh -ic 'cd "$(CURDIR)" && exec bash scripts/publish-vibecrafted-release.sh' unified-product-contract-gate: + @$(MAKE) --no-print-directory release-version-gate @set -eu; \ uv run --project vibecrafted-core --with pytest python -m pytest \ tests/tui/test_unified_app_contract.py \ @@ -180,6 +197,9 @@ unified-product-contract-gate: rc=0; env -u PYTHONPATH PYTHONNOUSERSITE=1 "$$runner" walkaround --release-output "$$tmp/release-output.json" --signature "$$tmp/release-output.json.sig" --output "$$tmp/walkaround.json" >/dev/null 2>&1 || rc=$$?; test "$$rc" -eq 22; \ test ! -e "$$tmp/walkaround.json") +release-version-gate: + @$(PYTHON) scripts/version_bump.py --check --file "$(VERSION_FILE)" + tui-installer: init-hooks @if ! command -v uv >/dev/null 2>&1; then \ echo "bootstrapping uv..."; \ @@ -261,6 +281,13 @@ endif install-auto: install install: + @VIBECRAFTED_RUNTIME_PACK="$(RUNTIME_PACK)" bash "$(RUNTIME_PACK_INSTALLER)" + +# Explicit source/compiler lane retained for the portable Linux/WSL carrier. +# It is not the normal customer installer: it may require Rust, cargo-leptos, +# sibling donors, and platform targets. macOS CLI/App install the exact same +# closed Runtime Pack through `make install` and AppDelegate respectively. +install-source: @mkdir -p "$(HOME)/.vibecrafted" @: > "$(INSTALL_LOG)" @printf "Installing Vibecrafted\n" @@ -276,9 +303,8 @@ install: printf "\nVibecrafted is ready (headless: the vc-frame cockpit is not installed; vc-start needs it).\n\nStart here:\n export PATH=\"\$$HOME/.local/bin:\$$PATH\"\n vibecrafted doctor\n vibecrafted implement claude --prompt \"describe this repo\"\n vibecrafted await claude --last\n\nLog:\n ~/.vibecrafted/install.log\n"; \ fi -# `make install` calls `install-python-tools`; it was an empty .PHONY name -# (no recipe) so the uv-tool install never ran during `make install`. Alias it -# to the real recipe. +# The explicit source/compiler lane calls `install-python-tools`; retain the +# alias for that portable residual without putting it back on `make install`. install-python-tools: install-tools # Full install keeps the installer, runtime publication, Python-tool replacement, @@ -391,7 +417,7 @@ install-tools-held: fi; \ resolved_real="$$($(PYTHON) -c 'from pathlib import Path; import sys; print(Path(sys.argv[1]).resolve())' "$$resolved")"; \ if [ "$$entrypoint" = "vibecrafted" ]; then \ - expected_path="$$stable_root/vibecrafted-core/vibecrafted_core/deck/vibecrafted"; \ + expected_path="$$stable_root/bin/vibecrafted"; \ else \ expected_path="$$tool_root/bin/$$entrypoint"; \ fi; \ @@ -622,7 +648,7 @@ update: fi uninstall: - @$(PYTHON) $(INSTALLER) uninstall + @VIBECRAFTED_RUNTIME_PACK="$(RUNTIME_PACK)" bash "$(RUNTIME_PACK_INSTALLER)" --uninstall restore: @$(PYTHON) $(INSTALLER) restore diff --git a/README.md b/README.md index e977a96b..6694ba36 100644 --- a/README.md +++ b/README.md @@ -177,15 +177,31 @@ wsl --install wsl bash -c 'curl -fsSL https://vibecrafted.io/install.sh | bash' ``` -**From source** (power users, maintainers, anyone who wants the gates): +**macOS CLI Runtime Pack** (power users who do not want the App): download the +signed binary carrier and both sidecars from the latest release, then point the +checkout front door at it: ```bash git clone https://github.com/vetcoders/vibecrafted.git -cd vibecrafted && make install +cd vibecrafted +make install RUNTIME_PACK=../Vibecrafted_RuntimePack_---darwin-.tar.gz +make uninstall # same installer, same receipt +``` + +Maintainers who intentionally want local compilation use the explicit source +lane: + +```bash +make install-source make help-dev # the full target surface ``` -A source install gives you the complete headless runtime — `vibecrafted +`make install` never compiles a product for a stranger. It selects a closed +Runtime Pack for the current platform and architecture; Linux and WSL2 use the +Linux x86_64 or arm64 carrier. `make install-source` is the explicit maintainer +lane and may require the full build toolchain. + +A Runtime Pack install gives you the complete headless runtime — `vibecrafted doctor`, every skill launcher, `observe`/`await`, reports and transcripts under `~/.vibecrafted`. The visual cockpit (`vc-frame`, `vc-start`) is not part of it: it ships inside the desktop app below, and `vibecrafted init ` falls back @@ -208,6 +224,12 @@ verify the checksum, then open the DMG. The build path (`make release`) is exercised and produces a Developer ID signed, notarized and stapled artifact; until the release carrying it is published, use the bootstrap. +The same release also carries +`Vibecrafted_RuntimePack_---darwin-.tar.gz`, its +`.sha256`, and detached `.sig`. It contains the exact runtime embedded in the +App plus the same terminal/frame helpers; the DMG is an optional onboarding +overlay, not a second runtime authority. + **Every other system** (Linux, WSL2, or macOS without the desktop app): the same release carries `Vibecrafted_---portable.tar.gz` and its adjacent `.sha256`. It is not a convenience copy of the repository — it diff --git a/VERSION b/VERSION index cf78d5b6..80895903 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.2.4 +4.3.0 diff --git a/docs/QUICK_START.md b/docs/QUICK_START.md index cf316c9c..98ba7430 100644 --- a/docs/QUICK_START.md +++ b/docs/QUICK_START.md @@ -15,11 +15,14 @@ wsl --install wsl bash -c 'curl -fsSL https://vibecrafted.io/install.sh | bash' ``` -**From source:** +**macOS CLI, without the App:** download the Runtime Pack plus `.sha256` and +`.sig` from the latest release, then: ```bash git clone https://github.com/vetcoders/vibecrafted.git -cd vibecrafted && make install +cd vibecrafted +make install RUNTIME_PACK=../Vibecrafted_RuntimePack_---darwin-.tar.gz +make uninstall # deterministic reset from the same receipt ``` On macOS the intended end-user artifact is one signed and notarized @@ -29,6 +32,10 @@ verified against its adjacent `.dmg.sha256`. The build path is exercised and produces a signed, notarized, stapled artifact; until the release carrying it is published, use the bootstrap. +Power users can skip the DMG and App entirely. The adjacent +`Vibecrafted_RuntimePack_---darwin-.tar.gz` is +the same signed binary runtime that onboarding installs from the App. + Everywhere else — Linux, WSL2, or macOS without the desktop app — take `Vibecrafted_---portable.tar.gz` from the same release instead. It pins one exact commit through a closed `source-provenance.json`, @@ -86,7 +93,10 @@ Use `vibecrafted help` for the full operator surface. ## Developer checkout path -`make install`, `make install-auto` and the source bootstrap are the same -runtime the public installer stages, plus the build, test and release targets. +`make install` consumes a closed Runtime Pack selected for macOS or Linux and +the host architecture. WSL2 consumes the matching Linux carrier and no default +install silently compiles. `make install-source` is the explicit maintainer +compiler lane. A developer checkout also exposes the +build, test and release targets. Run `make help-dev` for the full inventory, or read [Build from source](public/getting-started/build-from-source.md). diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index 63528325..7ac255d7 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -19,14 +19,16 @@ below pass on downloaded bytes. One GitHub Release `v4.1.0` whose assets are exactly: -| Asset | Proves | -| ----------------------------------------------------- | ------------------------------------------------ | -| `Vibecrafted_4.1.0--.dmg` | signed, notarized desktop product | -| that name plus `.dmg.sha256` | checksum a stranger can `shasum -a 256 -c` | -| `Vibecrafted_4.1.0---portable.tar.gz` | installable product for Linux / WSL2 / macOS CLI | -| that name plus `.sha256` | checksum a stranger can `sha256sum -c` | -| `release-output.json` | bound source revisions + DMG path | -| `release-output.json.sig` | detached signature over that receipt | +| Asset | Proves | +| ---------------------------------------------------------------------- | ------------------------------------------ | +| `Vibecrafted_4.1.0--.dmg` | signed, notarized desktop product | +| that name plus `.dmg.sha256` | checksum a stranger can `shasum -a 256 -c` | +| `Vibecrafted_RuntimePack_4.1.0---darwin-.tar.gz` | same signed binary runtime for macOS CLI | +| that name plus `.sha256` and `.sig` | checksum plus detached release signature | +| `Vibecrafted_4.1.0---portable.tar.gz` | source fallback for Linux / WSL2 / macOS | +| that name plus `.sha256` | checksum a stranger can `sha256sum -c` | +| `release-output.json` | bound source revisions + DMG path | +| `release-output.json.sig` | detached signature over that receipt | Exactly six assets. `publish-release` refuses anything else — including the old source-tarball set. `portable-output.json` stays local: it is how the diff --git a/docs/RELEASE_KICKOFF.md b/docs/RELEASE_KICKOFF.md index 108c84be..f1a404e1 100644 --- a/docs/RELEASE_KICKOFF.md +++ b/docs/RELEASE_KICKOFF.md @@ -3,11 +3,12 @@ ## Public product - Owner: `vetcoders/vibecrafted` -- Artifacts: two channels, one commit +- Artifacts: three carriers, one commit - macOS desktop: `Vibecrafted_--.dmg` + - macOS CLI: `Vibecrafted_RuntimePack_---darwin-.tar.gz` - every other system: `Vibecrafted_---portable.tar.gz` - App: `Vibecrafted.app` -- Download: `https://github.com/vetcoders/vibecrafted/releases/latest` → `Vibecrafted_--.dmg` (macOS) or the `-portable.tar.gz` (Linux / WSL2 / macOS CLI) +- Download: `https://github.com/vetcoders/vibecrafted/releases/latest` → DMG (macOS desktop), `RuntimePack_...tar.gz` (macOS CLI), or `-portable.tar.gz` (Linux / WSL2 / source fallback) - Embedded donors: `vc-terminal`, `vc-frame` - Entry: bundled `vc-start` with durable `workspace_id` @@ -55,7 +56,12 @@ proves — is [RELEASE_CHECKLIST.md](RELEASE_CHECKLIST.md). macOS: download the canonical `Vibecrafted_--.dmg` and its `.dmg.sha256` from the latest release, verify the checksum, and open the DMG. -Linux, WSL2, or macOS without the desktop app: download +macOS without the App: download +`Vibecrafted_RuntimePack_---darwin-.tar.gz` plus +its `.sha256` and `.sig`, then run `make install RUNTIME_PACK=`. +Reset with `make uninstall`; both buttons use the same receipted installer. + +Linux, WSL2, or the explicit source fallback: download `Vibecrafted_---portable.tar.gz` and its `.sha256` from the same release, verify the checksum, unpack, and run the packed `install.sh`. diff --git a/docs/VC-FRAME.md b/docs/VC-FRAME.md index 13b36b43..fd9b42c0 100644 --- a/docs/VC-FRAME.md +++ b/docs/VC-FRAME.md @@ -117,13 +117,21 @@ simplified_ui true ### Operator layout = vibecrafted standard -`layouts/operator.kdl` (vc-start) is the same file content as built-in -`default_layout "vibecrafted"` (tabs **Start here** + **Shell**, no spaces in -layout _filenames_; never `Vibecrafted Operator.kdl`): +`layouts/operator.kdl` (vc-start) and built-in `default_layout "vibecrafted"` +share the same product tabs: **Start here**, **Agents**, **Shell**, and **voc** +(no spaces in layout _filenames_; never `Vibecrafted Operator.kdl`): - `default_tab_template` — compact-bar brand + **SESSIONS rail always** + status-bar -- tab **Start here** — Mission Control (`about` / `guide_mode "mission-control"`) -- tab **Shell** — operator work shell +- tab **Start here** — product map (`about` / `guide_mode "mission-control"`) +- tab **Agents** — Agent Workspaces dashboard; `[New agent]` creates an + interactive Agent TTY on this tab, while PANE + arrows walks its faces +- tab **Shell** — workspace shell +- tab **voc** — observation door for this workspace, never the launcher itself + +The interactive launcher deliberately exposes only contracts that stay in the +current panel today: `init --runtime plain` and bare `resume`. The accepted +design keeps `operator` / `partner` unresolved and `New dispatch` as a later, +server-owned headless door; the UI must not fake those choices prematurely. No strider split on the entrypoint. diff --git a/docs/installer/REQUIRED-SET.md b/docs/installer/REQUIRED-SET.md index e90f578c..d6931cde 100644 --- a/docs/installer/REQUIRED-SET.md +++ b/docs/installer/REQUIRED-SET.md @@ -9,61 +9,70 @@ has been upgraded since 3.7.x that manifest never saw `releases/`, `providers/`, This document inverts the question. Instead of listing what uninstall removes, it fixes **the required set**: the smallest collection of paths that must exist -for each launcher and flow to work. Anything the framework writes that is not in -the required set is a generation, a backup, a staging leftover, or a cache — and -uninstall takes it off by **discovery**, matching known names and patterns rather -than trusting a manifest. - -Implementation: `_build_uninstall_inventory` and `_managed_tools_entry` in -`scripts/vetcoders_install.py`. Regression coverage: +for each launcher and flow to work. Current Runtime Pack installs use a closed, +hashed ownership receipt. Legacy/source installs still need discovery as a +fallback because their historical manifests did not see the full product. + +Implementation: `cmd_runtime_install`, `cmd_runtime_uninstall`, +`_build_uninstall_inventory`, and `_managed_tools_entry` in +`scripts/vetcoders_install.py`. The exact same installer is embedded under +`Vibecrafted.app/Contents/Resources/runtime/scripts/`; AppDelegate delegates to +it and does not write the installation itself. Regression coverage: `tests/tui/test_installer_uninstall.py`, `tests/tui/test_installer_restore.py`. ## 1. The required set Every row is load-bearing: delete it and the named flow stops working. -| Surface | Required path | Needed for | -| ----------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| Launcher shims | `~/.local/bin/{vibecrafted, vc-*}` — one file per `PYTHON_ENTRYPOINT_LAUNCHERS` entry, plus the compat pack | every CLI entrypoint; they are the uv-receipt entrypoints and resolve into the current tools generation | -| PATH wiring | the `_launcher_path_line()` guard in `~/.zshrc` / `~/.bashrc` | the shims being on `PATH` in a fresh shell | -| Stable pointer | `~/.local/share/vibecrafted/tools/vibecrafted-current` (symlink) | every shim: paths are resolved through the pointer, never through a version directory | -| Tools generation | exactly one `tools/vibecrafted-generation---/` — the one `vibecrafted-current` points at | the Python package tree behind the pointer | -| uv environments | `/{vibecrafted, vibecrafted-mcp}`, `/vibecrafted-iterm2` where the iTerm2 plugin is installed | the interpreters the shims exec; owned by uv, not by us | -| Active release | `~/.local/share/vibecrafted/active.json` + the one `releases//` it names | app/runtime handoff — `active.json` carries `runtime_root` and `app_root` | -| Provider | `~/.local/share/vibecrafted/providers/vc-slack-agent/current` (symlink) + the one generation it names | `vc-slack` and the Slack bridge | -| Server assets | `~/.local/share/vibecrafted/server/site/` | the local dashboard/server surface | -| Skills store | `/vibecrafted-core/vibecrafted_core/skills/` | the one canonical copy of every skill | -| Skill projections | `~/./skills/` symlinks into the store, per installed runtime | agents seeing the skills at all | -| Install state | `~/.vibecrafted/.vc-install.json` (legacy installs: the same file next to the store) | update/uninstall knowing what this install registered | -| Frame config | `~/.config/vc-frame/`, `~/.config/vetcoders/frontier/` | `vc-frame` / `vc-start` cockpit | -| App bundle | `/Applications/Vibecrafted.app` | the GUI; installed from the DMG, not by this installer | - -Anything not in this table is disposable. In particular: **second and later -generations are never required.** One tools generation, one release, one provider -generation. Every other generation is retained history with no consumer. +| Surface | Required path | Needed for | +| ----------------- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| Launcher shims | `~/.local/bin/{vibecrafted, vc-*}` — one file per `PYTHON_ENTRYPOINT_LAUNCHERS` entry, plus the compat pack | every CLI entrypoint; they are the uv-receipt entrypoints and resolve into the current tools generation | +| PATH wiring | the `_launcher_path_line()` guard in `~/.zshrc` / `~/.bashrc` | the shims being on `PATH` in a fresh shell | +| Stable pointer | `~/.local/share/vibecrafted/tools/vibecrafted-current` (symlink) → the active `releases//` | foundations, doctor, CLI and agent projections resolving the same immutable generation as the App | +| uv environments | `/{vibecrafted, vibecrafted-mcp}`, `/vibecrafted-iterm2` where the iTerm2 plugin is installed | the interpreters the shims exec; owned by uv, not by us | +| Active release | `~/.local/share/vibecrafted/active.json` + the one `releases//` named by both pointers | app/runtime handoff — `active.json` carries `runtime_root` and `app_root` | +| Ownership receipt | `~/.local/share/vibecrafted/install-receipt.json` | deterministic reset, collision restore, and locally-modified-file refusal | +| Runtime installer | `/scripts/vetcoders_install.py` plus its bundled import closure | the same install/uninstall implementation for App and CLI | +| Provider | `~/.local/share/vibecrafted/providers/vc-slack-agent/current` (symlink) + the one generation it names | `vc-slack` and the Slack bridge | +| Server assets | `~/.local/share/vibecrafted/server/site/` | the local dashboard/server surface | +| Skills store | `/vibecrafted-core/vibecrafted_core/skills/` | the one canonical copy of every skill | +| Skill projections | `~/./skills/` symlinks into the store, per installed runtime | agents seeing the skills at all | +| Install state | `~/.vibecrafted/.vc-install.json` (legacy installs: the same file next to the store) | update/uninstall knowing what this install registered | +| Required tools | `loct`, `loctree-mcp`, `aicx`, `prview`, `screenscribe` plus the `vc-*` projections | complete agent product; every named tool belongs to the product payload, with no optional-product fiction | +| Frame config | `~/.config/vibecrafted/vc-frame/`, `~/.config/vetcoders/frontier/` | `vc-frame` / `vc-start` cockpit; no private top-level `~/.config/vc-frame` | +| App bundle | `/Applications/Vibecrafted.app` when the DMG channel is used | optional native transport/onboarding shell; CLI runtime must remain first-class without it | + +Anything not in this table is disposable. In particular: **there is no separate +tools generation in a Runtime Pack install.** `active.json` and +`vibecrafted-current` select one release generation; the latter is only a stable +filesystem projection for consumers that cannot read JSON. One active release +and one provider generation are required. Every other generation is retained +history with no consumer. ## 2. Discovery patterns uninstall removes -| Surface | Discovery pattern | Why it is removable | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -| Tools generations | `tools/vibecrafted-*`, `tools/vibecrafted-current` | only the pointer target is required; the rest are old generations | -| Incoming payloads | `tools/.incoming-*` | interrupted download staging | -| Atomic staging | `tools/..vibecrafted-*` | half-published generations left by an interrupted publish | -| Handoff receipt | `tools/.vibecrafted-current-handoff.json` | per-publish marker | -| Install lease | `tools/.vibecrafted-install.lock` | transient cross-process lock; **the teardown itself creates it**, so it is registered in the inventory up front (see §4) | -| Finder metadata | `tools/.DS_Store`, `/.DS_Store` | inert metadata inside directories we own end to end; left behind it keeps the parent unprunable | -| Releases | `/releases/` | rebuilt from the payload on the next install | -| Providers | `/providers/` | rebuilt from the payload on the next install | -| Server assets | `/server/` | shipped inside the payload | -| Active pointer | `/active.json` | meaningless once the release it names is gone | -| Framework config | children of `~/.config/vibecrafted/` except `*.env` | generated: themes, shell fragments, plists | -| Frame config trees | `~/.config/vc-frame/`, `~/.config/vetcoders/frontier/` | generated symlink farms plus their own `.bak*` / `.stale*` snapshots | -| Launchd job (macOS) | `~/Library/LaunchAgents/com.vetcoders.vibecrafted-slack-bridge.plist` | provider service definition; a loaded job ends at logout or explicit bootout | -| iTerm2 profiles (macOS) | `~/Library/Application Support/iTerm2/DynamicProfiles/vibecrafted*.json` | written by the iTerm2 plugin | -| App support (macOS) | `~/Library/Application Support/{io.vetcoders.vc-frame, com.vibecrafted.vc-board, com.vibecrafted.vc-term}` | framework runtime state | -| Caches (macOS) | `~/Library/Caches/io.vetcoders.vc-frame` | cache | -| Preferences (macOS) | `~/Library/Preferences/{io.vetcoders.vibecrafted, com.vibecrafted.vc-board, com.vibecrafted.vc-board.debug, com.vibecrafted.vc-term}.plist` | framework preference domains | -| Shell rc lines | the marked Vibecrafted block in `~/.zshrc` / `~/.bashrc` | edited in place, never truncated | +| Surface | Discovery pattern | Why it is removable | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| Legacy tools generations | `tools/vibecrafted-generation-*` | superseded source-installer generations; Runtime Pack installs point `vibecrafted-current` at `releases/` | +| Incoming payloads | `tools/.incoming-*` | interrupted download staging | +| Atomic staging | `tools/..vibecrafted-*` | half-published generations left by an interrupted publish | +| Handoff receipt | `tools/.vibecrafted-current-handoff.json` | per-publish marker | +| Install lease | `tools/.vibecrafted-install.lock` | transient cross-process lock; **the teardown itself creates it**, so it is registered in the inventory up front (see §4) | +| Finder metadata | `tools/.DS_Store`, `/.DS_Store` | inert metadata inside directories we own end to end; left behind it keeps the parent unprunable | +| Releases | `/releases/` | rebuilt from the payload on the next install | +| Providers | `/providers/` | rebuilt from the payload on the next install | +| Server assets | `/server/` | shipped inside the payload | +| Active pointer | `/active.json` | meaningless once the release it names is gone | +| Runtime receipt | `/install-receipt.json`, after its plan has been applied | per-install ownership evidence, not durable operator data | +| Framework config | children of `~/.config/vibecrafted/` except `*.env` | generated: themes, shell fragments, plists | +| Frame config trees | `~/.config/vibecrafted/vc-frame/`, legacy `~/.config/vc-frame/`, `~/.config/vetcoders/frontier/` | generated config/symlink farms plus their own `.bak*` / `.stale*` snapshots | +| Server LaunchAgent | `~/Library/LaunchAgents/io.vetcoders.vibecrafted.server.plist` | product-owned supervisor definition; booted out before removal | +| Launchd job (macOS) | `~/Library/LaunchAgents/com.vetcoders.vibecrafted-slack-bridge.plist` | provider service definition; a loaded job ends at logout or explicit bootout | +| iTerm2 profiles (macOS) | `~/Library/Application Support/iTerm2/DynamicProfiles/vibecrafted*.json` | written by the iTerm2 plugin | +| App support (macOS) | `~/Library/Application Support/{io.vetcoders.vc-frame, com.vibecrafted.vc-board, com.vibecrafted.vc-term}` | framework runtime state | +| Caches (macOS) | `~/Library/Caches/io.vetcoders.vc-frame` | cache | +| Preferences (macOS) | `~/Library/Preferences/{io.vetcoders.vibecrafted, com.vibecrafted.vc-board, com.vibecrafted.vc-board.debug, com.vibecrafted.vc-term}.plist` | framework preference domains | +| Shell rc lines | the marked Vibecrafted block in `~/.zshrc` / `~/.bashrc` | edited in place, never truncated | Empty parents (`tools/`, the runtime home, `~/.config/vibecrafted/`) are removed **only if empty** after their children are gone. A single preserved stranger keeps @@ -74,7 +83,7 @@ the directory, and the inventory says so in its reason line. | Surface | Action | Reason | | ------------------------------------------------------------------ | -------- | -------------------------------------------------------------------------------------- | | `~/.config/vibecrafted/*.env` | preserve | operator secrets (Slack tokens and friends); never removed, never copied into a backup | -| `~/.vibecrafted/{artifacts, control_plane, logs}` | preserve | operator data — runs, reports, transcripts. Not installer-owned at any version | +| pre-existing `~/.vibecrafted/{artifacts, control_plane, logs}` | preserve | operator data outside a receipted clean-profile install | | `/bin/*` | preserve | binary ownership is product-managed outside installer state | | Unrecognized `tools/` siblings | preserve | not a Vibecrafted-managed payload name | | Unrecognized runtime-home children | preserve | discovery has no evidence they are ours | @@ -84,9 +93,23 @@ the directory, and the inventory says so in its reason line. Rule: an unrecognized name is preserved. Discovery widens ownership by adding known names, never by claiming whatever it finds. +For a clean profile where the Runtime Pack receipt says the installer created +`~/.vibecrafted`, reset removes that whole root, including runtime state created +after installation. If the root pre-existed, only proven owned children are +removed; unrelated operator state remains. + ## 4. Invariants -**Backup before remove.** Every `remove` record passes through +**Receipt installs refuse drift before remove.** Runtime Pack uninstall hashes +every owned regular file and verifies every owned symlink target before teardown. +A locally modified launcher/config/projection is a conflict and stops the +operation before the service or generation is removed. Pre-install collisions, +including agent-native skill paths, are copied under +`/.installer-backups/` and restored during a successful reset. +Only projection directories created by this installer are candidates for +cleanup, and they are removed with `rmdir` semantics only when empty. + +**Legacy backup before remove.** Every discovery `remove` record passes through `create_teardown_backup`, which snapshots each present path into `~/.vibecrafted/backups/installer//` with a `restore-manifest.json` and a self-contained `restore.py`. `vibecrafted restore` replays that manifest by @@ -121,8 +144,15 @@ removed and not backed up. - Retention _during_ normal operation. Uninstall now removes all generations, but nothing prunes them on a live machine — 25 provider generations and 6 releases still accumulate. That is a separate cut. -- Foundation tools (`loct`, `loctree`, `aicx`) and their `/usr/local/bin` - symlinks. Different owner, different installer. +- Publishing upstream PRView release archives. The carrier currently prebuilds + the exact crates.io release because the documented GitHub Release channel has + no assets. Customers still receive a ready binary and never need Cargo; the + upstream release channel should be repaired so a future carrier can verify + and embed its archive directly. +- Rebuilding upstream AICX release archives without CI host paths. Version + `0.12.5` archives are checksum-correct but name their macOS builder under + `/Users`; the carrier therefore prebuilds exact commit `ced57997` with path + remaps rather than weakening payload hygiene. - `$TMPDIR` test scratch. Owned by the test suite, not by the installer. _𝚅𝚒𝚋𝚎𝚌𝚛𝚊𝚏𝚝𝚎𝚍. with AI Agents by Vetcoders (c)2024-2026 LibraxisAI_ diff --git a/docs/public/getting-started/install.md b/docs/public/getting-started/install.md index 85871824..23f8f4b9 100644 --- a/docs/public/getting-started/install.md +++ b/docs/public/getting-started/install.md @@ -15,6 +15,7 @@ that matches your platform, then verify the result with `vibecrafted doctor`. | Channel | Platform | What you get | Status | | ---------------------------- | -------------------- | ------------------------------------------------------ | ---------------------------------------- | | Signed `Vibecrafted.app` DMG | macOS 14+, arm64 | Full desktop product: terminal, frame, runtime, server | Build path complete; publication pending | +| Signed Runtime Pack | macOS 14+, per-arch | Same prebuilt runtime without DMG/App | Built and signed with the DMG | | Bootstrap `install.sh` | macOS, Linux, WSL2 | Command deck, runtime, control plane, skills | Published; CI-gated | | Source checkout | macOS, Linux, WSL2 | Everything above plus build, test and release targets | Published | | Container | anywhere Docker runs | Isolated operator runtime | Published | @@ -106,6 +107,25 @@ shasum -a 256 -c Vibecrafted_--.dmg.sha256 Drag `Vibecrafted.app` to Applications and launch it. +## macOS CLI Runtime Pack + +The App is optional. The same release carries +`Vibecrafted_RuntimePack_---darwin-.tar.gz`, its +`.sha256`, and detached `.sig`. It is built from the exact Runtime Pack inside +the signed App and adds only the bundled terminal and native `vc-frame` helper. + +From a checkout: + +```bash +make install RUNTIME_PACK=../Vibecrafted_RuntimePack_---darwin-.tar.gz +make uninstall +``` + +Both commands use the pack-owned Python and `vetcoders_install.py`. Installation +writes one ownership receipt; uninstall refuses modified managed files, restores +pre-existing collisions, and prunes only directories that receipt proves the +installer created. + Check what a given release actually carries: ```bash @@ -117,7 +137,7 @@ tests: `make release` produces a Developer ID signed, notarized and stapled DMG with a signed `release-output.json`. Until the release carrying it is published, use the bootstrap channel above. -## Portable channel — Linux, WSL2, macOS CLI +## Portable source channel — Linux, WSL2, source fallback Apple notarization cannot reach these systems, so the same release carries a second canonically named artifact: @@ -142,7 +162,7 @@ What the tarball is, and what it is not: digest over every entry, bound to the commit the release was cut from. `install.sh` re-validates that carrier before it stages anything. - It is **not** a prebuilt-binary bundle. The Rust cockpit binaries (`voc`, - `vc-admin`, `vc-server`) are still compiled locally by `make install`, so a + `vc-admin`, `vc-server`) are still compiled locally by `make install-source`, so a Rust toolchain remains a prerequisite on these systems. See the prerequisites section above. - On Windows this is the artifact you use _inside_ WSL2. There is no native diff --git a/docs/runtime/AGENT_INTERACTIVE_CONTRACT.md b/docs/runtime/AGENT_INTERACTIVE_CONTRACT.md index 5935c656..b87f3c00 100644 --- a/docs/runtime/AGENT_INTERACTIVE_CONTRACT.md +++ b/docs/runtime/AGENT_INTERACTIVE_CONTRACT.md @@ -60,6 +60,9 @@ every provider — bare resume stays interactive. - Always **interactive-only** (`terminal` / `visible`). - Seed prompt: `/vc-init` (+ optional operator text). - Grok: positional PROMPT, **no** `--single`, **no** `streaming-json`. +- Policy flags are `--policy-runtime local-native|local-worktrees|local-vm|cloud-soon` + and `--permissions bypass|auto|accept-edits|read-only`. The canonical matrix + lives in `vibecrafted_core.spawn`; unsupported provider cells fail closed. ### `vibecrafted resume ` diff --git a/install.sh b/install.sh index bd41b912..2e3e2253 100644 --- a/install.sh +++ b/install.sh @@ -3,7 +3,7 @@ set -euo pipefail usage() { cat <<'EOF_USAGE' -Usage: install.sh [--gui] [--yes] [--runtime ] [--ref ] [--archive-url | --archive-file ] [--tools-dir ] [make-target] +Usage: install.sh [--gui] [--yes] [--runtime ] [--ref ] [--archive-url | --archive-file ] [--runtime-pack-url | --runtime-pack-file ] [--tools-dir ] [make-target] Verify a local 𝚅𝚒𝚋𝚎𝚌𝚛𝚊𝚏𝚝𝚎𝚍. source snapshot, then run the transactional installer from that private candidate. The installer publishes into $HOME/.local/share/vibecrafted/tools. @@ -231,6 +231,8 @@ REQUIRED_FILES = frozenset( "install.ps1", "install.toml", "scripts/distribution_manifest.py", + "scripts/build-linux-arm64-runtime-pack.sh", + "scripts/installer_brand.py", "scripts/vetcoders_install.py", "scripts/vibecrafted", "scripts/verify-vibecrafted-product.sh", @@ -248,6 +250,9 @@ REQUIRED_FILES = frozenset( "vibecrafted-app/Cargo.lock", "vibecrafted-server/Cargo.toml", "vibecrafted-server/Cargo.lock", + "vibecrafted-vm/RuntimePack.Containerfile", + "vibecrafted-vm/runtime-entry.sh", + "vibecrafted-vm/runtime-provider-lock.json", } ) REQUIRED_DIRECTORIES = frozenset( @@ -1076,6 +1081,8 @@ default_ref="${VIBECRAFTED_REF:-main}" ref="$default_ref" archive_url="" archive_file="" +runtime_pack_url="" +runtime_pack_file="" tools_dir="$default_tools_dir" target="vibecrafted" use_gui=0 @@ -1110,6 +1117,16 @@ while [[ $# -gt 0 ]]; do [[ $# -gt 0 ]] || die "Missing value for --archive-file" archive_file="$1" ;; + --runtime-pack-url) + shift + [[ $# -gt 0 ]] || die "Missing value for --runtime-pack-url" + runtime_pack_url="$1" + ;; + --runtime-pack-file) + shift + [[ $# -gt 0 ]] || die "Missing value for --runtime-pack-file" + runtime_pack_file="$1" + ;; --tools-dir) shift [[ $# -gt 0 ]] || die "Missing value for --tools-dir" @@ -1135,6 +1152,9 @@ esac if [[ -n "$archive_url" && -n "$archive_file" ]]; then die "Use either --archive-url or --archive-file, not both" fi +if [[ -n "$runtime_pack_url" && -n "$runtime_pack_file" ]]; then + die "Use either --runtime-pack-url or --runtime-pack-file, not both" +fi if [[ "$use_gui" == "1" && "$target" != "vibecrafted" ]]; then die "--gui can only be used with the default vibecrafted install target" @@ -1156,20 +1176,22 @@ enforce_runtime_root_contract || exit 1 if [[ -z "$archive_url" && -z "$archive_file" ]]; then # Resolve latest version from the channel manifest instead of hard-pinning. channel_url="https://vibecrafted.io/channel/${ref}.json" - resolved_url="" + resolved_urls="" if command -v curl >/dev/null 2>&1; then - resolved_url="$(curl -fsSL "$channel_url" 2>/dev/null \ - | python3 -c "import sys,json; print(json.load(sys.stdin).get('archive_url',''))" 2>/dev/null)" || true + resolved_urls="$(curl -fsSL "$channel_url" 2>/dev/null \ + | python3 -c "import sys,json; p=json.load(sys.stdin); print(p.get('archive_url','')+'\\t'+p.get('runtime_pack_url',''))" 2>/dev/null)" || true fi - if [[ -n "$resolved_url" ]]; then + IFS=$'\t' read -r resolved_url resolved_pack_url <<< "$resolved_urls" + if [[ -n "$resolved_url" && -n "$resolved_pack_url" ]]; then archive_url="$resolved_url" + runtime_pack_url="$resolved_pack_url" vinfo "Resolved from channel ($ref): $archive_url" else # Raw GitHub source archives do not carry the writer-bound v2 distribution # tree receipt. Minting one after download would let candidate bytes attest # to themselves, so this legacy path is deliberately closed. Release # authentication remains a named W4 blocker while signature fetch is soft. - die "Channel manifest has no archive_url; refusing the untrusted raw GitHub fallback (W4 release authentication blocker)" + die "Channel manifest must bind archive_url and runtime_pack_url; refusing the untrusted raw GitHub fallback and incomplete release tuple (W4 release authentication blocker)" fi fi @@ -1221,9 +1243,13 @@ preflight_require_all() { exit 1 } -# git is consumed later by install-tools-held; without it the install used -# to die mid-flight AFTER three green phases — it belongs in pre-flight. -preflight_tools=(tar make python3 git) +# The public Runtime Pack path needs only extraction, signature verification +# and bootstrap Python. A local source-only fixture still needs make, but the +# production channel never gains a compiler toolchain fallback. +preflight_tools=(tar python3 openssl) +if [[ -z "$runtime_pack_file" && -z "$runtime_pack_url" ]]; then + preflight_tools+=(make) +fi if [[ -z "$archive_file" ]]; then preflight_tools+=(curl) fi @@ -1235,6 +1261,11 @@ export VIBECRAFTED_TOOLS_HOME="$tools_dir" if [[ -n "$archive_file" ]]; then [[ -f "$archive_file" ]] || die "Archive file not found: $archive_file" fi +if [[ -n "$runtime_pack_file" ]]; then + [[ -f "$runtime_pack_file" ]] || die "Runtime Pack file not found: $runtime_pack_file" + [[ -f "$runtime_pack_file.sha256" ]] || die "Runtime Pack checksum not found: $runtime_pack_file.sha256" + [[ -f "$runtime_pack_file.sig" ]] || die "Runtime Pack signature not found: $runtime_pack_file.sig" +fi current_link="$tools_dir/vibecrafted-current" @@ -1245,6 +1276,17 @@ mkdir -p "$tools_dir" tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/vibecrafted-bootstrap.XXXXXX")" trap 'rm -rf -- "$tmpdir"' EXIT +selected_runtime_pack="$runtime_pack_file" +if [[ -n "$runtime_pack_url" ]]; then + selected_runtime_pack="$tmpdir/$(basename "$runtime_pack_url")" + curl -fsSL "$runtime_pack_url" -o "$selected_runtime_pack" + curl -fsSL "$runtime_pack_url.sha256" -o "$selected_runtime_pack.sha256" + curl -fsSL "$runtime_pack_url.sig" -o "$selected_runtime_pack.sig" +fi +if [[ -z "$selected_runtime_pack" && -z "$archive_file" ]]; then + die "No Runtime Pack is bound to this public source candidate" +fi + # Candidate commands must return through this shell so the EXIT trap can remove # the private verified payload. Publication is owned by install-bundle-tools # under the installer lease; the bootstrap itself never writes `current`. @@ -1390,6 +1432,12 @@ if [[ "$target" == "vibecrafted" ]] && ! is_interactive_session; then done export VIBECRAFTED_RUNTIME="$runtime" + if [[ -n "$selected_runtime_pack" ]]; then + run_candidate_command env \ + VIBECRAFTED_RUNTIME_PACK="$selected_runtime_pack" \ + bash "$candidate_root/scripts/install-runtime-pack.sh" \ + --expected-source-revision "$expected_revision" + fi run_candidate_command make --no-print-directory -C "$candidate_root" install-auto RUNTIME="$runtime" fi diff --git a/packaging/homebrew/Casks/vibecrafted-app.rb b/packaging/homebrew/Casks/vibecrafted-app.rb index 2cf29e43..e0538eac 100644 --- a/packaging/homebrew/Casks/vibecrafted-app.rb +++ b/packaging/homebrew/Casks/vibecrafted-app.rb @@ -7,7 +7,7 @@ # docs/RELEASE_CHECKLIST.md has been run and the operator pastes the # real Vibecrafted_--.dmg coordinates. cask "vibecrafted-app" do - version "3.7.1,YYYYMMDD,sha8" + version "4.3.0,YYYYMMDD,sha8" sha256 "0000000000000000000000000000000000000000000000000000000000000000" url "https://github.com/vetcoders/vibecrafted/releases/download/v#{version.csv.first}/Vibecrafted_#{version.csv.first}-#{version.csv.second}-#{version.csv.third}.dmg" @@ -24,7 +24,7 @@ package. Verify the adjacent .dmg.sha256 before first launch if you downloaded the DMG by hand. - Fill version.csv (3.7.1, YYYYMMDD, sha8) and sha256 from the + Fill version.csv (4.3.0, YYYYMMDD, sha8) and sha256 from the published asset name after `make publish-release`. EOS end diff --git a/packaging/homebrew/Formula/vibecrafted.rb b/packaging/homebrew/Formula/vibecrafted.rb index 455245ec..5347772d 100644 --- a/packaging/homebrew/Formula/vibecrafted.rb +++ b/packaging/homebrew/Formula/vibecrafted.rb @@ -2,15 +2,15 @@ # Staged formula for vetcoders/homebrew-tap. # sha256 is a placeholder. Do not `brew install` this file until -# v3.7.1 is tagged and the operator pastes the real archive digest. +# v4.3.0 is tagged and the operator pastes the real archive digest. class Vibecrafted < Formula desc "Release engine for AI-built software" homepage "https://vibecrafted.io" - version "3.7.1" + version "4.3.0" license "BUSL-1.1" # GitHub source archive of the annotated tag. The product does not yet - # publish a 3.7.1 tarball on the Releases page (latest public release + # publish a 4.3.0 tarball on the Releases page (latest public release # is still v3.5.0). url "https://github.com/vetcoders/vibecrafted/archive/refs/tags/v#{version}.tar.gz" sha256 "0000000000000000000000000000000000000000000000000000000000000000" diff --git a/plugin.json b/plugin.json index 6aff87af..d79c30b3 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "vibecrafted", - "version": "4.2.4", + "version": "4.3.0", "description": "Release engine for AI-built software. Structural mapping, convergence loops, install truth, and launch-ready packaging for AI-generated repos.", "author": { "name": "Vetcoders", diff --git a/scripts/build-linux-arm64-runtime-pack.sh b/scripts/build-linux-arm64-runtime-pack.sh new file mode 100755 index 00000000..ebbd99a7 --- /dev/null +++ b/scripts/build-linux-arm64-runtime-pack.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +set -euo pipefail + +die() { printf 'Linux arm64 Runtime Pack build failed: %s\n' "$*" >&2; exit 1; } +require() { command -v "$1" >/dev/null 2>&1 || die "$1 is required"; } + +[[ "$(uname -s):$(uname -m)" == "Linux:aarch64" ]] \ + || die "builder must run natively on Linux/aarch64" +for tool in cargo curl make npm python3 sha256sum tar uv; do require "$tool"; done + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +output="${1:-$repo_root/build/Vibecrafted_RuntimePack_linux-arm64.tar.gz}" +source_revision="${VIBECRAFTED_SOURCE_REVISION:-}" +[[ "$source_revision" =~ ^[0-9a-f]{40}$ ]] || die "VIBECRAFTED_SOURCE_REVISION must be a full Git SHA" + +version="$(tr -d '[:space:]' < "$repo_root/VERSION")" +terminal_revision="d6685ead9018ad89411291d6198476666e48b0f8" +terminal_archive_sha256="3cd6670c4a80c589b945ed1b45c1f033c80745ceb34d3466e9476a1c3eeb0f71" +frame_revision="7ab84069c9b7994ce0b705ccedd708aa3a35dcb6" +frame_archive_sha256="55851e094b91d3b41712edcdc66d69f97da5859118395fee497bb104714b125c" +work="$(mktemp -d "${TMPDIR:-/tmp}/vibecrafted-linux-arm64.XXXXXX")" +trap 'rm -rf -- "$work"' EXIT INT TERM HUP +payload="$work/payload" +mkdir -p "$payload/bin" "$payload/libexec" "$payload/scripts" \ + "$payload/vibecrafted-core" "$payload/config" "$payload/server/site" + +fetch_source() { + local url="$1" expected="$2" archive="$3" destination="$4" + curl -fL --proto '=https' --tlsv1.2 "$url" -o "$archive" + [[ "$(sha256sum "$archive" | awk '{print $1}')" == "$expected" ]] \ + || die "source archive checksum mismatch: $url" + mkdir -p "$destination" + tar -xzf "$archive" --strip-components=1 -C "$destination" +} + +fetch_source \ + "https://codeload.github.com/vetcoders/vc-terminal/tar.gz/$terminal_revision" \ + "$terminal_archive_sha256" "$work/vc-terminal.tar.gz" "$work/vc-terminal" +fetch_source \ + "https://codeload.github.com/vetcoders/vc-frame/tar.gz/$frame_revision" \ + "$frame_archive_sha256" "$work/vc-frame.tar.gz" "$work/vc-frame" + +make -C "$work/vc-terminal" release-bins +install -m 0755 "$work/vc-terminal/target/release/alacritty" "$payload/bin/vc-terminal" +rm -rf "$work/vc-terminal" "$work/vc-terminal.tar.gz" + +frame_sha="$frame_revision" +( + cd "$work/vc-frame" + CARGO_PROFILE_RELEASE_STRIP=false \ + RUSTFLAGS="--remap-path-prefix=$work/vc-frame=/usr/src/vc-frame" \ + VC_FRAME_GIT_SHA="$frame_sha" VC_FRAME_GIT_DIRTY=0 \ + VC_FRAME_SOURCE_MANIFEST_DIR=/usr/src/vc-frame/zellij-utils \ + cargo xtask build --release +) +install -m 0755 "$work/vc-frame/target/release/vc-frame" "$payload/libexec/vc-frame" +install -m 0755 "$repo_root/scripts/vc-frame-product-entry.sh" "$payload/bin/vc-frame" +rm -rf "$work/vc-frame" "$work/vc-frame.tar.gz" + +voc_target="$work/voc-target" +CARGO_TARGET_DIR="$voc_target" cargo build --locked \ + --manifest-path "$repo_root/vibecrafted-app/Cargo.toml" \ + --release -p voc --bin voc --bin vc-start +install -m 0755 "$voc_target/release/voc" "$payload/bin/voc" +install -m 0755 "$voc_target/release/vc-start" "$payload/bin/vc-start" +rm -rf "$voc_target" + +server_build="$work/server-build" +make -C "$repo_root" CARGO_BUILD_ROOT="$server_build" build-server-release +install -m 0755 "$server_build/vibecrafted-server/release/vibecrafted-server-web" \ + "$payload/bin/vc-server" +cp -R "$server_build/vibecrafted-server/site/." "$payload/server/site/" +rm -rf "$server_build" + +printf '%s\n' "$version" > "$payload/VERSION" +install -m 0755 "$repo_root/vibecrafted-core/vibecrafted_core/deck/vibecrafted" \ + "$payload/bin/vibecrafted" +install -m 0755 "$repo_root/scripts/vibecrafted" "$payload/scripts/vibecrafted" +install -m 0755 "$repo_root/scripts/vetcoders_install.py" "$payload/scripts/vetcoders_install.py" +install -m 0644 "$repo_root/scripts/distribution_manifest.py" "$payload/scripts/distribution_manifest.py" +install -m 0644 "$repo_root/scripts/installer_brand.py" "$payload/scripts/installer_brand.py" +install -m 0755 "$repo_root/scripts/vc-frame-product-entry.sh" "$payload/scripts/vc-frame-product-entry.sh" +cp -R "$repo_root/bin/." "$payload/bin/" +cp -R "$repo_root/vibecrafted-core/vibecrafted_core" "$payload/vibecrafted-core/" +printf '%s+g%.8s\n' "$version" "$source_revision" \ + > "$payload/vibecrafted-core/vibecrafted_core/VERSION" +cp -R "$repo_root/config/." "$payload/config/" + +python3 "$repo_root/scripts/distribution_manifest.py" carrier \ + --source "$repo_root" --output "$payload/source-provenance.json" \ + --owner-repo vetcoders/vibecrafted --source-revision "$source_revision" +"$repo_root/scripts/stage-runtime-foundations.sh" "$payload/bin" + +uv python install 3.12.3 --install-dir "$work/python-seed" --no-bin +seed_python="$(find "$work/python-seed" -type f -path '*/bin/python3.12' -print -quit)" +[[ -n "$seed_python" ]] || die "uv did not produce CPython 3.12.3" +python_home="$(cd "$(dirname "$seed_python")/.." && pwd -P)" +mkdir -p "$payload/python" "$payload/python-site" +cp -RL "$python_home/." "$payload/python/" +uv pip install --python "$seed_python" --target "$payload/python-site" \ + 'jsonschema>=4.23,<5' 'PyYAML>=6.0,<7' 'screenscribe==0.1.19' +rm -rf "$payload/python-site/bin" +cat > "$payload/bin/python3" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +runtime_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +export PYTHONNOUSERSITE=1 PYTHONDONTWRITEBYTECODE=1 +export PYTHONPATH="$runtime_root/vibecrafted-core:$runtime_root/python-site" +exec "$runtime_root/python/bin/python3.12" "$@" +EOF +chmod 0755 "$payload/bin/python3" +python3 "$repo_root/scripts/render-python-entrypoint-launchers.py" \ + --pyproject "$repo_root/vibecrafted-core/pyproject.toml" --bin-dir "$payload/bin" +cat > "$payload/bin/screenscribe" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +runtime_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +exec "$runtime_root/bin/python3" -c 'from screenscribe.bootstrap import main; main()' "$@" +EOF +chmod 0755 "$payload/bin/screenscribe" + +find "$payload" -type f -name '*.py[co]' -delete +find "$payload" -depth -type d -name __pycache__ -exec rm -rf {} + +find "$payload" -type l -print -quit | grep -q . && die "payload contains symlinks" + +PAYLOAD="$payload" SOURCE_REVISION="$source_revision" \ +TERMINAL_REVISION="$terminal_revision" FRAME_REVISION="$frame_revision" python3 - <<'PY' +import hashlib, json, os, subprocess +from pathlib import Path + +root = Path(os.environ["PAYLOAD"]) +source_manifest_sha = hashlib.sha256((root / "source-provenance.json").read_bytes()).hexdigest() +foundation = json.loads((root / "runtime-foundations.json").read_text()) +sources = { + "vibecrafted": ("https://github.com/vetcoders/vibecrafted", os.environ["SOURCE_REVISION"], source_manifest_sha, "MIT"), + "vc-terminal": (f"https://codeload.github.com/vetcoders/vc-terminal/tar.gz/{os.environ['TERMINAL_REVISION']}", os.environ["TERMINAL_REVISION"], "3cd6670c4a80c589b945ed1b45c1f033c80745ceb34d3466e9476a1c3eeb0f71", "Apache-2.0"), + "vc-frame": (f"https://codeload.github.com/vetcoders/vc-frame/tar.gz/{os.environ['FRAME_REVISION']}", os.environ["FRAME_REVISION"], "55851e094b91d3b41712edcdc66d69f97da5859118395fee497bb104714b125c", "MIT"), + "screenscribe": ("https://files.pythonhosted.org/packages/a2/8e/53e22fc84d28246c0316ab03bd26904fd80c545170466bd2cb926204f965/screenscribe-0.1.19-py3-none-any.whl", "0.1.19", "9988fe819443e2b47d949e737e1325bc755b31c18f1348a5b7b709c7cf155323", "BUSL-1.1"), + "prview": ("https://crates.io/api/v1/crates/prview/0.6.0/download", "0.6.0", "c952a333d0c481509f30f520fd10d1016d814832e317bfd2bc2fdc34f1ecfc02", "BUSL-1.1"), +} +owners = { + "vibecrafted": "vibecrafted", "vc-server": "vibecrafted", "voc": "vibecrafted", + "vc-terminal": "vc-terminal", "vc-frame": "vc-frame", "screenscribe": "screenscribe", + "loct": "loctree", "loctree": "loctree", "loctree-mcp": "loctree", "loctree-lsp": "loctree", + "aicx": "aicx", "aicx-mcp": "aicx", "prview": "prview", +} +commands = { + "vibecrafted": ["--version"], "vc-server": ["--version"], "voc": ["--version"], + "vc-terminal": ["--version"], "vc-frame": ["--version"], "screenscribe": ["--version"], + "loct": ["--version"], "loctree": ["--version"], "loctree-mcp": ["--version"], + "loctree-lsp": ["--version"], "aicx": ["--version"], "aicx-mcp": ["--version"], + "prview": ["--version"], +} +records = [] +for name, argv in commands.items(): + path = root / "bin" / name + output = subprocess.run([str(path), *argv], text=True, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, timeout=30, check=True).stdout.strip().splitlines()[0] + owner = owners[name] + if owner in sources: + url, revision, archive_sha, license_name = sources[owner] + else: + revision = foundation.get("source_revisions", {}).get(owner, foundation["versions"].get(owner, "registry")) + archive = foundation.get("source_archives", {}).get(owner, {}) + url, archive_sha = archive.get("url", "https://pypi.org/project/screenscribe/" if name == "screenscribe" else "https://crates.io/") , archive.get("sha256", "registry-integrity") + license_name = foundation.get("licenses", {}).get(owner, "upstream-package-metadata") + records.append({"name": name, "path": f"bin/{name}", "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + "version_argv": argv, "version_output": output, "source_url": url, + "source_revision": revision, "source_archive_sha256": archive_sha, + "target": "aarch64-unknown-linux-gnu", "license": license_name}) +manifest = {"schema": "io.vetcoders.vibecrafted.runtime-inventory.v1", "platform": "linux", + "architecture": "arm64", "executables": records} +(root / "runtime-inventory.json").write_text(json.dumps(manifest, sort_keys=True, indent=2) + "\n") +PY + +"$repo_root/scripts/package-runtime-pack.sh" --payload-root "$payload" --output "$output" \ + --source-revision "$source_revision" --terminal-revision "$terminal_revision" \ + --frame-revision "$frame_revision" --version "$version" \ + --platform linux --architecture arm64 diff --git a/scripts/build-vibecrafted-release.sh b/scripts/build-vibecrafted-release.sh index d1939559..da215de6 100755 --- a/scripts/build-vibecrafted-release.sh +++ b/scripts/build-vibecrafted-release.sh @@ -66,6 +66,16 @@ DMG_NAME="Vibecrafted_${VERSION}-${RELEASE_DATE}-${ROOT_SHA:0:8}.dmg" DMG="$DIST_DIR/$DMG_NAME" DMG_CHECKSUM="$DMG.sha256" LEGACY_DMG="$DIST_DIR/Vibecrafted.dmg" +RUNTIME_PACK_PLATFORM="darwin" +RUNTIME_PACK_ARCHITECTURE="$(uname -m | sed 's/^arm64$/arm64/; s/^aarch64$/arm64/; s/^x86_64$/x64/')" +RUNTIME_PACK_NAME="Vibecrafted_RuntimePack_${VERSION}-${RELEASE_DATE}-${ROOT_SHA:0:8}-${RUNTIME_PACK_PLATFORM}-${RUNTIME_PACK_ARCHITECTURE}.tar.gz" +RUNTIME_PACK="$DIST_DIR/$RUNTIME_PACK_NAME" +RUNTIME_PACK_CHECKSUM="$RUNTIME_PACK.sha256" +RUNTIME_PACK_SIGNATURE="$RUNTIME_PACK.sig" +RUNTIME_PACK_RESOURCE_DIR="$APP/Contents/Resources/runtime-pack" +EMBEDDED_RUNTIME_PACK="$RUNTIME_PACK_RESOURCE_DIR/$RUNTIME_PACK_NAME" +EMBEDDED_RUNTIME_PACK_CHECKSUM="$EMBEDDED_RUNTIME_PACK.sha256" +EMBEDDED_RUNTIME_PACK_SIGNATURE="$EMBEDDED_RUNTIME_PACK.sig" KEYS="${KEYS:-$HOME/.keys}" SPOT_MONO_FONT="${VIBECRAFTED_SPOT_MONO_FONT:-$KEYS/fonts/SpotMono.ttc}" SIGNING_IDENTITY_FILE="$KEYS/signing-identity.txt" @@ -399,6 +409,27 @@ materialize_donor_snapshots() { || die "VIBECRAFTED_RELEASE_FAIL_AFTER_SNAPSHOT is set; failing on purpose so the reaper is exercised" } +embed_runtime_pack() { + log "Producing the canonical Runtime Pack carrier once inside Vibecrafted.app" + rm -rf "$RUNTIME_PACK_RESOURCE_DIR" + mkdir -p "$RUNTIME_PACK_RESOURCE_DIR" + install -m 0755 "$REPO_ROOT/scripts/install-runtime-pack.sh" \ + "$RUNTIME_PACK_RESOURCE_DIR/install-runtime-pack.sh" + install -m 0644 \ + "$REPO_ROOT/vibecrafted-core/vibecrafted_core/trust/vibecrafted-signing-v1.pub" \ + "$RUNTIME_PACK_RESOURCE_DIR/vibecrafted-signing-v1.pub" + "$REPO_ROOT/scripts/package-runtime-pack.sh" \ + --app "$APP" --output "$EMBEDDED_RUNTIME_PACK" \ + --source-revision "$ROOT_SHA" \ + --terminal-revision "$(git_sha "$TERMINAL_REPO")" \ + --frame-revision "$(git_sha "$FRAME_REPO")" \ + --version "$RUNTIME_VERSION" \ + --platform "$RUNTIME_PACK_PLATFORM" \ + --architecture "$RUNTIME_PACK_ARCHITECTURE" + /usr/bin/openssl dgst -sha256 -sign "$SIGNING_KEY" \ + -out "$EMBEDDED_RUNTIME_PACK_SIGNATURE" "$EMBEDDED_RUNTIME_PACK" +} + build_product() { materialize_donor_snapshots require_clean_repo "$REPO_ROOT" vibecrafted @@ -442,7 +473,16 @@ build_product() { # so the baked path would be both useless and a host-path leak (measured # 2026-08-19: 1 hit in Contents/Helpers/vc-frame). Pin it to the same root # the source remap advertises; the freshness probe then reports NoCheckout. + # `plugins-assets` deliberately rewrites tracked derived blobs in the + # detached snapshot. That does not make the source revision dirty: the + # regenerated bytes are checked by plugins-parity and the release receipt is + # bound to the immutable snapshot HEAD. Resolve that identity before Cargo + # asks zellij-utils/build.rs to inspect the derived-output mutation. + local frame_release_sha + frame_release_sha="$(git_sha "$FRAME_REPO")" CARGO_PROFILE_RELEASE_STRIP=false \ + VC_FRAME_GIT_SHA="$frame_release_sha" \ + VC_FRAME_GIT_DIRTY=0 \ VC_FRAME_SOURCE_MANIFEST_DIR=/usr/src/vc-frame/zellij-utils \ make -C "$FRAME_REPO" release-binary local frame_source="$FRAME_REPO/target/release/vc-frame" @@ -464,6 +504,10 @@ build_product() { [[ -d "$server_site/pkg" ]] || die "Vibecrafted Server hydrated site is missing" log "Building the single Swift host app" + local generated_project="vibecrafted-app/shell-agent/app/Vibecrafted.xcodeproj" + if git -C "$REPO_ROOT" ls-files --error-unmatch "$generated_project" >/dev/null 2>&1; then + die "generated Xcode project must not be tracked; project.yml is the source of truth" + fi make -C "$REPO_ROOT/vibecrafted-app/shell-agent" bindings xcode rm -rf "$BUILD_DIR/DerivedData" "$APP" mkdir -p "$BUILD_DIR" "$DIST_DIR" @@ -539,12 +583,32 @@ build_product() { local canonical_deck="$REPO_ROOT/vibecrafted-core/vibecrafted_core/deck/vibecrafted" install -m 0755 "$canonical_deck" "$runtime/scripts/vibecrafted" install -m 0755 "$canonical_deck" "$runtime/bin/vibecrafted" + # The DMG carries the same installer used by source/CLI channels. The native + # app invokes its Runtime Pack mode; installed launchers invoke its uninstall + # mode. Keep the small import closure beside it so it runs under the bundled + # interpreter without reaching back into a checkout or the host Python. + install -m 0755 "$REPO_ROOT/scripts/vetcoders_install.py" \ + "$runtime/scripts/vetcoders_install.py" + install -m 0644 "$REPO_ROOT/scripts/distribution_manifest.py" \ + "$runtime/scripts/distribution_manifest.py" + install -m 0644 "$REPO_ROOT/scripts/installer_brand.py" \ + "$runtime/scripts/installer_brand.py" + install -m 0755 "$REPO_ROOT/scripts/vc-frame-product-entry.sh" \ + "$runtime/scripts/vc-frame-product-entry.sh" + # A native Runtime Pack needs only the closed carrier, not a second copy of + # the source distribution. Derive it directly from immutable Git objects so + # ignored host metadata cannot race a temporary materialized payload. + "$REPO_ROOT/scripts/project-python" "$REPO_ROOT/scripts/distribution_manifest.py" \ + carrier --source "$REPO_ROOT" --output "$runtime/source-provenance.json" \ + --owner-repo vetcoders/vibecrafted --source-revision "$ROOT_SHA" /bin/cp -R "$REPO_ROOT/bin/." "$runtime/bin/" /bin/cp -R "$REPO_ROOT/vibecrafted-core/vibecrafted_core" \ "$runtime/vibecrafted-core/" printf '%s\n' "$RUNTIME_VERSION" \ > "$runtime/vibecrafted-core/vibecrafted_core/VERSION" /bin/cp -R "$REPO_ROOT/config/." "$runtime/config/" + log "Embedding the complete Runtime Foundations payload" + "$REPO_ROOT/scripts/stage-runtime-foundations.sh" "$runtime/bin" mkdir -p "$runtime/server/site" /bin/cp -R "$server_site/." "$runtime/server/site/" # The Living Tree may contain ignored interpreter caches. They are never @@ -564,7 +628,7 @@ build_product() { mkdir -p "$runtime/python" "$runtime/python-site" /bin/cp -RL "$python_home/." "$runtime/python/" uv pip install --python "$seed_python" --target "$runtime/python-site" \ - 'jsonschema>=4.23,<5' 'PyYAML>=6.0,<7' + 'jsonschema>=4.23,<5' 'PyYAML>=6.0,<7' 'screenscribe==0.1.19' install_name_tool -id '@loader_path/libpython3.12.dylib' \ "$runtime/python/lib/libpython3.12.dylib" @@ -614,6 +678,18 @@ build_product() { "$REPO_ROOT/scripts/render-python-entrypoint-launchers.py" \ --pyproject "$REPO_ROOT/vibecrafted-core/pyproject.toml" \ --bin-dir "$runtime/bin" + # ScreenScribe is a required product tool delivered inside the same private + # Python as Vibecrafted. Its PyPI console script is deliberately discarded + # above because the generated shebang names the ephemeral build seed. + # shellcheck disable=SC2016 + printf '%s\n' \ + '#!/bin/bash' \ + 'set -euo pipefail' \ + 'runtime_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"' \ + 'exec "$runtime_root/bin/python3" -c '\''from screenscribe.bootstrap import main; main()'\'' "$@"' \ + > "$runtime/bin/screenscribe" + chmod 0755 "$runtime/bin/screenscribe" + "$runtime/bin/screenscribe" --version >/dev/null if find "$APP" -type l -print -quit | grep -q .; then die "assembled app contains symlinks" @@ -633,6 +709,7 @@ build_product() { log "Signing nested code and binding exact source receipts" sign_macho_tree sign_nested_app_bundles + embed_runtime_pack require_clean_repo "$REPO_ROOT" vibecrafted require_clean_repo "$TERMINAL_REPO" vc-terminal require_clean_repo "$FRAME_REPO" vc-frame ${FRAME_DERIVED+"${FRAME_DERIVED[@]}"} @@ -685,7 +762,8 @@ notarize_product() { emit_release_tuple() { PYTHONPATH="$REPO_ROOT/vibecrafted-core" "$REPO_ROOT/scripts/project-python" \ "$REPO_ROOT/scripts/unified_product_manifest.py" release \ - --app "$APP" --dmg "$DMG" --output "$DIST_DIR/release-output.json" + --app "$APP" --dmg "$DMG" --runtime-pack "$RUNTIME_PACK" \ + --output "$DIST_DIR/release-output.json" /usr/bin/openssl dgst -sha256 -sign "$SIGNING_KEY" \ -out "$DIST_DIR/release-output.json.sig" "$DIST_DIR/release-output.json" run_bundled_verifier release-output \ @@ -696,14 +774,27 @@ emit_release_tuple() { ) } +emit_runtime_pack() { + log "Projecting the exact App-embedded Runtime Pack bytes as the standalone asset" + rm -f "$RUNTIME_PACK" "$RUNTIME_PACK_CHECKSUM" "$RUNTIME_PACK_SIGNATURE" + install -m 0644 "$EMBEDDED_RUNTIME_PACK" "$RUNTIME_PACK" + install -m 0644 "$EMBEDDED_RUNTIME_PACK_CHECKSUM" "$RUNTIME_PACK_CHECKSUM" + install -m 0644 "$EMBEDDED_RUNTIME_PACK_SIGNATURE" "$RUNTIME_PACK_SIGNATURE" + cmp "$EMBEDDED_RUNTIME_PACK" "$RUNTIME_PACK" + cmp "$EMBEDDED_RUNTIME_PACK_CHECKSUM" "$RUNTIME_PACK_CHECKSUM" + cmp "$EMBEDDED_RUNTIME_PACK_SIGNATURE" "$RUNTIME_PACK_SIGNATURE" +} + if [[ "$MODE" == "notarize" ]]; then [[ -d "$APP" ]] || die "missing $APP; run make dmg-signed first" + emit_runtime_pack notarize_product emit_release_tuple exit 0 fi build_product +emit_runtime_pack [[ "$MODE" == "app" ]] && exit 0 if [[ "$MODE" == "dmg" ]]; then create_dmg diff --git a/scripts/distribution_manifest.py b/scripts/distribution_manifest.py index c5a88935..86db074c 100755 --- a/scripts/distribution_manifest.py +++ b/scripts/distribution_manifest.py @@ -53,7 +53,8 @@ "install.ps1", "install.toml", "scripts/distribution_manifest.py", - "scripts/runtime_paths.py", + "scripts/build-linux-arm64-runtime-pack.sh", + "scripts/installer_brand.py", "scripts/vetcoders_install.py", "scripts/vibecrafted", "scripts/verify-vibecrafted-product.sh", @@ -71,6 +72,9 @@ "vibecrafted-app/Cargo.lock", "vibecrafted-server/Cargo.toml", "vibecrafted-server/Cargo.lock", + "vibecrafted-vm/RuntimePack.Containerfile", + "vibecrafted-vm/runtime-entry.sh", + "vibecrafted-vm/runtime-provider-lock.json", ) REQUIRED_DIRECTORIES = ( @@ -864,6 +868,55 @@ def assert_source_payload_matches_provenance( return provenance +def write_source_provenance_carrier( + source: str | Path, + output: str | Path, + *, + owner_repo: str | None = None, + source_revision: str | None = None, +) -> dict[str, object]: + """Write only the canonical carrier proven by an exact source snapshot. + + Native Runtime Packs need the same closed provenance record as the portable + archive, but they do not need a second materialized copy of the entire + source tree. Deriving the carrier directly also prevents host metadata + writers (notably Finder's .DS_Store) from racing a transient staging tree. + """ + source_root = Path(source).resolve(strict=False) + if not source_root.is_dir(): + raise ManifestError(f"source root is not a directory: {source_root}") + provenance = assert_source_payload_matches_provenance( + source_root, + owner_repo=owner_repo, + source_revision=source_revision, + ) + output_path = Path(os.path.abspath(output)) + output_path.parent.mkdir(parents=True, exist_ok=True) + if output_path.exists() and not output_path.is_file(): + raise ManifestError( + f"source provenance output must be a regular file: {output_path}" + ) + temporary_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="wb", + prefix=f".{output_path.name}.candidate-", + dir=output_path.parent, + delete=False, + ) as handle: + temporary_path = Path(handle.name) + handle.write(_canonical_provenance_bytes(provenance)) + handle.flush() + os.fsync(handle.fileno()) + temporary_path.chmod(0o644) + os.replace(temporary_path, output_path) + temporary_path = None + finally: + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) + return provenance + + def _relative_path(value: str | Path) -> Path: """Normalize ``value`` to a relative Path, rejecting absolute or ``..`` paths.""" relative = Path(value) @@ -2184,7 +2237,7 @@ def publish_archive_candidate( def _build_parser() -> argparse.ArgumentParser: - """Build the CLI parser exposing the check/stage/archive subcommands.""" + """Build the CLI parser exposing validation and carrier writers.""" parser = argparse.ArgumentParser(description=__doc__) subparsers = parser.add_subparsers(dest="command", required=True) @@ -2202,6 +2255,14 @@ def _build_parser() -> argparse.ArgumentParser: stage.add_argument("--source-revision") stage.add_argument("--require-source-provenance", action="store_true") + carrier = subparsers.add_parser( + "carrier", help="Write a canonical source-provenance carrier" + ) + carrier.add_argument("--source", required=True, type=Path) + carrier.add_argument("--output", required=True, type=Path) + carrier.add_argument("--owner-repo") + carrier.add_argument("--source-revision") + archive = subparsers.add_parser("archive", help="Create a validated tarball") archive.add_argument("--source", required=True, type=Path) archive.add_argument("--output", required=True, type=Path) @@ -2238,6 +2299,14 @@ def main(argv: list[str] | None = None) -> int: require_source_provenance=args.require_source_provenance, ) print(f"Payload staged: {args.destination}") + elif args.command == "carrier": + write_source_provenance_carrier( + args.source, + args.output, + owner_repo=args.owner_repo, + source_revision=args.source_revision, + ) + print(f"Source provenance written: {args.output}") elif args.command == "archive": archive = create_archive( args.source, diff --git a/scripts/install-foundations.sh b/scripts/install-foundations.sh index 36a68e59..7fa31565 100755 --- a/scripts/install-foundations.sh +++ b/scripts/install-foundations.sh @@ -112,13 +112,10 @@ binary_runs() { "$bin" --version >/dev/null 2>&1 || "$bin" --help >/dev/null 2>&1 } -# Live config dirs the product actually reads (frontier first — VC_FRAME_CONFIG_DIR). +# Sole live vc-frame config directory owned by the product. _vcframe_config_roots() { local xdg="${XDG_CONFIG_HOME:-$HOME/.config}" - printf '%s\n' \ - "${VC_FRAME_CONFIG_DIR:-}" \ - "$xdg/vetcoders/frontier/vc-frame" \ - "$xdg/vc-frame" + printf '%s\n' "$xdg/vibecrafted/vc-frame" } # COCKPIT READY — hard product spine after binary is on PATH. @@ -164,7 +161,7 @@ verify_vcframe_cockpit() { done < <(_vcframe_config_roots) if [[ -z "$cfg_root" ]]; then - warn "cockpit: no live config.kdl under frontier or ~/.config/vc-frame" + warn "cockpit: no live config.kdl under ~/.config/vibecrafted/vc-frame" warn " fix: vibecrafted config install # or checkout stage_vc_frame_config" fails=1 else @@ -681,8 +678,12 @@ install_vc_frame_product_wrapper() { current="$tools_home/vibecrafted-current" if [[ -d "$current" ]]; then gen="$(cd "$current" && pwd -P)" - mkdir -p "$gen/bin" - install -m 0755 "$wrapper_src" "$gen/bin/vc-frame" + # Generations are immutable and carry bin/vc-frame since publication + # materializes it; only a pre-materialization generation gets the copy. + if [[ ! -x "$gen/bin/vc-frame" ]]; then + mkdir -p "$gen/bin" + install -m 0755 "$wrapper_src" "$gen/bin/vc-frame" + fi ln -sfn "$current/bin/vc-frame" "$dest" ok "product vc-frame entry installed: $dest -> $current/bin/vc-frame (real=$real)" else diff --git a/scripts/install-runtime-pack.sh b/scripts/install-runtime-pack.sh new file mode 100755 index 00000000..fadb4aa2 --- /dev/null +++ b/scripts/install-runtime-pack.sh @@ -0,0 +1,266 @@ +#!/usr/bin/env bash +set -euo pipefail + +die() { printf 'Runtime Pack install failed: %s\n' "$*" >&2; exit 1; } + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +pack="${VIBECRAFTED_RUNTIME_PACK:-}" +temporary="" +operation="install" +dry_run="0" +verify_only="0" +app_root="" +terminal_host="" +frame_helper="" +expected_source_revision="" +expected_terminal_revision="" +expected_frame_revision="" +expected_version="$(tr -d '[:space:]' < "$REPO_ROOT/VERSION")" +expected_platform="" +expected_architecture="" + +cleanup() { + if [[ -n "$temporary" && -d "$temporary" ]]; then + rm -rf -- "$temporary" + fi +} +trap cleanup EXIT INT TERM HUP + +while (($#)); do + case "$1" in + --pack) + (($# >= 2)) || die "--pack requires a path" + pack="$2" + shift 2 + ;; + --uninstall) + operation="uninstall" + shift + ;; + --verify-only) + verify_only="1" + shift + ;; + --app-root|--terminal-host|--frame-helper|--expected-source-revision|--expected-terminal-revision|--expected-frame-revision|--expected-version|--expected-platform|--expected-architecture) + (($# >= 2)) || die "$1 requires a path or revision" + case "$1" in + --app-root) app_root="$2" ;; + --terminal-host) terminal_host="$2" ;; + --frame-helper) frame_helper="$2" ;; + --expected-source-revision) expected_source_revision="$2" ;; + --expected-terminal-revision) expected_terminal_revision="$2" ;; + --expected-frame-revision) expected_frame_revision="$2" ;; + --expected-version) expected_version="$2" ;; + --expected-platform) expected_platform="$2" ;; + --expected-architecture) expected_architecture="$2" ;; + esac + shift 2 + ;; + --dry-run|-n) + dry_run="1" + shift + ;; + --help|-h) + printf 'usage: %s [--pack ] [--verify-only] [--expected-*-revision ] [--app-root --terminal-host --frame-helper ] [--uninstall [--dry-run]]\n' "$0" + exit 0 + ;; + *) die "unknown argument: $1" ;; + esac +done + +if [[ "$operation" == "install" && "$dry_run" == "1" ]]; then + die "--dry-run is only valid with --uninstall" +fi + +if [[ -z "$expected_platform" ]]; then + case "$(uname -s)" in + Darwin) expected_platform="darwin" ;; + Linux) expected_platform="linux" ;; + *) die "unsupported Runtime Pack platform: $(uname -s)" ;; + esac +fi +if [[ -z "$expected_architecture" ]]; then + case "$(uname -m)" in + x86_64|amd64) + if [[ "$expected_platform" == "darwin" ]]; then + expected_architecture="x64" + else + expected_architecture="x86_64" + fi + ;; + arm64|aarch64) expected_architecture="arm64" ;; + *) die "unsupported Runtime Pack architecture: $(uname -m)" ;; + esac +fi +if [[ "$operation" == "uninstall" && "$verify_only" == "1" ]]; then + die "--verify-only cannot be combined with --uninstall" +fi +helper_argument_count=0 +[[ -n "$app_root" ]] && ((helper_argument_count += 1)) +[[ -n "$terminal_host" ]] && ((helper_argument_count += 1)) +[[ -n "$frame_helper" ]] && ((helper_argument_count += 1)) +if ((helper_argument_count != 0 && helper_argument_count != 3)); then + die "--app-root, --terminal-host and --frame-helper must be supplied together" +fi + +if [[ "$operation" == "uninstall" ]]; then + runtime_home="${VIBECRAFTED_RUNTIME_HOME:-${XDG_DATA_HOME:-$HOME/.local/share}/vibecrafted}" + receipt="$runtime_home/install-receipt.json" + if [[ ! -f "$receipt" ]]; then + printf '{"schema":"vibecrafted.runtime-uninstall-result.v1","status":"absent"}\n' + exit 0 + fi + [[ -d "$runtime_home" ]] || die "receipt exists outside a runtime home: $receipt" + runtime_home="$(cd "$runtime_home" && pwd -P)" + current="$runtime_home/tools/vibecrafted-current" + if [[ -d "$current" ]]; then + generation="$(cd "$current" && pwd -P)" + case "$generation" in + "$runtime_home"/releases/*) ;; + *) die "installed Runtime Pack projection escapes releases: $generation" ;; + esac + pack_python="$generation/bin/python3" + pack_installer="$generation/scripts/vetcoders_install.py" + [[ -x "$pack_python" ]] || die "installed Runtime Pack Python missing: $pack_python" + [[ -f "$pack_installer" ]] || die "installed Runtime Pack installer missing: $pack_installer" + arguments=(runtime-uninstall) + [[ "$dry_run" == "1" ]] && arguments+=(--dry-run) + exec "$pack_python" "$pack_installer" "${arguments[@]}" + fi + [[ -n "$pack" ]] \ + || die "installed Runtime Pack projection is missing; pass --pack to recover from the receipt" +fi + +if [[ -z "$pack" ]]; then + shopt -s nullglob + candidates=("$REPO_ROOT"/dist/Vibecrafted_RuntimePack_*-"$expected_platform"-"$expected_architecture".tar.gz) + shopt -u nullglob + if ((${#candidates[@]} == 1)); then + pack="${candidates[0]}" + elif ((${#candidates[@]} > 1)); then + die "multiple Runtime Packs in dist; set VIBECRAFTED_RUNTIME_PACK explicitly" + else + die "no ${expected_platform}/${expected_architecture} Runtime Pack found; set VIBECRAFTED_RUNTIME_PACK to the prebuilt release asset" + fi +fi + +pack_name="${pack##*/}" +pack_parent="$(cd "$(dirname "$pack")" 2>/dev/null && pwd)" \ + || die "cannot resolve Runtime Pack path: $pack" +pack="$pack_parent/$pack_name" + +payload_root="" + +if [[ -f "$pack" && "$pack" == *.tar.gz ]]; then + command -v tar >/dev/null 2>&1 \ + || die "tar is required to extract a Runtime Pack archive" + checksum="$pack.sha256" + signature="$pack.sig" + public_key="${VIBECRAFTED_RUNTIME_PACK_PUBLIC_KEY:-$REPO_ROOT/vibecrafted-core/vibecrafted_core/trust/vibecrafted-signing-v1.pub}" + [[ -f "$checksum" ]] || die "Runtime Pack checksum is missing: $checksum" + [[ -f "$signature" ]] || die "Runtime Pack signature is missing: $signature" + [[ -f "$public_key" ]] || die "trusted Runtime Pack public key is missing: $public_key" + if command -v shasum >/dev/null 2>&1; then + (cd "$(dirname "$pack")" && shasum -a 256 -c "$(basename "$checksum")" >/dev/null) \ + || die "Runtime Pack checksum mismatch" + elif command -v sha256sum >/dev/null 2>&1; then + (cd "$(dirname "$pack")" && sha256sum -c "$(basename "$checksum")" >/dev/null) \ + || die "Runtime Pack checksum mismatch" + else + die "cannot verify Runtime Pack checksum (shasum/sha256sum missing)" + fi + command -v openssl >/dev/null 2>&1 \ + || die "openssl is required to verify the Runtime Pack signature" + openssl dgst -sha256 -verify "$public_key" -signature "$signature" "$pack" >/dev/null 2>&1 \ + || die "Runtime Pack signature verification failed" + temporary="$(mktemp -d "${TMPDIR:-/tmp}/vibecrafted-runtime-pack.XXXXXX")" + tar -tzf "$pack" >/dev/null \ + || die "Runtime Pack archive cannot be listed" + archive_root="" + while IFS= read -r member; do + [[ -n "$member" ]] || die "Runtime Pack archive contains an empty member" + case "$member" in + /*|../*|*/../*|*/..) die "unsafe Runtime Pack archive member: $member" ;; + esac + member_root="${member%%/*}" + [[ -n "$member_root" ]] || die "Runtime Pack archive has no root directory" + if [[ -z "$archive_root" ]]; then + archive_root="$member_root" + elif [[ "$member_root" != "$archive_root" ]]; then + die "Runtime Pack archive must contain one root directory" + fi + done < <(tar -tzf "$pack") + [[ -n "$archive_root" ]] || die "Runtime Pack archive is empty" + while IFS= read -r mode _rest; do + case "${mode:0:1}" in + -|d) ;; + *) die "links/devices are forbidden in Runtime Pack archives" ;; + esac + done < <(tar -tvzf "$pack") + tar -xzf "$pack" -C "$temporary" \ + || die "Runtime Pack archive extraction failed" + payload_root="$temporary/$archive_root" + if find "$payload_root" -type l -print -quit | grep -q .; then + die "links are forbidden in extracted Runtime Pack archives" + fi +else + die "Runtime Pack must be the canonical .tar.gz carrier: $pack" +fi + +[[ -d "$payload_root" ]] || die "runtime payload missing: $payload_root" +pack_python="$payload_root/bin/python3" +pack_installer="$payload_root/scripts/vetcoders_install.py" +[[ -x "$pack_python" ]] || die "Runtime Pack Python missing: $pack_python" +[[ -f "$pack_installer" ]] || die "Runtime Pack installer missing: $pack_installer" +contract_arguments=( + -m vibecrafted_core.runtime_pack_contract verify + --root "$payload_root" + --carrier-basename "$pack_name" +) +[[ -n "$expected_source_revision" ]] \ + && contract_arguments+=(--expected-source-revision "$expected_source_revision") +[[ -n "$expected_terminal_revision" ]] \ + && contract_arguments+=(--expected-terminal-revision "$expected_terminal_revision") +[[ -n "$expected_frame_revision" ]] \ + && contract_arguments+=(--expected-frame-revision "$expected_frame_revision") +contract_arguments+=( + --expected-version "$expected_version" + --expected-platform "$expected_platform" + --expected-architecture "$expected_architecture" +) +contract_output="$(PYTHONPATH="$payload_root/vibecrafted-core" \ + "$pack_python" "${contract_arguments[@]}")" \ + || die "Runtime Pack internal provenance verification failed" +if [[ "$verify_only" == "1" ]]; then + printf '%s\n' "$contract_output" + exit 0 +fi + +if [[ "$operation" == "uninstall" ]]; then + arguments=(runtime-uninstall) + [[ "$dry_run" == "1" ]] && arguments+=(--dry-run) +else + arguments=(runtime-install --payload-root "$payload_root") +fi +if [[ "$operation" == "install" && -n "$app_root" ]]; then + app_root="$(cd "$app_root" && pwd -P)" \ + || die "cannot resolve Vibecrafted.app root: $app_root" + terminal_host="$(cd "$(dirname "$terminal_host")" && pwd -P)/${terminal_host##*/}" \ + || die "cannot resolve bundled terminal host" + frame_helper="$(cd "$(dirname "$frame_helper")" && pwd -P)/${frame_helper##*/}" \ + || die "cannot resolve bundled vc-frame helper" + [[ -x "$terminal_host" ]] || die "bundled terminal host missing: $terminal_host" + [[ -x "$frame_helper" ]] || die "bundled vc-frame helper missing: $frame_helper" + arguments+=( + --app-root "$app_root" + --terminal-host "$terminal_host" + --frame-helper "$frame_helper" + ) +fi + +if [[ -n "$temporary" ]]; then + "$pack_python" "$pack_installer" "${arguments[@]}" + exit 0 +fi +exec "$pack_python" "$pack_installer" "${arguments[@]}" diff --git a/scripts/lib/payload-hygiene.sh b/scripts/lib/payload-hygiene.sh index c835abe6..4ef94603 100644 --- a/scripts/lib/payload-hygiene.sh +++ b/scripts/lib/payload-hygiene.sh @@ -22,7 +22,7 @@ # Directories every macOS or Linux box has. An ancestor walk must stop here: a # payload that mentions `/Users` or `/Volumes` says nothing about who built it, # and forbidding one would flag every legitimate path reference in the tree. -_PAYLOAD_HYGIENE_GENERIC_ROOTS=$'/\n/Applications\n/Library\n/System\n/Users\n/Volumes\n/home\n/media\n/mnt\n/opt\n/private\n/private/var\n/srv\n/tmp\n/usr\n/var' +_PAYLOAD_HYGIENE_GENERIC_ROOTS=$'/\n/Applications\n/Library\n/System\n/Users\n/Volumes\n/home\n/media\n/mnt\n/opt\n/private\n/private/tmp\n/private/var\n/srv\n/tmp\n/usr\n/var' # payload_hygiene_topmost_host_root # diff --git a/scripts/package-runtime-pack.sh b/scripts/package-runtime-pack.sh new file mode 100755 index 00000000..a4ec6bee --- /dev/null +++ b/scripts/package-runtime-pack.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +set -euo pipefail + +die() { printf 'Runtime Pack packaging failed: %s\n' "$*" >&2; exit 1; } + +app="" +payload_root="" +output="" +source_revision="" +terminal_revision="" +frame_revision="" +version="" +platform="" +architecture="" +while (($#)); do + case "$1" in + --app) + (($# >= 2)) || die "--app requires a path" + app="$2" + shift 2 + ;; + --payload-root) + (($# >= 2)) || die "--payload-root requires a path" + payload_root="$2" + shift 2 + ;; + --output) + (($# >= 2)) || die "--output requires a path" + output="$2" + shift 2 + ;; + --source-revision|--terminal-revision|--frame-revision|--version|--platform|--architecture) + (($# >= 2)) || die "$1 requires a value" + case "$1" in + --source-revision) source_revision="$2" ;; + --terminal-revision) terminal_revision="$2" ;; + --frame-revision) frame_revision="$2" ;; + --version) version="$2" ;; + --platform) platform="$2" ;; + --architecture) architecture="$2" ;; + esac + shift 2 + ;; + --help|-h) + printf 'usage: %s (--app | --payload-root ) --output --source-revision --terminal-revision --frame-revision --version --platform --architecture \n' "$0" + exit 0 + ;; + *) die "unknown argument: $1" ;; + esac +done + +for required_value in output source_revision terminal_revision frame_revision version platform architecture; do + [[ -n "${!required_value}" ]] || die "--$required_value is required" +done +if [[ -n "$app" && -n "$payload_root" ]]; then + die "--app and --payload-root are mutually exclusive" +fi +if [[ -z "$app" && -z "$payload_root" ]]; then + die "one of --app or --payload-root is required" +fi + +work="$(mktemp -d "${TMPDIR:-/tmp}/vibecrafted-runtime-pack-build.XXXXXX")" +trap 'rm -rf -- "$work"' EXIT INT TERM HUP +root="$work/VibecraftedRuntime" +mkdir -p "$root" +if [[ -n "$app" ]]; then + app_name="${app##*/}" + app_parent="$(cd "$(dirname "$app")" 2>/dev/null && pwd)" \ + || die "cannot resolve app path: $app" + app="$app_parent/$app_name" + runtime="$app/Contents/Resources/runtime" + terminal="$app/Contents/Helpers/vc-terminal.app/Contents/MacOS/alacritty" + frame="$app/Contents/Helpers/vc-frame" + [[ -d "$runtime" ]] || die "app has no Runtime Pack payload: $runtime" + [[ -x "$terminal" ]] || die "app has no terminal host: $terminal" + [[ -x "$frame" ]] || die "app has no vc-frame helper: $frame" + if command -v ditto >/dev/null 2>&1; then + /usr/bin/ditto "$runtime" "$root" + else + cp -R "$runtime/." "$root/" + fi + install -m 0755 "$terminal" "$root/bin/vc-terminal" + mkdir -p "$root/libexec" + install -m 0755 "$frame" "$root/libexec/vc-frame" + install -m 0755 "$root/scripts/vc-frame-product-entry.sh" "$root/bin/vc-frame" +else + payload_root="$(cd "$payload_root" 2>/dev/null && pwd -P)" \ + || die "cannot resolve payload root" + cp -R "$payload_root/." "$root/" +fi + +if find "$root" -type l -print -quit | grep -q .; then + die "standalone Runtime Pack contains symlinks" +fi +for required in \ + VERSION bin/python3 bin/vibecrafted bin/vc-start bin/vc-terminal bin/vc-frame \ + libexec/vc-frame scripts/vibecrafted scripts/vetcoders_install.py \ + vibecrafted-core/vibecrafted_core/runtime_pack_contract.py; do + [[ -e "$root/$required" ]] || die "standalone Runtime Pack is missing $required" +done + +PYTHONPATH="$root/vibecrafted-core" "$root/bin/python3" \ + -m vibecrafted_core.runtime_pack_contract write \ + --root "$root" \ + --carrier-basename "$(basename "$output")" \ + --version "$version" \ + --platform "$platform" \ + --architecture "$architecture" \ + --source-revision "$source_revision" \ + --terminal-revision "$terminal_revision" \ + --frame-revision "$frame_revision" >/dev/null + +mkdir -p "$(dirname "$output")" +candidate="$work/$(basename "$output")" +COPYFILE_DISABLE=1 tar -czf "$candidate" -C "$work" VibecraftedRuntime +mv "$candidate" "$output" +( + cd "$(dirname "$output")" + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$(basename "$output")" > "$(basename "$output").sha256" + else + sha256sum "$(basename "$output")" > "$(basename "$output").sha256" + fi +) +printf '%s\n' "$output" diff --git a/scripts/publish-vibecrafted-release.sh b/scripts/publish-vibecrafted-release.sh index 3de81bae..d1ecec4d 100755 --- a/scripts/publish-vibecrafted-release.sh +++ b/scripts/publish-vibecrafted-release.sh @@ -2,9 +2,10 @@ # Publish the installable Vibecrafted artifacts after a cold verification of the # exact bytes downloaded back from a draft GitHub Release. # -# Two channels, one release, one commit: -# macOS desktop -> the signed, notarized, stapled DMG -# every other OS -> the provenance-bound portable tarball install.sh consumes +# Three carriers, one release, one commit: +# macOS desktop -> the signed, notarized, stapled DMG +# macOS CLI -> the signed binary Runtime Pack embedded in that App +# other systems -> the provenance-bound portable source tarball # Each channel is verified against the bytes GitHub hands back, never against # the bytes this machine still has in dist/. The asset allowlist below stays # exact: a release that grew an asset nobody named is a release nobody audited. @@ -29,7 +30,7 @@ die() { exit 1 } -for command_name in git gh uv shasum xcrun spctl hdiutil; do +for command_name in git gh uv shasum openssl xcrun spctl hdiutil; do command -v "$command_name" >/dev/null 2>&1 || die "missing command: $command_name" done test "$(uname -s)" = "Darwin" || die "the notarized DMG publisher must run on macOS" @@ -62,6 +63,32 @@ test -s "$DMG_CHECKSUM" || die "missing $DMG_CHECKSUM" xcrun stapler validate "$DMG" spctl --assess --type open --context context:primary-signature --verbose=2 "$DMG" +RUNTIME_PACK_NAME="$(uv run python3 -c 'import json; print(json.load(open("dist/release-output.json"))["runtime_pack"]["path"])')" +RUNTIME_PACK="$DIST/$RUNTIME_PACK_NAME" +RUNTIME_PACK_CHECKSUM="$RUNTIME_PACK.sha256" +RUNTIME_PACK_SIGNATURE="$RUNTIME_PACK.sig" +RUNTIME_PACK_PUBLIC_KEY="$ROOT/vibecrafted-core/vibecrafted_core/trust/vibecrafted-signing-v1.pub" +test -s "$RUNTIME_PACK" || die "missing $RUNTIME_PACK; run make release first" +test -s "$RUNTIME_PACK_CHECKSUM" || die "missing $RUNTIME_PACK_CHECKSUM" +test -s "$RUNTIME_PACK_SIGNATURE" || die "missing $RUNTIME_PACK_SIGNATURE" +test -s "$RUNTIME_PACK_PUBLIC_KEY" || die "missing trusted Runtime Pack public key" +( + cd "$DIST" + shasum -a 256 -c "$(basename "$RUNTIME_PACK_CHECKSUM")" +) +openssl dgst -sha256 -verify "$RUNTIME_PACK_PUBLIC_KEY" \ + -signature "$RUNTIME_PACK_SIGNATURE" "$RUNTIME_PACK" >/dev/null \ + || die "Runtime Pack signature verification failed" +VC_FRAME_SHA="$(uv run python3 -c 'import json; print(json.load(open("dist/release-output.json"))["source_revisions"]["vc-frame"])')" +VC_TERMINAL_SHA="$(uv run python3 -c 'import json; print(json.load(open("dist/release-output.json"))["source_revisions"]["vc-terminal"])')" +VIBECRAFTED_RUNTIME_PACK_PUBLIC_KEY="$RUNTIME_PACK_PUBLIC_KEY" \ + bash "$ROOT/scripts/install-runtime-pack.sh" \ + --pack "$RUNTIME_PACK" \ + --verify-only \ + --expected-source-revision "$HEAD_SHA" \ + --expected-terminal-revision "$VC_TERMINAL_SHA" \ + --expected-frame-revision "$VC_FRAME_SHA" >/dev/null + # The portable channel carries no Apple ticket, so its identity claim is the # closed source-provenance carrier: an allowlisted tree whose digest names one # commit. Bind that claim to the same HEAD the DMG names, or the release would @@ -101,6 +128,9 @@ fi gh release upload "$TAG" --repo "$REPO" \ "$DMG" \ "$DMG_CHECKSUM" \ + "$RUNTIME_PACK" \ + "$RUNTIME_PACK_CHECKSUM" \ + "$RUNTIME_PACK_SIGNATURE" \ "$PORTABLE" \ "$PORTABLE_CHECKSUM" \ "$RELEASE_OUTPUT#release-output.json" \ @@ -114,6 +144,9 @@ gh release download "$TAG" --repo "$REPO" --dir "$DOWNLOAD_DIR" EXPECTED_ASSETS="$(printf '%s\n' \ "$DMG_NAME" \ "$DMG_NAME.sha256" \ + "$RUNTIME_PACK_NAME" \ + "$RUNTIME_PACK_NAME.sha256" \ + "$RUNTIME_PACK_NAME.sig" \ "$PORTABLE_NAME" \ "$PORTABLE_NAME.sha256" \ "release-output.json" \ @@ -122,6 +155,9 @@ ACTUAL_ASSETS="$(find "$DOWNLOAD_DIR" -maxdepth 1 -type f -exec basename {} \; | test "$ACTUAL_ASSETS" = "$EXPECTED_ASSETS" || die "draft release contains unexpected assets" cmp "$DMG" "$DOWNLOAD_DIR/$DMG_NAME" cmp "$DMG_CHECKSUM" "$DOWNLOAD_DIR/$DMG_NAME.sha256" +cmp "$RUNTIME_PACK" "$DOWNLOAD_DIR/$RUNTIME_PACK_NAME" +cmp "$RUNTIME_PACK_CHECKSUM" "$DOWNLOAD_DIR/$RUNTIME_PACK_NAME.sha256" +cmp "$RUNTIME_PACK_SIGNATURE" "$DOWNLOAD_DIR/$RUNTIME_PACK_NAME.sig" cmp "$PORTABLE" "$DOWNLOAD_DIR/$PORTABLE_NAME" cmp "$PORTABLE_CHECKSUM" "$DOWNLOAD_DIR/$PORTABLE_NAME.sha256" cmp "$RELEASE_OUTPUT" "$DOWNLOAD_DIR/release-output.json" @@ -129,8 +165,20 @@ cmp "$RELEASE_SIGNATURE" "$DOWNLOAD_DIR/release-output.json.sig" ( cd "$DOWNLOAD_DIR" shasum -a 256 -c "$DMG_NAME.sha256" + shasum -a 256 -c "$RUNTIME_PACK_NAME.sha256" shasum -a 256 -c "$PORTABLE_NAME.sha256" ) +openssl dgst -sha256 -verify "$RUNTIME_PACK_PUBLIC_KEY" \ + -signature "$DOWNLOAD_DIR/$RUNTIME_PACK_NAME.sig" \ + "$DOWNLOAD_DIR/$RUNTIME_PACK_NAME" >/dev/null \ + || die "downloaded Runtime Pack signature verification failed" +VIBECRAFTED_RUNTIME_PACK_PUBLIC_KEY="$RUNTIME_PACK_PUBLIC_KEY" \ + bash "$ROOT/scripts/install-runtime-pack.sh" \ + --pack "$DOWNLOAD_DIR/$RUNTIME_PACK_NAME" \ + --verify-only \ + --expected-source-revision "$HEAD_SHA" \ + --expected-terminal-revision "$VC_TERMINAL_SHA" \ + --expected-frame-revision "$VC_FRAME_SHA" >/dev/null uv run --project vibecrafted-core verify-vibecrafted-walkaround verify-release \ --release-output "$DOWNLOAD_DIR/release-output.json" \ @@ -159,14 +207,35 @@ uv run python3 "$DISTRIBUTION_MANIFEST" check \ --expected-source-revision "$HEAD_SHA" bash "$PORTABLE_UNPACK_DIR/$PORTABLE_ROOT_NAME/install.sh" --help >/dev/null +# Exercise the exact downloaded binary carrier through both public CLI buttons +# in an isolated HOME. The second button consumes the receipt written by the +# first and must leave no private XDG/agent residue behind. +RUNTIME_PACK_SMOKE_HOME="$DOWNLOAD_DIR/runtime-pack-home" +mkdir -p "$RUNTIME_PACK_SMOKE_HOME" +RUNTIME_PACK_SMOKE_ENV=( + HOME="$RUNTIME_PACK_SMOKE_HOME" + XDG_CONFIG_HOME="$RUNTIME_PACK_SMOKE_HOME/.config" + XDG_DATA_HOME="$RUNTIME_PACK_SMOKE_HOME/.local/share" + VIBECRAFTED_HOME="$RUNTIME_PACK_SMOKE_HOME/.vibecrafted" + VIBECRAFTED_RUNTIME_HOME="$RUNTIME_PACK_SMOKE_HOME/.local/share/vibecrafted" + VIBECRAFTED_LAUNCHER_BIN="$RUNTIME_PACK_SMOKE_HOME/.local/bin" +) +env "${RUNTIME_PACK_SMOKE_ENV[@]}" \ + make --no-print-directory install RUNTIME_PACK="$DOWNLOAD_DIR/$RUNTIME_PACK_NAME" +env "${RUNTIME_PACK_SMOKE_ENV[@]}" \ + make --no-print-directory uninstall +test -z "$(find "$RUNTIME_PACK_SMOKE_HOME" -mindepth 1 -print -quit)" \ + || die "Runtime Pack install/uninstall left residue in isolated HOME" + DMG_SHA="$(shasum -a 256 "$DOWNLOAD_DIR/$DMG_NAME" | awk '{print $1}')" DMG_SIZE="$(stat -f %z "$DOWNLOAD_DIR/$DMG_NAME")" +RUNTIME_PACK_SHA="$(shasum -a 256 "$DOWNLOAD_DIR/$RUNTIME_PACK_NAME" | awk '{print $1}')" +RUNTIME_PACK_SIZE="$(stat -f %z "$DOWNLOAD_DIR/$RUNTIME_PACK_NAME")" PORTABLE_SHA="$(shasum -a 256 "$DOWNLOAD_DIR/$PORTABLE_NAME" | awk '{print $1}')" PORTABLE_SIZE="$(stat -f %z "$DOWNLOAD_DIR/$PORTABLE_NAME")" PORTABLE_TREE_SHA="$(uv run python3 -c 'import json; print(json.load(open("dist/portable-output.json"))["provenance"]["tree_sha256"])')" -VC_FRAME_SHA="$(uv run python3 -c 'import json; print(json.load(open("dist/release-output.json"))["source_revisions"]["vc-frame"])')" -VC_TERMINAL_SHA="$(uv run python3 -c 'import json; print(json.load(open("dist/release-output.json"))["source_revisions"]["vc-terminal"])')" DOWNLOAD_URL="https://github.com/$REPO/releases/download/$TAG/$DMG_NAME" +RUNTIME_PACK_URL="https://github.com/$REPO/releases/download/$TAG/$RUNTIME_PACK_NAME" PORTABLE_URL="https://github.com/$REPO/releases/download/$TAG/$PORTABLE_NAME" mkdir -p "$REPORT_DIR" @@ -180,6 +249,7 @@ cat > "$REPORT" < "$REPORT" < \`make uninstall\` left an empty HOME: PASS. + +Install from a checkout without installing the App: + +\`\`\`bash +curl -fsSLO $RUNTIME_PACK_URL +curl -fsSLO $RUNTIME_PACK_URL.sha256 +curl -fsSLO $RUNTIME_PACK_URL.sig +make install RUNTIME_PACK=$RUNTIME_PACK_NAME +\`\`\` + +### Portable source channel (Linux / WSL2 / source fallback) - Source: [$PORTABLE_URL]($PORTABLE_URL) - SHA-256: \`$PORTABLE_SHA\` @@ -236,12 +325,13 @@ bash $PORTABLE_ROOT_NAME/install.sh ## Sign-off -PASS — the release has exactly two canonically named installable artifacts built from one commit, \`$DMG_NAME\` for macOS desktop and \`$PORTABLE_NAME\` for every other system, and no donor repo owns a competing app, installer or update channel. +PASS — the release has exactly three canonically named installable carriers built from one commit: \`$DMG_NAME\` for macOS desktop, \`$RUNTIME_PACK_NAME\` for macOS CLI, and \`$PORTABLE_NAME\` as the cross-platform source fallback. App and CLI consume one Runtime Pack authority; no donor repo owns a competing app, installer or update channel. EOF gh release edit "$TAG" --repo "$REPO" --notes-file "$REPORT" --draft=false --latest test "$(gh release view "$TAG" --repo "$REPO" --json isDraft --jq .isDraft)" = "false" -printf 'Published %s\nReport: %s\nDMG: %s bytes / %s\nPortable: %s\n %s bytes / %s\n' \ +printf 'Published %s\nReport: %s\nDMG: %s bytes / %s\nRuntime Pack: %s\n %s bytes / %s\nPortable: %s\n %s bytes / %s\n' \ "$DOWNLOAD_URL" "$REPORT" "$DMG_SIZE" "$DMG_SHA" \ + "$RUNTIME_PACK_URL" "$RUNTIME_PACK_SIZE" "$RUNTIME_PACK_SHA" \ "$PORTABLE_URL" "$PORTABLE_SIZE" "$PORTABLE_SHA" diff --git a/scripts/stage-runtime-foundations.sh b/scripts/stage-runtime-foundations.sh new file mode 100755 index 00000000..edc29fd2 --- /dev/null +++ b/scripts/stage-runtime-foundations.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash +set -euo pipefail + +die() { printf 'FATAL: %s\n' "$*" >&2; exit 1; } +require() { command -v "$1" >/dev/null 2>&1 || die "$1 is required"; } + +[[ $# -eq 1 ]] || die "usage: $0 OUTPUT_BIN_DIR" +OUTPUT_BIN_DIR="$1" +LOCTREE_VERSION="0.14.4" +LOCTREE_REVISION="3e9eb0a74cb3c043d740de5fe7d8c93985d0a876" +LOCTREE_ARCHIVE_SHA256="cdf37cff13b423d9be916f74bb43bc5857729e64380d7bc2f16462568d74a5cb" +AICX_VERSION="0.12.5" +AICX_REVISION="ced57997dd97a2b08960f35e3a657d7b0c49a200" +AICX_ARCHIVE_SHA256="ffc65ad6652ee0e240beb333f54d7372b607690dcf5f6c29eb68adee2aed58e7" +PRVIEW_VERSION="0.6.0" +LOCTREE_SOURCE_BUILD=0 + +case "$(uname -s):$(uname -m)" in + Darwin:arm64) + LOCTREE_PACKAGE="@loctree/loctree-darwin-arm64" + EXE_SUFFIX="" + ;; + Linux:x86_64) + LOCTREE_PACKAGE="@loctree/loctree-linux-x64-gnu" + EXE_SUFFIX="" + ;; + Linux:aarch64|Linux:arm64) + # npm has no Linux arm64 platform package. Build the exact public release + # commit from a digest-pinned source archive instead of falling back to a + # sibling checkout or a mutable branch. + LOCTREE_PACKAGE="" + LOCTREE_SOURCE_BUILD=1 + EXE_SUFFIX="" + ;; + MINGW*:x86_64|MSYS*:x86_64|CYGWIN*:x86_64) + LOCTREE_PACKAGE="@loctree/loctree-win32-x64-msvc" + EXE_SUFFIX=".exe" + ;; + *) die "no complete Runtime Foundations payload for $(uname -s)/$(uname -m)" ;; +esac + +[[ "${VIBECRAFTED_FOUNDATIONS_TARGET_PROBE:-0}" == 1 ]] && exit 0 + +for tool in curl npm cargo python3; do require "$tool"; done + +sha256_file() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + sha256sum "$1" | awk '{print $1}' + fi +} + +fetch_source() { + local url="$1" expected="$2" archive="$3" destination="$4" + curl -fL --proto '=https' --tlsv1.2 "$url" -o "$archive" + [[ "$(sha256_file "$archive")" == "$expected" ]] \ + || die "source archive checksum mismatch: $url" + mkdir -p "$destination" + tar -xzf "$archive" --strip-components=1 -C "$destination" +} + +WORK="$(mktemp -d "${TMPDIR:-/tmp}/vibecrafted-foundations.XXXXXX")" +# Cleanup must never turn an otherwise complete carrier build into a release +# failure. Finder/metadata services can recreate .DS_Store while rm is walking +# a temporary tree on macOS, making rm report ENOTEMPTY after every binary has +# already been staged successfully. The tree is disposable and remains under +# the OS temporary root, so preserve the build result if best-effort cleanup +# loses that race. +trap 'rm -rf "$WORK" 2>/dev/null || true' EXIT +mkdir -p "$OUTPUT_BIN_DIR" "$WORK/loctree" "$WORK/aicx" "$WORK/prview" + +# npm verifies the registry integrity for the exact platform package. Extract +# only the native runtime files; Node and its global package tree are not part +# of the installed product. +if [[ "$LOCTREE_SOURCE_BUILD" == 1 ]]; then + fetch_source \ + "https://codeload.github.com/Loctree/loctree/tar.gz/${LOCTREE_REVISION}" \ + "$LOCTREE_ARCHIVE_SHA256" "$WORK/loctree/source.tar.gz" "$WORK/loctree/source" + LOCTREE_TARGET="$WORK/loctree/target" + CARGO_TARGET_DIR="$LOCTREE_TARGET" cargo build \ + --manifest-path "$WORK/loctree/source/Cargo.toml" --release --locked \ + -p loctree --bin loct --bin loctree + for package in loctree-mcp loctree-lsp; do + CARGO_TARGET_DIR="$LOCTREE_TARGET" cargo build \ + --manifest-path "$WORK/loctree/source/Cargo.toml" --release --locked \ + -p "$package" + done + for name in loct loctree loctree-mcp loctree-lsp; do + install -m 0755 "$LOCTREE_TARGET/release/$name" "$OUTPUT_BIN_DIR/$name" + done +else + npm pack "${LOCTREE_PACKAGE}@${LOCTREE_VERSION}" \ + --pack-destination "$WORK/loctree" >/dev/null + tar -xzf "$WORK/loctree"/*.tgz -C "$WORK/loctree" + for name in loct loctree loctree-mcp loctree-lsp; do + install -m 0755 "$WORK/loctree/package/bin/${name}${EXE_SUFFIX}" \ + "$OUTPUT_BIN_DIR/${name}${EXE_SUFFIX}" + done +fi +rm -rf "$WORK/loctree" + +# The published 0.12.5 AICX archives are checksum-correct but retain their CI +# builder's /Users path in both native binaries. Build the exact release commit +# with path remaps instead of weakening payload hygiene or byte-patching signed +# upstream artifacts. Customers still receive ready binaries and need no Rust. +fetch_source \ + "https://codeload.github.com/Loctree/aicx/tar.gz/${AICX_REVISION}" \ + "$AICX_ARCHIVE_SHA256" "$WORK/aicx/source.tar.gz" "$WORK/aicx/source" +AICX_TARGET="$WORK/aicx/target" +NATIVE_REMAP_FLAGS="-ffile-prefix-map=$HOME=/usr/src/operator-home -ffile-prefix-map=$WORK/aicx/source=/usr/src/aicx" +RUSTFLAGS="--remap-path-prefix=$HOME=/usr/src/operator-home --remap-path-prefix=$WORK/aicx/source=/usr/src/aicx" \ + CFLAGS="$NATIVE_REMAP_FLAGS" \ + CXXFLAGS="$NATIVE_REMAP_FLAGS" \ + OBJCFLAGS="$NATIVE_REMAP_FLAGS" \ + OBJCXXFLAGS="$NATIVE_REMAP_FLAGS" \ + CARGO_TARGET_DIR="$AICX_TARGET" \ + cargo build --manifest-path "$WORK/aicx/source/Cargo.toml" \ + --release --locked --bin aicx --bin aicx-mcp +for name in aicx aicx-mcp; do + source_path="$AICX_TARGET/release/${name}${EXE_SUFFIX}" + [[ -f "$source_path" ]] || die "AICX build contains no ${name}${EXE_SUFFIX}" + install -m 0755 "$source_path" "$OUTPUT_BIN_DIR/${name}${EXE_SUFFIX}" +done +rm -rf "$WORK/aicx" + +# PRView documents GitHub release binaries, but its release page currently has +# no assets. Build the exact published crate once, during carrier assembly, so +# customers still receive a ready binary and never need Rust or Cargo. On macOS +# the git2 dependency otherwise records the build machine's Homebrew OpenSSL +# paths, which would make the signed binary unusable on a clean Mac. +if [[ "$(uname -s)" == "Darwin" ]]; then + require brew + OPENSSL_PREFIX="$(brew --prefix openssl@3)" + [[ -f "$OPENSSL_PREFIX/lib/libssl.a" && -f "$OPENSSL_PREFIX/lib/libcrypto.a" ]] \ + || die "static OpenSSL archives are required to build portable PRView" + OPENSSL_DIR="$OPENSSL_PREFIX" OPENSSL_STATIC=1 \ + cargo install --locked --version "$PRVIEW_VERSION" --root "$WORK/prview" prview +else + cargo install --locked --version "$PRVIEW_VERSION" --root "$WORK/prview" prview +fi +install -m 0755 "$WORK/prview/bin/prview${EXE_SUFFIX}" \ + "$OUTPUT_BIN_DIR/prview${EXE_SUFFIX}" + +if [[ "$(uname -s)" == "Darwin" ]] && \ + otool -L "$OUTPUT_BIN_DIR/prview" | grep -Eq '^[[:space:]]+/(opt|usr/local)/'; then + otool -L "$OUTPUT_BIN_DIR/prview" >&2 + die "PRView retains a non-system dynamic library dependency" +fi + +"$OUTPUT_BIN_DIR/loct${EXE_SUFFIX}" --version | grep -F "$LOCTREE_VERSION" >/dev/null +"$OUTPUT_BIN_DIR/aicx${EXE_SUFFIX}" --version | grep -F "$AICX_VERSION" >/dev/null +"$OUTPUT_BIN_DIR/prview${EXE_SUFFIX}" --version | grep -F "$PRVIEW_VERSION" >/dev/null + +python3 - "$OUTPUT_BIN_DIR" "$LOCTREE_VERSION" "$AICX_VERSION" "$PRVIEW_VERSION" <<'PY' +import hashlib +import json +import os +import sys +from pathlib import Path + +root = Path(sys.argv[1]) +versions = {"loctree": sys.argv[2], "aicx": sys.argv[3], "prview": sys.argv[4]} +files = {} +for path in sorted(root.iterdir()): + if path.is_file() and os.access(path, os.X_OK): + files[path.name] = hashlib.sha256(path.read_bytes()).hexdigest() +payload = { + "schema": "io.vetcoders.vibecrafted.runtime-foundations.v1", + "versions": versions, + "source_revisions": { + "loctree": "3e9eb0a74cb3c043d740de5fe7d8c93985d0a876", + "aicx": "ced57997dd97a2b08960f35e3a657d7b0c49a200", + }, + "source_archives": { + "loctree": { + "url": "https://codeload.github.com/Loctree/loctree/tar.gz/3e9eb0a74cb3c043d740de5fe7d8c93985d0a876", + "sha256": "cdf37cff13b423d9be916f74bb43bc5857729e64380d7bc2f16462568d74a5cb", + }, + "aicx": { + "url": "https://codeload.github.com/Loctree/aicx/tar.gz/ced57997dd97a2b08960f35e3a657d7b0c49a200", + "sha256": "ffc65ad6652ee0e240beb333f54d7372b607690dcf5f6c29eb68adee2aed58e7", + }, + }, + "licenses": { + "loctree": "BUSL-1.1", + "aicx": "BUSL-1.1", + "prview": "BUSL-1.1", + }, + "files": files, +} +(root.parent / "runtime-foundations.json").write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8" +) +PY diff --git a/scripts/unified_product_manifest.py b/scripts/unified_product_manifest.py index 1dc4d225..eb61da97 100755 --- a/scripts/unified_product_manifest.py +++ b/scripts/unified_product_manifest.py @@ -4,15 +4,18 @@ from __future__ import annotations import argparse +import hashlib import json import os import plistlib import stat import subprocess +import tarfile from pathlib import Path from typing import Any from vibecrafted_core import product_contract as contract +from vibecrafted_core import runtime_pack_contract def _write(path: Path, payload: dict[str, Any], *, canonical: bool = False) -> None: @@ -232,6 +235,31 @@ def produce_release(args: argparse.Namespace) -> None: executable = app / product["outer_bundle_code"]["path"] signer = contract._codesign_release_evidence(app) policy = contract._release_policy() + runtime_pack = args.runtime_pack.resolve() + embedded_runtime_pack = app / "Contents/Resources/runtime-pack" / runtime_pack.name + if ( + runtime_pack.stat().st_size != embedded_runtime_pack.stat().st_size + or contract._sha256(runtime_pack) != contract._sha256(embedded_runtime_pack) + ): + raise SystemExit( + "standalone Runtime Pack bytes differ from the App-embedded carrier" + ) + with tarfile.open(runtime_pack, "r:gz") as archive: + member = archive.getmember( + f"VibecraftedRuntime/{runtime_pack_contract.PROVENANCE_NAME}" + ) + extracted = archive.extractfile(member) + if extracted is None: + raise SystemExit("Runtime Pack provenance cannot be read") + provenance_raw = extracted.read() + provenance = json.loads(provenance_raw.decode("utf-8")) + expected_revisions = { + "vibecrafted": product["git_sha"], + "vc-terminal": modules["vc-terminal"]["git_sha"], + "vc-frame": modules["vc-frame"]["git_sha"], + } + if provenance.get("source_revisions") != expected_revisions: + raise SystemExit("Runtime Pack provenance disagrees with the product sources") payload = { "schema": contract.RELEASE_OUTPUT_SCHEMA, "signature_policy": { @@ -271,6 +299,20 @@ def produce_release(args: argparse.Namespace) -> None: "sha256": contract._sha256(dmg), "size": dmg.stat().st_size, }, + "runtime_pack": { + "path": runtime_pack.name, + "embedded_path": (f"Contents/Resources/runtime-pack/{runtime_pack.name}"), + "sha256": contract._sha256(runtime_pack), + "size": runtime_pack.stat().st_size, + "provenance": { + "path": runtime_pack_contract.PROVENANCE_NAME, + "sha256": hashlib.sha256(provenance_raw).hexdigest(), + "version": provenance["version"], + "platform": provenance["platform"], + "architecture": provenance["architecture"], + "source_revisions": provenance["source_revisions"], + }, + }, "modules": { name: { "manifest": { @@ -321,6 +363,7 @@ def main() -> int: release = commands.add_parser("release") release.add_argument("--app", type=Path, required=True) release.add_argument("--dmg", type=Path, required=True) + release.add_argument("--runtime-pack", type=Path, required=True) release.add_argument("--output", type=Path, required=True) args = parser.parse_args() if args.command == "app": diff --git a/scripts/vc-frame-product-entry.sh b/scripts/vc-frame-product-entry.sh index 0d794d04..42ecc0c0 100755 --- a/scripts/vc-frame-product-entry.sh +++ b/scripts/vc-frame-product-entry.sh @@ -5,7 +5,7 @@ # in the product environment, the Cargo prefix, or Vibecrafted's data root. # # Policy (goal: bare frame is backyard-safe): -# 1. Always pin VC_FRAME_CONFIG_DIR to product frontier/view when present +# 1. Always pin VC_FRAME_CONFIG_DIR to the canonical product config # so bare attach gets the same Super binds, layouts, and scripts as vc-start. # 2. Product operator session names (vibecrafted / operator / default operator # session) never launch without the product config root. @@ -17,24 +17,21 @@ resolve_real_bin() { printf '%s\n' "$VIBECRAFTED_VC_FRAME_BIN" return 0 fi + + local wrapper_dir + wrapper_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" for candidate in \ + "$wrapper_dir/../libexec/vc-frame" \ "${HOME}/.cargo/bin/vc-frame" \ "${XDG_DATA_HOME:-$HOME/.local/share}/vibecrafted/bin/vc-frame" \ "${HOME}/.local/share/vibecrafted/bin/vc-frame" do - if [[ -x "$candidate" ]]; then - # Skip if it is this wrapper (same path as argv0 when recursive). - if [[ -f "$candidate" ]] && ! head -1 "$candidate" 2>/dev/null | grep -q 'vc-frame-product-entry\|product choke'; then - # binary: no shebang - if ! head -c 2 "$candidate" 2>/dev/null | grep -q '#!'; then - printf '%s\n' "$candidate" - return 0 - fi - fi - if file "$candidate" 2>/dev/null | grep -qi 'Mach-O\|ELF\|executable'; then - printf '%s\n' "$candidate" - return 0 - fi + # Ambient shell wrappers are never a real vc-frame. In particular, + # ~/.local/share/vibecrafted/bin/vc-frame may resolve back to this product + # entry and recurse forever. Follow symlinks, but accept native code only. + if [[ -x "$candidate" ]] && file -Lb "$candidate" 2>/dev/null | grep -Eqi 'Mach-O|ELF'; then + printf '%s\n' "$candidate" + return 0 fi done return 1 @@ -47,7 +44,9 @@ pin_darwin_socket_dir() { case "$(uname -s 2>/dev/null || true)" in Darwin) if [[ -z "${VC_FRAME_SOCKET_DIR:-}" && -z "${ZELLIJ_SOCKET_DIR:-}" ]]; then - export VC_FRAME_SOCKET_DIR="/tmp/vc-frame-$(id -u)" + local socket_uid + socket_uid="$(id -u)" + export VC_FRAME_SOCKET_DIR="/tmp/vc-frame-$socket_uid" export ZELLIJ_SOCKET_DIR="$VC_FRAME_SOCKET_DIR" fi ;; @@ -56,15 +55,7 @@ pin_darwin_socket_dir() { pin_product_config() { local xdg="${XDG_CONFIG_HOME:-$HOME/.config}" - local frontier="$xdg/vetcoders/frontier/vc-frame" - local view="$xdg/vc-frame" - if [[ -f "${VC_FRAME_CONFIG_DIR:-}/config.kdl" ]]; then - return 0 - fi - if [[ -f "$frontier/config.kdl" ]]; then - export VC_FRAME_CONFIG_DIR="$frontier" - return 0 - fi + local view="$xdg/vibecrafted/vc-frame" if [[ -f "$view/config.kdl" ]]; then export VC_FRAME_CONFIG_DIR="$view" return 0 diff --git a/scripts/version_bump.py b/scripts/version_bump.py index f25e066f..8821d36a 100755 --- a/scripts/version_bump.py +++ b/scripts/version_bump.py @@ -40,6 +40,60 @@ Path("vibecrafted-app/Cargo.lock"): ("control-core",), } PLUGIN_MANIFEST_RELATIVE = Path("plugin.json") +RELEASE_TEXT_PROJECTIONS = { + Path("vibecrafted-app/shell-agent/app/project.yml"): re.compile( + r'^\s*MARKETING_VERSION:\s*"(?P\d+\.\d+\.\d+)"', re.MULTILINE + ), + Path("packaging/homebrew/Formula/vibecrafted.rb"): re.compile( + r'^\s*version\s+"(?P\d+\.\d+\.\d+)"', re.MULTILINE + ), + Path("packaging/homebrew/Casks/vibecrafted-app.rb"): re.compile( + r'^\s*version\s+"(?P\d+\.\d+\.\d+),', re.MULTILINE + ), +} + + +def check_version_declarations(version_file: Path) -> str: + """Fail when a packaged product projection drifts from root VERSION.""" + current = version_file.read_text(encoding="utf-8").strip() + _parse_version(current) + projections = _version_projections(version_file) + if projections is None: + return current + pyprojects, packaged_versions, cargos = projections + declared: dict[Path, str] = { + path: _table_version(path.read_text(encoding="utf-8"), "project") + for path in pyprojects + } + declared.update( + {path: path.read_text(encoding="utf-8").strip() for path in packaged_versions} + ) + declared.update( + { + path: _table_version(path.read_text(encoding="utf-8"), "package") + for path in cargos + } + ) + plugin_path = version_file.parent / PLUGIN_MANIFEST_RELATIVE + if plugin_path.exists(): + declared[plugin_path] = str( + json.loads(plugin_path.read_text(encoding="utf-8")).get("version", "") + ) + for relative, pattern in RELEASE_TEXT_PROJECTIONS.items(): + path = version_file.parent / relative + if not path.exists(): + continue + match = pattern.search(path.read_text(encoding="utf-8")) + if match is None: + raise ValueError(f"version declaration missing: {path}") + declared[path] = match.group("version") + drift = {path: value for path, value in declared.items() if value != current} + if drift: + details = ", ".join(f"{path}={value}" for path, value in drift.items()) + raise ValueError( + f"Version drift detected; expected {current} in every declaration: {details}" + ) + return current def _parse_version(value: str) -> tuple[int, int, int]: @@ -234,6 +288,17 @@ def update_version_declarations(version_file: Path, requested: str) -> tuple[str if plugin_path.exists(): plugin_payload = json.loads(plugin_path.read_text(encoding="utf-8")) declared[plugin_path] = str(plugin_payload.get("version", "")) + release_texts: dict[Path, tuple[str, re.Pattern[str]]] = {} + for relative, pattern in RELEASE_TEXT_PROJECTIONS.items(): + path = version_file.parent / relative + if not path.exists(): + continue + text = path.read_text(encoding="utf-8") + match = pattern.search(text) + if match is None: + raise ValueError(f"version declaration missing: {path}") + declared[path] = match.group("version") + release_texts[path] = (text, pattern) declared.update( { path: _table_version(text, "package") @@ -268,6 +333,14 @@ def update_version_declarations(version_file: Path, requested: str) -> tuple[str if plugin_payload is not None: plugin_payload["version"] = next_version updates[plugin_path] = json.dumps(plugin_payload, indent=2) + "\n" + for path, (text, pattern) in release_texts.items(): + updates[path] = pattern.sub( + lambda match: match.group(0).replace( + match.group("version"), next_version, 1 + ), + text, + count=1, + ) for relative, package_names in CARGO_LOCK_PACKAGES.items(): lock_path = version_file.parent / relative if not lock_path.exists(): @@ -289,12 +362,19 @@ def main() -> int: parser = argparse.ArgumentParser( description="Bump VERSION and every packaged version declaration.", ) - parser.add_argument("version", help="{patch|minor|major|x.y.z}") + parser.add_argument("version", nargs="?", help="{patch|minor|major|x.y.z}") parser.add_argument("--file", default="VERSION", help="VERSION file path") + parser.add_argument("--check", action="store_true", help="verify projections only") args = parser.parse_args() version_file = Path(args.file) try: + if args.check: + current = check_version_declarations(version_file) + print(f"Version projections agree: v{current}") + return 0 + if args.version is None: + parser.error("version is required unless --check is used") current, next_version = update_version_declarations(version_file, args.version) except (OSError, ValueError) as exc: print(str(exc), file=sys.stderr) diff --git a/scripts/vetcoders_install.py b/scripts/vetcoders_install.py index a22364e4..df65d758 100755 --- a/scripts/vetcoders_install.py +++ b/scripts/vetcoders_install.py @@ -7,6 +7,9 @@ list Show available 𝚅𝚒𝚋𝚎𝚌𝚛𝚊𝚏𝚝𝚎𝚍. skills and the runtime substrate beneath them uninstall Remove 𝚅𝚒𝚋𝚎𝚌𝚛𝚊𝚏𝚝𝚎𝚍. skills, views, launchers, and helpers restore Restore pre-install state from backup + runtime-install Install a signed/offline Runtime Pack for the native app or CLI + runtime-uninstall + Remove exactly the Runtime Pack surfaces recorded at install Usage: python3 scripts/vetcoders_install.py install [--non-interactive] [--dry-run] [--advanced] @@ -14,6 +17,8 @@ python3 scripts/vetcoders_install.py list python3 scripts/vetcoders_install.py uninstall [--dry-run] python3 scripts/vetcoders_install.py restore [--dry-run] + python3 scripts/vetcoders_install.py runtime-install --payload-root PATH [--app-root PATH] + python3 scripts/vetcoders_install.py runtime-uninstall [--dry-run] """ from __future__ import annotations @@ -33,6 +38,7 @@ import re import runpy import select +import shlex import shutil import signal import stat @@ -1453,6 +1459,24 @@ def _restore_path_from_backup(src: Path, dst: Path) -> None: shutil.copy2(src, dst) +def _receipt_backup_path_is_allowed(backup: Path, backup_root: Path) -> bool: + """Validate the backup container without dereferencing its payload leaf. + + Collision backups intentionally preserve symlinks. Resolving `backup` + itself therefore follows an operator-owned symlink to its original target + and falsely makes a legitimate receipt look as if it escaped the backup + root. Resolve the parent chain instead: a symlinked parent still fails + closed, while the final entry remains an opaque file/dir/symlink payload. + """ + if not backup.is_absolute() or backup.name in {"", ".", ".."}: + return False + resolved_root = backup_root.resolve(strict=False) + resolved_parent = backup.parent.resolve(strict=False) + return resolved_parent == resolved_root or _is_subpath( + resolved_parent, resolved_root + ) + + @dataclass(frozen=True) class ManagedPath: """One managed filesystem path slated for teardown, with the action to take and why.""" @@ -2553,6 +2577,7 @@ def _rc_has_vibecrafted_bin_path(content: str) -> bool: RELEASE_CONTRACT_PACKAGE_ASSETS = ( "product_contract.py", + "runtime_pack_contract.py", "walkaround_runner.py", "schemas/unified_product.schema.v1.json", "trust/release-policy.v1.json", @@ -2602,6 +2627,32 @@ def _find_launcher_wrapper(name: str) -> Path | None: return None +def _runtime_pack_launcher_target(launcher: Path, current_tools: Path) -> Path | None: + """Return a wrapper's executable when it enters the active receipted generation.""" + try: + text = launcher.read_text(encoding="utf-8", errors="ignore")[:8192] + generation = current_tools.resolve(strict=True) + except (OSError, RuntimeError): + return None + for line in reversed(text.splitlines()): + if not line.strip().startswith("exec "): + continue + try: + argv = shlex.split(line.strip()) + except ValueError: + return None + if len(argv) < 2: + return None + try: + target = Path(argv[1]).resolve(strict=True) + except (OSError, RuntimeError): + return None + if target == generation or _is_subpath(target, generation): + return target + return None + return None + + def _uninstall_rc_entries() -> list[tuple[str, str]]: """The `(line, comment)` pairs this installer strips from rc files during cleanup/uninstall.""" entries = [ @@ -2979,9 +3030,7 @@ def _remove_path(path: Path) -> None: ) _SOURCE_PAYLOAD_SCHEMA = "vibecrafted.distribution-tree.v1" _SOURCE_PAYLOAD_KEYS = frozenset({"schema", "algorithm", "tree_sha256", "entry_count"}) -_RUNTIME_GENERATION_ENTRYPOINT = Path( - "vibecrafted-core/vibecrafted_core/deck/vibecrafted" -) +_RUNTIME_GENERATION_ENTRYPOINT = Path("bin/vibecrafted") _RUNTIME_GENERATION_RUNTIME_ALIAS = Path("runtime") _RUNTIME_GENERATION_CANONICAL_RUNTIME = Path( "vibecrafted-core/vibecrafted_core/runtime" @@ -2998,7 +3047,10 @@ def _remove_path(path: Path) -> None: frozenset( { Path("VERSION"), + Path("scripts/distribution_manifest.py"), + Path("scripts/installer_brand.py"), Path("scripts/vibecrafted"), + Path("scripts/vetcoders_install.py"), _RUNTIME_GENERATION_CANONICAL_CONFIG, _RUNTIME_GENERATION_ENTRYPOINT, } @@ -3036,6 +3088,10 @@ def _remove_path(path: Path) -> None: r"^Vibecrafted_[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?-" r"[0-9]{8}-[0-9a-f]{8}\.dmg$" ) +_RUNTIME_RELEASE_PACK_PATTERN = ( + r"^Vibecrafted_RuntimePack_[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?-" + r"[0-9]{8}-[0-9a-f]{8}-darwin-(?:arm64|x64)\.tar\.gz$" +) _RUNTIME_VERIFIER_SCHEMA_DEFS = frozenset( { "architecture", @@ -3128,6 +3184,9 @@ def _remove_path(path: Path) -> None: "$defs/releaseOutput/properties/outer_executable/properties/signer_policy", "$defs/releaseOutput/properties/code_resources", "$defs/releaseOutput/properties/dmg", + "$defs/releaseOutput/properties/runtime_pack", + "$defs/releaseOutput/properties/runtime_pack/properties/provenance", + "$defs/releaseOutput/properties/runtime_pack/properties/provenance/properties/source_revisions", "$defs/releaseOutput/properties/modules", "$defs/releaseOutput/properties/source_revisions", "$defs/releaseOutput/properties/notarization", @@ -3267,7 +3326,7 @@ class _RuntimeLaunchAgentBackup: @dataclass(frozen=True) class _RetiredVcFrameProcess: - """One same-user process proven to execute the retired vc-frame sibling.""" + """One same-user process proven by stable birth identity and argv.""" pid: int birth: tuple[str, int, int] @@ -3648,14 +3707,39 @@ def _assert_runtime_loaded_service_owner(shared_home: Path) -> Path | None: return loaded_home +def _runtime_supervisor_lock_is_held(shared_home: Path) -> bool: + """Distinguish a live supervisor lock from the harmless persistent lock inode.""" + lock_path = shared_home / "server" / "supervisor.lock" + try: + descriptor = os.open( + lock_path, + os.O_RDWR | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0), + ) + except FileNotFoundError: + return False + except OSError: + # A foreign, unreadable, or symlinked lock remains actionable evidence; + # ownership validation must fail closed later instead of ignoring it. + return True + try: + metadata = os.fstat(descriptor) + if not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != os.geteuid(): + return True + try: + fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + return True + fcntl.flock(descriptor, fcntl.LOCK_UN) + return False + finally: + os.close(descriptor) + + def _runtime_service_has_evidence(shared_home: Path) -> bool: - """True if any on-disk or launchd evidence suggests the runtime service is (or was) - installed. - """ + """True if durable ownership or live-lock evidence suggests an installed service.""" runtime_dir = shared_home / "server" evidence = ( Path.home() / "Library" / "LaunchAgents" / f"{_RUNTIME_SERVICE_LABEL}.plist", - runtime_dir / "supervisor.lock", runtime_dir / "server.pid", runtime_dir / "guardian.pid", runtime_dir / "server.identity.json", @@ -3663,6 +3747,8 @@ def _runtime_service_has_evidence(shared_home: Path) -> bool: ) if any(path.exists() or path.is_symlink() for path in evidence): return True + if _runtime_supervisor_lock_is_held(shared_home): + return True loaded_home = _runtime_loaded_service_home() return loaded_home == shared_home.resolve(strict=False) @@ -4259,8 +4345,85 @@ def _retired_vc_frame_process_census() -> tuple[_RetiredVcFrameProcess, ...]: return tuple(sorted(records, key=lambda record: (record.pid, record.birth))) +def _darwin_caller_ancestor_pids() -> frozenset[int]: + """Return this installer's process ancestry so teardown cannot kill its caller.""" + ancestors: set[int] = set() + pid = os.getpid() + while pid > 1 and pid not in ancestors: + ancestors.add(pid) + try: + pid = _darwin_process_parent_pid(pid) + except ProcessLookupError: + break + return frozenset(ancestors) + + +def _owned_runtime_process_roots(*, app_root: Path | None = None) -> tuple[Path, ...]: + """Canonical executable roots whose live processes belong to this product.""" + roots = [ + vibecrafted_runtime_home() / "releases", + Path("/Applications/Vibecrafted.app"), + Path.home() / "Applications/Vibecrafted.app", + ] + if app_root is not None: + roots.append(app_root) + return tuple(root.expanduser().resolve(strict=False) for root in roots) + + +def _runtime_process_argv_is_owned( + argv: Sequence[str], *, roots: Sequence[Path] +) -> bool: + """Match product executables, plus managed scripts run by a system shell.""" + if not argv: + return False + + def managed_path(raw: str) -> bool: + candidate = Path(raw).expanduser() + if not candidate.is_absolute(): + return False + resolved = candidate.resolve(strict=False) + return any(resolved == root or _is_subpath(resolved, root) for root in roots) + + if managed_path(argv[0]): + return True + if Path(argv[0]).name not in {"bash", "dash", "sh", "zsh"}: + return False + return any(managed_path(argument) for argument in argv[1:]) + + +def _owned_runtime_process_census( + *, app_root: Path | None = None +) -> tuple[_RetiredVcFrameProcess, ...]: + """Return stable same-user App, terminal, frame, server, and runtime shell processes.""" + if sys.platform != "darwin": + return () + process_ids = _darwin_process_ids() + if not process_ids: + return () + excluded = _darwin_caller_ancestor_pids() + roots = _owned_runtime_process_roots(app_root=app_root) + records: list[_RetiredVcFrameProcess] = [] + for pid in process_ids: + if pid in excluded: + continue + try: + first_birth = _darwin_process_birth(pid) + if first_birth[1] != os.geteuid(): + continue + first_argv = _darwin_process_arguments(pid, pointer_size=first_birth[2]) + second_argv = _darwin_process_arguments(pid, pointer_size=first_birth[2]) + second_birth = _darwin_process_birth(pid) + except ProcessLookupError: + continue + if first_birth != second_birth or first_argv != second_argv: + raise OSError(f"Darwin process {pid} changed during runtime census") + if _runtime_process_argv_is_owned(first_argv, roots=roots): + records.append(_RetiredVcFrameProcess(pid, first_birth, first_argv)) + return tuple(sorted(records, key=lambda record: (record.pid, record.birth))) + + def _retired_vc_frame_process_still_matches(record: _RetiredVcFrameProcess) -> bool: - """Re-prove birth identity and argv before signaling a retired process.""" + """Re-prove birth identity and argv before signaling a managed process.""" try: birth = _darwin_process_birth(record.pid) argv = _darwin_process_arguments(record.pid, pointer_size=birth[2]) @@ -4269,10 +4432,13 @@ def _retired_vc_frame_process_still_matches(record: _RetiredVcFrameProcess) -> b return birth == record.birth and argv == record.argv -def _terminate_retired_vc_frame_processes( - records: Sequence[_RetiredVcFrameProcess], *, timeout_seconds: float = 5.0 +def _terminate_verified_runtime_processes( + records: Sequence[_RetiredVcFrameProcess], + *, + label: str, + timeout_seconds: float = 5.0, ) -> None: - """Terminate only re-verified retired processes and require a zero-leftover postcondition.""" + """Terminate only re-verified processes and require a zero-leftover postcondition.""" for record in records: if _retired_vc_frame_process_still_matches(record): try: @@ -4305,11 +4471,31 @@ def _terminate_retired_vc_frame_processes( if pending: time.sleep(0.05) if pending: - raise OSError("retired vc-frame.real process remains after verified teardown") + raise OSError(f"{label} remains after verified teardown") + + +def _terminate_retired_vc_frame_processes( + records: Sequence[_RetiredVcFrameProcess], *, timeout_seconds: float = 5.0 +) -> None: + _terminate_verified_runtime_processes( + records, + label="retired vc-frame.real process", + timeout_seconds=timeout_seconds, + ) + + +def _terminate_owned_runtime_processes( + records: Sequence[_RetiredVcFrameProcess], *, timeout_seconds: float = 5.0 +) -> None: + _terminate_verified_runtime_processes( + records, + label="owned runtime process", + timeout_seconds=timeout_seconds, + ) def _teardown_owned_runtime_for_uninstall( - shared_home: Path, *, dry_run: bool + shared_home: Path, *, dry_run: bool, app_root: Path | None = None ) -> tuple[str, ...]: """Stop the owned service plane and retired vc-frame processes before deleting files.""" if sys.platform != "darwin": @@ -4379,6 +4565,13 @@ def _teardown_owned_runtime_for_uninstall( raise OSError( "retired vc-frame.real processes remain after teardown" ) + owned = _owned_runtime_process_census(app_root=app_root) + if owned: + actions.append(f"terminate {len(owned)} owned runtime process(es)") + if not dry_run: + _terminate_owned_runtime_processes(owned) + if _owned_runtime_process_census(app_root=app_root): + raise OSError("owned runtime processes remain after teardown") if dry_run and not lease_preexisting: # A dry run must leave the disk exactly as it found it. The real teardown # leaves the lease to the uninstall inventory, which removes it by name. @@ -5319,6 +5512,55 @@ def _darwin_process_birth(pid: int) -> tuple[str, int, int]: ) +def _darwin_process_parent_pid(pid: int) -> int: + """Return a stable Darwin process parent PID, or raise when the process vanished.""" + libproc, _ = _darwin_process_libraries() + info = _DarwinProcBSDInfo() + if ctypes.sizeof(info) != _DARWIN_PROC_BSDINFO_SIZE: + raise OSError("Darwin proc_bsdinfo ABI does not match the supported layout") + ctypes.set_errno(0) + received = libproc.proc_pidinfo( + pid, + _DARWIN_PROC_PIDTBSDINFO, + 0, + ctypes.byref(info), + _DARWIN_PROC_BSDINFO_SIZE, + ) + if received != _DARWIN_PROC_BSDINFO_SIZE: + observed_errno = ctypes.get_errno() + if received == 0 and observed_errno in {0, errno.ESRCH}: + raise ProcessLookupError(pid) + if observed_errno in {errno.EACCES, errno.EPERM}: + # macOS 27 can deny proc_pidinfo for the installer's own ancestry + # under a remote login. Keep libproc as the identity authority and + # use absolute ps only to build the conservative do-not-signal set. + result = subprocess.run( + ["/bin/ps", "-o", "ppid=", "-p", str(pid)], + check=False, + capture_output=True, + text=True, + timeout=2, + env={"LC_ALL": "C", "PATH": "/usr/bin:/bin"}, + ) + raw_parent = result.stdout.strip() + if result.returncode == 0 and re.fullmatch(r"[0-9]+", raw_parent): + return int(raw_parent) + if result.returncode == 1 and not raw_parent: + raise ProcessLookupError(pid) + detail = result.stderr.strip() or raw_parent or f"exit={result.returncode}" + raise OSError(f"cannot inspect Darwin process parent for {pid} ({detail})") + raise OSError( + f"cannot inspect Darwin process parent for {pid} (errno {observed_errno})" + ) + if ( + int(info.pbi_pid) != pid + or int(info.pbi_status) not in _DARWIN_STABLE_PROCESS_STATES + or int(info.pbi_flags) & _DARWIN_PROC_FLAG_INEXIT + ): + raise ProcessLookupError(pid) + return int(info.pbi_ppid) + + def _darwin_process_arguments(pid: int, *, pointer_size: int) -> tuple[str, ...]: """Fetch a PID's argv via `sysctl KERN_PROCARGS2`, parsing past the exec path and alignment padding; raises ProcessLookupError if the process is gone. @@ -7995,13 +8237,48 @@ def _materialize_vc_frame_generation(runtime_root: Path) -> None: destination / "layouts", destination / "themes", ) - if not required[0].is_file() or any(not path.is_dir() for path in required[1:]): + if ( + not required[0].is_file() + or not required[1].is_dir() + or not required[2].is_dir() + ): raise OSError( f"candidate runtime has incomplete materialized vc-frame config: " f"{destination}" ) +def _materialize_runtime_generation_vc_frame_entry(runtime_root: Path) -> None: + """Publish the vc-frame product-entry wrapper as ``bin/vc-frame`` of the generation. + + The wrapper resolves the real vc-frame binary at run time, so it needs no + host-specific baking; carrying it inside every generation keeps the public + ``~/.local/bin/vc-frame`` symlink valid across republishes. Writing it into + a *published* generation afterwards (the old foundations-installer habit) + mutated an immutable generation and vanished on the next publish. + """ + source = runtime_root / "scripts" / "vc-frame-product-entry.sh" + target = runtime_root / "bin" / "vc-frame" + if not source.is_file(): + raise OSError(f"candidate runtime has no vc-frame product entry: {source}") + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + target.chmod(0o755) + + +def _materialize_runtime_generation_entrypoint(runtime_root: Path) -> None: + """Publish the canonical command deck at the manifest-bound entrypoint.""" + source = ( + runtime_root / "vibecrafted-core" / "vibecrafted_core" / "deck" / "vibecrafted" + ) + target = runtime_root / _RUNTIME_GENERATION_ENTRYPOINT + if not source.is_file(): + raise OSError(f"candidate runtime has no canonical command deck: {source}") + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + target.chmod(0o755) + + def _runtime_active_text_files(runtime_root: Path) -> Iterator[Path]: """Yield every active (non-symlink) text config/script file under the runtime's watched roots. @@ -8347,6 +8624,18 @@ def require_closed_objects(node: object, path: tuple[str, ...] = ()) -> None: ) from exc if dmg_pattern != _RUNTIME_RELEASE_DMG_PATTERN: raise OSError("candidate unified-product schema canonical DMG pattern drifted") + try: + runtime_pack_pattern = definitions["releaseOutput"]["properties"][ + "runtime_pack" + ]["properties"]["path"]["pattern"] + except (KeyError, TypeError) as exc: + raise OSError( + "candidate unified-product schema has no canonical Runtime Pack path" + ) from exc + if runtime_pack_pattern != _RUNTIME_RELEASE_PACK_PATTERN: + raise OSError( + "candidate unified-product schema canonical Runtime Pack pattern drifted" + ) def _write_runtime_verifier_snapshot( @@ -8385,8 +8674,9 @@ def _run_runtime_verifier_semantic_command( argv: Sequence[str], *, cache: Path, + python_executable: Path, ) -> subprocess.CompletedProcess[str]: - """Run one captured candidate CLI under an isolated Python import/cache environment.""" + """Run one captured candidate CLI with the candidate runtime's own Python.""" environment = os.environ.copy() for name in ("PYTHONHOME", "PYTHONPATH", "PYTHONPYCACHEPREFIX"): environment.pop(name, None) @@ -8398,7 +8688,7 @@ def _run_runtime_verifier_semantic_command( ) return subprocess.run( [ - sys.executable, + str(python_executable), "-I", "-S", "-B", @@ -8435,8 +8725,36 @@ def _assert_runtime_verifier_semantic_failure( ) +def _runtime_verifier_python(runtime_root: Path) -> Path: + """Pick the interpreter that verifies a candidate generation. + + A Runtime Pack carries its own ``bin/python3`` and must be verified with it. + A source-staged generation carries none: its interpreter is the uv tool + environment bound *after* publication (``uv tool install --editable``), so + the installer's own interpreter verifies the captured bytes. A carried + interpreter that cannot execute is a broken pack, never a fallback case. + """ + runtime_python = runtime_root / "bin/python3" + if not runtime_python.exists() and not runtime_python.is_symlink(): + return Path(sys.executable) + if not runtime_python.is_file() or not os.access(runtime_python, os.X_OK): + raise OSError(f"candidate runtime Python is not executable: {runtime_python}") + return runtime_python + + def _validate_runtime_verifier_semantics(runtime_root: Path) -> None: """Validate captured candidate code/schema and exercise its real public entrypoints.""" + runtime_python = _runtime_verifier_python(runtime_root) + + def run_candidate( + argv: Sequence[str], *, cache: Path + ) -> subprocess.CompletedProcess[str]: + return _run_runtime_verifier_semantic_command( + argv, + cache=cache, + python_executable=runtime_python, + ) + captured: dict[Path, bytes] = {} for relative in sorted(_RUNTIME_GENERATION_REQUIRED_HASHES): try: @@ -8479,7 +8797,7 @@ def _validate_runtime_verifier_semantics(runtime_root: Path) -> None: runner = snapshot / _RUNTIME_VERIFIER_RUNNER cache = temporary / "pycache" - generation_result = _run_runtime_verifier_semantic_command( + generation_result = run_candidate( [str(product), "runtime-generation", str(snapshot)], cache=cache, ) @@ -8497,9 +8815,7 @@ def _validate_runtime_verifier_semantics(runtime_root: Path) -> None: + (f": {detail}" if detail else "") ) - product_help = _run_runtime_verifier_semantic_command( - [str(product), "--help"], cache=cache - ) + product_help = run_candidate([str(product), "--help"], cache=cache) if ( product_help.returncode != 0 or product_help.stderr @@ -8510,9 +8826,7 @@ def _validate_runtime_verifier_semantics(runtime_root: Path) -> None: ): raise OSError("candidate product-contract --help surface is incomplete") - runner_help = _run_runtime_verifier_semantic_command( - [str(runner), "--help"], cache=cache - ) + runner_help = run_candidate([str(runner), "--help"], cache=cache) if ( runner_help.returncode != 0 or runner_help.stderr @@ -8530,7 +8844,7 @@ def _validate_runtime_verifier_semantics(runtime_root: Path) -> None: drifted_launcher = drift_snapshot / "scripts/vibecrafted" drifted_launcher.write_bytes(drifted_launcher.read_bytes() + b"\n# drift\n") _assert_runtime_verifier_semantic_failure( - _run_runtime_verifier_semantic_command( + run_candidate( [ str(drift_snapshot / _RUNTIME_VERIFIER_PRODUCT), "runtime-generation", @@ -8571,7 +8885,7 @@ def _validate_runtime_verifier_semantics(runtime_root: Path) -> None: source_provenance_raw, ) _assert_runtime_verifier_semantic_failure( - _run_runtime_verifier_semantic_command( + run_candidate( [ str(legacy_snapshot / _RUNTIME_VERIFIER_PRODUCT), "runtime-generation", @@ -8593,7 +8907,7 @@ def _validate_runtime_verifier_semantics(runtime_root: Path) -> None: source_provenance_raw, ) _assert_runtime_verifier_semantic_failure( - _run_runtime_verifier_semantic_command( + run_candidate( [ str(open_snapshot / _RUNTIME_VERIFIER_PRODUCT), "runtime-generation", @@ -8606,7 +8920,7 @@ def _validate_runtime_verifier_semantics(runtime_root: Path) -> None: ) _assert_runtime_verifier_semantic_failure( - _run_runtime_verifier_semantic_command( + run_candidate( [str(product), "schema", str(snapshot / _RUNTIME_GENERATION_MANIFEST)], cache=cache, ), @@ -8646,9 +8960,7 @@ def _validate_runtime_verifier_semantics(runtime_root: Path) -> None: ) for command, arguments, forbidden_output in runner_negative_commands: _assert_runtime_verifier_semantic_failure( - _run_runtime_verifier_semantic_command( - [str(runner), command, *arguments], cache=cache - ), + run_candidate([str(runner), command, *arguments], cache=cache), expected_code=_RUNTIME_VERIFIER_E_MISSING, context=f"walk-around runner {command} missing input", ) @@ -8702,9 +9014,7 @@ def _validate_runtime_verifier_semantics(runtime_root: Path) -> None: ) for command, arguments, forbidden_output in runner_invalid_commands: _assert_runtime_verifier_semantic_failure( - _run_runtime_verifier_semantic_command( - [str(runner), command, *arguments], cache=cache - ), + run_candidate([str(runner), command, *arguments], cache=cache), expected_code=_RUNTIME_VERIFIER_E_PROOF, context=f"walk-around runner {command} invalid proof", ) @@ -8856,6 +9166,8 @@ def _sync_control_plane_tree_locked( if install_version: stamp_install_version(staging, install_version) _materialize_vc_frame_generation(staging) + _materialize_runtime_generation_entrypoint(staging) + _materialize_runtime_generation_vc_frame_entry(staging) audit_errors = _runtime_generation_audit_errors(staging, source_root=src) if audit_errors: raise OSError("\n".join(audit_errors)) @@ -9640,7 +9952,7 @@ def _secure_walkaround_preflight_source() -> str: "schema", "version", "source_fingerprint", "owner_repo", "source_revision", "source_payload", "entrypoint", "hashes", }) -ENTRYPOINT = "vibecrafted-core/vibecrafted_core/deck/vibecrafted" +ENTRYPOINT = "bin/vibecrafted" RUNTIME_ALIAS = "runtime" CANONICAL_RUNTIME = "vibecrafted-core/vibecrafted_core/runtime" PROJECTED_CONFIG = "runtime/generated/vc-frame/config.kdl" @@ -9954,10 +10266,13 @@ def _secure_walkaround_launcher_issues( uv_tools_root = Path( os.environ.get("UV_TOOL_DIR", str(xdg_data_home() / "uv" / "tools")) ).expanduser() - expected_wrapper = _canonical_path_preserving_final_symlink( + expected_uv_wrapper = _canonical_path_preserving_final_symlink( uv_tools_root / "vibecrafted" / "bin" / SECURE_WALKAROUND_LAUNCHER ) - if resolved_launcher != expected_wrapper: + expected_runtime_wrapper = _canonical_path_preserving_final_symlink( + vibecrafted_launcher_bin() / SECURE_WALKAROUND_LAUNCHER + ) + if resolved_launcher not in {expected_uv_wrapper, expected_runtime_wrapper}: return [ f"{SECURE_WALKAROUND_LAUNCHER}:corrupt:wrapper is outside managed tool roots" ] @@ -9973,14 +10288,22 @@ def _secure_walkaround_launcher_issues( raw = _capture_runtime_bound_file(resolved_launcher) except OSError as exc: return [f"{SECURE_WALKAROUND_LAUNCHER}:corrupt:{exc}"] - interpreters = tuple( - _canonical_path_preserving_final_symlink(candidate) - for candidate in ( - resolved_launcher.parent / "python", - resolved_launcher.parent / "python3", + if resolved_launcher == expected_runtime_wrapper: + runtime_python = current_tools / "bin/python3" + interpreters = ( + (_canonical_path_preserving_final_symlink(runtime_python),) + if runtime_python.is_file() and os.access(runtime_python, os.X_OK) + else () + ) + else: + interpreters = tuple( + _canonical_path_preserving_final_symlink(candidate) + for candidate in ( + resolved_launcher.parent / "python", + resolved_launcher.parent / "python3", + ) + if candidate.is_file() and os.access(candidate, os.X_OK) ) - if candidate.is_file() and os.access(candidate, os.X_OK) - ) matching = [ interpreter for interpreter in interpreters @@ -10713,10 +11036,19 @@ def _runtime_generation_contract_findings() -> list[DoctorFinding]: "is missing or broken" ) else: - if launcher_target != expected_launcher: + runtime_wrapper_target = _runtime_pack_launcher_target(launcher, current) + wrapper_matches = False + if runtime_wrapper_target is not None: + try: + wrapper_matches = _capture_runtime_bound_file( + runtime_wrapper_target + ) == _capture_runtime_bound_file(expected_launcher) + except OSError: + wrapper_matches = False + if launcher_target != expected_launcher and not wrapper_matches: errors.append( - "canonical vibecrafted launcher does not resolve to the current " - "generation entrypoint" + "canonical vibecrafted launcher neither resolves to nor wraps the " + "current manifest-bound generation entrypoint" ) if errors: return [ @@ -10876,9 +11208,10 @@ def _slack_provider_contract_findings() -> list[DoctorFinding]: except ModuleNotFoundError as exc: return [ DoctorFinding( - "fail", + "warn", "slack-provider", - f"Slack provider installer is missing: {exc}", + f"Slack provider installer is not bundled: {exc}. External " + "provider (vc-slack-agent) is optional", ) ] healthy, detail = provider.doctor() @@ -11485,6 +11818,9 @@ def run_doctor(store_path: Path, state: InstallState) -> list[DoctorFinding]: if "uv" in resolved.parts and "tools" in resolved.parts: python_entrypoint_owners.add("uv tool") continue + if _runtime_pack_launcher_target(launcher_path, current_link) is not None: + python_entrypoint_owners.add("runtime generation") + continue if name == "vibecrafted": try: expected = _launcher_symlink_target(Path()).resolve(strict=True) @@ -12559,18 +12895,18 @@ def _cmd_install_verbose(args: argparse.Namespace, repo_root: Path) -> int: def _launcher_symlink_target(repo_root: Path) -> Path: """Resolve what ~/.local/bin/vibecrafted should point at. - The host launcher always enters the immutable installed generation. Python - tooling may still live in its uv environment, but it is an implementation - dependency of the deck, never the user-facing runtime owner. + The host launcher always enters the immutable installed generation through + its manifest-bound entrypoint (``bin/vibecrafted``, hashed in + ``runtime-manifest.json``) — the same file the runtime-generation doctor + check verifies. Python tooling may still live in its uv environment, but it + is an implementation dependency of the deck, never the user-facing runtime + owner. """ _ = repo_root return ( vibecrafted_tools_home() / "vibecrafted-current" - / "vibecrafted-core" - / "vibecrafted_core" - / "deck" - / "vibecrafted" + / _RUNTIME_GENERATION_ENTRYPOINT ) @@ -13805,6 +14141,17 @@ def cmd_uninstall(args: argparse.Namespace) -> int: # legacy store for current installs. store_path = legacy_store dry_run = args.dry_run + runtime_receipt = _runtime_receipt_path(vibecrafted_runtime_home()) + if runtime_receipt.is_file(): + runtime_exit = cmd_runtime_uninstall( + argparse.Namespace(dry_run=dry_run, emit_result=False) + ) + if runtime_exit != 0: + print( + red("Runtime Pack uninstall stopped on locally modified managed files.") + ) + print(dim(f" receipt: {runtime_receipt}")) + return runtime_exit bundle = set(_known_bundle_names()) helper_file = _helper_target_path() legacy_file = _helper_legacy_path() @@ -13851,6 +14198,7 @@ def cmd_uninstall(args: argparse.Namespace) -> int: runtime_evidence = sys.platform == "darwin" and ( _runtime_service_has_evidence(shared_home) or bool(_retired_vc_frame_process_census()) + or bool(_owned_runtime_process_census()) ) has_work = runtime_evidence or any( (record.action in {"remove", "edit"} and _path_present(record.path)) @@ -13880,9 +14228,7 @@ def cmd_uninstall(args: argparse.Namespace) -> int: _print_uninstall_inventory(inventory) if runtime_evidence: - print( - " teardown runtime: verified service plane and retired vc-frame processes" - ) + print(" teardown runtime: verified service plane and owned runtime processes") print() if _IS_TTY and not dry_run: @@ -14108,6 +14454,1022 @@ def cmd_restore(args: argparse.Namespace) -> int: return 0 +# --------------------------------------------------------------------------- +# Signed/offline Runtime Pack installer +# --------------------------------------------------------------------------- + + +RUNTIME_INSTALL_RECEIPT = "install-receipt.json" +RUNTIME_INSTALL_SCHEMA = "vibecrafted.runtime-install.v1" +_RUNTIME_WRAPPER_VERBS = { + "telemetry": "telemetry", + "vc-dashboard": "dashboard", + "vc-dispatch": "dispatch", + "vc-doctor": "doctor", + "vc-help": "help", + "vc-init": "init", + "vc-justdo": "justdo", + "vc-receipt": "receipt", + "vc-resume": "resume", + "vc-status": "status", + "vc-update": "update", +} + + +def _runtime_install_paths() -> dict[str, Path]: + """Resolve the one cross-channel runtime/config/state layout.""" + home = Path.home() + runtime_home = Path( + os.environ.get( + "VIBECRAFTED_RUNTIME_HOME", + str( + Path(os.environ.get("XDG_DATA_HOME", home / ".local/share")) + / "vibecrafted" + ), + ) + ).expanduser() + config_home = Path(os.environ.get("XDG_CONFIG_HOME", home / ".config")).expanduser() + return { + "runtime_home": runtime_home, + "config_home": config_home, + "product_config": config_home / "vibecrafted", + "crafted_home": Path( + os.environ.get("VIBECRAFTED_HOME", home / ".vibecrafted") + ).expanduser(), + "launcher_home": Path( + os.environ.get("VIBECRAFTED_LAUNCHER_BIN", home / ".local/bin") + ).expanduser(), + } + + +def _runtime_receipt_path(runtime_home: Path) -> Path: + return runtime_home / RUNTIME_INSTALL_RECEIPT + + +def _sha256_path(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _assert_runtime_tree_has_no_symlinks(root: Path) -> None: + if root.is_symlink(): + raise RuntimeError(f"symlink is forbidden: {root}") + for parent, directories, files in os.walk(root, followlinks=False): + for name in [*directories, *files]: + candidate = Path(parent) / name + if candidate.is_symlink(): + raise RuntimeError(f"symlink is forbidden in runtime: {candidate}") + + +def _atomic_text(path: Path, body: str, *, mode: int = 0o644) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.parent / f".{path.name}.new-{os.getpid()}" + temporary.write_text(body, encoding="utf-8") + temporary.chmod(mode) + os.replace(temporary, path) + + +def _runtime_launcher_body( + *, + generation: Path, + config_home: Path, + crafted_home: Path, + runtime_home: Path, + frame_config: Path, + executable: Path, + leading_arguments: Sequence[str] = (), +) -> str: + quoted_arguments = " ".join(shlex_quote(value) for value in leading_arguments) + prefix = f"{quoted_arguments} " if quoted_arguments else "" + lines = [ + "#!/bin/bash", + "set -euo pipefail", + f"export XDG_CONFIG_HOME={shlex_quote(str(config_home))}", + f"export VIBECRAFTED_HOME={shlex_quote(str(crafted_home))}", + f"export VIBECRAFTED_RUNTIME_HOME={shlex_quote(str(runtime_home))}", + f"export VIBECRAFTED_RUNTIME_ROOT={shlex_quote(str(generation))}", + f"export VIBECRAFTED_ROOT={shlex_quote(str(generation))}", + f"export VIBECRAFTED_PYTHON={shlex_quote(str(generation / 'bin/python3'))}", + f"export VIBECRAFTED_VC_FRAME_BIN={shlex_quote(str(generation / 'libexec/vc-frame'))}", + f"export VC_FRAME_CONFIG_DIR={shlex_quote(str(frame_config))}", + f'export PATH="{generation / "bin"}:${{PATH:-/usr/bin:/bin:/usr/sbin:/sbin}}"', + 'export VIBECRAFTED_DECLARED_LAUNCHER="$0"', + f'exec {shlex_quote(str(executable))} {prefix}"$@"', + ] + return "\n".join(lines) + "\n" + + +def _load_runtime_install_receipt(path: Path) -> dict[str, Any]: + if not path.is_file(): + return {} + try: + receipt = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise RuntimeError( + f"cannot read runtime install receipt {path}: {exc}" + ) from exc + if receipt.get("schema") != RUNTIME_INSTALL_SCHEMA: + raise RuntimeError(f"unsupported runtime install receipt schema: {path}") + return receipt + + +def _checkpoint_runtime_install_receipt( + runtime_home: Path, receipt: Mapping[str, Any] +) -> None: + """Persist recoverable ownership after each completed install mutation.""" + _atomic_json_file(_runtime_receipt_path(runtime_home), dict(receipt)) + + +def _backup_runtime_collision( + destination: Path, *, runtime_home: Path, receipt: dict[str, Any] +) -> None: + backups = receipt.setdefault("backups", {}) + key = str(destination) + if key in backups or not _path_present(destination): + return + backup_root = runtime_home / ".installer-backups" / "original" + token = hashlib.sha256(key.encode("utf-8")).hexdigest()[:20] + backup = backup_root / f"{token}-{destination.name}" + backup.parent.mkdir(parents=True, exist_ok=True) + _copy_path_to_backup(destination, backup) + backups[key] = str(backup) + # Persist the restore map before the caller replaces/removes the original. + # A killed installer can then still be reset by this same entrypoint. + _checkpoint_runtime_install_receipt(runtime_home, receipt) + + +def _record_owned_file(receipt: dict[str, Any], path: Path) -> None: + receipt.setdefault("owned_files", {})[str(path)] = _sha256_path(path) + + +def _write_runtime_owned_file( + path: Path, + body: str, + *, + mode: int, + runtime_home: Path, + receipt: dict[str, Any], + previous: dict[str, Any], +) -> None: + previous_owned = previous.get("owned_files", {}) + key = str(path) + if _path_present(path): + if key in previous_owned: + if ( + path.is_symlink() + or not path.is_file() + or _sha256_path(path) != previous_owned[key] + ): + raise RuntimeError( + f"managed runtime file changed since install: {path}" + ) + else: + _backup_runtime_collision(path, runtime_home=runtime_home, receipt=receipt) + _atomic_text(path, body, mode=mode) + _record_owned_file(receipt, path) + _checkpoint_runtime_install_receipt(runtime_home, receipt) + + +def _write_runtime_owned_symlink( + path: Path, + target: Path, + *, + runtime_home: Path, + receipt: dict[str, Any], + previous: dict[str, Any], +) -> None: + """Atomically publish one receipted projection without losing a user collision.""" + canonical_target = target.resolve(strict=True) + key = str(path) + previous_owned = previous.get("owned_symlinks", {}) + if _path_present(path): + if key in previous_owned: + expected = Path(previous_owned[key]).resolve(strict=False) + if not path.is_symlink() or _symlink_target(path) != expected: + raise RuntimeError( + f"managed runtime symlink changed since install: {path}" + ) + else: + _backup_runtime_collision(path, runtime_home=runtime_home, receipt=receipt) + _remove_path(path) + _atomic_symlink(canonical_target, path) + receipt.setdefault("owned_symlinks", {})[key] = str(canonical_target) + _checkpoint_runtime_install_receipt(runtime_home, receipt) + + +def _ensure_runtime_projection_directory( + path: Path, *, runtime_home: Path, receipt: dict[str, Any] +) -> None: + """Create a real user projection directory and receipt only newly-created parents.""" + home = Path.home().expanduser() + candidate = path.expanduser() + if not candidate.is_absolute(): + raise RuntimeError(f"runtime projection path is not absolute: {path}") + + # Normalize `..` without resolving symlinks, then walk every descendant of + # HOME. Checking only the final directory is insufficient: `~/.agents` + # could itself point outside HOME while `~/.agents/skills` looks like a + # normal directory to pathlib. + normalized = Path(os.path.abspath(candidate)) + try: + relative = normalized.relative_to(home) + except ValueError as exc: + raise RuntimeError(f"runtime projection escapes HOME: {path}") from exc + + missing: list[Path] = [] + cursor = home + for component in relative.parts: + cursor /= component + if cursor.is_symlink(): + raise RuntimeError( + f"runtime projection ancestor must not be a symlink: {cursor}" + ) + if cursor.exists() and not cursor.is_dir(): + raise RuntimeError(f"runtime projection root is not a directory: {cursor}") + if not cursor.exists(): + missing.append(cursor) + normalized.mkdir(parents=True, exist_ok=True) + owned = receipt.setdefault("owned_empty_dirs", []) + for directory in reversed(missing): + value = str(directory) + if value not in owned: + owned.append(value) + _checkpoint_runtime_install_receipt(runtime_home, receipt) + + +def _ensure_runtime_install_directory( + path: Path, *, runtime_home: Path, receipt: dict[str, Any] +) -> None: + """Create an install root and receipt new HOME ancestors for final pruning. + + Explicit XDG/runtime overrides may legitimately live outside HOME. Their + exact managed roots remain receipted, but we never claim their surrounding + filesystem. Inside HOME, the installer owns every directory it had to + create and can therefore remove those directories later if they are empty. + """ + home = Path.home().expanduser() + normalized = Path(os.path.abspath(path.expanduser())) + try: + normalized.relative_to(home) + except ValueError: + normalized.mkdir(parents=True, exist_ok=True) + return + _ensure_runtime_projection_directory( + normalized, runtime_home=runtime_home, receipt=receipt + ) + + +def _install_runtime_agent_projections( + generation: Path, + *, + version: str, + runtime_home: Path, + receipt: dict[str, Any], + previous: dict[str, Any], +) -> tuple[list[str], list[str]]: + """Project the immutable generation into the agent-native discovery surfaces.""" + skills_root = generation / "vibecrafted-core/vibecrafted_core/skills" + skills = discover_skills(generation) + skill_names = [skill.name for skill in skills] + if not skill_names: + raise RuntimeError( + f"Runtime Pack contains no discoverable skills: {skills_root}" + ) + + runtimes = list(STANDARD_VIEW_RUNTIMES) + for runtime in runtimes: + view_root = runtime_skills_dir(runtime) + _ensure_runtime_projection_directory( + view_root, runtime_home=runtime_home, receipt=receipt + ) + for source, relative in iter_skill_root_rule_files(skills_root): + _write_runtime_owned_file( + view_root / relative, + source.read_text(encoding="utf-8"), + mode=stat.S_IMODE(source.stat().st_mode), + runtime_home=runtime_home, + receipt=receipt, + previous=previous, + ) + for skill in skills: + _write_runtime_owned_symlink( + view_root / skill.name, + skill, + runtime_home=runtime_home, + receipt=receipt, + previous=previous, + ) + + payloads = _agent_command_payloads(runtime) + if payloads: + commands_root = runtime_commands_dir(runtime) + _ensure_runtime_projection_directory( + commands_root, runtime_home=runtime_home, receipt=receipt + ) + for filename, content in payloads.items(): + _write_runtime_owned_file( + commands_root / filename, + content, + mode=0o644, + runtime_home=runtime_home, + receipt=receipt, + previous=previous, + ) + + now = datetime.now(timezone.utc).isoformat() + state = InstallState( + installed_at=now, + updated_at=now, + framework_version=version, + repo_commit="unknown", + repo_url="", + skills=skill_names, + runtimes=runtimes, + launcher_entries=_snapshot_launcher_entries(), + helper_files=[], + foundations={ + foundation.name: { + "channel": "bundled" if foundation.name == "vc-frame" else "detected", + "path": foundation.is_installed() or "", + } + for foundation in FOUNDATIONS + }, + product_tools=snapshot_product_tool_state(), + shell_helpers=False, + install_path=str(skills_root), + ) + _write_runtime_owned_file( + vibecrafted_home() / STATE_FILE, + json.dumps(asdict(state), indent=2) + "\n", + mode=0o644, + runtime_home=runtime_home, + receipt=receipt, + previous=previous, + ) + return skill_names, runtimes + + +def _runtime_install_result( + *, + generation: Path, + app_root: Path | None, + paths: Mapping[str, Path], + terminal_host: Path, +) -> dict[str, str]: + product_config = paths["product_config"] + return { + "schema": "vibecrafted.runtime-install-result.v1", + "root": str(generation), + "terminal": str(generation / "bin/vc-terminal"), + "terminal_host": str(terminal_host), + # AppDelegate exports this as VIBECRAFTED_VC_FRAME_BIN for the public + # product entry. Point it at the native provider, never back at the + # wrapper itself, or the first `vc-frame ls` recursively execs the + # wrapper forever. + "frame": str(generation / "libexec/vc-frame"), + "start": str(generation / "bin/vc-start"), + "primary_shell": str(generation / "config/alacritty/launch-primary-shell.zsh"), + "terminal_config": str(product_config / "terminal-entry.toml"), + "frame_config": str(product_config / "vc-frame"), + "runtime_home": str(paths["runtime_home"]), + "config_home": str(paths["config_home"]), + "crafted_home": str(paths["crafted_home"]), + "app_root": str(app_root) if app_root else "", + } + + +def cmd_runtime_install(args: argparse.Namespace) -> int: + """Install one immutable Runtime Pack and publish a closed ownership receipt.""" + payload_root = Path(args.payload_root).expanduser().resolve() + app_root = Path(args.app_root).expanduser().resolve() if args.app_root else None + if not (payload_root / "VERSION").is_file(): + raise RuntimeError(f"Runtime Pack has no VERSION: {payload_root}") + version = (payload_root / "VERSION").read_text(encoding="utf-8").strip() + if not version or not re.fullmatch(r"[A-Za-z0-9.+_-]+", version): + raise RuntimeError(f"invalid Runtime Pack VERSION: {version!r}") + _assert_runtime_tree_has_no_symlinks(payload_root) + + paths = _runtime_install_paths() + runtime_home = paths["runtime_home"] + receipt_path = _runtime_receipt_path(runtime_home) + previous = _load_runtime_install_receipt(receipt_path) + previous_roots = { + name: Path(value) for name, value in previous.get("roots", {}).items() + } + if previous and previous_roots != paths: + raise RuntimeError( + "existing runtime install receipt belongs to different install roots" + ) + previous_created = previous.get("roots_created", {}) + root_created = { + name: bool(previous_created.get(name)) or not path.exists() + for name, path in paths.items() + if name in {"runtime_home", "product_config", "crafted_home", "launcher_home"} + } + receipt: dict[str, Any] = { + "schema": RUNTIME_INSTALL_SCHEMA, + "installed_at": datetime.now(timezone.utc).isoformat(), + "version": version, + "payload_root": str(payload_root), + "app_root": str(app_root) if app_root else "", + "roots": {name: str(path) for name, path in paths.items()}, + "roots_created": root_created, + "owned_files": dict(previous.get("owned_files", {})), + "owned_symlinks": dict(previous.get("owned_symlinks", {})), + "owned_dirs": list(previous.get("owned_dirs", [])), + "owned_empty_dirs": list(previous.get("owned_empty_dirs", [])), + "backups": dict(previous.get("backups", {})), + } + + releases = runtime_home / "releases" + generation = releases / version + for directory in ( + releases, + paths["product_config"], + paths["crafted_home"] / "artifacts", + paths["crafted_home"] / "control_plane", + paths["launcher_home"], + ): + _ensure_runtime_install_directory( + directory, runtime_home=runtime_home, receipt=receipt + ) + _checkpoint_runtime_install_receipt(runtime_home, receipt) + + if str(generation) not in receipt["owned_dirs"]: + # Claim the final destination before publication. A crash after the + # atomic rename remains recoverable from the checkpointed receipt. + receipt["owned_dirs"].append(str(generation)) + _checkpoint_runtime_install_receipt(runtime_home, receipt) + + if not generation.exists(): + staging = Path(tempfile.mkdtemp(prefix=f".{version}.staging-", dir=releases)) + try: + shutil.rmtree(staging) + shutil.copytree(payload_root, staging) + bin_dir = staging / "bin" + bin_dir.mkdir(parents=True, exist_ok=True) + if args.terminal_host: + shutil.copy2( + Path(args.terminal_host).expanduser(), bin_dir / "vc-terminal" + ) + (bin_dir / "vc-terminal").chmod(0o755) + if args.frame_helper: + libexec_dir = staging / "libexec" + libexec_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2( + Path(args.frame_helper).expanduser(), libexec_dir / "vc-frame" + ) + (libexec_dir / "vc-frame").chmod(0o755) + shutil.copy2( + staging / "scripts/vc-frame-product-entry.sh", + bin_dir / "vc-frame", + ) + (bin_dir / "vc-frame").chmod(0o755) + _materialize_vc_frame_generation(staging) + _materialize_runtime_generation_entrypoint(staging) + source_provenance = load_source_provenance(staging) + if source_provenance is None: + raise RuntimeError("Runtime Pack has no source-provenance.json") + _write_runtime_generation_manifest( + staging, + source_root=payload_root, + source_provenance=source_provenance, + install_version=version, + ) + payload_errors = _runtime_generation_payload_errors(staging) + if payload_errors: + raise RuntimeError( + "Runtime Pack generation is invalid: " + "; ".join(payload_errors) + ) + _assert_runtime_tree_has_no_symlinks(staging) + os.replace(staging, generation) + finally: + if staging.exists(): + shutil.rmtree(staging) + _assert_runtime_tree_has_no_symlinks(generation) + + terminal_host = ( + Path(args.terminal_host).expanduser().resolve() + if args.terminal_host + else generation / "bin/vc-terminal" + ) + required = [ + generation / "bin/vibecrafted", + generation / "bin/loct", + generation / "bin/loctree-mcp", + generation / "bin/aicx", + generation / "bin/aicx-mcp", + generation / "bin/prview", + generation / "bin/screenscribe", + generation / "bin/vc-frame", + generation / "libexec/vc-frame", + generation / "bin/vc-server", + generation / "bin/vc-server-supervisor", + generation / "bin/vc-start", + generation / "bin/vc-workflow", + terminal_host, + generation / "config/alacritty/launch-primary-shell.zsh", + generation / "vibecrafted-core/vibecrafted_core/skills", + ] + missing = [ + str(path) + for path in required + if not ( + path.is_dir() + if path == generation / "vibecrafted-core/vibecrafted_core/skills" + else os.access(path, os.X_OK) + ) + ] + if missing: + raise RuntimeError("Runtime Pack is incomplete: " + ", ".join(missing)) + + # `active.json`, the CLI, foundations, doctor, and agent views must all + # name the same immutable release. `vibecrafted-current` is a stable + # projection for older consumers, never a second tools generation. + current_link = runtime_home / "tools/vibecrafted-current" + _write_runtime_owned_symlink( + current_link, + generation, + runtime_home=runtime_home, + receipt=receipt, + previous=previous, + ) + + product_config = paths["product_config"] + terminal_theme = product_config / "terminal-theme.toml" + if not terminal_theme.exists(): + shutil.copy2(generation / "config/vc-terminal/themes/dark.toml", terminal_theme) + receipt["owned_dirs"].append(str(terminal_theme)) + _checkpoint_runtime_install_receipt(runtime_home, receipt) + terminal_policy = generation / "config/vc-terminal/vibecrafted.toml" + terminal_entry = ( + "# Generated by the Vibecrafted installer.\n" + "[general]\n" + "import = [\n" + f" {json.dumps(str(terminal_policy))},\n" + f" {json.dumps(str(terminal_theme))},\n" + "]\n" + "live_config_reload = true\n" + ) + _write_runtime_owned_file( + product_config / "terminal-entry.toml", + terminal_entry, + mode=0o644, + runtime_home=runtime_home, + receipt=receipt, + previous=previous, + ) + + frame_config = product_config / "vc-frame" + if frame_config.exists(): + if str(frame_config) not in previous.get("owned_dirs", []): + _backup_runtime_collision( + frame_config, runtime_home=runtime_home, receipt=receipt + ) + _remove_path(frame_config) + shutil.copytree( + generation / "vibecrafted-core/vibecrafted_core/runtime/generated/vc-frame", + frame_config, + ) + if str(frame_config) not in receipt["owned_dirs"]: + receipt["owned_dirs"].append(str(frame_config)) + _checkpoint_runtime_install_receipt(runtime_home, receipt) + shell_config = product_config / "shell" + if shell_config.exists(): + if str(shell_config) not in previous.get("owned_dirs", []): + _backup_runtime_collision( + shell_config, runtime_home=runtime_home, receipt=receipt + ) + _remove_path(shell_config) + shutil.copytree( + generation / "vibecrafted-core/vibecrafted_core/runtime/shell", + shell_config, + ) + if str(shell_config) not in receipt["owned_dirs"]: + receipt["owned_dirs"].append(str(shell_config)) + _checkpoint_runtime_install_receipt(runtime_home, receipt) + _assert_runtime_tree_has_no_symlinks(product_config) + + bin_dir = generation / "bin" + for entry in sorted(bin_dir.iterdir(), key=lambda item: item.name): + if ( + entry.name in {"python3", "vc-terminal", SECURE_WALKAROUND_LAUNCHER} + or not entry.is_file() + ): + continue + if not os.access(entry, os.X_OK): + continue + destination = paths["launcher_home"] / entry.name + body = _runtime_launcher_body( + generation=generation, + config_home=paths["config_home"], + crafted_home=paths["crafted_home"], + runtime_home=runtime_home, + frame_config=frame_config, + executable=entry, + ) + _write_runtime_owned_file( + destination, + body, + mode=0o755, + runtime_home=runtime_home, + receipt=receipt, + previous=previous, + ) + + verifier_launcher = paths["launcher_home"] / SECURE_WALKAROUND_LAUNCHER + verifier_body = _secure_walkaround_launcher_contents( + current_link, + generation / "bin/python3", + launcher_path=verifier_launcher, + ).decode("utf-8") + _write_runtime_owned_file( + verifier_launcher, + verifier_body, + mode=0o755, + runtime_home=runtime_home, + receipt=receipt, + previous=previous, + ) + + terminal_launcher = paths["launcher_home"] / "vc-terminal" + terminal_body = _runtime_launcher_body( + generation=generation, + config_home=paths["config_home"], + crafted_home=paths["crafted_home"], + runtime_home=runtime_home, + frame_config=frame_config, + executable=terminal_host, + leading_arguments=( + "--config-file", + str(product_config / "terminal-entry.toml"), + ), + ) + _write_runtime_owned_file( + terminal_launcher, + terminal_body, + mode=0o755, + runtime_home=runtime_home, + receipt=receipt, + previous=previous, + ) + + deck = generation / "bin/vibecrafted" + for name, verb in _RUNTIME_WRAPPER_VERBS.items(): + if (bin_dir / name).is_file() and os.access(bin_dir / name, os.X_OK): + continue + destination = paths["launcher_home"] / name + body = _runtime_launcher_body( + generation=generation, + config_home=paths["config_home"], + crafted_home=paths["crafted_home"], + runtime_home=runtime_home, + frame_config=frame_config, + executable=deck, + leading_arguments=(verb,), + ) + _write_runtime_owned_file( + destination, + body, + mode=0o755, + runtime_home=runtime_home, + receipt=receipt, + previous=previous, + ) + + skill_names, runtime_views = _install_runtime_agent_projections( + generation, + version=version, + runtime_home=runtime_home, + receipt=receipt, + previous=previous, + ) + + active = { + "schema": "vibecrafted.active-runtime.v1", + "version": version, + "runtime_root": str(generation), + "app_root": str(app_root) if app_root else "", + } + active_path = runtime_home / "active.json" + _write_runtime_owned_file( + active_path, + json.dumps(active, indent=2, sort_keys=True) + "\n", + mode=0o644, + runtime_home=runtime_home, + receipt=receipt, + previous=previous, + ) + _atomic_json_file(receipt_path, receipt) + result = _runtime_install_result( + generation=generation, + app_root=app_root, + paths=paths, + terminal_host=terminal_host, + ) + result["tools_current"] = str(current_link) + result["skills"] = str(len(skill_names)) + result["runtime_views"] = ",".join(runtime_views) + print(json.dumps(result, sort_keys=True)) + return 0 + + +def _receipt_path_is_allowed(path: Path, roots: Mapping[str, Path]) -> bool: + allowed = [ + roots["runtime_home"], + roots["product_config"], + roots["crafted_home"], + roots["launcher_home"], + ] + if sys.platform == "darwin": + allowed.extend( + [ + Path.home() / "Library/LaunchAgents", + Path.home() / "Library/Caches/io.vetcoders.vc-frame", + Path(f"/tmp/vc-frame-{os.getuid()}"), + ] + ) + # Preserve the final component so a receipt cannot make an outside path + # appear managed merely by pointing its symlink into an allowed root. + resolved = _canonical_path_preserving_final_symlink(path) + return any( + resolved == root.resolve(strict=False) + or _is_subpath(resolved, root.resolve(strict=False)) + for root in allowed + ) + + +def _runtime_projection_roots() -> tuple[Path, ...]: + roots = [runtime_skills_dir(runtime) for runtime in STANDARD_VIEW_RUNTIMES] + roots.extend( + runtime_commands_dir(runtime) + for runtime in STANDARD_VIEW_RUNTIMES + if _agent_command_payloads(runtime) + ) + return tuple(roots) + + +def _receipt_projection_path_is_allowed(path: Path) -> bool: + """Allow only files below the exact agent discovery roots we project.""" + canonical = _canonical_path_preserving_final_symlink(path) + return any( + canonical == root.resolve(strict=False) + or _is_subpath(canonical, root.resolve(strict=False)) + for root in _runtime_projection_roots() + ) + + +def _receipt_empty_projection_dir_is_allowed(path: Path) -> bool: + """Empty-dir cleanup may also prune agent parents created by this install.""" + home = Path.home().resolve(strict=False) + allowed: set[Path] = set() + for root in _runtime_projection_roots(): + cursor = root.resolve(strict=False) + while cursor != home and _is_subpath(cursor, home): + allowed.add(cursor) + cursor = cursor.parent + return path.resolve(strict=False) in allowed + + +def _receipt_empty_dir_is_allowed(path: Path, roots: Mapping[str, Path]) -> bool: + """Allow receipted empty projection dirs and created HOME root ancestors.""" + if _receipt_path_is_allowed( + path, roots + ) or _receipt_empty_projection_dir_is_allowed(path): + return True + home = Path.home().resolve(strict=False) + candidate = path.resolve(strict=False) + allowed: set[Path] = set() + for root in roots.values(): + cursor = root.resolve(strict=False) + while cursor != home and _is_subpath(cursor, home): + allowed.add(cursor) + cursor = cursor.parent + return candidate in allowed + + +def _receipt_app_root(receipt: Mapping[str, Any]) -> Path | None: + """Return the receipted GUI carrier root after a narrow product-name check.""" + raw_root = str(receipt.get("app_root", "")).strip() + if not raw_root: + return None + root = Path(raw_root).expanduser() + if ( + not root.is_absolute() + or root.suffix != ".app" + or not root.name.startswith("Vibecrafted") + ): + raise RuntimeError(f"receipt app root is not a Vibecrafted app: {raw_root}") + return root.resolve(strict=False) + + +def cmd_runtime_uninstall(args: argparse.Namespace) -> int: + """Undo one Runtime Pack install from its ownership receipt.""" + paths = _runtime_install_paths() + runtime_home = paths["runtime_home"] + receipt_path = _runtime_receipt_path(runtime_home) + receipt = _load_runtime_install_receipt(receipt_path) + if not receipt: + if getattr(args, "emit_result", True): + print( + json.dumps( + { + "schema": "vibecrafted.runtime-uninstall-result.v1", + "status": "absent", + } + ) + ) + return 0 + recorded_roots = { + name: Path(value) for name, value in receipt.get("roots", {}).items() + } + if recorded_roots != paths: + raise RuntimeError( + "runtime install receipt roots do not match the current environment" + ) + + dry_run = bool(args.dry_run) + actions: list[str] = [] + owned_files = receipt.get("owned_files", {}) + owned_symlinks = receipt.get("owned_symlinks", {}) + for raw_path in owned_files: + path = Path(raw_path) + if not ( + _receipt_path_is_allowed(path, paths) + or _receipt_projection_path_is_allowed(path) + ): + raise RuntimeError(f"receipt path escapes managed roots: {raw_path}") + for raw_path, raw_target in owned_symlinks.items(): + path = Path(raw_path) + target = Path(raw_target).resolve(strict=False) + if not ( + _receipt_path_is_allowed(path, paths) + or _receipt_projection_path_is_allowed(path) + ): + raise RuntimeError(f"receipt symlink escapes managed roots: {raw_path}") + releases = runtime_home / "releases" + if target != releases.resolve(strict=False) and not _is_subpath( + target, releases.resolve(strict=False) + ): + raise RuntimeError(f"receipt symlink target escapes releases: {raw_target}") + for raw_path in receipt.get("owned_dirs", []): + if not _receipt_path_is_allowed(Path(raw_path), paths): + raise RuntimeError(f"receipt path escapes managed roots: {raw_path}") + for raw_path in receipt.get("owned_empty_dirs", []): + if not _receipt_empty_dir_is_allowed(Path(raw_path), paths): + raise RuntimeError( + f"receipt empty directory escapes projection roots: {raw_path}" + ) + backup_root = runtime_home / ".installer-backups" + for destination_raw, backup_raw in receipt.get("backups", {}).items(): + destination = Path(destination_raw) + backup = Path(backup_raw) + if not ( + _receipt_path_is_allowed(destination, paths) + or _receipt_projection_path_is_allowed(destination) + ): + raise RuntimeError( + f"receipt restore path escapes managed roots: {destination}" + ) + if not _receipt_backup_path_is_allowed(backup, backup_root): + raise RuntimeError(f"receipt backup path escapes backup root: {backup}") + conflicts = [ + raw_path + for raw_path, installed_hash in sorted(owned_files.items()) + if (path := Path(raw_path)).is_file() + and not path.is_symlink() + and _sha256_path(path) != installed_hash + ] + conflicts.extend( + raw_path + for raw_path, raw_target in sorted(owned_symlinks.items()) + if _path_present(path := Path(raw_path)) + and ( + not path.is_symlink() + or _symlink_target(path) != Path(raw_target).resolve(strict=False) + ) + ) + if conflicts: + result = { + "schema": "vibecrafted.runtime-uninstall-result.v1", + "status": "conflict", + "actions": [], + "conflicts": conflicts, + } + if getattr(args, "emit_result", True): + print(json.dumps(result, sort_keys=True)) + return 1 + runtime_actions = _teardown_owned_runtime_for_uninstall( + paths["crafted_home"], + dry_run=dry_run, + app_root=_receipt_app_root(receipt), + ) + actions.extend(runtime_actions) + + for raw_path in sorted(owned_symlinks, reverse=True): + path = Path(raw_path) + if not _path_present(path): + continue + actions.append(f"remove {path}") + if not dry_run: + _remove_path(path) + + for raw_path, installed_hash in sorted(owned_files.items(), reverse=True): + path = Path(raw_path) + if not ( + _receipt_path_is_allowed(path, paths) + or _receipt_projection_path_is_allowed(path) + ): + raise RuntimeError(f"receipt path escapes managed roots: {path}") + if not _path_present(path): + continue + actions.append(f"remove {path}") + if not dry_run: + _remove_path(path) + + for raw_path in sorted(receipt.get("owned_dirs", []), key=len, reverse=True): + path = Path(raw_path) + if not _receipt_path_is_allowed(path, paths): + raise RuntimeError(f"receipt path escapes managed roots: {path}") + if _path_present(path): + actions.append(f"remove {path}") + if not dry_run: + _remove_path(path) + + if sys.platform == "darwin": + runtime_surfaces = [ + Path.home() / "Library/LaunchAgents/io.vetcoders.vibecrafted.server.plist", + Path.home() / "Library/Caches/io.vetcoders.vc-frame", + Path(f"/tmp/vc-frame-{os.getuid()}"), + ] + for path in runtime_surfaces: + if _path_present(path): + actions.append(f"remove {path}") + if not dry_run: + _remove_path(path) + + for destination_raw, backup_raw in receipt.get("backups", {}).items(): + destination = Path(destination_raw) + backup = Path(backup_raw) + if _path_present(backup): + actions.append(f"restore {destination}") + if not dry_run: + destination.parent.mkdir(parents=True, exist_ok=True) + _restore_path_from_backup(backup, destination) + + roots_created = receipt.get("roots_created", {}) + for name in ("product_config", "crafted_home", "runtime_home", "launcher_home"): + root = paths[name] + if not roots_created.get(name) or not root.exists(): + continue + if name == "launcher_home" and any(root.iterdir()): + continue + if name == "product_config" and any(root.iterdir()): + continue + actions.append(f"remove {root}") + if not dry_run: + _remove_path(root) + + # Remove root ancestors only after the exact owned roots are gone. Running + # this earlier leaves ~/.local/share and ~/.config behind even though the + # receipt proves that this installer created them. + for raw_path in sorted( + receipt.get("owned_empty_dirs", []), + key=lambda value: len(Path(value).parts), + reverse=True, + ): + path = Path(raw_path) + if dry_run and path.is_dir() and not path.is_symlink(): + actions.append(f"remove if empty {path}") + elif path.is_dir() and not path.is_symlink() and not any(path.iterdir()): + actions.append(f"remove empty {path}") + path.rmdir() + + if not dry_run and receipt_path.exists(): + receipt_path.unlink() + if backup_root.exists() and not conflicts: + shutil.rmtree(backup_root) + if ( + roots_created.get("runtime_home") + and runtime_home.exists() + and not any(runtime_home.iterdir()) + ): + runtime_home.rmdir() + + result = { + "schema": "vibecrafted.runtime-uninstall-result.v1", + "status": "dry-run" if dry_run else ("conflict" if conflicts else "removed"), + "actions": actions, + "conflicts": conflicts, + } + if getattr(args, "emit_result", True): + print(json.dumps(result, sort_keys=True)) + return 1 if conflicts else 0 + + # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- @@ -14261,6 +15623,23 @@ def main(argv: Sequence[str] | None = None) -> int: "--dry-run", "-n", action="store_true", help="Show what would be done" ) + # runtime pack — the same installer entrypoint is embedded in the DMG and + # remains usable by non-GUI channels. + p_runtime_install = sub.add_parser( + "runtime-install", help="Install a signed/offline Vibecrafted Runtime Pack" + ) + p_runtime_install.add_argument("--payload-root", required=True) + p_runtime_install.add_argument("--app-root") + p_runtime_install.add_argument("--terminal-host") + p_runtime_install.add_argument("--frame-helper") + + p_runtime_uninstall = sub.add_parser( + "runtime-uninstall", help="Undo the receipted Runtime Pack install" + ) + p_runtime_uninstall.add_argument( + "--dry-run", "-n", action="store_true", help="Show what would be done" + ) + args = parser.parse_args(argv) if not args.command: parser.print_help() @@ -14278,6 +15657,10 @@ def main(argv: Sequence[str] | None = None) -> int: return cmd_uninstall(args) elif args.command == "restore": return cmd_restore(args) + elif args.command == "runtime-install": + return cmd_runtime_install(args) + elif args.command == "runtime-uninstall": + return cmd_runtime_uninstall(args) return 0 diff --git a/scripts/vibecrafted b/scripts/vibecrafted index f1ab0cc2..88099061 100755 --- a/scripts/vibecrafted +++ b/scripts/vibecrafted @@ -8,6 +8,7 @@ _vc_source_launcher_ulimits() { script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" for candidate in \ "$script_dir/../runtime/scripts/lib/ulimits.sh" \ + "$script_dir/../vibecrafted-core/vibecrafted_core/runtime/scripts/lib/ulimits.sh" \ "${VIBECRAFTED_ROOT:-}/vibecrafted-core/vibecrafted_core/runtime/scripts/lib/ulimits.sh" \ "${VIBECRAFTED_TOOLS_HOME:-${XDG_DATA_HOME:-$HOME/.local/share}/vibecrafted/tools}/vibecrafted-current/vibecrafted-core/vibecrafted_core/runtime/scripts/lib/ulimits.sh" \ "${VIBECRAFTED_HOME:-$HOME/.vibecrafted}/runtime/scripts/lib/ulimits.sh"; do @@ -112,6 +113,20 @@ _script_owner_root() { cd "$script_dir/.." && pwd } +_runtime_payload_root() { + local candidate="${VIBECRAFTED_RUNTIME_ROOT:-}" + if [[ -n "$candidate" && -x "$candidate/bin/vc-server" && -d "$candidate/server/site" ]]; then + printf '%s\n' "$candidate" + return 0 + fi + candidate="$(_script_owner_root)" + if [[ -x "$candidate/bin/vc-server" && -d "$candidate/server/site" ]]; then + printf '%s\n' "$candidate" + return 0 + fi + return 1 +} + _package_resource_root() { local script_root candidate script_root="$(_script_repo_root)" @@ -446,9 +461,31 @@ _dispatcher_core_dir() { # Resolve the interpreter that owns vibecrafted_core by reading the shebang # from the uv-tool shim on PATH, since it carries the correct uv python. +# An inherited VIBECRAFTED_PYTHON is honoured only when it lives inside the +# generation this deck serves (vibecrafted-current). A product session pins it +# to the release generation, whose bin/python3 wrapper overwrites PYTHONPATH +# with its own vibecrafted-core; running this generation's core through it +# loads a foreign core + installer and yields phantom doctor findings. +_vibecrafted_python_owned_by_current() { + local candidate="$1" candidate_real current_real deck_real + [[ -d "$crafted_tools" ]] || return 0 + current_real="$(cd "$crafted_tools" 2>/dev/null && pwd -P)" || return 1 + # Only an *installed* deck (living inside vibecrafted-current) is exposed to + # a foreign product-session interpreter; a checkout deck keeps honouring an + # explicit VIBECRAFTED_PYTHON (dev runs, tests). + # ~/.local/bin/vibecrafted is a symlink into the generation: resolve the + # deck file itself, not the directory the symlink sits in. + deck_real="$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null)" || return 0 + deck_real="$(cd "$(dirname "$deck_real")" 2>/dev/null && pwd -P)" || return 0 + [[ "$deck_real" == "$current_real" || "$deck_real" == "$current_real/"* ]] || return 0 + candidate_real="$(cd "$(dirname "$candidate")" 2>/dev/null && pwd -P)/$(basename "$candidate")" || return 1 + [[ "$candidate_real" == "$current_real/"* ]] +} + _vibecrafted_python() { local resolved shebang py - if [[ -n "${VIBECRAFTED_PYTHON:-}" && -x "$VIBECRAFTED_PYTHON" ]]; then + if [[ -n "${VIBECRAFTED_PYTHON:-}" && -x "$VIBECRAFTED_PYTHON" ]] \ + && _vibecrafted_python_owned_by_current "$VIBECRAFTED_PYTHON"; then printf '%s\n' "$VIBECRAFTED_PYTHON" return 0 fi @@ -979,16 +1016,25 @@ cmd_init_help() { printf ' Start an interactive repository orientation session with an agent.\n' printf '\n' printf '%bUsage:%b\n' "$_bold" "$_reset" - printf ' vibecrafted init [claude|codex|agy|junie|grok] [--runtime terminal|visible|plain] [--prompt ""]\n' + printf ' vibecrafted init [claude|codex|agy|junie|grok] [--runtime terminal|visible|plain] [--policy-runtime local-native|local-worktrees|local-vm|cloud-soon] [--permissions bypass|auto|accept-edits|read-only] [--token-budget safe|unlimited|N] [--continuity full-lineage|fresh|bare-fork] [--prompt ""]\n' printf ' vc-init [claude|codex|agy|junie|grok]\n' printf '\n' printf '%bRuntime:%b\n' "$_bold" "$_reset" printf ' terminal (default) open a tab in the vc-frame cockpit; falls back to plain when vc-frame is absent\n' printf ' plain start the agent in this terminal, no cockpit needed\n' + printf ' Policy runtime defaults to local-native; unavailable runtimes fail closed.\n' + printf ' Permission support is provider-specific; unsupported cells are rejected.\n' + printf ' Token budget defaults to safe (250000 measured tokens); N sets a bounded budget.\n' + printf ' unlimited is restricted to directly observed local-native sessions and still measures usage.\n' + printf ' Measured quota currently requires a verified Claude transcript capability; unsupported providers fail closed.\n' + printf ' Continuity defaults to fresh in the shell. full-lineage requires materialized AICX + active LOOP + parent lineage.\n' + printf ' bare-fork is expert-only and requires --parent-session .\n' printf '\n' printf '%bExamples:%b\n' "$_bold" "$_reset" printf ' vibecrafted init claude\n' printf ' vibecrafted init claude --runtime plain\n' + printf ' vibecrafted init claude --runtime plain --policy-runtime local-native --permissions accept-edits\n' + printf ' vibecrafted init claude --continuity full-lineage --continuity-parent \n' printf ' vc-init codex\n' printf '\n' } @@ -1065,7 +1111,7 @@ cmd_server_help() { printf '\n' printf '%bUsage:%b\n' "$_bold" "$_reset" printf ' vibecrafted server [start|stop|status|open|doctor] [options]\n' - printf ' vibecrafted server service [install|start|stop|status|uninstall] [options]\n' + printf ' vibecrafted server service [install|reconcile|restart|start|stop|status|logs|uninstall] [options]\n' printf '\n' printf '%bOptions:%b\n' "$_bold" "$_reset" printf ' --port, -p Specify the port (default: 3024)\n' @@ -1077,6 +1123,7 @@ cmd_server_help() { printf ' vibecrafted server service start\n' printf ' vibecrafted server status\n' printf ' vibecrafted server service stop\n' + printf ' vibecrafted server service logs --json\n' printf '\n' } @@ -3510,6 +3557,14 @@ cmd_server() { fi IFS=$'\t' read -r host port <<< "$endpoint" + local runtime_root server_bin site_root + runtime_root="$(_runtime_payload_root 2>/dev/null || true)" + server_bin="${runtime_root:+$runtime_root/bin/vc-server}" + site_root="${runtime_root:+$runtime_root/server/site}" + [[ -n "$server_bin" ]] || server_bin="$HOME/.local/bin/vc-server" + [[ -n "$site_root" ]] || \ + site_root="${VIBECRAFTED_RUNTIME_HOME:-$HOME/.local/share/vibecrafted}/server/site" + # 1. Check if already running local runtime_dir runtime_dir="$(_server_runtime_dir)" @@ -3520,10 +3575,10 @@ cmd_server() { existing_pid="$(_pid_file_value "$pid_file" 2>/dev/null || true)" existing_state="$(_managed_pid_state "$pid_file" "server")" if [[ "$existing_state" == "foreign" ]] && \ - [[ -x "$HOME/.local/bin/vc-server" ]] && \ + [[ -x "$server_bin" ]] && \ _adopt_launch_witness \ - server "$existing_pid" "$HOME/.local/bin/vc-server" \ - "$HOME/.local/bin/vc-server"; then + server "$existing_pid" "$server_bin" \ + "$server_bin"; then existing_state="$(_managed_pid_state "$pid_file" "server")" fi if [[ "$existing_state" == "live" ]]; then @@ -3549,8 +3604,8 @@ cmd_server() { if ! _stop_managed_process server Server; then if [[ -f "$(_server_launch_witness_file server)" ]] && \ _adopt_launch_witness \ - server "$existing_pid" "$HOME/.local/bin/vc-server" \ - "$HOME/.local/bin/vc-server"; then + server "$existing_pid" "$server_bin" \ + "$server_bin"; then _stop_managed_process server Server || return 1 else return 1 @@ -3585,13 +3640,11 @@ cmd_server() { fi # 3. Locate binary & site root - local server_bin="$HOME/.local/bin/vc-server" if [[ ! -x "$server_bin" ]]; then printf "%b✗%b vc-server binary not found at %s. Please run 'make install-all' or 'make install-server' first.\n" "$_red" "$_reset" "$server_bin" >&2 return 1 fi - local site_root="${VIBECRAFTED_RUNTIME_ROOT:-${VIBECRAFTED_RUNTIME_HOME:-$HOME/.local/share/vibecrafted}}/server/site" if [[ ! -d "$site_root" ]]; then printf "%b✗%b site assets directory not found at %s. Please run 'make install-all' or 'make install-server' first.\n" "$_red" "$_reset" "$site_root" >&2 return 1 @@ -3888,7 +3941,13 @@ cmd_server() { _server_supervisor_cli runtime-status || has_errors=1 # 1. Binary check - local server_bin="$HOME/.local/bin/vc-server" + local runtime_root server_bin site_root + runtime_root="$(_runtime_payload_root 2>/dev/null || true)" + server_bin="${runtime_root:+$runtime_root/bin/vc-server}" + site_root="${runtime_root:+$runtime_root/server/site}" + [[ -n "$server_bin" ]] || server_bin="$HOME/.local/bin/vc-server" + [[ -n "$site_root" ]] || \ + site_root="${VIBECRAFTED_RUNTIME_HOME:-$HOME/.local/share/vibecrafted}/server/site" if [[ -f "$server_bin" ]]; then if [[ -x "$server_bin" ]]; then printf " [%b✓%b] Binary present and executable at %s\n" "$_green" "$_reset" "$server_bin" @@ -3916,7 +3975,6 @@ cmd_server() { fi # 2. Site root & Fonts check - local site_root="${VIBECRAFTED_RUNTIME_ROOT:-${VIBECRAFTED_RUNTIME_HOME:-$HOME/.local/share/vibecrafted}}/server/site" if [[ -d "$site_root" ]]; then printf " [%b✓%b] Site root present at %s\n" "$_green" "$_reset" "$site_root" if [[ -d "$site_root/fonts" ]]; then @@ -5068,10 +5126,11 @@ cmd_uninstall() { # Bypass make for the same reason as cmd_doctor: prevent the # `make: Entering directory` and `*** Error N` traceback from # bleeding into the user-facing uninstall transcript. - local source_root="" installer="" + local source_root="" installer="" installer_python="python3" source_root="$(_repo_source_root 2>/dev/null || true)" for candidate in \ "${source_root:+$source_root/scripts/vetcoders_install.py}" \ + "${VIBECRAFTED_RUNTIME_ROOT:+$VIBECRAFTED_RUNTIME_ROOT/scripts/vetcoders_install.py}" \ "$crafted_tools/scripts/vetcoders_install.py" \ "$(_repo_root 2>/dev/null)/scripts/vetcoders_install.py"; do [[ -n "$candidate" && -f "$candidate" ]] && installer="$candidate" && break @@ -5080,9 +5139,14 @@ cmd_uninstall() { printf '%b✗%b Uninstall installer not found.\n' "$_red" "$_reset" >&2 return 1 } + if [[ -n "${VIBECRAFTED_RUNTIME_ROOT:-}" \ + && "$installer" == "$VIBECRAFTED_RUNTIME_ROOT/scripts/vetcoders_install.py" \ + && -x "$VIBECRAFTED_RUNTIME_ROOT/bin/python3" ]]; then + installer_python="$VIBECRAFTED_RUNTIME_ROOT/bin/python3" + fi # Forward argv verbatim: swallowing flags here once turned # `vibecrafted uninstall --dry-run` into a real, unconfirmed teardown. - python3 "$installer" uninstall "$@" + "$installer_python" "$installer" uninstall "$@" } _fetch_channel_version() { diff --git a/tests/tui/test_distribution_manifest.py b/tests/tui/test_distribution_manifest.py index d48a8ca8..82d16002 100644 --- a/tests/tui/test_distribution_manifest.py +++ b/tests/tui/test_distribution_manifest.py @@ -36,8 +36,8 @@ "install.ps1", "install.toml", "scripts/distribution_manifest.py", + "scripts/build-linux-arm64-runtime-pack.sh", "scripts/vetcoders_install.py", - "scripts/runtime_paths.py", "scripts/vibecrafted", "scripts/verify-vibecrafted-product.sh", "vibecrafted-core/pyproject.toml", @@ -56,6 +56,9 @@ "vibecrafted-app/Cargo.lock", "vibecrafted-server/Cargo.toml", "vibecrafted-server/Cargo.lock", + "vibecrafted-vm/RuntimePack.Containerfile", + "vibecrafted-vm/runtime-entry.sh", + "vibecrafted-vm/runtime-provider-lock.json", } @@ -186,6 +189,43 @@ def test_load_source_provenance_accepts_only_the_exact_closed_record( ) +def test_carrier_cli_uses_git_objects_without_materializing_host_junk( + tmp_path: Path, +) -> None: + source = tmp_path / "source" + revision = _committed_git_source(source) + (source / ".DS_Store").write_bytes(b"ignored host metadata") + output = tmp_path / "carrier" / manifest.SOURCE_PROVENANCE_FILE + + result = subprocess.run( + [ + sys.executable, + str(REPO_ROOT / "scripts" / "distribution_manifest.py"), + "carrier", + "--source", + str(source), + "--output", + str(output), + "--owner-repo", + SOURCE_OWNER_REPO, + "--source-revision", + revision, + ], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + payload = json.loads(output.read_text(encoding="utf-8")) + assert payload["source_revision"] == revision + assert payload["owner_repo"] == SOURCE_OWNER_REPO + assert payload["payload"] == manifest._distribution_tree_record_from_git( + source, revision + ) + assert output.stat().st_mode & 0o777 == 0o644 + + @pytest.mark.parametrize( "payload", [ @@ -1417,7 +1457,7 @@ def test_archive_accepts_clean_committed_included_payload( ("mutation", "relative"), [ ("tracked", "scripts/vetcoders_install.py"), - ("index", "scripts/runtime_paths.py"), + ("index", "scripts/verify-vibecrafted-product.sh"), ("deleted", "scripts/distribution_manifest.py"), ], ) diff --git a/tests/tui/test_install_bootstrap.py b/tests/tui/test_install_bootstrap.py index a7531af4..8e1363ac 100644 --- a/tests/tui/test_install_bootstrap.py +++ b/tests/tui/test_install_bootstrap.py @@ -32,6 +32,8 @@ "install.ps1", "install.toml", "scripts/distribution_manifest.py", + "scripts/build-linux-arm64-runtime-pack.sh", + "scripts/installer_brand.py", "scripts/vetcoders_install.py", "scripts/vibecrafted", "scripts/verify-vibecrafted-product.sh", @@ -49,6 +51,9 @@ "vibecrafted-app/Cargo.lock", "vibecrafted-server/Cargo.toml", "vibecrafted-server/Cargo.lock", + "vibecrafted-vm/RuntimePack.Containerfile", + "vibecrafted-vm/runtime-entry.sh", + "vibecrafted-vm/runtime-provider-lock.json", } FIXTURE_REQUIRED_SURFACES = { "bin/vc-workflow", @@ -358,6 +363,8 @@ def test_install_sh_blocks_raw_github_fallback_without_channel_archive( text = INSTALL_SH.read_text(encoding="utf-8") assert 'channel_url="https://vibecrafted.io/channel/${ref}.json"' in text + assert "p.get('runtime_pack_url','')" in text + assert "must bind archive_url and runtime_pack_url" in text assert "refusing the untrusted raw GitHub fallback" in text assert "W4 release authentication blocker" in text assert "archive/refs/heads/${ref}.tar.gz" not in text diff --git a/tests/tui/test_installer_doctor.py b/tests/tui/test_installer_doctor.py index cbe0d16e..d70e59dc 100644 --- a/tests/tui/test_installer_doctor.py +++ b/tests/tui/test_installer_doctor.py @@ -5,6 +5,7 @@ import shutil import struct import subprocess +import sys from argparse import Namespace from pathlib import Path @@ -524,6 +525,10 @@ def test_cmd_doctor_fix_launchers_repairs_missing_wrappers( detached_source = tmp_path / "detached-source" installer.stage_distribution_payload(REPO_ROOT, detached_source, mirror=True) (detached_source / "VERSION").write_text("1.4.1-test\n", encoding="utf-8") + _write_executable( + detached_source / "bin/python3", + f'#!/bin/sh\nexec {installer.shlex_quote(str(Path(sys.executable).absolute()))} "$@"\n', + ) _write_test_source_provenance(detached_source) monkeypatch.setattr( installer, "_doctor_launcher_source_root", lambda _store: detached_source @@ -798,6 +803,11 @@ def test_install_launcher_does_not_overwrite_unmanaged_dev_wrapper( installed_deck, (REPO_ROOT / "scripts" / "vibecrafted").read_text(encoding="utf-8"), ) + installed_entrypoint = ( + runtime_home / "tools" / "vibecrafted-current" / "bin" / "vibecrafted" + ) + installed_entrypoint.parent.mkdir(parents=True, exist_ok=True) + _write_executable(installed_entrypoint, installed_deck.read_text(encoding="utf-8")) installer._install_launcher(source_root, dry_run=False, update_rc=False) @@ -862,14 +872,22 @@ def test_installer_doctor_fails_when_walkaround_runner_launcher_is_missing() -> _RUNTIME_GENERATION_FIXTURE_SOURCES = { Path("VERSION"): Path("VERSION"), + Path("scripts/distribution_manifest.py"): Path("scripts/distribution_manifest.py"), + Path("scripts/installer_brand.py"): Path("scripts/installer_brand.py"), + Path("scripts/vetcoders_install.py"): Path("scripts/vetcoders_install.py"), Path("scripts/vibecrafted"): Path("scripts/vibecrafted"), Path( "vibecrafted-core/vibecrafted_core/runtime/generated/vc-frame/config.kdl" ): Path("vibecrafted-core/vibecrafted_core/config/vc-frame/config.kdl"), - installer._RUNTIME_GENERATION_ENTRYPOINT: installer._RUNTIME_GENERATION_ENTRYPOINT, + installer._RUNTIME_GENERATION_ENTRYPOINT: Path( + "vibecrafted-core/vibecrafted_core/deck/vibecrafted" + ), Path("vibecrafted-core/vibecrafted_core/product_contract.py"): Path( "vibecrafted-core/vibecrafted_core/product_contract.py" ), + Path("vibecrafted-core/vibecrafted_core/runtime_pack_contract.py"): Path( + "vibecrafted-core/vibecrafted_core/runtime_pack_contract.py" + ), Path("vibecrafted-core/vibecrafted_core/walkaround_runner.py"): Path( "vibecrafted-core/vibecrafted_core/walkaround_runner.py" ), @@ -889,7 +907,7 @@ def _write_release_contract_runtime_manifest( current_tools: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - assert len(_RUNTIME_GENERATION_FIXTURE_SOURCES) == 9 + assert len(_RUNTIME_GENERATION_FIXTURE_SOURCES) == 13 assert ( frozenset(_RUNTIME_GENERATION_FIXTURE_SOURCES) == installer._RUNTIME_GENERATION_REQUIRED_HASHES @@ -899,6 +917,13 @@ def _write_release_contract_runtime_manifest( target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(REPO_ROOT / source_relative, target) + runtime_python = current_tools / "bin/python3" + runtime_python.parent.mkdir(parents=True, exist_ok=True) + _write_executable( + runtime_python, + f'#!/bin/sh\nexec {installer.shlex_quote(str(Path(sys.executable).absolute()))} "$@"\n', + ) + provenance = _write_test_source_provenance(current_tools) monkeypatch.delenv("VIBECRAFTED_SOURCE_OWNER_REPO", raising=False) @@ -911,6 +936,54 @@ def _write_release_contract_runtime_manifest( ) +def test_runtime_semantic_verifier_uses_candidate_interpreter( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + candidate_python = tmp_path / "runtime/bin/python3" + observed: list[str] = [] + + def fake_run(argv, **_kwargs): + observed.extend(str(part) for part in argv) + return subprocess.CompletedProcess(argv, 0, "ok\n", "") + + monkeypatch.setattr(installer.subprocess, "run", fake_run) + + installer._run_runtime_verifier_semantic_command( + ["product-contract.py", "--help"], + cache=tmp_path / "cache", + python_executable=candidate_python, + ) + + assert observed[0] == str(candidate_python) + assert observed[1:4] == ["-I", "-S", "-B"] + assert str(Path(sys.executable)) not in observed[:1] + + +def test_runtime_verifier_python_falls_back_only_when_no_interpreter_is_carried( + tmp_path: Path, +) -> None: + source_staged = tmp_path / "staged" + (source_staged / "bin").mkdir(parents=True) + assert installer._runtime_verifier_python(source_staged) == Path(sys.executable) + + carried = tmp_path / "pack" + carried_python = carried / "bin/python3" + carried_python.parent.mkdir(parents=True) + carried_python.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + carried_python.chmod(0o755) + assert installer._runtime_verifier_python(carried) == carried_python + + carried_python.chmod(0o644) + with pytest.raises(OSError, match="not executable"): + installer._runtime_verifier_python(carried) + + dangling = tmp_path / "dangling" + (dangling / "bin").mkdir(parents=True) + (dangling / "bin/python3").symlink_to(tmp_path / "missing-interpreter") + with pytest.raises(OSError, match="not executable"): + installer._runtime_verifier_python(dangling) + + def test_installer_release_contract_assets_fail_closed_for_missing_or_exact_byte_drift( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -1066,6 +1139,7 @@ def test_release_contract_inventory_names_runner_schema_policy_and_key() -> None assert "verify-vibecrafted-walkaround" in installer.PYTHON_ENTRYPOINT_LAUNCHERS assert installer.RELEASE_CONTRACT_PACKAGE_ASSETS == ( "product_contract.py", + "runtime_pack_contract.py", "walkaround_runner.py", "schemas/unified_product.schema.v1.json", "trust/release-policy.v1.json", @@ -1963,7 +2037,7 @@ def test_vc_frame_delivery_stale_file_fails_view(tmp_path, monkeypatch): home.mkdir() tools = home / ".local" / "share" / "vibecrafted" / "tools" monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) - view = home / ".config" / "vc-frame" + view = home / ".config" / "vibecrafted" / "vc-frame" view.mkdir(parents=True) (view / "config.kdl").write_text('theme "choinka"\n', encoding="utf-8") (view / "layouts").mkdir() @@ -1999,7 +2073,7 @@ def test_vc_frame_delivery_pane_shell_warn_when_zsh_missing_and_layouts_unsubsti tools = home / ".local" / "share" / "vibecrafted" / "tools" monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) # Unsubstituted layouts (dev-style view pointing at raw kdl with zsh) - view = home / ".config" / "vc-frame" + view = home / ".config" / "vibecrafted" / "vc-frame" layouts = view / "layouts" layouts.mkdir(parents=True) (view / "config.kdl").write_text( diff --git a/tests/tui/test_installer_uninstall.py b/tests/tui/test_installer_uninstall.py index b6e01320..c2b181c0 100644 --- a/tests/tui/test_installer_uninstall.py +++ b/tests/tui/test_installer_uninstall.py @@ -19,6 +19,47 @@ def _isolate_uninstall_from_live_runtime(monkeypatch) -> None: monkeypatch.setattr(installer, "_runtime_service_snapshot", lambda _home: None) monkeypatch.setattr(installer, "_darwin_process_ids", tuple) + def materialize(root: Path) -> None: + source = root / "vibecrafted-core/vibecrafted_core/config/vc-frame" + for destination in ( + root / "vibecrafted-core/vibecrafted_core/runtime/generated/vc-frame", + ): + destination.mkdir(parents=True, exist_ok=True) + (destination / "config.kdl").write_text( + (source / "config.kdl").read_text(encoding="utf-8"), + encoding="utf-8", + ) + (destination / "layouts").mkdir() + (destination / "themes").mkdir() + (destination / "vc-composer.sh").write_text("#!/bin/sh\n", encoding="utf-8") + + monkeypatch.setattr(installer, "_materialize_vc_frame_generation", materialize) + monkeypatch.setattr( + installer, + "load_source_provenance", + lambda _root: { + "schema": "vibecrafted.source-provenance.v2", + "owner_repo": "vetcoders/vibecrafted", + "source_revision": "1" * 40, + "payload": { + "schema": "vibecrafted.distribution-tree.v1", + "algorithm": "sha256-path-mode-content-v1", + "tree_sha256": "2" * 64, + "entry_count": 1, + }, + }, + ) + monkeypatch.setattr( + installer, + "_write_runtime_generation_manifest", + lambda root, **_kwargs: (root / "runtime-manifest.json").write_text( + "{}\n", encoding="utf-8" + ), + ) + monkeypatch.setattr( + installer, "_runtime_generation_payload_errors", lambda _root: [] + ) + def _write_executable(path: Path, body: str | None = None) -> None: path.parent.mkdir(parents=True, exist_ok=True) @@ -26,6 +67,568 @@ def _write_executable(path: Path, body: str | None = None) -> None: path.chmod(0o755) +def _runtime_pack_fixture(root: Path) -> tuple[Path, Path, Path]: + payload = root / "runtime-pack" + for name in ( + "vibecrafted", + "loct", + "loctree-mcp", + "aicx", + "aicx-mcp", + "prview", + "screenscribe", + "vc-server", + "vc-server-supervisor", + "vc-start", + "vc-workflow", + ): + _write_executable(payload / "bin" / name) + _write_executable(payload / "vibecrafted-core/vibecrafted_core/deck/vibecrafted") + (payload / "VERSION").write_text("9.9.9+g12345678\n", encoding="utf-8") + terminal_root = payload / "config/vc-terminal" + (terminal_root / "themes").mkdir(parents=True) + (terminal_root / "vibecrafted.toml").write_text("[window]\n", encoding="utf-8") + (terminal_root / "themes/dark.toml").write_text( + "[colors.primary]\nbackground = '#000000'\n", encoding="utf-8" + ) + frame_config = payload / "vibecrafted-core/vibecrafted_core/config/vc-frame" + frame_config.mkdir(parents=True) + (frame_config / "config.kdl").write_text("// frame\n", encoding="utf-8") + _write_executable( + payload / "scripts/vc-frame-product-entry.sh", + "#!/usr/bin/env bash\npin_darwin_socket_dir() { :; }\n" + 'exec "$VIBECRAFTED_VC_FRAME_BIN" "$@"\n', + ) + shell = payload / "vibecrafted-core/vibecrafted_core/runtime/shell" + shell.mkdir(parents=True) + (shell / "vetcoders.sh").write_text("# shell\n", encoding="utf-8") + skills = payload / "vibecrafted-core/vibecrafted_core/skills" + for name in ("vc-audit", "vc-implement"): + skill = skills / name + skill.mkdir(parents=True) + (skill / "SKILL.md").write_text(f"# {name}\n", encoding="utf-8") + (skills / "VERIFICATION_RULE.md").write_text( + "# Verification rule\n", encoding="utf-8" + ) + _write_executable(payload / "config/alacritty/launch-primary-shell.zsh") + terminal_host = root / "Vibecrafted.app/Contents/Helpers/vc-terminal" + frame_helper = root / "Vibecrafted.app/Contents/Helpers/vc-frame" + _write_executable(terminal_host) + _write_executable(frame_helper) + return payload, terminal_host, frame_helper + + +def test_runtime_pack_installer_and_uninstaller_round_trip_from_one_tool( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + home = tmp_path / "home" + runtime_home = home / ".local/share/vibecrafted" + launcher_home = home / ".local/bin" + crafted_home = home / ".vibecrafted" + config_home = home / ".config" + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("VIBECRAFTED_RUNTIME_HOME", str(runtime_home)) + monkeypatch.setenv("VIBECRAFTED_LAUNCHER_BIN", str(launcher_home)) + monkeypatch.setenv("VIBECRAFTED_HOME", str(crafted_home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(config_home)) + teardown_calls: list[tuple[bool, Path | None]] = [] + + def teardown( + _shared_home: Path, *, dry_run: bool, app_root: Path | None = None + ) -> tuple[str, ...]: + teardown_calls.append((dry_run, app_root)) + return ("terminate receipted app",) + + monkeypatch.setattr(installer, "_teardown_owned_runtime_for_uninstall", teardown) + payload, terminal_host, frame_helper = _runtime_pack_fixture(tmp_path) + + launcher_home.mkdir(parents=True) + original_launcher = launcher_home / "vc-start" + original_launcher.write_text("operator-owned\n", encoding="utf-8") + original_screenscribe_target = ( + home / ".local/share/uv/tools/screenscribe/bin/screenscribe" + ) + _write_executable(original_screenscribe_target, "#!/bin/sh\necho original\n") + original_screenscribe = launcher_home / "screenscribe" + original_screenscribe.symlink_to(original_screenscribe_target) + operator_skill = home / ".codex/skills/vc-audit" + operator_skill.parent.mkdir(parents=True) + operator_skill.write_text("operator-owned skill\n", encoding="utf-8") + unrelated_skill = home / ".codex/skills/operator-private" + unrelated_skill.write_text("preserve me\n", encoding="utf-8") + app_root = terminal_host.parents[2] + install_args = Namespace( + payload_root=str(payload), + app_root=str(app_root), + terminal_host=str(terminal_host), + frame_helper=str(frame_helper), + ) + + assert installer.cmd_runtime_install(install_args) == 0 + installed = json.loads(capsys.readouterr().out) + generation = runtime_home / "releases/9.9.9+g12345678" + assert Path(installed["root"]) == generation + assert Path(installed["frame"]) == generation / "libexec/vc-frame" + assert "pin_darwin_socket_dir" in (generation / "bin/vc-frame").read_text( + encoding="utf-8" + ) + assert (generation / "libexec/vc-frame").read_bytes() == frame_helper.read_bytes() + assert (generation / "bin/vc-terminal").read_bytes() == terminal_host.read_bytes() + assert (runtime_home / installer.RUNTIME_INSTALL_RECEIPT).is_file() + current = runtime_home / "tools/vibecrafted-current" + assert current.is_symlink() + assert current.resolve() == generation.resolve() + assert "VIBECRAFTED_RUNTIME_ROOT=" in original_launcher.read_text(encoding="utf-8") + assert (config_home / "vibecrafted/vc-frame/config.kdl").is_file() + for runtime in installer.STANDARD_VIEW_RUNTIMES: + for skill_name in ("vc-audit", "vc-implement"): + view = home / f".{runtime}/skills/{skill_name}" + assert view.is_symlink() + assert view.resolve() == ( + generation / "vibecrafted-core/vibecrafted_core/skills" / skill_name + ) + assert (home / f".{runtime}/skills/VERIFICATION_RULE.md").is_file() + for runtime, commands in installer.MARBLES_COMMANDS_BY_RUNTIME.items(): + for command in commands: + assert installer.AGENT_COMMAND_MARKER in ( + home / f".{runtime}/commands/{command}" + ).read_text(encoding="utf-8") + state = json.loads( + (crafted_home / installer.STATE_FILE).read_text(encoding="utf-8") + ) + assert state["framework_version"] == "9.9.9+g12345678" + assert state["skills"] == ["vc-audit", "vc-implement"] + assert state["runtimes"] == installer.STANDARD_VIEW_RUNTIMES + + # The app calls the installer on every launch. Reconciliation must retain + # first-install ownership so a later reset still returns to baseline. + assert installer.cmd_runtime_install(install_args) == 0 + capsys.readouterr() + + (crafted_home / "runtime-created-state").write_text("owned\n", encoding="utf-8") + assert ( + installer.cmd_runtime_uninstall(Namespace(dry_run=True, emit_result=True)) == 0 + ) + preview = json.loads(capsys.readouterr().out) + assert preview["status"] == "dry-run" + assert "terminate receipted app" in preview["actions"] + assert generation.is_dir() + assert ( + installer.cmd_runtime_uninstall(Namespace(dry_run=False, emit_result=True)) == 0 + ) + removed = json.loads(capsys.readouterr().out) + assert removed["status"] == "removed" + assert "terminate receipted app" in removed["actions"] + assert teardown_calls == [ + (True, app_root.resolve()), + (False, app_root.resolve()), + ] + assert original_launcher.read_text(encoding="utf-8") == "operator-owned\n" + assert original_screenscribe.is_symlink() + assert original_screenscribe.readlink() == original_screenscribe_target + assert operator_skill.read_text(encoding="utf-8") == "operator-owned skill\n" + assert unrelated_skill.read_text(encoding="utf-8") == "preserve me\n" + assert not (home / ".agents").exists() + assert not (home / ".claude").exists() + assert not (home / ".codex/commands").exists() + assert not runtime_home.exists() + assert not crafted_home.exists() + assert not config_home.exists() + # .local predated the install in this fixture because it carries an + # operator-owned ScreenScribe target. The installer must preserve it. + assert (home / ".local").is_dir() + assert app_root.exists() + + +def test_runtime_pack_uninstall_prunes_only_created_empty_xdg_parents( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + home = tmp_path / "home" + runtime_home = home / ".local/share/vibecrafted" + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("VIBECRAFTED_RUNTIME_HOME", str(runtime_home)) + monkeypatch.setenv("VIBECRAFTED_LAUNCHER_BIN", str(home / ".local/bin")) + monkeypatch.setenv("VIBECRAFTED_HOME", str(home / ".vibecrafted")) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.setattr( + installer, "_teardown_owned_runtime_for_uninstall", lambda *_args, **_kwargs: [] + ) + payload, terminal_host, frame_helper = _runtime_pack_fixture(tmp_path) + args = Namespace( + payload_root=str(payload), + app_root=str(terminal_host.parents[2]), + terminal_host=str(terminal_host), + frame_helper=str(frame_helper), + ) + + assert installer.cmd_runtime_install(args) == 0 + capsys.readouterr() + assert (home / ".local/share/vibecrafted").is_dir() + assert (home / ".config/vibecrafted").is_dir() + + assert ( + installer.cmd_runtime_uninstall(Namespace(dry_run=False, emit_result=True)) == 0 + ) + capsys.readouterr() + + assert not (home / ".local").exists() + assert not (home / ".config").exists() + assert not (home / ".agents").exists() + assert not (home / ".claude").exists() + assert not (home / ".codex").exists() + + +def test_runtime_pack_refuses_missing_required_agent_foundation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("VIBECRAFTED_RUNTIME_HOME", str(home / "runtime")) + monkeypatch.setenv("VIBECRAFTED_LAUNCHER_BIN", str(home / "bin")) + monkeypatch.setenv("VIBECRAFTED_HOME", str(home / "state")) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / "config")) + payload, terminal_host, frame_helper = _runtime_pack_fixture(tmp_path) + (payload / "bin/prview").unlink() + + with pytest.raises(RuntimeError, match=r"Runtime Pack is incomplete: .*bin/prview"): + installer.cmd_runtime_install( + Namespace( + payload_root=str(payload), + app_root=str(terminal_host.parents[2]), + terminal_host=str(terminal_host), + frame_helper=str(frame_helper), + ) + ) + + assert not (home / "bin/vibecrafted").exists() + + +def test_runtime_pack_uninstall_preserves_locally_modified_managed_launcher( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("VIBECRAFTED_RUNTIME_HOME", str(home / "runtime")) + monkeypatch.setenv("VIBECRAFTED_LAUNCHER_BIN", str(home / "bin")) + monkeypatch.setenv("VIBECRAFTED_HOME", str(home / "state")) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / "config")) + monkeypatch.setattr( + installer, "_teardown_owned_runtime_for_uninstall", lambda *_args, **_kwargs: [] + ) + payload, terminal_host, frame_helper = _runtime_pack_fixture(tmp_path) + args = Namespace( + payload_root=str(payload), + app_root=str(terminal_host.parents[2]), + terminal_host=str(terminal_host), + frame_helper=str(frame_helper), + ) + assert installer.cmd_runtime_install(args) == 0 + capsys.readouterr() + launcher = home / "bin/vc-start" + launcher.write_text("operator changed this after install\n", encoding="utf-8") + + assert ( + installer.cmd_runtime_uninstall(Namespace(dry_run=False, emit_result=True)) == 1 + ) + result = json.loads(capsys.readouterr().out) + assert result["status"] == "conflict" + assert str(launcher) in result["conflicts"] + assert ( + launcher.read_text(encoding="utf-8") == "operator changed this after install\n" + ) + assert (home / "runtime/releases/9.9.9+g12345678").is_dir() + assert (home / "runtime" / installer.RUNTIME_INSTALL_RECEIPT).is_file() + + +def test_runtime_pack_uninstall_refuses_modified_agent_projection( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + home = tmp_path / "home" + runtime_home = home / "runtime" + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("VIBECRAFTED_RUNTIME_HOME", str(runtime_home)) + monkeypatch.setenv("VIBECRAFTED_LAUNCHER_BIN", str(home / "bin")) + monkeypatch.setenv("VIBECRAFTED_HOME", str(home / "state")) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / "config")) + monkeypatch.setattr( + installer, "_teardown_owned_runtime_for_uninstall", lambda *_args, **_kwargs: [] + ) + payload, terminal_host, frame_helper = _runtime_pack_fixture(tmp_path) + args = Namespace( + payload_root=str(payload), + app_root=str(terminal_host.parents[2]), + terminal_host=str(terminal_host), + frame_helper=str(frame_helper), + ) + assert installer.cmd_runtime_install(args) == 0 + capsys.readouterr() + + projection = home / ".codex/skills/vc-audit" + projection.unlink() + projection.symlink_to(home / "operator-owned-target") + + assert ( + installer.cmd_runtime_uninstall(Namespace(dry_run=False, emit_result=True)) == 1 + ) + result = json.loads(capsys.readouterr().out) + assert result["status"] == "conflict" + assert str(projection) in result["conflicts"] + assert projection.is_symlink() + assert runtime_home.is_dir() + + +def test_runtime_pack_install_refuses_symlinked_agent_projection_ancestor( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = tmp_path / "home" + outside = tmp_path / "outside-agent-home" + runtime_home = home / "runtime" + home.mkdir() + outside.mkdir() + (home / ".agents").symlink_to(outside, target_is_directory=True) + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("VIBECRAFTED_RUNTIME_HOME", str(runtime_home)) + monkeypatch.setenv("VIBECRAFTED_LAUNCHER_BIN", str(home / "bin")) + monkeypatch.setenv("VIBECRAFTED_HOME", str(home / "state")) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / "config")) + payload, terminal_host, frame_helper = _runtime_pack_fixture(tmp_path) + launcher = home / "bin/vc-start" + launcher.parent.mkdir(parents=True) + launcher.write_text("operator-owned\n", encoding="utf-8") + args = Namespace( + payload_root=str(payload), + app_root=str(terminal_host.parents[2]), + terminal_host=str(terminal_host), + frame_helper=str(frame_helper), + ) + + with pytest.raises( + RuntimeError, match="runtime projection ancestor must not be a symlink" + ): + installer.cmd_runtime_install(args) + + assert not any(outside.iterdir()) + assert (home / ".agents").is_symlink() + assert (runtime_home / installer.RUNTIME_INSTALL_RECEIPT).is_file() + + +def test_interrupted_runtime_pack_install_leaves_receipt_for_clean_reset( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + home = tmp_path / "home" + runtime_home = home / "runtime" + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("VIBECRAFTED_RUNTIME_HOME", str(runtime_home)) + monkeypatch.setenv("VIBECRAFTED_LAUNCHER_BIN", str(home / "bin")) + monkeypatch.setenv("VIBECRAFTED_HOME", str(home / "state")) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / "config")) + monkeypatch.setattr( + installer, "_teardown_owned_runtime_for_uninstall", lambda *_args, **_kwargs: [] + ) + payload, terminal_host, frame_helper = _runtime_pack_fixture(tmp_path) + args = Namespace( + payload_root=str(payload), + app_root=str(terminal_host.parents[2]), + terminal_host=str(terminal_host), + frame_helper=str(frame_helper), + ) + original = installer._write_runtime_owned_file + + def interrupt_during_agent_projection( + path: Path, *args: object, **kwargs: object + ) -> None: + if path == home / ".claude/skills/VERIFICATION_RULE.md": + raise RuntimeError("injected onboarding interruption") + original(path, *args, **kwargs) + + monkeypatch.setattr( + installer, "_write_runtime_owned_file", interrupt_during_agent_projection + ) + with pytest.raises(RuntimeError, match="injected onboarding interruption"): + installer.cmd_runtime_install(args) + + receipt = runtime_home / installer.RUNTIME_INSTALL_RECEIPT + assert receipt.is_file() + monkeypatch.setattr(installer, "_write_runtime_owned_file", original) + assert ( + installer.cmd_runtime_uninstall(Namespace(dry_run=False, emit_result=True)) == 0 + ) + result = json.loads(capsys.readouterr().out) + assert result["status"] == "removed" + assert not runtime_home.exists() + assert not (home / ".agents").exists() + assert not (home / ".claude").exists() + assert not (home / ".codex").exists() + + +def test_interrupted_projection_publish_restores_checkpointed_collision( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + home = tmp_path / "home" + runtime_home = home / "runtime" + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("VIBECRAFTED_RUNTIME_HOME", str(runtime_home)) + monkeypatch.setenv("VIBECRAFTED_LAUNCHER_BIN", str(home / "bin")) + monkeypatch.setenv("VIBECRAFTED_HOME", str(home / "state")) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / "config")) + monkeypatch.setattr( + installer, "_teardown_owned_runtime_for_uninstall", lambda *_args, **_kwargs: [] + ) + payload, terminal_host, frame_helper = _runtime_pack_fixture(tmp_path) + current = runtime_home / "tools/vibecrafted-current" + current.parent.mkdir(parents=True) + current.write_text("operator-owned current marker\n", encoding="utf-8") + args = Namespace( + payload_root=str(payload), + app_root=str(terminal_host.parents[2]), + terminal_host=str(terminal_host), + frame_helper=str(frame_helper), + ) + original_symlink = installer._atomic_symlink + + def interrupt_publish(_target: Path, path: Path) -> None: + if path == current: + raise RuntimeError("injected projection publication interruption") + original_symlink(_target, path) + + monkeypatch.setattr(installer, "_atomic_symlink", interrupt_publish) + with pytest.raises( + RuntimeError, match="injected projection publication interruption" + ): + installer.cmd_runtime_install(args) + + receipt_path = runtime_home / installer.RUNTIME_INSTALL_RECEIPT + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + backup = Path(receipt["backups"][str(current)]) + assert backup.read_text(encoding="utf-8") == "operator-owned current marker\n" + assert not current.exists() + + monkeypatch.setattr(installer, "_atomic_symlink", original_symlink) + assert ( + installer.cmd_runtime_uninstall(Namespace(dry_run=False, emit_result=True)) == 0 + ) + result = json.loads(capsys.readouterr().out) + assert result["status"] == "removed" + assert current.read_text(encoding="utf-8") == "operator-owned current marker\n" + + +def test_runtime_pack_uninstall_rejects_projection_path_injected_into_receipt( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + home = tmp_path / "home" + runtime_home = home / "runtime" + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("VIBECRAFTED_RUNTIME_HOME", str(runtime_home)) + monkeypatch.setenv("VIBECRAFTED_LAUNCHER_BIN", str(home / "bin")) + monkeypatch.setenv("VIBECRAFTED_HOME", str(home / "state")) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / "config")) + payload, terminal_host, frame_helper = _runtime_pack_fixture(tmp_path) + args = Namespace( + payload_root=str(payload), + app_root=str(terminal_host.parents[2]), + terminal_host=str(terminal_host), + frame_helper=str(frame_helper), + ) + assert installer.cmd_runtime_install(args) == 0 + capsys.readouterr() + + receipt_path = runtime_home / installer.RUNTIME_INSTALL_RECEIPT + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + receipt["owned_symlinks"][str(home / ".ssh/config")] = next( + iter(receipt["owned_symlinks"].values()) + ) + receipt_path.write_text(json.dumps(receipt), encoding="utf-8") + + with pytest.raises(RuntimeError, match="receipt symlink escapes managed roots"): + installer.cmd_runtime_uninstall(Namespace(dry_run=False, emit_result=True)) + assert receipt_path.is_file() + + +def test_runtime_pack_uninstall_rejects_tampered_backup_before_teardown( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + home = tmp_path / "home" + runtime_home = home / "runtime" + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("VIBECRAFTED_RUNTIME_HOME", str(runtime_home)) + monkeypatch.setenv("VIBECRAFTED_LAUNCHER_BIN", str(home / "bin")) + monkeypatch.setenv("VIBECRAFTED_HOME", str(home / "state")) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / "config")) + teardown_called = False + + def mark_teardown(*_args, **_kwargs) -> list[str]: + nonlocal teardown_called + teardown_called = True + return [] + + monkeypatch.setattr( + installer, "_teardown_owned_runtime_for_uninstall", mark_teardown + ) + payload, terminal_host, frame_helper = _runtime_pack_fixture(tmp_path) + launcher = home / "bin/vc-start" + launcher.parent.mkdir(parents=True) + launcher.write_text("operator-owned\n", encoding="utf-8") + args = Namespace( + payload_root=str(payload), + app_root=str(terminal_host.parents[2]), + terminal_host=str(terminal_host), + frame_helper=str(frame_helper), + ) + assert installer.cmd_runtime_install(args) == 0 + capsys.readouterr() + + receipt_path = runtime_home / installer.RUNTIME_INSTALL_RECEIPT + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + receipt["backups"][str(launcher)] = str(tmp_path / "outside-backup") + receipt_path.write_text(json.dumps(receipt), encoding="utf-8") + + with pytest.raises(RuntimeError, match="backup path escapes backup root"): + installer.cmd_runtime_uninstall(Namespace(dry_run=False, emit_result=True)) + + assert not teardown_called + assert launcher.read_text(encoding="utf-8") != "operator-owned\n" + assert (runtime_home / "releases/9.9.9+g12345678").is_dir() + assert receipt_path.is_file() + + +def test_runtime_pack_uninstall_rejects_symlinked_backup_parent( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + home = tmp_path / "home" + runtime_home = home / "runtime" + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("VIBECRAFTED_RUNTIME_HOME", str(runtime_home)) + monkeypatch.setenv("VIBECRAFTED_LAUNCHER_BIN", str(home / "bin")) + monkeypatch.setenv("VIBECRAFTED_HOME", str(home / "state")) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / "config")) + payload, terminal_host, frame_helper = _runtime_pack_fixture(tmp_path) + launcher = home / "bin/vc-start" + launcher.parent.mkdir(parents=True) + launcher.write_text("operator-owned\n", encoding="utf-8") + args = Namespace( + payload_root=str(payload), + app_root=str(terminal_host.parents[2]), + terminal_host=str(terminal_host), + frame_helper=str(frame_helper), + ) + assert installer.cmd_runtime_install(args) == 0 + capsys.readouterr() + + receipt_path = runtime_home / installer.RUNTIME_INSTALL_RECEIPT + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + destination = next(iter(receipt["backups"])) + outside = tmp_path / "outside" + outside.mkdir() + escaped_parent = runtime_home / ".installer-backups/escaped" + escaped_parent.symlink_to(outside) + receipt["backups"][destination] = str(escaped_parent / "payload") + receipt_path.write_text(json.dumps(receipt), encoding="utf-8") + + with pytest.raises(RuntimeError, match="backup path escapes backup root"): + installer.cmd_runtime_uninstall(Namespace(dry_run=False, emit_result=True)) + assert receipt_path.is_file() + + def _setup_installed_surface( tmp_path: Path, monkeypatch ) -> tuple[Path, Path, Path, Path, Path]: @@ -233,6 +836,76 @@ def test_retired_vc_frame_census_requires_exact_stable_same_user_argv0( ) +def test_owned_runtime_census_matches_product_processes_without_killing_editors( + tmp_path: Path, monkeypatch +) -> None: + home = tmp_path / "home" + runtime_releases = home / "runtime/releases" + app_executable = Path("/Applications/Vibecrafted.app/Contents/MacOS/Vibecrafted") + custom_app = home / "Applications/Vibecrafted-Recovery-Test.app" + births = {pid: (f"darwin:{pid}:1", os.geteuid(), 8) for pid in range(101, 108)} + births[106] = ("darwin:106:1", os.geteuid() + 1, 8) + arguments = { + 101: (str(app_executable),), + 102: (str(runtime_releases / "current/bin/vc-frame"), "--session", "test"), + 103: ("/bin/bash", str(runtime_releases / "current/start.sh")), + 104: ( + "/Applications/Visual Studio Code.app/Contents/MacOS/Electron", + str(runtime_releases / "current/README.md"), + ), + 105: (str(runtime_releases / "current/bin/vc-terminal"),), + 106: (str(runtime_releases / "current/bin/vc-server"),), + 107: (str(custom_app / "Contents/MacOS/Vibecrafted"),), + } + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("VIBECRAFTED_RUNTIME_HOME", str(home / "runtime")) + monkeypatch.setattr(installer.sys, "platform", "darwin") + monkeypatch.setattr(installer, "_darwin_process_ids", lambda: tuple(births)) + monkeypatch.setattr( + installer, "_darwin_caller_ancestor_pids", lambda: frozenset({105}) + ) + monkeypatch.setattr(installer, "_darwin_process_birth", births.__getitem__) + monkeypatch.setattr( + installer, + "_darwin_process_arguments", + lambda pid, *, pointer_size: arguments[pid], + ) + + assert tuple( + record.pid for record in installer._owned_runtime_process_census() + ) == ( + 101, + 102, + 103, + ) + assert tuple( + record.pid + for record in installer._owned_runtime_process_census(app_root=custom_app) + ) == (101, 102, 103, 107) + + +def test_darwin_parent_pid_uses_strict_ps_fallback_on_remote_login_eperm( + monkeypatch, +) -> None: + class DeniedLibproc: + @staticmethod + def proc_pidinfo(*_args) -> int: + installer.ctypes.set_errno(installer.errno.EPERM) + return 0 + + monkeypatch.setattr( + installer, "_darwin_process_libraries", lambda: (DeniedLibproc(), object()) + ) + monkeypatch.setattr( + installer.subprocess, + "run", + lambda *args, **kwargs: subprocess.CompletedProcess(args[0], 0, " 42\n", ""), + ) + + assert installer._darwin_process_parent_pid(101) == 42 + + def test_terminate_retired_vc_frame_reproves_identity_before_signal( monkeypatch, ) -> None: @@ -383,6 +1056,52 @@ def run_command( assert commands == [("service", "uninstall")] +def test_runtime_teardown_terminates_owned_runtime_processes_and_proves_zero( + tmp_path: Path, monkeypatch +) -> None: + shared_home = tmp_path / ".vibecrafted" + record = installer._RetiredVcFrameProcess( + 101, + ("darwin:1:1", os.geteuid(), 8), + (str(tmp_path / "runtime/releases/current/bin/vc-frame"),), + ) + censuses = iter(((record,), ())) + terminated: list[tuple[installer._RetiredVcFrameProcess, ...]] = [] + + monkeypatch.setattr(installer.sys, "platform", "darwin") + monkeypatch.setattr( + installer, "_current_tools_link", lambda _home: tmp_path / "current" + ) + monkeypatch.setattr( + installer, + "_tools_install_lease", + lambda _link, *, operation: nullcontext(9), + ) + monkeypatch.setattr( + installer, "_inherited_tools_install_lease", lambda _descriptor: nullcontext() + ) + monkeypatch.setattr(installer.os, "set_inheritable", lambda _fd, _value: None) + monkeypatch.setattr(installer, "_runtime_service_has_evidence", lambda _home: False) + monkeypatch.setattr(installer, "_retired_vc_frame_process_census", tuple) + monkeypatch.setattr( + installer, + "_owned_runtime_process_census", + lambda *, app_root=None: next(censuses), + ) + monkeypatch.setattr( + installer, + "_terminate_owned_runtime_processes", + lambda records: terminated.append(tuple(records)), + ) + + actions = installer._teardown_owned_runtime_for_uninstall( + shared_home, dry_run=False + ) + + assert actions == ("terminate 1 owned runtime process(es)",) + assert terminated == [(record,)] + + def test_runtime_teardown_does_not_probe_launcher_without_service_evidence( tmp_path: Path, monkeypatch ) -> None: @@ -415,6 +1134,38 @@ def forbidden_snapshot(_home: Path): ) +def test_stale_supervisor_lock_is_not_runtime_service_evidence( + tmp_path: Path, monkeypatch +) -> None: + home = tmp_path / "home" + shared_home = home / ".vibecrafted" + lock_path = shared_home / "server/supervisor.lock" + lock_path.parent.mkdir(parents=True) + lock_path.touch(mode=0o600) + monkeypatch.setenv("HOME", str(home)) + + assert installer._runtime_service_has_evidence(shared_home) is False + + +def test_held_supervisor_lock_is_runtime_service_evidence( + tmp_path: Path, monkeypatch +) -> None: + home = tmp_path / "home" + shared_home = home / ".vibecrafted" + lock_path = shared_home / "server/supervisor.lock" + lock_path.parent.mkdir(parents=True) + lock_path.touch(mode=0o600) + monkeypatch.setenv("HOME", str(home)) + + def held(_descriptor: int, operation: int) -> None: + if operation & installer.fcntl.LOCK_NB: + raise BlockingIOError + + monkeypatch.setattr(installer.fcntl, "flock", held) + + assert installer._runtime_service_has_evidence(shared_home) is True + + def test_cmd_uninstall_removes_release_contract_assets_with_managed_payload( tmp_path: Path, monkeypatch, capsys ) -> None: diff --git a/tests/tui/test_keys.py b/tests/tui/test_keys.py index acd46965..fbda9b84 100644 --- a/tests/tui/test_keys.py +++ b/tests/tui/test_keys.py @@ -23,7 +23,13 @@ def _write_installed_runtime_deck(home: Path) -> Path: deck.parent.mkdir(parents=True, exist_ok=True) deck.write_text("#!/usr/bin/env bash\nexit 0\n", encoding="utf-8") deck.chmod(0o755) - return deck + # The public launcher enters the generation through its manifest-bound + # entrypoint, a byte-identical copy of the deck. + entrypoint = deck.parents[3] / "bin" / "vibecrafted" + entrypoint.parent.mkdir(parents=True, exist_ok=True) + entrypoint.write_text(deck.read_text(encoding="utf-8"), encoding="utf-8") + entrypoint.chmod(0o755) + return entrypoint def test_read_framework_version_reads_version_file(tmp_path: Path) -> None: @@ -335,23 +341,8 @@ def test_app_installer_writes_deck_verb_wrappers() -> None: the shim itself. Without it `vc-resume claude --session ` degraded to `vibecrafted claude --session ` ("Unknown mode: --session"). """ - import re - from vibecrafted_core import cli - swift = ( - Path(__file__).resolve().parents[2] - / "vibecrafted-app" - / "shell-agent" - / "app" - / "Vibecrafted" - / "AppDelegate.swift" - ).read_text(encoding="utf-8") - - block = swift.split("deckVerbWrappers", 1)[1] - block = block.split("for wrapper in deckVerbWrappers", 1)[0] - pairs = dict(re.findall(r'\("([a-z-]+)", "([a-z]+)"\)', block)) - # vc-start ships as a real runtime binary; the installer's bin guard skips # it dynamically, so the static verb list intentionally leaves it out. expected = { @@ -359,4 +350,4 @@ def test_app_installer_writes_deck_verb_wrappers() -> None: for name, verb in cli.SHELL_WRAPPER_VERBS.items() if name != "vc-start" } - assert pairs == expected + assert vetcoders_install._RUNTIME_WRAPPER_VERBS == expected diff --git a/tests/tui/test_linux_arm64_runtime_pack.py b/tests/tui/test_linux_arm64_runtime_pack.py new file mode 100644 index 00000000..e7e4f795 --- /dev/null +++ b/tests/tui/test_linux_arm64_runtime_pack.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import os +import stat +import subprocess +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def _executable(path: Path, body: str) -> None: + path.write_text(body, encoding="utf-8") + path.chmod(path.stat().st_mode | stat.S_IXUSR) + + +def test_foundation_stager_accepts_linux_arm64_as_a_complete_target( + tmp_path: Path, +) -> None: + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _executable( + fake_bin / "uname", + '#!/bin/sh\n[ "${1:-}" = -s ] && echo Linux || echo aarch64\n', + ) + result = subprocess.run( + [ + "bash", + str(REPO_ROOT / "scripts/stage-runtime-foundations.sh"), + str(tmp_path / "out"), + ], + cwd=REPO_ROOT, + env={ + **os.environ, + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "VIBECRAFTED_FOUNDATIONS_TARGET_PROBE": "1", + }, + text=True, + capture_output=True, + check=False, + ) + assert ( + "no complete Runtime Foundations payload for Linux/aarch64" not in result.stderr + ) + + +def test_local_vm_image_consumes_only_the_exact_runtime_pack_carrier() -> None: + containerfile = (REPO_ROOT / "vibecrafted-vm/Containerfile").read_text( + encoding="utf-8" + ) + forbidden = ( + "COPY src/loctree-suite", + "COPY src/aicx", + "releases/latest", + "installing stub", + "best-effort", + '|| echo "[warn]', + 'VOLUME ["/workspace"', + ) + assert not [token for token in forbidden if token in containerfile] + assert "ARG RUNTIME_PACK_ARCHIVE" in containerfile + assert "runtime-pack-provenance.json" in containerfile + assert "passwd tini" in containerfile + assert "/usr/sbin/groupadd" in containerfile + assert "/usr/sbin/useradd" in containerfile + assert "chmod -R a-w" not in containerfile + assert "chown -R root:root /opt/vibecrafted-runtime" in containerfile + assert "USER vibecrafted" in containerfile + assert "vc-frame vc-terminal voc" in containerfile + entry = (REPO_ROOT / "vibecrafted-vm/runtime-entry.sh").read_text(encoding="utf-8") + assert "vc-frame vc-terminal voc" in entry + + +def test_linux_arm64_builder_uses_pinned_public_inputs() -> None: + builder = (REPO_ROOT / "vibecrafted-vm/RuntimePack.Containerfile").read_text( + encoding="utf-8" + ) + assembler = (REPO_ROOT / "scripts/build-linux-arm64-runtime-pack.sh").read_text( + encoding="utf-8" + ) + assert "astral.sh/uv" not in builder + assert "rustup target add wasm32-unknown-unknown wasm32-wasip1" in builder + assert builder.index("ARG VIBECRAFTED_SOURCE_REVISION") > builder.index( + "WORKDIR /src/vibecrafted" + ) + assert "69616218470b2ad053617efb9e7027b1518ea38918d933c2791e113d99cec507" in builder + assert "d6685ead9018ad89411291d6198476666e48b0f8" in assembler + assert "7ab84069c9b7994ce0b705ccedd708aa3a35dcb6" in assembler + assert "git clone" not in assembler + assert 'voc_target="$work/voc-target"' in assembler + assert 'CARGO_TARGET_DIR="$voc_target" cargo build --locked' in assembler + assert "--release -p voc --bin voc --bin vc-start" in assembler + assert ( + 'install -m 0755 "$voc_target/release/vc-start" ' + '"$payload/bin/vc-start"' in assembler + ) + assert '"$repo_root/vibecrafted-app/target' not in assembler + assert 'rm -rf "$work/vc-terminal" "$work/vc-terminal.tar.gz"' in assembler + assert ( + 'RUSTFLAGS="--remap-path-prefix=$work/vc-frame=/usr/src/vc-frame"' in assembler + ) + assert "cargo xtask build --release --no-plugins" not in assembler + assert "cargo xtask build --release" in assembler + assert 'rm -rf "$work/vc-frame" "$work/vc-frame.tar.gz"' in assembler + assert 'rm -rf "$voc_target"' in assembler + assert 'rm -rf "$server_build"' in assembler + assert ( + 'install -m 0755 "$repo_root/scripts/vibecrafted" ' + '"$payload/scripts/vibecrafted"' in assembler + ) + assert 'printf \'%s+g%.8s\\n\' "$version" "$source_revision"' in assembler + + foundations = (REPO_ROOT / "scripts/stage-runtime-foundations.sh").read_text( + encoding="utf-8" + ) + assert 'rm -rf "$WORK/loctree"' in foundations + assert 'rm -rf "$WORK/aicx"' in foundations diff --git a/tests/tui/test_makefile_installer_contract.py b/tests/tui/test_makefile_installer_contract.py index 911a5acd..7db8cc53 100644 --- a/tests/tui/test_makefile_installer_contract.py +++ b/tests/tui/test_makefile_installer_contract.py @@ -171,6 +171,21 @@ def test_release_workflow_is_read_only_and_validates_the_exact_tag_source() -> N assert "gh release edit" not in workflow +def test_portable_workflow_requires_runtime_pack_bootstrap_on_mac_and_linux() -> None: + workflow = (REPO_ROOT / ".github/workflows/portable.yml").read_text( + encoding="utf-8" + ) + bootstrap = workflow.split(" curl-bootstrap:", 1)[1] + + assert "if: github.event_name == 'merge_group'" not in bootstrap + assert "runner: macos-latest" in bootstrap + assert "runner: ubuntu-latest" in bootstrap + assert "test_runtime_pack_cli.py" in bootstrap + assert "test_install_bootstrap.py" in bootstrap + assert "cargo binstall" not in bootstrap + assert "build-essential" not in bootstrap + + def test_core_gate_isolated_from_the_previously_installed_runtime_stamp() -> None: makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8") gate = makefile.split("\ntest-core:", 1)[1].split("\ndispatch-test:", 1)[0] @@ -189,12 +204,9 @@ def test_bootstrap_help_requires_canonical_provenance_archives() -> None: def test_makefile_keeps_install_as_terminal_first_front_door() -> None: - """Contract: `make install` is the terminal-native human front door — a - compact, log-quiet step runner that greets, walks the canonical install - steps through $(INSTALL_STEP), and ends by pointing at `vc-start`. - `make install-auto` is the unattended automation path that reuses the same - front door, and `make setup-dev` opens the uv meta-installer in advanced - mode. + """Contract: `make install` consumes the same immutable Runtime Pack as + the native App. The historical compiler lane remains explicit as + `make install-source` for the portable source carrier only. Every recipe that bootstraps uv (setup-dev, install-all, tui-installer) must keep the uv bootstrap and the `uv run` invocation inside one shell @@ -206,32 +218,41 @@ def test_makefile_keeps_install_as_terminal_first_front_door() -> None: # CLI_PRODUCT_SPEC §6.5: `make help` is the six-target deck; everything # else lives in `make help-dev`. - assert "make install \\033[2mGuided install" in text + assert "make install \\033[2mInstall the receipted Runtime Pack" in text assert "make doctor \\033[2mHealth check" in text assert "dev targets: make help-dev" in text assert "help-dev:" in text assert "make skills" not in text.split("help:", 1)[1].split("\nvibecrafted:", 1)[0] assert "vibecrafted: install" in text - # The front door is the compact step runner: it greets, walks the canonical - # install steps through $(INSTALL_STEP), and ends by pointing at vc-start. + # The product front door delegates to the Runtime Pack-owned interpreter + # and installer. It must never compile foundations or donors itself. install_block = text.split("\ninstall:\n", 1)[1].split( - "\ninstall-python-tools:", 1 + "\n# Explicit source/compiler lane", 1 )[0] - assert 'printf "Installing Vibecrafted\\n"' in install_block + assert 'VIBECRAFTED_RUNTIME_PACK="$(RUNTIME_PACK)"' in install_block + assert 'bash "$(RUNTIME_PACK_INSTALLER)"' in install_block + assert 'if [ "$$(uname -s)" = "Darwin" ]' not in install_block + assert "$(MAKE) --no-print-directory install-source" not in install_block + assert "$(INSTALL_STEP)" not in install_block + + source_block = text.split("\ninstall-source:\n", 1)[1].split( + "\n# The explicit source/compiler lane", 1 + )[0] + assert 'printf "Installing Vibecrafted\\n"' in source_block for label in ( "foundations", "skills and launchers", "runtime tools", "app binaries", ): - assert f'$(INSTALL_STEP) "{label}"' in install_block, ( - f"front door must walk the `{label}` install step via $(INSTALL_STEP)" + assert f'$(INSTALL_STEP) "{label}"' in source_block, ( + f"source lane must walk the `{label}` install step via $(INSTALL_STEP)" ) - assert "vc-start" in install_block + assert "vc-start" in source_block - # install-auto is the unattended path: it reuses the same front door rather - # than forking a second installer recipe. + # The curl/portable source bootstrap remains explicit and cannot silently + # select a host or App Runtime Pack. assert "install-auto: install" in text # setup-dev opens the uv meta-installer in advanced mode. Advanced is an @@ -524,7 +545,17 @@ def fake_stage( monkeypatch.setattr( installer, "_materialize_vc_frame_generation", - lambda runtime_root: seen.update(materialized=runtime_root), + lambda runtime_root: seen.update(frame_materialized=runtime_root), + ) + monkeypatch.setattr( + installer, + "_materialize_runtime_generation_entrypoint", + lambda runtime_root: seen.update(entrypoint_materialized=runtime_root), + ) + monkeypatch.setattr( + installer, + "_materialize_runtime_generation_vc_frame_entry", + lambda runtime_root: seen.update(vc_frame_entry_materialized=runtime_root), ) monkeypatch.setattr( installer, @@ -548,7 +579,8 @@ def fake_stage( assert seen["mirror"] is True assert seen["require_source_provenance"] is True assert seen["manifest_source_provenance"] == source_provenance - assert seen["materialized"] == seen["destination"] + assert seen["frame_materialized"] == seen["destination"] + assert seen["entrypoint_materialized"] == seen["destination"] assert seen["manifested"] == seen["destination"] assert seen["validated"] == seen["destination"] assert (destination / "payload.txt").read_text(encoding="utf-8") == "validated\n" @@ -582,13 +614,13 @@ def test_install_manifest_post_install_uses_mirror_sync() -> None: assert "bash runtime/scripts/install-frontier-config.sh" not in text -def test_make_install_executes_the_shell_owned_stable_root_contract( +def test_make_install_source_executes_the_shell_owned_stable_root_contract( tmp_path: Path, ) -> None: makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8") - install_block = makefile.split("\ninstall:\n", 1)[1].split("\n# `make install`", 1)[ - 0 - ] + install_block = makefile.split("\ninstall-source:\n", 1)[1].split( + "\n# The explicit source/compiler lane", 1 + )[0] install_tools_block = makefile.split("\ninstall-tools-held:\n", 1)[1].split( "\n# install-all owns", 1 )[0] @@ -879,10 +911,7 @@ def test_install_all_installs_python_tools_with_uv_tool_install() -> None: "v._install_launcher(Path(sys.argv[1]), dry_run=False, update_rc=False)" in python_tools_block ) - assert ( - "$$stable_root/vibecrafted-core/vibecrafted_core/deck/vibecrafted" - in python_tools_block - ) + assert "$$stable_root/bin/vibecrafted" in python_tools_block assert 'if [ "$$entrypoint" = "vibecrafted" ]' in python_tools_block assert "vibecrafted-mcp" in ( REPO_ROOT / "vibecrafted-mcp" / "pyproject.toml" @@ -1372,7 +1401,9 @@ def test_make_install_verifies_server_supervisor_entrypoint() -> None: def test_make_install_enables_service_after_server_payload() -> None: text = (REPO_ROOT / "Makefile").read_text(encoding="utf-8") - install_block = text.split("\ninstall:\n", 1)[1].split("\n# `make install`", 1)[0] + install_block = text.split("\ninstall-source:\n", 1)[1].split( + "\n# The explicit source/compiler lane", 1 + )[0] held_block = text.split("\ninstall-tools-held:\n", 1)[1].split( "\n# install-all owns", 1 )[0] diff --git a/tests/tui/test_native_server_menu.py b/tests/tui/test_native_server_menu.py new file mode 100644 index 00000000..529194b2 --- /dev/null +++ b/tests/tui/test_native_server_menu.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +POLICY = ( + REPO_ROOT + / "vibecrafted-app" + / "shell-agent" + / "app" + / "Vibecrafted" + / "ServerMenuPolicy.swift" +) + + +def _run_policy(tmp_path: Path, scenario: str) -> list[str]: + swiftc = shutil.which("swiftc") + if swiftc is None: + pytest.skip("swiftc is required for the native server menu contract") + main = tmp_path / "main.swift" + main.write_text( + r""" +import Foundation + +let scenario = CommandLine.arguments[1] +let service: Data? +let receipt: Data? +let action: ServerLifecycleAction? + +switch scenario { +case "stopped": + service = #"{"installed":true,"loaded":false,"supervisor_live":false,"supervisor_verified":false,"supervisor_service_managed":false,"build_current":true,"pair_healthy":false,"supervisor_pid":null}"#.data(using: .utf8) + receipt = #"{"state":"healthy","endpoint":{"host":"127.0.0.1","port":4107,"url":"http://127.0.0.1:4107"}}"#.data(using: .utf8) + action = nil +case "healthy": + service = #"{"installed":true,"loaded":true,"supervisor_live":true,"supervisor_verified":true,"supervisor_service_managed":true,"build_current":true,"pair_healthy":true,"supervisor_pid":123}"#.data(using: .utf8) + receipt = #"{"state":"healthy","endpoint":{"host":"127.0.0.1","port":4107,"url":"http://127.0.0.1:4107"},"managed_pair":{"guardian_pid":124,"server_pid":125}}"#.data(using: .utf8) + action = nil +case "transition": + service = #"{"installed":true,"loaded":true,"supervisor_live":true,"supervisor_verified":true,"supervisor_service_managed":true,"build_current":true,"pair_healthy":true,"supervisor_pid":123}"#.data(using: .utf8) + receipt = nil + action = .restart +default: + service = #"{"installed":true,"loaded":true,"supervisor_live":false,"supervisor_verified":false,"supervisor_service_managed":false,"build_current":false,"pair_healthy":false,"supervisor_pid":null}"#.data(using: .utf8) + receipt = #"{"state":"backoff","last_error":"worker failed\ntrace"}"#.data(using: .utf8) + action = nil +} + +let state = deriveServerMenuState( + supervisorData: receipt, + serviceData: service, + actionInFlight: action, + runtimeReady: true) +print(state.header) +print(state.detail) +print(state.health.rawValue) +print("\(state.canStart),\(state.canStop),\(state.canRestart)") +print(serverActionArguments(for: .start).joined(separator: " ")) +print(serverActionArguments(for: .stop).joined(separator: " ")) +print(serverActionArguments(for: .restart).joined(separator: " ")) +let logs = decodeServerLogs( + data: #"{"directory":"/tmp/vc-home/server","stdout":"/tmp/vc-home/server/supervisor.stdout.log","stderr":"/tmp/vc-home/server/supervisor.stderr.log"}"#.data(using: .utf8)!)! +print(logs.directory.path) +""", + encoding="utf-8", + ) + binary = tmp_path / "server-menu-policy" + subprocess.run( + [swiftc, str(POLICY), str(main), "-o", str(binary)], + check=True, + cwd=REPO_ROOT, + ) + return subprocess.run( + [str(binary), scenario], + check=True, + capture_output=True, + text=True, + ).stdout.splitlines() + + +def test_server_menu_policy_routes_canonical_actions_and_logs(tmp_path: Path) -> None: + lines = _run_policy(tmp_path, "stopped") + assert lines[:4] == [ + "VC Server: STOPPED · 127.0.0.1:4107", + "Service is intentionally stopped", + "neutral", + "true,false,false", + ] + assert lines[4:7] == [ + "server service start", + "server service stop", + "server service restart", + ] + assert lines[7] == "/tmp/vc-home/server" + + +def test_server_menu_policy_enables_only_valid_healthy_actions(tmp_path: Path) -> None: + lines = _run_policy(tmp_path, "healthy") + assert lines[:4] == [ + "VC Server: HEALTHY · 127.0.0.1:4107", + "Supervisor PID 123", + "healthy", + "false,true,true", + ] + + +def test_server_menu_policy_disables_duplicate_transition_actions( + tmp_path: Path, +) -> None: + lines = _run_policy(tmp_path, "transition") + assert lines[:4] == [ + "VC Server: RESTARTING…", + "Waiting for the installed service owner", + "transitioning", + "false,false,false", + ] + + +def test_server_menu_policy_surfaces_actionable_failure(tmp_path: Path) -> None: + lines = _run_policy(tmp_path, "failed") + assert lines[:4] == [ + "VC Server: NEEDS ATTENTION", + "worker failed", + "failed", + "false,true,true", + ] diff --git a/tests/tui/test_operator_mode.py b/tests/tui/test_operator_mode.py index 17cc9dd7..d73bd52d 100644 --- a/tests/tui/test_operator_mode.py +++ b/tests/tui/test_operator_mode.py @@ -5,6 +5,7 @@ import re import shutil import subprocess +import sys import time from collections.abc import Callable from datetime import datetime, timezone @@ -666,6 +667,9 @@ def test_vc_init_finds_bundled_vc_frame_and_creates_missing_operator_session( env["VIBECRAFTED_RUNTIME_BIN"] = str(bundled_bin) env["XDG_CONFIG_HOME"] = str(tmp_path / "xdg") env["VIBECRAFTED_ROOT"] = str(REPO_ROOT) + # The test PATH intentionally excludes host tools; policy resolution is + # now a required core operation, so pin the current test interpreter. + env["VIBECRAFTED_PYTHON"] = sys.executable env["CAPTURE_FILE"] = str(capture_file) env["SESSION_STATE_FILE"] = str(session_state_file) env["VIBECRAFTED_OSASCRIPT_BIN"] = str(fake_bin / "osascript") @@ -930,6 +934,7 @@ def test_vc_init_missing_vc_frame_message_has_fresh_install_path_hint( env["VIBECRAFTED_RUNTIME_BIN"] = str(runtime_home / "bin") env["PATH"] = "/usr/bin:/bin:/usr/sbin:/sbin" env["VIBECRAFTED_ROOT"] = str(REPO_ROOT) + env["VIBECRAFTED_PYTHON"] = sys.executable env.pop("VC_FRAME", None) env.pop("VC_FRAME_PANE_ID", None) env.pop("VC_FRAME_SESSION_NAME", None) diff --git a/tests/tui/test_payload_hygiene.py b/tests/tui/test_payload_hygiene.py index 85066628..40390b87 100644 --- a/tests/tui/test_payload_hygiene.py +++ b/tests/tui/test_payload_hygiene.py @@ -251,6 +251,20 @@ def test_the_ancestor_walk_stops_before_generic_system_roots() -> None: run_library('payload_hygiene_topmost_host_root "/Volumes/ws/a/b"').strip() == "/Volumes/ws" ) + # `/private/tmp` is the realpath of `/tmp` on macOS — a directory every box + # has. A checkout built from under it must forbid its own scratch root, not + # the generic tmp: otherwise committed `/private/tmp` literals (test + # fixtures, tmp-normalization docs) flag every scratchpad build as a leak. + assert ( + run_library('payload_hygiene_topmost_host_root "/private/tmp/solo"').strip() + == "" + ) + assert ( + run_library( + 'payload_hygiene_topmost_host_root "/private/tmp/scratch/repo"' + ).strip() + == "/private/tmp/scratch" + ) # The packer refuses any path carrying one of these components, so a literal diff --git a/tests/tui/test_product_entry_policy.py b/tests/tui/test_product_entry_policy.py index d7d72a25..680e668e 100644 --- a/tests/tui/test_product_entry_policy.py +++ b/tests/tui/test_product_entry_policy.py @@ -83,6 +83,7 @@ def test_wrapper_never_executes_retired_sibling_shadow(tmp_path: Path) -> None: "PATH": f"{bin_dir}:/usr/bin:/bin", "HOME": str(home), "XDG_CONFIG_HOME": str(xdg), + "VIBECRAFTED_VC_FRAME_BIN": str(cargo_bin / "vc-frame"), "USER": "test", } proc = subprocess.run( @@ -98,6 +99,89 @@ def test_wrapper_never_executes_retired_sibling_shadow(tmp_path: Path) -> None: assert "RETIRED_SHADOW_RAN" not in proc.stdout +def test_installed_wrapper_prefers_adjacent_native_libexec_without_recursing( + tmp_path: Path, +) -> None: + home = tmp_path / "home" + xdg = tmp_path / "xdg" + generation = tmp_path / "release" + wrapper = generation / "bin" / "vc-frame" + real = generation / "libexec" / "vc-frame" + ambient = xdg / "data" / "vibecrafted" / "bin" / "vc-frame" + home.mkdir() + xdg.mkdir() + wrapper.parent.mkdir(parents=True) + real.parent.mkdir(parents=True) + ambient.parent.mkdir(parents=True) + wrapper.write_text(WRAPPER.read_text(encoding="utf-8"), encoding="utf-8") + wrapper.chmod(0o755) + _write_fake_bin(real.parent, real.name, "#!/bin/sh\nprintf '%s\\n' \"$*\"\n") + ambient.symlink_to(wrapper) + tool_bin = tmp_path / "tool-bin" + tool_bin.mkdir() + _write_fake_bin( + tool_bin, + "file", + "#!/bin/sh\nprintf 'Mach-O 64-bit executable arm64\\n'\n", + ) + + env = { + **{k: v for k, v in os.environ.items() if not k.startswith("VC_FRAME")}, + "PATH": f"{tool_bin}:/usr/bin:/bin", + "HOME": str(home), + "XDG_CONFIG_HOME": str(xdg), + "XDG_DATA_HOME": str(xdg / "data"), + "USER": "test", + } + proc = subprocess.run( + [str(wrapper), "list-sessions"], + capture_output=True, + text=True, + env=env, + check=False, + timeout=5, + ) + + assert proc.returncode == 0, (proc.stdout, proc.stderr) + assert proc.stdout == "list-sessions\n" + + +def test_wrapper_rejects_ambient_shell_wrapper_instead_of_recursing( + tmp_path: Path, +) -> None: + home = tmp_path / "home" + xdg = tmp_path / "xdg" + wrapper = tmp_path / "release" / "bin" / "vc-frame" + ambient = xdg / "data" / "vibecrafted" / "bin" / "vc-frame" + home.mkdir() + xdg.mkdir() + wrapper.parent.mkdir(parents=True) + ambient.parent.mkdir(parents=True) + wrapper.write_text(WRAPPER.read_text(encoding="utf-8"), encoding="utf-8") + wrapper.chmod(0o755) + ambient.symlink_to(wrapper) + + env = { + **{k: v for k, v in os.environ.items() if not k.startswith("VC_FRAME")}, + "PATH": "/usr/bin:/bin", + "HOME": str(home), + "XDG_CONFIG_HOME": str(xdg), + "XDG_DATA_HOME": str(xdg / "data"), + "USER": "test", + } + proc = subprocess.run( + [str(wrapper), "list-sessions"], + capture_output=True, + text=True, + env=env, + check=False, + timeout=5, + ) + + assert proc.returncode == 127, (proc.stdout, proc.stderr) + assert "real binary not found" in proc.stderr + + def test_product_entry_prepare_exists_in_shipped_dashboard() -> None: """Shell prepare is the real choke (vc-start never enters deck cmd_start).""" text = DASHBOARD.read_text(encoding="utf-8") @@ -247,7 +331,7 @@ def test_wrapper_pins_and_execs_when_frontier_config_present(tmp_path: Path) -> home.mkdir() xdg.mkdir() bin_dir.mkdir() - frontier = xdg / "vetcoders" / "frontier" / "vc-frame" + frontier = xdg / "vibecrafted" / "vc-frame" frontier.mkdir(parents=True) (frontier / "config.kdl").write_text("// product\n", encoding="utf-8") diff --git a/tests/tui/test_release_contract.py b/tests/tui/test_release_contract.py index 37846730..993eee91 100644 --- a/tests/tui/test_release_contract.py +++ b/tests/tui/test_release_contract.py @@ -27,7 +27,7 @@ ABSENT_FROM_MACOS_RUNNER_IMAGE = ("rg", "fd") -def test_public_install_surfaces_name_both_release_channels() -> None: +def test_public_install_surfaces_name_all_release_carriers() -> None: surfaces = ( "README.md", "docs/QUICK_START.md", @@ -37,6 +37,10 @@ def test_public_install_surfaces_name_both_release_channels() -> None: text = (REPO_ROOT / relative).read_text(encoding="utf-8") assert RELEASE_PAGE in text, f"{relative} must point to the unified release" assert "Vibecrafted_--.dmg" in text + assert ( + "Vibecrafted_RuntimePack_---darwin-.tar.gz" + in text + ), f"{relative} must name the macOS CLI Runtime Pack" # A non-macOS reader must find a version-pinned artifact on the same # page, not only a curl-pipe-bash line that tracks a moving branch. assert "Vibecrafted_---portable.tar.gz" in text, ( @@ -146,8 +150,8 @@ def test_tag_gate_only_calls_tools_its_own_runner_provides() -> None: ) -def test_publication_boundary_step_still_asserts_both_channel_names() -> None: - """The boundary step is the only thing pinning the six-asset shape. +def test_publication_boundary_step_still_asserts_all_carrier_names() -> None: + """The boundary step pins the exact three-carrier release shape. Rewriting its matcher (rg -> grep) must not quietly drop what it matches: one canonically named DMG and one portable tarball, each resolved by the @@ -157,15 +161,51 @@ def test_publication_boundary_step_still_asserts_both_channel_names() -> None: assert "Vibecrafted_.*YYYYMMDD|DMG_NAME|\\.dmg\\.sha256" in workflow assert "PORTABLE_NAME|portable\\.tar\\.gz|portable-output\\.json" in workflow + assert ( + "RUNTIME_PACK_NAME|RuntimePack_.*tar\\.gz|install-runtime-pack\\.sh" in workflow + ) for target in ( "scripts/build-vibecrafted-release.sh", "scripts/build-portable-release.sh", + "scripts/package-runtime-pack.sh", + "scripts/install-runtime-pack.sh", "scripts/publish-vibecrafted-release.sh", "docs/RELEASE_KICKOFF.md", ): assert target in workflow, f"boundary step stopped covering {target}" +def test_native_carrier_embeds_every_required_agent_foundation() -> None: + builder = (REPO_ROOT / "scripts/build-vibecrafted-release.sh").read_text( + encoding="utf-8" + ) + stager = (REPO_ROOT / "scripts/stage-runtime-foundations.sh").read_text( + encoding="utf-8" + ) + installer = (REPO_ROOT / "scripts/vetcoders_install.py").read_text(encoding="utf-8") + + assert 'stage-runtime-foundations.sh" "$runtime/bin"' in builder + assert "'screenscribe==0.1.19'" in builder + assert '"$runtime/bin/screenscribe" --version' in builder + assert '"$runtime/source-provenance.json"' in builder + assert 'carrier --source "$REPO_ROOT"' in builder + assert "provenance_stage" not in builder + assert '"$runtime/scripts/vc-frame-product-entry.sh"' in builder + for command in ("loct", "loctree-mcp", "aicx", "aicx-mcp", "prview"): + assert command in stager + assert f'generation / "bin/{command}"' in installer + assert 'generation / "bin/screenscribe"' in installer + assert 'generation / "libexec/vc-frame"' in installer + assert "_write_runtime_generation_manifest(" in installer + assert "runtime-foundations.json" in stager + assert "OPENSSL_STATIC=1" in stager + assert "PRView retains a non-system dynamic library dependency" in stager + assert "ced57997dd97a2b08960f35e3a657d7b0c49a200" in stager + assert "remap-path-prefix" in stager + assert "cargo install --locked" in stager + assert 'rm -rf "$WORK" 2>/dev/null || true' in stager + + def test_macos_publisher_cold_verifies_exact_uploaded_bytes() -> None: makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8") publisher = (REPO_ROOT / "scripts/publish-vibecrafted-release.sh").read_text( @@ -199,6 +239,9 @@ def test_macos_publisher_cold_verifies_exact_uploaded_bytes() -> None: for entry in ( '"$DMG_NAME"', '"$DMG_NAME.sha256"', + '"$RUNTIME_PACK_NAME"', + '"$RUNTIME_PACK_NAME.sha256"', + '"$RUNTIME_PACK_NAME.sig"', '"$PORTABLE_NAME"', '"$PORTABLE_NAME.sha256"', '"release-output.json"', @@ -227,6 +270,23 @@ def test_macos_publisher_cold_verifies_the_portable_channel() -> None: ) +def test_macos_publisher_cold_verifies_runtime_pack_install_and_uninstall() -> None: + publisher = (REPO_ROOT / "scripts/publish-vibecrafted-release.sh").read_text( + encoding="utf-8" + ) + + assert '["runtime_pack"]["path"]' in publisher + assert 'shasum -a 256 -c "$RUNTIME_PACK_NAME.sha256"' in publisher + assert 'openssl dgst -sha256 -verify "$RUNTIME_PACK_PUBLIC_KEY"' in publisher + assert 'cmp "$RUNTIME_PACK" "$DOWNLOAD_DIR/$RUNTIME_PACK_NAME"' in publisher + assert publisher.count("--verify-only") == 2 + assert '--expected-terminal-revision "$VC_TERMINAL_SHA"' in publisher + assert '--expected-frame-revision "$VC_FRAME_SHA"' in publisher + assert "make --no-print-directory install RUNTIME_PACK=" in publisher + assert "make --no-print-directory uninstall" in publisher + assert 'find "$RUNTIME_PACK_SMOKE_HOME" -mindepth 1 -print -quit' in publisher + + def test_portable_builder_binds_one_commit_and_proves_its_own_bytes() -> None: makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8") builder = (REPO_ROOT / "scripts/build-portable-release.sh").read_text( @@ -267,6 +327,15 @@ def test_builder_emits_the_canonical_versioned_dmg_and_checksum() -> None: assert 'printf \'%s\\n\' "$RUNTIME_VERSION" > "$runtime/VERSION"' in builder assert 'DMG_CHECKSUM="$DMG.sha256"' in builder assert 'LEGACY_DMG="$DIST_DIR/Vibecrafted.dmg"' in builder + assert ( + 'RUNTIME_PACK_NAME="Vibecrafted_RuntimePack_${VERSION}-${RELEASE_DATE}-${ROOT_SHA:0:8}-${RUNTIME_PACK_PLATFORM}-${RUNTIME_PACK_ARCHITECTURE}.tar.gz"' + in builder + ) + assert '"$REPO_ROOT/scripts/package-runtime-pack.sh"' in builder + assert '-out "$EMBEDDED_RUNTIME_PACK_SIGNATURE" "$EMBEDDED_RUNTIME_PACK"' in builder + assert 'install -m 0644 "$EMBEDDED_RUNTIME_PACK" "$RUNTIME_PACK"' in builder + assert 'cmp "$EMBEDDED_RUNTIME_PACK" "$RUNTIME_PACK"' in builder + assert '--runtime-pack "$RUNTIME_PACK"' in builder assert 'rm -f "$DMG_CHECKSUM" "$LEGACY_DMG"' in builder assert '/usr/bin/shasum -a 256 "$DMG_NAME"' in builder assert "-type d -name __pycache__" in builder @@ -280,6 +349,19 @@ def test_builder_emits_the_canonical_versioned_dmg_and_checksum() -> None: assert '"$runtime/runtime"' not in builder +def test_xcodegen_project_is_generated_from_one_tracked_source() -> None: + builder = (REPO_ROOT / "scripts/build-vibecrafted-release.sh").read_text( + encoding="utf-8" + ) + ignore = (REPO_ROOT / ".gitignore").read_text(encoding="utf-8") + assert (REPO_ROOT / "vibecrafted-app/shell-agent/app/project.yml").is_file() + assert "/vibecrafted-app/shell-agent/app/Vibecrafted.xcodeproj/" in ignore + assert ( + 'git -C "$REPO_ROOT" ls-files --error-unmatch "$generated_project"' in builder + ) + assert "generated Xcode project must not be tracked" in builder + + def test_release_entrypoint_renderer_uses_manifest_and_preserves_existing( tmp_path: Path, ) -> None: @@ -355,6 +437,8 @@ def test_release_bundle_binds_the_vibecrafted_app_icon() -> None: icon = REPO_ROOT / "vibecrafted-app/shell-agent/app/Vibecrafted/Vibecrafted.icns" assert "INFOPLIST_FILE: Vibecrafted/Info.plist" in project + assert 'MARKETING_VERSION: "4.3.0"' in project + assert '- "Vibecrafted.icns"' in project assert "CFBundleIconFile" in info_plist assert "Vibecrafted.icns" in info_plist assert 'plist["CFBundleIconFile"] = contract.PRODUCT_ICON_FILE' in manifest @@ -384,6 +468,7 @@ def test_release_bundle_binds_the_canonical_terminal_policy_and_font() -> None: builder = (REPO_ROOT / "scripts/build-vibecrafted-release.sh").read_text( encoding="utf-8" ) + installer = (REPO_ROOT / "scripts/vetcoders_install.py").read_text(encoding="utf-8") assert 'family = "Spot Mono"' in terminal assert "size = 18.5" in terminal @@ -396,10 +481,13 @@ def test_release_bundle_binds_the_canonical_terminal_policy_and_font() -> None: assert "CTFontManagerRegisterFontsForURL" in app_delegate assert "kCTFontFamilyNameAttribute as String" in app_delegate assert 'CTFontDescriptorCreateWithNameAndSize("Spot Mono"' not in app_delegate - assert "let terminalPolicy = generation.appendingPathComponent" in app_delegate - assert 'productConfig.appendingPathComponent("terminal-entry.toml")' in app_delegate - assert 'productConfig.appendingPathComponent("terminal-theme.toml")' in app_delegate - assert 'productConfig.appendingPathComponent("terminal.toml")' not in app_delegate + assert ( + 'terminal_policy = generation / "config/vc-terminal/vibecrafted.toml"' + in installer + ) + assert 'product_config / "terminal-entry.toml"' in installer + assert 'product_config / "terminal-theme.toml"' in installer + assert 'product_config / "terminal.toml"' not in installer assert ( 'install -m 0644 "$SPOT_MONO_FONT" "$resources/fonts/SpotMono.ttc"' in builder ) @@ -516,6 +604,13 @@ def test_dirty_donors_are_a_release_flag_with_a_reaper_not_a_manual_ritual() -> assert "materialize_donor_snapshots" in builder assert "VIBECRAFTED_RELEASE_FAIL_AFTER_SNAPSHOT" in builder + # Regenerated plugin assets are deterministic derived output. Their + # mutation must not make the binary claim that the immutable donor commit + # itself was dirty. + assert 'frame_release_sha="$(git_sha "$FRAME_REPO")"' in builder + assert 'VC_FRAME_GIT_SHA="$frame_release_sha"' in builder + assert "VC_FRAME_GIT_DIRTY=0" in builder + # Reaping goes through git; `rm -rf` alone is what creates ghosts. assert "worktree add --detach" in library assert "worktree remove --force" in library diff --git a/tests/tui/test_runtime_pack_cli.py b/tests/tui/test_runtime_pack_cli.py new file mode 100644 index 00000000..e28a4e9a --- /dev/null +++ b/tests/tui/test_runtime_pack_cli.py @@ -0,0 +1,565 @@ +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import subprocess +import sys +import tarfile +from pathlib import Path + +import pytest +from vibecrafted_core.runtime_pack_contract import ( + RuntimePackContractError, + write_provenance, +) + +REPO_ROOT = Path(__file__).resolve().parents[2] +INSTALLER = REPO_ROOT / "scripts/install-runtime-pack.sh" +PACKAGER = REPO_ROOT / "scripts/package-runtime-pack.sh" +SOURCE_SHA = "1" * 40 +TERMINAL_SHA = "2" * 40 +FRAME_SHA = "3" * 40 +VERSION = "4.3.0" + + +def _fake_runtime_payload(root: Path, capture: Path) -> None: + (root / "bin").mkdir(parents=True) + (root / "scripts").mkdir(parents=True) + contract_dir = root / "vibecrafted-core/vibecrafted_core" + contract_dir.mkdir(parents=True) + (contract_dir / "__init__.py").write_text("", encoding="utf-8") + (contract_dir / "runtime_pack_contract.py").write_bytes( + ( + REPO_ROOT / "vibecrafted-core/vibecrafted_core/runtime_pack_contract.py" + ).read_bytes() + ) + (root / "VERSION").write_text(f"{VERSION}\n", encoding="utf-8") + python = root / "bin/python3" + python.write_text( + "#!/usr/bin/env bash\n" + "export PYTHONDONTWRITEBYTECODE=1\n" + 'if [[ "${1:-}" == "-m" ]]; then\n' + f' exec "{sys.executable}" "$@"\n' + "fi\n" + 'printf "%s\\n" "$@" > "$CAPTURE"\n', + encoding="utf-8", + ) + python.chmod(0o755) + vc_start = root / "bin/vc-start" + vc_start.write_text("#!/bin/sh\n", encoding="utf-8") + vc_start.chmod(0o755) + (root / "scripts/vetcoders_install.py").write_text("# fixture\n", encoding="utf-8") + launcher = root / "scripts/vibecrafted" + launcher.write_text("#!/bin/sh\n", encoding="utf-8") + launcher.chmod(0o755) + capture.parent.mkdir(parents=True, exist_ok=True) + + +def _source_provenance(root: Path, revision: str = SOURCE_SHA) -> None: + payload = { + "schema": "vibecrafted.source-provenance.v2", + "owner_repo": "vetcoders/vibecrafted", + "source_revision": revision, + "payload": {}, + } + (root / "source-provenance.json").write_text( + json.dumps(payload, sort_keys=True, indent=2) + "\n", encoding="utf-8" + ) + + +def _sealed_archive( + tmp_path: Path, + payload: Path, + *, + name: str = "Vibecrafted_RuntimePack_fixture.tar.gz", + source_revision: str = SOURCE_SHA, +) -> tuple[Path, Path]: + archive = tmp_path / name + _source_provenance(payload, source_revision) + write_provenance( + payload, + carrier_basename=name, + version=VERSION, + platform="darwin", + architecture="arm64", + source_revision=source_revision, + terminal_revision=TERMINAL_SHA, + frame_revision=FRAME_SHA, + ) + with tarfile.open(archive, "w:gz") as bundle: + bundle.add(payload, arcname="VibecraftedRuntime") + checksum = hashlib.sha256(archive.read_bytes()).hexdigest() + archive.with_suffix(archive.suffix + ".sha256").write_text( + f"{checksum} {archive.name}\n", encoding="utf-8" + ) + private_key = tmp_path / "signing.key" + public_key = tmp_path / "signing.pub" + subprocess.run( + ["openssl", "genpkey", "-algorithm", "RSA", "-out", str(private_key)], + check=True, + capture_output=True, + ) + subprocess.run( + [ + "openssl", + "pkey", + "-in", + str(private_key), + "-pubout", + "-out", + str(public_key), + ], + check=True, + capture_output=True, + ) + subprocess.run( + [ + "openssl", + "dgst", + "-sha256", + "-sign", + str(private_key), + "-out", + str(archive) + ".sig", + str(archive), + ], + check=True, + capture_output=True, + ) + return archive, public_key + + +def _run( + *arguments: str, env: dict[str, str] | None = None +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + "bash", + str(INSTALLER), + "--expected-version", + VERSION, + "--expected-platform", + "darwin", + "--expected-architecture", + "arm64", + *arguments, + ], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + env={**os.environ, **(env or {})}, + ) + + +def test_runtime_pack_rejects_directory_carrier(tmp_path: Path) -> None: + payload = tmp_path / "VibecraftedRuntime" + capture = tmp_path / "argv" + _fake_runtime_payload(payload, capture) + + result = _run("--pack", str(payload), env={"CAPTURE": str(capture)}) + + assert result.returncode != 0 + assert "canonical .tar.gz carrier" in result.stderr + assert not capture.exists() + + +def test_runtime_pack_rejects_app_as_carrier(tmp_path: Path) -> None: + app = tmp_path / "Vibecrafted.app" + payload = app / "Contents/Resources/runtime" + capture = tmp_path / "argv" + _fake_runtime_payload(payload, capture) + terminal = app / "Contents/Helpers/vc-terminal.app/Contents/MacOS/alacritty" + frame = app / "Contents/Helpers/vc-frame" + for helper in (terminal, frame): + helper.parent.mkdir(parents=True, exist_ok=True) + helper.write_text("#!/bin/sh\n", encoding="utf-8") + helper.chmod(0o755) + + result = _run("--pack", str(app), env={"CAPTURE": str(capture)}) + + assert result.returncode != 0 + assert "canonical .tar.gz carrier" in result.stderr + assert not capture.exists() + + +def test_runtime_uninstall_uses_installed_generation_tool(tmp_path: Path) -> None: + home = tmp_path / "home" + runtime_home = home / ".local/share/vibecrafted" + generation = runtime_home / "releases/4.2.4+gfixture" + capture = tmp_path / "argv" + _fake_runtime_payload(generation, capture) + (runtime_home / "tools").mkdir(parents=True) + (runtime_home / "tools/vibecrafted-current").symlink_to(generation) + (runtime_home / "install-receipt.json").write_text("{}\n", encoding="utf-8") + + result = _run( + "--uninstall", + "--dry-run", + env={"HOME": str(home), "CAPTURE": str(capture)}, + ) + + assert result.returncode == 0, result.stderr + assert capture.read_text(encoding="utf-8").splitlines() == [ + str(generation / "scripts/vetcoders_install.py"), + "runtime-uninstall", + "--dry-run", + ] + + +def test_runtime_uninstall_is_idempotent_when_receipt_is_absent(tmp_path: Path) -> None: + result = _run("--uninstall", env={"HOME": str(tmp_path / "home")}) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout)["status"] == "absent" + + +def test_runtime_uninstall_recovers_from_pack_when_projection_is_missing( + tmp_path: Path, +) -> None: + home = tmp_path / "home" + runtime_home = home / ".local/share/vibecrafted" + runtime_home.mkdir(parents=True) + (runtime_home / "install-receipt.json").write_text("{}\n", encoding="utf-8") + payload = tmp_path / "VibecraftedRuntime" + capture = tmp_path / "argv" + _fake_runtime_payload(payload, capture) + + archive, public_key = _sealed_archive(tmp_path, payload) + result = _run( + "--uninstall", + "--pack", + str(archive), + env={ + "HOME": str(home), + "CAPTURE": str(capture), + "VIBECRAFTED_RUNTIME_PACK_PUBLIC_KEY": str(public_key), + }, + ) + + assert result.returncode == 0, result.stderr + assert capture.read_text(encoding="utf-8").splitlines() == [ + next( + line + for line in capture.read_text(encoding="utf-8").splitlines() + if line.endswith("scripts/vetcoders_install.py") + ), + "runtime-uninstall", + ] + + +def test_runtime_pack_rejects_an_unresolvable_path(tmp_path: Path) -> None: + missing = tmp_path / "missing/RuntimePack.tar.gz" + + result = _run("--pack", str(missing)) + + assert result.returncode != 0 + assert "cannot resolve Runtime Pack path" in result.stderr + + +def test_runtime_packager_emits_one_closed_root_and_checksum(tmp_path: Path) -> None: + app = tmp_path / "Vibecrafted.app" + runtime = app / "Contents/Resources/runtime" + required = ( + "VERSION", + "bin/python3", + "bin/vc-start", + "bin/vibecrafted", + "scripts/vibecrafted", + "scripts/vc-frame-product-entry.sh", + "scripts/vetcoders_install.py", + ) + for relative in required: + path = runtime / relative + path.parent.mkdir(parents=True, exist_ok=True) + if relative == "bin/python3": + path.write_text( + f'#!/usr/bin/env bash\nexec "{sys.executable}" "$@"\n', + encoding="utf-8", + ) + else: + path.write_text( + f"{VERSION}\n" if relative == "VERSION" else "fixture\n", + encoding="utf-8", + ) + if relative.startswith("bin/") or relative == "scripts/vibecrafted": + path.chmod(0o755) + contract_dir = runtime / "vibecrafted-core/vibecrafted_core" + contract_dir.mkdir(parents=True) + (contract_dir / "__init__.py").write_text("", encoding="utf-8") + shutil.copy2( + REPO_ROOT / "vibecrafted-core/vibecrafted_core/runtime_pack_contract.py", + contract_dir / "runtime_pack_contract.py", + ) + _source_provenance(runtime) + terminal = app / "Contents/Helpers/vc-terminal.app/Contents/MacOS/alacritty" + frame = app / "Contents/Helpers/vc-frame" + for helper in (terminal, frame): + helper.parent.mkdir(parents=True, exist_ok=True) + helper.write_text("#!/bin/sh\n", encoding="utf-8") + helper.chmod(0o755) + output = tmp_path / "Vibecrafted_RuntimePack_fixture.tar.gz" + + result = subprocess.run( + [ + "bash", + str(PACKAGER), + "--app", + str(app), + "--output", + str(output), + "--source-revision", + SOURCE_SHA, + "--terminal-revision", + TERMINAL_SHA, + "--frame-revision", + FRAME_SHA, + "--version", + VERSION, + "--platform", + "darwin", + "--architecture", + "arm64", + ], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + with tarfile.open(output, "r:gz") as archive: + names = {member.name for member in archive.getmembers()} + assert all( + name == "VibecraftedRuntime" or name.startswith("VibecraftedRuntime/") + for name in names + ) + assert "VibecraftedRuntime/bin/vc-terminal" in names + assert "VibecraftedRuntime/bin/vc-frame" in names + assert "VibecraftedRuntime/bin/vc-start" in names + assert "VibecraftedRuntime/libexec/vc-frame" in names + assert "VibecraftedRuntime/runtime-pack-provenance.json" in names + assert "VibecraftedRuntime/scripts/vibecrafted" in names + assert not any( + member.issym() or member.islnk() for member in archive.getmembers() + ) + expected = hashlib.sha256(output.read_bytes()).hexdigest() + assert ( + output.with_suffix(output.suffix + ".sha256") + .read_text(encoding="utf-8") + .split()[0] + == expected + ) + + # Linux arm64 additionally requires the closed executable inventory that + # the native builder records from real produced bytes. A synthetic payload + # cannot be mislabeled as a complete Linux carrier merely because it has + # executable-shaped files. + for relative in ("bin/vc-terminal", "bin/vc-frame", "libexec/vc-frame"): + helper = runtime / relative + helper.parent.mkdir(parents=True, exist_ok=True) + helper.write_text("#!/bin/sh\n", encoding="utf-8") + helper.chmod(0o755) + linux_output = tmp_path / "Vibecrafted_RuntimePack_fixture-linux-arm64.tar.gz" + linux = subprocess.run( + [ + "bash", + str(PACKAGER), + "--payload-root", + str(runtime), + "--output", + str(linux_output), + "--source-revision", + SOURCE_SHA, + "--terminal-revision", + TERMINAL_SHA, + "--frame-revision", + FRAME_SHA, + "--version", + VERSION, + "--platform", + "linux", + "--architecture", + "arm64", + ], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + assert linux.returncode != 0 + assert "Linux arm64 Runtime Pack inventory is invalid" in linux.stderr + assert not linux_output.exists() + + +def test_runtime_pack_contract_rejects_missing_install_launcher(tmp_path: Path) -> None: + payload = tmp_path / "VibecraftedRuntime" + capture = tmp_path / "argv" + _fake_runtime_payload(payload, capture) + _source_provenance(payload) + (payload / "scripts/vibecrafted").unlink() + + with pytest.raises( + RuntimePackContractError, + match="Runtime Pack installer payload is missing scripts/vibecrafted", + ): + write_provenance( + payload, + carrier_basename="Vibecrafted_RuntimePack_fixture.tar.gz", + version=VERSION, + platform="darwin", + architecture="arm64", + source_revision=SOURCE_SHA, + terminal_revision=TERMINAL_SHA, + frame_revision=FRAME_SHA, + ) + + +def test_runtime_pack_contract_rejects_missing_vc_start(tmp_path: Path) -> None: + payload = tmp_path / "VibecraftedRuntime" + capture = tmp_path / "argv" + _fake_runtime_payload(payload, capture) + _source_provenance(payload) + (payload / "bin/vc-start").unlink() + + with pytest.raises( + RuntimePackContractError, + match="Runtime Pack installer payload is missing bin/vc-start", + ): + write_provenance( + payload, + carrier_basename="Vibecrafted_RuntimePack_fixture.tar.gz", + version=VERSION, + platform="darwin", + architecture="arm64", + source_revision=SOURCE_SHA, + terminal_revision=TERMINAL_SHA, + frame_revision=FRAME_SHA, + ) + + +def test_runtime_pack_archive_requires_release_signature(tmp_path: Path) -> None: + archive = tmp_path / "Vibecrafted_RuntimePack_fixture.tar.gz" + root = tmp_path / "source/VibecraftedRuntime" + capture = tmp_path / "argv" + _fake_runtime_payload(root, capture) + with tarfile.open(archive, "w:gz") as bundle: + bundle.add(root, arcname="VibecraftedRuntime") + checksum = hashlib.sha256(archive.read_bytes()).hexdigest() + archive.with_suffix(archive.suffix + ".sha256").write_text( + f"{checksum} {archive.name}\n", encoding="utf-8" + ) + + result = _run("--pack", str(archive), env={"CAPTURE": str(capture)}) + + assert result.returncode != 0 + assert "Runtime Pack signature is missing" in result.stderr + assert not capture.exists() + + +def test_signed_archive_bootstraps_without_ambient_python_and_cleans_temp( + tmp_path: Path, +) -> None: + payload = tmp_path / "source/VibecraftedRuntime" + capture = tmp_path / "argv" + _fake_runtime_payload(payload, capture) + archive, public_key = _sealed_archive(tmp_path, payload) + fake_bin = tmp_path / "fake-bin" + fake_bin.mkdir() + ambient_python = fake_bin / "python3" + ambient_python.write_text("#!/bin/sh\nexit 97\n", encoding="utf-8") + ambient_python.chmod(0o755) + extraction_home = tmp_path / "extract" + extraction_home.mkdir() + + result = _run( + "--pack", + str(archive), + env={ + "CAPTURE": str(capture), + "PATH": f"{fake_bin}:/usr/bin:/bin:/usr/sbin:/sbin", + "TMPDIR": str(extraction_home), + "VIBECRAFTED_RUNTIME_PACK_PUBLIC_KEY": str(public_key), + }, + ) + + assert result.returncode == 0, result.stderr + arguments = capture.read_text(encoding="utf-8").splitlines() + assert arguments[1:3] == ["runtime-install", "--payload-root"] + assert arguments[3].startswith(str(extraction_home)) + assert arguments[0] == f"{arguments[3]}/scripts/vetcoders_install.py" + assert not any(extraction_home.iterdir()) + + +def test_signed_carrier_rejects_expected_source_mismatch_before_installer( + tmp_path: Path, +) -> None: + payload = tmp_path / "source/VibecraftedRuntime" + capture = tmp_path / "argv" + _fake_runtime_payload(payload, capture) + archive, public_key = _sealed_archive(tmp_path, payload) + + result = _run( + "--pack", + str(archive), + "--expected-source-revision", + "4" * 40, + env={ + "CAPTURE": str(capture), + "VIBECRAFTED_RUNTIME_PACK_PUBLIC_KEY": str(public_key), + }, + ) + + assert result.returncode != 0 + assert "internal provenance verification failed" in result.stderr + assert not capture.exists() + + +def test_signed_carrier_rejects_expected_donor_mismatch_before_installer( + tmp_path: Path, +) -> None: + payload = tmp_path / "source/VibecraftedRuntime" + capture = tmp_path / "argv" + _fake_runtime_payload(payload, capture) + archive, public_key = _sealed_archive(tmp_path, payload) + + result = _run( + "--pack", + str(archive), + "--expected-terminal-revision", + "5" * 40, + env={ + "CAPTURE": str(capture), + "VIBECRAFTED_RUNTIME_PACK_PUBLIC_KEY": str(public_key), + }, + ) + + assert result.returncode != 0 + assert "internal provenance verification failed" in result.stderr + assert not capture.exists() + + +def test_signed_carrier_rejects_selected_platform_mismatch_before_installer( + tmp_path: Path, +) -> None: + payload = tmp_path / "source/VibecraftedRuntime" + capture = tmp_path / "argv" + _fake_runtime_payload(payload, capture) + archive, public_key = _sealed_archive(tmp_path, payload) + + result = _run( + "--pack", + str(archive), + "--expected-platform", + "linux", + env={ + "CAPTURE": str(capture), + "VIBECRAFTED_RUNTIME_PACK_PUBLIC_KEY": str(public_key), + }, + ) + + assert result.returncode != 0 + assert "internal provenance verification failed" in result.stderr + assert not capture.exists() diff --git a/tests/tui/test_safe_quit.py b/tests/tui/test_safe_quit.py new file mode 100644 index 00000000..fcae4a76 --- /dev/null +++ b/tests/tui/test_safe_quit.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +QUIT_SAFETY = REPO_ROOT / "vibecrafted-app/shell-agent/app/Vibecrafted/QuitSafety.swift" + + +def _run_swift_policy(tmp_path: Path, payload: bytes, status: int) -> str: + swiftc = shutil.which("swiftc") + if swiftc is None: + pytest.skip("swiftc is required for the macOS Safe Quit contract") + main = tmp_path / "main.swift" + main.write_text( + r""" +import Foundation + +let payload = Data(FileHandle.standardInput.readDataToEndOfFile()) +switch decodeRuntimeActivityTruth(data: payload, terminationStatus: Int32(CommandLine.arguments[1])!) { +case .available(let summary): + print("available:\(summary.lanes):\(summary.worktrees)") +case .unavailable(let reason): + print("unavailable:\(reason)") +} +""", + encoding="utf-8", + ) + binary = tmp_path / "quit-safety" + subprocess.run( + [swiftc, str(QUIT_SAFETY), str(main), "-o", str(binary)], + check=True, + cwd=REPO_ROOT, + ) + return ( + subprocess.run( + [str(binary), str(status)], + input=payload, + capture_output=True, + check=True, + ) + .stdout.decode() + .strip() + ) + + +def test_safe_quit_policy_accepts_zero_active_lanes(tmp_path: Path) -> None: + payload = json.dumps( + { + "schema_version": "vibecrafted.lifecycle-activity.v1", + "summary": {"lanes": 0, "worktrees": 0}, + } + ).encode() + assert _run_swift_policy(tmp_path, payload, 0) == "available:0:0" + + +@pytest.mark.parametrize( + ("payload", "status", "reason"), + [ + (b"{}", 7, "exited with status 7"), + (b"not-json", 0, "malformed JSON"), + ], +) +def test_safe_quit_policy_fails_safe_when_truth_is_unavailable( + tmp_path: Path, payload: bytes, status: int, reason: str +) -> None: + result = _run_swift_policy(tmp_path, payload, status) + assert result.startswith("unavailable:") + assert reason in result + + +def test_app_termination_routes_share_the_fail_safe_policy() -> None: + delegate = ( + REPO_ROOT / "vibecrafted-app/shell-agent/app/Vibecrafted/AppDelegate.swift" + ).read_text(encoding="utf-8") + assert 'process.arguments = ["status", "--activity", "--json"]' in delegate + assert "func applicationShouldTerminate(" in delegate + assert ( + 'alert.messageText = "Vibecrafted lifecycle truth is unavailable"' in delegate + ) + assert delegate.count('alert.addButton(withTitle: "Cancel")') >= 2 + assert delegate.count('alert.addButton(withTitle: "Quit Anyway")') >= 2 + request_quit = delegate[ + delegate.index("@objc private func requestQuit()") : delegate.index( + "private func buildMainMenu()" + ) + ] + assert "NSApp.terminate(nil)" in request_quit + assert "activeRunSummary" not in request_quit diff --git a/tests/tui/test_staged_tools_sync.py b/tests/tui/test_staged_tools_sync.py index 2c69c63f..edfc60c1 100644 --- a/tests/tui/test_staged_tools_sync.py +++ b/tests/tui/test_staged_tools_sync.py @@ -83,6 +83,13 @@ def _write_complete_source( 1, ) _write_executable(root / "scripts" / "vibecrafted", launcher) + _write_executable( + root / "vibecrafted-core/vibecrafted_core/deck/vibecrafted", launcher + ) + _write_executable( + root / "bin" / "python3", + f'#!/bin/sh\nexec {installer.shlex_quote(str(Path(sys.executable).absolute()))} "$@"\n', + ) _write_source_provenance_fixture(root) @@ -327,6 +334,10 @@ def _write_valid_runtime_generation(root: Path) -> None: deck.parent.mkdir(parents=True) deck.write_bytes((REPO_ROOT / "scripts" / "vibecrafted").read_bytes()) deck.chmod(0o755) + runtime_deck = root / "bin" / "vibecrafted" + runtime_deck.parent.mkdir(parents=True) + runtime_deck.write_bytes(deck.read_bytes()) + runtime_deck.chmod(0o755) def _write_runtime_launch_agent( @@ -5990,9 +6001,7 @@ def observed_replace(source_path, destination_path) -> None: ) assert manifest["schema"] == installer._RUNTIME_GENERATION_MANIFEST_SCHEMA assert manifest["version"] == "9.9.9+gtest" - assert manifest["entrypoint"] == ( - "vibecrafted-core/vibecrafted_core/deck/vibecrafted" - ) + assert manifest["entrypoint"] == installer._RUNTIME_GENERATION_ENTRYPOINT.as_posix() assert (manifest["owner_repo"], manifest["source_revision"]) == ( source_provenance["owner_repo"], source_provenance["source_revision"], @@ -6372,9 +6381,7 @@ def test_runtime_generation_doctor_verifies_manifest_and_launcher( ) launcher = home / ".local" / "bin" / "vibecrafted" launcher.parent.mkdir(parents=True) - launcher.symlink_to( - current / "vibecrafted-core" / "vibecrafted_core" / "deck" / "vibecrafted" - ) + launcher.symlink_to(current / installer._RUNTIME_GENERATION_ENTRYPOINT) findings = installer._runtime_generation_contract_findings() assert findings == [ @@ -6432,9 +6439,7 @@ def test_runtime_generation_doctor_rejects_deck_drift_and_incomplete_hashes( ) launcher = home / ".local" / "bin" / "vibecrafted" launcher.parent.mkdir(parents=True) - launcher.symlink_to( - current / "vibecrafted-core" / "vibecrafted_core" / "deck" / "vibecrafted" - ) + launcher.symlink_to(current / installer._RUNTIME_GENERATION_ENTRYPOINT) deck = generation / installer._RUNTIME_GENERATION_ENTRYPOINT original = deck.read_bytes() deck.write_bytes(original + b"\nexit 99\n") @@ -6488,7 +6493,7 @@ def test_runtime_generation_doctor_rejects_launcher_from_old_generation( [finding] = installer._runtime_generation_contract_findings() assert finding.level == "fail" - assert "does not resolve to the current generation entrypoint" in finding.message + assert "neither resolves to nor wraps" in finding.message def test_chained_prepared_publish_keeps_last_verified_rollback_target( diff --git a/tests/tui/test_unified_app_contract.py b/tests/tui/test_unified_app_contract.py index 89d88868..893ae544 100644 --- a/tests/tui/test_unified_app_contract.py +++ b/tests/tui/test_unified_app_contract.py @@ -5,11 +5,13 @@ import json import os import plistlib +import re import shutil import stat import struct import subprocess import sys +import tarfile import tempfile from collections.abc import Callable from contextlib import contextmanager @@ -18,6 +20,7 @@ import pytest from vibecrafted_core import product_contract as contract +from vibecrafted_core import runtime_pack_contract REPO_ROOT = Path(__file__).resolve().parents[2] VERIFY_SCRIPT = REPO_ROOT / "scripts/verify-vibecrafted-product.sh" @@ -200,6 +203,46 @@ def _module_fixture( return manifest +def _runtime_pack_fixture(app: Path) -> str: + name = "Vibecrafted_RuntimePack_1.0.0-20260814-22222222-darwin-arm64.tar.gz" + embedded = app / "Contents/Resources/runtime-pack" / name + with tempfile.TemporaryDirectory(prefix="runtime-pack-fixture.") as temporary: + payload = Path(temporary) / "VibecraftedRuntime" + payload.mkdir() + (payload / "VERSION").write_text("1.0.0\n", encoding="utf-8") + launcher = payload / "scripts/vibecrafted" + launcher.parent.mkdir(parents=True) + launcher.write_text("#!/bin/sh\n", encoding="utf-8") + launcher.chmod(0o755) + vc_start = payload / "bin/vc-start" + vc_start.parent.mkdir(parents=True) + vc_start.write_text("#!/bin/sh\n", encoding="utf-8") + vc_start.chmod(0o755) + _write_json( + payload / "source-provenance.json", + { + "schema": runtime_pack_contract.SOURCE_PROVENANCE_SCHEMA, + "owner_repo": "vetcoders/vibecrafted", + "source_revision": "2" * 40, + "payload": {}, + }, + ) + runtime_pack_contract.write_provenance( + payload, + carrier_basename=name, + version="1.0.0", + platform="darwin-arm64", + architecture="arm64", + source_revision="2" * 40, + terminal_revision="4" * 40, + frame_revision="6" * 40, + ) + embedded.parent.mkdir(parents=True, exist_ok=True) + with tarfile.open(embedded, "w:gz") as archive: + archive.add(payload, arcname="VibecraftedRuntime") + return name + + def _app_fixture(app: Path, macho_executable: Path) -> dict[str, Any]: terminal_relative = "Contents/Helpers/vc-terminal.app/Contents/MacOS/alacritty" for relative in ( @@ -323,6 +366,7 @@ def add_module_binding( entrypoint_name="frame", product_entry=frame_product_entry, ) + runtime_pack_name = _runtime_pack_fixture(app) manifest = { "schema": contract.PRODUCT_SCHEMA, "product": contract.PRODUCT_NAME, @@ -384,6 +428,11 @@ def add_module_binding( kind="executable", ), _entry(app, contract._LAUNCH_PRIMARY_SHELL, kind="resource"), + _entry( + app, + f"Contents/Resources/runtime-pack/{runtime_pack_name}", + kind="resource", + ), ], "entrypoints": { "app": "Contents/MacOS/Vibecrafted", @@ -437,7 +486,7 @@ def _transaction_fixture(path: Path, macho_executable: Path) -> dict[str, Any]: "owner_repo": "vetcoders/vibecrafted", "source_revision": "8" * 40, "source_payload": _runtime_source_payload(), - "entrypoint": "vibecrafted-core/vibecrafted_core/deck/vibecrafted", + "entrypoint": contract.RUNTIME_GENERATION_ENTRYPOINT, "hashes": { relative: f"{index:x}" * 64 for index, relative in enumerate( @@ -551,6 +600,18 @@ def _release_output_fixture( } policy = contract._release_policy() executable = app / product["outer_bundle_code"]["path"] + runtime_pack_name = ( + f"Vibecrafted_RuntimePack_{product['version']}-20260814-" + f"{product['git_sha'][:8]}-darwin-arm64.tar.gz" + ) + embedded_runtime_pack = app / "Contents/Resources/runtime-pack" / runtime_pack_name + runtime_pack = root / runtime_pack_name + if runtime_pack.resolve() != embedded_runtime_pack.resolve(): + shutil.copy2(embedded_runtime_pack, runtime_pack) + with tarfile.open(runtime_pack, "r:gz") as archive: + provenance_bytes = archive.extractfile( + f"VibecraftedRuntime/{runtime_pack_contract.PROVENANCE_NAME}" + ).read() payload = { "schema": contract.RELEASE_OUTPUT_SCHEMA, "signature_policy": { @@ -590,6 +651,24 @@ def _release_output_fixture( "sha256": _sha256(dmg), "size": dmg.stat().st_size, }, + "runtime_pack": { + "path": runtime_pack_name, + "embedded_path": f"Contents/Resources/runtime-pack/{runtime_pack_name}", + "sha256": _sha256(runtime_pack), + "size": runtime_pack.stat().st_size, + "provenance": { + "path": runtime_pack_contract.PROVENANCE_NAME, + "sha256": hashlib.sha256(provenance_bytes).hexdigest(), + "version": product["version"], + "platform": "darwin-arm64", + "architecture": "arm64", + "source_revisions": { + "vibecrafted": product["git_sha"], + "vc-terminal": modules["vc-terminal"]["git_sha"], + "vc-frame": modules["vc-frame"]["git_sha"], + }, + }, + }, "modules": { name: { "manifest": { @@ -775,9 +854,30 @@ def test_native_app_bootstraps_and_launches_only_the_canonical_product_entry() - assert "launchWorkspaceTerminal()" in launch_handler assert "showMainWindowIfNeeded()" not in launch_handler assert "\tLSUIElement\n\t" in info - assert 'withTitle: "Open Console"' in delegate - assert 'withTitle: "Open vc-terminal"' in delegate - assert 'withTitle: "Quit"' in delegate + assert 'withTitle: "Open VC Console"' in delegate + assert 'withTitle: "Open VC Terminal"' in delegate + assert 'withTitle: "VC Server"' in delegate + assert 'withTitle: "Start"' in delegate + assert 'withTitle: "Stop"' in delegate + assert 'withTitle: "Restart"' in delegate + assert 'withTitle: "Open Logs"' in delegate + assert 'withTitle: "Server Diagnostics…"' in delegate + assert 'withTitle: "About Vibecrafted"' in delegate + assert 'withTitle: "Vibecrafted Help"' in delegate + assert 'withTitle: "Quit Vibecrafted"' in delegate + assert "#selector(requestQuit)" in delegate + assert 'process.arguments = ["status", "--activity", "--json"]' in delegate + assert "func applicationShouldTerminate(" in delegate + assert 'withTitle: "Cancel"' in delegate + assert 'withTitle: "Quit Anyway"' in delegate + assert 'appendingPathComponent("server/supervisor.status.json")' in delegate + assert "serverActionArguments(for: action)" in delegate + assert 'process.arguments = ["server", "service", "status", "--json"]' in delegate + assert 'process.arguments = ["server", "service", "logs", "--json"]' in delegate + assert "menu.delegate = self" in delegate + assert "statusRefreshTimer = Timer.scheduledTimer(" in delegate + assert "statusIcon(health:" in delegate + assert "health.color.setFill()" in delegate assert "process.isRunning" in delegate assert ( "NSRunningApplication(processIdentifier: process.processIdentifier)?.activate(options: [])" @@ -790,28 +890,38 @@ def test_native_app_bootstraps_and_launches_only_the_canonical_product_entry() - ] assert " false\n" in termination_handler assert "Contents/Helpers/vc-terminal.app/Contents/MacOS/alacritty" in delegate - assert "config/alacritty/launch-primary-shell.zsh" in delegate - assert 'appendingPathComponent("releases", isDirectory: true)' in delegate - assert 'appendingPathComponent("active.json")' in delegate - assert 'generation.appendingPathComponent("bin/vc-start")' in delegate - assert "let runtimeEntries = try manager.contentsOfDirectory" in delegate - assert "launcherHome.appendingPathComponent(name)" in delegate + assert 'appendingPathComponent("runtime-pack", isDirectory: true)' in delegate + assert 'appendingPathComponent("install-runtime-pack.sh")' in delegate + assert '"--expected-source-revision"' in delegate assert ( - 'launcherHome.appendingPathComponent("vc-terminal"), common: common, ' - "executable: terminalHost" in delegate - ) - assert 'generation.appendingPathComponent("bin/vc-server")' in delegate - assert 'generation.appendingPathComponent("bin/vc-guardian")' in delegate - assert 'generation.appendingPathComponent("bin/vc-server-supervisor")' in delegate - assert "rename(temporary.path, destination.path)" in delegate - assert "assertNoSymlinks(below: generation)" in delegate + 'appendingPathComponent("runtime")' + not in delegate[ + delegate.index("private func installCanonicalRuntime") : delegate.index( + "private func uninstallCanonicalRuntime" + ) + ] + ) + assert 'arguments: ["--uninstall"]' in delegate + assert '"--payload-root", runtime.path' not in delegate + assert '"--terminal-host", terminalHost.path' in delegate + assert '"--frame-helper", frameHelper.path' in delegate + assert "JSONDecoder().decode(CanonicalRuntimeInstall.self" in delegate + # The installer returns POSIX paths, not URL strings. Decoding them directly + # as URL produces relative URLs whose `.path` passes file checks but which + # Foundation.Process rejects as an executableURL on macOS. + assert "container.decode(String.self, forKey: key)" in delegate + assert 'guard path.hasPrefix("/")' in delegate + assert "URL(fileURLWithPath: path)" in delegate + # AppDelegate is the UI/process host, not a second installer implementation. + assert "createDirectory(at:" not in delegate + assert "copyItem(at:" not in delegate + assert "writeLauncher(" not in delegate + assert 'appendingPathComponent("active.json")' not in delegate # PATH composes: the signed generation wins, the caller's PATH survives behind # it. A hard-coded system-only PATH strips Homebrew/~/.local/bin/~/.cargo/bin # from every spawned agent CLI, so `#!/usr/bin/env` shebangs exit 127. assert 'environment["PATH"] = composedPath(' in delegate assert 'environment["PATH"] = "/usr/bin:/bin:/usr/sbin:/sbin"' not in delegate - assert '"export PATH=\\"\\(shellDoubleQuoteBody(' in delegate - assert ':${PATH:-/usr/bin:/bin:/usr/sbin:/sbin}\\""' in delegate assert '["server", "service", "reconcile"]' in delegate assert "shell-agent" not in delegate assert 'name = "vc-start"' in cargo @@ -830,6 +940,48 @@ def test_native_app_bootstraps_and_launches_only_the_canonical_product_entry() - assert "!entry.starts_with('/')" in launcher +def test_status_menu_custom_actions_explicitly_target_app_delegate() -> None: + """Every AppDelegate selector wired into the status menu must set an explicit target. + + LSUIElement apps have no key window, so responder-chain lookup can leave a + status-menu item greyed out; `.target = self` is the contract that keeps + every custom action live. NSApplication selectors resolve via the app + object itself and are exempt. + """ + delegate = ( + REPO_ROOT / "vibecrafted-app/shell-agent/app/Vibecrafted/AppDelegate.swift" + ).read_text(encoding="utf-8") + status_menu = delegate[ + delegate.index("private func buildStatusItem()") : delegate.index( + "@objc private func openConsoleFromStatusItem" + ) + ] + wired = re.findall( + r"let (\w+) = \w+\.addItem\(\s*withTitle: \"([^\"]+)\",\s*action: #selector\((\w+)\)", + status_menu, + ) + assert {selector for _, _, selector in wired} >= { + "openConsoleFromStatusItem", + "openTerminalFromStatusItem", + "startServerFromStatusItem", + "stopServerFromStatusItem", + "restartServerFromStatusItem", + "openServerLogsFromStatusItem", + "showServerDiagnostics", + "showStatusItemHelp", + "requestQuit", + } + for item_name, title, selector in wired: + assert f"{item_name}.target = self" in status_menu, (item_name, title, selector) + untargeted = re.findall( + r"\baddItem\(\s*withTitle: \"[^\"]+\",\s*action: #selector\((?!NSApplication\.)(\w+)\)", + status_menu, + ) + assert sorted(untargeted) == sorted(selector for _, _, selector in wired), ( + untargeted + ) + + def test_tracked_product_source_contains_no_symlinks() -> None: index = subprocess.check_output(["git", "ls-files", "-s"], cwd=REPO_ROOT, text=True) tracked_symlinks = [ @@ -1802,7 +1954,7 @@ def test_transaction_rejects_exact_legacy_four_hash_runtime_manifest( "vibecrafted-core/vibecrafted_core/runtime/generated/vc-frame/config.kdl" ], "vibecrafted-core/vibecrafted_core/deck/vibecrafted": manifest["hashes"][ - "vibecrafted-core/vibecrafted_core/deck/vibecrafted" + contract.RUNTIME_GENERATION_ENTRYPOINT ], } _write_json(manifest_path, manifest) @@ -3132,6 +3284,9 @@ def test_unified_release_has_one_top_level_owner() -> None: assert "run_bundled_verifier release-output" in builder assert '"$verifier" -m vibecrafted_core.product_contract "$@"' in builder assert '"$runtime/vibecrafted-core/vibecrafted_core/VERSION"' in builder + assert '"$runtime/scripts/vetcoders_install.py"' in builder + assert '"$runtime/scripts/distribution_manifest.py"' in builder + assert '"$runtime/scripts/installer_brand.py"' in builder assert '"$REPO_ROOT/scripts/verify-vibecrafted-product.sh"' not in builder assert "--noprofile" not in builder # vc-start, not the release shell, owns this assert "vc-frame.real" not in builder @@ -3156,6 +3311,7 @@ def test_terminal_policy_uses_operator_toml_and_primary_shell_chain() -> None: delegate = ( REPO_ROOT / "vibecrafted-app/shell-agent/app/Vibecrafted/AppDelegate.swift" ).read_text(encoding="utf-8") + installer = (REPO_ROOT / "scripts/vetcoders_install.py").read_text(encoding="utf-8") assert 'family = "Spot Mono"' in terminal assert "size = 18.5" in terminal @@ -3177,14 +3333,31 @@ def test_terminal_policy_uses_operator_toml_and_primary_shell_chain() -> None: assert '"$0" "$@"' in primary_shell assert "process.executableURL = install.terminalHost" in delegate assert '"-e", install.primaryShell.path, install.start.path, "operator"' in delegate - assert 'productConfig.appendingPathComponent("terminal-entry.toml")' in delegate - assert 'productConfig.appendingPathComponent("terminal-theme.toml")' in delegate + assert 'product_config / "terminal-entry.toml"' in installer + assert 'product_config / "terminal-theme.toml"' in installer assert 'let socketRoot = "/tmp/vc-frame-\\(getuid())"' in delegate assert 'environment["VC_FRAME_SOCKET_DIR"] = socketRoot' in delegate assert 'environment["ZELLIJ_SOCKET_DIR"] = socketRoot' in delegate assert 'environment["VIBECRAFTED_LEGACY_VC_FRAME_SOCKET_DIR"]' in delegate +def test_installed_deck_resolves_server_binary_and_site_from_its_generation() -> None: + deck = (REPO_ROOT / "scripts/vibecrafted").read_text(encoding="utf-8") + canonical = ( + REPO_ROOT / "vibecrafted-core/vibecrafted_core/deck/vibecrafted" + ).read_bytes() + + assert canonical == (REPO_ROOT / "scripts/vibecrafted").read_bytes() + assert "_runtime_payload_root()" in deck + assert 'server_bin="${runtime_root:+$runtime_root/bin/vc-server}"' in deck + assert 'site_root="${runtime_root:+$runtime_root/server/site}"' in deck + assert 'local server_bin="$HOME/.local/bin/vc-server"' not in deck + assert ( + '"$script_dir/../vibecrafted-core/vibecrafted_core/runtime/scripts/lib/ulimits.sh"' + in deck + ) + + def test_primary_shell_exits_instead_of_reusing_pty_after_vc_start_failure( tmp_path: Path, ) -> None: diff --git a/tests/tui/test_uv_bootstrap.py b/tests/tui/test_uv_bootstrap.py index b9e65986..ab75ae57 100644 --- a/tests/tui/test_uv_bootstrap.py +++ b/tests/tui/test_uv_bootstrap.py @@ -53,6 +53,8 @@ "install.ps1", "install.toml", "scripts/distribution_manifest.py", + "scripts/build-linux-arm64-runtime-pack.sh", + "scripts/installer_brand.py", "scripts/vetcoders_install.py", "scripts/vibecrafted", "scripts/verify-vibecrafted-product.sh", @@ -70,6 +72,9 @@ "vibecrafted-app/Cargo.lock", "vibecrafted-server/Cargo.toml", "vibecrafted-server/Cargo.lock", + "vibecrafted-vm/RuntimePack.Containerfile", + "vibecrafted-vm/runtime-entry.sh", + "vibecrafted-vm/runtime-provider-lock.json", } FIXTURE_REQUIRED_SURFACES = { "bin/vc-workflow", diff --git a/tests/tui/test_vc_frame_config.py b/tests/tui/test_vc_frame_config.py index 066a16bf..d0c8dd91 100644 --- a/tests/tui/test_vc_frame_config.py +++ b/tests/tui/test_vc_frame_config.py @@ -136,9 +136,11 @@ def test_layout_tab_branding_matches_frame_contract() -> None: for layout_file in sorted(LAYOUTS_DIR.glob("*.kdl")): payload = layout_file.read_text(encoding="utf-8") if layout_file.name == "operator.kdl": - # Launch alias for default_layout "vibecrafted": Start here + Shell. + # Launch alias for default_layout "vibecrafted": product workspace tabs. assert 'tab name="Start here"' in payload + assert 'tab name="Agents"' in payload assert 'tab name="Shell"' in payload + assert 'tab name="voc"' in payload continue assert "𝚅𝚒𝚋𝚎𝚌𝚛𝚊𝚏𝚝𝚎𝚍." in payload, f"{layout_file.name} missing branded tab name" @@ -154,11 +156,15 @@ def test_marbles_layout_is_operator_centric() -> None: def test_operator_layout_matches_vibecrafted_standard() -> None: """vc-start operator.kdl is the launch alias of default_layout vibecrafted: - Start here + Shell, SESSIONS rail on every tab, no strider, no spaced names.""" + Start here + Agents + Shell + voc, SESSIONS rail on every tab, no strider.""" payload = (LAYOUTS_DIR / "operator.kdl").read_text(encoding="utf-8") assert 'tab name="Start here"' in payload + assert 'tab name="Agents"' in payload assert 'tab name="Shell"' in payload - assert 'guide_mode "mission-control"' in payload + assert 'tab name="voc"' in payload + assert "vc-start-here.py" in payload + assert "vc-agent-workshop.py" in payload + assert "vibecrafted tui" in payload assert "session-manager" in payload assert "rail true" in payload assert "default_tab_template" in payload @@ -176,9 +182,11 @@ def test_operator_layout_matches_vibecrafted_standard() -> None: assert "VibeCrafted" not in active -def test_operator_layout_guide_and_shell_tabs() -> None: +def test_operator_layout_start_here_and_shell_tabs() -> None: payload = (LAYOUTS_DIR / "operator.kdl").read_text(encoding="utf-8") - assert 'plugin location="about"' in payload + assert 'command="bash" name="Start Here"' in payload + assert 'plugin location="about"' not in payload + assert "vibecrafted config install --force" in payload assert 'name="Shell"' in payload # Shell wakes with banner then zsh (not bare suspended /bin/zsh). assert "exec zsh" in payload or "zsh -l" in payload diff --git a/tests/tui/test_vibecrafted_launcher.py b/tests/tui/test_vibecrafted_launcher.py index 25630c58..4984353b 100644 --- a/tests/tui/test_vibecrafted_launcher.py +++ b/tests/tui/test_vibecrafted_launcher.py @@ -24,6 +24,8 @@ def _write_fake_agent(bin_dir: Path, name: str, capture_file: Path) -> None: [ "#!/usr/bin/env bash", "set -euo pipefail", + 'if [[ "${1:-}" == "--help" ]]; then printf " --session-id \\n"; exit 0; fi', + 'if [[ "${1:-}" == "--version" ]]; then printf "2.1.232 (Claude Code)\\n"; exit 0; fi', 'printf "%s\\n" "$@" > "$CAPTURE_FILE"', ] ) @@ -181,6 +183,59 @@ def test_python_resolver_skips_bash_product_launchers(tmp_path: Path) -> None: assert Path(result.stdout.strip()) == fake_python +def test_python_resolver_ignores_inherited_python_from_another_generation( + tmp_path: Path, +) -> None: + """VIBECRAFTED_PYTHON from a product session pinned to a different (release) + generation must not drive this deck's core; the uv-tool shim shebang wins.""" + fake_bin = tmp_path / "bin" + tools_home = tmp_path / "tools" + current_gen = tools_home / "vibecrafted-generation-test" + # The rule only bites an *installed* deck, so the copy lives in the generation. + launcher_copy = current_gen / "vibecrafted-deck" + foreign_gen = tmp_path / "releases" / "4.2.4+gold" + for directory in (fake_bin, current_gen / "bin", foreign_gen / "bin"): + directory.mkdir(parents=True) + (tools_home / "vibecrafted-current").symlink_to(current_gen) + _write_trimmed_launcher(launcher_copy) + + shim_python = fake_bin / "python3" + shim_python.write_text("#!/bin/bash\nexit 0\n", encoding="utf-8") + shim_python.chmod(0o755) + shim = fake_bin / "vc-server-supervisor" + shim.write_text(f"#!{shim_python}\n", encoding="utf-8") + shim.chmod(0o755) + foreign_python = foreign_gen / "bin" / "python3" + foreign_python.write_text("#!/bin/bash\nexit 0\n", encoding="utf-8") + foreign_python.chmod(0o755) + owned_python = current_gen / "bin" / "python3" + owned_python.write_text("#!/bin/bash\nexit 0\n", encoding="utf-8") + owned_python.chmod(0o755) + + # The public command is a symlink into the generation; the deck must + # resolve its own file, not the directory the symlink sits in. + public_launcher = fake_bin / "vibecrafted" + public_launcher.symlink_to(launcher_copy) + + def resolve(inherited: Path) -> Path: + env = os.environ.copy() + env["PATH"] = f"{fake_bin}:/usr/bin:/bin" + env["VIBECRAFTED_TOOLS_HOME"] = str(tools_home) + env["VIBECRAFTED_PYTHON"] = str(inherited) + result = subprocess.run( + ["bash", "-c", f'source "{public_launcher}"; _vibecrafted_python'], + cwd=REPO_ROOT, + env=env, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + return Path(result.stdout.strip()) + + assert resolve(foreign_python) == shim_python + assert resolve(owned_python) == owned_python + + def _write_fake_command(bin_dir: Path, name: str, capture_file: Path) -> None: script_names = [name] if name == "vc-frame": @@ -535,13 +590,14 @@ def test_init_claude_uses_interactive_tab_without_print_mode( env["XDG_CONFIG_HOME"] = str(tmp_path / "xdg") env["VIBECRAFTED_ROOT"] = str(REPO_ROOT) env["FAKE_VC_FRAME_SESSION"] = _expected_operator_session() + env["VIBECRAFTED_RUNTIME_BIN"] = str(fake_bin) # Sanitize real vc_frame env to prevent leaks from the host session. env.pop("VC_FRAME", None) env.pop("VC_FRAME_PANE_ID", None) env.pop("VC_FRAME_SESSION_NAME", None) subprocess.run( - ["bash", str(LAUNCHER), "init", "claude"], + ["bash", str(LAUNCHER), "init", "claude", "--operator", "auto"], check=True, cwd=REPO_ROOT, env=env, @@ -556,12 +612,35 @@ def test_init_claude_uses_interactive_tab_without_print_mode( command_script = _spawned_command_script(payload) script_body = command_script.read_text(encoding="utf-8") - assert "claude --verbose --dangerously-skip-permissions " in script_body + assert ( + "vibecrafted_core.spawn interactive-launch claude --runtime local-native " + "--permissions bypass --token-budget safe --operator auto --continuity fresh --root" + ) in script_body assert "/vc-init" in script_body assert " -p " not in script_body -def test_init_codex_uses_interactive_tab_without_exec_mode(tmp_path: Path) -> None: +def test_init_shell_and_deck_accept_the_same_typed_continuity_flags() -> None: + expected = "--continuity full-lineage --continuity-parent " + for launcher in ( + LAUNCHER, + REPO_ROOT / "vibecrafted-core/vibecrafted_core/deck/vibecrafted", + ): + result = subprocess.run( + ["bash", str(launcher), "init", "--help"], + check=True, + cwd=REPO_ROOT, + capture_output=True, + text=True, + ) + assert "--continuity full-lineage|fresh|bare-fork" in result.stdout + assert expected in result.stdout + assert "bare-fork is expert-only" in result.stdout + + +def test_init_codex_fails_closed_without_measured_usage_capability( + tmp_path: Path, +) -> None: home = tmp_path / "home" fake_bin = tmp_path / "bin" capture_file = tmp_path / "capture.log" @@ -583,51 +662,35 @@ def test_init_codex_uses_interactive_tab_without_exec_mode(tmp_path: Path) -> No env["XDG_CONFIG_HOME"] = str(tmp_path / "xdg") env["VIBECRAFTED_ROOT"] = str(REPO_ROOT) env["FAKE_VC_FRAME_SESSION"] = _expected_operator_session() + env["VIBECRAFTED_RUNTIME_BIN"] = str(fake_bin) # Sanitize real vc_frame env to prevent leaks from the host session. env.pop("VC_FRAME", None) env.pop("VC_FRAME_PANE_ID", None) env.pop("VC_FRAME_SESSION_NAME", None) - subprocess.run( + result = subprocess.run( ["bash", str(LAUNCHER), "init", "codex"], - check=True, + check=False, cwd=REPO_ROOT, env=env, + capture_output=True, + text=True, ) - payload = capture_file.read_text(encoding="utf-8") - # When vc_frame operator session exists, spawn routes directly through vc_frame - # without opening a new terminal via osascript. - assert ( - f"VC_FRAME --session {_expected_operator_session()} action new-tab" in payload - ) - - command_script = _spawned_command_script(payload) - script_body = command_script.read_text(encoding="utf-8") - assert "codex --dangerously-bypass-approvals-and-sandbox " in script_body - assert "/vc-init" in script_body - assert "codex exec" not in script_body + assert result.returncode == 1 + assert "no verified live, child-attributable, monotonic usage" in result.stderr + payload = capture_file.read_text(encoding="utf-8") if capture_file.exists() else "" + assert "action new-tab" not in payload @pytest.mark.parametrize( - ("agent", "command_needle"), - [ - ("agy", "agy --dangerously-skip-permissions --add-dir . --prompt-interactive "), - ("junie", "junie --task="), - ( - "grok", - # Interactive TUI: positional prompt, NO --single (one-shot headless). - "grok --cwd . --permission-mode bypassPermissions --no-alt-screen ", - ), - ], + "agent", + ["agy", "junie", "grok"], ) -def test_init_fleet_agents_resolve_skill_init_helpers( - agent: str, command_needle: str, tmp_path: Path +def test_init_fleet_agents_fail_closed_without_measured_usage_capability( + agent: str, tmp_path: Path ) -> None: - """Regression: vibecrafted init must not fail with Missing helper - -skill-init. Fleet surface is five agents; wrappers for only - claude/codex used to brick agy/junie/grok at the launcher. - """ + """Unsupported measured-quota cells stay visible but cannot launch.""" home = tmp_path / "home" fake_bin = tmp_path / "bin" capture_file = tmp_path / "capture.log" @@ -649,6 +712,7 @@ def test_init_fleet_agents_resolve_skill_init_helpers( env["XDG_CONFIG_HOME"] = str(tmp_path / "xdg") env["VIBECRAFTED_ROOT"] = str(REPO_ROOT) env["FAKE_VC_FRAME_SESSION"] = _expected_operator_session() + env["VIBECRAFTED_RUNTIME_BIN"] = str(fake_bin) env.pop("VC_FRAME", None) env.pop("VC_FRAME_PANE_ID", None) env.pop("VC_FRAME_SESSION_NAME", None) @@ -662,34 +726,14 @@ def test_init_fleet_agents_resolve_skill_init_helpers( text=True, ) - assert result.returncode == 0, ( - f"init {agent} failed:\nstdout={result.stdout}\nstderr={result.stderr}" - ) + assert result.returncode == 1 assert "Missing helper" not in result.stderr - assert f"{agent}-skill-init" not in result.stderr or "Missing helper" not in ( - result.stdout + result.stderr - ) + assert "no verified live, child-attributable, monotonic usage" in result.stderr + payload = capture_file.read_text(encoding="utf-8") if capture_file.exists() else "" + assert "action new-tab" not in payload - payload = capture_file.read_text(encoding="utf-8") - assert ( - f"VC_FRAME --session {_expected_operator_session()} action new-tab" in payload - ) - - command_script = _spawned_command_script(payload) - script_body = command_script.read_text(encoding="utf-8") - assert command_needle in script_body - assert "/vc-init" in script_body - if agent == "grok": - assert " --single " not in script_body - assert "--single" not in script_body - - -def test_init_grok_is_interactive_tui_not_single_shot(tmp_path: Path) -> None: - """Regression: vibecrafted init grok must open the TUI like codex/claude. - --single is one-shot headless (prints + exits). That belongs only to - fleet/await non-interactive lanes, never vc-init / bare resume. - """ +def test_init_grok_rejects_quota_before_any_single_shot_or_tab(tmp_path: Path) -> None: home = tmp_path / "home" fake_bin = tmp_path / "bin" capture_file = tmp_path / "capture.log" @@ -711,6 +755,7 @@ def test_init_grok_is_interactive_tui_not_single_shot(tmp_path: Path) -> None: env["XDG_CONFIG_HOME"] = str(tmp_path / "xdg") env["VIBECRAFTED_ROOT"] = str(REPO_ROOT) env["FAKE_VC_FRAME_SESSION"] = _expected_operator_session() + env["VIBECRAFTED_RUNTIME_BIN"] = str(fake_bin) env.pop("VC_FRAME", None) env.pop("VC_FRAME_PANE_ID", None) env.pop("VC_FRAME_SESSION_NAME", None) @@ -723,17 +768,11 @@ def test_init_grok_is_interactive_tui_not_single_shot(tmp_path: Path) -> None: capture_output=True, text=True, ) - assert result.returncode == 0, result.stderr - script_body = _spawned_command_script( - capture_file.read_text(encoding="utf-8") - ).read_text(encoding="utf-8") - assert ( - "grok --cwd . --permission-mode bypassPermissions --no-alt-screen" - in script_body - ) - assert "/vc-init" in script_body - assert "--single" not in script_body - assert "streaming-json" not in script_body + assert result.returncode == 1 + assert "no verified live, child-attributable, monotonic usage" in result.stderr + payload = capture_file.read_text(encoding="utf-8") if capture_file.exists() else "" + assert "action new-tab" not in payload + assert "--single" not in payload def test_init_gemini_returns_actionable_agy_migration() -> None: diff --git a/vibecrafted-app/Cargo.lock b/vibecrafted-app/Cargo.lock index 4a0f4222..4fd4ddb0 100644 --- a/vibecrafted-app/Cargo.lock +++ b/vibecrafted-app/Cargo.lock @@ -644,7 +644,7 @@ dependencies = [ [[package]] name = "control-core" -version = "4.2.4" +version = "4.3.0" dependencies = [ "chrono", "libc", diff --git a/vibecrafted-app/docs/plans/PLAN_23_AGENT_OPERATOR_DASHBOARD.md b/vibecrafted-app/docs/plans/PLAN_23_AGENT_OPERATOR_DASHBOARD.md index 7cf73761..66dc03f1 100644 --- a/vibecrafted-app/docs/plans/PLAN_23_AGENT_OPERATOR_DASHBOARD.md +++ b/vibecrafted-app/docs/plans/PLAN_23_AGENT_OPERATOR_DASHBOARD.md @@ -1,6 +1,6 @@ # VC Operator 23: Agent-Operator Dashboard -- Repo: `/Users/tester/vc-workspace/vetcoders/vc-operator` +- Repo: `~/vc-workspace/vetcoders/vc-operator` - Branch: `main` - Baseline commit: `c8bb3d2` - Generated: `2026-05-16` @@ -15,7 +15,7 @@ ## 1) Cel główny (1:1) > Agent-Operator pracuje ślepo. Wszystkie dane są na miejscu — AICX -> extracts, `~/.vibecrafted/artifacts/`, `/tmp/claude-501/`, git logs — +> extracts, `~/.vibecrafted/artifacts/`, `/tmp/claude-/`, git logs — > ale **nikt ich nie składa w jeden widok**. Operator widzi pojedyncze > raporty po fakcie, agent pamięta ostatnie kilka run_id, ale "ile > dispatch'y miało peer-tier compliance? które skille są martwe? co @@ -38,7 +38,7 @@ dla agenta-operatora. Wszystko jest na miejscu!"_ edition 2024). Dashboard naturally lives as a new tab inside it. - Authoritative single source for per-dispatch attribution: `~/.vibecrafted/artifacts/////reports/*.meta.json`. -- Live state source: `/tmp/claude-501///tasks/.output` +- Live state source: `/tmp/claude-///tasks/.output` (JSONL, streaming-aware reader required). - AICX corroboration: `aicx steer --json --agent `, `aicx health --json`, `aicx intents --emit json --unresolved`. @@ -169,7 +169,7 @@ vco health # fleet health only - [ ] **B-1** `vc-justdo claude --file 02-b-state-machine-and-watchers.md` - Mission: introduce `MissionControlState` struct, wire `notify` - watchers for `~/.vibecrafted/artifacts/` and `/tmp/claude-501/`, + watchers for `~/.vibecrafted/artifacts/` and `/tmp/claude-/`, set up the async runtime tasks that hydrate state from disk. - Agent: claude (Rust state machine is its sweet spot) - [ ] **B-2** `vc-justdo gemini --file 03-b-active-dispatches-panel.md` diff --git a/vibecrafted-app/shell-agent/app/Vibecrafted.xcodeproj/project.pbxproj b/vibecrafted-app/shell-agent/app/Vibecrafted.xcodeproj/project.pbxproj deleted file mode 100644 index 569d2fae..00000000 --- a/vibecrafted-app/shell-agent/app/Vibecrafted.xcodeproj/project.pbxproj +++ /dev/null @@ -1,487 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 77; - objects = { - -/* Begin PBXBuildFile section */ - 0F83E26443735866DB5FC793 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DC22CD11479B2C9FC62F7A0 /* main.swift */; }; - 14EAC5B0973436D431AE7153 /* MainSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 677ED311C0A0AC32FEDF5C9E /* MainSplitViewController.swift */; }; - 2C18E0FE8C18A73791678DC2 /* MissionControlViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6F03047FF22E0433924BDD7 /* MissionControlViewController.swift */; }; - 4A2BA6E287B0E5E51054F35F /* libvibecrafted_shell_ffi.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = 66F000337318E7E1E774D5AD /* libvibecrafted_shell_ffi.dylib */; }; - 65E37AF5DAC0EC25D709DDB5 /* MainWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD9D34C38918A44FDA163417 /* MainWindowController.swift */; }; - 843BFAC3521E7E073E09D819 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4B8B7CA9D74B4B887436010 /* NotificationManager.swift */; }; - A60320E009ED2630B2CD138A /* SidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 018846F7BFF32DDA55BEB6B3 /* SidebarViewController.swift */; }; - AD8EDDBFCA0E035A3127FE9D /* vibecrafted_shell_ffi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 670FB28076F3623946392EE2 /* vibecrafted_shell_ffi.swift */; }; - AED4BA44792B0934E269B0C9 /* InspectorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DC7FC1EB764722753755233 /* InspectorViewController.swift */; }; - C8D308CC3EA6BC966B597FFF /* Vibecrafted.icns in Resources */ = {isa = PBXBuildFile; fileRef = 915E8799B77EBAA0B9A10DFF /* Vibecrafted.icns */; }; - D3552D0D169AC41B576D5826 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD135782CD6D34B7A4639DF0 /* AppDelegate.swift */; }; - F1F6DE9CF09A4B0E5BBAF04C /* CanvasViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB08FBAC6D3A32EA7AA68548 /* CanvasViewController.swift */; }; - FA200B41B03563783AE94B11 /* libvibecrafted_shell_ffi.dylib in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 66F000337318E7E1E774D5AD /* libvibecrafted_shell_ffi.dylib */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; -/* End PBXBuildFile section */ - -/* Begin PBXCopyFilesBuildPhase section */ - B5CE407F90D607E9182E30A6 /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - FA200B41B03563783AE94B11 /* libvibecrafted_shell_ffi.dylib in Embed Frameworks */, - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 018846F7BFF32DDA55BEB6B3 /* SidebarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarViewController.swift; sourceTree = ""; }; - 0AE9F2A66C4AB43ADF27641A /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = ""; }; - 66EC2ADEE7E46C767159D503 /* Vibecrafted.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Vibecrafted.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 66F000337318E7E1E774D5AD /* libvibecrafted_shell_ffi.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libvibecrafted_shell_ffi.dylib; path = ../../target/release/libvibecrafted_shell_ffi.dylib; sourceTree = ""; }; - 670FB28076F3623946392EE2 /* vibecrafted_shell_ffi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = vibecrafted_shell_ffi.swift; sourceTree = ""; }; - 677ED311C0A0AC32FEDF5C9E /* MainSplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSplitViewController.swift; sourceTree = ""; }; - 915E8799B77EBAA0B9A10DFF /* Vibecrafted.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = Vibecrafted.icns; sourceTree = ""; }; - 9DC22CD11479B2C9FC62F7A0 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; - 9DC7FC1EB764722753755233 /* InspectorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorViewController.swift; sourceTree = ""; }; - AD135782CD6D34B7A4639DF0 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - AD9D34C38918A44FDA163417 /* MainWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindowController.swift; sourceTree = ""; }; - B6F03047FF22E0433924BDD7 /* MissionControlViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissionControlViewController.swift; sourceTree = ""; }; - CB08FBAC6D3A32EA7AA68548 /* CanvasViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CanvasViewController.swift; sourceTree = ""; }; - D4B8B7CA9D74B4B887436010 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 015CE8C17BD0DD3E250BC1B0 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 4A2BA6E287B0E5E51054F35F /* libvibecrafted_shell_ffi.dylib in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 1B93B22B922052B6D58CB4EB /* Vibecrafted */ = { - isa = PBXGroup; - children = ( - 0AE9F2A66C4AB43ADF27641A /* .gitkeep */, - AD135782CD6D34B7A4639DF0 /* AppDelegate.swift */, - 9DC22CD11479B2C9FC62F7A0 /* main.swift */, - D4B8B7CA9D74B4B887436010 /* NotificationManager.swift */, - 915E8799B77EBAA0B9A10DFF /* Vibecrafted.icns */, - A2D426BFC3DA1DF414A5E096 /* Bridge */, - 9FFF832B23463AAAFCA9150D /* Views */, - ); - path = Vibecrafted; - sourceTree = ""; - }; - 3907FEAC328882CF9D256BA5 = { - isa = PBXGroup; - children = ( - 1B93B22B922052B6D58CB4EB /* Vibecrafted */, - 4CE0EF52F6A1AE4402F5E427 /* Frameworks */, - 7C875D8B1DE1322A5D94E60B /* Products */, - ); - sourceTree = ""; - }; - 4CE0EF52F6A1AE4402F5E427 /* Frameworks */ = { - isa = PBXGroup; - children = ( - 66F000337318E7E1E774D5AD /* libvibecrafted_shell_ffi.dylib */, - ); - name = Frameworks; - sourceTree = ""; - }; - 7C875D8B1DE1322A5D94E60B /* Products */ = { - isa = PBXGroup; - children = ( - 66EC2ADEE7E46C767159D503 /* Vibecrafted.app */, - ); - name = Products; - sourceTree = ""; - }; - 9FFF832B23463AAAFCA9150D /* Views */ = { - isa = PBXGroup; - children = ( - CB08FBAC6D3A32EA7AA68548 /* CanvasViewController.swift */, - 9DC7FC1EB764722753755233 /* InspectorViewController.swift */, - 677ED311C0A0AC32FEDF5C9E /* MainSplitViewController.swift */, - AD9D34C38918A44FDA163417 /* MainWindowController.swift */, - B6F03047FF22E0433924BDD7 /* MissionControlViewController.swift */, - 018846F7BFF32DDA55BEB6B3 /* SidebarViewController.swift */, - ); - path = Views; - sourceTree = ""; - }; - A2D426BFC3DA1DF414A5E096 /* Bridge */ = { - isa = PBXGroup; - children = ( - 670FB28076F3623946392EE2 /* vibecrafted_shell_ffi.swift */, - ); - path = Bridge; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 1ACCA333474D06C6E9378FEF /* Vibecrafted */ = { - isa = PBXNativeTarget; - buildConfigurationList = CE13A064BDAD522E149B07DD /* Build configuration list for PBXNativeTarget "Vibecrafted" */; - buildPhases = ( - 80F542BE7AAC1F9A358F5919 /* Build Rust FFI */, - 3FAD01166F3AB65C7E6A005F /* Sources */, - 5C09F58E39B5124D10F145E3 /* Resources */, - 015CE8C17BD0DD3E250BC1B0 /* Frameworks */, - B5CE407F90D607E9182E30A6 /* Embed Frameworks */, - B1AC2505E58398D692EF0A46 /* Build Rust binaries */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = Vibecrafted; - packageProductDependencies = ( - ); - productName = Vibecrafted; - productReference = 66EC2ADEE7E46C767159D503 /* Vibecrafted.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 39668BB660972BC933C8618E /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = YES; - LastUpgradeCheck = 1600; - TargetAttributes = { - 1ACCA333474D06C6E9378FEF = { - ProvisioningStyle = Manual; - }; - }; - }; - buildConfigurationList = 562EBA724EB39ABF14AC75F6 /* Build configuration list for PBXProject "Vibecrafted" */; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - Base, - en, - ); - mainGroup = 3907FEAC328882CF9D256BA5; - minimizedProjectReferenceProxies = 1; - preferredProjectObjectVersion = 77; - productRefGroup = 7C875D8B1DE1322A5D94E60B /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 1ACCA333474D06C6E9378FEF /* Vibecrafted */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 5C09F58E39B5124D10F145E3 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - C8D308CC3EA6BC966B597FFF /* Vibecrafted.icns in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 80F542BE7AAC1F9A358F5919 /* Build Rust FFI */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - name = "Build Rust FFI"; - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "export MACOSX_DEPLOYMENT_TARGET=\"${MACOSX_DEPLOYMENT_TARGET:-14.0}\"\ncd \"$PROJECT_DIR/../..\" && cargo build -p vibecrafted-shell-ffi --release\n./shell-agent/scripts/fix-dylib-install-names.sh\ncargo run -p vibecrafted-uniffi-bindgen -- generate --library target/release/libvibecrafted_shell_ffi.dylib --language swift --out-dir shell-agent/app/Vibecrafted/Bridge/\n./shell-agent/scripts/normalize-bindings.sh shell-agent/app/Vibecrafted/Bridge\n"; - }; - B1AC2505E58398D692EF0A46 /* Build Rust binaries */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - name = "Build Rust binaries"; - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/bash; - shellScript = "bash $SRCROOT/../scripts/build-rust-binaries.sh\nbash $SRCROOT/../scripts/fix-dylib-install-names.sh \"$TARGET_BUILD_DIR/$CONTENTS_FOLDER_PATH\"\n"; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 3FAD01166F3AB65C7E6A005F /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - D3552D0D169AC41B576D5826 /* AppDelegate.swift in Sources */, - F1F6DE9CF09A4B0E5BBAF04C /* CanvasViewController.swift in Sources */, - AED4BA44792B0934E269B0C9 /* InspectorViewController.swift in Sources */, - 14EAC5B0973436D431AE7153 /* MainSplitViewController.swift in Sources */, - 65E37AF5DAC0EC25D709DDB5 /* MainWindowController.swift in Sources */, - 2C18E0FE8C18A73791678DC2 /* MissionControlViewController.swift in Sources */, - 843BFAC3521E7E073E09D819 /* NotificationManager.swift in Sources */, - A60320E009ED2630B2CD138A /* SidebarViewController.swift in Sources */, - 0F83E26443735866DB5FC793 /* main.swift in Sources */, - AD8EDDBFCA0E035A3127FE9D /* vibecrafted_shell_ffi.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin XCBuildConfiguration section */ - 3424F71B364B0A848B7A1C03 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = ""; - CODE_SIGN_IDENTITY = "-"; - CODE_SIGN_STYLE = Manual; - COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "\"../../target/release\"", - ); - GCC_OPTIMIZATION_LEVEL = s; - GENERATE_INFOPLIST_FILE = NO; - HEADER_SEARCH_PATHS = ( - "$(PROJECT_DIR)/Vibecrafted/Bridge", - ); - INFOPLIST_FILE = Vibecrafted/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - LIBRARY_SEARCH_PATHS = ( - "$(PROJECT_DIR)/../../target/release", - ); - MARKETING_VERSION = 0.1.0; - OTHER_LDFLAGS = ( - "$(PROJECT_DIR)/../../target/release/libvibecrafted_shell_ffi.a", - "-framework", - Security, - "-framework", - SystemConfiguration, - "-framework", - Carbon, - "-framework", - UserNotifications, - ); - PRODUCT_BUNDLE_IDENTIFIER = io.vetcoders.vibecrafted; - SDKROOT = macosx; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OBJC_BRIDGING_HEADER = Vibecrafted/Bridge/vibecrafted_shell_ffiFFI.h; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - }; - name = Release; - }; - 5DEDBE02B5B1BABA92A6CD72 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "$(inherited)", - "DEBUG=1", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 14.0; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - ONLY_ACTIVE_ARCH = YES; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = macosx; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 6.0; - }; - name = Debug; - }; - 6BA388368F975EFE2B838040 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = ""; - CODE_SIGN_IDENTITY = "-"; - CODE_SIGN_STYLE = Manual; - COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "\"../../target/release\"", - ); - GENERATE_INFOPLIST_FILE = NO; - HEADER_SEARCH_PATHS = ( - "$(PROJECT_DIR)/Vibecrafted/Bridge", - ); - INFOPLIST_FILE = Vibecrafted/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - LIBRARY_SEARCH_PATHS = ( - "$(PROJECT_DIR)/../../target/release", - ); - MARKETING_VERSION = 0.1.0; - OTHER_LDFLAGS = ( - "$(PROJECT_DIR)/../../target/release/libvibecrafted_shell_ffi.a", - "-framework", - Security, - "-framework", - SystemConfiguration, - "-framework", - Carbon, - "-framework", - UserNotifications, - ); - PRODUCT_BUNDLE_IDENTIFIER = io.vetcoders.vibecrafted; - SDKROOT = macosx; - SWIFT_OBJC_BRIDGING_HEADER = Vibecrafted/Bridge/vibecrafted_shell_ffiFFI.h; - }; - name = Debug; - }; - 859B5E01DDCDFBD9B78C5B23 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 14.0; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = macosx; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - SWIFT_VERSION = 6.0; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 562EBA724EB39ABF14AC75F6 /* Build configuration list for PBXProject "Vibecrafted" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 5DEDBE02B5B1BABA92A6CD72 /* Debug */, - 859B5E01DDCDFBD9B78C5B23 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Debug; - }; - CE13A064BDAD522E149B07DD /* Build configuration list for PBXNativeTarget "Vibecrafted" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 6BA388368F975EFE2B838040 /* Debug */, - 3424F71B364B0A848B7A1C03 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Debug; - }; -/* End XCConfigurationList section */ - }; - rootObject = 39668BB660972BC933C8618E /* Project object */; -} diff --git a/vibecrafted-app/shell-agent/app/Vibecrafted.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/vibecrafted-app/shell-agent/app/Vibecrafted.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a6..00000000 --- a/vibecrafted-app/shell-agent/app/Vibecrafted.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/vibecrafted-app/shell-agent/app/Vibecrafted/AppDelegate.swift b/vibecrafted-app/shell-agent/app/Vibecrafted/AppDelegate.swift index cef55ad1..be742998 100644 --- a/vibecrafted-app/shell-agent/app/Vibecrafted/AppDelegate.swift +++ b/vibecrafted-app/shell-agent/app/Vibecrafted/AppDelegate.swift @@ -5,7 +5,7 @@ import os.log private let installLog = Logger(subsystem: "io.vetcoders.vibecrafted", category: "install") -private struct CanonicalRuntimeInstall { +private struct CanonicalRuntimeInstall: Decodable { let root: URL let terminal: URL let terminalHost: URL @@ -17,6 +17,58 @@ private struct CanonicalRuntimeInstall { let runtimeHome: URL let configHome: URL let craftedHome: URL + + enum CodingKeys: String, CodingKey { + case root + case terminal + case terminalHost = "terminal_host" + case frame + case start + case primaryShell = "primary_shell" + case terminalConfig = "terminal_config" + case frameConfig = "frame_config" + case runtimeHome = "runtime_home" + case configHome = "config_home" + case craftedHome = "crafted_home" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + func fileURL(_ key: CodingKeys) throws -> URL { + let path = try container.decode(String.self, forKey: key) + guard path.hasPrefix("/") else { + throw DecodingError.dataCorruptedError( + forKey: key, in: container, + debugDescription: "runtime installer returned a non-absolute filesystem path") + } + return URL(fileURLWithPath: path) + } + + root = try fileURL(.root) + terminal = try fileURL(.terminal) + terminalHost = try fileURL(.terminalHost) + frame = try fileURL(.frame) + start = try fileURL(.start) + primaryShell = try fileURL(.primaryShell) + terminalConfig = try fileURL(.terminalConfig) + frameConfig = try fileURL(.frameConfig) + runtimeHome = try fileURL(.runtimeHome) + configHome = try fileURL(.configHome) + craftedHome = try fileURL(.craftedHome) + } +} + +extension TrayServerHealth { + var color: NSColor { + switch self { + case .checking: return .systemGray + case .healthy: return .systemGreen + case .transitioning: return .systemOrange + case .failed: return .systemRed + case .neutral: return .systemGray + } + } } final class EventObserver: @unchecked Sendable, EventCallback { @@ -33,10 +85,24 @@ final class EventObserver: @unchecked Sendable, EventCallback { } @MainActor -class AppDelegate: NSObject, NSApplicationDelegate { +class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { var mainWindow: MainWindowController? private var statusItem: NSStatusItem? + private var serverStatusMenuItem: NSMenuItem? + private var serverDetailMenuItem: NSMenuItem? + private var startServerMenuItem: NSMenuItem? + private var stopServerMenuItem: NSMenuItem? + private var restartServerMenuItem: NSMenuItem? + private var openServerLogsMenuItem: NSMenuItem? + private var trayBaseIcon: NSImage? + private var statusRefreshTimer: Timer? private var terminalProcess: Process? + private var serverStatusProcess: Process? + private var serverActionProcess: Process? + private var serverActionInFlight: ServerLifecycleAction? + private var serverUtilityProcess: Process? + private var canonicalInstall: CanonicalRuntimeInstall? + private var canonicalRuntimeEnvironment: [String: String]? private var workspaceLaunchFailureReported = false private var eyeReconcileProcess: Process? let eventObserver = EventObserver() @@ -51,6 +117,15 @@ class AppDelegate: NSObject, NSApplicationDelegate { } func applicationDidFinishLaunching(_ notification: Notification) { + if ProcessInfo.processInfo.arguments.contains("--uninstall") { + do { + try uninstallCanonicalRuntime() + exit(EXIT_SUCCESS) + } catch { + fputs("Vibecrafted uninstall failed: \(error)\n", stderr) + exit(EXIT_FAILURE) + } + } if ProcessInfo.processInfo.arguments.contains("--bootstrap-only") { do { let install = try installCanonicalRuntime() @@ -87,6 +162,32 @@ class AppDelegate: NSObject, NSApplicationDelegate { false } + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + switch activeRunSummary() { + case .available(let summary) where summary.lanes == 0: + return .terminateNow + case .available(let summary): + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "Active or stalled Vibecrafted lanes still need a control surface" + alert.informativeText = + "\(summary.lanes) active/stalled lane(s), including \(summary.worktrees) worktree-backed lane(s). Quitting the app does not make that work disappear, but removes its live control surface." + alert.addButton(withTitle: "Cancel") + alert.addButton(withTitle: "Quit Anyway") + return alert.runModal() == .alertSecondButtonReturn ? .terminateNow : .terminateCancel + case .unavailable(let reason): + installLog.error("Cannot inspect lifecycle truth before quit: \(reason, privacy: .public)") + let alert = NSAlert() + alert.alertStyle = .critical + alert.messageText = "Vibecrafted lifecycle truth is unavailable" + alert.informativeText = + "The canonical control plane could not confirm whether any lanes are active or stalled. Cancel to keep the live control surface, or quit explicitly anyway." + alert.addButton(withTitle: "Cancel") + alert.addButton(withTitle: "Quit Anyway") + return alert.runModal() == .alertSecondButtonReturn ? .terminateNow : .terminateCancel + } + } + func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { true } @@ -96,6 +197,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } func applicationWillTerminate(_ notification: Notification) { + statusRefreshTimer?.invalidate() NotificationManager.shared.clearHeartbeat(craftedHome: craftedHomeURL()) } @@ -126,12 +228,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { "Cannot publish the canonical Vibecrafted runtime: \(error.localizedDescription)") return } + canonicalInstall = install for required in [ install.terminal, install.terminalHost, install.frame, install.start, install.primaryShell, ] - where !FileManager.default.isExecutableFile( + where !FileManager.default.isExecutableFile( atPath: required.path) { reportWorkspaceLaunchFailure( @@ -186,6 +289,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { environment["VIBECRAFTED_LEGACY_VC_FRAME_SOCKET_DIR"] = "/\(temp)/vc-frame-\(getuid())" } + canonicalRuntimeEnvironment = environment + refreshServerStatus() let process = Process() process.executableURL = install.terminalHost @@ -234,7 +339,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { var registrationError: Unmanaged? if !CTFontManagerRegisterFontsForURL(font as CFURL, .session, ®istrationError) { - let message = registrationError?.takeRetainedValue().localizedDescription + let message = + registrationError?.takeRetainedValue().localizedDescription ?? "CoreText rejected SpotMono.ttc" // A system-installed Spot Mono can already occupy the session scope. // Accept that case only when CoreText resolves the required family. @@ -251,254 +357,112 @@ class AppDelegate: NSObject, NSApplicationDelegate { } private func installCanonicalRuntime() throws -> CanonicalRuntimeInstall { - let manager = FileManager.default - let host = ProcessInfo.processInfo.environment - let home = host["HOME"] ?? manager.homeDirectoryForCurrentUser.path - let runtimeHome = URL( - fileURLWithPath: - host["VIBECRAFTED_RUNTIME_HOME"] - ?? host["XDG_DATA_HOME"].map { "\($0)/vibecrafted" } - ?? "\(home)/.local/share/vibecrafted", isDirectory: true) - let configHome = URL( - fileURLWithPath: host["XDG_CONFIG_HOME"] ?? "\(home)/.config", isDirectory: true) - let productConfig = configHome.appendingPathComponent("vibecrafted", isDirectory: true) - let craftedHome = URL( - fileURLWithPath: host["VIBECRAFTED_HOME"] ?? "\(home)/.vibecrafted", isDirectory: true) - let launcherHome = URL( - fileURLWithPath: host["VIBECRAFTED_LAUNCHER_BIN"] ?? "\(home)/.local/bin", - isDirectory: true) - let appRoot = Bundle.main.bundleURL - let bundledRuntime = appRoot.appendingPathComponent( - "Contents/Resources/runtime", isDirectory: true) - let versionURL = bundledRuntime.appendingPathComponent("VERSION") - let version = try String(contentsOf: versionURL, encoding: .utf8) - .trimmingCharacters(in: .whitespacesAndNewlines) - let allowed = CharacterSet(charactersIn: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz.+_-") - guard !version.isEmpty, version.unicodeScalars.allSatisfy(allowed.contains) else { + let resources = appRoot.appendingPathComponent("Contents/Resources", isDirectory: true) + let carrierDirectory = resources.appendingPathComponent("runtime-pack", isDirectory: true) + let carriers = try FileManager.default.contentsOfDirectory( + at: carrierDirectory, includingPropertiesForKeys: nil + ).filter { + $0.lastPathComponent.hasPrefix("Vibecrafted_RuntimePack_") && $0.pathExtension == "gz" + } + guard carriers.count == 1 else { throw NSError( domain: "io.vetcoders.vibecrafted.install", code: 1, - userInfo: [NSLocalizedDescriptionKey: "invalid bundled VERSION: \(version)"]) - } - - let releases = runtimeHome.appendingPathComponent("releases", isDirectory: true) - let generation = releases.appendingPathComponent(version, isDirectory: true) - try manager.createDirectory(at: releases, withIntermediateDirectories: true) - try manager.createDirectory(at: productConfig, withIntermediateDirectories: true) - try manager.createDirectory(at: craftedHome, withIntermediateDirectories: true) - try manager.createDirectory( - at: craftedHome.appendingPathComponent("artifacts", isDirectory: true), - withIntermediateDirectories: true) - try manager.createDirectory( - at: craftedHome.appendingPathComponent("control_plane", isDirectory: true), - withIntermediateDirectories: true) - try manager.createDirectory(at: launcherHome, withIntermediateDirectories: true) - - if !manager.fileExists(atPath: generation.path) { - let staging = releases.appendingPathComponent( - ".\(version).staging-\(UUID().uuidString)", isDirectory: true) - defer { try? manager.removeItem(at: staging) } - try manager.copyItem(at: bundledRuntime, to: staging) - let bin = staging.appendingPathComponent("bin", isDirectory: true) - try manager.createDirectory(at: bin, withIntermediateDirectories: true) - try manager.copyItem( - at: appRoot.appendingPathComponent( - "Contents/Helpers/vc-terminal.app/Contents/MacOS/alacritty"), - to: bin.appendingPathComponent("vc-terminal")) - try manager.copyItem( - at: appRoot.appendingPathComponent("Contents/Helpers/vc-frame"), - to: bin.appendingPathComponent("vc-frame")) - try assertNoSymlinks(below: staging) - try manager.moveItem(at: staging, to: generation) - } - try assertNoSymlinks(below: generation) - let bin = generation.appendingPathComponent("bin", isDirectory: true) - - // The terminal policy and palettes are signed, version-bound inputs. The - // tiny entry file is regenerated on every launch and imports the current - // generation plus one product-owned mutable palette. We never read or - // mutate the user's Alacritty configuration. - let terminalPolicy = generation.appendingPathComponent( - "config/vc-terminal/vibecrafted.toml") - let terminalThemes = generation.appendingPathComponent( - "config/vc-terminal/themes", isDirectory: true) - let terminalTheme = productConfig.appendingPathComponent("terminal-theme.toml") - if !manager.fileExists(atPath: terminalTheme.path) { - try manager.copyItem( - at: terminalThemes.appendingPathComponent("dark.toml"), to: terminalTheme) - } - let terminalConfig = productConfig.appendingPathComponent("terminal-entry.toml") - let terminalEntry = """ - # Generated by Vibecrafted.app. Do not point this at a private Alacritty config. - [general] - import = [ - \(tomlBasicString(terminalPolicy.path)), - \(tomlBasicString(terminalTheme.path)), - ] - live_config_reload = true - """ - try Data(terminalEntry.utf8).write(to: terminalConfig, options: .atomic) - let sourceFrameConfig = generation.appendingPathComponent( - "vibecrafted-core/vibecrafted_core/config/vc-frame", isDirectory: true) - let frameConfig = productConfig.appendingPathComponent("vc-frame", isDirectory: true) - if !manager.fileExists(atPath: frameConfig.path) { - try manager.copyItem(at: sourceFrameConfig, to: frameConfig) - } - let sourceShell = generation.appendingPathComponent( - "vibecrafted-core/vibecrafted_core/runtime/shell", isDirectory: true) - let productShell = productConfig.appendingPathComponent("shell", isDirectory: true) - // The shell helper layer is runtime code, not operator config. A - // copy-once projection froze an old parser here (`--run-id` unknown while - // the release already spoke it), so every install refreshes it in full. - if manager.fileExists(atPath: productShell.path) { - try manager.removeItem(at: productShell) - } - try manager.copyItem(at: sourceShell, to: productShell) - try assertNoSymlinks(below: productConfig) - - let terminal = generation.appendingPathComponent("bin/vc-terminal") + userInfo: [NSLocalizedDescriptionKey: "signed App must contain one Runtime Pack carrier"]) + } + let manifestData = try Data( + contentsOf: resources.appendingPathComponent("product-manifest.json")) + guard + let manifest = try JSONSerialization.jsonObject(with: manifestData) as? [String: Any], + let sourceRevision = manifest["git_sha"] as? String, + let modules = manifest["modules"] as? [[String: Any]], + let terminalRevision = modules.first(where: { $0["module"] as? String == "vc-terminal" })?[ + "git_sha"] as? String, + let frameRevision = modules.first(where: { $0["module"] as? String == "vc-frame" })?[ + "git_sha"] as? String + else { + throw NSError( + domain: "io.vetcoders.vibecrafted.install", code: 1, + userInfo: [ + NSLocalizedDescriptionKey: "signed product manifest has no Runtime Pack source tuple" + ]) + } let terminalHost = appRoot.appendingPathComponent( "Contents/Helpers/vc-terminal.app/Contents/MacOS/alacritty") - let frame = generation.appendingPathComponent("bin/vc-frame") - let start = generation.appendingPathComponent("bin/vc-start") - let primaryShell = generation.appendingPathComponent( - "config/alacritty/launch-primary-shell.zsh") - let deck = generation.appendingPathComponent("bin/vibecrafted") - let server = generation.appendingPathComponent("bin/vc-server") - let guardian = generation.appendingPathComponent("bin/vc-guardian") - let supervisor = generation.appendingPathComponent("bin/vc-server-supervisor") - let workflow = generation.appendingPathComponent("bin/vc-workflow") - for required in [ - terminal, terminalHost, frame, start, primaryShell, deck, server, guardian, - supervisor, workflow, - ] - where !manager.isExecutableFile(atPath: required.path) - { + let frameHelper = appRoot.appendingPathComponent("Contents/Helpers/vc-frame") + let output = try runRuntimePackInstaller(arguments: [ + "--pack", carriers[0].path, + "--app-root", appRoot.path, + "--terminal-host", terminalHost.path, + "--frame-helper", frameHelper.path, + "--expected-source-revision", sourceRevision, + "--expected-terminal-revision", terminalRevision, + "--expected-frame-revision", frameRevision, + ]) + do { + return try JSONDecoder().decode(CanonicalRuntimeInstall.self, from: output) + } catch { throw NSError( domain: "io.vetcoders.vibecrafted.install", code: 2, - userInfo: [NSLocalizedDescriptionKey: "runtime entry is not executable: \(required.path)"]) - } - - let common = [ - "export XDG_CONFIG_HOME=\(shellQuote(configHome.path))", - "export VIBECRAFTED_HOME=\(shellQuote(craftedHome.path))", - "export VIBECRAFTED_RUNTIME_HOME=\(shellQuote(runtimeHome.path))", - "export VIBECRAFTED_RUNTIME_ROOT=\(shellQuote(generation.path))", - "export VIBECRAFTED_ROOT=\(shellQuote(generation.path))", - "export VIBECRAFTED_PYTHON=\(shellQuote(generation.appendingPathComponent("bin/python3").path))", - "export VIBECRAFTED_VC_FRAME_BIN=\(shellQuote(frame.path))", - "export VC_FRAME_CONFIG_DIR=\(shellQuote(frameConfig.path))", - // PATH is the one export that must compose instead of replace. A launcher - // that hard-codes the system set strips Homebrew, ~/.local/bin and - // ~/.cargo/bin from everything it spawns, so `#!/usr/bin/env node` CLIs - // exit 127. The generation still wins; the caller's PATH survives behind - // it, with the system set as the fallback when the caller has none. - "export PATH=\"\(shellDoubleQuoteBody(generation.appendingPathComponent("bin").path))" - + ":${PATH:-/usr/bin:/bin:/usr/sbin:/sbin}\"", - ] - let runtimeEntries = try manager.contentsOfDirectory( - at: bin, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles]) - for entry in runtimeEntries.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { - let name = entry.lastPathComponent - guard name != "python3", name != "vc-terminal" else { continue } - let values = try entry.resourceValues(forKeys: [.isRegularFileKey]) - guard values.isRegularFile == true, manager.isExecutableFile(atPath: entry.path) else { continue } - try writeLauncher( - launcherHome.appendingPathComponent(name), common: common, executable: entry) - } - try writeLauncher( - launcherHome.appendingPathComponent("vc-terminal"), common: common, executable: terminalHost, - leadingArguments: ["--config-file", terminalConfig.path]) - - // Deck-verb wrappers. These public names have no runtime binary of their - // own — they are verbs of the bash deck. A plain `exec deck "$@"` shim (or - // a symlink onto one) loses the invoked name at the shebang boundary, so - // `vc-resume claude --session ` used to degrade into - // `vibecrafted claude --session ` ("Unknown mode"). Injecting the verb - // into the shim keeps launcher identity across any exec chain. Keep this - // map in lockstep with SHELL_WRAPPER_VERBS in vibecrafted_core/cli.py - // (tests/tui/test_keys.py pins the parity). - let deckVerbWrappers: [(name: String, verb: String)] = [ - ("vc-help", "help"), - ("vc-init", "init"), - ("vc-dashboard", "dashboard"), - ("vc-dispatch", "dispatch"), - ("vc-resume", "resume"), - ("vc-justdo", "justdo"), - ("vc-doctor", "doctor"), - ("vc-status", "status"), - ("vc-update", "update"), - ("vc-receipt", "receipt"), - ("telemetry", "telemetry"), - ] - for wrapper in deckVerbWrappers - where !manager.isExecutableFile(atPath: bin.appendingPathComponent(wrapper.name).path) - { - try writeLauncher( - launcherHome.appendingPathComponent(wrapper.name), common: common, executable: deck, - leadingArguments: [wrapper.verb]) + userInfo: [ + NSLocalizedDescriptionKey: + "installer returned an invalid runtime result: \(error.localizedDescription)" + ]) } + } - let active: [String: String] = [ - "schema": "vibecrafted.active-runtime.v1", - "version": version, - "runtime_root": generation.path, - "app_root": appRoot.path, - ] - let activeData = try JSONSerialization.data( - withJSONObject: active, options: [.prettyPrinted, .sortedKeys]) - try activeData.write(to: runtimeHome.appendingPathComponent("active.json"), options: .atomic) - - return CanonicalRuntimeInstall( - root: generation, terminal: terminal, terminalHost: terminalHost, frame: frame, - start: start, primaryShell: primaryShell, terminalConfig: terminalConfig, - frameConfig: frameConfig, runtimeHome: runtimeHome, configHome: configHome, - craftedHome: craftedHome) + private func uninstallCanonicalRuntime() throws { + _ = try runRuntimePackInstaller(arguments: ["--uninstall"]) } - private func assertNoSymlinks(below root: URL) throws { - let keys: [URLResourceKey] = [.isSymbolicLinkKey] - if try root.resourceValues(forKeys: Set(keys)).isSymbolicLink == true { + private func runRuntimePackInstaller(arguments: [String]) throws -> Data { + let carrierDirectory = Bundle.main.bundleURL.appendingPathComponent( + "Contents/Resources/runtime-pack", isDirectory: true) + let installer = carrierDirectory.appendingPathComponent("install-runtime-pack.sh") + let publicKey = carrierDirectory.appendingPathComponent("vibecrafted-signing-v1.pub") + guard FileManager.default.isExecutableFile(atPath: installer.path), + FileManager.default.fileExists(atPath: publicKey.path) + else { throw NSError( - domain: "io.vetcoders.vibecrafted.install", code: 3, + domain: "io.vetcoders.vibecrafted.install", code: 1, userInfo: [ NSLocalizedDescriptionKey: - "symlink is forbidden: \(root.standardizedFileURL.path)" + "signed Runtime Pack bootstrap or trust root is missing" ]) } - guard let enumerator = FileManager.default.enumerator( - at: root, includingPropertiesForKeys: keys, options: [], errorHandler: nil) - else { return } - for case let item as URL in enumerator { - if try item.resourceValues(forKeys: Set(keys)).isSymbolicLink == true { - // The absolute path of the offending link is the only actionable part of - // this failure; it is what the operator has to delete or replace. - throw NSError( - domain: "io.vetcoders.vibecrafted.install", code: 3, - userInfo: [ - NSLocalizedDescriptionKey: - "symlink is forbidden in runtime: \(item.standardizedFileURL.path)" - ]) - } - } - } - - private func shellQuote(_ value: String) -> String { - "'\(value.replacingOccurrences(of: "'", with: "'\"'\"'"))'" - } - /// Escape `value` for use *inside* a double-quoted shell word, where parameter - /// expansion must stay live for the rest of the word. - private func shellDoubleQuoteBody(_ value: String) -> String { - value - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "$", with: "\\$") - .replacingOccurrences(of: "`", with: "\\`") - .replacingOccurrences(of: "\"", with: "\\\"") + let process = Process() + let output = Pipe() + let errors = Pipe() + process.executableURL = URL(fileURLWithPath: "/bin/bash") + process.arguments = [installer.path] + arguments + var environment = ProcessInfo.processInfo.environment + environment["PYTHONNOUSERSITE"] = "1" + environment["PYTHONDONTWRITEBYTECODE"] = "1" + environment["VIBECRAFTED_RUNTIME_PACK_PUBLIC_KEY"] = publicKey.path + process.environment = environment + process.standardOutput = output + process.standardError = errors + try process.run() + process.waitUntilExit() + + let result = output.fileHandleForReading.readDataToEndOfFile() + let failure = errors.fileHandleForReading.readDataToEndOfFile() + guard process.terminationStatus == 0 else { + let detail = + String(data: failure.isEmpty ? result : failure, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + ?? "installer exited \(process.terminationStatus)" + throw NSError( + domain: "io.vetcoders.vibecrafted.install", + code: Int(process.terminationStatus), + userInfo: [NSLocalizedDescriptionKey: detail]) + } + return result } - /// Signed generation bin first, then whatever PATH the caller already had; - /// the minimal system set only when the caller carried no PATH at all. + /// Signed generation bin first, then the inherited PATH; use the minimal + /// system set only when the caller carried no PATH at all. private func composedPath(generation: URL, inherited: String?) -> String { let tail = (inherited ?? "").isEmpty ? "/usr/bin:/bin:/usr/sbin:/sbin" : inherited! return "\(generation.appendingPathComponent("bin").path):\(tail)" @@ -508,6 +472,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { /// log for post-mortem, plus one modal so a broken install is never silent. private func reportWorkspaceLaunchFailure(_ message: String) { installLog.error("\(message, privacy: .public)") + fputs("Vibecrafted workspace launch failed: \(message)\n", stderr) guard !workspaceLaunchFailureReported else { return } workspaceLaunchFailureReported = true let alert = NSAlert() @@ -518,44 +483,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { alert.runModal() } - private func tomlBasicString(_ value: String) -> String { - let escaped = value - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - return "\"\(escaped)\"" - } - - private func writeLauncher( - _ destination: URL, common: [String], executable: URL, leadingArguments: [String] = [] - ) throws { - let arguments = leadingArguments.map(shellQuote).joined(separator: " ") - let prefix = arguments.isEmpty ? "" : "\(arguments) " - let body = - (["#!/bin/bash", "set -euo pipefail"] + common - + [ - // The deck's identity guard compares the DECLARED launcher path with - // the live process argv. The wrapper is that declared path, so it - // must ride through every exec into the final python argv — without - // this line vc-guardian failed capture-identity and `server start` - // rolled a healthy server back. - "export VIBECRAFTED_DECLARED_LAUNCHER=\"$0\"", - "exec \(shellQuote(executable.path)) \(prefix)\"$@\"", - ]) - .joined(separator: "\n") + "\n" - let temporary = destination.deletingLastPathComponent().appendingPathComponent( - ".\(destination.lastPathComponent).new-\(UUID().uuidString)") - try body.write(to: temporary, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes( - [.posixPermissions: 0o755], ofItemAtPath: temporary.path) - if rename(temporary.path, destination.path) != 0 { - let code = errno - try? FileManager.default.removeItem(at: temporary) - throw NSError( - domain: NSPOSIXErrorDomain, code: Int(code), - userInfo: [NSLocalizedDescriptionKey: "cannot atomically publish \(destination.path)"]) - } - } - // MARK: - Main Menu private func buildStatusItem() { @@ -565,22 +492,181 @@ class AppDelegate: NSObject, NSApplicationDelegate { let trayIcon = NSApp.applicationIconImage.copy() as? NSImage trayIcon?.size = NSSize(width: 18, height: 18) trayIcon?.accessibilityDescription = "Vibecrafted" - item.button?.image = + trayBaseIcon = trayIcon ?? NSImage(systemSymbolName: "hammer.fill", accessibilityDescription: "Vibecrafted") + item.button?.image = statusIcon(health: .checking) item.button?.imagePosition = .imageOnly + item.button?.toolTip = "Vibecrafted — checking server" let menu = NSMenu() - menu.addItem( - withTitle: "Open Console", action: #selector(openConsoleFromStatusItem), keyEquivalent: "") - menu.addItem( - withTitle: "Open vc-terminal", action: #selector(openTerminalFromStatusItem), + menu.delegate = self + let serverStatus = menu.addItem( + withTitle: "VC Server: CHECKING…", action: nil, keyEquivalent: "") + serverStatus.isEnabled = false + serverStatusMenuItem = serverStatus + let serverDetail = menu.addItem( + withTitle: "Reading supervisor state…", action: nil, keyEquivalent: "") + serverDetail.isEnabled = false + serverDetailMenuItem = serverDetail + menu.addItem(.separator()) + let console = menu.addItem( + withTitle: "Open VC Console", action: #selector(openConsoleFromStatusItem), keyEquivalent: "") + console.target = self + let terminal = menu.addItem( + withTitle: "Open VC Terminal", action: #selector(openTerminalFromStatusItem), keyEquivalent: "") + terminal.target = self + let serverOwner = menu.addItem(withTitle: "VC Server", action: nil, keyEquivalent: "") + let serverMenu = NSMenu(title: "VC Server") + let start = serverMenu.addItem( + withTitle: "Start", action: #selector(startServerFromStatusItem), keyEquivalent: "") + start.target = self + startServerMenuItem = start + let stop = serverMenu.addItem( + withTitle: "Stop", action: #selector(stopServerFromStatusItem), keyEquivalent: "") + stop.target = self + stopServerMenuItem = stop + let restart = serverMenu.addItem( + withTitle: "Restart", action: #selector(restartServerFromStatusItem), keyEquivalent: "") + restart.target = self + restartServerMenuItem = restart + serverMenu.addItem(.separator()) + let logs = serverMenu.addItem( + withTitle: "Open Logs", action: #selector(openServerLogsFromStatusItem), keyEquivalent: "") + logs.target = self + openServerLogsMenuItem = logs + serverOwner.submenu = serverMenu + let diagnostics = menu.addItem( + withTitle: "Server Diagnostics…", action: #selector(showServerDiagnostics), + keyEquivalent: "") + diagnostics.target = self menu.addItem(.separator()) menu.addItem( - withTitle: "Quit", action: #selector(NSApplication.terminate(_:)), - keyEquivalent: "q") + withTitle: "About Vibecrafted", + action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "") + let help = menu.addItem( + withTitle: "Vibecrafted Help", action: #selector(showStatusItemHelp), keyEquivalent: "") + help.target = self + menu.addItem(.separator()) + let quit = menu.addItem( + withTitle: "Quit Vibecrafted", action: #selector(requestQuit), keyEquivalent: "q") + quit.target = self item.menu = menu statusItem = item + statusRefreshTimer = Timer.scheduledTimer( + timeInterval: 5, target: self, selector: #selector(refreshServerStatusFromTimer), + userInfo: nil, repeats: true) + refreshServerStatus() + } + + func menuWillOpen(_ menu: NSMenu) { + refreshServerStatus() + } + + @objc private func refreshServerStatusFromTimer() { + refreshServerStatus() + } + + private func statusIcon(health: TrayServerHealth) -> NSImage? { + guard let base = trayBaseIcon else { return nil } + let size = NSSize(width: 18, height: 18) + let image = NSImage(size: size, flipped: false) { rect in + base.draw(in: rect) + let dotRect = NSRect(x: 11.5, y: 0.5, width: 6, height: 6) + NSColor.windowBackgroundColor.setFill() + NSBezierPath(ovalIn: dotRect.insetBy(dx: -1, dy: -1)).fill() + health.color.setFill() + NSBezierPath(ovalIn: dotRect).fill() + return true + } + image.isTemplate = false + image.accessibilityDescription = "Vibecrafted server status" + return image + } + + private func supervisorStatusURL() -> URL? { + canonicalInstall?.craftedHome.appendingPathComponent("server/supervisor.status.json") + } + + private func readSupervisorSnapshot() -> ServerSupervisorSnapshot? { + supervisorData().flatMap { try? JSONDecoder().decode(ServerSupervisorSnapshot.self, from: $0) } + } + + private func supervisorData() -> Data? { + guard let url = supervisorStatusURL() else { return nil } + return try? Data(contentsOf: url) + } + + private func conciseServerReason(_ reason: String?) -> String? { + guard let firstLine = reason?.split(whereSeparator: \.isNewline).first else { return nil } + let plain = String(firstLine) + .replacingOccurrences(of: "\u{001B}[31m", with: "") + .replacingOccurrences(of: "\u{001B}[0m", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !plain.isEmpty else { return nil } + return plain.count > 96 ? "\(plain.prefix(93))…" : plain + } + + private func refreshServerStatus() { + guard let install = canonicalInstall, let environment = canonicalRuntimeEnvironment else { + applyServerMenuState( + deriveServerMenuState( + supervisorData: nil, serviceData: nil, actionInFlight: nil, runtimeReady: false)) + return + } + guard serverStatusProcess?.isRunning != true else { return } + let deck = install.root.appendingPathComponent("bin/vibecrafted") + guard FileManager.default.isExecutableFile(atPath: deck.path) else { + applyServerMenuState( + deriveServerMenuState( + supervisorData: supervisorData(), serviceData: nil, + actionInFlight: serverActionInFlight, runtimeReady: true)) + return + } + + let output = Pipe() + let errors = Pipe() + let process = Process() + process.executableURL = deck + process.arguments = ["server", "service", "status", "--json"] + process.environment = environment + process.standardOutput = output + process.standardError = errors + process.terminationHandler = { [weak self] _ in + let serviceData = output.fileHandleForReading.readDataToEndOfFile() + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.serverStatusProcess = nil + self.applyServerMenuState( + deriveServerMenuState( + supervisorData: self.supervisorData(), + serviceData: serviceData.isEmpty ? nil : serviceData, + actionInFlight: self.serverActionInFlight, + runtimeReady: true)) + } + } + do { + try process.run() + serverStatusProcess = process + } catch { + applyServerMenuState( + deriveServerMenuState( + supervisorData: supervisorData(), serviceData: nil, + actionInFlight: serverActionInFlight, runtimeReady: true)) + } + } + + private func applyServerMenuState(_ state: ServerMenuState) { + serverStatusMenuItem?.title = state.header + serverDetailMenuItem?.title = state.detail + serverDetailMenuItem?.isHidden = state.detail.isEmpty + startServerMenuItem?.isEnabled = state.canStart + stopServerMenuItem?.isEnabled = state.canStop + restartServerMenuItem?.isEnabled = state.canRestart + openServerLogsMenuItem?.isEnabled = + canonicalInstall != nil && serverUtilityProcess?.isRunning != true + statusItem?.button?.image = statusIcon(health: state.health) + statusItem?.button?.toolTip = "Vibecrafted — \(state.header)" } @objc private func openConsoleFromStatusItem() { @@ -600,6 +686,200 @@ class AppDelegate: NSObject, NSApplicationDelegate { launchWorkspaceTerminal() } + @objc private func startServerFromStatusItem() { + performServerAction(.start) + } + + @objc private func stopServerFromStatusItem() { + performServerAction(.stop) + } + + @objc private func restartServerFromStatusItem() { + performServerAction(.restart) + } + + private func performServerAction(_ action: ServerLifecycleAction) { + guard serverActionProcess?.isRunning != true else { return } + guard let install = canonicalInstall, let environment = canonicalRuntimeEnvironment else { + reportWorkspaceLaunchFailure( + "Cannot \(action.rawValue) VC Server before runtime onboarding completes") + return + } + let deck = install.root.appendingPathComponent("bin/vibecrafted") + guard FileManager.default.isExecutableFile(atPath: deck.path) else { + reportWorkspaceLaunchFailure("Canonical server launcher is missing: \(deck.path)") + return + } + + let process = Process() + let output = Pipe() + let errors = Pipe() + process.executableURL = deck + process.arguments = serverActionArguments(for: action) + process.environment = environment + process.standardOutput = output + process.standardError = errors + process.terminationHandler = { [weak self] finished in + let stdout = output.fileHandleForReading.readDataToEndOfFile() + let stderr = errors.fileHandleForReading.readDataToEndOfFile() + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.serverActionProcess = nil + self.serverActionInFlight = nil + if finished.terminationStatus != 0 { + let detail = String(data: stderr.isEmpty ? stdout : stderr, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + ?? "Canonical service owner exited \(finished.terminationStatus)" + let alert = NSAlert() + alert.alertStyle = .critical + alert.messageText = "Vibecrafted could not \(action.rawValue) VC Server" + alert.informativeText = detail + alert.addButton(withTitle: "OK") + alert.runModal() + } + self.refreshServerStatus() + } + } + do { + try process.run() + serverActionProcess = process + serverActionInFlight = action + applyServerMenuState( + deriveServerMenuState( + supervisorData: supervisorData(), serviceData: nil, + actionInFlight: action, runtimeReady: true)) + } catch { + let alert = NSAlert() + alert.alertStyle = .critical + alert.messageText = "Vibecrafted could not \(action.rawValue) VC Server" + alert.informativeText = error.localizedDescription + alert.runModal() + } + } + + @objc private func openServerLogsFromStatusItem() { + guard serverUtilityProcess?.isRunning != true else { return } + guard let install = canonicalInstall, let environment = canonicalRuntimeEnvironment else { + reportWorkspaceLaunchFailure("Cannot open VC Server logs before runtime onboarding completes") + return + } + let deck = install.root.appendingPathComponent("bin/vibecrafted") + guard FileManager.default.isExecutableFile(atPath: deck.path) else { + reportWorkspaceLaunchFailure("Canonical server launcher is missing: \(deck.path)") + return + } + + let output = Pipe() + let errors = Pipe() + let process = Process() + process.executableURL = deck + process.arguments = ["server", "service", "logs", "--json"] + process.environment = environment + process.standardOutput = output + process.standardError = errors + process.terminationHandler = { [weak self] finished in + let stdout = output.fileHandleForReading.readDataToEndOfFile() + let stderr = errors.fileHandleForReading.readDataToEndOfFile() + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.serverUtilityProcess = nil + if finished.terminationStatus == 0, let logs = decodeServerLogs(data: stdout) { + NSWorkspace.shared.open(logs.directory) + } else { + let detail = String(data: stderr.isEmpty ? stdout : stderr, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + ?? "Canonical service owner did not return its log location" + let alert = NSAlert() + alert.alertStyle = .critical + alert.messageText = "Vibecrafted could not open VC Server logs" + alert.informativeText = detail + alert.addButton(withTitle: "OK") + alert.runModal() + } + self.refreshServerStatus() + } + } + do { + try process.run() + serverUtilityProcess = process + openServerLogsMenuItem?.isEnabled = false + } catch { + let alert = NSAlert() + alert.alertStyle = .critical + alert.messageText = "Vibecrafted could not open VC Server logs" + alert.informativeText = error.localizedDescription + alert.runModal() + } + } + + @objc private func showServerDiagnostics() { + let snapshot = readSupervisorSnapshot() + let alert = NSAlert() + alert.alertStyle = snapshot?.state.lowercased() == "healthy" ? .informational : .warning + alert.messageText = "Vibecrafted Server" + if let snapshot { + var lines = [ + "State: \(snapshot.state.uppercased())", + "Supervisor PID: \(snapshot.supervisorPID.map(String.init) ?? "—")", + "Server PID: \(snapshot.managedPair?.serverPID.map(String.init) ?? "—")", + "Guardian PID: \(snapshot.managedPair?.guardianPID.map(String.init) ?? "—")", + ] + if let reason = conciseServerReason(snapshot.lastError) { + lines.append("Last error: \(reason)") + } + if let path = supervisorStatusURL()?.path { + lines.append("Status receipt: \(path)") + } + alert.informativeText = lines.joined(separator: "\n") + } else { + alert.informativeText = "No supervisor status receipt exists for the installed runtime." + } + alert.addButton(withTitle: "OK") + alert.addButton(withTitle: "Open Console") + if alert.runModal() == .alertSecondButtonReturn { + showMainWindowIfNeeded() + } + } + + @objc private func showStatusItemHelp() { + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = "Vibecrafted Help" + alert.informativeText = + "The tray dot reports VC Server: green is healthy, amber is transitioning, red needs attention, and gray is stopped. Open VC Console for live runs; VC Server actions always route through the installed service owner." + alert.addButton(withTitle: "OK") + alert.runModal() + } + + private func activeRunSummary() -> RuntimeActivityTruth { + guard let install = canonicalInstall, let environment = canonicalRuntimeEnvironment else { + return .unavailable("canonical runtime onboarding is incomplete") + } + let deck = install.root.appendingPathComponent("bin/vibecrafted") + guard FileManager.default.isExecutableFile(atPath: deck.path) else { + return .unavailable("canonical lifecycle launcher is missing") + } + let output = Pipe() + let process = Process() + process.executableURL = deck + process.arguments = ["status", "--activity", "--json"] + process.environment = environment + process.standardOutput = output + process.standardError = FileHandle.nullDevice + do { + try process.run() + let data = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return decodeRuntimeActivityTruth(data: data, terminationStatus: process.terminationStatus) + } catch { + return .unavailable(error.localizedDescription) + } + } + + @objc private func requestQuit() { + NSApp.terminate(nil) + } + private func buildMainMenu() { let mainMenu = NSMenu() @@ -619,9 +899,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { withTitle: "Show All", action: #selector(NSApplication.unhideAllApplications(_:)), keyEquivalent: "") appMenu.addItem(.separator()) - appMenu.addItem( - withTitle: "Quit Vibecrafted", action: #selector(NSApplication.terminate(_:)), - keyEquivalent: "q") + let appQuit = appMenu.addItem( + withTitle: "Quit Vibecrafted", action: #selector(requestQuit), keyEquivalent: "q") + appQuit.target = self let appMenuItem = NSMenuItem() appMenuItem.submenu = appMenu diff --git a/vibecrafted-app/shell-agent/app/Vibecrafted/NotificationManager.swift b/vibecrafted-app/shell-agent/app/Vibecrafted/NotificationManager.swift index 01564f75..3f4d228c 100644 --- a/vibecrafted-app/shell-agent/app/Vibecrafted/NotificationManager.swift +++ b/vibecrafted-app/shell-agent/app/Vibecrafted/NotificationManager.swift @@ -7,7 +7,11 @@ import UserNotifications /// `osascript display notification` is attributed to Script Editor. Posting /// through `UNUserNotificationCenter` from this bundle keeps the sender as /// Vibecrafted.app and lets a click open Mission Control on the run. -final class NotificationManager: NSObject, UNUserNotificationCenterDelegate { +/// +/// Mutable state (`started`, `presentWindow`) is only written on the main +/// thread during app launch; notification-center and IPC callbacks arrive on +/// other threads but never mutate, which is why this is @unchecked Sendable. +final class NotificationManager: NSObject, UNUserNotificationCenterDelegate, @unchecked Sendable { static let shared = NotificationManager() static let categoryRunSettled = "run.settled" @@ -59,11 +63,20 @@ final class NotificationManager: NSObject, UNUserNotificationCenterDelegate { func handleOpenURLs(_ urls: [URL]) { for url in urls { + if Self.isConsoleURL(url) { + presentWindow?() + continue + } guard let target = Self.parseVibecraftedURL(url) else { continue } present(runId: target.runId, report: nil, preferReport: target.kind == "report") } } + static func isConsoleURL(_ url: URL) -> Bool { + guard url.scheme == urlScheme else { return false } + return url.host == "console" && url.path == "/open" + } + static func parseVibecraftedURL(_ url: URL) -> (kind: String, runId: String)? { guard url.scheme == urlScheme else { return nil } var parts: [String] = [] diff --git a/vibecrafted-app/shell-agent/app/Vibecrafted/QuitSafety.swift b/vibecrafted-app/shell-agent/app/Vibecrafted/QuitSafety.swift new file mode 100644 index 00000000..c4013234 --- /dev/null +++ b/vibecrafted-app/shell-agent/app/Vibecrafted/QuitSafety.swift @@ -0,0 +1,41 @@ +import Foundation + +struct RuntimeActivitySummary: Decodable, Equatable { + let lanes: Int + let worktrees: Int +} + +private struct RuntimeActivitySnapshot: Decodable { + let schemaVersion: String + let summary: RuntimeActivitySummary + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case summary + } +} + +enum RuntimeActivityTruth: Equatable { + case available(RuntimeActivitySummary) + case unavailable(String) +} + +func decodeRuntimeActivityTruth(data: Data, terminationStatus: Int32) -> RuntimeActivityTruth { + guard terminationStatus == 0 else { + return .unavailable("lifecycle query exited with status \(terminationStatus)") + } + do { + let snapshot = try JSONDecoder().decode(RuntimeActivitySnapshot.self, from: data) + guard snapshot.schemaVersion == "vibecrafted.lifecycle-activity.v1" else { + return .unavailable("lifecycle query returned an unsupported schema") + } + guard snapshot.summary.lanes >= 0, snapshot.summary.worktrees >= 0, + snapshot.summary.worktrees <= snapshot.summary.lanes + else { + return .unavailable("lifecycle query returned impossible counts") + } + return .available(snapshot.summary) + } catch { + return .unavailable("lifecycle query returned malformed JSON") + } +} diff --git a/vibecrafted-app/shell-agent/app/Vibecrafted/ServerMenuPolicy.swift b/vibecrafted-app/shell-agent/app/Vibecrafted/ServerMenuPolicy.swift new file mode 100644 index 00000000..9dc89730 --- /dev/null +++ b/vibecrafted-app/shell-agent/app/Vibecrafted/ServerMenuPolicy.swift @@ -0,0 +1,217 @@ +import Foundation + +enum ServerLifecycleAction: String { + case start + case stop + case restart +} + +enum TrayServerHealth: String { + case checking + case healthy + case transitioning + case failed + case neutral +} + +struct ServerMenuState { + let header: String + let detail: String + let health: TrayServerHealth + let canStart: Bool + let canStop: Bool + let canRestart: Bool +} + +struct ServerSupervisorSnapshot: Decodable { + struct Endpoint: Decodable { + let host: String + let port: Int + } + + struct ManagedPair: Decodable { + let guardianPID: Int? + let serverPID: Int? + + enum CodingKeys: String, CodingKey { + case guardianPID = "guardian_pid" + case serverPID = "server_pid" + } + } + + let state: String + let lastError: String? + let supervisorPID: Int? + let managedPair: ManagedPair? + let endpoint: Endpoint? + + enum CodingKeys: String, CodingKey { + case state + case lastError = "last_error" + case supervisorPID = "supervisor_pid" + case managedPair = "managed_pair" + case endpoint + } +} + +private struct ServerServiceSnapshot: Decodable { + let installed: Bool + let loaded: Bool + let supervisorLive: Bool + let supervisorVerified: Bool + let supervisorServiceManaged: Bool + let buildCurrent: Bool + let pairHealthy: Bool + let supervisorPID: Int? + + enum CodingKeys: String, CodingKey { + case installed + case loaded + case supervisorLive = "supervisor_live" + case supervisorVerified = "supervisor_verified" + case supervisorServiceManaged = "supervisor_service_managed" + case buildCurrent = "build_current" + case pairHealthy = "pair_healthy" + case supervisorPID = "supervisor_pid" + } +} + +struct ServerLogLocations: Decodable { + let directory: URL + let stdout: URL + let stderr: URL + + enum CodingKeys: String, CodingKey { + case directory + case stdout + case stderr + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + func absoluteURL(_ key: CodingKeys) throws -> URL { + let path = try container.decode(String.self, forKey: key) + guard path.hasPrefix("/") else { + throw DecodingError.dataCorruptedError( + forKey: key, in: container, + debugDescription: "server service returned a non-absolute log path") + } + return URL(fileURLWithPath: path) + } + directory = try absoluteURL(.directory) + stdout = try absoluteURL(.stdout) + stderr = try absoluteURL(.stderr) + } +} + +func serverActionArguments(for action: ServerLifecycleAction) -> [String] { + ["server", "service", action.rawValue] +} + +func decodeServerLogs(data: Data) -> ServerLogLocations? { + try? JSONDecoder().decode(ServerLogLocations.self, from: data) +} + +private func conciseServerFailure(_ value: String?) -> String? { + guard let line = value?.split(whereSeparator: \.isNewline).first else { return nil } + let plain = String(line) + .replacingOccurrences(of: "\u{001B}[31m", with: "") + .replacingOccurrences(of: "\u{001B}[0m", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !plain.isEmpty else { return nil } + return plain.count > 96 ? "\(plain.prefix(93))…" : plain +} + +private func endpointSuffix(_ snapshot: ServerSupervisorSnapshot?) -> String { + guard let endpoint = snapshot?.endpoint else { return "" } + return " · \(endpoint.host):\(endpoint.port)" +} + +func deriveServerMenuState( + supervisorData: Data?, + serviceData: Data?, + actionInFlight: ServerLifecycleAction?, + runtimeReady: Bool +) -> ServerMenuState { + if !runtimeReady { + return ServerMenuState( + header: "VC Server: WAITING FOR RUNTIME", + detail: "Runtime onboarding has not completed", + health: .checking, + canStart: false, + canStop: false, + canRestart: false) + } + + if let actionInFlight { + let transition: String + switch actionInFlight { + case .start: transition = "STARTING" + case .stop: transition = "STOPPING" + case .restart: transition = "RESTARTING" + } + return ServerMenuState( + header: "VC Server: \(transition)…", + detail: "Waiting for the installed service owner", + health: .transitioning, + canStart: false, + canStop: false, + canRestart: false) + } + + let decoder = JSONDecoder() + let supervisor = supervisorData.flatMap { + try? decoder.decode(ServerSupervisorSnapshot.self, from: $0) + } + guard let service = serviceData.flatMap({ + try? decoder.decode(ServerServiceSnapshot.self, from: $0) + }) else { + return ServerMenuState( + header: "VC Server: UNAVAILABLE\(endpointSuffix(supervisor))", + detail: conciseServerFailure(supervisor?.lastError) ?? "Canonical service status is unavailable", + health: .failed, + canStart: false, + canStop: false, + canRestart: false) + } + + guard service.installed else { + return ServerMenuState( + header: "VC Server: NOT INSTALLED\(endpointSuffix(supervisor))", + detail: "Install the canonical VC Server service first", + health: .failed, + canStart: false, + canStop: false, + canRestart: false) + } + + if !service.loaded { + return ServerMenuState( + header: "VC Server: STOPPED\(endpointSuffix(supervisor))", + detail: "Service is intentionally stopped", + health: .neutral, + canStart: true, + canStop: false, + canRestart: false) + } + + let healthy = service.supervisorLive && service.supervisorVerified + && service.supervisorServiceManaged && service.buildCurrent && service.pairHealthy + if healthy { + return ServerMenuState( + header: "VC Server: HEALTHY\(endpointSuffix(supervisor))", + detail: "Supervisor PID \(service.supervisorPID.map(String.init) ?? "—")", + health: .healthy, + canStart: false, + canStop: true, + canRestart: true) + } + + return ServerMenuState( + header: "VC Server: NEEDS ATTENTION\(endpointSuffix(supervisor))", + detail: conciseServerFailure(supervisor?.lastError) ?? "Installed service is not healthy", + health: .failed, + canStart: false, + canStop: true, + canRestart: true) +} diff --git a/vibecrafted-app/shell-agent/app/project.yml b/vibecrafted-app/shell-agent/app/project.yml index 57fb3784..1eefa2ee 100644 --- a/vibecrafted-app/shell-agent/app/project.yml +++ b/vibecrafted-app/shell-agent/app/project.yml @@ -21,6 +21,7 @@ targets: - "Bridge/vibecrafted_shell_ffiFFI.h" - "Bridge/vibecrafted_shell_ffiFFI.modulemap" - "Info.plist" + - "Vibecrafted.icns" preBuildScripts: - name: "Build Rust FFI" script: | @@ -32,7 +33,7 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: io.vetcoders.vibecrafted - MARKETING_VERSION: "0.1.0" + MARKETING_VERSION: "4.3.0" CURRENT_PROJECT_VERSION: 1 GENERATE_INFOPLIST_FILE: false INFOPLIST_FILE: Vibecrafted/Info.plist diff --git a/vibecrafted-app/tui-agent/README.md b/vibecrafted-app/tui-agent/README.md index bd12f403..fe26ec19 100644 --- a/vibecrafted-app/tui-agent/README.md +++ b/vibecrafted-app/tui-agent/README.md @@ -34,6 +34,19 @@ outside the control-plane directory. `config::default_state_root` falls back to historical variants (`state/control-plane`, `state`, `control-plane`) if the canonical `control_plane` path is missing, so older layouts keep loading. +## Refresh cadence + +The 250 ms UI tick redraws cached state only. Expensive control-plane +projection and Polarize prism discovery are invalidated by filesystem events, +debounced for 100 ms, and scheduled within one second at the default tick rate. +Observe polling runs independently every two seconds. If a filesystem watcher +cannot start, its affected surface falls back to a 30-second refresh. Pressing +`r` always bypasses the scheduler and forces a complete refresh. + +For an isolated performance proof, set `VOC_REFRESH_TRACE_PATH` to record one +JSONL row after each control-plane projection and Polarize discovery. Leave it +unset in normal use; tracing is opt-in and does no filesystem IO otherwise. + ## Launching workflows The TUI shells out to the existing `vibecrafted` command deck when you launch a diff --git a/vibecrafted-app/tui-agent/src/app.rs b/vibecrafted-app/tui-agent/src/app.rs index 971cff2b..eec0fa78 100644 --- a/vibecrafted-app/tui-agent/src/app.rs +++ b/vibecrafted-app/tui-agent/src/app.rs @@ -11,7 +11,9 @@ use crate::state::{ControlPlaneState, RenderedRun, RunKind, render_runs}; use std::collections::BTreeMap; use std::ffi::OsString; use std::fs; +use std::io::Write; use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AppTab { @@ -235,20 +237,20 @@ pub struct App { pub artifact_title: String, pub artifact_lines: Vec, /// Cached rmcp-mux supervisor snapshots (from - /// `crate::mux::current_summaries`). Refreshed on every `App::refresh` - /// so the Monitor tab can render MCP daemon health without doing IO - /// inside the draw path. + /// `crate::mux::current_summaries`). Refreshed from mux events and by an + /// explicit full refresh so drawing never performs IO. pub mux_summaries: Vec, pub mux_subscriber: Option, /// Cached polarize prism intents discovered under /// `$VIBECRAFTED_HOME/artifacts/**/polarize//prism.json`. - /// Refreshed with the run board so draw code remains pure rendering. + /// Refreshed when the artifact watcher observes a Polarize path or when + /// the operator explicitly requests a full refresh. pub polarize_intents: Vec, /// Cached Mission Control view derived from /// `~/.vibecrafted/artifacts/**/*.meta.json` plus live control-plane - /// runs. Built on every `refresh` so the dashboard tab can render - /// without doing IO inside the draw path. The artifact root is - /// resolved once via `mission_control::default_artifact_root()`. + /// runs. Rebuilt after relevant state/artifact changes so the dashboard + /// tab can render without doing IO inside the draw path. The artifact root + /// is resolved once via `mission_control::default_artifact_root()`. pub mission_control: MissionControlState, /// Selected panel index inside the Mission Control tab (0..7). pub mission_focus: usize, @@ -263,6 +265,7 @@ impl App { pub fn new(config: AppConfig) -> anyhow::Result { let state = ControlPlaneState::load(&config.state_root) .unwrap_or_else(|_| ControlPlaneState::empty(&config.state_root)); + trace_expensive_refresh("control_plane"); let runs = render_runs(&state); let launch_runtime = config.launch_runtime; let mission_artifact_root = mission_control::default_artifact_root(); @@ -318,24 +321,55 @@ impl App { } pub fn refresh(&mut self) { + self.refresh_control_plane(); + self.refresh_mux(); + self.refresh_polarize(); + self.refresh_mission_control(); + self.refresh_observe(); + } + + /// Reload the canonical control-plane projection after an invalidation. + /// Selection follows the stable run id across sorting/filter changes. + pub fn refresh_control_plane(&mut self) { + let selected_run_id = self.selected_run().map(|run| run.snapshot.run_id.clone()); let state = ControlPlaneState::load(&self.config.state_root) .unwrap_or_else(|_| ControlPlaneState::empty(&self.config.state_root)); + trace_expensive_refresh("control_plane"); self.state = state; let mut runs = render_runs(&self.state); apply_run_filters(&mut runs, self.queue_scope, &self.search_query); self.runs = runs; + if let Some(run_id) = selected_run_id + && let Some(index) = self + .runs + .iter() + .position(|run| run.snapshot.run_id == run_id) + { + self.selected = index; + } self.sync_selection(); - self.refresh_mux(); - self.refresh_polarize(); - self.refresh_mission_control(); - self.refresh_observe(); } - /// Refresh the cached Mission Control view. Cheap on small artifact - /// trees (a few directories of `*.meta.json`); bounded on huge - /// trees by `mission_control::META_SCAN_CAP`. Called on every - /// `refresh()` so the dashboard surfaces stay live without doing - /// disk IO inside the draw path. + /// Recompute only time-derived labels and filters from cached state. + /// This keeps ages and stale classifications moving without re-reading + /// the control-plane filesystem. + pub fn refresh_rendered_runs(&mut self) { + let selected_run_id = self.selected_run().map(|run| run.snapshot.run_id.clone()); + let mut runs = render_runs(&self.state); + apply_run_filters(&mut runs, self.queue_scope, &self.search_query); + self.runs = runs; + if let Some(run_id) = selected_run_id + && let Some(index) = self + .runs + .iter() + .position(|run| run.snapshot.run_id == run_id) + { + self.selected = index; + } + self.sync_selection(); + } + + /// Refresh the remote Observe projection on its own bounded cadence. pub fn refresh_observe(&mut self) { let origin = self.config.server.clone(); self.observe.origin = origin.clone(); @@ -445,6 +479,7 @@ impl App { pub fn refresh_polarize(&mut self) { self.polarize_intents = crate::polarize::current_intents(&self.config.launch_root); + trace_expensive_refresh("polarize"); } pub fn handle_ipc_event(&mut self, _event: rmcp_mux::ipc::IpcEvent) { @@ -514,7 +549,7 @@ impl App { pub fn toggle_filter(&mut self) { self.queue_scope = self.queue_scope.next(); - self.refresh(); + self.refresh_rendered_runs(); self.append_status(format!( "queue scope: {} ({} runs visible)", self.queue_scope.label(), @@ -524,13 +559,13 @@ impl App { pub fn set_search_query>(&mut self, query: S) { self.search_query = query.into(); - self.refresh(); + self.refresh_rendered_runs(); } pub fn clear_search(&mut self) { if !self.search_query.is_empty() { self.search_query.clear(); - self.refresh(); + self.refresh_rendered_runs(); self.append_status("search cleared"); } } @@ -1237,6 +1272,24 @@ impl App { } } +fn trace_expensive_refresh(kind: &str) { + let Some(path) = std::env::var_os("VOC_REFRESH_TRACE_PATH").filter(|value| !value.is_empty()) + else { + return; + }; + let unix_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let line = serde_json::json!({ + "kind": kind, + "unix_ms": unix_ms, + }); + if let Ok(mut file) = fs::OpenOptions::new().create(true).append(true).open(path) { + let _ = writeln!(file, "{line}"); + } +} + fn dispatch_line(selected: bool, content: String) -> String { if selected { format!("▶ {content}") diff --git a/vibecrafted-app/tui-agent/src/config.rs b/vibecrafted-app/tui-agent/src/config.rs index bff4ff1c..d1484afe 100644 --- a/vibecrafted-app/tui-agent/src/config.rs +++ b/vibecrafted-app/tui-agent/src/config.rs @@ -55,6 +55,10 @@ pub fn parse_args() -> anyhow::Result { print_help(); std::process::exit(0); } + "--version" | "-V" => { + println!("voc {}", env!("CARGO_PKG_VERSION")); + std::process::exit(0); + } "--state-root" => { let value = args .next() diff --git a/vibecrafted-app/tui-agent/src/lib.rs b/vibecrafted-app/tui-agent/src/lib.rs index c7c9257b..69b6282f 100644 --- a/vibecrafted-app/tui-agent/src/lib.rs +++ b/vibecrafted-app/tui-agent/src/lib.rs @@ -28,6 +28,109 @@ use std::sync::mpsc::{self, Sender}; use std::thread; use std::time::{Duration, Instant}; +const CHANGE_DEBOUNCE: Duration = Duration::from_millis(100); +const RENDER_REFRESH_INTERVAL: Duration = Duration::from_secs(1); +const OBSERVE_REFRESH_INTERVAL: Duration = Duration::from_secs(2); +const WATCHER_FALLBACK_INTERVAL: Duration = Duration::from_secs(30); +/// Watch events are debounced for 100 ms and serviced by the next UI poll. +/// The one-second bound includes the default 250 ms tick and watcher delivery jitter. +pub const MAX_CHANGE_LATENCY: Duration = Duration::from_secs(1); + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct RefreshPlan { + control_plane: bool, + polarize: bool, + mission_control: bool, + rendered_runs: bool, + observe: bool, +} + +#[derive(Debug)] +struct RefreshScheduler { + state_watcher_active: bool, + artifact_watcher_active: bool, + state_dirty_since: Option, + polarize_dirty_since: Option, + mission_dirty_since: Option, + last_control_plane: Instant, + last_artifacts: Instant, + last_rendered_runs: Instant, + last_observe: Instant, +} + +impl RefreshScheduler { + fn new(now: Instant, state_watcher_active: bool, artifact_watcher_active: bool) -> Self { + Self { + state_watcher_active, + artifact_watcher_active, + state_dirty_since: None, + polarize_dirty_since: None, + mission_dirty_since: None, + last_control_plane: now, + last_artifacts: now, + last_rendered_runs: now, + last_observe: now, + } + } + + fn mark_state_changed(&mut self, now: Instant) { + self.state_dirty_since.get_or_insert(now); + } + + fn mark_artifacts_changed(&mut self, change: ArtifactChange, now: Instant) { + if change.polarize { + self.polarize_dirty_since.get_or_insert(now); + } + if change.mission_control { + self.mission_dirty_since.get_or_insert(now); + } + } + + fn plan(&mut self, now: Instant) -> RefreshPlan { + let mut plan = RefreshPlan::default(); + if due(self.state_dirty_since, now, CHANGE_DEBOUNCE) + || (!self.state_watcher_active + && now.duration_since(self.last_control_plane) >= WATCHER_FALLBACK_INTERVAL) + { + plan.control_plane = true; + self.state_dirty_since = None; + self.last_control_plane = now; + } + if due(self.polarize_dirty_since, now, CHANGE_DEBOUNCE) + || (!self.artifact_watcher_active + && now.duration_since(self.last_artifacts) >= WATCHER_FALLBACK_INTERVAL) + { + plan.polarize = true; + self.polarize_dirty_since = None; + self.last_artifacts = now; + } + if due(self.mission_dirty_since, now, CHANGE_DEBOUNCE) { + plan.mission_control = true; + self.mission_dirty_since = None; + self.last_artifacts = now; + } + if now.duration_since(self.last_rendered_runs) >= RENDER_REFRESH_INTERVAL { + plan.rendered_runs = !plan.control_plane; + self.last_rendered_runs = now; + } + if now.duration_since(self.last_observe) >= OBSERVE_REFRESH_INTERVAL { + plan.observe = true; + self.last_observe = now; + } + plan + } +} + +fn due(since: Option, now: Instant, delay: Duration) -> bool { + since.is_some_and(|changed_at| now.duration_since(changed_at) >= delay) +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct ArtifactChange { + polarize: bool, + mission_control: bool, +} + pub use app::{App, AppTab, DeepAction, DispatchFocus, LaunchFocus, QueueScope}; pub use config::{AppConfig, CliOptions, build_config, parse_args}; pub use launch::{LaunchCommand, LaunchKind}; @@ -58,21 +161,35 @@ fn run_app(config: AppConfig) -> anyhow::Result<()> { let result = (|| -> anyhow::Result<()> { let mut app = App::new(config)?; - let (watch_tx, watch_rx) = mpsc::channel(); - let _watcher = match start_state_watcher(&app.config.state_root, watch_tx) { + let (state_tx, state_rx) = mpsc::channel(); + let state_watcher = match start_state_watcher(&app.config.state_root, state_tx) { Ok(watcher) => Some(watcher), Err(error) => { - app.append_status(format!("watcher unavailable: {error}")); + app.append_status(format!("state watcher unavailable: {error}")); None } }; - let mut last_tick = Instant::now(); + let (artifact_tx, artifact_rx) = mpsc::channel(); + let artifact_watcher = + match start_artifact_watcher(&crate::polarize::vibecrafted_home(), artifact_tx) { + Ok(watcher) => Some(watcher), + Err(error) => { + app.append_status(format!("artifact watcher unavailable: {error}")); + None + } + }; + let mut scheduler = RefreshScheduler::new( + Instant::now(), + state_watcher.is_some(), + artifact_watcher.is_some(), + ); loop { terminal.draw(|frame| ui::draw(frame, &app))?; + let last_draw = Instant::now(); let timeout = app .config .tick_rate - .checked_sub(last_tick.elapsed()) + .checked_sub(last_draw.elapsed()) .unwrap_or(Duration::ZERO); if event::poll(timeout)? @@ -82,9 +199,12 @@ fn run_app(config: AppConfig) -> anyhow::Result<()> { break; } - let mut watched_change = false; - while watch_rx.try_recv().is_ok() { - watched_change = true; + let now = Instant::now(); + while state_rx.try_recv().is_ok() { + scheduler.mark_state_changed(now); + } + while let Ok(change) = artifact_rx.try_recv() { + scheduler.mark_artifacts_changed(change, now); } let mut events = Vec::new(); if let Some(sub) = &app.mux_subscriber { @@ -96,16 +216,22 @@ fn run_app(config: AppConfig) -> anyhow::Result<()> { for event in events { app.handle_ipc_event(event); } - watched_change = true; } - if watched_change { - app.refresh(); - last_tick = Instant::now(); + let plan = scheduler.plan(now); + if plan.control_plane { + app.refresh_control_plane(); } - - if last_tick.elapsed() >= app.config.tick_rate { - app.refresh(); - last_tick = Instant::now(); + if plan.polarize { + app.refresh_polarize(); + } + if plan.control_plane || plan.mission_control || plan.polarize { + app.refresh_mission_control(); + } + if plan.rendered_runs { + app.refresh_rendered_runs(); + } + if plan.observe { + app.refresh_observe(); } } Ok(()) @@ -744,6 +870,40 @@ fn start_state_watcher(path: &Path, tx: Sender<()>) -> anyhow::Result, +) -> anyhow::Result { + let mut watcher = RecommendedWatcher::new( + move |event: notify::Result| { + let Ok(event) = event else { + return; + }; + let change = classify_artifact_change(&event.paths); + if change.polarize || change.mission_control { + let _ = tx.send(change); + } + }, + NotifyConfig::default(), + )?; + watcher.watch(path, RecursiveMode::Recursive)?; + Ok(watcher) +} + +fn classify_artifact_change(paths: &[PathBuf]) -> ArtifactChange { + ArtifactChange { + polarize: paths.iter().any(|path| { + path.components() + .any(|component| component.as_os_str() == "polarize") + }), + mission_control: paths.iter().any(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".meta.json")) + }), + } +} + #[cfg(test)] mod tests { use super::*; @@ -956,4 +1116,122 @@ mod tests { "probe_error=None must not render an empty probe section: lines={lines:?}" ); } + + #[test] + fn ui_ticks_do_not_schedule_expensive_projection_or_prism_discovery() { + let start = Instant::now(); + let mut scheduler = RefreshScheduler::new(start, true, true); + let mut control_plane_refreshes = 0; + let mut prism_discoveries = 0; + + for tick in 1..=400 { + let plan = scheduler.plan(start + Duration::from_millis(tick * 10)); + control_plane_refreshes += usize::from(plan.control_plane); + prism_discoveries += usize::from(plan.polarize); + } + + assert_eq!(control_plane_refreshes, 0); + assert_eq!(prism_discoveries, 0); + } + + #[test] + fn changed_state_and_prism_are_scheduled_inside_the_documented_bound() { + let start = Instant::now(); + let changed_at = start + Duration::from_secs(1); + let mut scheduler = RefreshScheduler::new(start, true, true); + scheduler.mark_state_changed(changed_at); + scheduler.mark_artifacts_changed( + ArtifactChange { + polarize: true, + mission_control: true, + }, + changed_at, + ); + + let before_debounce = scheduler.plan(changed_at + CHANGE_DEBOUNCE / 2); + assert!(!before_debounce.control_plane); + assert!(!before_debounce.polarize); + + let visible_at = changed_at + MAX_CHANGE_LATENCY; + let due = scheduler.plan(visible_at); + assert!(due.control_plane); + assert!(due.polarize); + assert!(due.mission_control); + + let unchanged_tick = scheduler.plan(visible_at + Duration::from_millis(10)); + assert!(!unchanged_tick.control_plane); + assert!(!unchanged_tick.polarize); + assert!(!unchanged_tick.mission_control); + } + + #[test] + fn artifact_invalidation_ignores_unrelated_churn() { + let unrelated = classify_artifact_change(&[ + PathBuf::from("/tmp/home/artifacts/run/transcript.log"), + PathBuf::from("/tmp/home/cache.json"), + ]); + assert_eq!(unrelated, ArtifactChange::default()); + + let relevant = classify_artifact_change(&[ + PathBuf::from("/tmp/home/artifacts/project/polarize/run/prism.json"), + PathBuf::from("/tmp/home/artifacts/run/report.meta.json"), + ]); + assert!(relevant.polarize); + assert!(relevant.mission_control); + } + + #[test] + fn explicit_refresh_bypasses_debounce_and_loads_new_control_plane_truth() { + let dir = tempfile::tempdir().expect("tempdir"); + let state_root = dir.path().join("control_plane"); + std::fs::create_dir_all(state_root.join("runs")).expect("runs dir"); + std::fs::write( + state_root.join("runs/forced-refresh.json"), + r#"{ + "run_id": "forced-refresh", + "agent": "codex", + "skill": "hydrate", + "state": "running", + "updated_at": "2026-08-25T21:27:30Z" + }"#, + ) + .expect("run snapshot"); + + let now = Instant::now(); + let mut scheduler = RefreshScheduler::new(now, true, true); + scheduler.mark_state_changed(now); + assert!( + !scheduler.plan(now).control_plane, + "change remains debounced" + ); + + let mut app = sample_app(); + app.config.state_root = state_root; + app.refresh_control_plane(); + + assert!( + app.runs + .iter() + .any(|run| run.snapshot.run_id == "forced-refresh"), + "the explicit refresh path must load disk truth without waiting for the scheduler" + ); + } + + #[test] + fn render_only_refresh_preserves_selection_by_run_id() { + let mut app = sample_app(); + app.selected = 1; + let expected = app.runs[1].snapshot.run_id.clone(); + app.state.runs = app + .runs + .iter() + .rev() + .map(|run| run.snapshot.clone()) + .collect(); + app.state.retained_runs = app.state.runs.clone(); + + app.refresh_rendered_runs(); + + assert_eq!(app.selected_run().unwrap().snapshot.run_id, expected); + } } diff --git a/vibecrafted-app/tui-agent/src/polarize.rs b/vibecrafted-app/tui-agent/src/polarize.rs index 0d7cf364..dfb9255a 100644 --- a/vibecrafted-app/tui-agent/src/polarize.rs +++ b/vibecrafted-app/tui-agent/src/polarize.rs @@ -222,7 +222,7 @@ fn parse_band(raw: &str) -> Option { } } -fn vibecrafted_home() -> PathBuf { +pub fn vibecrafted_home() -> PathBuf { if let Some(home) = env::var_os("VIBECRAFTED_HOME").filter(|value| !value.is_empty()) { return PathBuf::from(home); } diff --git a/vibecrafted-core/pyproject.toml b/vibecrafted-core/pyproject.toml index 2567e938..7722ff92 100644 --- a/vibecrafted-core/pyproject.toml +++ b/vibecrafted-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "vibecrafted" -version = "4.2.4" +version = "4.3.0" description = "Core Python library for Vibecrafted runtime and synthesis surfaces." readme = "README.md" requires-python = ">=3.11" diff --git a/vibecrafted-core/tests/delivery/test_delivery_e2e.py b/vibecrafted-core/tests/delivery/test_delivery_e2e.py index cc439f42..5961e5b0 100644 --- a/vibecrafted-core/tests/delivery/test_delivery_e2e.py +++ b/vibecrafted-core/tests/delivery/test_delivery_e2e.py @@ -403,3 +403,11 @@ def test_completed_receipts_are_explicitly_unverified(tmp_path: Path) -> None: assert "- execution_state: exited" in text assert "- proof_state: undeclared" in text assert "- delivery_state: unverified" in text + + +def test_quota_exhaustion_is_a_user_policy_interruption_not_provider_failure() -> None: + assert delivery_axes_for_receipt("quota_exhausted", {"exit_code": 75}) == { + "execution_state": "interrupted", + "proof_state": "undeclared", + "delivery_state": "unverified", + } diff --git a/vibecrafted-core/tests/dispatch/test_supervisor.py b/vibecrafted-core/tests/dispatch/test_supervisor.py index bf50f296..a61e512c 100644 --- a/vibecrafted-core/tests/dispatch/test_supervisor.py +++ b/vibecrafted-core/tests/dispatch/test_supervisor.py @@ -230,16 +230,21 @@ def test_workflow_cell_launcher_carries_cut_model_pin_into_spec( expect = { contains = "ok" } """, ) - captured: dict[str, str] = {} + captured: dict[str, object] = {} def fake_launch_workflow(spec, _base_dir, *, env=None): captured[spec.agent] = spec.model assert env is not None + captured[f"{spec.agent}_idempotency"] = env.get( + workflow.LAUNCH_IDEMPOTENCY_KEY_ENV + ) return {"accepted": True, "run_id": "r", "pid": 1, "report": ""} monkeypatch.setattr(supervisor_module, "launch_workflow", fake_launch_workflow) - launch = workflow_cell_launcher(dispatch, source_dir=tmp_path) + launch = workflow_cell_launcher( + dispatch, source_dir=tmp_path, dispatch_run_id="dispatch-stable-1" + ) launch(dispatch.cuts[0], "pinned cut", "initial") launch(dispatch.cuts[1], "unpinned cut", "initial") @@ -247,6 +252,12 @@ def fake_launch_workflow(spec, _base_dir, *, env=None): # cut forwards an empty pin (account default is a deliberate non-decision). assert captured["codex"] == "test-codex-model" assert captured["claude"] == "" + assert captured["codex_idempotency"] == ( + "dispatch:dispatch-stable-1:cut:c1:attempt:initial" + ) + assert captured["claude_idempotency"] == ( + "dispatch:dispatch-stable-1:cut:c2:attempt:initial" + ) def test_passing_cuts_flip_to_verified_and_emit_artifacts(tmp_path: Path) -> None: diff --git a/vibecrafted-core/tests/fixtures/interactive_provider.py b/vibecrafted-core/tests/fixtures/interactive_provider.py new file mode 100755 index 00000000..263bd367 --- /dev/null +++ b/vibecrafted-core/tests/fixtures/interactive_provider.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Deterministic local provider used by installed-wheel lifecycle smoke tests.""" + +from __future__ import annotations + +import json +import os +import sys +import time +from pathlib import Path + +if "--help" in sys.argv: + print(" --session-id ") + raise SystemExit(0) +if "--version" in sys.argv: + print("2.1.232 (Claude Code)") + raise SystemExit(0) + +session_id = sys.argv[sys.argv.index("--session-id") + 1] +role = os.environ.get("VIBECRAFTED_AGENT_ROLE", "agent") +captures_root = os.environ.get("SUPERVISION_CAPTURES", "").strip() +capture = ( + Path(captures_root) / f"{role}.json" + if captures_root + else Path(os.environ["SMOKE_CAPTURE"]) +) +capture.write_text( + json.dumps( + { + "pid": os.getpid(), + "stdin_tty": os.isatty(0), + "stdout_tty": os.isatty(1), + "stderr_tty": os.isatty(2), + "run_id": os.environ["VIBECRAFTED_RUN_ID"], + "provider_session_id": session_id, + "parent_root": os.environ["VIBECRAFTED_PARENT_ROOT"], + "effective_root": os.environ["VIBECRAFTED_EFFECTIVE_ROOT"], + "role": role, + "relation_id": os.environ.get("VIBECRAFTED_SUPERVISION_RELATION_ID", ""), + "peer_run_id": os.environ.get("VIBECRAFTED_SUPERVISION_PEER_RUN_ID", ""), + "prompt_role": os.environ.get("VIBECRAFTED_PROMPT_ROLE", ""), + } + ) + + "\n", + encoding="utf-8", +) +if role == "operator" and os.environ.get("SMOKE_OPERATOR_ACTION") == "stop": + child_meta = Path(os.environ["VIBECRAFTED_SUPERVISED_CHILD_META"]) + while True: + if child_meta.is_file(): + child = json.loads(child_meta.read_text(encoding="utf-8")) + if child.get("status") == "active" and child.get("worker_pid"): + break + time.sleep(0.01) + protocol = Path(os.environ["VIBECRAFTED_OPERATOR_PROTOCOL"]) + common = { + "actor_run_id": os.environ["VIBECRAFTED_RUN_ID"], + "child_run_id": child["run_id"], + "relation_id": child["supervision"]["relation_id"], + } + protocol.write_text( + json.dumps( + { + **common, + "kind": "observation", + "child_status": child["status"], + "child_worker_pid": child["worker_pid"], + "measured_usage": child["measured_usage"], + } + ) + + "\n" + + json.dumps( + { + **common, + "kind": "action", + "action": "stop", + "reason": "operator_policy_stop", + } + ) + + "\n", + encoding="utf-8", + ) + while True: + child = json.loads(child_meta.read_text(encoding="utf-8")) + if child.get("liveness") == "terminal": + break + time.sleep(0.01) + with protocol.open("a", encoding="utf-8") as handle: + handle.write( + json.dumps( + { + **common, + "kind": "observation", + "child_status": child["status"], + "child_worker_pid": child["worker_pid"], + "measured_usage": child["measured_usage"], + } + ) + + "\n" + ) + raise SystemExit(0) +usage_tokens = int(os.environ.get("SMOKE_USAGE_TOKENS", "0")) +transcript: Path | None = None +if usage_tokens and role == "agent": + transcript = ( + Path(os.environ["CLAUDE_CONFIG_DIR"]) + / "projects" + / "installed-wheel-smoke" + / f"{session_id}.jsonl" + ) + transcript.parent.mkdir(parents=True, exist_ok=True) + transcript.write_text( + json.dumps( + { + "type": "assistant", + "sessionId": session_id, + "cwd": os.getcwd(), + "version": "2.1.232", + "message": { + "id": "installed-wheel-message-1", + "usage": { + "input_tokens": usage_tokens, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "output_tokens": 0, + }, + }, + } + ) + + "\n", + encoding="utf-8", + ) +second_usage_tokens = int(os.environ.get("SMOKE_SECOND_USAGE_TOKENS", "0")) +if second_usage_tokens and transcript is not None: + time.sleep(float(os.environ.get("SMOKE_SECOND_USAGE_DELAY", "0.5"))) + with transcript.open("a", encoding="utf-8") as handle: + handle.write( + json.dumps( + { + "type": "assistant", + "sessionId": session_id, + "cwd": os.getcwd(), + "version": "2.1.232", + "message": { + "id": "installed-wheel-message-2", + "usage": { + "input_tokens": second_usage_tokens, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "output_tokens": 0, + }, + }, + } + ) + + "\n" + ) +if os.environ.get("SMOKE_BLOCK") == "1": + while True: + time.sleep(0.05) +raise SystemExit(int(os.environ.get("SMOKE_EXIT", "0"))) diff --git a/vibecrafted-core/tests/test_agent_workshop.py b/vibecrafted-core/tests/test_agent_workshop.py new file mode 100644 index 00000000..5b9ed52f --- /dev/null +++ b/vibecrafted-core/tests/test_agent_workshop.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +from types import ModuleType + +import pytest +from vibecrafted_core.vc_frame_staging import materialize_vc_frame_config + +SCRIPT = ( + Path(__file__).resolve().parents[1] + / "vibecrafted_core" + / "config" + / "vc-frame" + / "vc-agent-workshop.py" +) + + +def _load() -> ModuleType: + spec = importlib.util.spec_from_file_location("vc_agent_workshop", SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_agent_workshop_script_is_shipped_and_executable() -> None: + assert SCRIPT.is_file() + assert SCRIPT.stat().st_mode & 0o111 + + +def test_materialized_runtime_keeps_agent_workshop_executable(tmp_path: Path) -> None: + destination = tmp_path / "vc-frame" + materialize_vc_frame_config( + SCRIPT.parent, + destination, + pane_shell="bash", + clipboard_command=None, + ) + + installed = destination / SCRIPT.name + assert installed.is_file() + assert installed.stat().st_mode & 0o111 + + +def test_launcher_commands_keep_interactive_agent_in_this_panel() -> None: + workshop = _load() + + assert workshop.launch_argv("codex", "init") == [ + "vibecrafted", + "init", + "codex", + "--runtime", + "plain", + "--policy-runtime", + "local-native", + "--permissions", + "bypass", + "--operator", + "none", + "--continuity", + "fresh", + ] + assert workshop.launch_argv("claude", "resume") == [ + "vibecrafted", + "resume", + "claude", + ] + with pytest.raises(ValueError, match="interactive ritual"): + workshop.launch_argv("codex", "operator") + + +def test_launcher_projects_explicit_continuity_selection() -> None: + workshop = _load() + + assert workshop.launch_argv( + "claude", + "init", + continuity="bare-fork", + continuity_parent="11111111-1111-4111-8111-111111111111", + )[-4:] == [ + "--continuity", + "bare-fork", + "--parent-session", + "11111111-1111-4111-8111-111111111111", + ] + with pytest.raises(ValueError, match="explicit parent"): + workshop.launch_argv("claude", "init", continuity="bare-fork") + with pytest.raises(ValueError, match="unsupported continuity"): + workshop.launch_argv("claude", "init", continuity="latest") + + +def test_launcher_exposes_exact_disabled_continuity_reasons(tmp_path: Path) -> None: + workshop = _load() + + capabilities = workshop.continuity_policy_capabilities( + "claude", root=tmp_path, explicit_parent="", env={"PATH": ""} + ) + assert capabilities["fresh"]["available"] is True + assert capabilities["fresh"]["reason"] == "no inherited memory is supplied" + assert capabilities["full-lineage"]["available"] is False + assert capabilities["full-lineage"]["reason"] == ( + "no explicit/current parent lineage id" + ) + assert capabilities["bare-fork"]["available"] is False + assert "expert-only" in capabilities["bare-fork"]["reason"] + + +def test_launcher_refuses_unsupported_policy_instead_of_approximating() -> None: + workshop = _load() + + with pytest.raises(ValueError, match="no native accept-edits"): + workshop.launch_argv("codex", "init", "local-native", "accept-edits") + with pytest.raises(ValueError, match="coming soon"): + workshop.launch_argv("claude", "init", "cloud-soon", "auto") + with pytest.raises(ValueError, match="H2b2"): + workshop.launch_argv("claude", "resume", "local-worktrees", "auto") + + +def test_runtime_help_preserves_product_truth_and_recommended_default() -> None: + workshop = _load() + help_text = " ".join( + line for detail in workshop.RUNTIME_HELP.values() for line in detail + ) + + assert "no isolation" in help_text + assert "full disk scope per provider permissions" in help_text + assert "Shared checkout, no worktrees" in help_text + assert "Safe recommended local default" in help_text + assert "one canonical worktree per Agent launch" in help_text + assert "Maximum local concurrency" in help_text + assert "unattended pipelines require an Operator Agent" in help_text + assert "--operator auto or claude" in help_text + assert "Coming in H2b3" in help_text + assert "selected-workspace container launch and live proof" in help_text + assert "Coming soon; disabled" in help_text + + +def test_workspace_path_is_full_resolved_and_must_exist(tmp_path: Path) -> None: + workshop = _load() + child = tmp_path / "project" + child.mkdir() + + assert workshop.normalized_workspace("project", base=tmp_path) == child.resolve() + with pytest.raises(ValueError, match="does not exist"): + workshop.normalized_workspace("missing", base=tmp_path) + + +def test_dashboard_projects_only_human_agent_faces_from_agents_tab() -> None: + workshop = _load() + payload = [ + { + "tab_name": "Agents", + "title": "Sessions", + "is_plugin": True, + }, + {"tab_name": "Agents", "pane_title": "Agent Workspaces"}, + {"tab_name": "Agents", "pane_title": "codex · resume · vibecrafted"}, + {"tab_name": "Agents", "pane_title": "claude · init · vibecrafted"}, + {"tab_name": "Shell", "pane_title": "Shell"}, + {"tab_name": "Agents", "pane_title": "codex · resume · vibecrafted"}, + ] + + assert workshop.agent_faces_from_payload(payload) == [ + "codex · resume · vibecrafted", + "claude · init · vibecrafted", + ] diff --git a/vibecrafted-core/tests/test_cli.py b/vibecrafted-core/tests/test_cli.py index a6c4f654..26b45188 100644 --- a/vibecrafted-core/tests/test_cli.py +++ b/vibecrafted-core/tests/test_cli.py @@ -86,13 +86,21 @@ def test_core_parser_accepts_the_short_prompt_and_file_flags() -> None: def test_workflow_prompt_stdin_stays_out_of_argv_and_temp_files( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, + capsys, ) -> None: seen: dict[str, object] = {} def fake_launch(spec, source_dir): seen["spec"] = spec seen["source_dir"] = source_dir - return {"accepted": True, "run_id": "impl-stdin-1"} + return { + "accepted": True, + "run_id": "impl-stdin-1", + "agent": spec.agent, + "skill": spec.skill, + "root": spec.root, + "status": "launching", + } monkeypatch.setattr(cli, "launch_workflow", fake_launch) monkeypatch.setattr(cli.sys, "stdin", io.StringIO("secret prompt from stdin")) @@ -114,6 +122,12 @@ def fake_launch(spec, source_dir): spec = seen["spec"] assert spec.prompt == "secret prompt from stdin" assert spec.file == "" + body = json.loads(capsys.readouterr().out) + assert body["run_id"] == "impl-stdin-1" + assert body["accepted"] is True + assert body["agent"] == "codex" + assert body["root"] == str(tmp_path) + assert body["status"] == "launching" def test_review_from_home_uses_selected_workspace( @@ -1329,3 +1343,119 @@ def test_startup_watch_survives_a_null_accepted_field(tmp_path, capsys, monkeypa ) assert "Not logged in" in capsys.readouterr().err + + +def test_json_launch_prints_one_parseable_receipt_even_with_unserializable_extras( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys +) -> None: + launches = [] + + def fake_launch(spec, _source_dir): + launches.append(spec) + return { + "accepted": True, + "run_id": "work-260826-json-1", + "agent": spec.agent, + "skill": spec.skill, + "root": spec.root, + "status": "launching", + "weird": object(), + } + + monkeypatch.setattr(cli, "launch_workflow", fake_launch) + + rc = cli.main( + [ + "workflow", + "claude", + "--prompt", + "one invocation one run", + "--json", + "--root", + str(tmp_path), + ] + ) + + assert rc == 0 + assert len(launches) == 1 + captured = capsys.readouterr() + assert captured.out.strip() + body = json.loads(captured.out) + assert body["run_id"] == "work-260826-json-1" + assert body["agent"] == "claude" + assert body["skill"] == "workflow" + assert body["root"] == str(tmp_path) + assert body["accepted"] is True + assert body["status"] == "launching" + assert "schema" in body + + +def test_json_launch_exception_after_run_created_emits_recovered_receipt( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys +) -> None: + def fake_launch(_spec, _source_dir): + raise RuntimeError("viewer exploded after spawn") + + def fake_recover(spec): + return { + "accepted": True, + "run_id": "work-260826-recovered", + "agent": spec.agent, + "skill": spec.skill, + "root": spec.root, + "status": "launching", + "replayed": True, + } + + monkeypatch.setattr(cli, "launch_workflow", fake_launch) + monkeypatch.setattr(cli, "recover_launch_receipt", fake_recover) + + rc = cli.main( + [ + "workflow", + "claude", + "--prompt", + "same brief", + "--json", + "--root", + str(tmp_path), + ] + ) + + assert rc == 0 + captured = capsys.readouterr() + assert "viewer exploded after spawn" in captured.err + body = json.loads(captured.out) + assert body["run_id"] == "work-260826-recovered" + assert body["accepted"] is True + assert body["replayed"] is True + assert body["agent"] == "claude" + + +def test_json_launch_never_returns_empty_success_without_run_id( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys +) -> None: + monkeypatch.setattr( + cli, + "launch_workflow", + lambda _spec, _source: {"accepted": True, "status": "launching"}, + ) + + rc = cli.main( + [ + "workflow", + "claude", + "--prompt", + "missing id", + "--json", + "--root", + str(tmp_path), + ] + ) + + assert rc != 0 + captured = capsys.readouterr() + body = json.loads(captured.out) + assert body["accepted"] is True + assert body["run_id"] == "" + assert "missing run_id" in captured.err diff --git a/vibecrafted-core/tests/test_control_plane.py b/vibecrafted-core/tests/test_control_plane.py index 9c2cfe29..66dd85aa 100644 --- a/vibecrafted-core/tests/test_control_plane.py +++ b/vibecrafted-core/tests/test_control_plane.py @@ -217,6 +217,55 @@ def test_atomic_write_reports_actionable_degraded_mode_before_enospc( assert not target.exists() +def test_agent_meta_projects_structured_operator_relationship(tmp_path: Path) -> None: + meta = tmp_path / "meta.json" + meta.write_text( + json.dumps( + { + "run_id": "init-child", + "status": "active", + "updated_at": "2026-08-25T12:00:00Z", + "role": "agent", + "prompt_role": "/vc-init", + "provider_session_id": "child-session", + "operator_policy": { + "selection": "auto", + "provider": "claude", + "supported": True, + }, + "supervision": { + "relation_id": "relation-1", + "operator_run_id": "oper-1", + "child_run_id": "init-child", + "state": "active", + }, + "continuity": { + "mode": "full-lineage", + "lineage_id": "parent-run-1", + "supported": True, + "materialized": True, + "context_sha256": "abc", + "loop_sha256": "def", + }, + } + ), + encoding="utf-8", + ) + projected = control_plane._normalize_agent_meta(meta) + assert projected is not None + assert projected.extra["role"] == "agent" + assert projected.extra["operator_policy"]["provider"] == "claude" + assert projected.extra["supervision"]["operator_run_id"] == "oper-1" + assert projected.extra["continuity"] == { + "mode": "full-lineage", + "lineage_id": "parent-run-1", + "supported": True, + "materialized": True, + "context_sha256": "abc", + "loop_sha256": "def", + } + + def test_operator_stop_is_sticky_over_late_failure_and_artifact_aliases( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: @@ -710,6 +759,49 @@ def test_sync_state_keeps_run_live_when_worker_alive_despite_dead_launcher( assert "recovery_required" not in str(run.get("last_error") or "") +@pytest.mark.parametrize( + ("owner_pid", "worker_pid", "terminal_reason"), + [ + (999999999, os.getpid(), "owner_pid_gone"), + (os.getpid(), 999999999, "provider_pid_gone"), + ], +) +def test_sync_state_never_projects_interactive_run_live_when_either_role_is_dead( + owner_pid: int, + worker_pid: int, + terminal_reason: str, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + home = tmp_path / ".vibecrafted" + monkeypatch.setenv("VIBECRAFTED_HOME", str(home)) + monkeypatch.setenv("VIBECRAFTED_RUN_GC_GRACE_SECONDS", "999999999") + _write_meta( + home, + { + "run_id": f"interactive-{terminal_reason}", + "status": "active", + "agent": "codex", + "mode": "interactive", + "root": str(tmp_path), + "updated_at": dt.datetime.now(dt.timezone.utc).isoformat(), + "skill_code": "init", + "owner_pid": owner_pid, + "worker_pid": worker_pid, + "liveness": "active", + }, + ) + + snapshot = control_plane.sync_state() + run = snapshot["recent_runs"][0] + + assert run["state"] == "failed" + assert run["health"] == "final" + assert run["liveness"] == "pid_gone" + assert run["terminal_reason"] == terminal_reason + assert run["run_id"] not in {item["run_id"] for item in snapshot["active_runs"]} + + def test_sync_state_gc_terminalizes_old_stalled_dead_launcher( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: diff --git a/vibecrafted-core/tests/test_doctor.py b/vibecrafted-core/tests/test_doctor.py index e20e9bf7..90671246 100644 --- a/vibecrafted-core/tests/test_doctor.py +++ b/vibecrafted-core/tests/test_doctor.py @@ -1,6 +1,7 @@ from __future__ import annotations import shlex +import shutil import sys from pathlib import Path from types import SimpleNamespace @@ -640,6 +641,38 @@ def test_delivery_reads_package_owned_runtime_generation( ) +def test_delivery_accepts_exact_physical_runtime_pack_config( + tmp_path: Path, monkeypatch +) -> None: + tools, generation, home = _truth_sandbox(tmp_path, monkeypatch) + generated = ( + generation + / "vibecrafted-core" + / "vibecrafted_core" + / "runtime" + / "generated" + / "vc-frame" + ) + (generated / "themes").mkdir() + for name in doctor.OPERATOR_SCRIPT_NAMES: + (generated / name).write_text(f"#!/bin/sh\n# {name}\n", encoding="utf-8") + view = home / ".config" / "vibecrafted" / "vc-frame" + view.parent.mkdir(parents=True) + shutil.copytree(generated, view) + + findings = doctor._vc_frame_delivery_findings(home=home, tools_home=tools) + + relevant = [ + finding + for finding in findings + if finding.component == "vc-frame:view" + or finding.component == "vc-frame:operator-scripts:view" + ] + assert relevant + assert all(finding.level == "ok" for finding in relevant) + assert any("runtime-copy" in finding.message for finding in relevant) + + def test_truth_drift_fails_when_generation_disagrees_with_itself( tmp_path: Path, monkeypatch ) -> None: @@ -694,7 +727,7 @@ def test_truth_drift_fails_on_projection_into_parked_generation( / "vc-frame" ) _seed_truth(parked_generated) - view = home / ".config" / "vc-frame" + view = home / ".config" / "vibecrafted" / "vc-frame" view.mkdir(parents=True) (view / "config.kdl").symlink_to(parked_generated / "config.kdl") diff --git a/vibecrafted-core/tests/test_lifecycle_runner.py b/vibecrafted-core/tests/test_lifecycle_runner.py index b0177ec1..3aaf70c1 100644 --- a/vibecrafted-core/tests/test_lifecycle_runner.py +++ b/vibecrafted-core/tests/test_lifecycle_runner.py @@ -18,6 +18,7 @@ LifecycleRunner, LifecycleRunSpec, LifecycleSupervisor, + _lifecycle_stage_run_id, record_stage_worker_completion, ) from vibecrafted_core.workflows.model import WorkflowManifest, WorkflowStage @@ -119,6 +120,14 @@ def fake_launcher(spec, _source_dir): assert state["stages"][0]["launch"]["run_id"] == "child-implement" +def test_lifecycle_stage_identity_is_stable_per_attempt_not_content() -> None: + first = _lifecycle_stage_run_id("parent-1", "implement", 0) + + assert first == _lifecycle_stage_run_id("parent-1", "implement", 0) + assert first != _lifecycle_stage_run_id("parent-2", "implement", 0) + assert first != _lifecycle_stage_run_id("parent-1", "implement", 1) + + def test_lifecycle_runner_preserves_terminal_stage_failure( monkeypatch, tmp_path: Path ) -> None: diff --git a/vibecrafted-core/tests/test_native_notifications.py b/vibecrafted-core/tests/test_native_notifications.py index 3597414d..e10cd41b 100644 --- a/vibecrafted-core/tests/test_native_notifications.py +++ b/vibecrafted-core/tests/test_native_notifications.py @@ -33,3 +33,10 @@ def test_notification_manager_owns_user_notification_center() -> None: assert "OPEN_RUN" in source assert "OPEN_REPORT" in source assert "native_app.pid" in source + + +def test_notification_manager_accepts_start_here_console_deep_link() -> None: + source = NOTIFICATION_MANAGER.read_text(encoding="utf-8") + assert 'url.host == "console"' in source + assert 'url.path == "/open"' in source + assert "presentWindow?()" in source diff --git a/vibecrafted-core/tests/test_process_control.py b/vibecrafted-core/tests/test_process_control.py index bc74d51c..515eeab6 100644 --- a/vibecrafted-core/tests/test_process_control.py +++ b/vibecrafted-core/tests/test_process_control.py @@ -2,8 +2,10 @@ from __future__ import annotations +import copy import signal +import pytest from vibecrafted_core import process_control as pc from vibecrafted_core import run_reaper @@ -64,7 +66,7 @@ def test_process_identity_receipt_rejects_reused_pid_or_wrong_run(): assert reason == "process_identity_current" assert identity is not None - reused, reason, _identity = pc.validate_process_identity( + reused, reason, recaptured_identity = pc.validate_process_identity( receipt, expected_pid=904, expected_pgid=5004, @@ -74,6 +76,7 @@ def test_process_identity_receipt_rejects_reused_pid_or_wrong_run(): ) assert reused is False assert reason == "process_identity_mismatch" + assert recaptured_identity is not None wrong_run, reason, _identity = pc.validate_process_identity( receipt, @@ -87,6 +90,54 @@ def test_process_identity_receipt_rejects_reused_pid_or_wrong_run(): assert reason == "process_run_id_mismatch" +@pytest.mark.parametrize( + ("mutation", "reason"), + [ + ( + lambda receipt: receipt.pop("start_token"), + "process_identity_receipt_invalid", + ), + (lambda receipt: receipt.pop("run_id"), "process_identity_receipt_invalid"), + ( + lambda receipt: receipt.__setitem__("command_sha256", "g" * 64), + "process_identity_receipt_invalid", + ), + ], +) +def test_process_identity_rejects_malformed_receipt_before_recapture( + monkeypatch, mutation, reason +): + original = [entry(905, pgid=5005, command="python worker.py")] + receipt = pc.process_identity_receipt( + 905, + run_id="impl-malformed", + table=original, + ) + assert receipt is not None + malformed = copy.deepcopy(receipt) + mutation(malformed) + captures: list[int] = [] + monkeypatch.setattr( + pc, + "capture_process_identity", + lambda pid, **_kwargs: captures.append(pid), + ) + + current, actual_reason, identity = pc.validate_process_identity( + malformed, + expected_pid=905, + expected_pgid=5005, + expected_run_id="impl-malformed", + table=original, + env_index={905: "impl-malformed"}, + ) + + assert current is False + assert actual_reason == reason + assert identity is None + assert captures == [] + + def test_snapshot_protects_vc_frame(): table = [entry(901, pgid=4242, command="/usr/local/bin/vc-frame attach foo")] snap = pc.snapshot_processes( diff --git a/vibecrafted-core/tests/test_provider_policy.py b/vibecrafted-core/tests/test_provider_policy.py new file mode 100644 index 00000000..d0526be5 --- /dev/null +++ b/vibecrafted-core/tests/test_provider_policy.py @@ -0,0 +1,1813 @@ +from __future__ import annotations + +import io +import itertools +import json +import os +import pty +import shlex +import signal +import subprocess +import sys +import time +from pathlib import Path +from types import SimpleNamespace + +import pytest +from vibecrafted_core.spawn import ( + CONTINUITY_MODES, + PERMISSION_POLICIES, + POLICY_MODES, + POLICY_PROVIDERS, + RUNTIME_POLICIES, + ContinuityPolicy, + ProviderUsageCapability, + _ClaudeTranscriptUsage, + _fresh_child_environment, + _materialize_continuity, + _validate_operator_protocol_event, + interactive_policy_command, + interactive_workspace_command, + launch_interactive_workspace, + main, + prepare_interactive_workspace_launch, + resolve_continuity_policy, + resolve_operator_agent_policy, + resolve_provider_policy, + resolve_provider_usage_capability, + resolve_quota_policy, +) + + +def _fake_interactive_provider(path: Path) -> None: + path.write_text( + "#!/usr/bin/env python3\n" + "import json, os, pathlib, sys, time\n" + "if '--help' in sys.argv:\n" + " print(' --session-id ')\n" + " raise SystemExit(0)\n" + "if '--version' in sys.argv:\n" + " print('2.1.232 (Claude Code)')\n" + " raise SystemExit(0)\n" + "session_id = sys.argv[sys.argv.index('--session-id') + 1]\n" + "capture = pathlib.Path(os.environ['SMOKE_CAPTURE'])\n" + "capture.write_text(json.dumps({\n" + " 'pid': os.getpid(), 'stdin_tty': os.isatty(0),\n" + " 'stdout_tty': os.isatty(1), 'stderr_tty': os.isatty(2),\n" + " 'run_id': os.environ['VIBECRAFTED_RUN_ID'],\n" + " 'session_id': session_id,\n" + " 'argv': sys.argv[1:],\n" + " 'continuity_mode': os.environ['VIBECRAFTED_CONTINUITY_MODE'],\n" + " 'continuity_lineage_id': os.environ['VIBECRAFTED_CONTINUITY_LINEAGE_ID'],\n" + " 'inherited': {name: os.environ.get(name) for name in (\n" + " 'CODEX_SESSION_ID', 'CLAUDE_CODE_SESSION_ID',\n" + " 'VIBECRAFTED_LOOP_STATE_FILE', 'VIBECRAFTED_RESUME_CONTEXT',\n" + " 'AICX_CONTINUITY_FILE') if name in os.environ},\n" + "}) + '\\n', encoding='utf-8')\n" + "if os.environ.get('SMOKE_BLOCK') == '1':\n" + " while True: time.sleep(0.05)\n" + "raise SystemExit(int(os.environ.get('SMOKE_EXIT', '0')))\n", + encoding="utf-8", + ) + path.chmod(0o755) + + +def _wait_for(path: Path, *, timeout: float = 5.0) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if path.is_file(): + return + time.sleep(0.02) + raise AssertionError(f"timed out waiting for {path}") + + +def _fake_supervision_provider(path: Path) -> None: + path.write_text( + "#!/usr/bin/env python3\n" + "import json, os, pathlib, sys, time\n" + "if '--help' in sys.argv:\n" + " print(' --session-id ')\n" + " raise SystemExit(0)\n" + "if '--version' in sys.argv:\n" + " print('2.1.232 (Claude Code)')\n" + " raise SystemExit(0)\n" + "role = os.environ['VIBECRAFTED_AGENT_ROLE']\n" + "capture = pathlib.Path(os.environ['SUPERVISION_CAPTURES']) / f'{role}.json'\n" + "capture.write_text(json.dumps({\n" + " 'pid': os.getpid(), 'role': role,\n" + " 'run_id': os.environ['VIBECRAFTED_RUN_ID'],\n" + " 'session_id': sys.argv[sys.argv.index('--session-id') + 1],\n" + "}) + '\\n', encoding='utf-8')\n" + "if role == 'agent' and os.environ.get('AGENT_WRITE_USAGE') == '1':\n" + " session_id = sys.argv[sys.argv.index('--session-id') + 1]\n" + " transcript = pathlib.Path(os.environ['CLAUDE_CONFIG_DIR']) / 'projects' / 'fixture' / f'{session_id}.jsonl'\n" + " transcript.parent.mkdir(parents=True, exist_ok=True)\n" + " transcript.write_text(json.dumps({\n" + " 'sessionId': session_id, 'cwd': os.getcwd(), 'version': '2.1.232',\n" + " 'message': {'id': 'quota-message', 'usage': {\n" + " 'input_tokens': 1, 'cache_creation_input_tokens': 0,\n" + " 'cache_read_input_tokens': 0, 'output_tokens': 1}}\n" + " }) + '\\n', encoding='utf-8')\n" + "exit_key = 'OPERATOR_EXIT' if role == 'operator' else 'AGENT_EXIT'\n" + "if exit_key in os.environ: raise SystemExit(int(os.environ[exit_key]))\n" + "while True: time.sleep(0.05)\n", + encoding="utf-8", + ) + path.chmod(0o755) + + +def _interactive_argv(repo: Path) -> list[str]: + return [ + sys.executable, + "-m", + "vibecrafted_core.spawn", + "interactive-launch", + "claude", + "--runtime", + "local-native", + "--permissions", + "read-only", + "--root", + str(repo), + "--prompt", + "/vc-init", + ] + + +_TEST_USAGE_CAPABILITY = ProviderUsageCapability( + provider="claude", + supported=True, + source="claude-transcript-jsonl-v1", + provider_version="2.1.232", +) +_TEST_PROVIDER_SESSION_ID = "11111111-1111-4111-8111-111111111111" + + +def _git(repo: Path, *args: str) -> str: + completed = subprocess.run( + ["git", *args], cwd=repo, check=True, capture_output=True, text=True + ) + return completed.stdout.strip() + + +def _repo(path: Path) -> str: + path.mkdir() + _git(path, "init", "-q") + _git(path, "config", "user.email", "agents@vetcoders.io") + _git(path, "config", "user.name", "runtime-test") + (path / ".gitignore").write_text("target/\n", encoding="utf-8") + (path / "README.md").write_text("parent\n", encoding="utf-8") + _git(path, "add", "-A") + _git(path, "commit", "-q", "-m", "seed") + return _git(path, "rev-parse", "HEAD") + + +def test_interactive_worktree_launch_uses_canonical_owner_and_parent_identity( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = tmp_path / "home" + monkeypatch.setenv("VIBECRAFTED_HOME", str(home)) + repo = tmp_path / "repo" + baseline = _repo(repo) + + launch = prepare_interactive_workspace_launch( + provider="claude", + runtime="local-worktrees", + permissions="read-only", + selected_root=repo, + prompt="/vc-init", + run_id="init-260825-123102-00001", + executable=sys.executable, + worker_pid=4242, + quota_policy=resolve_quota_policy("safe", runtime="local-worktrees"), + usage_capability=_TEST_USAGE_CAPABILITY, + provider_session_id=_TEST_PROVIDER_SESSION_ID, + ) + + effective = Path(launch.effective_root) + assert effective != repo.resolve() + assert _git(effective, "rev-parse", "HEAD") == baseline + assert (repo / "README.md").read_text(encoding="utf-8") == "parent\n" + assert launch.parent_root == str(repo.resolve()) + assert launch.workspace_id + assert launch.vibecrafted_session_id + assert launch.meta_path.is_file() + assert launch.receipt["root"] == str(effective) + assert launch.receipt["parent_root"] == str(repo.resolve()) + assert launch.receipt["workspace_id"] == launch.workspace_id + assert launch.receipt["worker_pid"] == 4242 + + +def test_local_native_keeps_selected_checkout_and_creates_no_worktree( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("VIBECRAFTED_HOME", str(tmp_path / "home")) + repo = tmp_path / "repo" + baseline = _repo(repo) + + launch = prepare_interactive_workspace_launch( + provider="claude", + runtime="local-native", + permissions="read-only", + selected_root=repo, + prompt="/vc-init", + run_id="init-260825-123102-00002", + executable=sys.executable, + quota_policy=resolve_quota_policy("safe", runtime="local-native"), + usage_capability=_TEST_USAGE_CAPABILITY, + provider_session_id=_TEST_PROVIDER_SESSION_ID, + ) + + assert launch.effective_root == str(repo.resolve()) + assert launch.receipt["effective_worktree_path"] == "" + assert _git(repo, "rev-parse", "HEAD") == baseline + assert not (tmp_path / "home" / "worktrees").exists() + + +def test_two_interactive_worktree_launches_cannot_collide( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("VIBECRAFTED_HOME", str(tmp_path / "home")) + repo = tmp_path / "repo" + _repo(repo) + + launches = [ + prepare_interactive_workspace_launch( + provider="claude", + runtime="local-worktrees", + permissions="read-only", + selected_root=repo, + prompt="/vc-init", + run_id=f"init-260825-123102-0000{index}", + executable=sys.executable, + quota_policy=resolve_quota_policy("safe", runtime="local-worktrees"), + usage_capability=_TEST_USAGE_CAPABILITY, + provider_session_id=f"11111111-1111-4111-8111-11111111111{index}", + ) + for index in (3, 4) + ] + + assert launches[0].effective_root != launches[1].effective_root + assert launches[0].meta_path != launches[1].meta_path + assert _git(Path(launches[0].effective_root), "branch", "--show-current") != _git( + Path(launches[1].effective_root), "branch", "--show-current" + ) + + +def test_interactive_worktree_execs_provider_inside_canonical_checkout( + tmp_path: Path, +) -> None: + home = tmp_path / "home" + repo = tmp_path / "repo" + baseline = _repo(repo) + capture = tmp_path / "provider.json" + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + provider = fake_bin / "claude" + provider.write_text( + "#!/usr/bin/env python3\n" + "import json, os, pathlib, sys\n" + "if '--help' in sys.argv:\n" + " print(' --session-id ')\n" + " raise SystemExit(0)\n" + "if '--version' in sys.argv:\n" + " print('2.1.232 (Claude Code)')\n" + " raise SystemExit(0)\n" + "pathlib.Path(os.environ['SMOKE_CAPTURE']).write_text(json.dumps({\n" + " 'argv': sys.argv, 'cwd': os.getcwd(),\n" + " 'run_id': os.environ['VIBECRAFTED_RUN_ID'],\n" + " 'workspace_id': os.environ['VIBECRAFTED_WORKSPACE_ID'],\n" + " 'session_id': os.environ['VIBECRAFTED_SESSION_ID'],\n" + " 'instance_id': os.environ['VIBECRAFTED_WORKSPACE_INSTANCE_ID'],\n" + " 'build_id': os.environ['VIBECRAFTED_BUILD_ID'],\n" + " 'parent_root': os.environ['VIBECRAFTED_PARENT_ROOT'],\n" + " 'effective_root': os.environ['VIBECRAFTED_EFFECTIVE_ROOT'],\n" + "}) + '\\n', encoding='utf-8')\n", + encoding="utf-8", + ) + provider.chmod(0o755) + env = os.environ.copy() + env.pop("PYTHONPATH", None) + env["VIBECRAFTED_HOME"] = str(home) + env["VIBECRAFTED_RUNTIME_BIN"] = str(fake_bin) + env["SMOKE_CAPTURE"] = str(capture) + env["PATH"] = str(fake_bin) + os.pathsep + env["PATH"] + + completed = subprocess.run( + [ + sys.executable, + "-m", + "vibecrafted_core.spawn", + "interactive-launch", + "claude", + "--runtime", + "local-worktrees", + "--permissions", + "read-only", + "--root", + str(repo), + "--prompt", + "/vc-init", + ], + cwd=Path(__file__).resolve().parents[1], + env=env, + check=False, + capture_output=True, + text=True, + ) + + assert completed.returncode == 0, completed.stderr + observed = json.loads(capture.read_text(encoding="utf-8")) + effective = Path(observed["effective_root"]) + assert Path(observed["cwd"]) == effective + assert effective != repo.resolve() + assert observed["parent_root"] == str(repo.resolve()) + assert observed["workspace_id"] + assert observed["session_id"] + assert observed["instance_id"] + assert observed["build_id"] + assert _git(effective, "rev-parse", "HEAD") == baseline + assert _git(repo, "status", "--porcelain") == "" + meta = json.loads( + ( + home / "control_plane/runtime_runs" / observed["run_id"] / "meta.json" + ).read_text(encoding="utf-8") + ) + assert meta["parent_root"] == str(repo.resolve()) + assert meta["effective_worktree_path"] == str(effective) + assert meta["runtime_policy"] == "local-worktrees" + assert meta["permission_policy"] == "read-only" + assert meta["status"] == "completed" + assert meta["liveness"] == "terminal" + assert meta["exit_code"] == 0 + assert meta["terminal_reason"] == "provider_exit_zero" + + +def test_interactive_owner_keeps_distinct_live_provider_on_inherited_tty( + tmp_path: Path, +) -> None: + home = tmp_path / "home" + repo = tmp_path / "repo" + _repo(repo) + capture = tmp_path / "provider.json" + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _fake_interactive_provider(fake_bin / "claude") + env = os.environ.copy() + env.pop("PYTHONPATH", None) + env.update( + VIBECRAFTED_HOME=str(home), + VIBECRAFTED_RUNTIME_BIN=str(fake_bin), + SMOKE_CAPTURE=str(capture), + SMOKE_BLOCK="1", + CODEX_SESSION_ID="stale-codex", + CLAUDE_CODE_SESSION_ID="stale-claude", + VIBECRAFTED_LOOP_STATE_FILE="/stale-loop", + VIBECRAFTED_RESUME_CONTEXT="/stale-pack", + AICX_CONTINUITY_FILE="/stale-aicx", + PATH=str(fake_bin) + os.pathsep + env["PATH"], + ) + master_fd, slave_fd = pty.openpty() + owner = subprocess.Popen( + _interactive_argv(repo), + cwd=Path(__file__).resolve().parents[1], + env=env, + stdin=slave_fd, + stdout=slave_fd, + stderr=slave_fd, + start_new_session=True, + ) + os.close(slave_fd) + try: + _wait_for(capture) + observed = json.loads(capture.read_text(encoding="utf-8")) + meta_path = ( + home / "control_plane/runtime_runs" / observed["run_id"] / "meta.json" + ) + _wait_for(meta_path) + meta = json.loads(meta_path.read_text(encoding="utf-8")) + assert observed["stdin_tty"] is True + assert observed["stdout_tty"] is True + assert observed["stderr_tty"] is True + assert meta["owner_pid"] == owner.pid + assert meta["worker_pid"] == observed["pid"] + assert meta["owner_pid"] != meta["worker_pid"] + assert meta["status"] == "active" + assert meta["liveness"] == "active" + assert meta["quota_policy"] == { + "kind": "bounded", + "token_budget": 250_000, + "selection": "safe", + "warning": "", + } + assert meta["usage_capability"]["source"] == "claude-transcript-jsonl-v1" + assert meta["provider_session_id"] == observed["session_id"] + assert observed["continuity_mode"] == "fresh" + assert observed["continuity_lineage_id"].startswith("fresh:") + assert observed["inherited"] == {} + assert "--resume" not in observed["argv"] + assert "--fork-session" not in observed["argv"] + os.kill(meta["owner_pid"], 0) + os.kill(meta["worker_pid"], 0) + owner.send_signal(signal.SIGTERM) + assert owner.wait(timeout=5) == 128 + signal.SIGTERM + terminal = json.loads(meta_path.read_text(encoding="utf-8")) + assert terminal["status"] == "cancelled" + assert terminal["terminal_reason"] == "owner_signal:SIGTERM" + with pytest.raises(ProcessLookupError): + os.kill(meta["worker_pid"], 0) + finally: + if owner.poll() is None: + owner.kill() + owner.wait() + os.close(master_fd) + + +@pytest.mark.parametrize("runtime", ["local-native", "local-worktrees"]) +def test_operator_auto_creates_distinct_supervising_agent_relationship( + runtime: str, + tmp_path: Path, +) -> None: + home = tmp_path / "home" + repo = tmp_path / "repo" + _repo(repo) + captures = tmp_path / "captures" + captures.mkdir() + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + provider = fake_bin / "claude" + provider.write_text( + "#!/usr/bin/env python3\n" + "import json, os, pathlib, sys, time\n" + "if '--help' in sys.argv:\n" + " print(' --session-id ')\n" + " raise SystemExit(0)\n" + "if '--version' in sys.argv:\n" + " print('2.1.232 (Claude Code)')\n" + " raise SystemExit(0)\n" + "role = os.environ['VIBECRAFTED_AGENT_ROLE']\n" + "session_id = sys.argv[sys.argv.index('--session-id') + 1]\n" + "capture = pathlib.Path(os.environ['SUPERVISION_CAPTURES']) / f'{role}.json'\n" + "capture.write_text(json.dumps({\n" + " 'pid': os.getpid(), 'role': role, 'session_id': session_id,\n" + " 'run_id': os.environ['VIBECRAFTED_RUN_ID'],\n" + " 'relation_id': os.environ['VIBECRAFTED_SUPERVISION_RELATION_ID'],\n" + " 'peer_run_id': os.environ['VIBECRAFTED_SUPERVISION_PEER_RUN_ID'],\n" + " 'prompt_role': '/vc-operator' if role == 'operator' else '/vc-init',\n" + "}) + '\\n', encoding='utf-8')\n" + "if role == 'operator':\n" + " child_meta = pathlib.Path(os.environ['VIBECRAFTED_SUPERVISED_CHILD_META'])\n" + " protocol = pathlib.Path(os.environ['VIBECRAFTED_OPERATOR_PROTOCOL'])\n" + " while True:\n" + " if child_meta.is_file():\n" + " child = json.loads(child_meta.read_text(encoding='utf-8'))\n" + " if child.get('status') == 'active' and child.get('worker_pid'): break\n" + " time.sleep(0.01)\n" + " protocol.write_text(json.dumps({\n" + " 'kind': 'observation', 'actor_run_id': os.environ['VIBECRAFTED_RUN_ID'],\n" + " 'child_run_id': child['run_id'], 'relation_id': child['supervision']['relation_id'],\n" + " 'child_status': child['status'], 'child_worker_pid': child['worker_pid'],\n" + " 'measured_usage': child['measured_usage'],\n" + " }) + '\\n' + json.dumps({\n" + " 'kind': 'action', 'action': 'stop',\n" + " 'actor_run_id': os.environ['VIBECRAFTED_RUN_ID'],\n" + " 'child_run_id': child['run_id'], 'relation_id': child['supervision']['relation_id'],\n" + " 'reason': 'operator_policy_stop',\n" + " }) + '\\n', encoding='utf-8')\n" + " while True:\n" + " child = json.loads(child_meta.read_text(encoding='utf-8'))\n" + " if child.get('liveness') == 'terminal': break\n" + " time.sleep(0.01)\n" + " with protocol.open('a', encoding='utf-8') as handle:\n" + " handle.write(json.dumps({\n" + " 'kind': 'observation', 'actor_run_id': os.environ['VIBECRAFTED_RUN_ID'],\n" + " 'child_run_id': child['run_id'], 'relation_id': child['supervision']['relation_id'],\n" + " 'child_status': child['status'], 'child_worker_pid': child['worker_pid'],\n" + " 'measured_usage': child['measured_usage'],\n" + " }) + '\\n')\n" + " raise SystemExit(0)\n" + "while True: time.sleep(0.05)\n", + encoding="utf-8", + ) + provider.chmod(0o755) + env = os.environ.copy() + env.pop("PYTHONPATH", None) + env.update( + VIBECRAFTED_HOME=str(home), + VIBECRAFTED_RUNTIME_BIN=str(fake_bin), + SUPERVISION_CAPTURES=str(captures), + PATH=str(fake_bin) + os.pathsep + env["PATH"], + ) + argv = _interactive_argv(repo) + argv[argv.index("--runtime") + 1] = runtime + owner = subprocess.Popen( + [*argv, "--operator", "auto"], + cwd=Path(__file__).resolve().parents[1], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + try: + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if (captures / "operator.json").is_file() and ( + captures / "agent.json" + ).is_file(): + break + if owner.poll() is not None: + _, stderr = owner.communicate() + raise AssertionError( + f"operator=auto exited before two provider roles: " + f"rc={owner.returncode}, stderr={stderr.strip()}" + ) + time.sleep(0.02) + _wait_for(captures / "operator.json") + _wait_for(captures / "agent.json") + operator = json.loads((captures / "operator.json").read_text()) + agent = json.loads((captures / "agent.json").read_text()) + assert owner.wait(timeout=5) == 128 + signal.SIGTERM + + assert operator["role"] == "operator" + assert agent["role"] == "agent" + for identity in ("pid", "session_id", "run_id"): + assert operator[identity] != agent[identity] + assert operator["relation_id"] == agent["relation_id"] + assert operator["peer_run_id"] == agent["run_id"] + assert agent["peer_run_id"] == operator["run_id"] + + operator_meta = json.loads( + ( + home / "control_plane/runtime_runs" / operator["run_id"] / "meta.json" + ).read_text() + ) + agent_meta = json.loads( + ( + home / "control_plane/runtime_runs" / agent["run_id"] / "meta.json" + ).read_text() + ) + assert operator_meta["role"] == "operator" + assert operator_meta["permission_policy"] == "accept-edits" + assert agent_meta["role"] == "agent" + assert operator_meta["supervision"]["child_run_id"] == agent["run_id"] + assert agent_meta["supervision"]["operator_run_id"] == operator["run_id"] + assert ( + operator_meta["supervision"]["observation"]["child_run_id"] + == agent["run_id"] + ) + assert ( + operator_meta["supervision"]["observation"]["child_status"] == "cancelled" + ) + assert operator_meta["supervision"]["terminal_observation_confirmed"] is True + assert agent_meta["terminal_reason"] == "operator_policy_stop" + assert agent_meta["stop_actor_run_id"] == operator["run_id"] + assert operator_meta["terminal_reason"] == "child_settled" + assert operator_meta["root"] == agent_meta["root"] + assert operator_meta["continuity"] == agent_meta["continuity"] + assert operator_meta["continuity"]["mode"] == "fresh" + assert ( + subprocess.run( + ["git", "status", "--short"], + cwd=repo, + check=True, + capture_output=True, + text=True, + ).stdout + == "" + ) + if runtime == "local-worktrees": + assert agent_meta["effective_worktree_path"] != str(repo) + assert not Path(agent_meta["effective_worktree_path"]).exists() + assert agent_meta["settled_worktree_cleanup"] == "removed" + events = [ + json.loads(line) + for line in (home / "control_plane/events.jsonl").read_text().splitlines() + ] + relation_events = [ + event + for event in events + if event["run_id"] in {operator["run_id"], agent["run_id"]} + ] + assert [event["kind"] for event in relation_events[:2]] == [ + "lifecycle:reserved", + "lifecycle:reserved", + ] + assert all( + event["payload"]["supervision"]["relation_id"] == operator["relation_id"] + for event in relation_events[:2] + ) + for pid in (operator["pid"], agent["pid"]): + with pytest.raises(ProcessLookupError): + os.kill(pid, 0) + finally: + if owner.poll() is None: + owner.kill() + owner.wait() + + +@pytest.mark.parametrize( + ("selection", "runtime", "supported", "provider"), + [ + ("none", "local-native", True, None), + ("auto", "local-native", True, "claude"), + ("claude", "local-worktrees", True, "claude"), + ("auto", "local-vm", False, None), + ("claude", "cloud-soon", False, None), + ("codex", "local-native", False, None), + ], +) +def test_operator_policy_matrix_is_typed_and_fail_closed( + selection: str, runtime: str, supported: bool, provider: str | None +) -> None: + policy = resolve_operator_agent_policy(selection, runtime=runtime) + assert policy.supported is supported + assert policy.provider == provider + assert policy.permissions == ("accept-edits" if provider == "claude" else None) + assert bool(policy.reason) is not supported + if selection == "none": + assert "User-observed only" in policy.warning + + +def test_operator_none_is_explicit_and_spawns_no_hidden_supervisor( + tmp_path: Path, +) -> None: + home = tmp_path / "home" + repo = tmp_path / "repo" + _repo(repo) + capture = tmp_path / "provider.json" + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _fake_interactive_provider(fake_bin / "claude") + env = os.environ.copy() + env.pop("PYTHONPATH", None) + env.update( + VIBECRAFTED_HOME=str(home), + VIBECRAFTED_RUNTIME_BIN=str(fake_bin), + SMOKE_CAPTURE=str(capture), + PATH=str(fake_bin) + os.pathsep + env["PATH"], + ) + completed = subprocess.run( + [*_interactive_argv(repo), "--operator", "none"], + cwd=Path(__file__).resolve().parents[1], + env=env, + check=False, + ) + assert completed.returncode == 0 + metas = list((home / "control_plane/runtime_runs").glob("*/meta.json")) + assert len(metas) == 1 + meta = json.loads(metas[0].read_text(encoding="utf-8")) + assert meta["role"] == "agent" + assert meta["operator_policy"]["selection"] == "none" + assert "User-observed only" in meta["supervision"]["warning"] + + +@pytest.mark.parametrize( + ("exit_role", "exit_code", "child_reason", "operator_reason", "shell_status"), + [ + ("agent", 0, "provider_exit_zero", "child_settled", 0), + ("agent", 9, "provider_exit_nonzero", "child_settled", 9), + ("operator", 7, "supervision_lost", "supervision_lost", 1), + ], +) +def test_supervised_terminal_semantics_settle_both_processes( + exit_role: str, + exit_code: int, + child_reason: str, + operator_reason: str, + shell_status: int, + tmp_path: Path, +) -> None: + home = tmp_path / "home" + repo = tmp_path / "repo" + _repo(repo) + captures = tmp_path / "captures" + captures.mkdir() + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _fake_supervision_provider(fake_bin / "claude") + env = os.environ.copy() + env.pop("PYTHONPATH", None) + env.update( + VIBECRAFTED_HOME=str(home), + VIBECRAFTED_RUNTIME_BIN=str(fake_bin), + SUPERVISION_CAPTURES=str(captures), + PATH=str(fake_bin) + os.pathsep + env["PATH"], + ) + env["AGENT_EXIT" if exit_role == "agent" else "OPERATOR_EXIT"] = str(exit_code) + completed = subprocess.run( + [*_interactive_argv(repo), "--operator", "auto"], + cwd=Path(__file__).resolve().parents[1], + env=env, + check=False, + timeout=10, + ) + assert completed.returncode == shell_status + captured = { + role: json.loads((captures / f"{role}.json").read_text(encoding="utf-8")) + for role in ("operator", "agent") + } + metas = { + role: json.loads( + ( + home + / "control_plane/runtime_runs" + / captured[role]["run_id"] + / "meta.json" + ).read_text(encoding="utf-8") + ) + for role in ("operator", "agent") + } + assert metas["agent"]["terminal_reason"] == child_reason + assert metas["operator"]["terminal_reason"] == operator_reason + for role in ("operator", "agent"): + assert metas[role]["liveness"] == "terminal" + with pytest.raises(ProcessLookupError): + os.kill(captured[role]["pid"], 0) + + +@pytest.mark.parametrize("signum", [signal.SIGINT, signal.SIGTERM]) +def test_supervised_owner_signal_settles_operator_and_child( + signum: int, tmp_path: Path +) -> None: + home = tmp_path / "home" + repo = tmp_path / "repo" + _repo(repo) + captures = tmp_path / "captures" + captures.mkdir() + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _fake_supervision_provider(fake_bin / "claude") + env = os.environ.copy() + env.pop("PYTHONPATH", None) + env.update( + VIBECRAFTED_HOME=str(home), + VIBECRAFTED_RUNTIME_BIN=str(fake_bin), + SUPERVISION_CAPTURES=str(captures), + PATH=str(fake_bin) + os.pathsep + env["PATH"], + ) + owner = subprocess.Popen( + [*_interactive_argv(repo), "--operator", "auto"], + cwd=Path(__file__).resolve().parents[1], + env=env, + start_new_session=True, + ) + _wait_for(captures / "operator.json") + _wait_for(captures / "agent.json") + captured = { + role: json.loads((captures / f"{role}.json").read_text()) + for role in ("operator", "agent") + } + owner.send_signal(signum) + assert owner.wait(timeout=10) == 128 + signum + for role in ("operator", "agent"): + meta = json.loads( + ( + home + / "control_plane/runtime_runs" + / captured[role]["run_id"] + / "meta.json" + ).read_text() + ) + assert meta["liveness"] == "terminal" + expected = ( + "child_settled" + if role == "operator" + else f"owner_signal:{signal.Signals(signum).name}" + ) + assert meta["terminal_reason"] == expected + with pytest.raises(ProcessLookupError): + os.kill(captured[role]["pid"], 0) + + +def test_supervised_quota_exhaustion_preserves_exit_75_and_settles_operator( + tmp_path: Path, +) -> None: + home = tmp_path / "home" + repo = tmp_path / "repo" + _repo(repo) + captures = tmp_path / "captures" + captures.mkdir() + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _fake_supervision_provider(fake_bin / "claude") + env = os.environ.copy() + env.pop("PYTHONPATH", None) + env.update( + VIBECRAFTED_HOME=str(home), + VIBECRAFTED_RUNTIME_BIN=str(fake_bin), + SUPERVISION_CAPTURES=str(captures), + CLAUDE_CONFIG_DIR=str(tmp_path / "claude-home"), + AGENT_WRITE_USAGE="1", + PATH=str(fake_bin) + os.pathsep + env["PATH"], + ) + completed = subprocess.run( + [ + *_interactive_argv(repo), + "--operator", + "auto", + "--token-budget", + "1", + ], + cwd=Path(__file__).resolve().parents[1], + env=env, + check=False, + timeout=10, + ) + assert completed.returncode == 75 + captured = { + role: json.loads((captures / f"{role}.json").read_text()) + for role in ("operator", "agent") + } + agent_meta = json.loads( + ( + home + / "control_plane/runtime_runs" + / captured["agent"]["run_id"] + / "meta.json" + ).read_text() + ) + operator_meta = json.loads( + ( + home + / "control_plane/runtime_runs" + / captured["operator"]["run_id"] + / "meta.json" + ).read_text() + ) + assert agent_meta["terminal_reason"] == "quota_exhausted" + assert agent_meta["measured_usage"]["total_tokens"] == 2 + assert operator_meta["terminal_reason"] == "child_settled" + for role in ("operator", "agent"): + with pytest.raises(ProcessLookupError): + os.kill(captured[role]["pid"], 0) + + +def test_operator_protocol_rejects_foreign_stale_and_unbounded_truth() -> None: + child = { + "run_id": "init-child", + "status": "active", + "worker_pid": 123, + "measured_usage": {"total_tokens": 4}, + } + valid = { + "kind": "observation", + "actor_run_id": "oper-1", + "child_run_id": "init-child", + "relation_id": "rel-1", + "child_status": "active", + "child_worker_pid": 123, + "measured_usage": {"total_tokens": 4}, + } + _validate_operator_protocol_event( + valid, + relation_id="rel-1", + operator_run_id="oper-1", + child_receipt=child, + ) + for mutation in ( + {"actor_run_id": "oper-foreign"}, + {"child_run_id": "init-newest"}, + {"relation_id": "rel-stale"}, + {"child_worker_pid": 999}, + {"measured_usage": {"total_tokens": 3}}, + {"kind": "action", "action": "restart", "reason": "operator_policy_stop"}, + ): + event = {**valid, **mutation} + with pytest.raises(RuntimeError): + _validate_operator_protocol_event( + event, + relation_id="rel-1", + operator_run_id="oper-1", + child_receipt=child, + ) + + +@pytest.mark.parametrize("failed_spawn", ["operator", "agent"]) +def test_supervised_spawn_failure_has_no_false_active_or_orphan( + failed_spawn: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from vibecrafted_core import spawn + + home = tmp_path / "home" + repo = tmp_path / "repo" + _repo(repo) + captures = tmp_path / "captures" + captures.mkdir() + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _fake_supervision_provider(fake_bin / "claude") + monkeypatch.setenv("VIBECRAFTED_HOME", str(home)) + monkeypatch.setenv("VIBECRAFTED_RUNTIME_BIN", str(fake_bin)) + monkeypatch.setenv("SUPERVISION_CAPTURES", str(captures)) + monkeypatch.setenv("PATH", str(fake_bin) + os.pathsep + os.environ["PATH"]) + monkeypatch.setattr( + spawn, + "resolve_provider_usage_capability", + lambda *_args, **_kwargs: _TEST_USAGE_CAPABILITY, + ) + real_popen = spawn.subprocess.Popen + spawned_operator: list[subprocess.Popen[bytes]] = [] + + def fail_selected(*args: object, **kwargs: object): + environment = kwargs.get("env") + role = ( + environment.get("VIBECRAFTED_AGENT_ROLE") + if isinstance(environment, dict) + else None + ) + if role == failed_spawn: + raise OSError(f"{failed_spawn} spawn denied") + process = real_popen(*args, **kwargs) + spawned_operator.append(process) + return process + + monkeypatch.setattr(spawn.subprocess, "Popen", fail_selected) + with pytest.raises(OSError, match=f"{failed_spawn} spawn denied"): + launch_interactive_workspace( + "claude", + "/vc-init", + "local-native", + "read-only", + repo, + operator="auto", + ) + metas = [ + json.loads(path.read_text()) + for path in (home / "control_plane/runtime_runs").glob("*/meta.json") + ] + assert len(metas) == 2 + assert all(meta["liveness"] == "terminal" for meta in metas) + assert all(meta["status"] == "failed" for meta in metas) + events = (home / "control_plane/events.jsonl").read_text() + assert "lifecycle:active" not in events + for process in spawned_operator: + assert process.poll() is not None + + +def test_supervised_relation_reservation_write_failure_rolls_back_partial_truth( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from vibecrafted_core import spawn + + home = tmp_path / "home" + repo = tmp_path / "repo" + _repo(repo) + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _fake_supervision_provider(fake_bin / "claude") + monkeypatch.setenv("VIBECRAFTED_HOME", str(home)) + monkeypatch.setenv("VIBECRAFTED_RUNTIME_BIN", str(fake_bin)) + monkeypatch.setenv("PATH", str(fake_bin) + os.pathsep + os.environ["PATH"]) + monkeypatch.setattr( + spawn, + "resolve_provider_usage_capability", + lambda *_args, **_kwargs: _TEST_USAGE_CAPABILITY, + ) + real_write = spawn._write_meta + writes = 0 + + def fail_second(path: Path, payload: dict[str, object]) -> None: + nonlocal writes + writes += 1 + if writes == 2: + raise OSError("second relation receipt denied") + real_write(path, payload) + + monkeypatch.setattr(spawn, "_write_meta", fail_second) + with pytest.raises(OSError, match="second relation receipt denied"): + launch_interactive_workspace( + "claude", + "/vc-init", + "local-native", + "read-only", + repo, + operator="auto", + ) + assert not list((home / "control_plane/runtime_runs").glob("*/meta.json")) + + +def test_interactive_small_token_quota_stops_live_provider_with_distinct_truth( + tmp_path: Path, +) -> None: + home = tmp_path / "home" + claude_home = tmp_path / "claude-home" + repo = tmp_path / "repo" + _repo(repo) + capture = tmp_path / "provider.json" + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + provider = fake_bin / "claude" + provider.write_text( + "#!/usr/bin/env python3\n" + "import json, os, pathlib, sys, time\n" + "if '--help' in sys.argv:\n" + " print(' --session-id ')\n" + " raise SystemExit(0)\n" + "if '--version' in sys.argv:\n" + " print('2.1.232 (Claude Code)')\n" + " raise SystemExit(0)\n" + "session_id = sys.argv[sys.argv.index('--session-id') + 1]\n" + "capture = pathlib.Path(os.environ['SMOKE_CAPTURE'])\n" + "capture.write_text(json.dumps({'pid': os.getpid(), 'session_id': session_id}) + '\\n', encoding='utf-8')\n" + "transcript = pathlib.Path(os.environ['CLAUDE_CONFIG_DIR']) / 'projects' / 'fixture' / f'{session_id}.jsonl'\n" + "transcript.parent.mkdir(parents=True, exist_ok=True)\n" + "transcript.write_text(json.dumps({\n" + " 'type': 'assistant', 'uuid': 'event-1', 'sessionId': session_id,\n" + " 'cwd': os.getcwd(), 'version': '2.1.232',\n" + " 'message': {'id': 'msg-1', 'type': 'message', 'usage': {\n" + " 'input_tokens': 1, 'cache_creation_input_tokens': 0,\n" + " 'cache_read_input_tokens': 0, 'output_tokens': 1,\n" + " }},\n" + "}) + '\\n', encoding='utf-8')\n" + "while True: time.sleep(0.05)\n", + encoding="utf-8", + ) + provider.chmod(0o755) + env = os.environ.copy() + env.pop("PYTHONPATH", None) + env.update( + VIBECRAFTED_HOME=str(home), + VIBECRAFTED_RUNTIME_BIN=str(fake_bin), + CLAUDE_CONFIG_DIR=str(claude_home), + SMOKE_CAPTURE=str(capture), + PATH=str(fake_bin) + os.pathsep + env["PATH"], + ) + owner = subprocess.Popen( + [ + sys.executable, + "-m", + "vibecrafted_core.spawn", + "interactive-launch", + "claude", + "--runtime", + "local-native", + "--permissions", + "read-only", + "--root", + str(repo), + "--prompt", + "/vc-init", + "--token-budget", + "1", + ], + cwd=Path(__file__).resolve().parents[1], + env=env, + ) + try: + assert owner.wait(timeout=5) == 75 + observed = json.loads(capture.read_text(encoding="utf-8")) + meta_path = next((home / "control_plane/runtime_runs").glob("*/meta.json")) + terminal = json.loads(meta_path.read_text(encoding="utf-8")) + assert terminal["status"] == "quota_exhausted" + assert terminal["terminal_reason"] == "quota_exhausted" + assert terminal["provider_session_id"] == observed["session_id"] + assert terminal["measured_usage"]["total_tokens"] == 2 + assert terminal["quota_policy"]["token_budget"] == 1 + with pytest.raises(ProcessLookupError): + os.kill(observed["pid"], 0) + finally: + if owner.poll() is None: + owner.kill() + owner.wait() + + +def test_interactive_nonzero_exit_terminalizes_and_returns_provider_status( + tmp_path: Path, +) -> None: + home = tmp_path / "home" + repo = tmp_path / "repo" + _repo(repo) + capture = tmp_path / "provider.json" + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _fake_interactive_provider(fake_bin / "claude") + env = os.environ.copy() + env.pop("PYTHONPATH", None) + env.update( + VIBECRAFTED_HOME=str(home), + VIBECRAFTED_RUNTIME_BIN=str(fake_bin), + SMOKE_CAPTURE=str(capture), + SMOKE_EXIT="7", + PATH=str(fake_bin) + os.pathsep + env["PATH"], + ) + + completed = subprocess.run( + _interactive_argv(repo), + cwd=Path(__file__).resolve().parents[1], + env=env, + check=False, + ) + + observed = json.loads(capture.read_text(encoding="utf-8")) + meta = json.loads( + ( + home / "control_plane/runtime_runs" / observed["run_id"] / "meta.json" + ).read_text(encoding="utf-8") + ) + assert completed.returncode == 7 + assert meta["status"] == "failed" + assert meta["liveness"] == "terminal" + assert meta["exit_code"] == 7 + assert meta["terminal_reason"] == "provider_exit_nonzero" + assert meta["status"] != "quota_exhausted" + + +@pytest.mark.parametrize( + ("signum", "expected_status"), + [(signal.SIGINT, 128 + signal.SIGINT), (signal.SIGTERM, 128 + signal.SIGTERM)], +) +def test_interactive_owner_signal_terminalizes_without_surviving_child( + signum: int, expected_status: int, tmp_path: Path +) -> None: + home = tmp_path / "home" + repo = tmp_path / "repo" + _repo(repo) + capture = tmp_path / "provider.json" + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _fake_interactive_provider(fake_bin / "claude") + env = os.environ.copy() + env.pop("PYTHONPATH", None) + env.update( + VIBECRAFTED_HOME=str(home), + VIBECRAFTED_RUNTIME_BIN=str(fake_bin), + SMOKE_CAPTURE=str(capture), + SMOKE_BLOCK="1", + PATH=str(fake_bin) + os.pathsep + env["PATH"], + ) + owner = subprocess.Popen( + _interactive_argv(repo), + cwd=Path(__file__).resolve().parents[1], + env=env, + start_new_session=True, + ) + try: + _wait_for(capture) + observed = json.loads(capture.read_text(encoding="utf-8")) + meta_path = ( + home / "control_plane/runtime_runs" / observed["run_id"] / "meta.json" + ) + _wait_for(meta_path) + owner.send_signal(signum) + assert owner.wait(timeout=5) == expected_status + terminal = json.loads(meta_path.read_text(encoding="utf-8")) + assert terminal["status"] == "cancelled" + assert terminal["liveness"] == "terminal" + assert terminal["exit_code"] == expected_status + assert ( + terminal["terminal_reason"] == f"owner_signal:{signal.Signals(signum).name}" + ) + with pytest.raises(ProcessLookupError): + os.kill(observed["pid"], 0) + finally: + if owner.poll() is None: + owner.kill() + owner.wait() + + +def test_child_spawn_failure_publishes_no_false_active_and_removes_clean_worktree( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from vibecrafted_core import spawn + + home = tmp_path / "home" + repo = tmp_path / "repo" + _repo(repo) + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + provider = fake_bin / "claude" + _fake_interactive_provider(provider) + monkeypatch.setenv("VIBECRAFTED_HOME", str(home)) + monkeypatch.setenv("VIBECRAFTED_RUNTIME_BIN", str(fake_bin)) + monkeypatch.setenv("PATH", str(fake_bin) + os.pathsep + os.environ["PATH"]) + real_prepare = spawn.prepare_interactive_workspace_launch + real_popen = spawn.subprocess.Popen + + def prepare_then_break_spawn(*args: object, **kwargs: object): + prepared = real_prepare(*args, **kwargs) + + def break_once(*_args: object, **_kwargs: object): + monkeypatch.setattr(spawn.subprocess, "Popen", real_popen) + raise OSError("spawn denied") + + monkeypatch.setattr( + spawn.subprocess, + "Popen", + break_once, + ) + return prepared + + monkeypatch.setattr( + spawn, "prepare_interactive_workspace_launch", prepare_then_break_spawn + ) + + with pytest.raises(OSError, match="spawn denied"): + launch_interactive_workspace( + "claude", "/vc-init", "local-worktrees", "read-only", repo + ) + + meta_path = next((home / "control_plane/runtime_runs").glob("*/meta.json")) + meta = json.loads(meta_path.read_text(encoding="utf-8")) + assert meta["status"] == "failed" + assert meta["liveness"] == "terminal" + assert meta["terminal_reason"] == "child_spawn_failed" + assert meta["prepared_worktree_cleanup"] == "removed" + assert not Path(meta["effective_worktree_path"]).exists() + events = (home / "control_plane/events.jsonl").read_text(encoding="utf-8") + assert "lifecycle:active" not in events + + +@pytest.mark.parametrize("kind", ["non-git", "dirty"]) +def test_invalid_worktree_parent_fails_before_runtime_truth( + kind: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = tmp_path / "home" + monkeypatch.setenv("VIBECRAFTED_HOME", str(home)) + repo = tmp_path / "repo" + if kind == "non-git": + repo.mkdir() + else: + _repo(repo) + (repo / "dirty.txt").write_text("dirty\n", encoding="utf-8") + + with pytest.raises((ValueError, RuntimeError), match="git repository|clean"): + prepare_interactive_workspace_launch( + provider="claude", + runtime="local-worktrees", + permissions="read-only", + selected_root=repo, + prompt="/vc-init", + run_id="init-260825-123102-00005", + executable=sys.executable, + quota_policy=resolve_quota_policy("safe", runtime="local-worktrees"), + usage_capability=_TEST_USAGE_CAPABILITY, + provider_session_id=_TEST_PROVIDER_SESSION_ID, + ) + + assert not (home / "control_plane" / "runtime_runs").exists() + + +def test_worktree_creation_failure_has_no_accepted_or_spawned_truth( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from vibecrafted_core.dispatch.worktrees import WorktreeManager + + home = tmp_path / "home" + monkeypatch.setenv("VIBECRAFTED_HOME", str(home)) + repo = tmp_path / "repo" + _repo(repo) + monkeypatch.setattr( + WorktreeManager, + "prepare_agent_launch", + lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("create failed")), + ) + + with pytest.raises(RuntimeError, match="create failed"): + prepare_interactive_workspace_launch( + provider="claude", + runtime="local-worktrees", + permissions="read-only", + selected_root=repo, + prompt="/vc-init", + run_id="init-260825-123102-00006", + executable=sys.executable, + quota_policy=resolve_quota_policy("safe", runtime="local-worktrees"), + usage_capability=_TEST_USAGE_CAPABILITY, + provider_session_id=_TEST_PROVIDER_SESSION_ID, + ) + + assert not (home / "control_plane" / "runtime_runs").exists() + + +def test_every_runtime_permission_provider_mode_cell_is_explicit() -> None: + cells = [ + resolve_provider_policy(provider, runtime, permissions, mode) + for provider, runtime, permissions, mode in itertools.product( + POLICY_PROVIDERS, RUNTIME_POLICIES, PERMISSION_POLICIES, POLICY_MODES + ) + ] + + assert len(cells) == 5 * 4 * 4 * 2 + assert all(cell.behavior or cell.reason for cell in cells) + assert all(cell.supported != bool(cell.reason) for cell in cells) + + +@pytest.mark.parametrize("provider", POLICY_PROVIDERS) +def test_worktrees_are_interactive_only_while_vm_and_cloud_stay_unavailable( + provider: str, +) -> None: + assert resolve_provider_policy( + provider, "local-worktrees", "bypass", "interactive" + ).supported + assert not resolve_provider_policy( + provider, "local-worktrees", "bypass", "headless" + ).supported + assert ( + "VM entrypoint" + in resolve_provider_policy(provider, "local-vm", "bypass", "interactive").reason + ) + assert ( + "coming soon" + in resolve_provider_policy( + provider, "cloud-soon", "bypass", "interactive" + ).reason + ) + + +def test_accept_edits_is_native_or_unsupported_never_approximated() -> None: + for provider in ("claude", "agy", "grok"): + decision = resolve_provider_policy( + provider, "local-native", "accept-edits", "headless" + ) + assert decision.supported + assert "edits pass" in decision.behavior + assert "fail closed" in decision.behavior + + for provider in ("codex", "junie"): + decision = resolve_provider_policy( + provider, "local-native", "accept-edits", "interactive" + ) + assert not decision.supported + assert "no native accept-edits" in decision.reason + + +def test_junie_interactive_only_policies_fail_closed_headless() -> None: + assert resolve_provider_policy( + "junie", "local-native", "bypass", "interactive" + ).supported + assert not resolve_provider_policy( + "junie", "local-native", "bypass", "headless" + ).supported + assert not resolve_provider_policy( + "junie", "local-native", "read-only", "headless" + ).supported + + +def test_interactive_command_uses_contract_flags() -> None: + command = interactive_policy_command( + "claude", "/vc-init", "local-native", "accept-edits" + ) + assert command == [ + "claude", + "--verbose", + "--permission-mode", + "acceptEdits", + "/vc-init", + ] + + with pytest.raises(ValueError, match="no native accept-edits"): + interactive_policy_command("codex", "/vc-init", "local-native", "accept-edits") + + +def test_interactive_workspace_command_wraps_the_exact_init_route( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr( + "vibecrafted_core.spawn.resolve_provider_usage_capability", + lambda _provider: _TEST_USAGE_CAPABILITY, + ) + command = interactive_workspace_command( + "claude", "/vc-init", "local-worktrees", "read-only", tmp_path + ) + + assert command[:4] == [ + sys.executable, + "-m", + "vibecrafted_core.spawn", + "interactive-launch", + ] + assert command[-2:] == ["--prompt", "/vc-init"] + assert "local-worktrees" in command + assert "read-only" in command + + +@pytest.mark.parametrize( + ("selection", "runtime", "expected_kind", "expected_budget"), + [ + (None, "local-native", "bounded", 250_000), + ("safe", "local-worktrees", "bounded", 250_000), + ("42", "local-native", "bounded", 42), + ("unlimited", "local-native", "unlimited", None), + ], +) +def test_quota_policy_is_typed_and_validated( + selection: str | None, runtime: str, expected_kind: str, expected_budget: int | None +) -> None: + policy = resolve_quota_policy(selection, runtime=runtime) + assert policy.kind == expected_kind + assert policy.token_budget == expected_budget + + +@pytest.mark.parametrize("selection", ["0", "-1", "10000001", "wat"]) +def test_invalid_bounded_quota_fails_closed(selection: str) -> None: + with pytest.raises(ValueError, match="token budget"): + resolve_quota_policy(selection, runtime="local-native") + + +def test_unlimited_quota_is_restricted_to_observed_local_native() -> None: + with pytest.raises(ValueError, match="User-observed local-native"): + resolve_quota_policy("unlimited", runtime="local-worktrees") + + +def test_unsupported_provider_quota_fails_before_runtime_truth( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = tmp_path / "home" + repo = tmp_path / "repo" + _repo(repo) + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + provider = fake_bin / "codex" + provider.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + provider.chmod(0o755) + monkeypatch.setenv("VIBECRAFTED_HOME", str(home)) + monkeypatch.setenv("VIBECRAFTED_RUNTIME_BIN", str(fake_bin)) + monkeypatch.setenv("PATH", str(fake_bin) + os.pathsep + os.environ["PATH"]) + + with pytest.raises(ValueError, match="no verified live"): + launch_interactive_workspace( + "codex", "/vc-init", "local-native", "read-only", repo, "safe" + ) + + assert not (home / "control_plane" / "runtime_runs").exists() + + +def test_measured_usage_capability_matrix_has_one_honest_provider( + tmp_path: Path, +) -> None: + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + claude = fake_bin / "claude" + _fake_interactive_provider(claude) + + supported = resolve_provider_usage_capability("claude", executable=str(claude)) + assert supported.supported is True + assert supported.source == "claude-transcript-jsonl-v1" + assert supported.provider_version == "2.1.232" + for provider in ("codex", "agy", "grok", "junie"): + executable = fake_bin / provider + executable.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + executable.chmod(0o755) + capability = resolve_provider_usage_capability( + provider, executable=str(executable) + ) + assert capability.supported is False + assert "no verified live" in capability.reason + + +def _usage_event( + *, session_id: str, cwd: Path, message_id: str, input_tokens: int = 3 +) -> dict[str, object]: + return { + "type": "assistant", + "sessionId": session_id, + "cwd": str(cwd), + "version": "2.1.232", + "message": { + "id": message_id, + "usage": { + "input_tokens": input_tokens, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "output_tokens": 2, + }, + }, + } + + +def test_exact_session_usage_is_monotonic_and_deduplicates_message_ids( + tmp_path: Path, +) -> None: + session_id = "22222222-2222-4222-8222-222222222222" + repo = tmp_path / "repo" + repo.mkdir() + config = tmp_path / "claude" + reader = _ClaudeTranscriptUsage( + provider_session_id=session_id, + effective_root=str(repo), + provider_version="2.1.232", + env={"CLAUDE_CONFIG_DIR": str(config)}, + ) + transcript = config / "projects" / "fixture" / f"{session_id}.jsonl" + transcript.parent.mkdir(parents=True) + event = _usage_event(session_id=session_id, cwd=repo, message_id="msg-1") + transcript.write_text( + json.dumps(event) + "\n" + json.dumps(event) + "\n", encoding="utf-8" + ) + + assert reader.poll() == { + "input_tokens": 3, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "output_tokens": 2, + "total_tokens": 5, + "messages": 1, + } + transcript.write_text( + transcript.read_text(encoding="utf-8") + + json.dumps(_usage_event(session_id=session_id, cwd=repo, message_id="msg-2")) + + "\n", + encoding="utf-8", + ) + assert reader.poll()["total_tokens"] == 10 + + +def test_usage_reader_ignores_unrelated_newest_session_and_rejects_foreign_event( + tmp_path: Path, +) -> None: + session_id = "33333333-3333-4333-8333-333333333333" + repo = tmp_path / "repo" + repo.mkdir() + config = tmp_path / "claude" + unrelated = config / "projects" / "fixture" / "newest.jsonl" + unrelated.parent.mkdir(parents=True) + unrelated.write_text("{}\n", encoding="utf-8") + reader = _ClaudeTranscriptUsage( + provider_session_id=session_id, + effective_root=str(repo), + provider_version="2.1.232", + env={"CLAUDE_CONFIG_DIR": str(config)}, + ) + assert reader.poll()["total_tokens"] == 0 + transcript = unrelated.with_name(f"{session_id}.jsonl") + transcript.write_text( + json.dumps( + _usage_event( + session_id="foreign-session", cwd=repo, message_id="msg-foreign" + ) + ) + + "\n", + encoding="utf-8", + ) + with pytest.raises(RuntimeError, match="foreign session"): + reader.poll() + + +def test_usage_reader_rejects_preexisting_exact_session_source(tmp_path: Path) -> None: + session_id = "44444444-4444-4444-8444-444444444444" + repo = tmp_path / "repo" + repo.mkdir() + config = tmp_path / "claude" + transcript = config / "projects" / "fixture" / f"{session_id}.jsonl" + transcript.parent.mkdir(parents=True) + transcript.write_text("{}\n", encoding="utf-8") + + with pytest.raises(ValueError, match="already exists"): + _ClaudeTranscriptUsage( + provider_session_id=session_id, + effective_root=str(repo), + provider_version="2.1.232", + env={"CLAUDE_CONFIG_DIR": str(config)}, + ) + + +def test_policy_cli_reads_the_same_contract(monkeypatch, capsys) -> None: + monkeypatch.setattr(sys, "stdin", io.StringIO("/vc-init")) + + assert ( + main( + [ + "policy-command", + "grok", + "--runtime", + "local-native", + "--permissions", + "read-only", + ] + ) + == 0 + ) + assert shlex.split(capsys.readouterr().out) == [ + "grok", + "--cwd", + ".", + "--permission-mode", + "plan", + "--no-alt-screen", + "/vc-init", + ] + + +def test_interactive_command_requires_typed_continuity_selection( + tmp_path: Path, monkeypatch, capsys +) -> None: + """H2b2d fail-first: the canonical owner must accept explicit fresh truth.""" + monkeypatch.setattr(sys, "stdin", io.StringIO("/vc-init")) + monkeypatch.setattr( + "vibecrafted_core.spawn.resolve_provider_usage_capability", + lambda _provider: _TEST_USAGE_CAPABILITY, + ) + + assert ( + main( + [ + "interactive-command", + "claude", + "--runtime", + "local-native", + "--permissions", + "read-only", + "--continuity", + "fresh", + "--root", + str(tmp_path), + ] + ) + == 0 + ) + command = shlex.split(capsys.readouterr().out) + assert command[command.index("--continuity") + 1] == "fresh" + + +def test_continuity_modes_are_exact_and_fresh_proves_scoped_absence() -> None: + assert CONTINUITY_MODES == ("full-lineage", "fresh", "bare-fork") + policy = resolve_continuity_policy("fresh", provider="claude", env={}) + child = _fresh_child_environment( + { + "PATH": "/tools", + "HOME": "/user", + "CODEX_SESSION_ID": "current", + "VIBECRAFTED_LOOP_STATE_FILE": "/stale-loop", + "VIBECRAFTED_RESUME_CONTEXT": "/stale-pack", + "AICX_CONTINUITY_FILE": "/stale-aicx", + }, + policy, + ) + assert child == {"PATH": "/tools", "HOME": "/user"} + + +def test_full_lineage_requires_explicit_parent_evidence() -> None: + with pytest.raises(ValueError, match="parent lineage id"): + resolve_continuity_policy("full-lineage", provider="claude", env={}) + policy = resolve_continuity_policy( + "full-lineage", + provider="claude", + parent_lineage_id="run-parent-42", + env={}, + ) + assert policy.as_dict()["lineage_id"] == "run-parent-42" + assert not policy.parent_provider_session_id + command = interactive_policy_command( + "claude", + "/vc-init", + "local-native", + "read-only", + provider_session_id=_TEST_PROVIDER_SESSION_ID, + continuity_policy=policy, + ) + assert command[command.index("--session-id") + 1] == _TEST_PROVIDER_SESSION_ID + assert "--resume" not in command + assert "--fork-session" not in command + + +def test_bare_fork_rejects_missing_malformed_current_and_unsupported_parent( + monkeypatch, +) -> None: + for parent in ("", "bad parent", "*"): + with pytest.raises(ValueError, match="well-formed"): + resolve_continuity_policy( + "bare-fork", provider="claude", parent_session_id=parent, env={} + ) + with pytest.raises(ValueError, match="current provider session"): + resolve_continuity_policy( + "bare-fork", + provider="claude", + parent_session_id="same-session", + env={"CLAUDE_CODE_SESSION_ID": "same-session"}, + ) + with pytest.raises(ValueError, match="unsupported for agy"): + resolve_continuity_policy( + "bare-fork", provider="agy", parent_session_id="agy-parent", env={} + ) + + +def test_continuity_rejection_writes_terminal_truth_before_any_spawn( + tmp_path: Path, monkeypatch +) -> None: + home = tmp_path / "home" + repo = tmp_path / "repo" + repo.mkdir() + monkeypatch.setenv("VIBECRAFTED_HOME", str(home)) + + with pytest.raises(ValueError, match="well-formed"): + launch_interactive_workspace( + "claude", + "/vc-init", + "local-native", + "read-only", + repo, + continuity="bare-fork", + ) + + receipts = list((home / "control_plane/runtime_runs").glob("*/meta.json")) + assert len(receipts) == 1 + receipt = json.loads(receipts[0].read_text(encoding="utf-8")) + assert receipt["status"] == "failed" + assert receipt["liveness"] == "terminal" + assert receipt["terminal_reason"] == "continuity_validation_failed" + assert receipt["continuity"]["supported"] is False + assert "worker_pid" not in receipt + + +def test_confirmed_bare_fork_constructs_only_explicit_parent(monkeypatch) -> None: + from vibecrafted_core.continuity import capabilities + + monkeypatch.setattr( + capabilities, + "probe", + lambda *_args, **_kwargs: SimpleNamespace( + state=capabilities.PROBE_CONFIRMED, detail="confirmed" + ), + ) + policy = resolve_continuity_policy( + "bare-fork", + provider="claude", + parent_session_id="parent-session-42", + env={}, + ) + command = interactive_policy_command( + "claude", + "/vc-init", + "local-native", + "read-only", + provider_session_id="11111111-1111-4111-8111-111111111111", + continuity_policy=policy, + ) + assert command[command.index("--resume") + 1] == "parent-session-42" + assert "--fork-session" in command + assert "AICX" not in " ".join(command) + + +def test_full_lineage_materializes_bounded_new_session_pack_and_active_loop( + tmp_path: Path, monkeypatch +) -> None: + from vibecrafted_core import aicx_session_chain + + repo = tmp_path / "repo" + repo.mkdir() + loop_path = repo / ".vibecrafted" / "operator-loop.local.md" + loop_path.parent.mkdir() + loop_path.write_text( + "---\nactive: true\niteration: 2\n---\n\nShip H2b2d.\n", + encoding="utf-8", + ) + monkeypatch.setenv("VIBECRAFTED_HOME", str(tmp_path / "home")) + monkeypatch.setenv("VIBECRAFTED_LOOP_STATE_FILE", str(loop_path)) + monkeypatch.setattr("vibecrafted_core.spawn.which", lambda *_a, **_k: "/bin/aicx") + + def assemble(**kwargs): + body = ( + "# Resume continuity pack\n## Session catalog\nrow\n" + "## Continuity\n## NOW\ntruth\n## Operator instruction\nnew session\n" + ) + kwargs["context_file"].parent.mkdir(parents=True, exist_ok=True) + kwargs["context_file"].write_text(body, encoding="utf-8") + kwargs["meta_file"].write_text("{}\n", encoding="utf-8") + return SimpleNamespace( + mode="new_session", + empty_kind="none", + session_count=2, + degradations=[], + body=body, + ) + + monkeypatch.setattr(aicx_session_chain, "assemble_resume_continuity_pack", assemble) + policy = ContinuityPolicy("full-lineage", "parent-run") + material = _materialize_continuity( + policy, provider="claude", root=repo, run_id="init-test", prompt="/vc-init" + ) + assert "Start a new provider session; never attach" in material.prompt + assert material.context_sha256 and material.loop_sha256 + assert material.receipt()["materialized"] is True + + +def test_full_lineage_rejects_degraded_material_before_spawn( + tmp_path: Path, monkeypatch +) -> None: + from vibecrafted_core import aicx_session_chain + + loop_path = tmp_path / "loop.md" + loop_path.write_text("---\nactive: true\n---\nGoal\n", encoding="utf-8") + monkeypatch.setenv("VIBECRAFTED_LOOP_STATE_FILE", str(loop_path)) + monkeypatch.setattr("vibecrafted_core.spawn.which", lambda *_a, **_k: "/bin/aicx") + + def degraded(**kwargs): + kwargs["context_file"].parent.mkdir(parents=True, exist_ok=True) + kwargs["context_file"].write_text("degraded", encoding="utf-8") + return SimpleNamespace( + mode="new_session", + empty_kind="empty_project", + session_count=0, + degradations=["stale"], + body="degraded", + ) + + monkeypatch.setattr(aicx_session_chain, "assemble_resume_continuity_pack", degraded) + with pytest.raises(ValueError, match="empty, stale, degraded"): + _materialize_continuity( + ContinuityPolicy("full-lineage", "parent-run"), + provider="claude", + root=tmp_path, + run_id="init-degraded", + prompt="/vc-init", + ) diff --git a/vibecrafted-core/tests/test_run_board.py b/vibecrafted-core/tests/test_run_board.py new file mode 100644 index 00000000..d6971f66 --- /dev/null +++ b/vibecrafted-core/tests/test_run_board.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import json + +from vibecrafted_core import run_board + + +def _run(run_id: str, *, started_at: str, root: str = "/repo") -> dict[str, str]: + return { + "run_id": run_id, + "state": "active", + "started_at": started_at, + "root": root, + } + + +def test_lifecycle_activity_ignores_today_and_display_limit(monkeypatch) -> None: + old_active = _run("old-active", started_at="2025-01-01T00:00:00+00:00") + recent = [ + _run(f"recent-{index}", started_at=f"2026-08-25T12:{index:02d}:00+00:00") + for index in range(13) + ] + monkeypatch.setattr( + run_board, + "sync_state", + lambda: { + "active_runs": [old_active], + "stalled_runs": [], + "recent_runs": recent, + }, + ) + + activity = run_board.collect_lifecycle_activity() + + assert [lane["run_id"] for lane in activity["lanes"]] == ["old-active"] + assert activity["summary"] == {"lanes": 1, "worktrees": 0} + + +def test_lifecycle_activity_includes_stalled_and_worktree_lanes( + monkeypatch, tmp_path +) -> None: + custom_home = tmp_path / "custom-vibecrafted-home" + monkeypatch.setenv("VIBECRAFTED_HOME", str(custom_home)) + worktree = _run( + "worktree-active", + started_at="2026-08-24T01:00:00+00:00", + root=str(custom_home / "worktrees/vetcoders/vibecrafted/2026_0825/cut"), + ) + stalled = { + **_run("stalled", started_at="2026-08-23T01:00:00+00:00"), + "state": "stalled", + } + monkeypatch.setattr( + run_board, + "sync_state", + lambda: { + "active_runs": [worktree], + "stalled_runs": [stalled, worktree], + "recent_runs": [], + }, + ) + + activity = run_board.collect_lifecycle_activity() + + assert {lane["run_id"] for lane in activity["lanes"]} == { + "worktree-active", + "stalled", + } + assert activity["summary"] == {"lanes": 2, "worktrees": 1} + + +def test_status_activity_json_is_the_unfiltered_machine_contract( + monkeypatch, capsys +) -> None: + monkeypatch.setattr( + run_board, + "sync_state", + lambda: { + "active_runs": [], + "stalled_runs": [], + "recent_runs": [_run("recent", started_at="2026-08-25T01:00:00+00:00")], + }, + ) + + assert run_board.status_main(["--activity", "--json"]) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["schema_version"] == "vibecrafted.lifecycle-activity.v1" + assert payload["summary"] == {"lanes": 0, "worktrees": 0} + assert payload["lanes"] == [] diff --git a/vibecrafted-core/tests/test_run_triage.py b/vibecrafted-core/tests/test_run_triage.py index 92093cb4..2dbca8dc 100644 --- a/vibecrafted-core/tests/test_run_triage.py +++ b/vibecrafted-core/tests/test_run_triage.py @@ -590,6 +590,7 @@ def test_death_after_real_work_is_not_a_clean_failure() -> None: "contract_failed", "ghost", "timed_out", + "quota_exhausted", "recovery_required", "blocked", "stalled", diff --git a/vibecrafted-core/tests/test_runtime_receipt.py b/vibecrafted-core/tests/test_runtime_receipt.py index b7fad0a0..498a4ad3 100644 --- a/vibecrafted-core/tests/test_runtime_receipt.py +++ b/vibecrafted-core/tests/test_runtime_receipt.py @@ -21,10 +21,14 @@ } _RUNTIME_FILE_BYTES = { "VERSION": f"{_RUNTIME_VERSION}\n".encode(), + "scripts/distribution_manifest.py": b"MANIFEST = True\n", + "scripts/installer_brand.py": b"BRAND = True\n", "scripts/vibecrafted": b"#!/usr/bin/env bash\n", + "scripts/vetcoders_install.py": b"#!/usr/bin/env python3\n", pc.RUNTIME_GENERATION_CANONICAL_CONFIG: b"layout {}\n", pc.RUNTIME_GENERATION_ENTRYPOINT: b"#!/usr/bin/env bash\n", "vibecrafted-core/vibecrafted_core/product_contract.py": b"contract = True\n", + "vibecrafted-core/vibecrafted_core/runtime_pack_contract.py": b"pack = True\n", "vibecrafted-core/vibecrafted_core/walkaround_runner.py": b"runner = True\n", "vibecrafted-core/vibecrafted_core/schemas/unified_product.schema.v1.json": ( b"{}\n" @@ -279,7 +283,7 @@ def test_vibecrafted_receipt_uses_checkout_free_runtime_manifest( tmp_path: Path, monkeypatch ) -> None: generation, deck, manifest = _runtime_generation_fixture(tmp_path) - assert len(manifest["hashes"]) == 9 + assert len(manifest["hashes"]) == 13 assert ( pc.verify_installed_runtime_generation(generation, expected_entrypoint=deck) == manifest diff --git a/vibecrafted-core/tests/test_server_supervisor.py b/vibecrafted-core/tests/test_server_supervisor.py index cc734fca..e4e7f72b 100644 --- a/vibecrafted-core/tests/test_server_supervisor.py +++ b/vibecrafted-core/tests/test_server_supervisor.py @@ -295,6 +295,12 @@ def test_launch_agent_carries_the_installing_path_and_active_generation( assert payload["EnvironmentVariables"]["PATH"] == ( f"{generation / 'bin'}:/opt/homebrew/bin:/usr/bin:/bin" ) + assert payload["EnvironmentVariables"]["VIBECRAFTED_RUNTIME_ROOT"] == str( + generation + ) + assert supervisor._child_environment(config.paths)[ + "VIBECRAFTED_RUNTIME_ROOT" + ] == str(generation) # No active-runtime receipt: the installing PATH still survives whole. (config.paths.runtime_home / "active.json").unlink() @@ -306,6 +312,8 @@ def test_launch_agent_carries_the_installing_path_and_active_generation( ) assert payload["EnvironmentVariables"]["PATH"] == "/opt/homebrew/bin:/usr/bin:/bin" + assert "VIBECRAFTED_RUNTIME_ROOT" not in payload["EnvironmentVariables"] + assert "VIBECRAFTED_RUNTIME_ROOT" not in supervisor._child_environment(config.paths) def test_launch_agent_path_drops_empty_and_relative_segments( @@ -1427,6 +1435,42 @@ def test_linux_service_command_fails_closed_without_mutation( assert not (tmp_path / "operator" / "Library" / "LaunchAgents").exists() +def test_service_logs_reports_canonical_owner_paths( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + launcher = _executable(tmp_path / "bin" / "vibecrafted") + home = (tmp_path / "crafted-home").resolve() + runtime_home = (tmp_path / "runtime").resolve() + operator_home = (tmp_path / "operator").resolve() + monkeypatch.setattr(supervisor.sys, "platform", "darwin") + + result = supervisor.main( + [ + "service", + "logs", + "--json", + "--launcher", + str(launcher), + "--home", + str(home), + "--runtime-home", + str(runtime_home), + "--operator-home", + str(operator_home), + ] + ) + + assert result == 0 + assert json.loads(capsys.readouterr().out) == { + "directory": str(home / "server"), + "stdout": str(home / "server" / "supervisor.stdout.log"), + "stderr": str(home / "server" / "supervisor.stderr.log"), + } + assert not (operator_home / "Library" / "LaunchAgents").exists() + + def test_child_environment_is_a_minimal_nonsecret_allowlist( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, diff --git a/vibecrafted-core/tests/test_start_here.py b/vibecrafted-core/tests/test_start_here.py new file mode 100644 index 00000000..69b1f4e1 --- /dev/null +++ b/vibecrafted-core/tests/test_start_here.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +from types import ModuleType + +from vibecrafted_core.vc_frame_staging import materialize_vc_frame_config + +SCRIPT = ( + Path(__file__).resolve().parents[1] + / "vibecrafted_core" + / "config" + / "vc-frame" + / "vc-start-here.py" +) + + +def _load() -> ModuleType: + spec = importlib.util.spec_from_file_location("vc_start_here", SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_start_here_is_shipped_and_keeps_executable_mode(tmp_path: Path) -> None: + assert SCRIPT.is_file() + assert SCRIPT.stat().st_mode & 0o111 + + destination = tmp_path / "vc-frame" + materialize_vc_frame_config( + SCRIPT.parent, + destination, + pane_shell="bash", + clipboard_command=None, + ) + + installed = destination / SCRIPT.name + assert installed.is_file() + assert installed.stat().st_mode & 0o111 + + +def test_start_here_routes_to_existing_product_owners() -> None: + start_here = _load() + + assert start_here.action_argv("agents") == [ + "vc-frame", + "action", + "go-to-tab-name", + "Agents", + ] + assert start_here.action_argv("shell") == [ + "vc-frame", + "action", + "go-to-tab-name", + "Shell", + ] + assert start_here.action_argv("console") == [ + "/usr/bin/open", + "vibecrafted://console/open", + ] + assert start_here.action_argv("help")[:5] == [ + "vc-frame", + "action", + "new-pane", + "--floating", + "--name", + ] + + +def test_start_here_readiness_is_truthful_and_actionable() -> None: + start_here = _load() + healthy = { + "installed": True, + "loaded": True, + "supervisor_live": True, + "supervisor_verified": True, + "supervisor_service_managed": True, + "build_current": True, + "pair_healthy": True, + } + + assert start_here.readiness_from_service_payload(healthy) == ( + "ready", + "VC Server is healthy — this workspace is ready", + ) + assert start_here.readiness_from_service_payload( + {"installed": True, "loaded": False} + ) == ( + "stopped", + "VC Server is stopped — use the Vibecrafted menu bar to start it", + ) + assert start_here.readiness_from_service_payload(None, deck_available=False) == ( + "missing", + "Vibecrafted launcher is missing — reinstall the Runtime Pack", + ) + + +def test_start_here_mouse_targets_the_same_actions_as_keyboard() -> None: + start_here = _load() + targets = [(10, 4, 28, "agents"), (13, 4, 28, "shell")] + + assert start_here.action_for_mouse_row(10, targets, 9) == "agents" + assert start_here.action_for_mouse_row(13, targets, 28) == "shell" + assert start_here.action_for_mouse_row(12, targets, 9) is None diff --git a/vibecrafted-core/tests/test_vc_frame_delivery.py b/vibecrafted-core/tests/test_vc_frame_delivery.py index e1eda16c..8160a7b3 100644 --- a/vibecrafted-core/tests/test_vc_frame_delivery.py +++ b/vibecrafted-core/tests/test_vc_frame_delivery.py @@ -83,7 +83,7 @@ def test_stage_wires_view_through_current(tmp_path: Path, monkeypatch) -> None: path_env=os.environ.get("PATH", ""), ) assert plan.channel == "store-current" - view = home / ".config" / "vc-frame" + view = home / ".config" / "vibecrafted" / "vc-frame" cfg = view / "config.kdl" assert cfg.is_symlink() or cfg.is_file() resolved = cfg.resolve() @@ -100,24 +100,22 @@ def test_stage_wires_view_through_current(tmp_path: Path, monkeypatch) -> None: assert ( _runtime_payload(current) / "generated" / "vc-frame" / "config.kdl" ).exists() - # Operator scripts + frontier projection (VC_FRAME_CONFIG_DIR) are install-owned. + # Operator scripts share the one product-owned XDG projection. generated = _runtime_payload(current) / "generated" / "vc-frame" assert (generated / "vc-composer.sh").is_file() composer_view = view / "vc-composer.sh" assert composer_view.is_symlink() assert composer_view.resolve() == (generated / "vc-composer.sh").resolve() frontier = home / ".config" / "vetcoders" / "frontier" / "vc-frame" - frontier_cfg = frontier / "config.kdl" - frontier_composer = frontier / "vc-composer.sh" - assert frontier_cfg.is_symlink() - assert frontier_composer.is_symlink() - assert frontier_composer.resolve() == (generated / "vc-composer.sh").resolve() + assert not frontier.exists() assert 'bind "Super e"' in text or 'bind "Super e"' in text assert "support_kitty_keyboard_protocol true" in text -def test_stage_rewires_stale_frontier_composer(tmp_path: Path, monkeypatch) -> None: - """STALE-FILE under frontier must not shadow package scripts forever.""" +def test_stage_does_not_republish_retired_frontier_projection( + tmp_path: Path, monkeypatch +) -> None: + """The retired frontier path is not a second live vc-frame authority.""" home = tmp_path / "home" home.mkdir() tools = home / ".local" / "share" / "vibecrafted" / "tools" @@ -137,13 +135,15 @@ def test_stage_rewires_stale_frontier_composer(tmp_path: Path, monkeypatch) -> N prefer_repo=False, path_env=os.environ.get("PATH", ""), ) - assert stale.is_symlink() - body = stale.resolve().read_text(encoding="utf-8") - assert "ancient" not in body - assert "VC_COMPOSER_CARET" in body or "guicursor" in body or "t_SI" in body + assert stale.is_file() + assert not stale.is_symlink() + assert "ancient" in stale.read_text(encoding="utf-8") + assert ( + home / ".config" / "vibecrafted" / "vc-frame" / "vc-composer.sh" + ).is_symlink() -def test_wire_can_force_managed_frontier_without_claiming_user_view( +def test_wire_force_frontier_compat_flag_still_wires_only_canonical_view( tmp_path: Path, monkeypatch ) -> None: home = tmp_path / "home" @@ -156,7 +156,7 @@ def test_wire_can_force_managed_frontier_without_claiming_user_view( (foreign / "layouts" / "operator.kdl").write_text( "foreign layout\n", encoding="utf-8" ) - user_view = home / ".config" / "vc-frame" + user_view = home / ".config" / "vibecrafted" / "vc-frame" frontier = home / ".config" / "vetcoders" / "frontier" / "vc-frame" user_view.mkdir(parents=True) frontier.mkdir(parents=True) @@ -167,13 +167,14 @@ def test_wire_can_force_managed_frontier_without_claiming_user_view( home=home, tools_home=tools, prefer_repo=False, + force=True, force_frontier=True, ) - assert (user_view / "layouts").resolve() == (foreign / "layouts").resolve() - assert (frontier / "layouts").resolve() == ( + assert (user_view / "layouts").resolve() == ( _runtime_payload(runtime) / "generated" / "vc-frame" / "layouts" ).resolve() + assert (frontier / "layouts").resolve() == (foreign / "layouts").resolve() assert (foreign / "layouts" / "operator.kdl").read_text( encoding="utf-8" ) == "foreign layout\n" @@ -191,7 +192,7 @@ def test_wire_only_requires_pre_materialized_runtime( with pytest.raises(RuntimeError, match="pre-materialized"): wire_vc_frame_config(home=home, tools_home=tools, prefer_repo=False) - assert not (home / ".config" / "vc-frame").exists() + assert not (home / ".config" / "vibecrafted" / "vc-frame").exists() def test_wire_only_never_mutates_published_generation( @@ -219,7 +220,7 @@ def test_wire_only_never_mutates_published_generation( def observed_replace(source, destination) -> None: destination_path = Path(destination) - if destination_path.parent == home / ".config" / "vc-frame": + if destination_path.parent == home / ".config" / "vibecrafted" / "vc-frame": replace_observations.append( destination_path.exists() or destination_path.is_symlink() ) @@ -258,7 +259,7 @@ def test_wire_failure_restores_displaced_user_view( _seed_complete_runtime(tools) monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) stage_vc_frame_config(home=home, tools_home=tools, prefer_repo=False) - view = home / ".config" / "vc-frame" / "config.kdl" + view = home / ".config" / "vibecrafted" / "vc-frame" / "config.kdl" view.unlink() view.write_text("operator config\n", encoding="utf-8") original_replace = os.replace @@ -324,7 +325,7 @@ def test_stage_keeps_mirrored_runtime_source_distinct_from_generated_view( assert (source / "config.kdl").read_text(encoding="utf-8").startswith("theme") generated = _runtime_payload(runtime) / "generated" / "vc-frame" assert (generated / "config.kdl").is_file() - assert (home / ".config" / "vc-frame" / "config.kdl").resolve() == ( + assert (home / ".config" / "vibecrafted" / "vc-frame" / "config.kdl").resolve() == ( generated / "config.kdl" ).resolve() @@ -343,7 +344,7 @@ def test_dry_run_mutates_nothing(tmp_path: Path, monkeypatch) -> None: prefer_repo=False, ) assert plan.dry_run is True - assert not (home / ".config" / "vc-frame").exists() + assert not (home / ".config" / "vibecrafted" / "vc-frame").exists() assert not tools.exists() or not list(tools.iterdir()) @@ -368,7 +369,7 @@ def test_regular_file_collision_gets_stale_backup(tmp_path: Path, monkeypatch) - _seed_complete_runtime(tools) monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) monkeypatch.delenv("VIBECRAFTED_PREFER_REPO_VC_FRAME", raising=False) - view = home / ".config" / "vc-frame" + view = home / ".config" / "vibecrafted" / "vc-frame" view.mkdir(parents=True) stale = view / "config.kdl" stale.write_text('theme "choinka"\n', encoding="utf-8") @@ -396,7 +397,7 @@ def test_same_second_rewires_never_overwrite_operator_backups( "vibecrafted_core.vc_frame_delivery._timestamp", lambda: "20260726_120000", ) - view = home / ".config" / "vc-frame" + view = home / ".config" / "vibecrafted" / "vc-frame" view.mkdir(parents=True) config = view / "config.kdl" @@ -425,7 +426,7 @@ def test_foreign_symlink_is_preserved_without_force( monkeypatch.delenv("VIBECRAFTED_PREFER_REPO_VC_FRAME", raising=False) operator_config = tmp_path / "operator-config.kdl" operator_config.write_text('theme "operator-custom"\n', encoding="utf-8") - view = home / ".config" / "vc-frame" + view = home / ".config" / "vibecrafted" / "vc-frame" view.mkdir(parents=True) config_link = view / "config.kdl" config_link.symlink_to(operator_config) @@ -457,7 +458,7 @@ def test_foreign_symlink_is_replaced_with_force(tmp_path: Path, monkeypatch) -> monkeypatch.delenv("VIBECRAFTED_PREFER_REPO_VC_FRAME", raising=False) operator_config = tmp_path / "operator-config.kdl" operator_config.write_text('theme "operator-custom"\n', encoding="utf-8") - view = home / ".config" / "vc-frame" + view = home / ".config" / "vibecrafted" / "vc-frame" view.mkdir(parents=True) config_link = view / "config.kdl" config_link.symlink_to(operator_config) @@ -485,7 +486,7 @@ def test_config_refresh_preserves_runtime_pointer_and_view_paths( monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) monkeypatch.delenv("VIBECRAFTED_PREFER_REPO_VC_FRAME", raising=False) stage_vc_frame_config(home=home, tools_home=tools, version="vA", prefer_repo=False) - view_cfg = home / ".config" / "vc-frame" / "config.kdl" + view_cfg = home / ".config" / "vibecrafted" / "vc-frame" / "config.kdl" path_a = str(view_cfg) stage_vc_frame_config( home=home, tools_home=tools, version="vB", prefer_repo=False, force=True @@ -508,7 +509,7 @@ def test_stage_rewires_legacy_store_view_to_generated_assets( legacy = runtime / "config" / "vc-frame" legacy.mkdir(parents=True) (legacy / "config.kdl").write_text('theme "legacy"\n', encoding="utf-8") - view = home / ".config" / "vc-frame" + view = home / ".config" / "vibecrafted" / "vc-frame" view.mkdir(parents=True) (view / "config.kdl").symlink_to(legacy / "config.kdl") monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) @@ -531,7 +532,7 @@ def test_dev_mode_targets_checkout(tmp_path: Path, monkeypatch) -> None: prefer_repo=True, ) assert plan.channel == "dev-checkout" - view_cfg = (home / ".config" / "vc-frame" / "config.kdl").resolve() + view_cfg = (home / ".config" / "vibecrafted" / "vc-frame" / "config.kdl").resolve() # should land under repo config/vc-frame assert view_cfg.is_file() assert "config/vc-frame" in str(view_cfg).replace("\\", "/") diff --git a/vibecrafted-core/tests/test_vc_frame_tab_gc.py b/vibecrafted-core/tests/test_vc_frame_tab_gc.py index 40f5e8b6..35a02a3b 100644 --- a/vibecrafted-core/tests/test_vc_frame_tab_gc.py +++ b/vibecrafted-core/tests/test_vc_frame_tab_gc.py @@ -15,6 +15,7 @@ from vibecrafted_core.runtime_transcript import write_runtime_transcript_manifest from vibecrafted_core.vc_frame_tab_gc import ( BUCKET_SESSIONS, + PROTECTED_TAB_NAMES, LiveTab, close_tab, collect_cleanup, @@ -30,6 +31,10 @@ VIEWER_TOKEN = "a" * 32 +def test_product_workspace_tabs_are_never_gc_candidates() -> None: + assert PROTECTED_TAB_NAMES == {"Start here", "Agents", "Shell", "voc"} + + def _write_json(path: Path, payload: Any) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text( diff --git a/vibecrafted-core/tests/test_workflow.py b/vibecrafted-core/tests/test_workflow.py index dd570002..5ee86191 100644 --- a/vibecrafted-core/tests/test_workflow.py +++ b/vibecrafted-core/tests/test_workflow.py @@ -12,7 +12,7 @@ from datetime import datetime, timezone from pathlib import Path from types import SimpleNamespace -from typing import Any +from typing import Any, Self import pytest from vibecrafted_core import control_plane, process_control, spawn, trust, workflow @@ -54,6 +54,45 @@ time.sleep(float(os.environ.get("NATIVE_RESUME_TEST_HOLD", "0"))) """ +_LAUNCH_RETRY_CHILD_SCRIPT = r""" +import json +import os +import sys + +from vibecrafted_core import workflow + +real_popen = workflow.subprocess.Popen +workflow._sweep_stale_runs = lambda: None +workflow._stdin_command = lambda _agent: [sys.executable, "-c", "pass"] +workflow._resolve_agent_command = lambda _agent, command, _env: list(command) +workflow.open_live_viewer = lambda **_kwargs: {"status": "skipped"} + +if os.environ["LAUNCH_RETRY_MODE"] == "spawn": + def fake_popen(*args, **kwargs): + if kwargs.get("start_new_session"): + return real_popen( + [sys.executable, "-c", "import time; time.sleep(30)"], + cwd=kwargs.get("cwd"), + env=kwargs.get("env"), + stdout=kwargs.get("stdout"), + stderr=kwargs.get("stderr"), + start_new_session=True, + text=True, + ) + return real_popen(*args, **kwargs) + workflow.subprocess.Popen = fake_popen + +root = os.environ["LAUNCH_RETRY_ROOT"] +spec = workflow.normalize_launch_spec( + {"skill": "workflow", "agent": "claude", "prompt": "cross process"}, + root, +) +result = workflow.launch_workflow(spec, root) +receipt = workflow.machine_launch_receipt(result) +receipt["launcher_pid"] = result.get("pid") +print(json.dumps(receipt), flush=True) +""" + def _source_dir(tmp_path: Path) -> Path: root = tmp_path / "src" @@ -245,6 +284,819 @@ def test_launch_workflow_returns_pid_and_logs_spawn( } +class _AliveProc: + pid = 4242 + + def wait(self) -> int: + return 0 + + def __enter__(self) -> Self: + return self + + def __exit__(self, *_exc: object) -> None: + return None + + +def _patch_launch_popen( + monkeypatch: pytest.MonkeyPatch, + pops: list[object], + *, + boom: bool = False, +) -> None: + real_popen = subprocess.Popen + + def fake_popen(*args: Any, **kwargs: Any) -> Any: + if kwargs.get("start_new_session"): + pops.append(1) + if boom: + raise OSError("dispatcher vanished after reservation") + return _AliveProc() + return real_popen(*args, **kwargs) + + monkeypatch.setattr(workflow.subprocess, "Popen", fake_popen) + + +def _write_reserved_launch_record( + spec: workflow.WorkflowLaunchSpec, + *, + run_id: str, + owner_identity: dict[str, Any], +) -> tuple[str, Path]: + key = workflow.launch_idempotency_key(spec) + workflow._write_launch_idempotency_record( + key, + { + "run_id": run_id, + "agent": spec.agent, + "skill": spec.skill, + "root": spec.root, + "state": "reserved", + "accepted": False, + "owner_pid": owner_identity.get("pid"), + "owner_identity": owner_identity, + "spec_digest": workflow._launch_spec_digest(spec), + "receipt": { + "run_id": run_id, + "agent": spec.agent, + "skill": spec.skill, + "root": spec.root, + "accepted": False, + "status": "reserved", + }, + }, + ) + return key, workflow._launch_idempotency_path(key) + + +def test_independent_identical_launches_create_distinct_runs_by_default( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("VIBECRAFTED_HOME", str(tmp_path / ".vibecrafted")) + source = _source_dir(tmp_path) + spec = workflow.normalize_launch_spec( + {"skill": "workflow", "agent": "claude", "prompt": "same brief"}, + source, + ) + pops: list[object] = [] + _patch_launch_popen(monkeypatch, pops) + monkeypatch.setattr( + workflow, + "_stdin_command", + lambda _agent: [sys.executable, "-c", "pass"], + ) + + first = workflow.launch_workflow(spec, source) + second = workflow.launch_workflow(spec, source) + + assert first["accepted"] is True + assert first["run_id"] + assert len(pops) == 2 + assert second["run_id"] != first["run_id"] + assert second.get("replayed") is not True + assert second["accepted"] is True + assert not second.get("idempotency_key") + + +def test_explicit_run_ids_never_collapse_identical_launches( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("VIBECRAFTED_HOME", str(tmp_path / ".vibecrafted")) + source = _source_dir(tmp_path) + base = { + "skill": "workflow", + "agent": "claude", + "prompt": "same brief", + } + first_spec = workflow.normalize_launch_spec( + {**base, "run_id": "job-alpha-001"}, source + ) + second_spec = workflow.normalize_launch_spec( + {**base, "run_id": "job-beta-002"}, source + ) + pops: list[object] = [] + _patch_launch_popen(monkeypatch, pops) + monkeypatch.setattr( + workflow, + "_stdin_command", + lambda _agent: [sys.executable, "-c", "pass"], + ) + + first = workflow.launch_workflow(first_spec, source) + second = workflow.launch_workflow(second_spec, source) + + assert first["run_id"] == "job-alpha-001" + assert second["run_id"] == "job-beta-002" + assert len(pops) == 2 + + +def test_same_explicit_run_id_retries_once_and_conflicting_spec_fails_closed( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("VIBECRAFTED_HOME", str(tmp_path / ".vibecrafted")) + monkeypatch.setattr(_AliveProc, "pid", os.getpid()) + monkeypatch.setattr( + workflow, + "validate_process_identity", + lambda *_args, **_kwargs: (True, "process_identity_current", None), + ) + source = _source_dir(tmp_path) + spec = workflow.normalize_launch_spec( + { + "skill": "workflow", + "agent": "claude", + "prompt": "same brief", + "run_id": "job-stable-001", + }, + source, + ) + conflict = workflow.normalize_launch_spec( + { + "skill": "workflow", + "agent": "claude", + "prompt": "different brief", + "run_id": "job-stable-001", + }, + source, + ) + pops: list[object] = [] + _patch_launch_popen(monkeypatch, pops) + monkeypatch.setattr( + workflow, + "_stdin_command", + lambda _agent: [sys.executable, "-c", "pass"], + ) + + first = workflow.launch_workflow(spec, source) + retry = workflow.launch_workflow(spec, source) + + assert first["accepted"] is True + assert retry["accepted"] is True, retry + assert retry["run_id"] == first["run_id"] == "job-stable-001" + assert retry["replayed"] is True + assert len(pops) == 1 + with pytest.raises(ValueError, match="idempotency identity conflicts"): + workflow.launch_workflow(conflict, source) + + +def test_transport_env_argument_supplies_canonical_retry_identity( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("VIBECRAFTED_HOME", str(tmp_path / ".vibecrafted")) + monkeypatch.delenv(workflow.LAUNCH_IDEMPOTENCY_KEY_ENV, raising=False) + monkeypatch.setattr(_AliveProc, "pid", os.getpid()) + monkeypatch.setattr( + workflow, + "validate_process_identity", + lambda *_args, **_kwargs: (True, "process_identity_current", None), + ) + source = _source_dir(tmp_path) + spec = workflow.normalize_launch_spec( + {"skill": "workflow", "agent": "claude", "prompt": "same brief"}, source + ) + transport_env = {workflow.LAUNCH_IDEMPOTENCY_KEY_ENV: "supervisor-attempt-1"} + pops: list[object] = [] + _patch_launch_popen(monkeypatch, pops) + monkeypatch.setattr( + workflow, + "_stdin_command", + lambda _agent: [sys.executable, "-c", "pass"], + ) + + first = workflow.launch_workflow(spec, source, env=transport_env) + retry = workflow.launch_workflow(spec, source, env=transport_env) + + assert first["accepted"] is True + assert retry["accepted"] is True + assert retry["run_id"] == first["run_id"] + assert retry["replayed"] is True + assert len(pops) == 1 + + +@pytest.mark.parametrize( + "stale_reason", + [ + "process_identity_gone", + "process_identity_mismatch", + "process_run_id_mismatch", + ], +) +def test_reserved_record_reclaims_only_proven_stale_owner( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, stale_reason: str +) -> None: + monkeypatch.setenv("VIBECRAFTED_HOME", str(tmp_path / ".vibecrafted")) + monkeypatch.setenv(workflow.LAUNCH_IDEMPOTENCY_KEY_ENV, "transport-attempt-1") + source = _source_dir(tmp_path) + spec = workflow.normalize_launch_spec( + {"skill": "workflow", "agent": "claude", "prompt": "same brief"}, source + ) + key = workflow.launch_idempotency_key(spec) + workflow._write_launch_idempotency_record( + key, + { + "run_id": "reserved-run-1", + "agent": spec.agent, + "skill": spec.skill, + "root": spec.root, + "state": "reserved", + "accepted": False, + "owner_pid": 424242, + "owner_identity": { + "pid": 424242, + "pgid": 424242, + "start_token": "original-owner", + "command_sha256": "a" * 64, + "run_id": "reserved-run-1", + }, + "spec_digest": workflow._launch_spec_digest(spec), + "receipt": {"accepted": False, "status": "reserved"}, + }, + ) + monkeypatch.setattr( + workflow, + "validate_process_identity", + lambda *_args, **_kwargs: (False, stale_reason, None), + ) + monkeypatch.setattr( + workflow, + "_stdin_command", + lambda _agent: [sys.executable, "-c", "pass"], + ) + pops: list[object] = [] + _patch_launch_popen(monkeypatch, pops) + + result = workflow.launch_workflow(spec, source) + + assert result["accepted"] is True + assert result["run_id"] == "reserved-run-1" + assert len(pops) == 1 + + +@pytest.mark.parametrize( + "ambiguous_reason", + [ + "process_identity_permission_ambiguous", + "process_identity_unavailable", + "process_identity_unreadable", + "process_identity_unknown", + ], +) +def test_ambiguous_reservation_owner_refuses_without_spawn_or_receipt_success( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ambiguous_reason: str, +) -> None: + from vibecrafted_core.cli import _emit_launch_result + + monkeypatch.setenv("VIBECRAFTED_HOME", str(tmp_path / ".vibecrafted")) + monkeypatch.setenv(workflow.LAUNCH_IDEMPOTENCY_KEY_ENV, "ambiguous-owner") + source = _source_dir(tmp_path) + spec = workflow.normalize_launch_spec( + {"skill": "workflow", "agent": "claude", "prompt": "same brief"}, source + ) + key = workflow.launch_idempotency_key(spec) + workflow._write_launch_idempotency_record( + key, + { + "run_id": "ambiguous-reserved-run", + "agent": spec.agent, + "skill": spec.skill, + "root": spec.root, + "state": "reserved", + "accepted": False, + "owner_pid": 424242, + "owner_identity": { + "pid": 424242, + "pgid": 424242, + "start_token": "unreadable-owner", + "command_sha256": "a" * 64, + "run_id": "ambiguous-reserved-run", + }, + "spec_digest": workflow._launch_spec_digest(spec), + "receipt": { + "run_id": "ambiguous-reserved-run", + "agent": spec.agent, + "skill": spec.skill, + "root": spec.root, + "accepted": False, + "status": "reserved", + }, + }, + ) + record_path = workflow._launch_idempotency_path(key) + reservation_before = record_path.read_bytes() + monkeypatch.setattr(workflow, "_sweep_stale_runs", lambda: None) + monkeypatch.setattr(workflow, "lookup_run", lambda _run_id: None) + monkeypatch.setattr( + workflow, + "validate_process_identity", + lambda *_args, **_kwargs: (False, ambiguous_reason, None), + ) + pops: list[object] = [] + _patch_launch_popen(monkeypatch, pops) + + result = workflow.launch_workflow(spec, source) + + assert result["accepted"] is False + assert result["status"] == "retryable_reservation_owner_ambiguous" + assert result["reason"] == ambiguous_reason + assert result["retryable"] is True + assert result["run_id"] == "ambiguous-reserved-run" + assert pops == [] + assert record_path.read_bytes() == reservation_before + assert workflow.machine_launch_receipt(result)["accepted"] is False + assert _emit_launch_result(result, json_mode=True) == 1 + emitted = json.loads(capsys.readouterr().out) + assert emitted["accepted"] is False + assert emitted["status"] == "retryable_reservation_owner_ambiguous" + + +@pytest.mark.parametrize( + "malform", + [ + lambda receipt: receipt.pop("start_token"), + lambda receipt: receipt.pop("run_id"), + lambda receipt: receipt.__setitem__("command_sha256", "g" * 64), + ], + ids=["missing-start-token", "missing-run-id", "invalid-command-hash"], +) +def test_malformed_reservation_owner_receipt_fails_closed_on_real_launch_path( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + malform: Any, +) -> None: + monkeypatch.setenv("VIBECRAFTED_HOME", str(tmp_path / ".vibecrafted")) + monkeypatch.setenv(workflow.LAUNCH_IDEMPOTENCY_KEY_ENV, "malformed-owner") + source = _source_dir(tmp_path) + spec = workflow.normalize_launch_spec( + {"skill": "workflow", "agent": "claude", "prompt": "same brief"}, source + ) + run_id = "malformed-reserved-run" + owner_identity = process_control.process_identity_receipt( + os.getpid(), run_id=run_id + ) + assert owner_identity is not None + malform(owner_identity) + _key, record_path = _write_reserved_launch_record( + spec, + run_id=run_id, + owner_identity=owner_identity, + ) + reservation_before = record_path.read_bytes() + monkeypatch.setattr(workflow, "_sweep_stale_runs", lambda: None) + monkeypatch.setattr(workflow, "lookup_run", lambda _run_id: None) + pops: list[object] = [] + _patch_launch_popen(monkeypatch, pops) + + result = workflow.launch_workflow(spec, source) + + assert result["accepted"] is False + assert result["status"] == "retryable_reservation_owner_ambiguous" + assert result["reason"] == "process_identity_receipt_invalid" + assert result["retryable"] is True + assert result["run_id"] == run_id + assert pops == [] + assert record_path.read_bytes() == reservation_before + + +def test_complete_reservation_owner_receipt_with_recaptured_mismatch_is_reclaimed( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("VIBECRAFTED_HOME", str(tmp_path / ".vibecrafted")) + monkeypatch.setenv(workflow.LAUNCH_IDEMPOTENCY_KEY_ENV, "reused-owner-pid") + source = _source_dir(tmp_path) + spec = workflow.normalize_launch_spec( + {"skill": "workflow", "agent": "claude", "prompt": "same brief"}, source + ) + run_id = "reused-owner-run" + owner_identity = process_control.process_identity_receipt( + os.getpid(), run_id=run_id + ) + assert owner_identity is not None + owner_identity["command_sha256"] = "0" * 64 + _write_reserved_launch_record( + spec, + run_id=run_id, + owner_identity=owner_identity, + ) + monkeypatch.setattr(workflow, "_sweep_stale_runs", lambda: None) + monkeypatch.setattr(workflow, "lookup_run", lambda _run_id: None) + monkeypatch.setattr( + workflow, + "_stdin_command", + lambda _agent: [sys.executable, "-c", "pass"], + ) + pops: list[object] = [] + _patch_launch_popen(monkeypatch, pops) + + result = workflow.launch_workflow(spec, source) + + assert result["accepted"] is True + assert result["run_id"] == run_id + assert pops == [1] + + +def test_phantom_dispatched_record_returns_retryable_fail_closed_receipt( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("VIBECRAFTED_HOME", str(tmp_path / ".vibecrafted")) + monkeypatch.setenv(workflow.LAUNCH_IDEMPOTENCY_KEY_ENV, "transport-attempt-2") + source = _source_dir(tmp_path) + spec = workflow.normalize_launch_spec( + {"skill": "workflow", "agent": "claude", "prompt": "same brief"}, source + ) + key = workflow.launch_idempotency_key(spec) + workflow._write_launch_idempotency_record( + key, + { + "run_id": "phantom-run-1", + "agent": spec.agent, + "skill": spec.skill, + "root": spec.root, + "state": "dispatched", + "accepted": True, + "spec_digest": workflow._launch_spec_digest(spec), + "receipt": {"accepted": True, "status": "launching"}, + }, + ) + monkeypatch.setattr(workflow, "lookup_run", lambda _run_id: None) + pops: list[object] = [] + _patch_launch_popen(monkeypatch, pops) + + result = workflow.launch_workflow(spec, source) + + assert result["accepted"] is False + assert result["status"] == "retryable_unknown_run" + assert result["retryable"] is True + assert len(pops) == 0 + + +def test_live_reservation_contention_is_retryable_but_never_accepted( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("VIBECRAFTED_HOME", str(tmp_path / ".vibecrafted")) + monkeypatch.setenv(workflow.LAUNCH_IDEMPOTENCY_KEY_ENV, "transport-contention") + source = _source_dir(tmp_path) + spec = workflow.normalize_launch_spec( + {"skill": "workflow", "agent": "claude", "prompt": "same brief"}, source + ) + key = workflow.launch_idempotency_key(spec) + workflow._write_launch_idempotency_record( + key, + { + "run_id": "reserved-live-1", + "agent": spec.agent, + "skill": spec.skill, + "root": spec.root, + "state": "reserved", + "accepted": False, + "owner_pid": os.getpid(), + "owner_identity": {"pid": os.getpid(), "pgid": os.getpgrp()}, + "spec_digest": workflow._launch_spec_digest(spec), + "receipt": {"accepted": False, "status": "reserved"}, + }, + ) + monkeypatch.setattr( + workflow, + "validate_process_identity", + lambda *_args, **_kwargs: (True, "process_identity_current", None), + ) + + result = workflow.launch_workflow(spec, source) + + assert result["accepted"] is False + assert result["status"] == "reservation_in_progress" + assert result["retryable"] is True + + +def test_crash_after_reservation_recovers_only_a_nonaccepted_receipt( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("VIBECRAFTED_HOME", str(tmp_path / ".vibecrafted")) + monkeypatch.setenv(workflow.LAUNCH_IDEMPOTENCY_KEY_ENV, "transport-crash") + source = _source_dir(tmp_path) + spec = workflow.normalize_launch_spec( + {"skill": "workflow", "agent": "claude", "prompt": "same brief"}, source + ) + monkeypatch.setattr( + workflow, + "validate_process_identity", + lambda *_args, **_kwargs: (True, "process_identity_current", None), + ) + monkeypatch.setattr( + workflow, + "build_launch_command", + lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("pre-spawn crash")), + ) + + with pytest.raises(OSError, match="pre-spawn crash"): + workflow.launch_workflow(spec, source) + recovered = workflow.recover_launch_receipt(spec) + + assert recovered is not None + assert recovered["accepted"] is False + assert recovered["status"] == "reservation_in_progress" + assert recovered["run_id"] + + +def test_stale_reservation_recovers_canonical_live_run_without_respawn( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("VIBECRAFTED_HOME", str(tmp_path / ".vibecrafted")) + monkeypatch.setenv(workflow.LAUNCH_IDEMPOTENCY_KEY_ENV, "post-spawn-crash") + source = _source_dir(tmp_path) + spec = workflow.normalize_launch_spec( + {"skill": "workflow", "agent": "claude", "prompt": "same brief"}, source + ) + key = workflow.launch_idempotency_key(spec) + workflow._write_launch_idempotency_record( + key, + { + "run_id": "canonical-after-crash", + "agent": spec.agent, + "skill": spec.skill, + "root": spec.root, + "state": "reserved", + "accepted": False, + "owner_pid": 1, + "owner_identity": {"pid": 1, "pgid": 1}, + "spec_digest": workflow._launch_spec_digest(spec), + "receipt": {"accepted": False, "status": "reserved"}, + }, + ) + monkeypatch.setattr( + workflow, + "lookup_run", + lambda _run_id: { + "run_id": "canonical-after-crash", + "state": "running", + "agent": spec.agent, + "skill": spec.skill, + "root": spec.root, + "launcher_pid": os.getpid(), + "launcher_identity": {"pid": os.getpid(), "pgid": os.getpgrp()}, + }, + ) + identity_checks = iter( + [ + (False, "process_identity_gone", None), + (True, "process_identity_current", None), + ] + ) + monkeypatch.setattr( + workflow, + "validate_process_identity", + lambda *_args, **_kwargs: next(identity_checks), + ) + pops: list[object] = [] + _patch_launch_popen(monkeypatch, pops) + + result = workflow.launch_workflow(spec, source) + + assert result["accepted"] is True + assert result["run_id"] == "canonical-after-crash" + assert result["replayed"] is True + assert result["recovered"] is True + assert pops == [] + assert workflow._read_launch_idempotency_record(key)["state"] == "dispatched" + + +def test_legacy_unbound_idempotency_record_fails_closed( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("VIBECRAFTED_HOME", str(tmp_path / ".vibecrafted")) + monkeypatch.setenv(workflow.LAUNCH_IDEMPOTENCY_KEY_ENV, "legacy-unbound") + source = _source_dir(tmp_path) + spec = workflow.normalize_launch_spec( + {"skill": "workflow", "agent": "claude", "prompt": "same brief"}, source + ) + workflow._write_launch_idempotency_record( + workflow.launch_idempotency_key(spec), + { + "run_id": "legacy-unbound-run", + "state": "dispatched", + "accepted": True, + "receipt": {"accepted": True, "status": "launching"}, + }, + ) + + with pytest.raises(ValueError, match="idempotency identity conflicts"): + workflow.launch_workflow(spec, source) + + +def test_explicit_transport_retry_replays_across_processes( + tmp_path: Path, +) -> None: + source = _source_dir(tmp_path) + env = os.environ.copy() + env.update( + { + "VIBECRAFTED_HOME": str(tmp_path / ".vibecrafted"), + "VIBECRAFTED_GUARD": "0", + workflow.LAUNCH_IDEMPOTENCY_KEY_ENV: "cross-process-attempt-1", + "LAUNCH_RETRY_ROOT": str(source), + "LAUNCH_RETRY_MODE": "spawn", + } + ) + + launcher_pid = 0 + try: + first = subprocess.run( + [sys.executable, "-c", _LAUNCH_RETRY_CHILD_SCRIPT], + env=env, + text=True, + capture_output=True, + check=True, + ) + first_receipt = json.loads(first.stdout) + launcher_pid = int(first_receipt.get("launcher_pid") or 0) + env["LAUNCH_RETRY_MODE"] = "retry" + second = subprocess.run( + [sys.executable, "-c", _LAUNCH_RETRY_CHILD_SCRIPT], + env=env, + text=True, + capture_output=True, + check=True, + ) + second_receipt = json.loads(second.stdout) + + assert first.stdout.count("\n") == 1 + assert second.stdout.count("\n") == 1 + assert first_receipt["accepted"] is True + assert second_receipt["accepted"] is True + assert second_receipt["run_id"] == first_receipt["run_id"] + assert second_receipt["replayed"] is True + finally: + if launcher_pid > 0: + try: + os.kill(launcher_pid, signal.SIGTERM) + except ProcessLookupError: + pass + + +def test_launch_registry_prunes_only_bounded_failed_or_terminal_history( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("VIBECRAFTED_HOME", str(tmp_path / ".vibecrafted")) + monkeypatch.setattr(workflow, "LAUNCH_IDEMPOTENCY_MAX_TERMINAL_RECORDS", 1) + monkeypatch.setattr(workflow, "LAUNCH_IDEMPOTENCY_TERMINAL_TTL_SECONDS", 10_000) + for key, state in (("failed-old", "failed"), ("failed-new", "failed")): + workflow._write_launch_idempotency_record( + key, + { + "run_id": key, + "state": state, + "accepted": False, + "receipt": {"accepted": False, "status": state}, + }, + ) + workflow._write_launch_idempotency_record( + "reserved-live-or-ambiguous", + { + "run_id": "reserved-run", + "state": "reserved", + "accepted": False, + "receipt": {"accepted": False, "status": "reserved"}, + }, + ) + now = time.time() + os.utime(workflow._launch_idempotency_path("failed-old"), (now - 20, now - 20)) + os.utime(workflow._launch_idempotency_path("failed-new"), (now - 10, now - 10)) + os.utime( + workflow._launch_idempotency_path("reserved-live-or-ambiguous"), + (now - 30, now - 30), + ) + + removed = workflow._prune_launch_idempotency_registry(now=now) + + assert removed == 1 + assert not workflow._launch_idempotency_path("failed-old").exists() + assert workflow._launch_idempotency_path("failed-new").exists() + assert workflow._launch_idempotency_path("reserved-live-or-ambiguous").exists() + + +def test_launch_registry_never_persists_prompt_text( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("VIBECRAFTED_HOME", str(tmp_path / ".vibecrafted")) + monkeypatch.setenv(workflow.LAUNCH_IDEMPOTENCY_KEY_ENV, "prompt-hygiene-attempt") + source = _source_dir(tmp_path) + secret_prompt = "do not persist this prompt marker 8a889795" + spec = workflow.normalize_launch_spec( + {"skill": "workflow", "agent": "claude", "prompt": secret_prompt}, source + ) + pops: list[object] = [] + _patch_launch_popen(monkeypatch, pops) + monkeypatch.setattr( + workflow, + "_stdin_command", + lambda _agent: [sys.executable, "-c", "pass"], + ) + + result = workflow.launch_workflow(spec, source) + + assert result["accepted"] is True + registry_text = "\n".join( + path.read_text(encoding="utf-8") + for path in workflow._launch_idempotency_registry().glob("*.json") + ) + assert secret_prompt not in registry_text + + +def test_spawn_exception_after_run_id_does_not_mint_sibling_while_live( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("VIBECRAFTED_HOME", str(tmp_path / ".vibecrafted")) + source = _source_dir(tmp_path) + spec = workflow.normalize_launch_spec( + {"skill": "workflow", "agent": "claude", "prompt": "same brief"}, + source, + ) + monkeypatch.setattr( + workflow, + "_stdin_command", + lambda _agent: [sys.executable, "-c", "pass"], + ) + pops: list[object] = [] + _patch_launch_popen(monkeypatch, pops, boom=True) + + first = workflow.launch_workflow(spec, source) + assert first["accepted"] is False + assert first["run_id"] + assert len(pops) == 1 + + _patch_launch_popen(monkeypatch, pops, boom=False) + second = workflow.launch_workflow(spec, source) + + # Failed launches are retryable; a later successful retry may mint a new + # run. The incident class is empty success of a *live* launch. + assert second["accepted"] is True + assert len(pops) == 2 + assert second["run_id"] != first["run_id"] + + +def test_viewer_exception_after_spawn_still_returns_receipt_for_retry( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("VIBECRAFTED_HOME", str(tmp_path / ".vibecrafted")) + monkeypatch.setenv( + workflow.LAUNCH_IDEMPOTENCY_KEY_ENV, "viewer-exception-transport-retry" + ) + monkeypatch.setattr(_AliveProc, "pid", os.getpid()) + monkeypatch.setattr( + workflow, + "validate_process_identity", + lambda *_args, **_kwargs: (True, "process_identity_current", None), + ) + source = _source_dir(tmp_path) + spec = workflow.normalize_launch_spec( + {"skill": "workflow", "agent": "claude", "prompt": "same brief"}, + source, + ) + pops: list[object] = [] + _patch_launch_popen(monkeypatch, pops) + monkeypatch.setattr( + workflow, + "_stdin_command", + lambda _agent: [sys.executable, "-c", "pass"], + ) + + def boom_viewer(**_kwargs: Any) -> dict[str, Any]: + raise RuntimeError("live viewer exploded") + + monkeypatch.setattr(workflow, "open_live_viewer", boom_viewer) + + first = workflow.launch_workflow(spec, source) + second = workflow.launch_workflow(spec, source) + + assert first["accepted"] is True + assert first["run_id"] + assert first["live_viewer"]["status"] == "failed" + assert len(pops) == 1 + assert second["run_id"] == first["run_id"] + assert second.get("replayed") is True + + def test_launch_workflow_preseeds_machine_owned_claim_digest( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: @@ -1260,7 +2112,7 @@ def test_claude_terminal_command_streams_visible_json(tmp_path: Path) -> None: assert command[:4] == ["claude", "-p", "--output-format", "stream-json"] assert "--verbose" in command - assert "--dangerously-skip-permissions" in command + assert command[-2:] == ["--permission-mode", "bypassPermissions"] def test_stream_capable_agents_use_native_stream_commands(tmp_path: Path) -> None: diff --git a/vibecrafted-core/vibecrafted_core/VERSION b/vibecrafted-core/vibecrafted_core/VERSION index cf78d5b6..80895903 100644 --- a/vibecrafted-core/vibecrafted_core/VERSION +++ b/vibecrafted-core/vibecrafted_core/VERSION @@ -1 +1 @@ -4.2.4 +4.3.0 diff --git a/vibecrafted-core/vibecrafted_core/cli.py b/vibecrafted-core/vibecrafted_core/cli.py index 282020a0..cdb2fa29 100644 --- a/vibecrafted-core/vibecrafted_core/cli.py +++ b/vibecrafted-core/vibecrafted_core/cli.py @@ -34,6 +34,7 @@ manual_resume_session, normalize_launch_spec, operator_continue_run, + recover_launch_receipt, ) AGENTS = {"claude", "codex", "agy", "junie", "grok", "swarm"} @@ -250,12 +251,12 @@ def _build_parser() -> argparse.ArgumentParser: capabilities.add_argument("--json", action="store_true") config = sub.add_parser( "config", - help="install/wire packaged vc-frame config into the tools store and ~/.config/vc-frame", + help="install/wire packaged vc-frame config into ~/.config/vibecrafted/vc-frame", ) config_sub = config.add_subparsers(dest="config_action") config_install = config_sub.add_parser( "install", - help="stage package config → tools store + wire ~/.config/vc-frame view", + help="stage package config → wire ~/.config/vibecrafted/vc-frame view", ) config_install.add_argument( "--dry-run", @@ -568,6 +569,56 @@ def _print_launch_receipt(payload: dict[str, Any]) -> None: print("=====================================================================") +def _emit_launch_result(result: dict[str, Any], *, json_mode: bool) -> int: + """Write exactly one launch receipt to stdout. Never exit 0 on empty stdout. + + Diagnostics go to stderr. A run that already mutated control-plane state + must still emit ``run_id`` so a retry can resolve it instead of guessing. + """ + from .workflow import _json_plain, machine_launch_receipt + + receipt = machine_launch_receipt(result) + run_id = str(receipt.get("run_id") or "") + if json_mode: + payload = _json_plain(result) + if not isinstance(payload, dict): + payload = {} + payload.update(receipt) + try: + text = json.dumps(payload, ensure_ascii=False, indent=2) + except (TypeError, ValueError): + text = json.dumps(receipt, ensure_ascii=False, indent=2) + if not str(text).strip(): + print("error: launch produced an empty receipt", file=sys.stderr) + if run_id: + print(f"run_id: {run_id}", file=sys.stderr) + return _EX_TEMPFAIL if run_id else 1 + try: + sys.stdout.write(text if text.endswith("\n") else f"{text}\n") + sys.stdout.flush() + except BrokenPipeError: + print( + f"error: stdout closed after launch; run_id={run_id or 'unknown'}", + file=sys.stderr, + ) + return _EX_TEMPFAIL if run_id else 1 + else: + try: + _print_launch_receipt(result) + sys.stdout.flush() + except BrokenPipeError: + print( + f"error: stdout closed after launch; run_id={run_id or 'unknown'}", + file=sys.stderr, + ) + return _EX_TEMPFAIL if run_id else 1 + _watch_launch_startup(result) + if receipt["accepted"] and not run_id: + print("error: accepted launch missing run_id", file=sys.stderr) + return 1 + return 0 if receipt["accepted"] else 1 + + # Parity contract with the shell launcher's `spawn_watch_startup` # (runtime/scripts/lib/launcher_watch.sh): same markers, same short window. # The core dispatch path carried no such guard, so a fresh machine's first @@ -1656,13 +1707,27 @@ def main(argv: Sequence[str] | None = None) -> int: command=str(args.command), agent=args.agent, message=str(exc) ) return 2 - result = launch_workflow(spec, source_dir) - if args.json: - print(json.dumps(result, ensure_ascii=False, indent=2)) - else: - _print_launch_receipt(result) - _watch_launch_startup(result) - return 0 if result.get("accepted") else 1 + try: + result = launch_workflow(spec, source_dir) + except (OSError, TypeError, ValueError, RuntimeError) as exc: + print( + f"error: launch raised {type(exc).__name__}: {exc}", + file=sys.stderr, + ) + recovered = recover_launch_receipt(spec) + if recovered and recovered.get("run_id"): + result = recovered + else: + result = { + "accepted": False, + "run_id": "", + "agent": spec.agent, + "skill": spec.skill, + "root": spec.root, + "status": "failed", + "error": f"{type(exc).__name__}: {exc}", + } + return _emit_launch_result(result, json_mode=bool(args.json)) if __name__ == "__main__": # pragma: no cover diff --git a/vibecrafted-core/vibecrafted_core/config/vc-frame/config.kdl b/vibecrafted-core/vibecrafted_core/config/vc-frame/config.kdl index 073e62cd..3ef45ed7 100644 --- a/vibecrafted-core/vibecrafted_core/config/vc-frame/config.kdl +++ b/vibecrafted-core/vibecrafted_core/config/vc-frame/config.kdl @@ -148,10 +148,9 @@ keybinds { }; } // Command Composer (spec 1.2 §A): Super+e / Cmd+E. Alt+e unbound so - // macOS Option types Polish `ę`. Prefers installed vc-composer.sh on - // frontier (install must project it — never leave a STALE-FILE there). + // macOS Option types Polish `ę`. Uses the one installed product config. bind "Super e" { - Run "sh" "-c" "if [ -x \"${HOME}/.config/vetcoders/frontier/vc-frame/vc-composer.sh\" ]; then \"${HOME}/.config/vetcoders/frontier/vc-frame/vc-composer.sh\"; elif [ -x \"${HOME}/.config/vc-frame/vc-composer.sh\" ]; then \"${HOME}/.config/vc-frame/vc-composer.sh\"; else f=$(mktemp \"${TMPDIR:-/tmp}/vc-composer.XXXXXX\") || exit 1; ${EDITOR:-vim} -c 'set number' -c 'set laststatus=0' -c 'set nowrap' -c 'set textwidth=0' -c 'set sidescroll=1' -c 'nnoremap :set wrap! wrap?' \"$f\"; if [ -s \"$f\" ]; then vc-frame action toggle-floating-panes; vc-frame action write-chars \"$(cat \"$f\")\"; fi; rm -f -- \"$f\"; fi" { + Run "sh" "-c" "if [ -x \"${XDG_CONFIG_HOME:-$HOME/.config}/vibecrafted/vc-frame/vc-composer.sh\" ]; then \"${XDG_CONFIG_HOME:-$HOME/.config}/vibecrafted/vc-frame/vc-composer.sh\"; else f=$(mktemp \"${TMPDIR:-/tmp}/vc-composer.XXXXXX\") || exit 1; ${EDITOR:-vim} -c 'set number' -c 'set laststatus=0' -c 'set nowrap' -c 'set textwidth=0' -c 'set sidescroll=1' -c 'nnoremap :set wrap! wrap?' \"$f\"; if [ -s \"$f\" ]; then vc-frame action toggle-floating-panes; vc-frame action write-chars \"$(cat \"$f\")\"; fi; rm -f -- \"$f\"; fi" { floating true close_on_exit true name "✍ Composer · ⧉ Paste stack" @@ -197,7 +196,7 @@ keybinds { // Was Ctrl+Shift+c (dead without CSI-u); relocated to pane mode as 'y' (yank). pane { bind "y" { - Run "bash" "-lc" "$HOME/.config/vetcoders/frontier/vc-frame/copy-scrollback.sh" { + Run "bash" "-lc" "${XDG_CONFIG_HOME:-$HOME/.config}/vibecrafted/vc-frame/copy-scrollback.sh" { close_on_exit true floating true name "copy-pane-scrollback" @@ -255,7 +254,7 @@ keybinds { // block merges into the built-in scroll mode). scroll { bind "y" { - Run "bash" "-lc" "$HOME/.config/vetcoders/frontier/vc-frame/copy-scrollback.sh" { + Run "bash" "-lc" "${XDG_CONFIG_HOME:-$HOME/.config}/vibecrafted/vc-frame/copy-scrollback.sh" { close_on_exit true floating true name "copy-scrollback" diff --git a/vibecrafted-core/vibecrafted_core/config/vc-frame/copy-scrollback.sh b/vibecrafted-core/vibecrafted_core/config/vc-frame/copy-scrollback.sh index c18f9f10..19a11b43 100644 --- a/vibecrafted-core/vibecrafted_core/config/vc-frame/copy-scrollback.sh +++ b/vibecrafted-core/vibecrafted_core/config/vc-frame/copy-scrollback.sh @@ -36,8 +36,7 @@ fi if [[ -s "$tmp" ]]; then paste_stack="" for candidate in \ - "${HOME}/.config/vetcoders/frontier/vc-frame/paste-stack.sh" \ - "${HOME}/.config/vc-frame/paste-stack.sh" \ + "${XDG_CONFIG_HOME:-$HOME/.config}/vibecrafted/vc-frame/paste-stack.sh" \ "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/paste-stack.sh" do if [[ -x "$candidate" ]]; then diff --git a/vibecrafted-core/vibecrafted_core/config/vc-frame/layouts/operator.kdl b/vibecrafted-core/vibecrafted_core/config/vc-frame/layouts/operator.kdl index afc863b3..0262d068 100644 --- a/vibecrafted-core/vibecrafted_core/config/vc-frame/layouts/operator.kdl +++ b/vibecrafted-core/vibecrafted_core/config/vc-frame/layouts/operator.kdl @@ -5,8 +5,10 @@ // // // Tabs: -// "Start here" — first-run map (about mission-control): chrome, rail, keys +// "Start here" — first-run product map +// "Agents" — one workspace, interactive Agent faces and New agent card // "Shell" — work shell with banner + zsh (not a silent suspended pane) +// "voc" — eyes for the current workspace, not a launcher // // Contract: every tab keeps the left Sessions rail (default_tab_template). // @@ -35,18 +37,27 @@ layout { } tab name="Start here" focus=true { - pane name="Start here" { - plugin location="about" { - guide_mode "mission-control" - pane_title "Start here — map of this workspace" - } + pane command="bash" name="Start Here" { + args "-lc" "root=\"${VC_FRAME_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/vibecrafted/vc-frame}\"; launcher=\"$root/vc-start-here.py\"; if [ -x \"$launcher\" ]; then exec \"$launcher\"; fi; printf '\\n START HERE\\n\\n Launcher missing: %s\\n Run: vibecrafted config install --force\\n\\n' \"$launcher\"; exec \"${SHELL:-/bin/zsh}\" -l" + } + } + + tab name="Agents" { + pane command="bash" name="Agent Workspaces" { + args "-lc" "root=\"${VC_FRAME_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/vibecrafted/vc-frame}\"; launcher=\"$root/vc-agent-workshop.py\"; if [ -x \"$launcher\" ]; then exec \"$launcher\" home; fi; printf '\\n AGENT WORKSPACES\\n\\n Launcher missing: %s\\n Run: vibecrafted config install --force\\n\\n' \"$launcher\"; exec \"${SHELL:-/bin/zsh}\" -l" } } tab name="Shell" hide_floating_panes=true { // Banner then interactive zsh — wakes immediately (no suspended Enter void). pane command="bash" name="Shell" { - args "-lc" "printf '\\n VIBECRAFTED · Shell\\n\\n Your work shell — the Guide lives on tab 1.\\n\\n First commands:\\n vibecrafted start open / attach operator flow\\n vibecrafted help list CLI surfaces\\n\\n Chrome, always on screen:\\n left rail sessions / agent rooms — click a name to jump\\n top row tabs of this session: Start here | Shell\\n Ctrl+t 1 back to the Guide\\n\\n'; exec zsh -l" + args "-lc" "printf '\\n VIBECRAFTED · Shell\\n\\n Your work shell — Agent Workspaces lives on the Agents tab.\\n\\n First commands:\\n vibecrafted start open / attach workspace\\n vibecrafted help list CLI surfaces\\n\\n Chrome, always on screen:\\n left rail workspaces / Agent rooms — click a name to jump\\n top row Start here | Agents | Shell | voc\\n Ctrl+t 1 back to Start here\\n\\n'; exec zsh -l" + } + } + + tab name="voc" hide_floating_panes=true { + pane command="bash" name="voc" { + args "-lc" "if command -v voc >/dev/null 2>&1; then exec voc; elif command -v vibecrafted >/dev/null 2>&1; then exec vibecrafted tui; fi; printf '\\n voc is not installed in this Runtime Pack.\\n\\n'; exec \"${SHELL:-/bin/zsh}\" -l" } } diff --git a/vibecrafted-core/vibecrafted_core/config/vc-frame/scrollback-select.sh b/vibecrafted-core/vibecrafted_core/config/vc-frame/scrollback-select.sh index 88ddd47f..61bafeeb 100755 --- a/vibecrafted-core/vibecrafted_core/config/vc-frame/scrollback-select.sh +++ b/vibecrafted-core/vibecrafted_core/config/vc-frame/scrollback-select.sh @@ -16,8 +16,7 @@ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" PASTE_STACK="" for candidate in \ "${SCRIPT_DIR}/paste-stack.sh" \ - "${HOME}/.config/vetcoders/frontier/vc-frame/paste-stack.sh" \ - "${HOME}/.config/vc-frame/paste-stack.sh" + "${XDG_CONFIG_HOME:-$HOME/.config}/vibecrafted/vc-frame/paste-stack.sh" do if [[ -x "$candidate" ]]; then PASTE_STACK="$candidate" diff --git a/vibecrafted-core/vibecrafted_core/config/vc-frame/vc-agent-workshop.py b/vibecrafted-core/vibecrafted_core/config/vc-frame/vc-agent-workshop.py new file mode 100755 index 00000000..f492c906 --- /dev/null +++ b/vibecrafted-core/vibecrafted_core/config/vc-frame/vc-agent-workshop.py @@ -0,0 +1,715 @@ +#!/usr/bin/env python3 +"""Agent Workspaces dashboard and interactive Agent launcher. + +This is deliberately a terminal surface, not a second control plane. vc-frame +owns the panes, Vibecrafted owns the launch command, and the User chooses which +interactive Agent is born in the current workspace. +""" + +from __future__ import annotations + +import argparse +import curses +import json +import os +import shutil +import subprocess +import sys +import time +from pathlib import Path +from typing import Any + +from vibecrafted_core.spawn import ( + CONTINUITY_MODES, + OPERATOR_POLICIES, + PERMISSION_POLICIES, + RUNTIME_POLICIES, + continuity_policy_capabilities, + resolve_operator_agent_policy, + resolve_provider_policy, + runtime_policy_capabilities, +) + +AGENTS = ("agy", "claude", "codex", "grok", "junie") +# The accepted design leaves operator/partner unresolved. Do not expose them +# until their CLI contracts can guarantee an interactive TTY on this tab. +RITUALS = ("init", "resume") +RUNTIME_HELP = { + "local-native": ( + "Direct selected checkout; no isolation; full disk scope per provider permissions.", + "Shared checkout, no worktrees — for deliberate control.", + ), + "local-worktrees": ( + "Safe recommended local default; one canonical worktree per Agent launch.", + "Maximum local concurrency; unattended pipelines require an Operator Agent via --operator auto or claude.", + ), + "local-vm": ( + "Coming in H2b3; disabled until selected-workspace container launch and live proof exist.", + "", + ), + "cloud-soon": ("Coming soon; disabled.", ""), +} + + +def launch_argv( + agent: str, + ritual: str, + runtime: str = "local-native", + permissions: str = "bypass", + operator: str = "none", + continuity: str = "fresh", + continuity_parent: str = "", +) -> list[str]: + """Return the one canonical interactive command for a launcher choice.""" + if agent not in AGENTS: + raise ValueError(f"unsupported agent: {agent}") + if ritual not in RITUALS: + raise ValueError(f"unsupported interactive ritual: {ritual}") + if continuity not in CONTINUITY_MODES: + raise ValueError(f"unsupported continuity policy: {continuity}") + if ritual == "init": + decision = resolve_provider_policy(agent, runtime, permissions, "interactive") + if not decision.supported: + raise ValueError(decision.reason) + if operator not in OPERATOR_POLICIES: + raise ValueError(f"unsupported Operator Agent policy: {operator}") + operator_decision = resolve_operator_agent_policy(operator, runtime=runtime) + if not operator_decision.supported: + raise ValueError(operator_decision.reason) + # `init` defaults to opening another vc-frame tab. The workshop's law + # is stricter: this exact floating panel becomes the Agent TTY. + command = [ + "vibecrafted", + "init", + agent, + "--runtime", + "plain", + "--policy-runtime", + runtime, + "--permissions", + permissions, + "--operator", + operator_decision.selection, + "--continuity", + continuity, + ] + if continuity == "bare-fork": + if not continuity_parent: + raise ValueError( + "bare-fork requires an explicit parent provider-session id" + ) + command.extend(["--parent-session", continuity_parent]) + elif continuity == "full-lineage" and continuity_parent: + command.extend(["--continuity-parent", continuity_parent]) + return command + if runtime != "local-native": + raise ValueError( + "worktree resume supervision belongs to H2b2 and is not configured yet" + ) + return ["vibecrafted", "resume", agent] + + +def normalized_workspace(raw: str, *, base: Path | None = None) -> Path: + """Resolve and validate the full workspace path entered by the User.""" + root = (base or Path.cwd()).expanduser() + candidate = Path(raw).expanduser() + if not candidate.is_absolute(): + candidate = root / candidate + candidate = candidate.resolve() + if not candidate.is_dir(): + raise ValueError(f"workspace does not exist: {candidate}") + return candidate + + +def _pane_rows(payload: Any) -> list[dict[str, Any]]: + if isinstance(payload, list): + return [item for item in payload if isinstance(item, dict)] + if isinstance(payload, dict): + for key in ("panes", "items", "data"): + value = payload.get(key) + if isinstance(value, list): + return [item for item in value if isinstance(item, dict)] + return [] + + +def agent_faces_from_payload(payload: Any) -> list[str]: + """Project vc-frame's pane JSON into human-facing Agents-tab faces.""" + faces: list[str] = [] + for pane in _pane_rows(payload): + if pane.get("is_plugin"): + continue + tab_name = str(pane.get("tab_name") or pane.get("tab") or "") + if tab_name and tab_name.casefold() != "agents": + continue + title = str( + pane.get("pane_title") or pane.get("title") or pane.get("name") or "" + ) + command = str(pane.get("command") or pane.get("pane_command") or "") + label = title.strip() or Path(command).name.strip() + if not label or label.casefold() in {"agent workspaces", "new agent"}: + continue + if label not in faces: + faces.append(label) + return faces + + +def current_faces() -> list[str]: + try: + result = subprocess.run( + [ + "vc-frame", + "action", + "list-panes", + "--json", + "--state", + "--tab", + "--command", + ], + check=False, + capture_output=True, + text=True, + timeout=1.5, + ) + if result.returncode != 0: + return [] + return agent_faces_from_payload(json.loads(result.stdout)) + except (FileNotFoundError, json.JSONDecodeError, subprocess.TimeoutExpired): + return [] + + +def _clip(text: str, width: int) -> str: + if width <= 0: + return "" + if len(text) <= width: + return text + return text[: max(0, width - 1)] + "…" + + +def _safe_addstr( + window: curses.window, row: int, col: int, text: str, attr: int = 0 +) -> None: + height, width = window.getmaxyx() + if row < 0 or row >= height or col < 0 or col >= width: + return + try: + window.addstr(row, col, _clip(text, width - col), attr) + except curses.error: + pass + + +def _dim_unavailable_choices( + window: curses.window, + row: int, + col: int, + choices: tuple[str, ...], + available: tuple[bool, ...], +) -> None: + """Redraw disabled choice tokens with terminal-native dim styling.""" + for choice, enabled in zip(choices, available, strict=True): + token = f"[{choice}]" if enabled else f"({choice})" + if not enabled: + _safe_addstr(window, row, col, token, curses.A_DIM) + col += len(token) + 1 + + +class Workshop: + def __init__(self, window: curses.window, *, mode: str) -> None: + self.window = window + self.mode = mode + self.home_choice = 0 + self.row = 0 + self.agent = 2 # codex is the least surprising neutral default here + self.ritual = 0 + self.runtime = 1 # safe recommended local default when the provider supports it + self.permissions = 0 + self.continuity = 0 + self.continuity_parent = "" + self.path = str(Path.cwd()) + self.error = "" + self.mouse_targets: list[tuple[int, int, int, int, str]] = [] + self.last_faces_at = 0.0 + self.faces: list[str] = [] + + def configure(self) -> None: + curses.curs_set(0) + curses.noecho() + curses.cbreak() + self.window.keypad(True) + self.window.timeout(500) + self._normalize_runtime_choice() + self._normalize_continuity_choice() + try: + curses.mousemask(curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION) + except curses.error: + pass + + def run(self) -> None: + self.configure() + while True: + self.draw() + key = self.window.getch() + if key == -1: + continue + if key == curses.KEY_RESIZE: + continue + if key == curses.KEY_MOUSE: + self.handle_mouse() + continue + if self.mode == "home": + self.handle_home_key(key) + else: + self.handle_launcher_key(key) + + def draw(self) -> None: + self.window.erase() + self.mouse_targets.clear() + if self.mode == "home": + self.draw_home() + else: + self.draw_launcher() + self.window.refresh() + + def draw_home(self) -> None: + height, width = self.window.getmaxyx() + left = max(2, (width - min(width - 4, 78)) // 2) + top = max(1, min(6, (height - 18) // 2)) + _safe_addstr(self.window, top, left, "AGENT WORKSPACES", curses.A_BOLD) + _safe_addstr( + self.window, + top + 2, + left, + "One workspace. Many interactive Agents. One shared context.", + ) + _safe_addstr(self.window, top + 4, left, "Workspace", curses.A_DIM) + _safe_addstr(self.window, top + 5, left, self.path, curses.A_BOLD) + + buttons = ("New agent", "voc") + col = left + for index, label in enumerate(buttons): + text = f"[ {label} ]" + attr = curses.A_REVERSE if index == self.home_choice else curses.A_BOLD + _safe_addstr(self.window, top + 7, col, text, attr) + self.mouse_targets.append((top + 7, col, col + len(text), index, "home")) + col += len(text) + 2 + + now = time.monotonic() + if now - self.last_faces_at > 2: + self.faces = current_faces() + self.last_faces_at = now + _safe_addstr( + self.window, + top + 10, + left, + f"Agents here ({len(self.faces)})", + curses.A_DIM, + ) + if self.faces: + for offset, face in enumerate(self.faces[: max(1, height - top - 14)]): + _safe_addstr(self.window, top + 11 + offset, left + 2, f"• {face}") + else: + _safe_addstr( + self.window, + top + 11, + left + 2, + "No Agent faces yet — New agent opens the first interactive TTY.", + curses.A_DIM, + ) + _safe_addstr( + self.window, + height - 2, + left, + "←/→ choose · Enter open · n New agent · v voc · PANE+arrows switch faces", + curses.A_DIM, + ) + if self.error: + _safe_addstr(self.window, height - 1, left, self.error, curses.A_BOLD) + + def draw_launcher(self) -> None: + height, width = self.window.getmaxyx() + card_width = min(max(58, width - 4), 92) + left = max(1, (width - card_width) // 2) + top = max(1, (height - 15) // 2) + inner = max(20, card_width - 4) + _safe_addstr( + self.window, + top, + left, + "┌ ❯ New agent " + "─" * max(1, card_width - 29) + " [Cancel] ┐", + ) + agent_line = " agent " + " ".join( + f"«{name}»" if index == self.agent else f"[{name}]" + for index, name in enumerate(AGENTS) + ) + ritual_line = " ritual " + " ".join( + f"«{name}»" if index == self.ritual else f"[{name}]" + for index, name in enumerate(RITUALS) + ) + provider = AGENTS[self.agent] + capabilities = runtime_policy_capabilities(provider) + runtime_line = " runtime " + " ".join( + (f"«{name}»" if index == self.runtime else f"[{name}]") + if capabilities[name]["available"] + else f"({name})" + for index, name in enumerate(RUNTIME_POLICIES) + ) + permission_line = " permits " + " ".join( + (f"«{name}»" if index == self.permissions else f"[{name}]") + if resolve_provider_policy( + provider, RUNTIME_POLICIES[self.runtime], name, "interactive" + ).supported + else f"({name})" + for index, name in enumerate(PERMISSION_POLICIES) + ) + continuity_caps = continuity_policy_capabilities( + provider, + root=self.path, + explicit_parent=self.continuity_parent, + ) + continuity_line = " memory " + " ".join( + (f"«{name}»" if index == self.continuity else f"[{name}]") + if continuity_caps[name]["available"] + else f"({name})" + for index, name in enumerate(CONTINUITY_MODES) + ) + rows = ( + agent_line, + ritual_line, + runtime_line, + permission_line, + continuity_line, + f" parent {self.continuity_parent or '(none)'}", + f" path {self.path}", + ) + for index, line in enumerate(rows): + attr = curses.A_REVERSE if index == self.row else 0 + _safe_addstr( + self.window, + top + index + 1, + left, + "│ " + + _clip(line, inner) + + " " * max(0, inner - len(_clip(line, inner))) + + " │", + attr, + ) + _dim_unavailable_choices( + self.window, + top + 3, + left + 2 + len(" runtime "), + RUNTIME_POLICIES, + tuple(bool(capabilities[name]["available"]) for name in RUNTIME_POLICIES), + ) + _dim_unavailable_choices( + self.window, + top + 5, + left + 2 + len(" memory "), + CONTINUITY_MODES, + tuple( + bool(continuity_caps[name]["available"]) for name in CONTINUITY_MODES + ), + ) + _dim_unavailable_choices( + self.window, + top + 4, + left + 2 + len(" permits "), + PERMISSION_POLICIES, + tuple( + resolve_provider_policy( + provider, + RUNTIME_POLICIES[self.runtime], + name, + "interactive", + ).supported + for name in PERMISSION_POLICIES + ), + ) + runtime_help = RUNTIME_HELP[RUNTIME_POLICIES[self.runtime]] + _safe_addstr( + self.window, + top + 8, + left, + ("│ " + _clip(runtime_help[0], inner)).ljust(card_width - 1) + "│", + curses.A_DIM, + ) + _safe_addstr( + self.window, + top + 9, + left, + ("│ " + _clip(runtime_help[1], inner)).ljust(card_width - 1) + "│", + curses.A_DIM, + ) + _safe_addstr( + self.window, + top + 10, + left, + "│ Enter = interactive TTY on this Agents tab".ljust(card_width - 1) + "│", + curses.A_DIM, + ) + _safe_addstr( + self.window, + top + 11, + left, + "└─ ↑/↓ row · ←/→ choice · type path · Enter launch · Esc cancel " + + "─" * max(0, card_width - 67) + + "┘", + ) + unavailable = [ + f"{name}: {capabilities[name]['reason']}" + for name in RUNTIME_POLICIES + if not capabilities[name]["available"] + ] + _safe_addstr( + self.window, + top + 12, + left, + "Unavailable — " + + " · ".join( + unavailable + + [ + f"{name}: {continuity_caps[name]['reason']}" + for name in CONTINUITY_MODES + if not continuity_caps[name]["available"] + ] + ), + curses.A_DIM, + ) + if self.error: + _safe_addstr( + self.window, min(height - 1, top + 13), left, self.error, curses.A_BOLD + ) + + def handle_home_key(self, key: int) -> None: + if key in (curses.KEY_LEFT, ord("h")): + self.home_choice = (self.home_choice - 1) % 2 + elif key in (curses.KEY_RIGHT, ord("l"), ord("\t")): + self.home_choice = (self.home_choice + 1) % 2 + elif key in (ord("n"), ord("N")): + self.open_launcher() + elif key in (ord("v"), ord("V")): + self.open_voc() + elif key in (10, 13, curses.KEY_ENTER): + (self.open_launcher, self.open_voc)[self.home_choice]() + + def handle_launcher_key(self, key: int) -> None: + self.error = "" + if key == 27: + raise SystemExit(0) + if key == curses.KEY_UP: + self.row = (self.row - 1) % 7 + return + if key in (curses.KEY_DOWN, ord("\t")): + self.row = (self.row + 1) % 7 + return + if key in (curses.KEY_LEFT, curses.KEY_RIGHT, ord(" ")): + delta = -1 if key == curses.KEY_LEFT else 1 + if self.row == 0: + self.agent = (self.agent + delta) % len(AGENTS) + self._normalize_runtime_choice() + self._normalize_permission_choice() + self._normalize_continuity_choice() + elif self.row == 1: + self.ritual = (self.ritual + delta) % len(RITUALS) + elif self.row == 2: + self._cycle_runtime(delta) + elif self.row == 3: + self._cycle_permissions(delta) + elif self.row == 4: + self._cycle_continuity(delta) + return + if key in (10, 13, curses.KEY_ENTER): + self.launch() + return + if self.row in (5, 6): + if key in (curses.KEY_BACKSPACE, 127, 8): + if self.row == 5: + self.continuity_parent = self.continuity_parent[:-1] + else: + self.path = self.path[:-1] + elif 32 <= key <= 126: + if self.row == 5: + self.continuity_parent += chr(key) + else: + self.path += chr(key) + + def _cycle_runtime(self, delta: int) -> None: + capabilities = runtime_policy_capabilities(AGENTS[self.agent]) + for _ in RUNTIME_POLICIES: + self.runtime = (self.runtime + delta) % len(RUNTIME_POLICIES) + name = RUNTIME_POLICIES[self.runtime] + if capabilities[name]["available"]: + return + self.error = "No runtime is available for this provider" + + def _cycle_permissions(self, delta: int) -> None: + provider = AGENTS[self.agent] + runtime = RUNTIME_POLICIES[self.runtime] + for _ in PERMISSION_POLICIES: + self.permissions = (self.permissions + delta) % len(PERMISSION_POLICIES) + if resolve_provider_policy( + provider, runtime, PERMISSION_POLICIES[self.permissions], "interactive" + ).supported: + return + self.error = "No permission policy is available for this provider/runtime" + + def _normalize_runtime_choice(self) -> None: + capabilities = runtime_policy_capabilities(AGENTS[self.agent]) + current = RUNTIME_POLICIES[self.runtime] + if capabilities[current]["available"]: + return + for index, runtime in enumerate(RUNTIME_POLICIES): + if capabilities[runtime]["available"]: + self.runtime = index + return + + def _normalize_permission_choice(self) -> None: + provider = AGENTS[self.agent] + runtime = RUNTIME_POLICIES[self.runtime] + current = PERMISSION_POLICIES[self.permissions] + if resolve_provider_policy(provider, runtime, current, "interactive").supported: + return + for index, permissions in enumerate(PERMISSION_POLICIES): + if resolve_provider_policy( + provider, runtime, permissions, "interactive" + ).supported: + self.permissions = index + return + + def _cycle_continuity(self, delta: int) -> None: + capabilities = continuity_policy_capabilities( + AGENTS[self.agent], root=self.path, explicit_parent=self.continuity_parent + ) + for _ in CONTINUITY_MODES: + self.continuity = (self.continuity + delta) % len(CONTINUITY_MODES) + if capabilities[CONTINUITY_MODES[self.continuity]]["available"]: + return + self.error = "No continuity policy is currently materializable" + + def _normalize_continuity_choice(self) -> None: + capabilities = continuity_policy_capabilities( + AGENTS[self.agent], root=self.path, explicit_parent=self.continuity_parent + ) + if capabilities["full-lineage"]["available"]: + self.continuity = CONTINUITY_MODES.index("full-lineage") + else: + self.continuity = CONTINUITY_MODES.index("fresh") + + def handle_mouse(self) -> None: + try: + _, x, y, _, state = curses.getmouse() + except curses.error: + return + if not state: + return + for row, start, end, index, kind in self.mouse_targets: + if kind == "home" and y == row and start <= x < end: + self.home_choice = index + (self.open_launcher, self.open_voc)[index]() + return + + def open_launcher(self) -> None: + script = str(Path(__file__).resolve()) + command = [ + "vc-frame", + "action", + "new-pane", + "--floating", + "--name", + "New agent", + "--width", + "72%", + "--height", + "32%", + "--cwd", + self.path, + "--", + sys.executable, + script, + "launcher", + ] + try: + result = subprocess.run( + command, check=False, capture_output=True, text=True + ) + except FileNotFoundError: + self.error = "vc-frame is not available in this Runtime Pack" + return + if result.returncode != 0: + self.error = ( + result.stderr or result.stdout or "cannot open launcher" + ).strip() + + def open_voc(self) -> None: + try: + result = subprocess.run( + ["vc-frame", "action", "go-to-tab-name", "voc"], + check=False, + capture_output=True, + text=True, + ) + except FileNotFoundError: + self.error = "vc-frame is not available in this Runtime Pack" + return + if result.returncode != 0: + self.error = ( + result.stderr or result.stdout or "voc tab is unavailable" + ).strip() + + def launch(self) -> None: + try: + workspace = normalized_workspace(self.path) + runtime_name = RUNTIME_POLICIES[self.runtime] + capability = runtime_policy_capabilities(AGENTS[self.agent])[runtime_name] + if not capability["available"]: + raise ValueError(str(capability["reason"])) + continuity_name = CONTINUITY_MODES[self.continuity] + continuity_capability = continuity_policy_capabilities( + AGENTS[self.agent], + root=workspace, + explicit_parent=self.continuity_parent, + )[continuity_name] + if not continuity_capability["available"]: + raise ValueError(str(continuity_capability["reason"])) + argv = launch_argv( + AGENTS[self.agent], + RITUALS[self.ritual], + runtime_name, + PERMISSION_POLICIES[self.permissions], + continuity=continuity_name, + continuity_parent=self.continuity_parent, + ) + except ValueError as exc: + self.error = str(exc) + return + executable = shutil.which(argv[0]) + if executable is None: + self.error = "vibecrafted launcher is missing from PATH" + return + title = f"{AGENTS[self.agent]} · {RITUALS[self.ritual]} · {workspace.name}" + subprocess.run( + ["vc-frame", "action", "rename-pane", title], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + curses.endwin() + os.chdir(workspace) + os.execvpe(executable, argv, os.environ.copy()) + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Vibecrafted Agent Workspaces") + parser.add_argument("mode", choices=("home", "launcher"), nargs="?", default="home") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + try: + curses.wrapper(lambda window: Workshop(window, mode=args.mode).run()) + except KeyboardInterrupt: + return 130 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vibecrafted-core/vibecrafted_core/config/vc-frame/vc-composer.sh b/vibecrafted-core/vibecrafted_core/config/vc-frame/vc-composer.sh index 0c316f47..ea4411c0 100755 --- a/vibecrafted-core/vibecrafted_core/config/vc-frame/vc-composer.sh +++ b/vibecrafted-core/vibecrafted_core/config/vc-frame/vc-composer.sh @@ -18,9 +18,7 @@ # the number of -c / +cmd arguments (~10) and dies with: # Too many "+command", "-c command" or "--cmd command" arguments # -# Install/symlink to: -# ~/.config/vetcoders/frontier/vc-frame/vc-composer.sh -# or ~/.config/vc-frame/vc-composer.sh +# Installed at: $XDG_CONFIG_HOME/vibecrafted/vc-frame/vc-composer.sh set -euo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" @@ -29,8 +27,7 @@ resolve_tool() { local candidate for candidate in \ "${SCRIPT_DIR}/${name}" \ - "${HOME}/.config/vetcoders/frontier/vc-frame/${name}" \ - "${HOME}/.config/vc-frame/${name}" + "${XDG_CONFIG_HOME:-$HOME/.config}/vibecrafted/vc-frame/${name}" do if [[ -x "$candidate" ]]; then printf '%s\n' "$candidate" diff --git a/vibecrafted-core/vibecrafted_core/config/vc-frame/vc-start-here.py b/vibecrafted-core/vibecrafted_core/config/vc-frame/vc-start-here.py new file mode 100755 index 00000000..a07995b7 --- /dev/null +++ b/vibecrafted-core/vibecrafted_core/config/vc-frame/vc-start-here.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +"""Actionable first screen for the shipped Vibecrafted workspace. + +This pane is a view and launcher only. vc-frame owns navigation, the native app +owns VC Console, and the existing Vibecrafted deck owns diagnostics and server +truth. +""" + +from __future__ import annotations + +import curses +import json +import shutil +import subprocess +from typing import Any + +PRODUCT_LINE = ( + "Vibecrafted is a workspace where you start and coordinate AI Agents " + "that do real work with runtime continuity and visible proof." +) + +ACTIONS = ( + ("Agent Workspaces", "Start or resume an Agent in the current workspace", "agents"), + ("Shell", "Open the installed work shell", "shell"), + ("VC Console", "Open native run status and reports", "console"), + ("Help & diagnostics", "Check this installed runtime and its owner", "help"), +) + + +def action_argv(action: str) -> list[str]: + """Return the existing product owner command for a Start Here action.""" + if action == "agents": + return ["vc-frame", "action", "go-to-tab-name", "Agents"] + if action == "shell": + return ["vc-frame", "action", "go-to-tab-name", "Shell"] + if action == "console": + return ["/usr/bin/open", "vibecrafted://console/open"] + if action == "help": + return [ + "vc-frame", + "action", + "new-pane", + "--floating", + "--name", + "Vibecrafted Help & diagnostics", + "--width", + "72%", + "--height", + "70%", + "--", + "bash", + "-lc", + "vibecrafted doctor; printf '\\nPress Enter to close diagnostics…'; read -r _", + ] + raise ValueError(f"unknown Start Here action: {action}") + + +def readiness_from_service_payload( + payload: Any, + *, + deck_available: bool = True, + frame_available: bool = True, +) -> tuple[str, str]: + """Project canonical service JSON into one concise first-run readiness line.""" + if not deck_available: + return "missing", "Vibecrafted launcher is missing — reinstall the Runtime Pack" + if not frame_available: + return "missing", "vc-frame is missing — reinstall the Runtime Pack" + if not isinstance(payload, dict): + return "attention", "VC Server status is unavailable — open Help & diagnostics" + if not payload.get("installed"): + return "attention", "VC Server is not installed — open Help & diagnostics" + if not payload.get("loaded"): + return ( + "stopped", + "VC Server is stopped — use the Vibecrafted menu bar to start it", + ) + healthy = all( + bool(payload.get(key)) + for key in ( + "supervisor_live", + "supervisor_verified", + "supervisor_service_managed", + "build_current", + "pair_healthy", + ) + ) + if healthy: + return "ready", "VC Server is healthy — this workspace is ready" + return "attention", "VC Server needs attention — open Help & diagnostics" + + +def probe_readiness() -> tuple[str, str]: + deck = shutil.which("vibecrafted") + frame = shutil.which("vc-frame") + if deck is None or frame is None: + return readiness_from_service_payload( + None, deck_available=deck is not None, frame_available=frame is not None + ) + try: + result = subprocess.run( + [deck, "server", "service", "status", "--json"], + check=False, + capture_output=True, + text=True, + timeout=2.0, + ) + return readiness_from_service_payload(json.loads(result.stdout)) + except (OSError, json.JSONDecodeError, subprocess.TimeoutExpired): + return readiness_from_service_payload(None) + + +def action_for_mouse_row( + y: int, targets: list[tuple[int, int, int, str]], x: int +) -> str | None: + for row, left, right, action in targets: + if y == row and left <= x <= right: + return action + return None + + +def _clip(text: str, width: int) -> str: + if width <= 0: + return "" + return text if len(text) <= width else text[: max(0, width - 1)] + "…" + + +def _put(window: curses.window, row: int, col: int, text: str, attr: int = 0) -> None: + height, width = window.getmaxyx() + if not (0 <= row < height and 0 <= col < width): + return + try: + window.addstr(row, col, _clip(text, width - col), attr) + except curses.error: + pass + + +class StartHere: + def __init__(self, window: curses.window) -> None: + self.window = window + self.selected = 0 + self.readiness = probe_readiness() + self.error = "" + self.targets: list[tuple[int, int, int, str]] = [] + + def configure(self) -> None: + curses.curs_set(0) + curses.noecho() + curses.cbreak() + self.window.keypad(True) + try: + curses.mousemask(curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION) + except curses.error: + pass + + def run(self) -> None: + self.configure() + while True: + self.draw() + key = self.window.getch() + if key in (ord("q"), 27): + return + if key in (curses.KEY_UP, ord("k")): + self.selected = (self.selected - 1) % len(ACTIONS) + elif key in (curses.KEY_DOWN, ord("j"), 9): + self.selected = (self.selected + 1) % len(ACTIONS) + elif key in (10, 13, curses.KEY_ENTER): + self.activate(ACTIONS[self.selected][2]) + elif key == ord("r"): + self.readiness = probe_readiness() + self.error = "" + elif ord("1") <= key <= ord(str(len(ACTIONS))): + self.selected = key - ord("1") + self.activate(ACTIONS[self.selected][2]) + elif key == curses.KEY_MOUSE: + self.handle_mouse() + + def draw(self) -> None: + self.window.erase() + self.targets.clear() + height, width = self.window.getmaxyx() + canvas = min(82, max(40, width - 4)) + left = max(2, (width - canvas) // 2) + top = max(1, min(5, (height - 24) // 2)) + _put(self.window, top, left, "START HERE", curses.A_BOLD) + _put(self.window, top + 2, left, PRODUCT_LINE) + state, message = self.readiness + readiness_attr = curses.A_BOLD if state == "ready" else curses.A_DIM + _put(self.window, top + 5, left, f"RUNTIME · {message}", readiness_attr) + _put(self.window, top + 7, left, "Choose where to begin:", curses.A_BOLD) + first = top + 9 + for index, (title, detail, action) in enumerate(ACTIONS): + row = first + index * 3 + marker = "▶" if index == self.selected else " " + label = f"{marker} [{index + 1}] {title}" + attr = ( + curses.A_REVERSE | curses.A_BOLD + if index == self.selected + else curses.A_BOLD + ) + _put(self.window, row, left, label, attr) + _put(self.window, row + 1, left + 6, detail, curses.A_DIM) + self.targets.append((row, left, left + len(label), action)) + self.targets.append((row + 1, left, left + canvas, action)) + footer = first + len(ACTIONS) * 3 + 1 + _put( + self.window, + footer, + left, + "↑↓ / j k select Enter open click open r refresh q close", + curses.A_DIM, + ) + if self.error: + _put(self.window, footer + 2, left, self.error, curses.A_BOLD) + self.window.refresh() + + def activate(self, action: str) -> None: + try: + result = subprocess.run( + action_argv(action), + check=False, + capture_output=True, + text=True, + timeout=8.0, + ) + if result.returncode != 0: + self.error = (result.stderr or result.stdout).strip() or ( + f"{action} exited {result.returncode}" + ) + else: + self.error = "" + except (OSError, subprocess.TimeoutExpired) as error: + self.error = f"Could not open {action}: {error}" + + def handle_mouse(self) -> None: + try: + _, x, y, _, button = curses.getmouse() + except curses.error: + return + if not button & (curses.BUTTON1_CLICKED | curses.BUTTON1_RELEASED): + return + action = action_for_mouse_row(y, self.targets, x) + if action is None: + return + self.selected = [item[2] for item in ACTIONS].index(action) + self.activate(action) + + +def main() -> int: + curses.wrapper(lambda window: StartHere(window).run()) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vibecrafted-core/vibecrafted_core/control_plane.py b/vibecrafted-core/vibecrafted_core/control_plane.py index 20c6657b..699b6a6f 100644 --- a/vibecrafted-core/vibecrafted_core/control_plane.py +++ b/vibecrafted-core/vibecrafted_core/control_plane.py @@ -82,6 +82,7 @@ "contract_failed", "recovery_required", "timed_out", + "quota_exhausted", "gc", "ghost", } @@ -1073,6 +1074,35 @@ def _reconcile_dead_launcher(run: dict[str, Any]) -> dict[str, Any]: liveness = str(result.get("liveness") or "") if state in FINAL_STATES: return result + owner_pid = _coerce_int(result.get("owner_pid")) + if owner_pid is not None: + owner_alive = _pid_is_alive(owner_pid) + provider_alive = any( + _pid_is_alive(pid) + for pid in ( + _coerce_int(result.get("worker_pid")), + _coerce_int(result.get("worker_pgid")), + ) + if pid is not None + ) + if owner_alive and provider_alive: + return result + now = _now().isoformat() + result["state"] = "failed" + result["health"] = "final" + result["liveness"] = "pid_gone" + result["completed_at"] = str(result.get("completed_at") or now) + result["updated_at"] = now + result["exit_code"] = _coerce_int(result.get("exit_code")) or 1 + result["terminal_reason"] = ( + "owner_pid_gone" if not owner_alive else "provider_pid_gone" + ) + result["recovery_required"] = True + result["last_error"] = _append_last_error( + str(result.get("last_error") or ""), + "interactive lifecycle owner/provider identity is no longer live", + ) + return result # P0: a dead/absent launcher pid is NOT proof the run died. The launcher is an # ephemeral spawn-shell that exits right after forking the detached dispatcher; # for headless/detached dispatch it is gone within seconds while the dispatcher @@ -1358,11 +1388,16 @@ def _worker_is_alive(run: dict[str, Any]) -> bool: a run is never marked recovery_required merely because the ephemeral launcher pid died while the worker keeps running and delivering. """ + provider_alive = False for key in ("worker_pid", "worker_pgid"): pid = _coerce_int(run.get(key)) if pid is not None and _pid_is_alive(pid): - return True - return False + provider_alive = True + break + owner_pid = _coerce_int(run.get("owner_pid")) + if owner_pid is not None: + return provider_alive and _pid_is_alive(owner_pid) + return provider_alive def _await_process_is_alive(run: dict[str, Any]) -> bool: @@ -1801,10 +1836,14 @@ def _normalize_agent_meta(path: Path) -> RunStatus | None: "native_resume", "resume_idempotency_key", "worker_command", + "owner_pid", + "owner_identity", "worker_pid", "worker_pgid", "worker_identity", "launcher_identity", + "terminal_reason", + "exit_signal", "heartbeat_at", "meta", "artifact_ok", @@ -1824,6 +1863,15 @@ def _normalize_agent_meta(path: Path) -> RunStatus | None: "workspace_display_label", "worker_host_session", "worker_host_display", + # H2b2c — typed Operator Agent -> child Agent relationship truth. + "role", + "prompt_role", + "provider_session_id", + "operator_policy", + "supervision", + "stop_actor_run_id", + # H2b2d — bounded typed continuity receipt (never prompt bodies). + "continuity", ): if key in payload and payload.get(key) not in (None, ""): extra[key] = payload[key] @@ -2065,6 +2113,8 @@ def _merge_event_stream( "resume_settlement_revision", "resume_trust_receipt_id", "worker_command", + "owner_pid", + "owner_identity", "worker_pid", "worker_pgid", "worker_identity", @@ -2086,6 +2136,8 @@ def _merge_event_stream( "stop_already_dead", "stop_alive_after_grace", "stop_grace_seconds", + "terminal_reason", + "exit_signal", # Durable workspace identity must survive event-stream refreshes; # otherwise a later generic `state` event erases the identity # carried by lifecycle:created/active. diff --git a/vibecrafted-core/vibecrafted_core/deck/vibecrafted b/vibecrafted-core/vibecrafted_core/deck/vibecrafted index f1ab0cc2..88099061 100755 --- a/vibecrafted-core/vibecrafted_core/deck/vibecrafted +++ b/vibecrafted-core/vibecrafted_core/deck/vibecrafted @@ -8,6 +8,7 @@ _vc_source_launcher_ulimits() { script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" for candidate in \ "$script_dir/../runtime/scripts/lib/ulimits.sh" \ + "$script_dir/../vibecrafted-core/vibecrafted_core/runtime/scripts/lib/ulimits.sh" \ "${VIBECRAFTED_ROOT:-}/vibecrafted-core/vibecrafted_core/runtime/scripts/lib/ulimits.sh" \ "${VIBECRAFTED_TOOLS_HOME:-${XDG_DATA_HOME:-$HOME/.local/share}/vibecrafted/tools}/vibecrafted-current/vibecrafted-core/vibecrafted_core/runtime/scripts/lib/ulimits.sh" \ "${VIBECRAFTED_HOME:-$HOME/.vibecrafted}/runtime/scripts/lib/ulimits.sh"; do @@ -112,6 +113,20 @@ _script_owner_root() { cd "$script_dir/.." && pwd } +_runtime_payload_root() { + local candidate="${VIBECRAFTED_RUNTIME_ROOT:-}" + if [[ -n "$candidate" && -x "$candidate/bin/vc-server" && -d "$candidate/server/site" ]]; then + printf '%s\n' "$candidate" + return 0 + fi + candidate="$(_script_owner_root)" + if [[ -x "$candidate/bin/vc-server" && -d "$candidate/server/site" ]]; then + printf '%s\n' "$candidate" + return 0 + fi + return 1 +} + _package_resource_root() { local script_root candidate script_root="$(_script_repo_root)" @@ -446,9 +461,31 @@ _dispatcher_core_dir() { # Resolve the interpreter that owns vibecrafted_core by reading the shebang # from the uv-tool shim on PATH, since it carries the correct uv python. +# An inherited VIBECRAFTED_PYTHON is honoured only when it lives inside the +# generation this deck serves (vibecrafted-current). A product session pins it +# to the release generation, whose bin/python3 wrapper overwrites PYTHONPATH +# with its own vibecrafted-core; running this generation's core through it +# loads a foreign core + installer and yields phantom doctor findings. +_vibecrafted_python_owned_by_current() { + local candidate="$1" candidate_real current_real deck_real + [[ -d "$crafted_tools" ]] || return 0 + current_real="$(cd "$crafted_tools" 2>/dev/null && pwd -P)" || return 1 + # Only an *installed* deck (living inside vibecrafted-current) is exposed to + # a foreign product-session interpreter; a checkout deck keeps honouring an + # explicit VIBECRAFTED_PYTHON (dev runs, tests). + # ~/.local/bin/vibecrafted is a symlink into the generation: resolve the + # deck file itself, not the directory the symlink sits in. + deck_real="$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null)" || return 0 + deck_real="$(cd "$(dirname "$deck_real")" 2>/dev/null && pwd -P)" || return 0 + [[ "$deck_real" == "$current_real" || "$deck_real" == "$current_real/"* ]] || return 0 + candidate_real="$(cd "$(dirname "$candidate")" 2>/dev/null && pwd -P)/$(basename "$candidate")" || return 1 + [[ "$candidate_real" == "$current_real/"* ]] +} + _vibecrafted_python() { local resolved shebang py - if [[ -n "${VIBECRAFTED_PYTHON:-}" && -x "$VIBECRAFTED_PYTHON" ]]; then + if [[ -n "${VIBECRAFTED_PYTHON:-}" && -x "$VIBECRAFTED_PYTHON" ]] \ + && _vibecrafted_python_owned_by_current "$VIBECRAFTED_PYTHON"; then printf '%s\n' "$VIBECRAFTED_PYTHON" return 0 fi @@ -979,16 +1016,25 @@ cmd_init_help() { printf ' Start an interactive repository orientation session with an agent.\n' printf '\n' printf '%bUsage:%b\n' "$_bold" "$_reset" - printf ' vibecrafted init [claude|codex|agy|junie|grok] [--runtime terminal|visible|plain] [--prompt ""]\n' + printf ' vibecrafted init [claude|codex|agy|junie|grok] [--runtime terminal|visible|plain] [--policy-runtime local-native|local-worktrees|local-vm|cloud-soon] [--permissions bypass|auto|accept-edits|read-only] [--token-budget safe|unlimited|N] [--continuity full-lineage|fresh|bare-fork] [--prompt ""]\n' printf ' vc-init [claude|codex|agy|junie|grok]\n' printf '\n' printf '%bRuntime:%b\n' "$_bold" "$_reset" printf ' terminal (default) open a tab in the vc-frame cockpit; falls back to plain when vc-frame is absent\n' printf ' plain start the agent in this terminal, no cockpit needed\n' + printf ' Policy runtime defaults to local-native; unavailable runtimes fail closed.\n' + printf ' Permission support is provider-specific; unsupported cells are rejected.\n' + printf ' Token budget defaults to safe (250000 measured tokens); N sets a bounded budget.\n' + printf ' unlimited is restricted to directly observed local-native sessions and still measures usage.\n' + printf ' Measured quota currently requires a verified Claude transcript capability; unsupported providers fail closed.\n' + printf ' Continuity defaults to fresh in the shell. full-lineage requires materialized AICX + active LOOP + parent lineage.\n' + printf ' bare-fork is expert-only and requires --parent-session .\n' printf '\n' printf '%bExamples:%b\n' "$_bold" "$_reset" printf ' vibecrafted init claude\n' printf ' vibecrafted init claude --runtime plain\n' + printf ' vibecrafted init claude --runtime plain --policy-runtime local-native --permissions accept-edits\n' + printf ' vibecrafted init claude --continuity full-lineage --continuity-parent \n' printf ' vc-init codex\n' printf '\n' } @@ -1065,7 +1111,7 @@ cmd_server_help() { printf '\n' printf '%bUsage:%b\n' "$_bold" "$_reset" printf ' vibecrafted server [start|stop|status|open|doctor] [options]\n' - printf ' vibecrafted server service [install|start|stop|status|uninstall] [options]\n' + printf ' vibecrafted server service [install|reconcile|restart|start|stop|status|logs|uninstall] [options]\n' printf '\n' printf '%bOptions:%b\n' "$_bold" "$_reset" printf ' --port, -p Specify the port (default: 3024)\n' @@ -1077,6 +1123,7 @@ cmd_server_help() { printf ' vibecrafted server service start\n' printf ' vibecrafted server status\n' printf ' vibecrafted server service stop\n' + printf ' vibecrafted server service logs --json\n' printf '\n' } @@ -3510,6 +3557,14 @@ cmd_server() { fi IFS=$'\t' read -r host port <<< "$endpoint" + local runtime_root server_bin site_root + runtime_root="$(_runtime_payload_root 2>/dev/null || true)" + server_bin="${runtime_root:+$runtime_root/bin/vc-server}" + site_root="${runtime_root:+$runtime_root/server/site}" + [[ -n "$server_bin" ]] || server_bin="$HOME/.local/bin/vc-server" + [[ -n "$site_root" ]] || \ + site_root="${VIBECRAFTED_RUNTIME_HOME:-$HOME/.local/share/vibecrafted}/server/site" + # 1. Check if already running local runtime_dir runtime_dir="$(_server_runtime_dir)" @@ -3520,10 +3575,10 @@ cmd_server() { existing_pid="$(_pid_file_value "$pid_file" 2>/dev/null || true)" existing_state="$(_managed_pid_state "$pid_file" "server")" if [[ "$existing_state" == "foreign" ]] && \ - [[ -x "$HOME/.local/bin/vc-server" ]] && \ + [[ -x "$server_bin" ]] && \ _adopt_launch_witness \ - server "$existing_pid" "$HOME/.local/bin/vc-server" \ - "$HOME/.local/bin/vc-server"; then + server "$existing_pid" "$server_bin" \ + "$server_bin"; then existing_state="$(_managed_pid_state "$pid_file" "server")" fi if [[ "$existing_state" == "live" ]]; then @@ -3549,8 +3604,8 @@ cmd_server() { if ! _stop_managed_process server Server; then if [[ -f "$(_server_launch_witness_file server)" ]] && \ _adopt_launch_witness \ - server "$existing_pid" "$HOME/.local/bin/vc-server" \ - "$HOME/.local/bin/vc-server"; then + server "$existing_pid" "$server_bin" \ + "$server_bin"; then _stop_managed_process server Server || return 1 else return 1 @@ -3585,13 +3640,11 @@ cmd_server() { fi # 3. Locate binary & site root - local server_bin="$HOME/.local/bin/vc-server" if [[ ! -x "$server_bin" ]]; then printf "%b✗%b vc-server binary not found at %s. Please run 'make install-all' or 'make install-server' first.\n" "$_red" "$_reset" "$server_bin" >&2 return 1 fi - local site_root="${VIBECRAFTED_RUNTIME_ROOT:-${VIBECRAFTED_RUNTIME_HOME:-$HOME/.local/share/vibecrafted}}/server/site" if [[ ! -d "$site_root" ]]; then printf "%b✗%b site assets directory not found at %s. Please run 'make install-all' or 'make install-server' first.\n" "$_red" "$_reset" "$site_root" >&2 return 1 @@ -3888,7 +3941,13 @@ cmd_server() { _server_supervisor_cli runtime-status || has_errors=1 # 1. Binary check - local server_bin="$HOME/.local/bin/vc-server" + local runtime_root server_bin site_root + runtime_root="$(_runtime_payload_root 2>/dev/null || true)" + server_bin="${runtime_root:+$runtime_root/bin/vc-server}" + site_root="${runtime_root:+$runtime_root/server/site}" + [[ -n "$server_bin" ]] || server_bin="$HOME/.local/bin/vc-server" + [[ -n "$site_root" ]] || \ + site_root="${VIBECRAFTED_RUNTIME_HOME:-$HOME/.local/share/vibecrafted}/server/site" if [[ -f "$server_bin" ]]; then if [[ -x "$server_bin" ]]; then printf " [%b✓%b] Binary present and executable at %s\n" "$_green" "$_reset" "$server_bin" @@ -3916,7 +3975,6 @@ cmd_server() { fi # 2. Site root & Fonts check - local site_root="${VIBECRAFTED_RUNTIME_ROOT:-${VIBECRAFTED_RUNTIME_HOME:-$HOME/.local/share/vibecrafted}}/server/site" if [[ -d "$site_root" ]]; then printf " [%b✓%b] Site root present at %s\n" "$_green" "$_reset" "$site_root" if [[ -d "$site_root/fonts" ]]; then @@ -5068,10 +5126,11 @@ cmd_uninstall() { # Bypass make for the same reason as cmd_doctor: prevent the # `make: Entering directory` and `*** Error N` traceback from # bleeding into the user-facing uninstall transcript. - local source_root="" installer="" + local source_root="" installer="" installer_python="python3" source_root="$(_repo_source_root 2>/dev/null || true)" for candidate in \ "${source_root:+$source_root/scripts/vetcoders_install.py}" \ + "${VIBECRAFTED_RUNTIME_ROOT:+$VIBECRAFTED_RUNTIME_ROOT/scripts/vetcoders_install.py}" \ "$crafted_tools/scripts/vetcoders_install.py" \ "$(_repo_root 2>/dev/null)/scripts/vetcoders_install.py"; do [[ -n "$candidate" && -f "$candidate" ]] && installer="$candidate" && break @@ -5080,9 +5139,14 @@ cmd_uninstall() { printf '%b✗%b Uninstall installer not found.\n' "$_red" "$_reset" >&2 return 1 } + if [[ -n "${VIBECRAFTED_RUNTIME_ROOT:-}" \ + && "$installer" == "$VIBECRAFTED_RUNTIME_ROOT/scripts/vetcoders_install.py" \ + && -x "$VIBECRAFTED_RUNTIME_ROOT/bin/python3" ]]; then + installer_python="$VIBECRAFTED_RUNTIME_ROOT/bin/python3" + fi # Forward argv verbatim: swallowing flags here once turned # `vibecrafted uninstall --dry-run` into a real, unconfirmed teardown. - python3 "$installer" uninstall "$@" + "$installer_python" "$installer" uninstall "$@" } _fetch_channel_version() { diff --git a/vibecrafted-core/vibecrafted_core/dispatch/supervisor.py b/vibecrafted-core/vibecrafted_core/dispatch/supervisor.py index 1e088fa6..f8eb15a9 100644 --- a/vibecrafted-core/vibecrafted_core/dispatch/supervisor.py +++ b/vibecrafted-core/vibecrafted_core/dispatch/supervisor.py @@ -19,6 +19,7 @@ from vibecrafted_core.delivery.model import ExecutionEnvelope from vibecrafted_core.workflow import ( + LAUNCH_IDEMPOTENCY_KEY_ENV, WorkflowLaunchSpec, launch_workflow, reserve_run_id, @@ -115,7 +116,10 @@ def to_dict(self) -> dict[str, Any]: def workflow_cell_launcher( - dispatch: Dispatch, *, source_dir: str | Path | None = None + dispatch: Dispatch, + *, + source_dir: str | Path | None = None, + dispatch_run_id: str = "", ) -> CellLauncher: """Production launcher: every cell goes through the existing `launch_workflow` runtime — the dispatch layer never spawns its own @@ -145,6 +149,10 @@ def launch(cut: Cut, prompt: str, kind: str) -> CellRun: "VIBECRAFTED_DISPATCH_SCHEDULER_SLOT": str(cut.scheduler_slot), "VIBECRAFTED_DISPATCH_INTEGRATOR": str(cut.integrator).lower(), } + if dispatch_run_id: + runtime_env[LAUNCH_IDEMPOTENCY_KEY_ENV] = ( + f"dispatch:{dispatch_run_id}:cut:{cut.id}:attempt:{kind}" + ) if cut.target_path: runtime_env["CARGO_TARGET_DIR"] = cut.target_path result = launch_workflow(spec, base_dir, env=runtime_env) @@ -193,7 +201,9 @@ def __init__( ) self.worktrees = WorktreeManager(self.repo) if self.manage_worktrees else None self.launcher = launcher or workflow_cell_launcher( - dispatch, source_dir=source_dir + dispatch, + source_dir=source_dir, + dispatch_run_id=self.run_id, ) self._sleep = sleep self._io_lock = threading.RLock() diff --git a/vibecrafted-core/vibecrafted_core/dispatch/worktrees.py b/vibecrafted-core/vibecrafted_core/dispatch/worktrees.py index 89e051ed..c8031c49 100644 --- a/vibecrafted-core/vibecrafted_core/dispatch/worktrees.py +++ b/vibecrafted-core/vibecrafted_core/dispatch/worktrees.py @@ -140,6 +140,27 @@ def prepare( self._validate_target(root) return geometry + def prepare_agent_launch( + self, provider: str, launch_id: str, baseline_sha: str + ) -> WorktreeGeometry: + """Create one clean per-Agent interactive checkout through this owner.""" + observed_root = _git(self.main_repo, "rev-parse", "--show-toplevel") + if not observed_root or Path(observed_root).resolve() != self.main_repo: + raise WorktreeContractError( + f"selected workspace is not a git repository root: {self.main_repo}" + ) + observed_head = _git(self.main_repo, "rev-parse", "HEAD") + if not observed_head or observed_head != baseline_sha: + raise WorktreeContractError( + "selected workspace HEAD changed before worktree creation; retry the launch" + ) + dirty = _git(self.main_repo, "status", "--porcelain") + if dirty: + raise WorktreeContractError( + f"local worktrees require a clean selected workspace: {dirty}" + ) + return self.prepare(f"{provider}-{launch_id}", baseline_sha) + def validate(self, geometry: WorktreeGeometry) -> None: """Revalidate a receipt's geometry before launch or resume.""" if geometry.integrator_exclusive: diff --git a/vibecrafted-core/vibecrafted_core/doctor.py b/vibecrafted-core/vibecrafted_core/doctor.py index 1d43faba..8a5c5575 100644 --- a/vibecrafted-core/vibecrafted_core/doctor.py +++ b/vibecrafted-core/vibecrafted_core/doctor.py @@ -632,6 +632,41 @@ def _packaged_asset_findings() -> list[_Finding]: return findings +def _config_entry_matches(candidate: Path, expected: Path) -> bool: + """True only for an unaliased physical copy with identical closed contents.""" + + def inventory(root: Path) -> dict[str, tuple[str, str]] | None: + if root.is_symlink(): + return None + if root.is_file(): + try: + return {".": ("file", hashlib.sha256(root.read_bytes()).hexdigest())} + except OSError: + return None + if not root.is_dir(): + return None + entries: dict[str, tuple[str, str]] = {} + for path in sorted(root.rglob("*")): + if path.is_symlink(): + return None + relative = path.relative_to(root).as_posix() + if path.is_dir(): + entries[relative] = ("dir", "") + elif path.is_file(): + try: + entries[relative] = ( + "file", + hashlib.sha256(path.read_bytes()).hexdigest(), + ) + except OSError: + return None + else: + return None + return entries + + return inventory(candidate) == inventory(expected) and candidate.exists() + + def _vc_frame_delivery_findings( *, home: Path | None = None, @@ -695,6 +730,8 @@ def _vc_frame_delivery_findings( for name in ("config.kdl", "layouts", "themes"): path = view / name ch = classify_view_path(path, store_current=store_cfg, checkout=checkout) + if ch == "STALE-FILE" and _config_entry_matches(path, generated / name): + ch = "runtime-copy" channels.append(ch) if ch == "DANGLING": findings.append( @@ -851,14 +888,9 @@ def _vc_frame_delivery_findings( ) ) - # Operator scripts + Super/Cmd contract on both projections. The runtime - # pins VC_FRAME_CONFIG_DIR to frontier first; a STALE-FILE composer there - # shadows every install that only rewires ~/.config/vc-frame. - frontier_cfg = froot / "vc-frame" - for projection, label in ( - (view, "view"), - (frontier_cfg, "frontier"), - ): + # Operator scripts + Super/Cmd contract live beside the sole canonical + # product config. Legacy frontier paths are residue, never a second view. + for projection, label in ((view, "view"),): missing_scripts = [ name for name in OPERATOR_SCRIPT_NAMES @@ -867,7 +899,9 @@ def _vc_frame_delivery_findings( stale_scripts = [ name for name in OPERATOR_SCRIPT_NAMES - if (projection / name).is_file() and not (projection / name).is_symlink() + if (projection / name).is_file() + and not (projection / name).is_symlink() + and not _config_entry_matches(projection / name, generated / name) ] if missing_scripts: findings.append( diff --git a/vibecrafted-core/vibecrafted_core/lifecycle_runner.py b/vibecrafted-core/vibecrafted_core/lifecycle_runner.py index 4dc472ae..735abee8 100644 --- a/vibecrafted-core/vibecrafted_core/lifecycle_runner.py +++ b/vibecrafted-core/vibecrafted_core/lifecycle_runner.py @@ -7,6 +7,7 @@ import hashlib import json import os +import re import subprocess import sys import time @@ -57,6 +58,16 @@ LIFECYCLE_SCHEMA_ID = "vibecrafted.lifecycle.v1" +def _lifecycle_stage_run_id( + lifecycle_run_id: str, stage_id: str, occurrence: int +) -> str: + """Return a stable explicit child identity for one lifecycle stage attempt.""" + material = f"{lifecycle_run_id}\n{stage_id}\n{occurrence}" + digest = hashlib.sha256(material.encode("utf-8")).hexdigest()[:24] + prefix = re.sub(r"[^A-Za-z0-9._-]+", "-", stage_id).strip("-._") or "stage" + return f"{prefix[:24]}-{digest}" + + def delivery_axes_for_receipt( status: str, payload: dict[str, Any] | None = None ) -> dict[str, str]: @@ -71,13 +82,18 @@ def delivery_axes_for_receipt( "launching": ExecutionState.LAUNCHED, "running": ExecutionState.RUNNING, "completed": ExecutionState.EXITED, + "quota_exhausted": ExecutionState.INTERRUPTED, }.get(str(status), ExecutionState.FAILED) # The execution axis states what the PROCESS did, not what the artifact # gate concluded. A worker that exited 0 without a report is the contract's # exit_0_without_report specimen — needs_attention, never a fabricated # execution failure (which would settle x instead of n). exit_code = source.get("exit_code") - if isinstance(exit_code, int) and str(status) not in ("launching", "running"): + if isinstance(exit_code, int) and str(status) not in ( + "launching", + "running", + "quota_exhausted", + ): execution_default = ( ExecutionState.EXITED if exit_code == 0 else ExecutionState.FAILED ) @@ -910,6 +926,11 @@ async def _start_stage( model=model, lifecycle_state_path=str(state_path or ""), claim_digest=claim_digest_for_text(source_prompt), + run_id=_lifecycle_stage_run_id( + lifecycle_run_id, + stage.id, + len(previous_reports), + ), ) commit_before = _git_head(root) git_before = _git_status(root) @@ -1579,7 +1600,13 @@ def lifecycle_main(workflow_id: str, argv: Sequence[str] | None = None) -> int: ) ) if args.json: - print(json.dumps(state, ensure_ascii=False, indent=2, sort_keys=True)) + print( + json.dumps( + state, ensure_ascii=False, indent=2, sort_keys=True, default=str + ), + flush=True, + ) else: _print_lifecycle_receipt(state) + sys.stdout.flush() return 0 if state.get("status") in {"launching", "completed"} else 1 diff --git a/vibecrafted-core/vibecrafted_core/loop.py b/vibecrafted-core/vibecrafted_core/loop.py index 4bca8870..07cddb32 100644 --- a/vibecrafted-core/vibecrafted_core/loop.py +++ b/vibecrafted-core/vibecrafted_core/loop.py @@ -13,7 +13,7 @@ from pathlib import Path from typing import Any -from . import control_plane, cron, ui +from . import control_plane, ui from .clock import utc_now_z @@ -121,6 +121,10 @@ def command_deck() -> str: def _framework_heartbeat(*, root: Path, run_id: str, then_cmd: str = "") -> int: """Fire one immediate ``vibecrafted cron tick`` heartbeat for a running worker.""" + # cron imports default_state_file from this module. Keep the reverse edge + # lazy so importing ship/loop in a fresh interpreter cannot form a cycle. + from . import cron + argv = [ "tick", "--root", diff --git a/vibecrafted-core/vibecrafted_core/process_control.py b/vibecrafted-core/vibecrafted_core/process_control.py index 1f653235..61127a2b 100644 --- a/vibecrafted-core/vibecrafted_core/process_control.py +++ b/vibecrafted-core/vibecrafted_core/process_control.py @@ -204,32 +204,62 @@ def validate_process_identity( ``SPAWN_RUN_ID`` evidence is best-effort on macOS. If the OS exposes it, it must match; when it does not, the mandatory receipt run id plus start token, command hash, PID, and PGID still have to match exactly. + + Receipt-invalid and receipt-mismatch reasons are emitted before OS capture + and are never proof of stale ownership. ``process_identity_mismatch`` is + reserved for a complete receipt that reached capture and differed from the + live identity. """ if not isinstance(receipt, Mapping): return False, "process_identity_unavailable", None - try: - receipt_pid = int(receipt.get("pid") or 0) - receipt_pgid = int(receipt.get("pgid") or 0) - except (TypeError, ValueError): - return False, "process_identity_invalid", None - receipt_run_id = str(receipt.get("run_id") or "").strip() - expected_run = str(expected_run_id or "").strip() - expected_start = str(receipt.get("start_token") or "").strip() - expected_hash = str(receipt.get("command_sha256") or "").strip() + receipt_pid = receipt.get("pid") + receipt_pgid = receipt.get("pgid") + receipt_run_id = receipt.get("run_id") + expected_start = receipt.get("start_token") + expected_hash = receipt.get("command_sha256") if ( - receipt_pid <= 0 - or receipt_pid != expected_pid - or not expected_run - or receipt_run_id != expected_run + isinstance(receipt_pid, bool) + or not isinstance(receipt_pid, int) + or receipt_pid <= 0 + or isinstance(receipt_pgid, bool) + or not isinstance(receipt_pgid, int) + or receipt_pgid <= 0 + or not isinstance(receipt_run_id, str) + or receipt_run_id != receipt_run_id.strip() + or not receipt_run_id + or not isinstance(expected_start, str) + or expected_start != expected_start.strip() or not expected_start + or not isinstance(expected_hash, str) or len(expected_hash) != 64 + or any(char not in "0123456789abcdef" for char in expected_hash) + ): + return False, "process_identity_receipt_invalid", None + + if ( + isinstance(expected_pid, bool) + or not isinstance(expected_pid, int) + or expected_pid <= 0 + or not isinstance(expected_run_id, str) + or expected_run_id != expected_run_id.strip() + or not expected_run_id + or ( + expected_pgid is not None + and ( + isinstance(expected_pgid, bool) + or not isinstance(expected_pgid, int) + or expected_pgid <= 0 + ) + ) ): - return False, "process_identity_mismatch", None - if expected_pgid is not None and ( - expected_pgid <= 0 or receipt_pgid != expected_pgid + return False, "process_identity_expectation_invalid", None + if ( + receipt_pid != expected_pid + or receipt_run_id != expected_run_id + or (expected_pgid is not None and receipt_pgid != expected_pgid) ): - return False, "process_identity_mismatch", None + return False, "process_identity_receipt_mismatch", None identity = capture_process_identity(expected_pid, table=table) if identity is None: @@ -247,7 +277,7 @@ def validate_process_identity( if env_index is None else env_index.get(expected_pid) ) - if discovered and str(discovered).strip() != expected_run: + if discovered and str(discovered).strip() != expected_run_id: return False, "process_run_id_mismatch", identity return True, "process_identity_current", identity diff --git a/vibecrafted-core/vibecrafted_core/product_contract.py b/vibecrafted-core/vibecrafted_core/product_contract.py index 1b79d9f5..ebd4e332 100644 --- a/vibecrafted-core/vibecrafted_core/product_contract.py +++ b/vibecrafted-core/vibecrafted_core/product_contract.py @@ -10,6 +10,7 @@ import argparse import hashlib +import importlib.util import json import os import plistlib @@ -21,6 +22,7 @@ import struct import subprocess import sys +import tarfile import tempfile import time from collections.abc import Callable, Mapping, Sequence @@ -31,6 +33,17 @@ from typing import Any, NoReturn from xml.parsers.expat import ExpatError +try: + from . import runtime_pack_contract +except ImportError: # Direct execution from a sealed runtime verifier snapshot. + _runtime_pack_spec = importlib.util.spec_from_file_location( + "runtime_pack_contract", Path(__file__).with_name("runtime_pack_contract.py") + ) + if _runtime_pack_spec is None or _runtime_pack_spec.loader is None: + raise + runtime_pack_contract = importlib.util.module_from_spec(_runtime_pack_spec) + _runtime_pack_spec.loader.exec_module(runtime_pack_contract) + MODULE_SCHEMA = "io.vetcoders.vibecrafted.module.v1" ASSEMBLY_SCHEMA = "io.vetcoders.vibecrafted.module-assembly.v1" PRODUCT_SCHEMA = "io.vetcoders.vibecrafted.product.v1" @@ -99,7 +112,7 @@ def is_canonical_release_dmg_name( SOURCE_PROVENANCE_NAME = "source-provenance.json" SOURCE_PROVENANCE_SCHEMA = "vibecrafted.source-provenance.v2" SOURCE_PAYLOAD_SCHEMA = "vibecrafted.distribution-tree.v1" -RUNTIME_GENERATION_ENTRYPOINT = "vibecrafted-core/vibecrafted_core/deck/vibecrafted" +RUNTIME_GENERATION_ENTRYPOINT = "bin/vibecrafted" RUNTIME_GENERATION_PROJECTED_CONFIG = "runtime/generated/vc-frame/config.kdl" RUNTIME_GENERATION_CANONICAL_CONFIG = ( "vibecrafted-core/vibecrafted_core/runtime/generated/vc-frame/config.kdl" @@ -107,10 +120,14 @@ def is_canonical_release_dmg_name( RUNTIME_GENERATION_REQUIRED_HASHES = frozenset( { "VERSION", + "scripts/distribution_manifest.py", + "scripts/installer_brand.py", "scripts/vibecrafted", + "scripts/vetcoders_install.py", RUNTIME_GENERATION_CANONICAL_CONFIG, RUNTIME_GENERATION_ENTRYPOINT, "vibecrafted-core/vibecrafted_core/product_contract.py", + "vibecrafted-core/vibecrafted_core/runtime_pack_contract.py", "vibecrafted-core/vibecrafted_core/walkaround_runner.py", "vibecrafted-core/vibecrafted_core/schemas/unified_product.schema.v1.json", "vibecrafted-core/vibecrafted_core/trust/release-policy.v1.json", @@ -2294,6 +2311,47 @@ def _mounted_release_dmg(dmg: Path): ) +@contextmanager +def _extracted_runtime_pack(carrier: Path): + """Extract a verified Runtime Pack without accepting archive aliases or links.""" + + with tempfile.TemporaryDirectory(prefix="vibecrafted-runtime-pack-proof-") as raw: + destination = Path(raw) + with tarfile.open(carrier, "r:gz") as archive: + members = archive.getmembers() + roots: set[str] = set() + for member in members: + pure = PurePosixPath(member.name) + if ( + pure.is_absolute() + or ".." in pure.parts + or not pure.parts + or member.issym() + or member.islnk() + or not (member.isdir() or member.isfile()) + ): + _fail(E_PROOF, "Runtime Pack archive contains an unsafe member") + roots.add(pure.parts[0]) + if roots != {"VibecraftedRuntime"}: + _fail(E_PROOF, "Runtime Pack archive must contain one canonical root") + for member in members: + target = destination.joinpath(*PurePosixPath(member.name).parts) + if member.isdir(): + target.mkdir(parents=True, exist_ok=True) + continue + target.parent.mkdir(parents=True, exist_ok=True) + source = archive.extractfile(member) + if source is None: + _fail(E_PROOF, "Runtime Pack member cannot be read") + with target.open("xb") as handle: + shutil.copyfileobj(source, handle) + target.chmod(member.mode & 0o777) + root = destination / "VibecraftedRuntime" + if any(path.is_symlink() for path in root.rglob("*")): + _fail(E_PROOF, "Runtime Pack extraction produced a symlink") + yield root + + def _verify_release_artifacts( payload: Mapping[str, Any], *, @@ -2381,6 +2439,66 @@ def _verify_release_artifacts( } if payload["source_revisions"] != expected_revisions: _fail(E_PROOF, "release output source revisions do not match the product") + + runtime_pack = payload["runtime_pack"] + pack_name = runtime_pack["path"] + expected_pack_prefix = f"Vibecrafted_RuntimePack_{product['version']}-" + expected_pack_suffix = ( + f"-{product['git_sha'][:8]}-darwin-{product['architecture']}.tar.gz" + ) + if ( + Path(pack_name).name != pack_name + or not pack_name.startswith(expected_pack_prefix) + or not pack_name.endswith(expected_pack_suffix) + ): + _fail(E_PROOF, "Runtime Pack basename does not bind the release source") + carrier = _release_relative_path(receipt_root, pack_name, field="runtime_pack.path") + if not carrier.is_file() or carrier.is_symlink(): + _fail(E_MISSING, "standalone Runtime Pack is missing") + if runtime_pack["size"] != carrier.stat().st_size or runtime_pack[ + "sha256" + ] != _sha256(carrier): + _fail(E_HASH, "standalone Runtime Pack bytes disagree with release output") + embedded_relative = _relative_path( + runtime_pack["embedded_path"], field="runtime_pack.embedded_path" + ).as_posix() + if embedded_relative != f"Contents/Resources/runtime-pack/{pack_name}": + _fail(E_PROOF, "App-embedded Runtime Pack path is not canonical") + embedded = app / embedded_relative + if ( + not embedded.is_file() + or embedded.is_symlink() + or embedded.stat().st_size != carrier.stat().st_size + or _sha256(embedded) != runtime_pack["sha256"] + ): + _fail(E_HASH, "App-embedded Runtime Pack differs from the standalone carrier") + provenance_receipt = runtime_pack["provenance"] + if provenance_receipt["source_revisions"] != expected_revisions: + _fail(E_PROOF, "Runtime Pack receipt donor tuple disagrees with the product") + with _extracted_runtime_pack(carrier) as pack_root: + try: + provenance = runtime_pack_contract.verify_provenance( + pack_root, + carrier_basename=pack_name, + expected_source_revision=expected_revisions["vibecrafted"], + expected_terminal_revision=expected_revisions["vc-terminal"], + expected_frame_revision=expected_revisions["vc-frame"], + ) + except runtime_pack_contract.RuntimePackContractError as exc: + _fail(E_PROOF, str(exc)) + provenance_path = pack_root / runtime_pack_contract.PROVENANCE_NAME + if provenance_receipt != { + "path": runtime_pack_contract.PROVENANCE_NAME, + "sha256": _sha256(provenance_path), + "version": provenance["version"], + "platform": provenance["platform"], + "architecture": provenance["architecture"], + "source_revisions": provenance["source_revisions"], + }: + _fail( + E_PROOF, + "Runtime Pack internal provenance disagrees with release output", + ) if require_walkaround: return _run_walkaround_probes(app, dmg) return _run_live_release_checks(app, dmg) @@ -4003,7 +4121,7 @@ def module_binding( "tree_sha256": "f" * 64, "entry_count": 42, }, - "entrypoint": "vibecrafted-core/vibecrafted_core/deck/vibecrafted", + "entrypoint": RUNTIME_GENERATION_ENTRYPOINT, "hashes": { relative: f"{index:x}" * 64 for index, relative in enumerate( diff --git a/vibecrafted-core/vibecrafted_core/run_board.py b/vibecrafted-core/vibecrafted_core/run_board.py index 92c48f78..4e07be6a 100644 --- a/vibecrafted-core/vibecrafted_core/run_board.py +++ b/vibecrafted-core/vibecrafted_core/run_board.py @@ -13,9 +13,13 @@ import json import sys from collections.abc import Sequence +from pathlib import Path from typing import Any from .control_plane import ControlPlaneStorageError, sync_state +from .runtime_paths import vibecrafted_home + +_LIFECYCLE_ACTIVITY_SCHEMA = "vibecrafted.lifecycle-activity.v1" _STATE_GLYPH = { "completed": "ok", @@ -90,6 +94,48 @@ def collect_board(*, all_days: bool, limit: int) -> dict[str, Any]: } +def _is_canonical_worktree_lane(run: dict[str, Any]) -> bool: + root = str(run.get("root") or "").strip() + if not root: + return False + worktrees_root = (vibecrafted_home() / "worktrees").resolve(strict=False) + try: + Path(root).expanduser().resolve(strict=False).relative_to(worktrees_root) + except ValueError: + return False + return True + + +def collect_lifecycle_activity() -> dict[str, Any]: + """Return every active/stalled lane without presentation filtering. + + ``sync_state`` owns lifecycle classification. This projection deliberately + consumes only its canonical active/stalled buckets; it never reclassifies + recent rows by state, date, health text, or display limit. + """ + board = sync_state() + seen: set[str] = set() + lanes: list[dict[str, Any]] = [] + for bucket in ("active_runs", "stalled_runs"): + for raw_run in board.get(bucket) or []: + run = dict(raw_run) + run_id = str(run.get("run_id") or "") + if not run_id or run_id in seen: + continue + seen.add(run_id) + lanes.append(run) + lanes.sort(key=_sort_key, reverse=True) + return { + "schema_version": _LIFECYCLE_ACTIVITY_SCHEMA, + "summary": { + "lanes": len(lanes), + "worktrees": sum(_is_canonical_worktree_lane(run) for run in lanes), + }, + "lanes": lanes, + "warnings": list(board.get("warnings") or []), + } + + def render_board(result: dict[str, Any], *, all_days: bool) -> str: runs = result["runs"] lines: list[str] = [] @@ -164,9 +210,20 @@ def status_main(argv: Sequence[str] | None = None) -> int: "--limit", type=int, default=12, help="rows to show (0 = no limit)" ) parser.add_argument("--json", action="store_true", help="machine-readable output") + parser.add_argument( + "--activity", + action="store_true", + help="machine lifecycle truth: every active/stalled lane (requires --json)", + ) args = parser.parse_args(list(argv) if argv is not None else None) + if args.activity and not args.json: + parser.error("--activity requires --json") try: - result = collect_board(all_days=bool(args.all), limit=int(args.limit)) + result = ( + collect_lifecycle_activity() + if args.activity + else collect_board(all_days=bool(args.all), limit=int(args.limit)) + ) except ControlPlaneStorageError as exc: print(f"status: {exc}", file=sys.stderr) return 2 diff --git a/vibecrafted-core/vibecrafted_core/run_triage.py b/vibecrafted-core/vibecrafted_core/run_triage.py index ca7b0c1c..bc28d5f8 100644 --- a/vibecrafted-core/vibecrafted_core/run_triage.py +++ b/vibecrafted-core/vibecrafted_core/run_triage.py @@ -190,6 +190,10 @@ "blocked", "stalled", "timed_out", + # User-selected measured budget exhaustion is neither provider + # overload nor proof that the worker failed. Keep it out of the + # provider-error infra bucket and route it to operator attention. + "quota_exhausted", "ghost", "gc", } diff --git a/vibecrafted-core/vibecrafted_core/runtime/shell/lib/operator.sh b/vibecrafted-core/vibecrafted_core/runtime/shell/lib/operator.sh index 89390691..c4a28fcf 100644 --- a/vibecrafted-core/vibecrafted_core/runtime/shell/lib/operator.sh +++ b/vibecrafted-core/vibecrafted_core/runtime/shell/lib/operator.sh @@ -57,35 +57,27 @@ _vetcoders_compose_init_prompt() { _vetcoders_init_command_text() { local tool="$1" local init_prompt="$2" - local quoted_prompt - quoted_prompt="$(_vetcoders_shell_quote "$init_prompt")" - - case "$tool" in - claude) - printf 'claude --verbose --dangerously-skip-permissions %s' "$quoted_prompt" - ;; - codex) - printf 'codex --dangerously-bypass-approvals-and-sandbox %s' "$quoted_prompt" - ;; - gemini) - printf 'gemini -y -i %s' "$quoted_prompt" - ;; - agy) - printf 'agy --dangerously-skip-permissions --add-dir . --prompt-interactive %s' "$quoted_prompt" - ;; - junie) - printf 'junie --task=%s --project=. --skip-update-check --use-local-cache' "$quoted_prompt" - ;; - grok) - # Interactive TUI: positional PROMPT seeds the session and stays open. - # NEVER use --single here — that is one-shot headless (prints + exits). - printf 'grok --cwd . --permission-mode bypassPermissions --no-alt-screen %s' "$quoted_prompt" - ;; - *) - echo "Unsupported init agent: $tool" >&2 - return 1 - ;; - esac + local policy_runtime="${3:-local-native}" + local permissions="${4:-bypass}" + local token_budget="${5:-safe}" + local operator_policy="${6:-none}" + local continuity="${7:-fresh}" + local parent_session="${8:-}" + local continuity_parent="${9:-}" + local python_spec py import_root + local -a continuity_args=(--continuity "$continuity") + [[ -z "$parent_session" ]] || continuity_args+=(--parent-session "$parent_session") + [[ -z "$continuity_parent" ]] || continuity_args+=(--continuity-parent "$continuity_parent") + python_spec="$(_vetcoders_core_python_spec)" || return 1 + py="${python_spec%%$'\t'*}" + import_root="${python_spec#*$'\t'}" + if [[ -n "$import_root" ]]; then + printf '%s' "$init_prompt" | VIBECRAFTED_INTERACTIVE_IMPORT_ROOT="$import_root" \ + PYTHONPATH="$import_root${PYTHONPATH:+:$PYTHONPATH}" \ + "$py" -m vibecrafted_core.spawn interactive-command "$tool" --runtime "$policy_runtime" --permissions "$permissions" --token-budget "$token_budget" --operator "$operator_policy" "${continuity_args[@]}" --root "${_vetcoders_contract_root:-$(_vetcoders_repo_root)}" + else + printf '%s' "$init_prompt" | "$py" -m vibecrafted_core.spawn interactive-command "$tool" --runtime "$policy_runtime" --permissions "$permissions" --token-budget "$token_budget" --operator "$operator_policy" "${continuity_args[@]}" --root "${_vetcoders_contract_root:-$(_vetcoders_repo_root)}" + fi } # Operator-mode launcher helpers — parallel to init helpers above. @@ -125,33 +117,5 @@ _vetcoders_compose_operator_prompt() { _vetcoders_operator_command_text() { local tool="$1" local operator_prompt="$2" - local quoted_prompt - quoted_prompt="$(_vetcoders_shell_quote "$operator_prompt")" - - case "$tool" in - claude) - printf 'claude --verbose --dangerously-skip-permissions %s' "$quoted_prompt" - ;; - codex) - printf 'codex --dangerously-bypass-approvals-and-sandbox %s' "$quoted_prompt" - ;; - gemini) - printf 'gemini -y -i %s' "$quoted_prompt" - ;; - agy) - printf 'agy --dangerously-skip-permissions --add-dir . --prompt-interactive %s' "$quoted_prompt" - ;; - junie) - printf 'junie --task=%s --project=. --skip-update-check --use-local-cache' "$quoted_prompt" - ;; - grok) - # Same contract as vc-init: interactive TUI, not --single one-shot. - printf 'grok --cwd . --permission-mode bypassPermissions --no-alt-screen %s' "$quoted_prompt" - ;; - *) - echo "Unsupported operator agent: $tool" >&2 - return 1 - ;; - esac + _vetcoders_init_command_text "$tool" "$operator_prompt" "${3:-local-native}" "${4:-bypass}" } - diff --git a/vibecrafted-core/vibecrafted_core/runtime/shell/lib/operator_entrypoints.sh b/vibecrafted-core/vibecrafted_core/runtime/shell/lib/operator_entrypoints.sh index 007c64dc..c22a7230 100644 --- a/vibecrafted-core/vibecrafted_core/runtime/shell/lib/operator_entrypoints.sh +++ b/vibecrafted-core/vibecrafted_core/runtime/shell/lib/operator_entrypoints.sh @@ -4,7 +4,7 @@ _vetcoders_skill_init() { local tool="$1" shift - local runtime init_prompt command_text + local runtime init_prompt command_text permissions _vetcoders_parse_contract "$@" || return 1 [[ -z "$_vetcoders_contract_count" ]] || { @@ -22,7 +22,9 @@ _vetcoders_skill_init() { runtime="$(_vetcoders_init_runtime "${_vetcoders_contract_runtime:-terminal}")" || return 1 init_prompt="$(_vetcoders_compose_init_prompt "$_vetcoders_contract_prompt" "$_vetcoders_contract_file")" || return 1 - command_text="$(_vetcoders_init_command_text "$tool" "$init_prompt")" || return 1 + permissions="${_vetcoders_contract_permissions:-}" + [[ -n "$permissions" ]] || { [[ "$tool" == "junie" ]] && permissions="auto" || permissions="bypass"; } + command_text="$(_vetcoders_init_command_text "$tool" "$init_prompt" "${_vetcoders_contract_policy_runtime:-local-native}" "$permissions" "${_vetcoders_contract_token_budget:-safe}" "${_vetcoders_contract_operator:-none}" "${_vetcoders_contract_continuity:-fresh}" "${_vetcoders_contract_parent_session:-}" "${_vetcoders_contract_continuity_parent:-}")" || return 1 # No cockpit, or an explicit `--runtime plain`: the orientation session is # the agent itself, so run it right here in the caller's terminal. A fresh @@ -61,7 +63,7 @@ _vetcoders_init_in_current_terminal() { _vetcoders_skill_operator() { local tool="$1" shift - local runtime operator_prompt command_text + local runtime operator_prompt command_text permissions _vetcoders_parse_contract "$@" || return 1 [[ -z "$_vetcoders_contract_count" ]] || { @@ -81,9 +83,10 @@ _vetcoders_skill_operator() { runtime="$(_vetcoders_operator_runtime "${_vetcoders_contract_runtime:-terminal}")" || return 1 operator_prompt="$(_vetcoders_compose_operator_prompt "$_vetcoders_contract_prompt" "$_vetcoders_contract_file")" || return 1 - command_text="$(_vetcoders_operator_command_text "$tool" "$operator_prompt")" || return 1 + permissions="${_vetcoders_contract_permissions:-}" + [[ -n "$permissions" ]] || { [[ "$tool" == "junie" ]] && permissions="auto" || permissions="bypass"; } + command_text="$(_vetcoders_operator_command_text "$tool" "$operator_prompt" "${_vetcoders_contract_policy_runtime:-local-native}" "$permissions")" || return 1 _vetcoders_prepare_operator_runtime "$runtime" || return 1 _vetcoders_spawn_into_operator_session "$(_vetcoders_operator_face_tab "$tool")" "$command_text" } - diff --git a/vibecrafted-core/vibecrafted_core/runtime/shell/lib/prompts.sh b/vibecrafted-core/vibecrafted_core/runtime/shell/lib/prompts.sh index d32abe24..ab0aa85e 100644 --- a/vibecrafted-core/vibecrafted_core/runtime/shell/lib/prompts.sh +++ b/vibecrafted-core/vibecrafted_core/runtime/shell/lib/prompts.sh @@ -33,6 +33,13 @@ _vetcoders_contract_reset() { _vetcoders_contract_count="" _vetcoders_contract_depth="" _vetcoders_contract_runtime="" + _vetcoders_contract_policy_runtime="" + _vetcoders_contract_permissions="" + _vetcoders_contract_token_budget="" + _vetcoders_contract_operator="" + _vetcoders_contract_continuity="" + _vetcoders_contract_parent_session="" + _vetcoders_contract_continuity_parent="" _vetcoders_contract_root="" _vetcoders_contract_tail="" _vetcoders_contract_dry_run="" @@ -119,6 +126,41 @@ _vetcoders_parse_contract() { [[ $# -gt 0 ]] || { echo "Missing value for --runtime" >&2; return 1; } _vetcoders_contract_runtime="$1" ;; + --policy-runtime) + shift + [[ $# -gt 0 ]] || { echo "Missing value for --policy-runtime" >&2; return 1; } + _vetcoders_contract_policy_runtime="$1" + ;; + --permissions) + shift + [[ $# -gt 0 ]] || { echo "Missing value for --permissions" >&2; return 1; } + _vetcoders_contract_permissions="$1" + ;; + --token-budget) + shift + [[ $# -gt 0 ]] || { echo "Missing value for --token-budget" >&2; return 1; } + _vetcoders_contract_token_budget="$1" + ;; + --operator) + shift + [[ $# -gt 0 ]] || { echo "Missing value for --operator" >&2; return 1; } + _vetcoders_contract_operator="$1" + ;; + --continuity) + shift + [[ $# -gt 0 ]] || { echo "Missing value for --continuity" >&2; return 1; } + _vetcoders_contract_continuity="$1" + ;; + --parent-session) + shift + [[ $# -gt 0 ]] || { echo "Missing value for --parent-session" >&2; return 1; } + _vetcoders_contract_parent_session="$1" + ;; + --continuity-parent) + shift + [[ $# -gt 0 ]] || { echo "Missing value for --continuity-parent" >&2; return 1; } + _vetcoders_contract_continuity_parent="$1" + ;; --root) shift [[ $# -gt 0 ]] || { echo "Missing value for --root" >&2; return 1; } diff --git a/vibecrafted-core/vibecrafted_core/runtime_pack_contract.py b/vibecrafted-core/vibecrafted_core/runtime_pack_contract.py new file mode 100644 index 00000000..f5c0f1b8 --- /dev/null +++ b/vibecrafted-core/vibecrafted_core/runtime_pack_contract.py @@ -0,0 +1,385 @@ +"""Closed identity contract for the immutable Vibecrafted Runtime Pack.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import stat +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +SCHEMA = "io.vetcoders.vibecrafted.runtime-pack-provenance.v1" +PROVENANCE_NAME = "runtime-pack-provenance.json" +SOURCE_PROVENANCE_NAME = "source-provenance.json" +INVENTORY_NAME = "runtime-inventory.json" +SOURCE_PROVENANCE_SCHEMA = "vibecrafted.source-provenance.v2" +GIT_SHA = re.compile(r"[0-9a-f]{40}") +SHA256 = re.compile(r"[0-9a-f]{64}") +LINUX_ARM64_EXECUTABLES = frozenset( + { + "vibecrafted", + "vc-server", + "loct", + "loctree", + "loctree-mcp", + "loctree-lsp", + "aicx", + "aicx-mcp", + "prview", + "screenscribe", + "vc-frame", + "vc-terminal", + "voc", + } +) +RUNTIME_INSTALLER_EXECUTABLES = frozenset( + { + "bin/vc-start", + "scripts/vibecrafted", + } +) + + +class RuntimePackContractError(RuntimeError): + """Raised when Runtime Pack identity or payload evidence is not closed.""" + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _canonical_json(payload: Mapping[str, Any]) -> str: + return json.dumps(payload, ensure_ascii=True, sort_keys=True, indent=2) + "\n" + + +def _runtime_installer_payload(root: Path) -> None: + for relative in RUNTIME_INSTALLER_EXECUTABLES: + path = root / relative + try: + mode = path.lstat().st_mode + except OSError as exc: + raise RuntimePackContractError( + f"Runtime Pack installer payload is missing {relative}" + ) from exc + if not stat.S_ISREG(mode) or stat.S_IMODE(mode) & 0o111 == 0: + raise RuntimePackContractError( + f"Runtime Pack installer payload is not executable: {relative}" + ) + + +def _validate_revision(value: str, *, field: str) -> str: + if GIT_SHA.fullmatch(value) is None: + raise RuntimePackContractError(f"{field} must be a full Git revision") + return value + + +def _payload_files(root: Path) -> list[dict[str, Any]]: + records: list[dict[str, Any]] = [] + for path in sorted(root.rglob("*")): + relative = path.relative_to(root).as_posix() + if path.is_symlink(): + raise RuntimePackContractError( + f"Runtime Pack payload contains a symlink: {relative}" + ) + if path.is_dir(): + continue + mode = path.lstat().st_mode + if not stat.S_ISREG(mode): + raise RuntimePackContractError( + f"Runtime Pack payload contains a non-regular file: {relative}" + ) + if relative == PROVENANCE_NAME: + continue + records.append( + { + "path": relative, + "sha256": _sha256(path), + "size": path.stat().st_size, + "mode": f"{stat.S_IMODE(mode):04o}", + } + ) + if not records: + raise RuntimePackContractError("Runtime Pack payload is empty") + return records + + +def _source_provenance(root: Path, *, expected_revision: str) -> dict[str, Any]: + path = root / SOURCE_PROVENANCE_NAME + try: + raw = path.read_text(encoding="utf-8") + payload = json.loads(raw) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise RuntimePackContractError( + "Runtime Pack source provenance is invalid" + ) from exc + if ( + not isinstance(payload, dict) + or set(payload) != {"schema", "owner_repo", "source_revision", "payload"} + or payload.get("schema") != SOURCE_PROVENANCE_SCHEMA + or payload.get("owner_repo") != "vetcoders/vibecrafted" + or payload.get("source_revision") != expected_revision + or raw != _canonical_json(payload) + ): + raise RuntimePackContractError( + "Runtime Pack source provenance disagrees with the expected source revision" + ) + return payload + + +def _linux_arm64_inventory(root: Path) -> dict[str, Any]: + path = root / INVENTORY_NAME + try: + raw = path.read_text(encoding="utf-8") + inventory = json.loads(raw) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise RuntimePackContractError( + "Linux arm64 Runtime Pack inventory is invalid" + ) from exc + executables = inventory.get("executables") if isinstance(inventory, dict) else None + required_record = { + "name", + "path", + "sha256", + "version_argv", + "version_output", + "source_url", + "source_revision", + "source_archive_sha256", + "target", + "license", + } + if ( + not isinstance(inventory, dict) + or set(inventory) != {"schema", "platform", "architecture", "executables"} + or inventory.get("schema") != "io.vetcoders.vibecrafted.runtime-inventory.v1" + or inventory.get("platform") != "linux" + or inventory.get("architecture") != "arm64" + or raw != _canonical_json(inventory) + or not isinstance(executables, list) + or {record.get("name") for record in executables if isinstance(record, dict)} + != LINUX_ARM64_EXECUTABLES + ): + raise RuntimePackContractError( + "Linux arm64 Runtime Pack inventory violates the closed schema" + ) + for record in executables: + if ( + not isinstance(record, dict) + or set(record) != required_record + or not all( + isinstance(record[field], str) and record[field] + for field in required_record - {"version_argv"} + ) + or not isinstance(record["version_argv"], list) + or not all( + isinstance(item, str) and item for item in record["version_argv"] + ) + or SHA256.fullmatch(record["sha256"]) is None + or SHA256.fullmatch(record["source_archive_sha256"]) is None + or record["target"] != "aarch64-unknown-linux-gnu" + or record["path"] != f"bin/{record['name']}" + or _sha256(root / record["path"]) != record["sha256"] + ): + raise RuntimePackContractError( + "Linux arm64 Runtime Pack executable inventory is invalid" + ) + return inventory + + +def write_provenance( + root: str | Path, + *, + carrier_basename: str, + version: str, + platform: str, + architecture: str, + source_revision: str, + terminal_revision: str, + frame_revision: str, +) -> dict[str, Any]: + payload_root = Path(root).resolve(strict=True) + revisions = { + "vibecrafted": _validate_revision(source_revision, field="source_revision"), + "vc-terminal": _validate_revision(terminal_revision, field="terminal_revision"), + "vc-frame": _validate_revision(frame_revision, field="frame_revision"), + } + if Path(carrier_basename).name != carrier_basename or not carrier_basename.endswith( + ".tar.gz" + ): + raise RuntimePackContractError("carrier basename must be a .tar.gz basename") + if not version or version != version.strip(): + raise RuntimePackContractError("Runtime Pack version is invalid") + _runtime_installer_payload(payload_root) + _source_provenance(payload_root, expected_revision=source_revision) + if platform == "linux" and architecture == "arm64": + _linux_arm64_inventory(payload_root) + provenance = { + "schema": SCHEMA, + "carrier_basename": carrier_basename, + "version": version, + "platform": platform, + "architecture": architecture, + "source_revisions": revisions, + "payload": { + "algorithm": "sha256", + "files": _payload_files(payload_root), + }, + } + (payload_root / PROVENANCE_NAME).write_text( + _canonical_json(provenance), encoding="utf-8" + ) + return provenance + + +def verify_provenance( + root: str | Path, + *, + carrier_basename: str, + expected_source_revision: str | None = None, + expected_terminal_revision: str | None = None, + expected_frame_revision: str | None = None, + expected_version: str | None = None, + expected_platform: str | None = None, + expected_architecture: str | None = None, +) -> dict[str, Any]: + payload_root = Path(root).resolve(strict=True) + path = payload_root / PROVENANCE_NAME + try: + raw = path.read_text(encoding="utf-8") + provenance = json.loads(raw) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise RuntimePackContractError("Runtime Pack provenance is invalid") from exc + required = { + "schema", + "carrier_basename", + "version", + "platform", + "architecture", + "source_revisions", + "payload", + } + revisions = ( + provenance.get("source_revisions") if isinstance(provenance, dict) else None + ) + payload = provenance.get("payload") if isinstance(provenance, dict) else None + files = payload.get("files") if isinstance(payload, dict) else None + if ( + not isinstance(provenance, dict) + or set(provenance) != required + or provenance.get("schema") != SCHEMA + or raw != _canonical_json(provenance) + or provenance.get("carrier_basename") != carrier_basename + or not isinstance(provenance.get("version"), str) + or not provenance["version"] + or not isinstance(provenance.get("platform"), str) + or not provenance["platform"] + or not isinstance(provenance.get("architecture"), str) + or not provenance["architecture"] + or not isinstance(revisions, dict) + or set(revisions) != {"vibecrafted", "vc-terminal", "vc-frame"} + or any( + not isinstance(value, str) or GIT_SHA.fullmatch(value) is None + for value in revisions.values() + ) + or not isinstance(payload, dict) + or set(payload) != {"algorithm", "files"} + or payload.get("algorithm") != "sha256" + or not isinstance(files, list) + or not files + ): + raise RuntimePackContractError( + "Runtime Pack provenance violates the closed schema" + ) + expected_revisions = { + "vibecrafted": expected_source_revision, + "vc-terminal": expected_terminal_revision, + "vc-frame": expected_frame_revision, + } + for name, expected in expected_revisions.items(): + if expected is not None and revisions[name] != _validate_revision( + expected, field=f"expected_{name}_revision" + ): + raise RuntimePackContractError( + f"Runtime Pack {name} revision disagrees with the expected release tuple" + ) + expected_identity = { + "version": expected_version, + "platform": expected_platform, + "architecture": expected_architecture, + } + for field, expected in expected_identity.items(): + if expected is not None and provenance[field] != expected: + raise RuntimePackContractError( + f"Runtime Pack {field} disagrees with the selected release asset" + ) + _runtime_installer_payload(payload_root) + _source_provenance(payload_root, expected_revision=revisions["vibecrafted"]) + if provenance["platform"] == "linux" and provenance["architecture"] == "arm64": + _linux_arm64_inventory(payload_root) + observed = _payload_files(payload_root) + if files != observed: + raise RuntimePackContractError( + "Runtime Pack payload digests do not match provenance" + ) + version = (payload_root / "VERSION").read_text(encoding="utf-8").strip() + if version != provenance["version"]: + raise RuntimePackContractError("Runtime Pack VERSION disagrees with provenance") + return provenance + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + commands = parser.add_subparsers(dest="command", required=True) + write = commands.add_parser("write") + verify = commands.add_parser("verify") + for command in (write, verify): + command.add_argument("--root", type=Path, required=True) + command.add_argument("--carrier-basename", required=True) + write.add_argument("--version", required=True) + write.add_argument("--platform", required=True) + write.add_argument("--architecture", required=True) + write.add_argument("--source-revision", required=True) + write.add_argument("--terminal-revision", required=True) + write.add_argument("--frame-revision", required=True) + verify.add_argument("--expected-source-revision") + verify.add_argument("--expected-terminal-revision") + verify.add_argument("--expected-frame-revision") + verify.add_argument("--expected-version") + verify.add_argument("--expected-platform") + verify.add_argument("--expected-architecture") + args = parser.parse_args(argv) + if args.command == "write": + payload = write_provenance( + args.root, + carrier_basename=args.carrier_basename, + version=args.version, + platform=args.platform, + architecture=args.architecture, + source_revision=args.source_revision, + terminal_revision=args.terminal_revision, + frame_revision=args.frame_revision, + ) + else: + payload = verify_provenance( + args.root, + carrier_basename=args.carrier_basename, + expected_source_revision=args.expected_source_revision, + expected_terminal_revision=args.expected_terminal_revision, + expected_frame_revision=args.expected_frame_revision, + expected_version=args.expected_version, + expected_platform=args.expected_platform, + expected_architecture=args.expected_architecture, + ) + print(json.dumps(payload, sort_keys=True, separators=(",", ":"))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vibecrafted-core/vibecrafted_core/schemas/unified_product.schema.v1.json b/vibecrafted-core/vibecrafted_core/schemas/unified_product.schema.v1.json index 7ba2bf14..b2e25829 100644 --- a/vibecrafted-core/vibecrafted_core/schemas/unified_product.schema.v1.json +++ b/vibecrafted-core/vibecrafted_core/schemas/unified_product.schema.v1.json @@ -535,6 +535,7 @@ "outer_executable", "code_resources", "dmg", + "runtime_pack", "modules", "source_revisions", "notarization" @@ -621,6 +622,45 @@ "size": { "type": "integer", "minimum": 1 } } }, + "runtime_pack": { + "type": "object", + "additionalProperties": false, + "required": ["path", "embedded_path", "sha256", "size", "provenance"], + "properties": { + "path": { + "type": "string", + "pattern": "^Vibecrafted_RuntimePack_[0-9]+\\.[0-9]+\\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?-[0-9]{8}-[0-9a-f]{8}-darwin-(?:arm64|x64)\\.tar\\.gz$" + }, + "embedded_path": { + "type": "string", + "pattern": "^Contents/Resources/runtime-pack/Vibecrafted_RuntimePack_[^/]+\\.tar\\.gz$" + }, + "sha256": { "$ref": "#/$defs/sha256" }, + "size": { "type": "integer", "minimum": 1 }, + "provenance": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256", "version", "platform", "architecture", "source_revisions"], + "properties": { + "path": { "const": "runtime-pack-provenance.json" }, + "sha256": { "$ref": "#/$defs/sha256" }, + "version": { "type": "string", "minLength": 1 }, + "platform": { "const": "darwin-arm64" }, + "architecture": { "const": "arm64" }, + "source_revisions": { + "type": "object", + "additionalProperties": false, + "required": ["vibecrafted", "vc-terminal", "vc-frame"], + "properties": { + "vibecrafted": { "$ref": "#/$defs/gitSha" }, + "vc-terminal": { "$ref": "#/$defs/gitSha" }, + "vc-frame": { "$ref": "#/$defs/gitSha" } + } + } + } + } + } + }, "modules": { "type": "object", "additionalProperties": false, diff --git a/vibecrafted-core/vibecrafted_core/server_supervisor.py b/vibecrafted-core/vibecrafted_core/server_supervisor.py index 57f9dafb..70d1ba63 100644 --- a/vibecrafted-core/vibecrafted_core/server_supervisor.py +++ b/vibecrafted-core/vibecrafted_core/server_supervisor.py @@ -581,8 +581,8 @@ def _process_alive(pid: int) -> bool: return True -def _active_generation_bin(runtime_home: Path) -> Path | None: - """`/bin` for the runtime generation the app published, read from +def _active_generation_root(runtime_home: Path) -> Path | None: + """Runtime generation the app published, read from `runtime_home/active.json`; None when that receipt is missing, malformed, or names a root outside `runtime_home`.""" @@ -604,7 +604,12 @@ def _active_generation_bin(runtime_home: Path) -> Path | None: return None if not generation.is_relative_to(runtime_home): return None - return generation / "bin" + return generation + + +def _active_generation_bin(runtime_home: Path) -> Path | None: + generation = _active_generation_root(runtime_home) + return generation / "bin" if generation is not None else None def _service_path(paths: SupervisorPaths) -> str: @@ -664,6 +669,9 @@ def _child_environment(paths: SupervisorPaths) -> dict[str, str]: "VIBECRAFTED_SERVER_SUPERVISOR_CHILD": "1", } ) + generation = _active_generation_root(paths.runtime_home) + if generation is not None: + environment["VIBECRAFTED_RUNTIME_ROOT"] = str(generation) return environment @@ -1494,6 +1502,24 @@ def render_launch_agent_plist( config.paths.launch_agent_file.parent, ): _ensure_owned_directory(directory) + service_environment = { + "HOME": str(config.paths.operator_home), + "PATH": _service_path(config.paths), + "VIBECRAFTED_HOME": str(config.paths.home), + "VIBECRAFTED_RUNTIME_HOME": str(config.paths.runtime_home), + "VC_SERVER_PUBLIC_URL": config.public_url or config.endpoint, + "VIBECRAFTED_SERVER_CONFIG": str(config.config_file or ""), + "VIBECRAFTED_SERVER_SERVICE": "launchd", + "VIBECRAFTED_SERVER_SUPERVISOR_PATH": str(supervisor), + "VIBECRAFTED_SERVER_SUPERVISOR_SHA256": supervisor_sha256, + "VIBECRAFTED_SERVER_SUPERVISOR_RUNTIME_SHA256": runtime_sha256, + "VIBECRAFTED_SERVER_SUPERVISOR_VERSION": PACKAGE_VERSION, + "VIBECRAFTED_SERVER_LAUNCHER_SHA256": launcher_sha256, + "VIBECRAFTED_TRIAGE_RUN": os.environ.get("VIBECRAFTED_TRIAGE_RUN", "1"), + } + generation = _active_generation_root(config.paths.runtime_home) + if generation is not None: + service_environment["VIBECRAFTED_RUNTIME_ROOT"] = str(generation) payload: dict[str, Any] = { "Label": LAUNCH_AGENT_LABEL, "ProgramArguments": [ @@ -1525,21 +1551,7 @@ def render_launch_agent_plist( "ProcessType": "Background", "StandardOutPath": str(config.paths.stdout_log), "StandardErrorPath": str(config.paths.stderr_log), - "EnvironmentVariables": { - "HOME": str(config.paths.operator_home), - "PATH": _service_path(config.paths), - "VIBECRAFTED_HOME": str(config.paths.home), - "VIBECRAFTED_RUNTIME_HOME": str(config.paths.runtime_home), - "VC_SERVER_PUBLIC_URL": config.public_url or config.endpoint, - "VIBECRAFTED_SERVER_CONFIG": str(config.config_file or ""), - "VIBECRAFTED_SERVER_SERVICE": "launchd", - "VIBECRAFTED_SERVER_SUPERVISOR_PATH": str(supervisor), - "VIBECRAFTED_SERVER_SUPERVISOR_SHA256": supervisor_sha256, - "VIBECRAFTED_SERVER_SUPERVISOR_RUNTIME_SHA256": runtime_sha256, - "VIBECRAFTED_SERVER_SUPERVISOR_VERSION": PACKAGE_VERSION, - "VIBECRAFTED_SERVER_LAUNCHER_SHA256": launcher_sha256, - "VIBECRAFTED_TRIAGE_RUN": os.environ.get("VIBECRAFTED_TRIAGE_RUN", "1"), - }, + "EnvironmentVariables": service_environment, } return plistlib.dumps(payload, fmt=plistlib.FMT_XML, sort_keys=True) @@ -2572,6 +2584,7 @@ def _build_parser() -> argparse.ArgumentParser: "start", "stop", "status", + "logs", "uninstall", ), ) @@ -2733,9 +2746,9 @@ def _runtime_status(paths: SupervisorPaths) -> int: def _service_command(args: argparse.Namespace) -> int: - """Dispatch the `service` subcommand's action (status/install/reconcile/ - restart/start/stop/uninstall), serializing mutating actions behind the - tools-install lease; prints a confirmation line and returns an exit code + """Dispatch the `service` subcommand's action (status/logs/install/ + reconcile/restart/start/stop/uninstall), serializing mutating actions behind + the tools-install lease; prints a confirmation line and returns an exit code (status returns 1 unless every health field is green).""" _require_macos_service() @@ -2756,6 +2769,19 @@ def _service_command(args: argparse.Namespace) -> int: ) else 1 ) + if args.action == "logs": + payload = { + "directory": str(config.paths.server_dir), + "stdout": str(config.paths.stdout_log), + "stderr": str(config.paths.stderr_log), + } + if args.json: + print(json.dumps(payload, sort_keys=True)) + else: + print(f"Directory: {payload['directory']}") + print(f"Stdout: {payload['stdout']}") + print(f"Stderr: {payload['stderr']}") + return 0 with _ToolsInstallMutationLease(config.paths): if args.action in {"install", "reconcile"}: changed, restarted = install_and_reconcile_service( diff --git a/vibecrafted-core/vibecrafted_core/skills/pl/vc-aicx/SKILL.md b/vibecrafted-core/vibecrafted_core/skills/pl/vc-aicx/SKILL.md index 3a07edfa..4455c20c 100644 --- a/vibecrafted-core/vibecrafted_core/skills/pl/vc-aicx/SKILL.md +++ b/vibecrafted-core/vibecrafted_core/skills/pl/vc-aicx/SKILL.md @@ -115,7 +115,7 @@ tak samo. ``` $HOME/.claude/projects///tool-results/.txt # Agent result (most common) $HOME/.claude/projects///subagents/agent-.jsonl # Subagent session -/private/tmp/claude-501/.../tasks/.output # Background task +/private/tmp/claude-//tasks/.output # Background task $HOME/.claude/projects//.jsonl # Full session ``` diff --git a/vibecrafted-core/vibecrafted_core/skills/pl/vc-canary/FLOW.md b/vibecrafted-core/vibecrafted_core/skills/pl/vc-canary/FLOW.md index bab31da4..65306531 100644 --- a/vibecrafted-core/vibecrafted_core/skills/pl/vc-canary/FLOW.md +++ b/vibecrafted-core/vibecrafted_core/skills/pl/vc-canary/FLOW.md @@ -11,8 +11,9 @@ flowchart TD G --> H[pair verdicts: SAME / VARIANT / DRIFTED / BYPASS / FALSE] H --> I[Phase III: prism + writer/arbiter/observer/projection] I --> J[findings: CUT_BLOCKER / CUT_COHERENT / FOLLOW_UP / OBSERVATION] - J --> K[append .loctree/canary/JOURNAL.md] - K --> L[report → discuss → decide — no code mutation] + J --> V[werdykt przebiegu: AXES_CLOSED_CANDIDATE / AXES_OPEN / INSTRUMENT_INCOMPLETE / LAUNCHER_CONTRACT_CONFLICT] + V --> K[append .loctree/canary/JOURNAL.md] + K --> L[raport + BUILD/LINT/TEST/RUNTIME=NOT_ASSESSED → discuss → decide — zero mutacji kodu] ``` ## Kontrakt faz diff --git a/vibecrafted-core/vibecrafted_core/skills/pl/vc-canary/SKILL.md b/vibecrafted-core/vibecrafted_core/skills/pl/vc-canary/SKILL.md index 31590aa6..4fc66759 100644 --- a/vibecrafted-core/vibecrafted_core/skills/pl/vc-canary/SKILL.md +++ b/vibecrafted-core/vibecrafted_core/skills/pl/vc-canary/SKILL.md @@ -1,6 +1,6 @@ --- name: canary -version: 2.0.0 +version: 2.1.0 description: > Radar konkurencji o prawdę w repo: wykrywa komponenty rywalizujące o tę samą klasę prawdy (identity, autorstwo, redukcja, finality, delivery, @@ -70,6 +70,20 @@ kandydatów na osie. Wyczuwanie planów przez goły grep, dokumentację albo (wyłącznie ranking hubów). **Zakazane:** ładowanie surowego, wielomegabajtowego `snapshot.json` do kontekstu modelu. +## Prawo jednego instrumentu + +Loctree jest **jedynym instrumentem anatomicznym**: `repo-view`, `focus`, +`slice`, `impact`, `find` (discover/literal), occurrences, `body`, +`follow`, `twins`, `crowd`, `hotspots` i `prism` to odczyty tego samego +instrumentu. `canary_cli` może wyłącznie agregować, walidować kompletność +i utrwalać dowody Loctree — nigdy nie jest niezależnym źródłem prawdy +architektonicznej. `grep`/`rg`/`awk`/`sed`/systemowy `find`/grzebanie +w surowym snapshocie są zakazane jako inwentarz albo dowód nieobecności. +Gdy Loctree nie umie odpowiedzieć na wymagane pytanie: dopisz dokładną +porażkę do `.loctree/loctree-fail.md` docelowego repo, sklasyfikuj +twierdzenie jako `UNRESOLVED` i nigdy nie zaklejaj luki dowodem +zastępczym. + ## Phase 0 — Authority & freshness Żadnego radaru na nieświeżym drzewie. Zapisz, z pokwitowaniami: @@ -112,6 +126,14 @@ of Y files") dowodzi, gdzie decyzja _nie_ żyje. „Przeszukałem semantycznie i wygląda na jedno miejsce" to twierdzenie bez dowodu — dokładnie ten tryb porażki, przed którym ta faza chroni. +Twierdzenie o nieobecności jest dopuszczalne tylko wtedy, gdy zacytowane +pokwitowanie coverage pokazuje **wszystkie** warunki: `offset == 0` · +`emitted == total` · `truncated == false` · `universe.scan_complete == +true` · odpowiednie flagi zaufania prawdziwe. Cenzus liczy **referencje** +(call sites, konsumentów), nigdy same definicje — cenzus definicji ukrył +kiedyś 141 miejsc wywołań. Żadnej liczby bez przypiętego fingerprinta +snapshotu. + Sklasyfikuj każdą konkurującą parę: | Werdykt | Znaczenie | @@ -142,6 +164,13 @@ się tutaj — nigdzie wcześniej — i każdy niesie klasyfikację: | `FOLLOW_UP` | realne, niepilne; idzie do backlogu z dowodami | | `OBSERVATION` | wielowładza udowodniona jako legalna lub uśpiona; obserwuj | +Każdy zbadany wiersz dostaje dodatkowo dokładnie jedną dyspozycję: +`authority_edge` (sprowadza się do domniemanej władzy) · +`proven_non_runtime` (obserwator/projekcja/diagnostyka z udowodnioną +granicą) · `obsolete_residue` (martwy konkurent; kandydat do późniejszego +cuta) · `UNRESOLVED` (dowody Loctree niewystarczające — zapisane, nigdy +po cichu porzucone). Nie wymyślaj właściciela, żeby domknąć graf. + ## Schemat dowodowy (per finding) Oś · konkurenci (`file:line`, LOC) · walczące symbole · werdykt pary · @@ -149,6 +178,22 @@ znak legendy · klasyfikacja · dowód (wyjścia loct cytowane per organ) · pokwitowanie falsyfikacji nieobecności (linia literal coverage). Finding bez któregokolwiek elementu jest kandydatem, nie findingiem. +## Werdykt przebiegu i uczciwe wyjście + +Każdy przebieg zwraca dokładnie jeden werdykt: + +| Werdykt | Warunek | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `AXES_CLOSED_CANDIDATE` | zero wierszy `UNRESOLVED`; każda zachowana krawędź wykonywalna sprowadza się do jednej władzy; zero bypassów; kompletne, nieucięte dowody pod jednym fingerprintem | +| `AXES_OPEN` | cokolwiek mniej — powiedz to wprost | +| `INSTRUMENT_INCOMPLETE` | Loctree nie pokrył wymaganych pytań (fail-log dopisany) | +| `LAUNCHER_CONTRACT_CONFLICT` | odziedziczone instrukcje żądały mutacji lub commitów — stop, zostań przy N=1, raportuj | + +Brakujące historyczne punkty kontrolne pozostają `MISSING` — nigdy nie są +rekonstruowane ani interpolowane. Raport kończy się linią uczciwego stanu +`BUILD/LINT/TEST/RUNTIME=NOT_ASSESSED`: canary jest radarem bez mutacji, +a zielone bramki leżą poza jego jurysdykcją. + ## Kontrakt journala Append-only `./.loctree/canary/JOURNAL.md` w docelowym repo. Każdy @@ -179,9 +224,12 @@ jako **formacja Mode B** Living Tree Rule — briefy per-scope są spisanym planem dispatchu, bramki per-scope wcześniej zadeklarowanymi verifierami, domeny scope'ów rozłączne, a sesja canary jednowątkowym integratorem. **Worker sam tworzy swój worktree** od bazy integracji (launcher niczego -nie provisionuje); commituje w jego wnętrzu; integrator merguje gałęzie -scope'ów sekwencyjnie i zbiera artefakty z dysku worktree przed -sprzątaniem. Nigdy nie parkuj równoległej floty w jednym wspólnym +nie provisionuje) — przypięte SHA jako zamrożony widok, nie poczekalnia +zmian: radar jest wolny od mutacji, więc nie ma commitów scope'ów do +mergowania; integrator kopiuje pliki dowodowe per-oś +(`.loctree/canary/axes/*.json`, gitignored) z każdego worktree przed +sprzątaniem, a zwrócony JSON workera jest zabezpieczeniem. Nigdy nie +parkuj równoległej floty w jednym wspólnym checkoucie (stałe polecenie operatora, 2026-08-20). Każdy scope dostaje własny podkatalog scratchpadu, nazwany w briefie — płaskie wspólne nazwy w tmp kolidują między równoległymi scope'ami. @@ -231,6 +279,12 @@ dane w plikach. - Nadpisywanie journala zamiast dopisywania - Używanie `loct-context-full.json` jako inwentarza plików - Stała liczba agentów zamiast osi z Phase I +- Liczenie definicji zamiast referencji (cenzus definicji ukrył kiedyś + 141 miejsc wywołań) +- Rekonstruowanie albo interpolowanie brakujących historycznych punktów + kontrolnych zamiast zapisania `MISSING` +- Dispatch wycofanego briefu docstring-WRITE katalogera — canary nie + mutuje kodu (kontrakt wycofany 2026-08-24) ## Weryfikacja przed handoffem diff --git a/vibecrafted-core/vibecrafted_core/skills/pl/vc-canary/references/canary-agent-brief.md b/vibecrafted-core/vibecrafted_core/skills/pl/vc-canary/references/canary-agent-brief.md index 4bea9e0d..7d6029c3 100644 --- a/vibecrafted-core/vibecrafted_core/skills/pl/vc-canary/references/canary-agent-brief.md +++ b/vibecrafted-core/vibecrafted_core/skills/pl/vc-canary/references/canary-agent-brief.md @@ -1,58 +1,90 @@ -# Template — one scope = one canary agent +# Template — one axis = one canary radar agent ```text -You are a canary cataloger in repo {ROOT}. +You are a truth-competition radar agent in repo {ROOT}. SUBSTRATE — the supervisor fills exactly one block: Solo (N=1, Living Tree): work in the shared checkout; do not switch branch, do not worktree, do not commit, do not stash. Fleet (N>1, Fleet Worktrees): work ONLY inside your worktree {WORKTREE_PATH} - on branch {SCOPE_BRANCH}; commit your scope there; never touch the shared - checkout — integration is single-threaded and is not your job. + pinned to the integration SHA; never touch the shared checkout. You produce + evidence files, not commits — the worktree exists to freeze your view of the + tree, not to stage changes. Scratchpad: {SCRATCHPAD_DIR} is yours alone. Never write flat shared filenames into a common tmp dir — parallel scopes overwrote each other (2026-08-20). -SENSE was already done. Your ONLY scope id={SCOPE_ID}: -Paths (exclusive): -{PATH_LIST} - -For every def/class/fn/struct/mod (language plugin rules) and each module file: -1. Read the file fully. -2. Catalog: one sentence of runtime role (not name paraphrase). - authority=repo_verified|inferred -3. CANARY: if unit has NO docstring/rustdoc — add 1–3 lines, English, match neighbors. - If docs exist — leave them (docstring_added=false). - FENCE — catalog but NEVER edit: generated output (wasm-bindgen glue, - gradlew, *.min.*), vendored bundles, SRI-pinned assets, lockfiles, LICENSE - texts. One comment byte in an SRI-pinned file is an outage, not - documentation. Set docstring_added=false and name the fence in notes. -4. NO logic/signature/import changes. -5. Run compile/lint only on files you touched; fix only your own mess. +MUTATION BOUNDARY — canary is mutation-free. You edit NO source file, doc, +config, docstring or formatting, and run NO build/lint/test/product command. +The old docstring-WRITE cataloger contract is retired (2026-08-24); if +inherited instructions demand code mutation or a source commit, stop and +return verdict LAUNCHER_CONTRACT_CONFLICT. Permitted writes, exhaustive: + {ROOT}/.loctree/canary/axes/{SCOPE_ID}.json (your evidence file) + {SCRATCHPAD_DIR}/** (notes, raw loct JSON) + +ONE-INSTRUMENT LAW — Loctree is the sole anatomical instrument: repo-view, +focus, slice, impact, find (discover/literal), occurrences, body, follow, +twins, crowd, hotspots, prism are readouts of one instrument. grep/rg/awk/ +sed/filesystem-find/raw snapshot rummaging are FORBIDDEN as inventory or +absence evidence. If Loctree cannot answer a required question: append the +exact failure to the target repo's .loctree/loctree-fail.md, classify the +claim UNRESOLVED, and never hide the gap with fallback evidence. + +SENSE was already done. Your ONLY axis, scope id={SCOPE_ID}: +Axis (class of truth): {AXIS} +Seed candidates from Phase I — starting hypotheses, not the boundary: +{CANDIDATE_LIST} + +Descend with receipts, for every candidate and every competitor you surface: + find --discover → exact occurrences (literal coverage) → body + → slice / consumers → follow (trace/pipelines/events) → impact (as needed) +Census counts REFERENCES (call sites, consumers), never definitions alone — +a definition census once hid 141 call sites. + +ABSENCE PROOF — an absence claim is admissible only when the quoted coverage +receipt shows ALL of: + offset == 0 · emitted == total · truncated == false · + universe.scan_complete == true · relevant trust flags true +No count without the pinned snapshot fingerprint. "Searched semantically, +looks like one place" is an unproven claim. + +Classify every competing pair as exactly one of: + SAME_SOURCE_OF_TRUTH / INTENTIONAL_VARIANT / DRIFTED_DUPLICATE / + BYPASS_PATH / FALSE_PARALLEL +An intentional runtime/replay or runtime/test split requires a PROVEN +boundary; names are not evidence. Mark runtime weight: + 🔥 daily-runtime collision · ⚠ same responsibility, other stage/mode · + ◌ offline/test/alternate competitor. +For the axis prove: writer, arbiter, observer(s), projection(s); the count +of executable routes answering the same runtime question; any bypass around +the presumed authority. Do not invent an owner to complete the graph. +Every examined row gets exactly one disposition: + authority_edge / proven_non_runtime / obsolete_residue / UNRESOLVED. Return ONE JSON object — written to -{ROOT}/.loctree/canary/catalogs/{SCOPE_ID}.json AND returned as your final -message. `{ROOT}` is YOUR substrate root: solo → the shared checkout; fleet → -your own `{WORKTREE_PATH}` (never the integration checkout). `.loctree/` is -gitignored, so a scope-branch merge does NOT carry the catalog — before -removing any worktree the integrator COPIES -`{WORKTREE_PATH}/.loctree/canary/catalogs/*.json` from every scope worktree -into the integration checkout's `.loctree/canary/catalogs/` (your returned -JSON is the backstop) and only then runs `merge-catalog`, which scans exactly -one `--input-dir`. The top-level key is `catalog` — canonical; `units` is accepted only as a -warned legacy alias and must not be used for new output: - -{"scope": "{SCOPE_ID}", - "catalog": [{"file": …, "name": …, "line": …, "kind": …, "role": …, - "docstring_added": …, "authority": …}, …], - "files_touched": […], - "gate": {"compile": "pass|fail|not_run", "lint": …, "detail": …}, - "notes": […], "loctree_hooks": […]} - -`merge-catalog --strict` resolves a language plugin from every unit's `file` -and enforces that plugin's `REQUIRED_FIELDS` and `KIND_ENUM`: `role` and -`authority` are required for every supported language, not just Rust. A -violation names the catalog file and unit and rejects the entire scope before -any merged catalog is written. -notes: dead/twins/name-mismatch — honest, will be loct-cross-checked. A -truthful partial catalog beats a padded complete one — state exact read counts. +{ROOT}/.loctree/canary/axes/{SCOPE_ID}.json AND returned as your final +message ({ROOT} is YOUR substrate root; .loctree/ is gitignored, so the +integrator copies axis files from every scope before cleanup; your returned +JSON is the backstop): + +{"scope": "{SCOPE_ID}", "axis": "{AXIS}", + "verdict": "AXIS_CLOSED_CANDIDATE|AXIS_OPEN|INSTRUMENT_INCOMPLETE|LAUNCHER_CONTRACT_CONFLICT", + "snapshot_fingerprint": "…", "head_sha": "…", + "pairs": [{"a": "file:line", "b": "file:line", "loc": [0, 0], + "symbols": ["…"], "verdict": "…", "legend": "🔥|⚠|◌", + "disposition": "…", "proof": [": ", "…"], + "absence_receipt": ""}], + "roles": {"writer": "…", "arbiter": "…", "observer": ["…"], + "projection": ["…"]}, + "bypasses": ["…"], "unresolved": [{"claim": "…", "loctree_gap": "…"}], + "notes": ["…"]} + +AXIS_CLOSED_CANDIDATE requires: zero UNRESOLVED rows, every retained +executable edge resolving to one authority, zero bypasses, and complete +untruncated evidence under one snapshot fingerprint. Anything less is +AXIS_OPEN — say so plainly. + +Honest exit, always the last line of your final message: +BUILD/LINT/TEST/RUNTIME=NOT_ASSESSED (canary is a mutation-free radar). +A truthful partial radar beats a padded complete one — state exactly what +was not examined and why. ``` diff --git a/vibecrafted-core/vibecrafted_core/skills/vc-aicx/SKILL.md b/vibecrafted-core/vibecrafted_core/skills/vc-aicx/SKILL.md index 774590e7..15c6cd24 100644 --- a/vibecrafted-core/vibecrafted_core/skills/vc-aicx/SKILL.md +++ b/vibecrafted-core/vibecrafted_core/skills/vc-aicx/SKILL.md @@ -114,7 +114,7 @@ work the same. ``` $HOME/.claude/projects///tool-results/.txt # Agent result (most common) $HOME/.claude/projects///subagents/agent-.jsonl # Subagent session -/private/tmp/claude-501/.../tasks/.output # Background task +/private/tmp/claude-//tasks/.output # Background task $HOME/.claude/projects//.jsonl # Full session ``` diff --git a/vibecrafted-core/vibecrafted_core/skills/vc-canary/FLOW.md b/vibecrafted-core/vibecrafted_core/skills/vc-canary/FLOW.md index ce433e8c..23ca2700 100644 --- a/vibecrafted-core/vibecrafted_core/skills/vc-canary/FLOW.md +++ b/vibecrafted-core/vibecrafted_core/skills/vc-canary/FLOW.md @@ -11,8 +11,9 @@ flowchart TD G --> H[pair verdicts: SAME / VARIANT / DRIFTED / BYPASS / FALSE] H --> I[Phase III: prism + writer/arbiter/observer/projection] I --> J[findings: CUT_BLOCKER / CUT_COHERENT / FOLLOW_UP / OBSERVATION] - J --> K[append .loctree/canary/JOURNAL.md] - K --> L[report → discuss → decide — no code mutation] + J --> V[run verdict: AXES_CLOSED_CANDIDATE / AXES_OPEN / INSTRUMENT_INCOMPLETE / LAUNCHER_CONTRACT_CONFLICT] + V --> K[append .loctree/canary/JOURNAL.md] + K --> L[report + BUILD/LINT/TEST/RUNTIME=NOT_ASSESSED → discuss → decide — no code mutation] ``` ## Phase contract diff --git a/vibecrafted-core/vibecrafted_core/skills/vc-canary/SKILL.md b/vibecrafted-core/vibecrafted_core/skills/vc-canary/SKILL.md index 10ecc5ab..6472f995 100644 --- a/vibecrafted-core/vibecrafted_core/skills/vc-canary/SKILL.md +++ b/vibecrafted-core/vibecrafted_core/skills/vc-canary/SKILL.md @@ -1,6 +1,6 @@ --- name: canary -version: 2.0.0 +version: 2.1.0 description: > Truth-competition radar for a repo: detect components competing for the same class of truth (identity, authorship, reduction, finality, delivery, @@ -68,6 +68,19 @@ repo" instead of Loctree organs is a process failure. ranking only). **Forbidden:** loading raw multi‑MB `snapshot.json` into the model context. +## One-instrument law + +Loctree is the **sole anatomical instrument**: `repo-view`, `focus`, +`slice`, `impact`, `find` (discover/literal), occurrences, `body`, +`follow`, `twins`, `crowd`, `hotspots` and `prism` are readouts of the same +instrument. `canary_cli` may only aggregate, validate completeness and +preserve Loctree evidence — never act as an independent source of +architectural truth. `grep`/`rg`/`awk`/`sed`/filesystem `find`/raw snapshot +rummaging are forbidden as inventory or absence evidence. When Loctree +cannot answer a required question: append the exact failure to the target +repo's `.loctree/loctree-fail.md`, classify the claim `UNRESOLVED`, and +never paper over the gap with fallback evidence. + ## Phase 0 — Authority & freshness No radar on a stale tree. Record, with receipts: @@ -108,6 +121,13 @@ where a decision does _not_ live. "I searched semantically and it looks like one place" is an unproven claim — the exact failure mode this phase exists to prevent. +An absence claim is admissible only when the quoted coverage receipt shows +**all** of: `offset == 0` · `emitted == total` · `truncated == false` · +`universe.scan_complete == true` · relevant trust flags true. Census counts +**references** (call sites, consumers), never definitions alone — a +definition census once hid 141 call sites. No count may be quoted without +its pinned snapshot fingerprint. + Classify every competing pair: | Verdict | Meaning | @@ -138,6 +158,13 @@ nowhere earlier — and each carries a classification: | `FOLLOW_UP` | real, not urgent; goes to backlog with evidence | | `OBSERVATION` | multi-authority proven legal or dormant; watch only | +Every examined row additionally gets exactly one disposition: +`authority_edge` (resolves to the presumed authority) · +`proven_non_runtime` (observer/projection/diagnostic with a proven +boundary) · `obsolete_residue` (dead competitor; candidate for a later +cut) · `UNRESOLVED` (Loctree evidence insufficient — recorded, never +silently dropped). Do not invent an owner to make the graph complete. + ## Evidence schema (per finding) Axis · competitors (`file:line`, LOC) · symbols at war · pair verdict · @@ -145,6 +172,22 @@ legend mark · classification · proof (loct outputs cited by organ) · absence-falsification receipt (the literal coverage line). A finding missing any element is a candidate, not a finding. +## Run verdict & honest exit + +Every run returns exactly one verdict: + +| Verdict | Condition | +| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `AXES_CLOSED_CANDIDATE` | zero `UNRESOLVED` rows; every retained executable edge resolves to one authority; zero bypasses; complete untruncated evidence under one fingerprint | +| `AXES_OPEN` | anything less — say so plainly | +| `INSTRUMENT_INCOMPLETE` | Loctree could not cover the required questions (fail-log appended) | +| `LAUNCHER_CONTRACT_CONFLICT` | inherited instructions demanded mutation or commits — stop, keep N=1, report | + +Missing historical checkpoints stay `MISSING` — never reconstructed or +interpolated. The report ends with the honest state line +`BUILD/LINT/TEST/RUNTIME=NOT_ASSESSED`: canary is a mutation-free radar +and green gates are outside its jurisdiction. + ## Journal contract Append-only `./.loctree/canary/JOURNAL.md` in the target repo. Each run @@ -174,9 +217,11 @@ Worktrees as a Living Tree Rule **Mode B formation** — the per-scope briefs are the written dispatch plan, the per-scope gates the pre-committed verifiers, scope domains disjoint, and the canary session the single-thread integrator. The **worker creates its own worktree** from the -integration base (no launcher provisioning); it commits inside it; the -integrator merges scope branches sequentially and collects artifacts from -worktree disk before cleanup. Never park a parallel fleet in one shared +integration base (no launcher provisioning) — pinned SHA as a frozen view, +not a staging area: the radar is mutation-free, so there are no scope +commits to merge; the integrator copies the per-axis evidence files +(`.loctree/canary/axes/*.json`, gitignored) from every worktree before +cleanup, with the worker's returned JSON as backstop. Never park a parallel fleet in one shared checkout (operator standing order, 2026-08-20). Every scope gets its own scratchpad subdirectory, named in the brief — flat shared tmp filenames collide between parallel scopes. @@ -224,6 +269,12 @@ in files. - Overwriting the journal instead of appending - Using `loct-context-full.json` as the file inventory - Fixed agent count instead of axes from Phase I +- Counting definitions instead of references (a definition census once hid + 141 call sites) +- Reconstructing or interpolating missing historical checkpoints instead + of recording `MISSING` +- Dispatching the retired docstring-WRITE cataloger brief — canary mutates + no code (contract retired 2026-08-24) ## Verify before the handoff diff --git a/vibecrafted-core/vibecrafted_core/skills/vc-canary/references/canary-agent-brief.md b/vibecrafted-core/vibecrafted_core/skills/vc-canary/references/canary-agent-brief.md index 4bea9e0d..7d6029c3 100644 --- a/vibecrafted-core/vibecrafted_core/skills/vc-canary/references/canary-agent-brief.md +++ b/vibecrafted-core/vibecrafted_core/skills/vc-canary/references/canary-agent-brief.md @@ -1,58 +1,90 @@ -# Template — one scope = one canary agent +# Template — one axis = one canary radar agent ```text -You are a canary cataloger in repo {ROOT}. +You are a truth-competition radar agent in repo {ROOT}. SUBSTRATE — the supervisor fills exactly one block: Solo (N=1, Living Tree): work in the shared checkout; do not switch branch, do not worktree, do not commit, do not stash. Fleet (N>1, Fleet Worktrees): work ONLY inside your worktree {WORKTREE_PATH} - on branch {SCOPE_BRANCH}; commit your scope there; never touch the shared - checkout — integration is single-threaded and is not your job. + pinned to the integration SHA; never touch the shared checkout. You produce + evidence files, not commits — the worktree exists to freeze your view of the + tree, not to stage changes. Scratchpad: {SCRATCHPAD_DIR} is yours alone. Never write flat shared filenames into a common tmp dir — parallel scopes overwrote each other (2026-08-20). -SENSE was already done. Your ONLY scope id={SCOPE_ID}: -Paths (exclusive): -{PATH_LIST} - -For every def/class/fn/struct/mod (language plugin rules) and each module file: -1. Read the file fully. -2. Catalog: one sentence of runtime role (not name paraphrase). - authority=repo_verified|inferred -3. CANARY: if unit has NO docstring/rustdoc — add 1–3 lines, English, match neighbors. - If docs exist — leave them (docstring_added=false). - FENCE — catalog but NEVER edit: generated output (wasm-bindgen glue, - gradlew, *.min.*), vendored bundles, SRI-pinned assets, lockfiles, LICENSE - texts. One comment byte in an SRI-pinned file is an outage, not - documentation. Set docstring_added=false and name the fence in notes. -4. NO logic/signature/import changes. -5. Run compile/lint only on files you touched; fix only your own mess. +MUTATION BOUNDARY — canary is mutation-free. You edit NO source file, doc, +config, docstring or formatting, and run NO build/lint/test/product command. +The old docstring-WRITE cataloger contract is retired (2026-08-24); if +inherited instructions demand code mutation or a source commit, stop and +return verdict LAUNCHER_CONTRACT_CONFLICT. Permitted writes, exhaustive: + {ROOT}/.loctree/canary/axes/{SCOPE_ID}.json (your evidence file) + {SCRATCHPAD_DIR}/** (notes, raw loct JSON) + +ONE-INSTRUMENT LAW — Loctree is the sole anatomical instrument: repo-view, +focus, slice, impact, find (discover/literal), occurrences, body, follow, +twins, crowd, hotspots, prism are readouts of one instrument. grep/rg/awk/ +sed/filesystem-find/raw snapshot rummaging are FORBIDDEN as inventory or +absence evidence. If Loctree cannot answer a required question: append the +exact failure to the target repo's .loctree/loctree-fail.md, classify the +claim UNRESOLVED, and never hide the gap with fallback evidence. + +SENSE was already done. Your ONLY axis, scope id={SCOPE_ID}: +Axis (class of truth): {AXIS} +Seed candidates from Phase I — starting hypotheses, not the boundary: +{CANDIDATE_LIST} + +Descend with receipts, for every candidate and every competitor you surface: + find --discover → exact occurrences (literal coverage) → body + → slice / consumers → follow (trace/pipelines/events) → impact (as needed) +Census counts REFERENCES (call sites, consumers), never definitions alone — +a definition census once hid 141 call sites. + +ABSENCE PROOF — an absence claim is admissible only when the quoted coverage +receipt shows ALL of: + offset == 0 · emitted == total · truncated == false · + universe.scan_complete == true · relevant trust flags true +No count without the pinned snapshot fingerprint. "Searched semantically, +looks like one place" is an unproven claim. + +Classify every competing pair as exactly one of: + SAME_SOURCE_OF_TRUTH / INTENTIONAL_VARIANT / DRIFTED_DUPLICATE / + BYPASS_PATH / FALSE_PARALLEL +An intentional runtime/replay or runtime/test split requires a PROVEN +boundary; names are not evidence. Mark runtime weight: + 🔥 daily-runtime collision · ⚠ same responsibility, other stage/mode · + ◌ offline/test/alternate competitor. +For the axis prove: writer, arbiter, observer(s), projection(s); the count +of executable routes answering the same runtime question; any bypass around +the presumed authority. Do not invent an owner to complete the graph. +Every examined row gets exactly one disposition: + authority_edge / proven_non_runtime / obsolete_residue / UNRESOLVED. Return ONE JSON object — written to -{ROOT}/.loctree/canary/catalogs/{SCOPE_ID}.json AND returned as your final -message. `{ROOT}` is YOUR substrate root: solo → the shared checkout; fleet → -your own `{WORKTREE_PATH}` (never the integration checkout). `.loctree/` is -gitignored, so a scope-branch merge does NOT carry the catalog — before -removing any worktree the integrator COPIES -`{WORKTREE_PATH}/.loctree/canary/catalogs/*.json` from every scope worktree -into the integration checkout's `.loctree/canary/catalogs/` (your returned -JSON is the backstop) and only then runs `merge-catalog`, which scans exactly -one `--input-dir`. The top-level key is `catalog` — canonical; `units` is accepted only as a -warned legacy alias and must not be used for new output: - -{"scope": "{SCOPE_ID}", - "catalog": [{"file": …, "name": …, "line": …, "kind": …, "role": …, - "docstring_added": …, "authority": …}, …], - "files_touched": […], - "gate": {"compile": "pass|fail|not_run", "lint": …, "detail": …}, - "notes": […], "loctree_hooks": […]} - -`merge-catalog --strict` resolves a language plugin from every unit's `file` -and enforces that plugin's `REQUIRED_FIELDS` and `KIND_ENUM`: `role` and -`authority` are required for every supported language, not just Rust. A -violation names the catalog file and unit and rejects the entire scope before -any merged catalog is written. -notes: dead/twins/name-mismatch — honest, will be loct-cross-checked. A -truthful partial catalog beats a padded complete one — state exact read counts. +{ROOT}/.loctree/canary/axes/{SCOPE_ID}.json AND returned as your final +message ({ROOT} is YOUR substrate root; .loctree/ is gitignored, so the +integrator copies axis files from every scope before cleanup; your returned +JSON is the backstop): + +{"scope": "{SCOPE_ID}", "axis": "{AXIS}", + "verdict": "AXIS_CLOSED_CANDIDATE|AXIS_OPEN|INSTRUMENT_INCOMPLETE|LAUNCHER_CONTRACT_CONFLICT", + "snapshot_fingerprint": "…", "head_sha": "…", + "pairs": [{"a": "file:line", "b": "file:line", "loc": [0, 0], + "symbols": ["…"], "verdict": "…", "legend": "🔥|⚠|◌", + "disposition": "…", "proof": [": ", "…"], + "absence_receipt": ""}], + "roles": {"writer": "…", "arbiter": "…", "observer": ["…"], + "projection": ["…"]}, + "bypasses": ["…"], "unresolved": [{"claim": "…", "loctree_gap": "…"}], + "notes": ["…"]} + +AXIS_CLOSED_CANDIDATE requires: zero UNRESOLVED rows, every retained +executable edge resolving to one authority, zero bypasses, and complete +untruncated evidence under one snapshot fingerprint. Anything less is +AXIS_OPEN — say so plainly. + +Honest exit, always the last line of your final message: +BUILD/LINT/TEST/RUNTIME=NOT_ASSESSED (canary is a mutation-free radar). +A truthful partial radar beats a padded complete one — state exactly what +was not examined and why. ``` diff --git a/vibecrafted-core/vibecrafted_core/spawn.py b/vibecrafted-core/vibecrafted_core/spawn.py index 712d1ff8..beea3e40 100644 --- a/vibecrafted-core/vibecrafted_core/spawn.py +++ b/vibecrafted-core/vibecrafted_core/spawn.py @@ -4,21 +4,28 @@ import argparse import datetime as dt +import hashlib +import inspect import json import os import re import shlex +import signal import subprocess +import sys import threading +import time +import uuid from collections.abc import Callable, Sequence from dataclasses import dataclass, field +from functools import lru_cache from pathlib import Path from shutil import which from typing import Any from .agent_dispatch import extract_session_id, sandbox_supported from .clock import utc_now_iso -from .control_plane import ensure_session_id, normalize_run_root +from .control_plane import control_plane_home, ensure_session_id, normalize_run_root from .events import append_event from .report_contract import ( CLAIM_DIGEST_ENV, @@ -32,6 +39,2348 @@ EventCallback = Callable[[dict[str, Any]], None] +POLICY_PROVIDERS = ("codex", "claude", "agy", "grok", "junie") +RUNTIME_POLICIES = ("local-native", "local-worktrees", "local-vm", "cloud-soon") +PERMISSION_POLICIES = ("bypass", "auto", "accept-edits", "read-only") +POLICY_MODES = ("interactive", "headless") +QUOTA_PRESET_TOKENS = 250_000 +QUOTA_MAX_TOKENS = 10_000_000 +QUOTA_EXHAUSTED_EXIT_CODE = 75 +OPERATOR_POLICIES = ("none", "auto", "claude") +CONTINUITY_MODES = ("full-lineage", "fresh", "bare-fork") +_CONTINUITY_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") +_INHERITED_CONTINUITY_ENV = ( + "AICX_CONTEXT_FILE", + "AICX_CONTINUITY_FILE", + "CLAUDE_CODE_SESSION_ID", + "CODEX_SESSION_ID", + "VIBECRAFTED_OPERATOR_SESSION_ID", + "VIBECRAFTED_PARENT_RUN_ID", + "VIBECRAFTED_PARENT_SESSION_ID", + "VIBECRAFTED_RESUME_CONTEXT", + "VIBECRAFTED_RESUME_META", + "VIBECRAFTED_LOOP_STATE_FILE", + "VIBECRAFTED_LOOP_NR", + "SPAWN_LOOP_NR", +) +USER_OBSERVED_WARNING = ( + "User-observed only: no Operator Agent is supervising this Agent Workspace." +) + + +@dataclass(frozen=True) +class ProviderPolicy: + """Canonical provider/runtime/permission decision shared by CLI and UI.""" + + provider: str + runtime: str + permissions: str + mode: str + supported: bool + flags: tuple[str, ...] = () + behavior: str = "" + reason: str = "" + + def as_dict(self) -> dict[str, Any]: + return { + "provider": self.provider, + "runtime": self.runtime, + "permissions": self.permissions, + "mode": self.mode, + "supported": self.supported, + "status": "SUPPORTED" if self.supported else "UNSUPPORTED", + "flags": list(self.flags), + "behavior": self.behavior, + "reason": self.reason, + } + + +@dataclass(frozen=True) +class QuotaPolicy: + """Validated User-selected policy for one interactive provider session.""" + + kind: str + token_budget: int | None + selection: str + warning: str = "" + + def as_dict(self) -> dict[str, Any]: + return { + "kind": self.kind, + "token_budget": self.token_budget, + "selection": self.selection, + "warning": self.warning, + } + + +@dataclass(frozen=True) +class OperatorAgentPolicy: + """Typed decision for a distinct Operator Agent supervising one child Agent.""" + + selection: str + provider: str | None + permissions: str | None + supported: bool + warning: str = "" + reason: str = "" + + def as_dict(self) -> dict[str, Any]: + return { + "selection": self.selection, + "provider": self.provider, + "permissions": self.permissions, + "supported": self.supported, + "status": "SUPPORTED" if self.supported else "UNSUPPORTED", + "warning": self.warning, + "reason": self.reason, + } + + +@dataclass(frozen=True) +class ContinuityPolicy: + """One fail-closed continuity decision resolved before runtime truth.""" + + mode: str + lineage_id: str + parent_provider_session_id: str = "" + supported: bool = True + reason: str = "" + + def as_dict(self) -> dict[str, Any]: + return { + "mode": self.mode, + "lineage_id": self.lineage_id, + "parent_provider_session_id": self.parent_provider_session_id, + "supported": self.supported, + "status": "SUPPORTED" if self.supported else "UNSUPPORTED", + "reason": self.reason, + } + + +@dataclass(frozen=True) +class ContinuityMaterial: + """Private bounded transport material; receipts project hashes, not bodies.""" + + policy: ContinuityPolicy + prompt: str + context_path: str = "" + loop_state_path: str = "" + context_sha256: str = "" + loop_sha256: str = "" + + def receipt(self) -> dict[str, Any]: + return { + **self.policy.as_dict(), + "context_sha256": self.context_sha256, + "loop_sha256": self.loop_sha256, + "materialized": bool(self.context_sha256 and self.loop_sha256) + if self.policy.mode == "full-lineage" + else True, + } + + +@dataclass(frozen=True) +class ProviderUsageCapability: + """Provider-specific proof that live usage can be attributed to one child.""" + + provider: str + supported: bool + source: str = "" + provider_version: str = "" + reason: str = "" + + def as_dict(self) -> dict[str, Any]: + return { + "provider": self.provider, + "supported": self.supported, + "status": "SUPPORTED" if self.supported else "UNSUPPORTED", + "source": self.source, + "provider_version": self.provider_version, + "reason": self.reason, + } + + +@dataclass(frozen=True) +class InteractiveWorkspaceLaunch: + """Durable parent/effective-root truth for one interactive Agent launch.""" + + run_id: str + provider: str + runtime: str + permissions: str + parent_root: str + effective_root: str + workspace_id: str + vibecrafted_session_id: str + meta_path: Path + receipt: dict[str, Any] + quota_policy: QuotaPolicy + usage_capability: ProviderUsageCapability + provider_session_id: str + worktree_manager: Any | None = field(default=None, repr=False, compare=False) + worktree_geometry: Any | None = field(default=None, repr=False, compare=False) + + +_PERMISSION_CONTRACT: dict[str, dict[str, tuple[tuple[str, ...], str] | None]] = { + "codex": { + "bypass": ( + ("--dangerously-bypass-approvals-and-sandbox",), + "all actions bypass approval and sandbox", + ), + "auto": ( + ("--ask-for-approval", "on-request", "--sandbox", "workspace-write"), + "provider requests approval when needed", + ), + "accept-edits": None, + "read-only": ( + ("--ask-for-approval", "never", "--sandbox", "read-only"), + "writes and escalations fail closed", + ), + }, + "claude": { + "bypass": ( + ("--permission-mode", "bypassPermissions"), + "all actions bypass permission prompts", + ), + "auto": ( + ("--permission-mode", "auto"), + "provider selects when to request permission", + ), + "accept-edits": ( + ("--permission-mode", "acceptEdits"), + "edits pass; other actions require permission and fail closed without an operator", + ), + "read-only": ( + ("--permission-mode", "plan"), + "plan mode prevents edits and execution", + ), + }, + "agy": { + "bypass": ( + ("--dangerously-skip-permissions",), + "all actions bypass permission prompts", + ), + "auto": ((), "provider default permission prompts remain active"), + "accept-edits": ( + ("--mode", "accept-edits"), + "edits pass; other actions require permission and fail closed without an operator", + ), + "read-only": (("--mode", "plan"), "plan mode prevents edits and execution"), + }, + "grok": { + "bypass": ( + ("--permission-mode", "bypassPermissions"), + "all actions bypass permission prompts", + ), + "auto": ( + ("--permission-mode", "auto"), + "provider selects when to request permission", + ), + "accept-edits": ( + ("--permission-mode", "acceptEdits"), + "edits pass; other actions require permission and fail closed without an operator", + ), + "read-only": ( + ("--permission-mode", "plan"), + "plan mode prevents edits and execution", + ), + }, + "junie": { + "bypass": (("--brave",), "interactive brave mode bypasses confirmations"), + "auto": ((), "provider default permission prompts remain active"), + "accept-edits": None, + "read-only": ( + ("--plan",), + "interactive plan mode prevents edits and execution", + ), + }, +} + + +def resolve_quota_policy( + selection: str | int | None, + *, + runtime: str, + mode: str = "interactive", +) -> QuotaPolicy: + """Validate one bounded or explicitly User-observed unlimited policy.""" + raw = "safe" if selection is None else str(selection).strip().lower() + if not raw or raw == "safe": + return QuotaPolicy("bounded", QUOTA_PRESET_TOKENS, "safe") + if raw == "unlimited": + if mode != "interactive" or runtime != "local-native": + raise ValueError( + "unlimited quota is restricted to directly User-observed local-native sessions" + ) + return QuotaPolicy( + "unlimited", + None, + "unlimited", + "User selected unlimited usage; Vibecrafted will measure but will not terminate on token usage", + ) + try: + budget = int(raw, 10) + except ValueError as exc: + raise ValueError( + "token budget must be safe, unlimited, or a positive integer" + ) from exc + if budget <= 0: + raise ValueError("token budget must be a positive integer") + if budget > QUOTA_MAX_TOKENS: + raise ValueError(f"token budget must not exceed {QUOTA_MAX_TOKENS}") + return QuotaPolicy("bounded", budget, raw) + + +def _validated_continuity_id(value: str, *, label: str) -> str: + normalized = str(value or "").strip() + if not normalized or not _CONTINUITY_ID.fullmatch(normalized): + raise ValueError(f"{label} must be one explicit, well-formed identifier") + return normalized + + +def _ambient_parent_lineage(env: dict[str, str]) -> str: + for name in ( + "VIBECRAFTED_RUN_ID", + "CODEX_SESSION_ID", + "CLAUDE_CODE_SESSION_ID", + "VIBECRAFTED_OPERATOR_SESSION_ID", + ): + candidate = str(env.get(name) or "").strip() + if candidate and _CONTINUITY_ID.fullmatch(candidate): + return candidate + return "" + + +def resolve_continuity_policy( + selection: str | None, + *, + provider: str, + parent_session_id: str = "", + parent_lineage_id: str = "", + env: dict[str, str] | None = None, +) -> ContinuityPolicy: + """Resolve one continuity mode without inferring a native session target.""" + mode = str(selection or "fresh").strip().lower() + if mode not in CONTINUITY_MODES: + raise ValueError( + f"unknown continuity mode {mode!r}; choose {', '.join(CONTINUITY_MODES)}" + ) + ambient = dict(os.environ if env is None else env) + if mode == "fresh": + if parent_session_id or parent_lineage_id: + raise ValueError( + "fresh continuity rejects parent session and lineage input" + ) + return ContinuityPolicy(mode="fresh", lineage_id=f"fresh:{uuid.uuid4()}") + if mode == "full-lineage": + if parent_session_id: + raise ValueError("full-lineage never accepts a native parent session") + lineage = parent_lineage_id or _ambient_parent_lineage(ambient) + return ContinuityPolicy( + mode=mode, + lineage_id=_validated_continuity_id(lineage, label="parent lineage id"), + ) + + parent = _validated_continuity_id( + parent_session_id, label="bare-fork parent provider-session id" + ) + if parent_lineage_id: + raise ValueError("bare-fork accepts only an explicit provider-session parent") + current_ids = { + str(ambient.get(name) or "").strip() + for name in ( + "CODEX_SESSION_ID", + "CLAUDE_CODE_SESSION_ID", + "VIBECRAFTED_OPERATOR_SESSION_ID", + "VIBECRAFTED_PROVIDER_SESSION_ID", + ) + } + if parent in current_ids: + raise ValueError("bare-fork parent is the current provider session") + from .continuity.capabilities import ( + PROBE_CONFIRMED, + SUPPORTED, + capability_for, + probe, + ) + + capability = capability_for(provider) + if capability.native_fork != SUPPORTED: + raise ValueError( + f"bare-fork unsupported for {provider}: {capability.fork_runtime_restrictions}" + ) + evidence = probe(provider, refresh=True) + if evidence.state != PROBE_CONFIRMED: + raise ValueError( + f"bare-fork capability probe did not confirm {provider}: {evidence.detail}" + ) + return ContinuityPolicy( + mode=mode, + lineage_id=parent, + parent_provider_session_id=parent, + ) + + +def _sha256_file(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _materialize_continuity( + policy: ContinuityPolicy, + *, + provider: str, + root: Path, + run_id: str, + prompt: str, +) -> ContinuityMaterial: + if policy.mode != "full-lineage": + return ContinuityMaterial(policy=policy, prompt=prompt) + from .aicx_session_chain import ( + CliSessionChain, + assemble_resume_continuity_pack, + pack_contains_recover_instruction, + ) + + loop_path = Path( + os.environ.get("VIBECRAFTED_LOOP_STATE_FILE", "").strip() + or root / ".vibecrafted" / "operator-loop.local.md" + ).expanduser() + try: + loop_text = loop_path.read_text(encoding="utf-8") + except (FileNotFoundError, OSError) as exc: + raise ValueError( + f"full-lineage requires readable current LOOP state: {exc}" + ) from exc + lines = loop_text.splitlines() + fields: dict[str, str] = {} + prompt_start = 0 + if lines[:1] == ["---"]: + for index, line in enumerate(lines[1:], start=1): + if line == "---": + prompt_start = index + 1 + break + if ":" in line: + key, raw = line.split(":", 1) + fields[key.strip()] = raw.strip().strip('"') + loop_prompt = "\n".join(lines[prompt_start:]).strip() + if fields.get("active") != "true" or not loop_prompt: + raise ValueError( + "full-lineage requires an active LOOP with a non-empty durable goal" + ) + aicx_bin = which("aicx", path=agent_tool_search_path()) + if not aicx_bin: + raise ValueError("full-lineage requires the aicx executable") + run_dir = control_plane_home() / "runtime_runs" / run_id + context_path = run_dir / "continuity-pack.md" + meta_path = run_dir / "continuity-pack.meta.json" + pack = assemble_resume_continuity_pack( + agent=provider, + root=root, + hours=48, + context_file=context_path, + meta_file=meta_path, + chain=CliSessionChain(aicx_bin), + ) + required_sections = ( + "## Session catalog", + "## Continuity", + "## Operator instruction", + ) + if ( + pack.mode != "new_session" + or pack.empty_kind != "none" + or pack.session_count < 1 + or pack.degradations + or not all(section in pack.body for section in required_sections) + or pack_contains_recover_instruction(pack.body) + ): + context_path.unlink(missing_ok=True) + meta_path.unlink(missing_ok=True) + raise ValueError( + "full-lineage continuity pack is empty, stale, degraded, or not new-session safe" + ) + loop_copy = run_dir / "current-loop.md" + loop_copy.write_text(loop_text, encoding="utf-8") + bounded_prompt = ( + f"{prompt}\n\n" + "Continuity mode: full-lineage. Start a new provider session; never attach.\n" + f"Read bounded AICX continuity: {context_path}\n" + f"Read durable current goal/LOOP: {loop_copy}\n" + f"Parent lineage evidence: {policy.lineage_id}\n" + ) + return ContinuityMaterial( + policy=policy, + prompt=bounded_prompt, + context_path=str(context_path), + loop_state_path=str(loop_copy), + context_sha256=_sha256_file(context_path), + loop_sha256=_sha256_file(loop_copy), + ) + + +def _fresh_child_environment( + env: dict[str, str], policy: ContinuityPolicy +) -> dict[str, str]: + child = dict(env) + if policy.mode == "fresh": + for name in _INHERITED_CONTINUITY_ENV: + child.pop(name, None) + for name in tuple(child): + if name.startswith(("VIBECRAFTED_RESUME_", "AICX_CONTINUITY_")): + child.pop(name, None) + return child + + +def continuity_policy_capabilities( + provider: str, + *, + root: str | os.PathLike[str], + explicit_parent: str = "", + env: dict[str, str] | None = None, +) -> dict[str, dict[str, Any]]: + """Project exact selector availability without materializing or spawning.""" + ambient = dict(os.environ if env is None else env) + root_path = Path(root).expanduser().resolve() + parent_lineage = explicit_parent or _ambient_parent_lineage(ambient) + loop_path = Path( + ambient.get("VIBECRAFTED_LOOP_STATE_FILE", "").strip() + or root_path / ".vibecrafted" / "operator-loop.local.md" + ).expanduser() + full_reason = "" + if not parent_lineage: + full_reason = "no explicit/current parent lineage id" + elif not loop_path.is_file(): + full_reason = f"current LOOP state missing: {loop_path}" + elif which("aicx", path=agent_tool_search_path(ambient)) is None: + full_reason = "aicx executable not found" + else: + try: + loop_text = loop_path.read_text(encoding="utf-8") + except OSError as exc: + full_reason = f"current LOOP unreadable: {exc}" + else: + if ( + "active: true" not in loop_text + or not loop_text.split("---")[-1].strip() + ): + full_reason = "current LOOP is inactive or has no durable goal" + bare_reason = "" + if not explicit_parent: + bare_reason = "expert-only: provide an explicit parent provider-session id" + else: + try: + resolve_continuity_policy( + "bare-fork", + provider=provider, + parent_session_id=explicit_parent, + env=ambient, + ) + except ValueError as exc: + bare_reason = str(exc) + return { + "full-lineage": { + "available": not full_reason, + "recommended": True, + "reason": full_reason, + }, + "fresh": { + "available": True, + "recommended": False, + "reason": "no inherited memory is supplied", + }, + "bare-fork": { + "available": not bare_reason, + "recommended": False, + "reason": bare_reason, + }, + } + + +def resolve_provider_usage_capability( + provider: str, + *, + executable: str | None = None, +) -> ProviderUsageCapability: + """Probe the exact installed executable for an attributable live source.""" + resolved = executable or which(provider, path=agent_tool_search_path()) + if not resolved: + return ProviderUsageCapability( + provider, False, reason=f"{provider} executable not found" + ) + resolved_path = str(Path(resolved).expanduser().resolve()) + try: + stat = Path(resolved_path).stat() + except OSError as exc: + return ProviderUsageCapability( + provider, False, reason=f"cannot inspect {provider} executable: {exc}" + ) + return _probe_provider_usage_capability( + provider, resolved_path, stat.st_mtime_ns, stat.st_size + ) + + +@lru_cache(maxsize=32) +def _probe_provider_usage_capability( + provider: str, + executable: str, + _mtime_ns: int, + _size: int, +) -> ProviderUsageCapability: + if provider != "claude": + return ProviderUsageCapability( + provider, + False, + reason=( + f"{provider} exposes no verified live, child-attributable, monotonic " + "usage side channel compatible with inherited interactive TTY" + ), + ) + try: + version_result = subprocess.run( + [executable, "--version"], + check=False, + capture_output=True, + text=True, + timeout=5, + ) + help_result = subprocess.run( + [executable, "--help"], + check=False, + capture_output=True, + text=True, + timeout=5, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + return ProviderUsageCapability( + provider, False, reason=f"Claude capability probe failed: {exc}" + ) + version = (version_result.stdout or version_result.stderr).splitlines() + version_text = version[0].strip() if version else "" + help_text = f"{help_result.stdout}\n{help_result.stderr}" + if version_result.returncode != 0 or not version_text: + return ProviderUsageCapability( + provider, + False, + reason="Claude version probe did not return exact version truth", + ) + if help_result.returncode != 0 or "--session-id " not in help_text: + return ProviderUsageCapability( + provider, + False, + provider_version=version_text, + reason="installed Claude does not expose --session-id ", + ) + return ProviderUsageCapability( + provider, + True, + source="claude-transcript-jsonl-v1", + provider_version=version_text.split()[0], + ) + + +def resolve_provider_policy( + provider: str, + runtime: str, + permissions: str, + mode: str, +) -> ProviderPolicy: + """Resolve one policy cell without approximating unsupported semantics.""" + if provider not in POLICY_PROVIDERS: + raise ValueError(f"unsupported provider: {provider}") + if runtime not in RUNTIME_POLICIES: + raise ValueError(f"unsupported runtime policy: {runtime}") + if permissions not in PERMISSION_POLICIES: + raise ValueError(f"unsupported permission policy: {permissions}") + if mode not in POLICY_MODES: + raise ValueError(f"unsupported policy mode: {mode}") + if runtime == "cloud-soon": + return ProviderPolicy( + provider, + runtime, + permissions, + mode, + False, + reason="cloud runtime is coming soon", + ) + if runtime == "local-vm": + return ProviderPolicy( + provider, + runtime, + permissions, + mode, + False, + reason="Docker/Colima may be present, but canonical init has no VM entrypoint", + ) + if runtime == "local-worktrees" and mode != "interactive": + return ProviderPolicy( + provider, + runtime, + permissions, + mode, + False, + reason="local worktrees are available only for interactive Agent Workspaces", + ) + cell = _PERMISSION_CONTRACT[provider][permissions] + if cell is None: + return ProviderPolicy( + provider, + runtime, + permissions, + mode, + False, + reason=f"{provider} exposes no native {permissions} policy", + ) + if ( + provider == "junie" + and mode == "headless" + and permissions in {"bypass", "read-only"} + ): + return ProviderPolicy( + provider, + runtime, + permissions, + mode, + False, + reason=f"junie {permissions} is interactive-only", + ) + flags, behavior = cell + return ProviderPolicy(provider, runtime, permissions, mode, True, flags, behavior) + + +def runtime_policy_capabilities(provider: str) -> dict[str, dict[str, Any]]: + """Report host substrate separately from canonical-launcher availability.""" + provider_executable = which(provider, path=agent_tool_search_path()) + provider_found = provider_executable is not None + usage = resolve_provider_usage_capability(provider, executable=provider_executable) + git_found = which("git") is not None + try: + from .dispatch.supervisor import run_dispatch + + dispatch_manages_worktrees = ( + "manage_worktrees" in inspect.signature(run_dispatch).parameters + ) + except (ImportError, ValueError): + dispatch_manages_worktrees = False + worktree_substrate = git_found and dispatch_manages_worktrees + vm_found = which("docker") is not None or which("colima") is not None + return { + "local-native": { + "available": provider_found and usage.supported, + "usage_capability": usage.as_dict(), + "reason": ( + "" + if provider_found and usage.supported + else ( + f"{provider} executable not found" + if not provider_found + else usage.reason + ) + ), + }, + "local-worktrees": { + "available": provider_found and worktree_substrate and usage.supported, + "substrate": worktree_substrate, + "usage_capability": usage.as_dict(), + "reason": "" + if provider_found and worktree_substrate and usage.supported + else ( + f"{provider} executable not found" + if not provider_found + else ( + "git/dispatch manage_worktrees unavailable" + if not worktree_substrate + else usage.reason + ) + ), + }, + "local-vm": { + "available": False, + "substrate": vm_found, + "reason": "no canonical VM entrypoint" + if vm_found + else "Docker/Colima is not detected", + }, + "cloud-soon": {"available": False, "reason": "coming soon"}, + } + + +def interactive_policy_command( + provider: str, + prompt: str, + runtime: str, + permissions: str, + *, + provider_session_id: str | None = None, + continuity_policy: ContinuityPolicy | None = None, +) -> list[str]: + """Build one interactive argv from the canonical policy decision.""" + decision = resolve_provider_policy(provider, runtime, permissions, "interactive") + if not decision.supported: + raise ValueError(decision.reason) + flags = list(decision.flags) + continuity = continuity_policy or ContinuityPolicy("fresh", "fresh:implicit") + if provider == "claude": + session_flags = ( + ["--session-id", provider_session_id] if provider_session_id else [] + ) + if continuity.mode == "bare-fork": + session_flags = [ + "--resume", + continuity.parent_provider_session_id, + "--fork-session", + *session_flags, + ] + return ["claude", "--verbose", *flags, *session_flags, prompt] + if provider == "codex": + return ["codex", *flags, prompt] + if provider == "agy": + return ["agy", *flags, "--add-dir", ".", "--prompt-interactive", prompt] + if provider == "junie": + return [ + "junie", + *flags, + f"--prompt={prompt}", + "--project=.", + "--skip-update-check", + "--use-local-cache", + ] + session_flags: list[str] = [] + if continuity.mode == "bare-fork": + session_flags = [ + "--resume", + continuity.parent_provider_session_id, + "--fork-session", + ] + if provider_session_id: + session_flags.extend(["--session-id", provider_session_id]) + return [ + "grok", + "--cwd", + ".", + *flags, + *session_flags, + "--no-alt-screen", + prompt, + ] + + +def interactive_workspace_command( + provider: str, + prompt: str, + runtime: str, + permissions: str, + root: str | os.PathLike[str], + token_budget: str | int | None = None, + operator: str = "none", + continuity: str = "fresh", + parent_session_id: str = "", + parent_lineage_id: str = "", +) -> list[str]: + """Build the portable wrapper argv used by the exact ``init`` route.""" + decision = resolve_provider_policy(provider, runtime, permissions, "interactive") + if not decision.supported: + raise ValueError(decision.reason) + quota = resolve_quota_policy(token_budget, runtime=runtime) + capability = resolve_provider_usage_capability(provider) + if not capability.supported: + raise ValueError(capability.reason) + operator_policy = resolve_operator_agent_policy(operator, runtime=runtime) + if not operator_policy.supported: + raise ValueError(operator_policy.reason) + continuity_policy = resolve_continuity_policy( + continuity, + provider=provider, + parent_session_id=parent_session_id, + parent_lineage_id=parent_lineage_id, + ) + command = [ + sys.executable, + "-m", + "vibecrafted_core.spawn", + "interactive-launch", + provider, + "--runtime", + runtime, + "--permissions", + permissions, + "--token-budget", + quota.selection, + "--operator", + operator_policy.selection, + "--continuity", + continuity_policy.mode, + "--root", + str(Path(root).expanduser().resolve()), + "--prompt", + prompt, + ] + if continuity_policy.parent_provider_session_id: + command[command.index("--root") : command.index("--root")] = [ + "--parent-session", + continuity_policy.parent_provider_session_id, + ] + elif continuity_policy.mode == "full-lineage": + command[command.index("--root") : command.index("--root")] = [ + "--continuity-parent", + continuity_policy.lineage_id, + ] + import_root = os.environ.get("VIBECRAFTED_INTERACTIVE_IMPORT_ROOT", "").strip() + if import_root: + pythonpath = import_root + if os.environ.get("PYTHONPATH"): + pythonpath = f"{pythonpath}{os.pathsep}{os.environ['PYTHONPATH']}" + return ["env", f"PYTHONPATH={pythonpath}", *command] + return command + + +def resolve_operator_agent_policy( + selection: str | None, + *, + runtime: str, +) -> OperatorAgentPolicy: + """Resolve the only supported supervision shape without silent fallback.""" + normalized = (selection or "none").strip().lower() + if normalized not in OPERATOR_POLICIES: + return OperatorAgentPolicy( + selection=normalized, + provider=None, + permissions=None, + supported=False, + reason=( + f"unknown Operator Agent policy {normalized!r}; choose " + f"{', '.join(OPERATOR_POLICIES)}" + ), + ) + if normalized == "none": + return OperatorAgentPolicy( + selection="none", + provider=None, + permissions=None, + supported=True, + warning=USER_OBSERVED_WARNING, + ) + if runtime not in {"local-native", "local-worktrees"}: + return OperatorAgentPolicy( + selection=normalized, + provider=None, + permissions=None, + supported=False, + reason=( + f"Operator Agent supervision is unavailable for runtime {runtime}; " + "use local-native or local-worktrees" + ), + ) + return OperatorAgentPolicy( + selection=normalized, + provider="claude", + permissions="accept-edits", + supported=True, + ) + + +def prepare_interactive_workspace_launch( + *, + provider: str, + runtime: str, + permissions: str, + selected_root: str | os.PathLike[str], + prompt: str, + run_id: str | None = None, + executable: str | None = None, + worker_pid: int | None = None, + publish: bool = True, + quota_policy: QuotaPolicy | None = None, + usage_capability: ProviderUsageCapability | None = None, + provider_session_id: str | None = None, + continuity_material: ContinuityMaterial | None = None, +) -> InteractiveWorkspaceLaunch: + """Resolve identity/root and publish truth only after launch preparation succeeds.""" + decision = resolve_provider_policy(provider, runtime, permissions, "interactive") + if not decision.supported: + raise ValueError(decision.reason) + parent = Path(selected_root).expanduser().resolve() + if not parent.is_dir(): + raise ValueError(f"selected workspace does not exist: {parent}") + resolved_executable = executable or which(provider, path=agent_tool_search_path()) + if not resolved_executable: + raise ValueError(f"{provider} executable not found") + quota = quota_policy or resolve_quota_policy(None, runtime=runtime) + capability = usage_capability or resolve_provider_usage_capability( + provider, executable=resolved_executable + ) + if not capability.supported: + raise ValueError(capability.reason) + effective_provider_session_id = provider_session_id or str(uuid.uuid4()) + try: + uuid.UUID(effective_provider_session_id) + except ValueError as exc: + raise ValueError("provider session id must be a valid UUID") from exc + + from .dispatch.worktrees import ( + WorktreeContractError, + WorktreeGeometry, + WorktreeManager, + ) + from .workflow import reserve_run_id + from .workspace_catalog import resolve_run_workspace_identity + + effective_run_id = run_id or reserve_run_id("init") + geometry: WorktreeGeometry | None = None + effective = parent + manager: WorktreeManager | None = None + if runtime == "local-worktrees": + manager = WorktreeManager(parent) + baseline = _git_output(parent, "rev-parse", "HEAD") + if not baseline: + raise ValueError(f"selected workspace is not a git repository: {parent}") + geometry = manager.prepare_agent_launch(provider, effective_run_id, baseline) + effective = Path(geometry.worktree_path).resolve() + + try: + identity = resolve_run_workspace_identity( + root=parent, env={}, create_if_missing=True + ) + now_iso = dt.datetime.now(dt.timezone.utc).isoformat() + run_dir = control_plane_home() / "runtime_runs" / effective_run_id + prompt_path = run_dir / "prompt.md" + prompt_path.parent.mkdir(parents=True, exist_ok=True) + prompt_path.write_text(prompt, encoding="utf-8") + meta_path = run_dir / "meta.json" + owner_pid = int(worker_pid or os.getpid()) + receipt: dict[str, Any] = { + "created_at": now_iso, + "updated_at": now_iso, + "started_at": now_iso, + "status": "active" if publish else "prepared", + "run_id": effective_run_id, + "agent": provider, + "skill": "init", + "mode": "interactive", + "runtime_policy": runtime, + "permission_policy": permissions, + "quota_policy": quota.as_dict(), + "quota_warning": quota.warning, + "usage_capability": capability.as_dict(), + "usage_measurement": { + "source": capability.source, + "attribution": "provider_session_id+cwd+provider_version+message_id", + "monotonic": True, + }, + "measured_usage": { + "input_tokens": 0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + "messages": 0, + }, + "provider_session_id": effective_provider_session_id, + "continuity": ( + continuity_material.receipt() + if continuity_material is not None + else ContinuityPolicy("fresh", f"fresh:{uuid.uuid4()}").as_dict() + ), + "root": str(effective), + "parent_root": str(parent), + "effective_worktree_path": str(effective) if geometry else "", + "input": str(prompt_path), + "owner_pid": owner_pid, + "launcher_pid": owner_pid, + "liveness": "active" if publish else "prepared", + "executable": str(Path(resolved_executable).expanduser()), + **identity.to_meta_fields(), + } + if publish: + # Compatibility for direct preparation callers. The real interactive + # owner replaces this with the provider child PID after Popen succeeds. + receipt["worker_pid"] = owner_pid + if geometry is not None: + receipt.update( + branch=geometry.branch, + baseline_sha=geometry.baseline_sha, + artifact_path=geometry.artifact_path, + ) + if publish: + _write_meta(meta_path, receipt) + append_event( + "lifecycle:active", + effective_run_id, + "interactive Agent Workspace is live", + {**receipt, "meta": str(meta_path), "identity_required": True}, + ) + except Exception: + if manager is not None and geometry is not None: + try: + manager.cleanup(geometry, settled=True) + except (OSError, WorktreeContractError) as cleanup_exc: + import logging + + logging.getLogger(__name__).warning( + "failed to remove unlaunched interactive worktree %s: %s", + geometry.worktree_path, + cleanup_exc, + ) + raise + return InteractiveWorkspaceLaunch( + run_id=effective_run_id, + provider=provider, + runtime=runtime, + permissions=permissions, + parent_root=str(parent), + effective_root=str(effective), + workspace_id=identity.workspace_id, + vibecrafted_session_id=identity.vibecrafted_session_id, + meta_path=meta_path, + receipt=receipt, + quota_policy=quota, + usage_capability=capability, + provider_session_id=effective_provider_session_id, + worktree_manager=manager, + worktree_geometry=geometry, + ) + + +def _git_output(root: Path, *args: str) -> str: + completed = subprocess.run( + ["git", *args], cwd=root, check=False, capture_output=True, text=True + ) + return completed.stdout.strip() if completed.returncode == 0 else "" + + +class _ClaudeTranscriptUsage: + """Incremental, exact-session reader for Claude's provider-owned JSONL.""" + + _FIELDS = ( + "input_tokens", + "cache_creation_input_tokens", + "cache_read_input_tokens", + "output_tokens", + ) + + def __init__( + self, + *, + provider_session_id: str, + effective_root: str, + provider_version: str, + env: dict[str, str], + ) -> None: + configured = env.get("CLAUDE_CONFIG_DIR", "").strip() + base = ( + Path(configured).expanduser() + if configured + else Path(env.get("HOME", str(Path.home()))).expanduser() / ".claude" + ) + self.projects_root = (base / "projects").resolve() + self.provider_session_id = provider_session_id + self.effective_root = str(Path(effective_root).resolve()) + self.provider_version = provider_version.split()[0] + self.path: Path | None = None + self.identity: tuple[int, int] | None = None + self.offset = 0 + self.seen_message_ids: set[str] = set() + self.totals = {field: 0 for field in self._FIELDS} + self._reject_existing_source() + + def _matching_paths(self) -> list[Path]: + if not self.projects_root.is_dir(): + return [] + return list(self.projects_root.rglob(f"{self.provider_session_id}.jsonl")) + + def _reject_existing_source(self) -> None: + if self._matching_paths(): + raise ValueError( + "provider session usage source already exists; refusing stale or reused session identity" + ) + + def _bind_path(self) -> bool: + matches = self._matching_paths() + if not matches: + return False + if len(matches) != 1: + raise RuntimeError("multiple provider usage sources claim one session id") + candidate = matches[0] + if candidate.is_symlink(): + raise RuntimeError("provider usage source must not be a symlink") + resolved = candidate.resolve(strict=True) + if not resolved.is_relative_to(self.projects_root): + raise RuntimeError("provider usage source escaped provider projects root") + stat = resolved.stat() + if not resolved.is_file(): + raise RuntimeError("provider usage source is not a regular file") + self.path = resolved + self.identity = (stat.st_dev, stat.st_ino) + return True + + def poll(self) -> dict[str, int]: + if self.path is None and not self._bind_path(): + return self.as_dict() + assert self.path is not None + stat = self.path.stat() + if self.identity != (stat.st_dev, stat.st_ino): + raise RuntimeError("provider usage source identity changed during the run") + if stat.st_size < self.offset: + raise RuntimeError("provider usage source was truncated during the run") + with self.path.open("r", encoding="utf-8") as handle: + handle.seek(self.offset) + while True: + line_start = handle.tell() + line = handle.readline() + if not line: + break + if not line.endswith("\n"): + handle.seek(line_start) + break + self._consume_line(line) + self.offset = handle.tell() + return self.as_dict() + + def _consume_line(self, line: str) -> None: + try: + event = json.loads(line) + except json.JSONDecodeError as exc: + raise RuntimeError("provider usage source contains invalid JSONL") from exc + if not isinstance(event, dict): + raise TypeError("provider usage event must be a JSON object") + message = event.get("message") + if not isinstance(message, dict) or not isinstance(message.get("usage"), dict): + return + if event.get("sessionId") != self.provider_session_id: + raise RuntimeError("provider usage event belongs to a foreign session") + event_cwd = event.get("cwd") + if ( + not isinstance(event_cwd, str) + or str(Path(event_cwd).resolve()) != self.effective_root + ): + raise RuntimeError("provider usage event belongs to a foreign workspace") + if event.get("version") != self.provider_version: + raise RuntimeError( + "provider usage event version differs from probed executable" + ) + message_id = message.get("id") + if not isinstance(message_id, str) or not message_id: + raise RuntimeError("provider usage event has no attributable message id") + if message_id in self.seen_message_ids: + return + usage = message["usage"] + values: dict[str, int] = {} + for field_name in self._FIELDS: + value = usage.get(field_name, 0) + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise RuntimeError(f"provider usage field {field_name} is invalid") + values[field_name] = value + self.seen_message_ids.add(message_id) + for field_name, value in values.items(): + self.totals[field_name] += value + + def as_dict(self) -> dict[str, int]: + return { + **self.totals, + "total_tokens": sum(self.totals.values()), + "messages": len(self.seen_message_ids), + } + + +def launch_interactive_workspace( + provider: str, + prompt: str, + runtime: str, + permissions: str, + root: str | os.PathLike[str], + token_budget: str | int | None = None, + operator: str = "none", + continuity: str = "fresh", + parent_session_id: str = "", + parent_lineage_id: str = "", +) -> int: + """Own one provider child while preserving the inherited interactive TTY.""" + operator_policy = resolve_operator_agent_policy(operator, runtime=runtime) + if not operator_policy.supported: + raise ValueError(operator_policy.reason) + from .workflow import reserve_run_id + + run_id = reserve_run_id("init") + try: + continuity_policy = resolve_continuity_policy( + continuity, + provider=provider, + parent_session_id=parent_session_id, + parent_lineage_id=parent_lineage_id, + ) + continuity_material = _materialize_continuity( + continuity_policy, + provider=provider, + root=Path(root).expanduser().resolve(), + run_id=run_id, + prompt=prompt, + ) + except ValueError as exc: + now_iso = dt.datetime.now(dt.timezone.utc).isoformat() + failed = { + "created_at": now_iso, + "updated_at": now_iso, + "completed_at": now_iso, + "status": "failed", + "liveness": "terminal", + "terminal_reason": "continuity_validation_failed", + "reason": str(exc), + "run_id": run_id, + "agent": provider, + "skill": "init", + "mode": "interactive", + "root": str(Path(root).expanduser().resolve()), + "continuity": { + "mode": str(continuity or "fresh"), + "lineage_id": str(parent_lineage_id or parent_session_id), + "supported": False, + "status": "UNSUPPORTED", + "reason": str(exc), + }, + } + meta_path = control_plane_home() / "runtime_runs" / run_id / "meta.json" + meta_path.parent.mkdir(parents=True, exist_ok=True) + _write_meta(meta_path, failed) + append_event( + "lifecycle:failed", + run_id, + "continuity validation failed before provider spawn", + {**failed, "meta": str(meta_path)}, + ) + raise + if operator_policy.provider is not None: + return _launch_supervised_interactive_workspace( + provider=provider, + prompt=continuity_material.prompt, + runtime=runtime, + permissions=permissions, + root=root, + token_budget=token_budget, + operator_policy=operator_policy, + run_id=run_id, + continuity_material=continuity_material, + ) + quota = resolve_quota_policy(token_budget, runtime=runtime) + child_env = _fresh_child_environment(os.environ.copy(), continuity_policy) + provider_session_id = str(uuid.uuid4()) + command = interactive_policy_command( + provider, + continuity_material.prompt, + runtime, + permissions, + provider_session_id=provider_session_id, + continuity_policy=continuity_policy, + ) + resolved = _resolve_agent_command(provider, command, child_env) + capability = resolve_provider_usage_capability(provider, executable=resolved[0]) + if not capability.supported: + raise ValueError(capability.reason) + launch = prepare_interactive_workspace_launch( + provider=provider, + runtime=runtime, + permissions=permissions, + selected_root=root, + prompt=continuity_material.prompt, + run_id=run_id, + executable=resolved[0], + publish=False, + quota_policy=quota, + usage_capability=capability, + provider_session_id=provider_session_id, + continuity_material=continuity_material, + ) + try: + usage_reader = _ClaudeTranscriptUsage( + provider_session_id=provider_session_id, + effective_root=launch.effective_root, + provider_version=capability.provider_version, + env=child_env, + ) + except Exception: + _cleanup_unspawned_interactive_launch(launch) + raise + child_env.update( + { + "VIBECRAFTED_RUN_ID": launch.run_id, + "VIBECRAFTED_SESSION_ID": launch.vibecrafted_session_id, + "VIBECRAFTED_WORKSPACE_ID": launch.workspace_id, + "VIBECRAFTED_WORKSPACE_INSTANCE_ID": str( + launch.receipt["workspace_instance_id"] + ), + "VIBECRAFTED_BUILD_ID": str(launch.receipt["build_id"]["rendered"]), + "VIBECRAFTED_PARENT_ROOT": launch.parent_root, + "VIBECRAFTED_EFFECTIVE_ROOT": launch.effective_root, + "VIBECRAFTED_AGENT_ROLE": "agent", + "VIBECRAFTED_PROMPT_ROLE": prompt.splitlines()[0] if prompt else "", + "VIBECRAFTED_CONTINUITY_MODE": continuity_policy.mode, + "VIBECRAFTED_CONTINUITY_LINEAGE_ID": continuity_policy.lineage_id, + } + ) + try: + # Omitting stdin/stdout/stderr is the contract: the provider inherits the + # wrapper's exact descriptors and controlling terminal. No PTY broker, + # pipe, or terminal-text parser sits between the User and provider. + child = subprocess.Popen( + resolved, + cwd=launch.effective_root, + env=child_env, + ) + except (OSError, ValueError) as exc: + cleanup = _cleanup_unspawned_interactive_launch(launch) + _terminalize_interactive_launch( + launch, + launch.receipt, + status="failed", + exit_code=2, + terminal_reason="child_spawn_failed", + error=str(exc), + extra={"prepared_worktree_cleanup": cleanup}, + ) + raise + + now_iso = dt.datetime.now(dt.timezone.utc).isoformat() + receipt = { + **launch.receipt, + "updated_at": now_iso, + "spawned_at": now_iso, + "status": "active", + "liveness": "active", + "owner_pid": os.getpid(), + "launcher_pid": os.getpid(), + "worker_pid": child.pid, + "role": "agent", + "prompt_role": prompt.splitlines()[0] if prompt else "", + "operator_policy": operator_policy.as_dict(), + "supervision": { + "mode": "user_observed", + "state": "not_configured", + "warning": operator_policy.warning, + }, + } + received_signal: list[int] = [] + previous_handlers: dict[int, Any] = {} + + def _forward_owner_signal(signum: int, _frame: Any) -> None: + if not received_signal: + received_signal.append(signum) + if child.poll() is None: + try: + child.send_signal(signum) + except ProcessLookupError: + pass + + if threading.current_thread() is threading.main_thread(): + for signum in ( + signal.SIGINT, + signal.SIGTERM, + getattr(signal, "SIGHUP", signal.SIGTERM), + ): + if signum in previous_handlers: + continue + previous_handlers[signum] = signal.getsignal(signum) + signal.signal(signum, _forward_owner_signal) + + # Publish the mandatory roles immediately after successful child creation. + # Stronger process fingerprints are a subsequent best-effort enrichment. + _write_meta(launch.meta_path, receipt) + append_event( + "lifecycle:active", + launch.run_id, + "interactive Agent Workspace provider child is live", + {**receipt, "meta": str(launch.meta_path), "identity_required": True}, + ) + try: + from .process_control import process_identity_receipt + + owner_identity = process_identity_receipt(os.getpid(), run_id=launch.run_id) + worker_identity = process_identity_receipt(child.pid, run_id=launch.run_id) + if owner_identity is not None: + receipt["owner_identity"] = owner_identity + if worker_identity is not None: + receipt["worker_identity"] = worker_identity + _write_meta(launch.meta_path, receipt) + except (OSError, RuntimeError, ValueError): + # PID + role truth remains mandatory; stronger identity is best-effort + # because a deterministic fast-exit provider may already be terminal. + pass + quota_exhausted = False + provider_returncode: int + try: + while True: + current_returncode = child.poll() + measured_usage = usage_reader.poll() + if measured_usage != receipt["measured_usage"]: + receipt["measured_usage"] = measured_usage + receipt["updated_at"] = dt.datetime.now(dt.timezone.utc).isoformat() + _write_meta(launch.meta_path, receipt) + if ( + current_returncode is None + and not received_signal + and quota.token_budget is not None + and measured_usage["total_tokens"] >= quota.token_budget + ): + quota_exhausted = True + child.terminate() + try: + provider_returncode = child.wait(timeout=5) + except subprocess.TimeoutExpired: + child.kill() + provider_returncode = child.wait() + break + if current_returncode is not None: + provider_returncode = current_returncode + break + time.sleep(0.05) + except Exception as exc: + if child.poll() is None: + child.terminate() + try: + child.wait(timeout=5) + except subprocess.TimeoutExpired: + child.kill() + child.wait() + _terminalize_interactive_launch( + launch, + receipt, + status="failed", + exit_code=1, + terminal_reason="wrapper_exception", + error=str(exc), + ) + raise + finally: + for signum, handler in previous_handlers.items(): + signal.signal(signum, handler) + + shell_status = ( + 128 + abs(provider_returncode) + if provider_returncode < 0 + else provider_returncode + ) + if quota_exhausted: + status = "quota_exhausted" + terminal_reason = "quota_exhausted" + shell_status = QUOTA_EXHAUSTED_EXIT_CODE + elif received_signal: + owner_signal = received_signal[0] + status = "cancelled" + terminal_reason = f"owner_signal:{signal.Signals(owner_signal).name}" + if shell_status == 0: + shell_status = 128 + owner_signal + elif provider_returncode < 0: + status = "cancelled" + terminal_reason = ( + f"provider_signal:{signal.Signals(abs(provider_returncode)).name}" + ) + elif provider_returncode == 0: + status = "completed" + terminal_reason = "provider_exit_zero" + else: + status = "failed" + terminal_reason = "provider_exit_nonzero" + _terminalize_interactive_launch( + launch, + receipt, + status=status, + exit_code=shell_status, + terminal_reason=terminal_reason, + exit_signal=abs(provider_returncode) if provider_returncode < 0 else None, + ) + return shell_status + + +def _launch_supervised_interactive_workspace( + *, + provider: str, + prompt: str, + runtime: str, + permissions: str, + root: str | os.PathLike[str], + token_budget: str | int | None, + operator_policy: OperatorAgentPolicy, + run_id: str, + continuity_material: ContinuityMaterial, +) -> int: + """Own one child and one distinct supervisor on the existing lifecycle throne.""" + assert operator_policy.provider is not None + quota = resolve_quota_policy(token_budget, runtime=runtime) + continuity_policy = continuity_material.policy + base_env = _fresh_child_environment(os.environ.copy(), continuity_policy) + child_session_id = str(uuid.uuid4()) + child_command = interactive_policy_command( + provider, + prompt, + runtime, + permissions, + provider_session_id=child_session_id, + continuity_policy=continuity_policy, + ) + child_resolved = _resolve_agent_command(provider, child_command, base_env) + child_capability = resolve_provider_usage_capability( + provider, executable=child_resolved[0] + ) + if not child_capability.supported: + raise ValueError(child_capability.reason) + launch = prepare_interactive_workspace_launch( + provider=provider, + runtime=runtime, + permissions=permissions, + selected_root=root, + prompt=prompt, + run_id=run_id, + executable=child_resolved[0], + publish=False, + quota_policy=quota, + usage_capability=child_capability, + provider_session_id=child_session_id, + continuity_material=continuity_material, + ) + try: + usage_reader = _ClaudeTranscriptUsage( + provider_session_id=child_session_id, + effective_root=launch.effective_root, + provider_version=child_capability.provider_version, + env=base_env, + ) + from .workflow import reserve_run_id + + operator_run_id = reserve_run_id("oper") + operator_session_id = str(uuid.uuid4()) + relation_id = str(uuid.uuid4()) + operator_run_dir = control_plane_home() / "runtime_runs" / operator_run_id + operator_run_dir.mkdir(parents=True, exist_ok=True) + operator_meta_path = operator_run_dir / "meta.json" + operator_prompt_path = operator_run_dir / "prompt.md" + protocol_path = operator_run_dir / "operator-protocol.jsonl" + operator_prompt = _operator_supervision_prompt( + child_run_id=launch.run_id, + child_meta_path=launch.meta_path, + relation_id=relation_id, + protocol_path=protocol_path, + ) + operator_prompt_path.write_text(operator_prompt, encoding="utf-8") + except Exception: + _cleanup_unspawned_interactive_launch(launch) + raise + + now_iso = dt.datetime.now(dt.timezone.utc).isoformat() + relation = { + "relation_id": relation_id, + "operator_run_id": operator_run_id, + "child_run_id": launch.run_id, + "state": "reserved", + "protocol": "operator-protocol-jsonl-v1", + } + child_receipt = { + **launch.receipt, + "updated_at": now_iso, + "status": "reserved", + "liveness": "reserved", + "role": "agent", + "prompt_role": prompt.splitlines()[0] if prompt else "", + "operator_policy": operator_policy.as_dict(), + "supervision": dict(relation), + } + operator_receipt = { + **launch.receipt, + "created_at": now_iso, + "updated_at": now_iso, + "started_at": now_iso, + "status": "reserved", + "liveness": "reserved", + "run_id": operator_run_id, + "agent": operator_policy.provider, + "skill": "operator", + "permission_policy": operator_policy.permissions, + "role": "operator", + "prompt_role": "/vc-operator", + "input": str(operator_prompt_path), + "provider_session_id": operator_session_id, + "operator_policy": operator_policy.as_dict(), + "supervision": dict(relation), + "measured_usage": { + "input_tokens": 0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + "messages": 0, + }, + } + operator_command = interactive_policy_command( + operator_policy.provider, + operator_prompt, + runtime, + operator_policy.permissions or "accept-edits", + provider_session_id=operator_session_id, + continuity_policy=ContinuityPolicy( + mode="fresh", lineage_id=continuity_policy.lineage_id + ), + ) + operator_resolved = _resolve_agent_command( + operator_policy.provider, operator_command, base_env + ) + operator_capability = resolve_provider_usage_capability( + operator_policy.provider, executable=operator_resolved[0] + ) + if not operator_capability.supported: + _cleanup_unspawned_interactive_launch(launch) + raise ValueError(operator_capability.reason) + operator_receipt["usage_capability"] = operator_capability.as_dict() + # The pair is durably reserved and bidirectionally bound before either + # provider can become ACTIVE. There is no second relationship database. + try: + _write_meta(launch.meta_path, child_receipt) + _write_meta(operator_meta_path, operator_receipt) + except Exception: + launch.meta_path.unlink(missing_ok=True) + operator_meta_path.unlink(missing_ok=True) + _cleanup_unspawned_interactive_launch(launch) + raise + append_event( + "lifecycle:reserved", + launch.run_id, + "Agent reserved with Operator Agent relation", + {**child_receipt, "meta": str(launch.meta_path)}, + ) + append_event( + "lifecycle:reserved", + operator_run_id, + "Operator Agent reserved with child relation", + {**operator_receipt, "meta": str(operator_meta_path)}, + ) + + operator_env = { + **base_env, + "VIBECRAFTED_RUN_ID": operator_run_id, + "VIBECRAFTED_SESSION_ID": launch.vibecrafted_session_id, + "VIBECRAFTED_WORKSPACE_ID": launch.workspace_id, + "VIBECRAFTED_WORKSPACE_INSTANCE_ID": str( + launch.receipt["workspace_instance_id"] + ), + "VIBECRAFTED_PARENT_ROOT": launch.parent_root, + "VIBECRAFTED_EFFECTIVE_ROOT": launch.effective_root, + "VIBECRAFTED_AGENT_ROLE": "operator", + "VIBECRAFTED_PROMPT_ROLE": "/vc-operator", + "VIBECRAFTED_SUPERVISION_RELATION_ID": relation_id, + "VIBECRAFTED_SUPERVISION_PEER_RUN_ID": launch.run_id, + "VIBECRAFTED_SUPERVISED_CHILD_META": str(launch.meta_path), + "VIBECRAFTED_OPERATOR_PROTOCOL": str(protocol_path), + "VIBECRAFTED_CONTINUITY_MODE": continuity_policy.mode, + "VIBECRAFTED_CONTINUITY_LINEAGE_ID": continuity_policy.lineage_id, + } + try: + operator_log = (operator_run_dir / "provider.log").open("ab") + operator_child = subprocess.Popen( + operator_resolved, + cwd=launch.effective_root, + env=operator_env, + stdin=subprocess.DEVNULL, + stdout=operator_log, + stderr=subprocess.STDOUT, + ) + except (OSError, ValueError) as exc: + if "operator_log" in locals(): + operator_log.close() + cleanup = _cleanup_unspawned_interactive_launch(launch) + _terminalize_interactive_launch( + launch, + child_receipt, + status="failed", + exit_code=2, + terminal_reason="operator_spawn_failed", + error=str(exc), + extra={"prepared_worktree_cleanup": cleanup}, + ) + _terminalize_related_receipt( + operator_run_id, + operator_meta_path, + operator_receipt, + status="failed", + exit_code=2, + terminal_reason="operator_spawn_failed", + error=str(exc), + ) + raise + + child_env = { + **base_env, + "VIBECRAFTED_RUN_ID": launch.run_id, + "VIBECRAFTED_SESSION_ID": launch.vibecrafted_session_id, + "VIBECRAFTED_WORKSPACE_ID": launch.workspace_id, + "VIBECRAFTED_WORKSPACE_INSTANCE_ID": str( + launch.receipt["workspace_instance_id"] + ), + "VIBECRAFTED_BUILD_ID": str(launch.receipt["build_id"]["rendered"]), + "VIBECRAFTED_PARENT_ROOT": launch.parent_root, + "VIBECRAFTED_EFFECTIVE_ROOT": launch.effective_root, + "VIBECRAFTED_AGENT_ROLE": "agent", + "VIBECRAFTED_PROMPT_ROLE": prompt.splitlines()[0] if prompt else "", + "VIBECRAFTED_SUPERVISION_RELATION_ID": relation_id, + "VIBECRAFTED_SUPERVISION_PEER_RUN_ID": operator_run_id, + "VIBECRAFTED_CONTINUITY_MODE": continuity_policy.mode, + "VIBECRAFTED_CONTINUITY_LINEAGE_ID": continuity_policy.lineage_id, + } + try: + # The User-facing child keeps the exact inherited descriptors and TTY. + child = subprocess.Popen( + child_resolved, + cwd=launch.effective_root, + env=child_env, + ) + except (OSError, ValueError) as exc: + operator_code = _stop_owned_process(operator_child) + operator_log.close() + cleanup = _cleanup_unspawned_interactive_launch(launch) + _terminalize_interactive_launch( + launch, + child_receipt, + status="failed", + exit_code=2, + terminal_reason="child_spawn_failed", + error=str(exc), + extra={"prepared_worktree_cleanup": cleanup}, + ) + _terminalize_related_receipt( + operator_run_id, + operator_meta_path, + operator_receipt, + status="failed", + exit_code=_shell_status(operator_code), + terminal_reason="child_spawn_failed", + error=str(exc), + ) + raise + + if operator_child.poll() is not None: + child_code = _stop_owned_process(child) + operator_code = operator_child.returncode or 0 + operator_log.close() + _terminalize_interactive_launch( + launch, + child_receipt, + status="failed", + exit_code=1, + terminal_reason="supervision_lost_before_active", + extra={"provider_exit_code": _shell_status(child_code)}, + ) + _terminalize_related_receipt( + operator_run_id, + operator_meta_path, + operator_receipt, + status="failed", + exit_code=_shell_status(operator_code), + terminal_reason="supervision_lost_before_active", + ) + return 1 + + active_at = dt.datetime.now(dt.timezone.utc).isoformat() + active_relation = {**relation, "state": "active"} + child_receipt.update( + updated_at=active_at, + spawned_at=active_at, + status="active", + liveness="active", + owner_pid=os.getpid(), + launcher_pid=os.getpid(), + worker_pid=child.pid, + supervision=active_relation, + ) + operator_receipt.update( + updated_at=active_at, + spawned_at=active_at, + status="active", + liveness="active", + owner_pid=os.getpid(), + launcher_pid=os.getpid(), + worker_pid=operator_child.pid, + supervision=active_relation, + ) + try: + _write_meta(launch.meta_path, child_receipt) + _write_meta(operator_meta_path, operator_receipt) + append_event( + "lifecycle:active", + launch.run_id, + "supervised interactive Agent Workspace child is live", + { + **child_receipt, + "meta": str(launch.meta_path), + "identity_required": True, + }, + ) + append_event( + "lifecycle:active", + operator_run_id, + "Operator Agent is supervising exact child", + { + **operator_receipt, + "meta": str(operator_meta_path), + "identity_required": True, + }, + ) + except Exception as exc: + child_code = _stop_owned_process(child) + operator_code = _stop_owned_process(operator_child) + operator_log.close() + _terminalize_interactive_launch( + launch, + child_receipt, + status="failed", + exit_code=1, + terminal_reason="active_publish_failed", + error=str(exc), + extra={"provider_exit_code": _shell_status(child_code)}, + ) + _terminalize_related_receipt( + operator_run_id, + operator_meta_path, + operator_receipt, + status="failed", + exit_code=_shell_status(operator_code), + terminal_reason="active_publish_failed", + error=str(exc), + ) + raise + + received_signal: list[int] = [] + previous_handlers: dict[int, Any] = {} + + def _forward_owner_signal(signum: int, _frame: Any) -> None: + if not received_signal: + received_signal.append(signum) + for owned_process in (child, operator_child): + if owned_process.poll() is None: + try: + owned_process.send_signal(signum) + except ProcessLookupError: + pass + + if threading.current_thread() is threading.main_thread(): + for signum in ( + signal.SIGINT, + signal.SIGTERM, + getattr(signal, "SIGHUP", signal.SIGTERM), + ): + if signum in previous_handlers: + continue + previous_handlers[signum] = signal.getsignal(signum) + signal.signal(signum, _forward_owner_signal) + + quota_exhausted = False + supervision_lost = False + stop_actor_run_id = "" + protocol_offset = 0 + provider_returncode = 0 + try: + while True: + current_returncode = child.poll() + measured_usage = usage_reader.poll() + if measured_usage != child_receipt["measured_usage"]: + child_receipt["measured_usage"] = measured_usage + child_receipt["updated_at"] = dt.datetime.now( + dt.timezone.utc + ).isoformat() + _write_meta(launch.meta_path, child_receipt) + events, protocol_offset = _poll_operator_protocol( + protocol_path, protocol_offset + ) + for event in events: + _validate_operator_protocol_event( + event, + relation_id=relation_id, + operator_run_id=operator_run_id, + child_receipt=child_receipt, + ) + if event["kind"] == "observation": + operator_receipt["supervision"] = { + **active_relation, + "observation": { + "child_run_id": launch.run_id, + "child_status": child_receipt["status"], + "child_worker_pid": child.pid, + "measured_usage": child_receipt["measured_usage"], + "observed_at": dt.datetime.now(dt.timezone.utc).isoformat(), + }, + } + operator_receipt["updated_at"] = dt.datetime.now( + dt.timezone.utc + ).isoformat() + _write_meta(operator_meta_path, operator_receipt) + elif current_returncode is None: + stop_actor_run_id = operator_run_id + append_event( + "operator:stop-requested", + launch.run_id, + "Operator Agent requested bounded child stop", + { + "run_id": launch.run_id, + "actor_run_id": operator_run_id, + "relation_id": relation_id, + "reason": event["reason"], + }, + ) + provider_returncode = _stop_owned_process(child) + break + if stop_actor_run_id: + break + if current_returncode is not None: + provider_returncode = current_returncode + break + operator_returncode = operator_child.poll() + if operator_returncode is not None: + provider_returncode = _stop_owned_process(child) + supervision_lost = not received_signal + break + if ( + not received_signal + and quota.token_budget is not None + and measured_usage["total_tokens"] >= quota.token_budget + ): + quota_exhausted = True + provider_returncode = _stop_owned_process(child) + break + time.sleep(0.05) + except Exception as exc: + child_code = _stop_owned_process(child) + operator_code = _stop_owned_process(operator_child) + _terminalize_interactive_launch( + launch, + child_receipt, + status="failed", + exit_code=1, + terminal_reason="wrapper_exception", + error=str(exc), + extra={"provider_exit_code": _shell_status(child_code)}, + ) + _terminalize_related_receipt( + operator_run_id, + operator_meta_path, + operator_receipt, + status="failed", + exit_code=_shell_status(operator_code), + terminal_reason="wrapper_exception", + error=str(exc), + ) + raise + finally: + for signum, handler in previous_handlers.items(): + signal.signal(signum, handler) + + shell_status = _shell_status(provider_returncode) + if quota_exhausted: + status = "quota_exhausted" + terminal_reason = "quota_exhausted" + shell_status = QUOTA_EXHAUSTED_EXIT_CODE + elif received_signal: + owner_signal = received_signal[0] + status = "cancelled" + terminal_reason = f"owner_signal:{signal.Signals(owner_signal).name}" + shell_status = 128 + owner_signal + elif stop_actor_run_id: + status = "cancelled" + terminal_reason = "operator_policy_stop" + shell_status = 128 + signal.SIGTERM + elif supervision_lost: + status = "failed" + terminal_reason = "supervision_lost" + shell_status = 1 + elif provider_returncode < 0: + status = "cancelled" + terminal_reason = ( + f"provider_signal:{signal.Signals(abs(provider_returncode)).name}" + ) + elif provider_returncode == 0: + status = "completed" + terminal_reason = "provider_exit_zero" + else: + status = "failed" + terminal_reason = "provider_exit_nonzero" + terminal_extra = { + "supervision": {**child_receipt["supervision"], "state": "terminal"} + } + if stop_actor_run_id: + terminal_extra["stop_actor_run_id"] = stop_actor_run_id + child_terminal = _terminalize_interactive_launch( + launch, + child_receipt, + status=status, + exit_code=shell_status, + terminal_reason=terminal_reason, + exit_signal=abs(provider_returncode) if provider_returncode < 0 else None, + extra=terminal_extra, + ) + terminal_observation_confirmed = False + settlement_error = "" + if not supervision_lost and not received_signal and operator_child.poll() is None: + deadline = time.monotonic() + 1.0 + try: + while time.monotonic() < deadline: + events, protocol_offset = _poll_operator_protocol( + protocol_path, protocol_offset + ) + for event in events: + _validate_operator_protocol_event( + event, + relation_id=relation_id, + operator_run_id=operator_run_id, + child_receipt=child_terminal, + ) + if event["kind"] == "observation": + terminal_observation_confirmed = True + operator_receipt["supervision"] = { + **active_relation, + "observation": { + "child_run_id": launch.run_id, + "child_status": child_terminal["status"], + "child_worker_pid": child.pid, + "measured_usage": child_terminal["measured_usage"], + "observed_at": dt.datetime.now( + dt.timezone.utc + ).isoformat(), + }, + } + _write_meta(operator_meta_path, operator_receipt) + if terminal_observation_confirmed or operator_child.poll() is not None: + break + time.sleep(0.05) + except (OSError, RuntimeError, TypeError, ValueError) as exc: + settlement_error = str(exc) + operator_code = _stop_owned_process(operator_child) + operator_log.close() + settled_worktree_cleanup = _cleanup_settled_interactive_launch(launch) + child_terminal["settled_worktree_cleanup"] = settled_worktree_cleanup + child_terminal["updated_at"] = dt.datetime.now(dt.timezone.utc).isoformat() + _write_meta(launch.meta_path, child_terminal) + if settled_worktree_cleanup != "not-applicable": + append_event( + "lifecycle:worktree-cleanup", + launch.run_id, + f"settled interactive worktree cleanup: {settled_worktree_cleanup}", + { + "run_id": launch.run_id, + "result": settled_worktree_cleanup, + "meta": str(launch.meta_path), + }, + ) + operator_failed = supervision_lost or bool(settlement_error) + _terminalize_related_receipt( + operator_run_id, + operator_meta_path, + operator_receipt, + status="failed" if operator_failed else "completed", + exit_code=_shell_status(operator_code) if operator_failed else 0, + terminal_reason=( + "supervision_lost" + if supervision_lost + else "operator_protocol_failed" + if settlement_error + else "child_settled" + ), + error=settlement_error, + extra={ + "supervision": { + **operator_receipt["supervision"], + "state": "terminal", + "terminal_observation_confirmed": terminal_observation_confirmed, + } + }, + ) + return shell_status + + +def _operator_supervision_prompt( + *, child_run_id: str, child_meta_path: Path, relation_id: str, protocol_path: Path +) -> str: + return ( + "/vc-operator\n" + "Supervise exactly one child Agent through structured lifecycle truth.\n" + f"child_run_id: {child_run_id}\n" + f"child_meta: {child_meta_path}\n" + f"relation_id: {relation_id}\n" + f"protocol_jsonl: {protocol_path}\n" + "Observe child status and measured_usage from child_meta. Write only typed " + "observation or bounded stop action JSON objects to protocol_jsonl. Remain " + "live until the child reaches terminal state. Never signal or reap the child.\n" + ) + + +def _poll_operator_protocol( + path: Path, offset: int +) -> tuple[list[dict[str, Any]], int]: + if not path.exists(): + return [], offset + if path.is_symlink() or not path.is_file(): + raise RuntimeError("Operator Agent protocol must be a regular non-symlink file") + if path.stat().st_size < offset: + raise RuntimeError("Operator Agent protocol was truncated") + events: list[dict[str, Any]] = [] + with path.open("r", encoding="utf-8") as handle: + handle.seek(offset) + while True: + line_start = handle.tell() + line = handle.readline() + if not line: + break + if not line.endswith("\n"): + handle.seek(line_start) + break + try: + event = json.loads(line) + except json.JSONDecodeError as exc: + raise RuntimeError( + "Operator Agent protocol contains invalid JSONL" + ) from exc + if not isinstance(event, dict): + raise TypeError("Operator Agent protocol event must be an object") + events.append(event) + return events, handle.tell() + + +def _validate_operator_protocol_event( + event: dict[str, Any], + *, + relation_id: str, + operator_run_id: str, + child_receipt: dict[str, Any], +) -> None: + if event.get("kind") not in {"observation", "action"}: + raise RuntimeError("Operator Agent protocol kind is unsupported") + if event.get("actor_run_id") != operator_run_id: + raise RuntimeError("Operator Agent protocol actor identity mismatch") + if event.get("child_run_id") != child_receipt["run_id"]: + raise RuntimeError("Operator Agent protocol child identity mismatch") + if event.get("relation_id") != relation_id: + raise RuntimeError("Operator Agent protocol relation identity mismatch") + if event["kind"] == "observation": + if event.get("child_status") != child_receipt["status"]: + raise RuntimeError("Operator Agent observation is not current child truth") + if event.get("child_worker_pid") != child_receipt["worker_pid"]: + raise RuntimeError( + "Operator Agent observation names a foreign child process" + ) + if event.get("measured_usage") != child_receipt["measured_usage"]: + raise RuntimeError( + "Operator Agent observation usage is not exact child truth" + ) + return + if event.get("action") not in {"stop", "cancel"}: + raise RuntimeError("Operator Agent action is outside the bounded policy") + if event.get("reason") not in {"operator_policy_stop", "operator_policy_cancel"}: + raise RuntimeError("Operator Agent action reason is outside the bounded policy") + + +def _stop_owned_process(process: subprocess.Popen[Any]) -> int: + """Stop and reap one process from the existing wrapper owner only.""" + current = process.poll() + if current is not None: + return current + process.terminate() + try: + return process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + return process.wait() + + +def _shell_status(returncode: int) -> int: + return 128 + abs(returncode) if returncode < 0 else returncode + + +def _terminalize_related_receipt( + run_id: str, + meta_path: Path, + receipt: dict[str, Any], + *, + status: str, + exit_code: int, + terminal_reason: str, + error: str = "", + extra: dict[str, Any] | None = None, +) -> dict[str, Any]: + completed_at = dt.datetime.now(dt.timezone.utc).isoformat() + terminal = { + **receipt, + "updated_at": completed_at, + "completed_at": completed_at, + "status": status, + "liveness": "terminal", + "exit_code": int(exit_code), + "terminal_reason": terminal_reason, + **(extra or {}), + } + if error: + terminal["error"] = error + _write_meta(meta_path, terminal) + append_event( + f"lifecycle:{status}", + run_id, + f"Operator Agent terminal: {terminal_reason}", + {**terminal, "meta": str(meta_path), "identity_required": True}, + ) + return terminal + + +def _cleanup_unspawned_interactive_launch(launch: InteractiveWorkspaceLaunch) -> str: + """Remove only the clean worktree prepared for a child that never existed.""" + if launch.worktree_manager is None or launch.worktree_geometry is None: + return "not-applicable" + try: + return str( + launch.worktree_manager.cleanup(launch.worktree_geometry, settled=True) + ) + except (OSError, RuntimeError, ValueError) as exc: + return f"preserved:{exc}" + + +def _cleanup_settled_interactive_launch(launch: InteractiveWorkspaceLaunch) -> str: + """Delegate clean terminal worktree cleanup to the canonical manager.""" + if launch.worktree_manager is None or launch.worktree_geometry is None: + return "not-applicable" + try: + return str( + launch.worktree_manager.cleanup(launch.worktree_geometry, settled=True) + ) + except (OSError, RuntimeError, ValueError) as exc: + return f"preserved:{exc}" + + +def _terminalize_interactive_launch( + launch: InteractiveWorkspaceLaunch, + receipt: dict[str, Any], + *, + status: str, + exit_code: int, + terminal_reason: str, + exit_signal: int | None = None, + error: str = "", + extra: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Atomically terminalize the same interactive receipt and event identity.""" + completed_at = dt.datetime.now(dt.timezone.utc).isoformat() + terminal = { + **receipt, + "updated_at": completed_at, + "completed_at": completed_at, + "status": status, + "liveness": "terminal", + "exit_code": int(exit_code), + "terminal_reason": terminal_reason, + **(extra or {}), + } + if exit_signal is not None: + terminal["exit_signal"] = signal.Signals(exit_signal).name + if error: + terminal["error"] = error + _write_meta(launch.meta_path, terminal) + append_event( + f"lifecycle:{status}", + launch.run_id, + f"interactive Agent Workspace terminal: {terminal_reason}", + {**terminal, "meta": str(launch.meta_path), "identity_required": True}, + ) + return terminal + + ANSI_PATTERN = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]") SESSION_PATTERNS = ( re.compile( @@ -156,22 +2505,28 @@ def _default_command(agent: str, prompt: str) -> list[str]: "Use 'vibecrafted workflow agy --prompt ...' (or agy in other launchers). " "No execution path may launch the gemini binary." ) + policy = resolve_provider_policy( + agent, "local-native", "auto" if agent == "junie" else "bypass", "headless" + ) + if not policy.supported: + raise ValueError(policy.reason) + flags = list(policy.flags) if agent == "claude": return [ "claude", "--print", "--verbose", - "--dangerously-skip-permissions", + *flags, prompt, ] if agent == "codex": - return ["codex", "exec", "--dangerously-bypass-approvals-and-sandbox", prompt] + return ["codex", "exec", *flags, prompt] if agent == "agy": # agy >= 1.1: --print takes the prompt as its value (Go flags) and # print mode does not read stdin; flags must precede it. return [ "agy", - "--dangerously-skip-permissions", + *flags, "--add-dir", ".", "--print-timeout", @@ -180,14 +2535,21 @@ def _default_command(agent: str, prompt: str) -> list[str]: prompt, ] if agent == "junie": - return ["junie", "--task", prompt, "--project", ".", "--skip-update-check"] + return [ + "junie", + *flags, + "--task", + prompt, + "--project", + ".", + "--skip-update-check", + ] if agent == "grok": return [ "grok", "--cwd", ".", - "--permission-mode", - "bypassPermissions", + *flags, "--no-alt-screen", "--single", prompt, @@ -202,6 +2564,18 @@ def _stdin_command(agent: str) -> list[str]: on stdin so they do not leak through ps(1) or hit ARG_MAX. """ + if agent == "gemini": + raise ValueError( + "gemini CLI is deprecated. Google Antigravity CLI (agy) is the replacement. " + "Use 'vibecrafted workflow agy --prompt ...' (or agy in other launchers). " + "No execution path may launch the gemini binary." + ) + policy = resolve_provider_policy( + agent, "local-native", "auto" if agent == "junie" else "bypass", "headless" + ) + if not policy.supported: + raise ValueError(policy.reason) + flags = list(policy.flags) if agent == "claude": return [ "claude", @@ -209,22 +2583,16 @@ def _stdin_command(agent: str) -> list[str]: "--output-format", "stream-json", "--verbose", - "--dangerously-skip-permissions", + *flags, ] if agent == "codex": return [ "codex", "exec", "--json", - "--dangerously-bypass-approvals-and-sandbox", + *flags, "-", ] - if agent == "gemini": - raise ValueError( - "gemini CLI is deprecated. Google Antigravity CLI (agy) is the replacement. " - "Use 'vibecrafted workflow agy --prompt ...' (or agy in other launchers). " - "No execution path may launch the gemini binary." - ) if agent == "agy": # agy >= 1.1 print mode reads no stdin and --print requires a value; # a shell shim folds stdin into the flag. The prompt lands on the @@ -233,7 +2601,7 @@ def _stdin_command(agent: str) -> list[str]: "bash", "-c", ( - "agy --dangerously-skip-permissions --add-dir . " + f"agy {shlex.join(flags)} --add-dir . " '--print-timeout 30m --print "$(cat)"' ), ] @@ -253,8 +2621,7 @@ def _stdin_command(agent: str) -> list[str]: "grok", "--cwd", ".", - "--permission-mode", - "bypassPermissions", + *flags, "--no-alt-screen", "--output-format", "streaming-json", @@ -1595,6 +3962,56 @@ def _build_parser() -> argparse.ArgumentParser: finish.add_argument("meta") finish.add_argument("status") finish.add_argument("exit_code", nargs="?", default="0") + policy = sub.add_parser( + "policy-command", help="Resolve the canonical interactive provider policy." + ) + policy.add_argument("provider", choices=POLICY_PROVIDERS) + policy.add_argument("--runtime", choices=RUNTIME_POLICIES, default="local-native") + policy.add_argument("--permissions", choices=PERMISSION_POLICIES, default="bypass") + interactive_command = sub.add_parser( + "interactive-command", help="Build the canonical interactive workspace wrapper." + ) + interactive_command.add_argument("provider", choices=POLICY_PROVIDERS) + interactive_command.add_argument( + "--runtime", choices=RUNTIME_POLICIES, default="local-native" + ) + interactive_command.add_argument( + "--permissions", choices=PERMISSION_POLICIES, default="bypass" + ) + interactive_command.add_argument("--token-budget", default="safe") + interactive_command.add_argument( + "--operator", choices=OPERATOR_POLICIES, default="none" + ) + interactive_command.add_argument( + "--continuity", choices=CONTINUITY_MODES, default="fresh" + ) + interactive_command.add_argument("--parent-session", default="") + interactive_command.add_argument("--continuity-parent", default="") + interactive_command.add_argument("--root", required=True) + interactive_launch = sub.add_parser( + "interactive-launch", help="Prepare and exec an interactive Agent Workspace." + ) + interactive_launch.add_argument("provider", choices=POLICY_PROVIDERS) + interactive_launch.add_argument( + "--runtime", choices=RUNTIME_POLICIES, default="local-native" + ) + interactive_launch.add_argument( + "--permissions", choices=PERMISSION_POLICIES, default="bypass" + ) + interactive_launch.add_argument("--root", required=True) + interactive_launch.add_argument("--prompt", required=True) + interactive_launch.add_argument("--token-budget", default="safe") + interactive_launch.add_argument( + "--operator", choices=OPERATOR_POLICIES, default="none" + ) + interactive_launch.add_argument( + "--continuity", choices=CONTINUITY_MODES, default="fresh" + ) + interactive_launch.add_argument("--parent-session", default="") + interactive_launch.add_argument("--continuity-parent", default="") + sub.add_parser( + "policy-matrix", help="Print the complete provider policy matrix as JSON." + ) return parser @@ -1639,6 +4056,67 @@ def main(argv: Sequence[str] | None = None) -> int: claim_digest=os.environ.get(CLAIM_DIGEST_ENV, ""), ) return 0 + if args.command == "policy-command": + try: + command = interactive_policy_command( + args.provider, sys.stdin.read(), args.runtime, args.permissions + ) + except ValueError as exc: + print(f"UNSUPPORTED: {exc}", file=sys.stderr) + return 2 + print(shlex.join(command)) + return 0 + if args.command == "interactive-command": + prompt = sys.stdin.read() + try: + command = interactive_workspace_command( + args.provider, + prompt, + args.runtime, + args.permissions, + args.root, + args.token_budget, + args.operator, + args.continuity, + args.parent_session, + args.continuity_parent, + ) + except ValueError as exc: + print(str(exc), file=sys.stderr) + return 2 + print(shlex.join(command)) + return 0 + if args.command == "interactive-launch": + try: + return launch_interactive_workspace( + args.provider, + args.prompt, + args.runtime, + args.permissions, + args.root, + args.token_budget, + args.operator, + args.continuity, + args.parent_session, + args.continuity_parent, + ) + except (OSError, RuntimeError, ValueError) as exc: + print(str(exc), file=sys.stderr) + return 2 + if args.command == "policy-matrix": + print( + json.dumps( + [ + resolve_provider_policy(p, r, q, m).as_dict() + for p in POLICY_PROVIDERS + for r in RUNTIME_POLICIES + for q in PERMISSION_POLICIES + for m in POLICY_MODES + ], + indent=2, + ) + ) + return 0 return 2 diff --git a/vibecrafted-core/vibecrafted_core/vc_frame_delivery.py b/vibecrafted-core/vibecrafted_core/vc_frame_delivery.py index 6999f7a8..cffa7cb5 100644 --- a/vibecrafted-core/vibecrafted_core/vc_frame_delivery.py +++ b/vibecrafted-core/vibecrafted_core/vc_frame_delivery.py @@ -8,14 +8,13 @@ the published generation. - Ownership: config delivery never creates or flips the runtime-owned ``vibecrafted-current`` symlink. -- Views (both must stay in lockstep — operator runtime pins frontier first): - 1. ``$XDG_CONFIG_HOME/vc-frame/{config.kdl,layouts,themes,operator scripts}`` - 2. ``$XDG_CONFIG_HOME/vetcoders/frontier/vc-frame/…`` (``VC_FRAME_CONFIG_DIR``) +- View: ``$XDG_CONFIG_HOME/vibecrafted/vc-frame/`` is the sole live config + directory. ``VC_FRAME_CONFIG_DIR`` pins every product entrypoint there. - Stage-time host adaptation: rewrite every shipped zsh entrypoint and select an available clipboard command. - Operator scripts (Composer / paste-stack / quick-cmd / …) are first-class - install artifacts, not hand-copied orphans. STALE-FILE copies under frontier - are backed up and re-wired on every delivery pass. + install artifacts, not hand-copied orphans. STALE-FILE copies in the canonical + product directory are backed up and re-wired on every delivery pass. """ from __future__ import annotations @@ -46,9 +45,8 @@ _FENCE_BEGIN = "# >>> vibecrafted >>>" _FENCE_END = "# <<< vibecrafted <<<" -# Shipped next to config.kdl. compact-bar / default keybinds prefer frontier paths -# first — if install skips these, an old STALE-FILE on disk shadows the package -# forever (see scaf-260805-triptych runtime diagnosis, 2026-08-07). +# Shipped next to config.kdl. If install skips these, an old STALE-FILE in the +# canonical product directory shadows the package forever. OPERATOR_SCRIPT_NAMES: tuple[str, ...] = ( "auto-theme.sh", "vc-composer.sh", @@ -57,6 +55,8 @@ "scrollback-select.sh", "vc-quick-cmd.sh", "vc-deck.sh", + "vc-agent-workshop.py", + "vc-start-here.py", ) _CORE_VIEW_NAMES: tuple[str, ...] = ("config.kdl", "layouts", "themes") @@ -114,14 +114,13 @@ def prefer_repo_vc_frame(env: dict[str, str] | None = None) -> bool: def vc_frame_user_config_dir(home: Path | None = None) -> Path: - """Directory bare vc-frame reads (respects XDG_CONFIG_HOME).""" + """The one product-owned vc-frame config directory.""" if home is not None: - # Sandbox: treat HOME's .config unless XDG is set in env xdg = os.environ.get("XDG_CONFIG_HOME") if xdg: - return Path(xdg).expanduser() / "vc-frame" - return home / ".config" / "vc-frame" - return xdg_config_home() / "vc-frame" + return Path(xdg).expanduser() / "vibecrafted" / "vc-frame" + return home / ".config" / "vibecrafted" / "vc-frame" + return xdg_config_home() / "vibecrafted" / "vc-frame" def tools_current_path(tools_home: Path | None = None) -> Path: @@ -315,7 +314,7 @@ def plan_delivery( Re-materializes host-adapted config from the package source into the published generation's package-owned ``runtime/generated/vc-frame`` (never mutating the source itself or the ``vibecrafted-current`` owner symlink), then wires - both the legacy view and frontier projections to point at that generation. + the sole canonical product view to that generation. """ source = vc_frame_config_source() tools = tools_home if tools_home is not None else vibecrafted_tools_home() @@ -393,24 +392,16 @@ def plan_delivery( # decides whether an existing owned link is current or needs migration. store_anchor = current store_current = store_anchor if not use_repo else current - # Two projections: legacy view + frontier (the path VC_FRAME_CONFIG_DIR - # pins via _vetcoders_pin_vc_frame_config_dir). Wiring only the view left - # frontier as STALE-FILE forever — scripts/config never refreshed. - projection_roots = ( + _ = force_frontier # retained CLI compatibility; no second live projection + _wire_projection( view_root, - frontier_root(home) / "vc-frame", + base, + force=force, + dry_run=dry_run, + actions=plan.actions, + store_current=store_current, + checkout=checkout, ) - managed_frontier = frontier_root(home) / "vc-frame" - for projection in projection_roots: - _wire_projection( - projection, - base, - force=force or (force_frontier and projection == managed_frontier), - dry_run=dry_run, - actions=plan.actions, - store_current=store_current, - checkout=checkout, - ) return plan @@ -424,7 +415,7 @@ def _wire_projection( store_current: Path, checkout: Path | None, ) -> None: - """Wire one config projection (view or frontier) from the staged base.""" + """Wire the canonical config projection from the staged base.""" for name in _CORE_VIEW_NAMES: target = base / name if not target.exists() and not dry_run: diff --git a/vibecrafted-core/vibecrafted_core/vc_frame_staging.py b/vibecrafted-core/vibecrafted_core/vc_frame_staging.py index 601938e7..bbe427a3 100644 --- a/vibecrafted-core/vibecrafted_core/vc_frame_staging.py +++ b/vibecrafted-core/vibecrafted_core/vc_frame_staging.py @@ -18,6 +18,7 @@ _EXEC_ZSH_RE = re.compile(r"exec\s+(?:/bin/)?zsh\s+-l") _COPY_PBCOPY_RE = re.compile(r'copy_command\s+"pbcopy"') _PBCOPY_STDIN_RE = re.compile(r"\bpbcopy(?=\s*<)") +_EXECUTABLE_CONFIG_NAMES = frozenset({"vc-agent-workshop.py", "vc-start-here.py"}) def resolve_pane_shell(path_env: str | None = None) -> str: @@ -124,7 +125,7 @@ def materialize_vc_frame_config( ) else: shutil.copy2(source_file, destination_file) - if name.endswith(".sh"): + if name.endswith(".sh") or name in _EXECUTABLE_CONFIG_NAMES: mode = destination_file.stat().st_mode destination_file.chmod( mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH diff --git a/vibecrafted-core/vibecrafted_core/vc_frame_tab_gc.py b/vibecrafted-core/vibecrafted_core/vc_frame_tab_gc.py index 96797874..5663835e 100644 --- a/vibecrafted-core/vibecrafted_core/vc_frame_tab_gc.py +++ b/vibecrafted-core/vibecrafted_core/vc_frame_tab_gc.py @@ -27,7 +27,7 @@ ) BUCKET_SESSIONS = ("Finalized runs", "Failed runs", "Needs attention") -PROTECTED_TAB_NAMES = {"Start here", "Shell"} +PROTECTED_TAB_NAMES = {"Start here", "Agents", "Shell", "voc"} _HEX = frozenset("0123456789abcdefABCDEF") diff --git a/vibecrafted-core/vibecrafted_core/workflow.py b/vibecrafted-core/vibecrafted_core/workflow.py index 4f0b9f11..0976d137 100644 --- a/vibecrafted-core/vibecrafted_core/workflow.py +++ b/vibecrafted-core/vibecrafted_core/workflow.py @@ -79,6 +79,11 @@ "timed_out", "ghost", } +LAUNCH_IDEMPOTENCY_SCHEMA = "vibecrafted.launch-idempotency.v1" +LAUNCH_IDEMPOTENCY_KEY_ENV = "VIBECRAFTED_LAUNCH_IDEMPOTENCY_KEY" +LAUNCH_IDEMPOTENCY_MAX_TERMINAL_RECORDS = 2048 +LAUNCH_IDEMPOTENCY_TERMINAL_TTL_SECONDS = 30 * 24 * 60 * 60 +LAUNCH_RECEIPT_SCHEMA = "vibecrafted.launch_receipt.v1" @dataclass(frozen=True) @@ -1903,6 +1908,472 @@ def _launch_tracking_payload( } +def _json_plain(value: Any) -> Any: + """Reduce a launch payload to JSON-serializable builtins.""" + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, Path): + return str(value) + if isinstance(value, dict): + return {str(key): _json_plain(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_json_plain(item) for item in value] + return str(value) + + +def machine_launch_receipt(payload: dict[str, Any]) -> dict[str, Any]: + """Return the one stdout-safe launch receipt operators and agents parse.""" + accepted = bool(payload.get("accepted")) + status = str( + payload.get("status") + or ("launching" if accepted else payload.get("reason") or "rejected") + ) + agent = str(payload.get("agent") or "") + return { + "schema": LAUNCH_RECEIPT_SCHEMA, + "run_id": str(payload.get("run_id") or ""), + "agent": agent, + "skill": str(payload.get("skill") or ""), + "root": str(payload.get("root") or ""), + "accepted": accepted, + "status": status, + "replayed": bool(payload.get("replayed")), + "idempotency_key": str(payload.get("idempotency_key") or ""), + } + + +def _launch_idempotency_enabled() -> bool: + """Whether one logical launch may reuse a live run instead of minting a sibling.""" + raw = str(os.environ.get("VIBECRAFTED_LAUNCH_IDEMPOTENCY", "1")).strip().lower() + return raw not in {"0", "false", "off", "no"} + + +def launch_idempotency_key( + spec: WorkflowLaunchSpec, *, env: dict[str, str] | None = None +) -> str: + """Return caller-supplied launch identity, or ``""`` for a fresh invocation. + + Byte-identical content is never operator-intention identity. Transports may + supply the existing ``VIBECRAFTED_LAUNCH_IDEMPOTENCY_KEY`` across retries; + an explicit ``spec.run_id`` is also a stable caller-owned identity. + """ + source = os.environ if env is None else env + override = str(source.get(LAUNCH_IDEMPOTENCY_KEY_ENV) or "").strip() + if override: + return override + run_id = str(spec.run_id or "").strip() + return f"run-id:{run_id}" if run_id else "" + + +def _launch_spec_digest(spec: WorkflowLaunchSpec) -> str: + """Bind one explicit invocation identity to secret-safe launch semantics.""" + prompt = _source_prompt(spec) + material = { + **spec.to_payload(), + "prompt": "", + "prompt_sha256": hashlib.sha256(prompt.encode("utf-8")).hexdigest(), + "root": str(Path(spec.root or "").expanduser().resolve(strict=False)), + } + encoded = json.dumps(material, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + +def _launch_idempotency_registry() -> Path: + """Directory for launch-idempotency records under the control-plane home.""" + registry = control_plane_home() / "launch_idempotency" + registry.mkdir(parents=True, exist_ok=True) + return registry + + +def _launch_idempotency_path(key: str) -> Path: + """Content-addressed path for one launch fingerprint.""" + digest = hashlib.sha256(key.encode("utf-8")).hexdigest() + return _launch_idempotency_registry() / f"{digest}.json" + + +def _read_launch_idempotency_record(key: str) -> dict[str, Any]: + """Read one launch-idempotency record, or ``{}`` when absent/invalid.""" + if not key: + return {} + path = _launch_idempotency_path(key) + payload = _read_json_object(path) + if not payload: + return {} + if payload.get("schema") != LAUNCH_IDEMPOTENCY_SCHEMA: + return {} + if str(payload.get("idempotency_key") or "") != key: + return {} + return payload + + +def _write_launch_idempotency_record(key: str, payload: dict[str, Any]) -> None: + """Atomically persist a launch-idempotency record for ``key``.""" + if not key: + return + now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + record = { + "schema": LAUNCH_IDEMPOTENCY_SCHEMA, + "idempotency_key": key, + "run_id": str(payload.get("run_id") or ""), + "agent": str(payload.get("agent") or ""), + "skill": str(payload.get("skill") or ""), + "root": str(payload.get("root") or ""), + "state": str(payload.get("state") or "reserved"), + "accepted": bool(payload.get("accepted")), + "owner_pid": int(payload.get("owner_pid") or os.getpid()), + "owner_identity": _json_plain(payload.get("owner_identity")), + "spec_digest": str(payload.get("spec_digest") or ""), + "receipt": _json_plain(payload.get("receipt") or {}), + "updated_at": now, + } + with run_mutation_locks(control_plane_home(), run_id="launch-idempotency-registry"): + if payload.get("created_at"): + record["created_at"] = str(payload["created_at"]) + else: + existing = _read_launch_idempotency_record(key) + record["created_at"] = str(existing.get("created_at") or now) + atomic_write_json(_launch_idempotency_path(key), record) + + +def _prune_launch_idempotency_registry(*, now: float | None = None) -> int: + """Bound failed/terminal history without deleting live or ambiguous claims.""" + with run_mutation_locks(control_plane_home(), run_id="launch-idempotency-registry"): + registry = _launch_idempotency_registry() + current_time = time.time() if now is None else now + eligible: list[tuple[float, Path]] = [] + for path in registry.glob("*.json"): + payload = _read_json_object(path) + state = str(payload.get("state") or "") + if state == "failed": + pass + elif state == "dispatched": + run_id = str(payload.get("run_id") or "") + run = lookup_run(run_id) if run_id else None + if run is None or not _run_is_terminal(run): + continue + else: + continue + try: + modified_at = path.stat().st_mtime + except OSError: + continue + eligible.append((modified_at, path)) + + eligible.sort(key=lambda item: item[0], reverse=True) + removed = 0 + for index, (modified_at, path) in enumerate(eligible): + expired = ( + current_time - modified_at > LAUNCH_IDEMPOTENCY_TERMINAL_TTL_SECONDS + ) + over_limit = index >= LAUNCH_IDEMPOTENCY_MAX_TERMINAL_RECORDS + if not expired and not over_limit: + continue + try: + path.unlink() + except FileNotFoundError: + continue + except OSError: + continue + removed += 1 + return removed + + +def _replay_launch_payload(record: dict[str, Any], *, status: str) -> dict[str, Any]: + """Rebuild a launch receipt from a stored idempotency record.""" + stored = dict(record.get("receipt") or {}) + run_id = str(stored.get("run_id") or record.get("run_id") or "") + payload = { + **stored, + "run_id": run_id, + "agent": str(stored.get("agent") or record.get("agent") or ""), + "skill": str(stored.get("skill") or record.get("skill") or ""), + "root": str(stored.get("root") or record.get("root") or ""), + "accepted": bool(stored.get("accepted", record.get("accepted"))), + "status": str(stored.get("status") or status), + "replayed": True, + "idempotency_key": str(record.get("idempotency_key") or ""), + "message": str( + stored.get("message") or f"Replayed launch for {run_id or 'existing run'}" + ), + } + return payload + + +def _retryable_launch_payload( + record: dict[str, Any], *, status: str, reason: str +) -> dict[str, Any]: + """Return a structured refusal for an identity that lacks launch proof.""" + stored = dict(record.get("receipt") or {}) + return { + **stored, + "run_id": str(stored.get("run_id") or record.get("run_id") or ""), + "agent": str(stored.get("agent") or record.get("agent") or ""), + "skill": str(stored.get("skill") or record.get("skill") or ""), + "root": str(stored.get("root") or record.get("root") or ""), + "accepted": False, + "status": status, + "retryable": True, + "replayed": False, + "idempotency_key": str(record.get("idempotency_key") or ""), + "reason": reason, + "message": reason, + } + + +def _canonical_run_launch_payload( + record: dict[str, Any], run: dict[str, Any] +) -> dict[str, Any]: + """Recover acceptance only from a canonical run with qualified liveness.""" + stored = dict(record.get("receipt") or {}) + run_id = str(run.get("run_id") or record.get("run_id") or "") + payload = { + **stored, + "run_id": run_id, + "agent": str(run.get("agent") or record.get("agent") or ""), + "skill": str(run.get("skill") or record.get("skill") or ""), + "root": str(run.get("root") or record.get("root") or ""), + "accepted": True, + "status": str(run.get("state") or "launching"), + "replayed": True, + "recovered": True, + "idempotency_key": str(record.get("idempotency_key") or ""), + "message": f"Recovered canonical launch for {run_id}", + } + for field in ( + "report", + "transcript", + "meta", + "launcher_pid", + "launcher_identity", + "worker_pid", + "worker_identity", + ): + if run.get(field) is not None: + payload[field] = _json_plain(run[field]) + return payload + + +def _classify_record_owner(record: dict[str, Any]) -> tuple[str, str]: + """Classify reservation ownership without turning ambiguity into staleness.""" + run_id = str(record.get("run_id") or "") + try: + owner_pid = int(record.get("owner_pid") or 0) + except (TypeError, ValueError): + return "ambiguous", "process_identity_invalid" + receipt = record.get("owner_identity") + if owner_pid <= 0 or not isinstance(receipt, dict): + return "ambiguous", "process_identity_unavailable" + expected_pgid = receipt.get("pgid") + try: + pgid = int(expected_pgid) if expected_pgid is not None else None + except (TypeError, ValueError): + return "ambiguous", "process_identity_invalid" + current, reason, _identity = validate_process_identity( + receipt, + expected_pid=owner_pid, + expected_pgid=pgid, + expected_run_id=run_id, + ) + if current: + return "current", reason or "process_identity_current" + if reason in { + "process_identity_gone", + "process_identity_mismatch", + "process_run_id_mismatch", + }: + return "stale", reason + return "ambiguous", reason or "process_identity_unknown" + + +def _run_has_current_process_proof(run_id: str, run: dict[str, Any]) -> bool: + """Require a current canonical worker/launcher identity for active replay.""" + for prefix in ("worker", "launcher"): + receipt = run.get(f"{prefix}_identity") + if not isinstance(receipt, dict): + continue + raw_pid = run.get(f"{prefix}_pid") or receipt.get("pid") + try: + pid = int(raw_pid or 0) + pgid = int(receipt.get("pgid") or 0) + except (TypeError, ValueError): + continue + current, _reason, _identity = validate_process_identity( + receipt, + expected_pid=pid, + expected_pgid=pgid or None, + expected_run_id=run_id, + ) + if current: + return True + return False + + +def _replay_launch_if_current(record: dict[str, Any]) -> dict[str, Any] | None: + """Return a replay receipt when retrying would mint a sibling of a live launch.""" + if not record: + return None + run_id = str(record.get("run_id") or "") + if not run_id: + return None + state = str(record.get("state") or "") + if state == "reserved": + owner_state, owner_reason = _classify_record_owner(record) + if owner_state == "current": + return _retryable_launch_payload( + record, + status="reservation_in_progress", + reason="launch reservation is owned by another live invocation", + ) + run = lookup_run(run_id) + if run is not None and ( + _run_is_terminal(run) or _run_has_current_process_proof(run_id, run) + ): + return _canonical_run_launch_payload(record, run) + if owner_state == "ambiguous": + return _retryable_launch_payload( + record, + status="retryable_reservation_owner_ambiguous", + reason=owner_reason, + ) + if run is None: + return None + return _retryable_launch_payload( + record, + status="retryable_unproven_liveness", + reason="reserved run exists but has no current canonical process proof", + ) + if state != "dispatched": + return None + run = lookup_run(run_id) + if run is None: + return _retryable_launch_payload( + record, + status="retryable_unknown_run", + reason="idempotent run is unknown to control_plane", + ) + if _run_is_terminal(run): + return _replay_launch_payload( + record, status=str(run.get("state") or "completed") + ) + stored_receipt = dict(record.get("receipt") or {}) + if not ( + _run_has_current_process_proof(run_id, run) + or _run_has_current_process_proof(run_id, stored_receipt) + ): + return _retryable_launch_payload( + record, + status="retryable_unproven_liveness", + reason="idempotent run has no current canonical process proof", + ) + return _replay_launch_payload(record, status=str(run.get("state") or "launching")) + + +def _claim_launch_idempotency( + spec: WorkflowLaunchSpec, key: str, *, spec_digest: str +) -> tuple[dict[str, Any] | None, str]: + """Under the caller lock: replay a live launch or reserve one run id.""" + existing = _read_launch_idempotency_record(key) + existing_digest = str(existing.get("spec_digest") or "") + if existing and existing_digest != spec_digest: + raise ValueError("idempotency identity conflicts with a different launch spec") + replay = _replay_launch_if_current(existing) + if replay is not None: + if replay.get("accepted") and str(existing.get("state") or "") == "reserved": + replay = _finish_launch_idempotency(key, replay, spec_digest=spec_digest) + return replay, str(existing.get("run_id") or "") + reuse = "" + if str(existing.get("state") or "") == "reserved": + reuse = str(existing.get("run_id") or "") + run_id = str(spec.run_id or reuse or "") or reserve_run_id(spec.skill) + _write_launch_idempotency_record( + key, + { + "run_id": run_id, + "agent": spec.agent, + "skill": spec.skill, + "root": spec.root, + "state": "reserved", + "accepted": False, + "owner_pid": os.getpid(), + "owner_identity": process_identity_receipt(os.getpid(), run_id=run_id), + "spec_digest": spec_digest, + "receipt": { + "run_id": run_id, + "agent": spec.agent, + "skill": spec.skill, + "root": spec.root, + "accepted": False, + "status": "reserved", + }, + }, + ) + return None, run_id + + +def _finish_launch_idempotency( + key: str, payload: dict[str, Any], *, spec_digest: str = "" +) -> dict[str, Any]: + """Persist the launch receipt onto the fingerprint, then return it.""" + if not key: + return payload + accepted = bool(payload.get("accepted")) + result = {**payload, "idempotency_key": key} + _write_launch_idempotency_record( + key, + { + "run_id": str(result.get("run_id") or ""), + "agent": str(result.get("agent") or ""), + "skill": str(result.get("skill") or ""), + "root": str(result.get("root") or ""), + "state": "dispatched" if accepted else "failed", + "accepted": accepted, + "owner_pid": os.getpid(), + "owner_identity": process_identity_receipt( + os.getpid(), run_id=str(result.get("run_id") or "") + ), + "spec_digest": spec_digest, + "receipt": _json_plain(result), + }, + ) + _prune_launch_idempotency_registry() + return result + + +def recover_launch_receipt( + spec: WorkflowLaunchSpec, *, env: dict[str, str] | None = None +) -> dict[str, Any] | None: + """Return a stored receipt for ``spec`` when a prior launch already reserved a run.""" + if not _launch_idempotency_enabled(): + return None + key = launch_idempotency_key(spec, env=env) + if not key: + return None + record = _read_launch_idempotency_record(key) + expected_digest = _launch_spec_digest(spec) + if record and str(record.get("spec_digest") or "") != expected_digest: + return _retryable_launch_payload( + record, + status="idempotency_conflict", + reason="idempotency identity conflicts with a different launch spec", + ) + if not record.get("run_id"): + return None + replay = _replay_launch_if_current(record) + if replay is not None: + return replay + stored = dict(record.get("receipt") or {}) + run_id = str(stored.get("run_id") or record.get("run_id") or "") + if not run_id: + return None + stored["run_id"] = run_id + stored["replayed"] = True + stored["idempotency_key"] = key + dispatched = str(record.get("state") or "") == "dispatched" + stored["accepted"] = bool(record.get("accepted")) and dispatched + stored.setdefault("status", "launching" if stored["accepted"] else "failed") + return stored + + def launch_workflow( spec: WorkflowLaunchSpec, source_dir: str | Path, @@ -1960,7 +2431,29 @@ def launch_workflow( raise # not a git repository / stubbed subprocess / unreadable context → allow - run_id = spec.run_id or reserve_run_id(spec.skill) + idem_key = "" + idem_spec_digest = "" + claimed_run_id = "" + if _launch_idempotency_enabled(): + effective_identity_env = dict(os.environ) + if env: + effective_identity_env.update(env) + idem_key = launch_idempotency_key(spec, env=effective_identity_env) + if idem_key: + idem_spec_digest = _launch_spec_digest(spec) + digest = hashlib.sha256(idem_key.encode("utf-8")).hexdigest() + with run_mutation_locks( + control_plane_home(), + run_id=f"lidem-{digest[:24]}", + idempotency_key=idem_key, + ): + replay, claimed_run_id = _claim_launch_idempotency( + spec, idem_key, spec_digest=idem_spec_digest + ) + if replay is not None: + return replay + + run_id = spec.run_id or claimed_run_id or reserve_run_id(spec.skill) if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", run_id): raise ValueError("run_id must be a safe 1-128 character identifier") artifacts = _run_artifact_paths(run_id) @@ -2022,21 +2515,26 @@ def launch_workflow( try: worker_command = _resolve_agent_command(spec.agent, worker_command, merged_env) except FileNotFoundError as exc: - return { - "accepted": False, - "message": f"Failed to launch {spec.skill}: {exc}", - "error": f"{type(exc).__name__}: {exc}", - "worker_command": worker_command, - "run_id": run_id, - "agent": spec.agent, - "skill": spec.skill, - "root": spec.root, - "report": str(report_path), - "transcript": str(artifacts["transcript"]), - "meta": str(artifacts["meta"]), - "prompt_file": str(prompt_path), - "control_plane": {"sync": "deferred", "run_id": run_id}, - } + return _finish_launch_idempotency( + idem_key, + { + "accepted": False, + "message": f"Failed to launch {spec.skill}: {exc}", + "error": f"{type(exc).__name__}: {exc}", + "worker_command": worker_command, + "run_id": run_id, + "agent": spec.agent, + "skill": spec.skill, + "root": spec.root, + "status": "failed", + "report": str(report_path), + "transcript": str(artifacts["transcript"]), + "meta": str(artifacts["meta"]), + "prompt_file": str(prompt_path), + "control_plane": {"sync": "deferred", "run_id": run_id}, + }, + spec_digest=idem_spec_digest, + ) launch_tracking = _launch_tracking_payload(launch_meta) model_receipt = _model_override_receipt(spec.agent, spec.model) if spec.model and runtime_kind == "supervised_research": @@ -2250,25 +2748,33 @@ def launch_workflow( **model_receipt, }, ) - return { - "accepted": False, - "message": f"Failed to launch {spec.skill}: {host.error}", - "command": command, - "worker_command": worker_command, - "dispatch_command": dispatch_command, - "transport": transport, - "command_script": str(command_script or ""), - "launch_log": str(launch_log), - "spec": safe_spec, - "error": host.error, - "last_error": host.error, - "run_id": run_id, - "operator_session": operator_session, - **launch_tracking, - "retry_of": retry_of, - **model_receipt, - "control_plane": sync_state(), - } + return _finish_launch_idempotency( + idem_key, + { + "accepted": False, + "message": f"Failed to launch {spec.skill}: {host.error}", + "command": command, + "worker_command": worker_command, + "dispatch_command": dispatch_command, + "transport": transport, + "command_script": str(command_script or ""), + "launch_log": str(launch_log), + "spec": safe_spec, + "error": host.error, + "last_error": host.error, + "run_id": run_id, + "agent": spec.agent, + "skill": spec.skill, + "root": spec.root, + "status": "failed", + "operator_session": operator_session, + **launch_tracking, + "retry_of": retry_of, + **model_receipt, + "control_plane": sync_state(), + }, + spec_digest=idem_spec_digest, + ) launcher_pid = host.pid else: proc = subprocess.Popen( @@ -2332,23 +2838,31 @@ def launch_workflow( **model_receipt, }, ) - return { - "accepted": False, - "message": f"Failed to launch {spec.skill}: {exc}", - "command": command, - "worker_command": worker_command, - "dispatch_command": dispatch_command, - "transport": transport, - "command_script": str(command_script or ""), - "launch_log": str(launch_log), - "spec": safe_spec, - "error": f"{type(exc).__name__}: {exc}", - "run_id": run_id, - **launch_tracking, - "retry_of": retry_of, - **model_receipt, - "control_plane": sync_state(), - } + return _finish_launch_idempotency( + idem_key, + { + "accepted": False, + "message": f"Failed to launch {spec.skill}: {exc}", + "command": command, + "worker_command": worker_command, + "dispatch_command": dispatch_command, + "transport": transport, + "command_script": str(command_script or ""), + "launch_log": str(launch_log), + "spec": safe_spec, + "error": f"{type(exc).__name__}: {exc}", + "run_id": run_id, + "agent": spec.agent, + "skill": spec.skill, + "root": spec.root, + "status": "failed", + **launch_tracking, + "retry_of": retry_of, + **model_receipt, + "control_plane": sync_state(), + }, + spec_digest=idem_spec_digest, + ) append_event( kind="launch", run_id=run_id, @@ -2394,15 +2908,22 @@ def launch_workflow( # bucket, not a worker tab. A vc-frame transport already owns a tab of # its own, so only the detached path gets one. if transport == "headless": - live_viewer = open_live_viewer( - run_id=run_id, - agent=spec.agent, - root=spec.root, - launch_dir=launch_dir, - transcript_path=artifacts["transcript"], - meta_path=artifacts["meta"], - env=merged_env, - ) + try: + live_viewer = open_live_viewer( + run_id=run_id, + agent=spec.agent, + root=spec.root, + launch_dir=launch_dir, + transcript_path=artifacts["transcript"], + meta_path=artifacts["meta"], + env=merged_env, + ) + except (OSError, ValueError, RuntimeError) as exc: + live_viewer = _live_viewer_receipt( + "failed", + reason=f"{type(exc).__name__}: {exc}", + run_id=run_id, + ) handle.write( json.dumps({"ts": stamp, "event": "live_viewer", **live_viewer}) + "\n" ) @@ -2411,48 +2932,52 @@ def launch_workflow( "skipped", reason=f"transport_{transport}", run_id=run_id ) - return { - "live_viewer": live_viewer, - "accepted": True, - "message": f"Launched {spec.skill} via Vibecrafted core runtime.", - "command": command, - "dispatch_command": dispatch_command, - "worker_command": worker_command, - "transport": transport, - "command_script": str(command_script or ""), - "pid": launcher_pid, - "launcher_identity": launcher_identity, - "run_id": run_id, - "agent": spec.agent, - "skill": spec.skill, - "root": spec.root, - "dispatch": 0, - "status": "launching", - "control": str(run_snapshot_dir() / f"{run_id}.json"), - "report": str(report_path), - "transcript": str(artifacts["transcript"]), - "meta": str(artifacts["meta"]), - "prompt_file": str(prompt_path), - "session_id": session_id, - "operator_session": operator_session, - "control_plane_identity": { + return _finish_launch_idempotency( + idem_key, + { + "live_viewer": live_viewer, + "accepted": True, + "message": f"Launched {spec.skill} via Vibecrafted core runtime.", + "command": command, + "dispatch_command": dispatch_command, + "worker_command": worker_command, + "transport": transport, + "command_script": str(command_script or ""), + "pid": launcher_pid, + "launcher_identity": launcher_identity, "run_id": run_id, + "agent": spec.agent, + "skill": spec.skill, + "root": spec.root, + "dispatch": 0, + "status": "launching", + "control": str(run_snapshot_dir() / f"{run_id}.json"), + "report": str(report_path), + "transcript": str(artifacts["transcript"]), + "meta": str(artifacts["meta"]), + "prompt_file": str(prompt_path), "session_id": session_id, "operator_session": operator_session, + "control_plane_identity": { + "run_id": run_id, + "session_id": session_id, + "operator_session": operator_session, + }, + "workflow": _workflow_metadata(spec.skill), + **model_receipt, + **launch_tracking, + "retry_of": retry_of, + "launch_log": str(launch_log), + "spec": safe_spec, + # Launch acceptance is already durable in the event stream, run meta, + # and dispatcher process. A global board reconciliation here can block + # on an unrelated run and turn a successful launch into a traceback. + # Reconciliation belongs to observe/await/board readers, never the + # launch acknowledgement path. + "control_plane": {"sync": "deferred", "run_id": run_id}, }, - "workflow": _workflow_metadata(spec.skill), - **model_receipt, - **launch_tracking, - "retry_of": retry_of, - "launch_log": str(launch_log), - "spec": safe_spec, - # Launch acceptance is already durable in the event stream, run meta, - # and dispatcher process. A global board reconciliation here can block - # on an unrelated run and turn a successful launch into a traceback. - # Reconciliation belongs to observe/await/board readers, never the - # launch acknowledgement path. - "control_plane": {"sync": "deferred", "run_id": run_id}, - } + spec_digest=idem_spec_digest, + ) def stop_run( diff --git a/vibecrafted-mcp/pyproject.toml b/vibecrafted-mcp/pyproject.toml index c1bf49f5..a29ad9b6 100644 --- a/vibecrafted-mcp/pyproject.toml +++ b/vibecrafted-mcp/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "vibecrafted-mcp" -version = "4.2.4" +version = "4.3.0" description = "MCP server for the Vibecrafted operator framework — synthesis brain for the Vetcoders ecosystem." readme = "README.md" requires-python = ">=3.11" diff --git a/vibecrafted-mcp/vibecrafted_mcp/VERSION b/vibecrafted-mcp/vibecrafted_mcp/VERSION index cf78d5b6..80895903 100644 --- a/vibecrafted-mcp/vibecrafted_mcp/VERSION +++ b/vibecrafted-mcp/vibecrafted_mcp/VERSION @@ -1 +1 @@ -4.2.4 +4.3.0 diff --git a/vibecrafted-server/Cargo.lock b/vibecrafted-server/Cargo.lock index 1c2dae2f..e97f6258 100644 --- a/vibecrafted-server/Cargo.lock +++ b/vibecrafted-server/Cargo.lock @@ -308,7 +308,7 @@ checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b" [[package]] name = "control-core" -version = "4.2.4" +version = "4.3.0" dependencies = [ "chrono", "libc", @@ -2400,7 +2400,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vibecrafted-server-web" -version = "4.2.4" +version = "4.3.0" dependencies = [ "axum", "bytes", diff --git a/vibecrafted-server/control-core/Cargo.toml b/vibecrafted-server/control-core/Cargo.toml index e6dcb4c0..c870e578 100644 --- a/vibecrafted-server/control-core/Cargo.toml +++ b/vibecrafted-server/control-core/Cargo.toml @@ -4,7 +4,7 @@ # control-core stays self-sufficient to avoid editing W1-b's shared root. [package] name = "control-core" -version = "4.2.4" +version = "4.3.0" edition = "2024" rust-version = "1.85.0" license = "BUSL-1.1" diff --git a/vibecrafted-server/control-core/src/model.rs b/vibecrafted-server/control-core/src/model.rs index de55b379..34e8e270 100644 --- a/vibecrafted-server/control-core/src/model.rs +++ b/vibecrafted-server/control-core/src/model.rs @@ -180,7 +180,9 @@ pub fn delivery_axes_for_receipt( | "paused" | "stalled" => ExecutionState::Running, "completed" | "closed" | "converged" | "report_validated" => ExecutionState::Exited, - "interrupted" | "stopped" | "killed_by_operator" => ExecutionState::Interrupted, + "interrupted" | "stopped" | "killed_by_operator" | "quota_exhausted" => { + ExecutionState::Interrupted + } "timed_out" => ExecutionState::TimedOut, "failed" | "blocked" @@ -232,7 +234,7 @@ pub const ACTIVE_STATES: [&str; 13] = [ ]; /// Terminal states. Mirrors `control_plane.FINAL_STATES`. -pub const FINAL_STATES: [&str; 14] = [ +pub const FINAL_STATES: [&str; 15] = [ "report_validated", "completed", "closed", @@ -245,6 +247,7 @@ pub const FINAL_STATES: [&str; 14] = [ "contract_failed", "recovery_required", "timed_out", + "quota_exhausted", "gc", "ghost", ]; @@ -486,6 +489,74 @@ pub struct RunControls { /// The durable fields mirror `control_plane.RunStatus` plus retained snapshot /// metadata. [`crate::read::ControlPlane`] then adds read-only process evidence /// and typed controls without mutating the Python-owned files. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OperatorAgentPolicyProjection { + pub selection: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permissions: Option, + pub supported: bool, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub warning: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub reason: String, +} + +/// Public, bounded continuity receipt. Prompt bodies and local material paths +/// stay private; only the selected policy, lineage identity, and content hashes +/// cross the control-plane read boundary. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContinuityPolicyProjection { + pub mode: String, + pub lineage_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub parent_provider_session_id: String, + pub supported: bool, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub status: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub reason: String, + #[serde(default)] + pub materialized: Option, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub context_sha256: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub loop_sha256: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SupervisionRelationProjection { + #[serde(default, skip_serializing_if = "String::is_empty")] + pub relation_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub operator_run_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub child_run_id: String, + pub state: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub protocol: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mode: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub warning: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observation: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OperatorAgentProjection { + pub role: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub prompt_role: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub provider_session_id: String, + pub policy: OperatorAgentPolicyProjection, + pub supervision: SupervisionRelationProjection, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub stop_actor_run_id: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RunStatus { pub run_id: String, @@ -520,6 +591,9 @@ pub struct RunStatus { pub current_loop: Option, #[serde(default)] pub total_loops: Option, + /// Explicit Vibecrafted lifecycle owner for supervised interactive runs. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner_pid: Option, /// Durable worker process identity from supervisor metadata. #[serde(default, skip_serializing_if = "Option::is_none")] pub worker_pid: Option, @@ -530,6 +604,12 @@ pub struct RunStatus { /// an N-process probe storm. #[serde(default, skip_serializing_if = "Option::is_none")] pub worker_alive: Option, + /// Structured H2b2c relationship; absent on legacy and unsupervised runs. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub operator_agent: Option, + /// Typed H2b2d continuity truth; absent on legacy and non-interactive runs. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub continuity: Option, #[serde(default, skip_serializing_if = "is_false")] pub recovery_required: bool, #[serde(default, skip_serializing_if = "String::is_empty")] @@ -784,7 +864,7 @@ impl SettlementBoard { } fn is_unsettled_settlement_terminal(run: &RunStatus) -> bool { - const TERMINAL_STATES: [&str; 17] = [ + const TERMINAL_STATES: [&str; 18] = [ "report_validated", "completed", "closed", @@ -797,6 +877,7 @@ fn is_unsettled_settlement_terminal(run: &RunStatus) -> bool { "contract_failed", "recovery_required", "timed_out", + "quota_exhausted", "gc", "ghost", "stalled", @@ -1048,9 +1129,12 @@ impl LifecycleRun { session_id: String::new(), current_loop: None, total_loops: None, + owner_pid: None, worker_pid: None, worker_pgid: None, worker_alive: None, + operator_agent: None, + continuity: None, recovery_required: false, stop_reason: String::new(), agent_session_id: String::new(), @@ -1298,12 +1382,28 @@ pub struct AgentMeta { #[serde(default)] pub session_id: String, #[serde(default, deserialize_with = "de_coerced_int")] + pub owner_pid: Option, + #[serde(default, deserialize_with = "de_coerced_int")] pub worker_pid: Option, #[serde(default, deserialize_with = "de_coerced_int")] pub worker_pgid: Option, #[serde(default)] pub worker_alive: Option, #[serde(default)] + pub role: String, + #[serde(default)] + pub prompt_role: String, + #[serde(default)] + pub provider_session_id: String, + #[serde(default)] + pub operator_policy: Option, + #[serde(default)] + pub continuity: Option, + #[serde(default)] + pub supervision: Option, + #[serde(default)] + pub stop_actor_run_id: String, + #[serde(default)] pub recovery_required: bool, #[serde(default)] pub stop_reason: String, @@ -1438,9 +1538,24 @@ impl AgentMeta { session_id: self.session_id.clone(), current_loop: None, total_loops: None, + owner_pid: self.owner_pid, worker_pid: self.worker_pid, worker_pgid: self.worker_pgid, worker_alive: self.worker_alive, + operator_agent: match (&self.operator_policy, &self.supervision) { + (Some(policy), Some(supervision)) if !self.role.is_empty() => { + Some(OperatorAgentProjection { + role: self.role.clone(), + prompt_role: self.prompt_role.clone(), + provider_session_id: self.provider_session_id.clone(), + policy: policy.clone(), + supervision: supervision.clone(), + stop_actor_run_id: self.stop_actor_run_id.clone(), + }) + } + _ => None, + }, + continuity: self.continuity.clone(), recovery_required: self.recovery_required, stop_reason: self.stop_reason.clone(), agent_session_id: self.agent_session_id.clone(), @@ -1533,9 +1648,18 @@ pub fn merge_status(existing: Option, incoming: RunStatus) -> RunStat session_id: nonempty_or(&preferred.session_id, &other.session_id), current_loop: preferred.current_loop.or(other.current_loop), total_loops: preferred.total_loops.or(other.total_loops), + owner_pid: preferred.owner_pid.or(other.owner_pid), worker_pid: preferred.worker_pid.or(other.worker_pid), worker_pgid: preferred.worker_pgid.or(other.worker_pgid), worker_alive: preferred.worker_alive.or(other.worker_alive), + operator_agent: preferred + .operator_agent + .clone() + .or_else(|| other.operator_agent.clone()), + continuity: preferred + .continuity + .clone() + .or_else(|| other.continuity.clone()), recovery_required: preferred.recovery_required || other.recovery_required, stop_reason: nonempty_or(&preferred.stop_reason, &other.stop_reason), agent_session_id: nonempty_or(&preferred.agent_session_id, &other.agent_session_id), @@ -1571,11 +1695,52 @@ pub fn merge_status(existing: Option, incoming: RunStatus) -> RunStat merged } - #[cfg(test)] mod status_thread_tests { use super::*; + #[test] + fn agent_meta_projects_typed_operator_agent_relationship() { + let raw = serde_json::json!({ + "run_id": "init-child", + "status": "active", + "updated_at": "2026-08-25T12:00:00Z", + "role": "agent", + "prompt_role": "/vc-init", + "provider_session_id": "child-session", + "operator_policy": { + "selection": "auto", "provider": "claude", "supported": true + }, + "supervision": { + "relation_id": "relation-1", "operator_run_id": "oper-1", + "child_run_id": "init-child", "state": "active", + "protocol": "operator-protocol-jsonl-v1" + }, + "continuity": { + "mode": "full-lineage", "lineage_id": "parent-run-1", + "supported": true, "status": "SUPPORTED", + "materialized": true, "context_sha256": "abc", "loop_sha256": "def" + } + }); + let meta: AgentMeta = serde_json::from_value(raw).expect("typed Agent meta"); + let status = meta + .normalize( + DateTime::parse_from_rfc3339("2026-08-25T12:00:01Z") + .unwrap() + .to_utc(), + ) + .expect("run projection"); + let relationship = status.operator_agent.expect("Operator Agent projection"); + assert_eq!(relationship.role, "agent"); + assert_eq!(relationship.policy.provider.as_deref(), Some("claude")); + assert_eq!(relationship.supervision.operator_run_id, "oper-1"); + assert_eq!(relationship.supervision.child_run_id, "init-child"); + let continuity = status.continuity.expect("continuity projection"); + assert_eq!(continuity.mode, "full-lineage"); + assert_eq!(continuity.lineage_id, "parent-run-1"); + assert_eq!(continuity.context_sha256, "abc"); + } + #[test] fn delivery_axes_mid_flight_are_not_failed() { let axes = delivery_axes_for_receipt("promise", None, None, None); @@ -1584,6 +1749,9 @@ mod status_thread_tests { assert_eq!(axes.execution_state, ExecutionState::TimedOut); let axes = delivery_axes_for_receipt("interrupted", None, None, None); assert_eq!(axes.execution_state, ExecutionState::Interrupted); + let axes = delivery_axes_for_receipt("quota_exhausted", None, None, None); + assert_eq!(axes.execution_state, ExecutionState::Interrupted); + assert!(is_final_state("quota_exhausted")); let axes = delivery_axes_for_receipt("failed", None, None, None); assert_eq!(axes.execution_state, ExecutionState::Failed); let axes = delivery_axes_for_receipt("completed", None, None, None); diff --git a/vibecrafted-server/control-core/src/read.rs b/vibecrafted-server/control-core/src/read.rs index 59113135..753d269c 100644 --- a/vibecrafted-server/control-core/src/read.rs +++ b/vibecrafted-server/control-core/src/read.rs @@ -22,10 +22,11 @@ use chrono::{DateTime, Utc}; use crate::events::EventStream; use crate::model::{ - AgentMeta, DeliverySealRef, Event, FINAL_STATES, Health, LifecycleRun, LifecycleRunSummary, + AgentMeta, ContinuityPolicyProjection, DeliverySealRef, Event, FINAL_STATES, Health, + LifecycleRun, LifecycleRunSummary, OperatorAgentPolicyProjection, OperatorAgentProjection, RECENT_RUN_LIMIT, RUN_STALL_SECONDS, RunStatus, SettlementBoard, SettlementTui, - SettlementVerdict, TrustReceiptV1, coerce_int_value, is_final_state, merge_status, - operator_session_name, parse_iso, skill_from_code, state_health, + SettlementVerdict, SupervisionRelationProjection, TrustReceiptV1, coerce_int_value, + is_final_state, merge_status, operator_session_name, parse_iso, skill_from_code, state_health, }; /// Resolve `~`-prefixed paths against `$HOME`. Other paths pass through. @@ -460,9 +461,12 @@ impl ControlPlane { session_id: value("session_id"), current_loop: None, total_loops: None, + owner_pid: integer("owner_pid"), worker_pid: integer("worker_pid"), worker_pgid: integer("worker_pgid"), worker_alive: boolean("worker_alive"), + operator_agent: meta.as_ref().and_then(operator_agent_projection), + continuity: meta.as_ref().and_then(continuity_projection), recovery_required: boolean("recovery_required").unwrap_or(false), stop_reason: value("stop_reason"), agent_session_id: value("agent_session_id"), @@ -776,6 +780,7 @@ impl ControlPlane { events.retain(|event| !event_has_test_provenance(event, &self.home)); let worker_pid_candidates: HashSet<(String, i64)> = events .iter() + .filter(|event| event_owner_pid(event).is_none_or(pid_is_alive)) .flat_map(|event| event_worker_pids(event).map(|pid| (event.run_id.clone(), pid))) .collect(); let live_worker_runs: HashSet = worker_pid_candidates @@ -817,6 +822,11 @@ impl ControlPlane { if run.worker_pid.is_some() || run.worker_pgid.is_some() { run.worker_alive = Some(false); } + if run.owner_pid.is_some() && run.worker_alive == Some(false) { + run.health = "stalled".to_string(); + run.liveness = "pid_gone".to_string(); + run.recovery_required = true; + } } let await_run = !terminal && run @@ -1024,6 +1034,11 @@ fn normalize_event(event: &Event, existing: Option<&RunStatus>, now: DateTime, now: DateTime String { } } +fn operator_agent_projection(payload: &serde_json::Value) -> Option { + let role = payload.get("role")?.as_str()?.to_string(); + if role.is_empty() { + return None; + } + let policy: OperatorAgentPolicyProjection = + serde_json::from_value(payload.get("operator_policy")?.clone()).ok()?; + let supervision: SupervisionRelationProjection = + serde_json::from_value(payload.get("supervision")?.clone()).ok()?; + Some(OperatorAgentProjection { + role, + prompt_role: payload + .get("prompt_role") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(), + provider_session_id: payload + .get("provider_session_id") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(), + policy, + supervision, + stop_actor_run_id: payload + .get("stop_actor_run_id") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(), + }) +} + +fn continuity_projection(payload: &serde_json::Value) -> Option { + serde_json::from_value(payload.get("continuity")?.clone()).ok() +} + fn event_has_test_provenance(event: &Event, home: &Path) -> bool { if is_pytest_temp_path(home) { return false; @@ -1238,6 +1296,10 @@ fn event_worker_pids(event: &Event) -> impl Iterator + '_ { .filter_map(coerce_int_value) } +fn event_owner_pid(event: &Event) -> Option { + event.payload.get("owner_pid").and_then(coerce_int_value) +} + fn pid_is_alive(pid: i64) -> bool { if pid <= 0 { return false; @@ -1349,12 +1411,18 @@ fn settlement_tui(value: &str) -> Option { fn enrich_run_status(run: &mut RunStatus, payload: &serde_json::Value, probe_worker_alive: bool) { if probe_worker_alive && (run.worker_pid.is_some() || run.worker_pgid.is_some()) { - run.worker_alive = Some( + let provider_alive = [run.worker_pid, run.worker_pgid] .into_iter() .flatten() - .any(pid_is_alive), - ); + .any(pid_is_alive); + let owner_alive = run.owner_pid.is_none_or(pid_is_alive); + run.worker_alive = Some(provider_alive && owner_alive); + if run.owner_pid.is_some() && run.worker_alive == Some(false) && !run.is_terminal() { + run.health = "stalled".to_string(); + run.liveness = "pid_gone".to_string(); + run.recovery_required = true; + } } let settlement = payload @@ -1431,12 +1499,18 @@ fn refresh_worker_liveness(run: &mut RunStatus) { if run.worker_pid.is_none() && run.worker_pgid.is_none() { return; } - run.worker_alive = Some( + let provider_alive = [run.worker_pid, run.worker_pgid] .into_iter() .flatten() - .any(pid_is_alive), - ); + .any(pid_is_alive); + let owner_alive = run.owner_pid.is_none_or(pid_is_alive); + run.worker_alive = Some(provider_alive && owner_alive); + if run.owner_pid.is_some() && run.worker_alive == Some(false) && !run.is_terminal() { + run.health = "stalled".to_string(); + run.liveness = "pid_gone".to_string(); + run.recovery_required = true; + } let terminal = run.is_terminal(); let await_run = !terminal && run @@ -1592,9 +1666,12 @@ fn normalize_lock(path: &Path, now: DateTime) -> Option { session_id: String::new(), current_loop: None, total_loops: None, + owner_pid: None, worker_pid: None, worker_pgid: None, worker_alive: None, + operator_agent: None, + continuity: None, recovery_required: false, stop_reason: String::new(), agent_session_id: String::new(), @@ -1721,9 +1798,12 @@ impl MarblesState { session_id: String::new(), current_loop: self.current_loop, total_loops: self.total_loops, + owner_pid: None, worker_pid: None, worker_pgid: None, worker_alive: None, + operator_agent: None, + continuity: None, recovery_required: false, stop_reason: String::new(), agent_session_id: String::new(), @@ -2150,6 +2230,47 @@ mod tests { fs::remove_dir_all(home).ok(); } + #[test] + fn interactive_owner_and_provider_must_both_be_live_for_active_projection() { + let home = temp_home("interactive-owner-liveness"); + let runtime = home.join("control_plane/runtime_runs/interactive-owner-dead"); + fs::create_dir_all(&runtime).expect("runtime run"); + let now = Utc::now(); + fs::write( + runtime.join("meta.json"), + serde_json::to_vec(&json!({ + "run_id": "interactive-owner-dead", + "status": "active", + "agent": "codex", + "skill": "init", + "root": "/srv/checkout/vibecrafted", + "updated_at": now.to_rfc3339(), + "liveness": "active", + "owner_pid": 999999999_i64, + "worker_pid": std::process::id() + })) + .expect("meta json"), + ) + .expect("meta"); + + let view = ControlPlane::new(&home).compute_view(now); + let run = view + .recent_runs + .iter() + .find(|run| run.run_id == "interactive-owner-dead") + .expect("interactive run remains inspectable"); + + assert_eq!(run.owner_pid, Some(999999999)); + assert_eq!(run.worker_alive, Some(false)); + assert!( + view.active_runs + .iter() + .all(|run| run.run_id != "interactive-owner-dead") + ); + assert_eq!(view.settlement_counts.active, 0); + fs::remove_dir_all(home).ok(); + } + #[test] fn settlement_needs_attention_counts_and_stalled_bucket_is_orthogonal() { let unique = format!( diff --git a/vibecrafted-server/web/Cargo.toml b/vibecrafted-server/web/Cargo.toml index 07386849..218163ea 100644 --- a/vibecrafted-server/web/Cargo.toml +++ b/vibecrafted-server/web/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vibecrafted-server-web" -version = "4.2.4" +version = "4.3.0" edition = "2024" rust-version = "1.85.0" license = "BUSL-1.1" diff --git a/vibecrafted-vm/Containerfile b/vibecrafted-vm/Containerfile index f2f5db76..225b19e7 100644 --- a/vibecrafted-vm/Containerfile +++ b/vibecrafted-vm/Containerfile @@ -1,247 +1,96 @@ # syntax=docker/dockerfile:1.7 -# ============================================================================ -# vc-workspace — SoTA dev container for the Vetcoders / vibecrafted / loctree -# stack. Debian 13 trixie base, full framework + foundations + agent CLIs + -# tailnet integration. -# -# Naming: vc-workspace = this container (image/node). vc-runtime = the multiroot -# repo tree it mounts at /workspace. Two names, two things — no overlap. -# -# Foundations are COMPILED FROM SOURCE (builder stage) because prebuilt -# linux/arm64 bundles do not exist (loct.io / GH releases ship only macOS-arm64 -# + linux-x86_64). Source is vendored at build time into src/ via -# `git archive HEAD` of the local aicx + loctree-suite checkouts (refresh with -# vendor-src.sh). Native arm64 binaries, your exact committed code, zero -# private-repo creds in any layer. -# -# Build (single arch, local): docker compose up -d --build # compose.yaml + .env -# First build ~15-20 min (Rust workspace compile incl. aicx native-embedder / -# llama-cpp-sys). Subsequent builds cache layer-by-layer. -# -# 𝚅𝚒𝚋𝚎𝚌𝚛𝚊𝚏𝚝𝚎𝚍. with AI Agents by Vetcoders (c)2024-2026 LibraxisAI -# ============================================================================ -ARG DEBIAN_RELEASE=trixie -ARG ZIG_VERSION=0.13.0 - -# ────────────────────────────────────────────────────────────────────────── -# Stage A — builder: compile Vetcoders foundations from vendored source -# ────────────────────────────────────────────────────────────────────────── -FROM debian:${DEBIAN_RELEASE} AS builder - -ENV DEBIAN_FRONTEND=noninteractive -ENV CARGO_TERM_COLOR=always - -# Build deps. cmake/ninja/clang/libclang/llvm are needed by aicx's -# native-embedder (llama-cpp-2 / GGUF) and any bindgen sys-crates. -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates curl git pkg-config \ - build-essential cmake ninja-build \ - libssl-dev libudev-dev \ - clang libclang-dev llvm \ - && rm -rf /var/lib/apt/lists/* - -# Rust via rustup. The aicx checkout pins rust-toolchain.toml = 1.95.0, which -# rustup auto-installs on first cargo invocation inside that tree; loctree needs -# MSRV 1.85+. Install a recent stable as the default for the loctree build. -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ - | sh -s -- -y --default-toolchain stable --profile minimal --no-modify-path -ENV PATH="/root/.cargo/bin:${PATH}" -ENV RUSTUP_HOME=/root/.rustup -RUN rustc --version && cargo --version - -# Vendored source (git archive HEAD of local checkouts — clean, tracked-only). -WORKDIR /build -COPY src/loctree-suite ./loctree-suite -COPY src/aicx ./aicx - -# Compile loctree-suite. NOTE: the loctree-rs package declares shim [[bin]]s -# that can collide on output filename with the dedicated loctree-mcp / -# loctree-lsp packages, so we build per-package in SEPARATE invocations and -# install each binary immediately. -RUN cd loctree-suite \ - && cargo build --release -p loctree --bin loct --bin loctree \ - && install -m 0755 target/release/loct /usr/local/bin/loct \ - && install -m 0755 target/release/loctree /usr/local/bin/loctree \ - && cargo build --release -p loctree-mcp \ - && install -m 0755 target/release/loctree-mcp /usr/local/bin/loctree-mcp \ - && cargo build --release -p loctree-lsp \ - && install -m 0755 target/release/loctree-lsp /usr/local/bin/loctree-lsp \ - && loct --version - -# Compile aicx: aicx + aicx-mcp (default features: native + cloud embedder). -# rust-toolchain.toml in this dir pins the toolchain; rustup fetches it. -RUN cd aicx \ - && cargo build --release --bin aicx --bin aicx-mcp \ - && install -m 0755 target/release/aicx /usr/local/bin/aicx \ - && install -m 0755 target/release/aicx-mcp /usr/local/bin/aicx-mcp \ - && aicx --version - -# prview — public crate, best-effort (not load-bearing). Always leave SOMETHING -# at /usr/local/bin/prview so the runtime COPY never fails on an empty glob: -# real binary if the build succeeds, a stub that explains itself otherwise. -RUN ( cargo install --locked prview 2>/dev/null \ - && cp /root/.cargo/bin/prview /usr/local/bin/prview \ - && prview --version ) \ - || ( echo "[warn] prview build unavailable — installing stub" \ - && printf '#!/bin/sh\necho "prview not installed in this image (build skipped it)" >&2\nexit 127\n' \ - > /usr/local/bin/prview \ - && chmod 0755 /usr/local/bin/prview ) - -# ────────────────────────────────────────────────────────────────────────── -# Stage B — runtime: slim Debian + agent CLIs + framework + tailscale -# ────────────────────────────────────────────────────────────────────────── -FROM debian:${DEBIAN_RELEASE}-slim AS runtime - -ENV DEBIAN_FRONTEND=noninteractive -ENV LANG=C.UTF-8 -ENV LC_ALL=C.UTF-8 -ENV TZ=UTC -# /opt/vibecrafted is only a build-time SEED (image layer, see the framework -# install below). The live install owns the canonical roots — store -# ~/.vibecrafted · runtime ~/.local/share/vibecrafted · launchers -# ~/.local/bin — so the image must not export VIBECRAFTED_ROOT or shadow -# canonical launchers with seed paths (the installer fail-fasts on that drift). -ENV PATH="/root/.local/bin:/usr/local/bin:/root/.cargo/bin:${PATH}" - -# Runtime deps. gnupg2 for signed installers; make for the vibecrafted installer -# preflight. CLI niceties (eza/bat/fd/rg/just/zoxide) from Debian trixie apt -# (tolerant install). libgomp1 covers aicx native-embedder runtime. -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates curl wget git git-lfs gnupg2 openssh-client openssh-server \ - libssl3 libudev1 libgomp1 \ - python3 python3-pip python3-venv \ - make coreutils \ - jq htop tmux unzip xz-utils \ - zsh \ - iproute2 iptables iputils-ping dnsutils \ - && for pkg in eza bat fd-find ripgrep just zoxide; do \ - apt-get install -y --no-install-recommends "$pkg" \ - || echo "[warn] apt: $pkg unavailable, skipping"; \ - done \ +# H2b3a selector carrier. The Runtime Pack is built and sealed outside this +# image; this image only verifies and installs those exact bytes. The separate +# docker/runtime tailnet deployment does not consume this Containerfile. +FROM node:22.19.0-bookworm-slim@sha256:4a4884e8a44826194dff92ba316264f392056cbe243dcc9fd3551e71cea02b90 + +ARG RUNTIME_PACK_ARCHIVE=build/Vibecrafted_RuntimePack_linux-arm64.tar.gz +ARG RUNTIME_PACK_CARRIER_BASENAME +ARG RUNTIME_PACK_SHA256 +ARG RUNTIME_PACK_MANIFEST_SHA256 +ARG VIBECRAFTED_SOURCE_REVISION +ARG CODEX_VERSION=0.149.1 +ARG CLAUDE_VERSION=2.1.245 +ARG GEMINI_VERSION=0.56.0 + +LABEL org.opencontainers.image.title="Vibecrafted Local VM Runtime" \ + org.opencontainers.image.description="Exact non-root Vibecrafted Runtime Pack carrier" \ + org.opencontainers.image.source="https://github.com/vetcoders/vibecrafted" \ + org.opencontainers.image.revision="${VIBECRAFTED_SOURCE_REVISION}" \ + io.vetcoders.vibecrafted.runtime-pack.sha256="${RUNTIME_PACK_SHA256}" \ + io.vetcoders.vibecrafted.runtime-pack.manifest-sha256="${RUNTIME_PACK_MANIFEST_SHA256}" + +ENV DEBIAN_FRONTEND=noninteractive \ + LANG=C.UTF-8 \ + LC_ALL=C.UTF-8 \ + HOME=/var/lib/vibecrafted \ + XDG_CONFIG_HOME=/var/lib/vibecrafted/.config \ + XDG_DATA_HOME=/var/lib/vibecrafted/.local/share \ + VIBECRAFTED_HOME=/var/lib/vibecrafted/.vibecrafted \ + VIBECRAFTED_RUNTIME_ROOT=/opt/vibecrafted-runtime \ + RUNTIME_PACK_CARRIER_BASENAME=${RUNTIME_PACK_CARRIER_BASENAME} \ + PATH=/opt/vibecrafted-runtime/bin:/usr/local/bin:/usr/bin:/bin + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates file git libayatana-appindicator3-1 libdbus-1-3 \ + libfontconfig1 libfreetype6 libgomp1 libgtk-3-0 libssl3 libudev1 \ + libwayland-client0 libx11-6 libxcb1 libxdo3 libxkbcommon0 passwd tini \ && rm -rf /var/lib/apt/lists/* \ - && { [ -e /usr/bin/batcat ] && ln -sf /usr/bin/batcat /usr/local/bin/bat || true; } \ - && { [ -e /usr/bin/fdfind ] && ln -sf /usr/bin/fdfind /usr/local/bin/fd || true; } - -# Node 22 LTS for agent CLIs -RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ - && apt-get install -y --no-install-recommends nodejs \ - && rm -rf /var/lib/apt/lists/* \ - && node --version && npm --version - -# Agent CLIs (npm globals + canonical claude symlink), retry-on-flake. -RUN npm config set fund false && npm config set audit false; \ - ( npm install -g @anthropic-ai/claude-code @openai/codex @google/gemini-cli \ - || (echo "[warn] npm retry…" && sleep 3 && \ - npm install -g @anthropic-ai/claude-code @openai/codex @google/gemini-cli) ) \ - || echo "[warn] agent CLI npm install had issues — continuing"; \ - NPM_BIN="$(npm root -g 2>/dev/null)"; \ - if [ -n "$NPM_BIN" ] && [ -f "$NPM_BIN/@anthropic-ai/claude-code/cli.js" ]; then \ - ln -sf "$NPM_BIN/@anthropic-ai/claude-code/cli.js" /usr/local/bin/claude; \ - chmod +x /usr/local/bin/claude; \ - fi; \ - for c in claude codex gemini; do command -v "$c" >/dev/null 2>&1 && echo " ok: $c" || echo " [warn] missing: $c"; done; true - -# uv (Python package manager) -RUN curl -LsSf https://astral.sh/uv/install.sh | sh \ - && ln -sf /root/.local/bin/uv /usr/local/bin/uv \ - && uv --version - -# Vetcoders foundations — compiled binaries from builder -COPY --from=builder /usr/local/bin/loct /usr/local/bin/loct -COPY --from=builder /usr/local/bin/loctree /usr/local/bin/loctree -COPY --from=builder /usr/local/bin/loctree-mcp /usr/local/bin/loctree-mcp -COPY --from=builder /usr/local/bin/loctree-lsp /usr/local/bin/loctree-lsp -COPY --from=builder /usr/local/bin/aicx /usr/local/bin/aicx -COPY --from=builder /usr/local/bin/aicx-mcp /usr/local/bin/aicx-mcp -COPY --from=builder /usr/local/bin/prview /usr/local/bin/prview -RUN for b in loct loctree loctree-mcp loctree-lsp aicx aicx-mcp; do \ - command -v "$b" >/dev/null 2>&1 && echo " ok: $b" || echo " [warn] missing: $b"; \ + && /usr/sbin/groupadd --gid 10001 vibecrafted \ + && /usr/sbin/useradd --uid 10001 --gid 10001 --home-dir /var/lib/vibecrafted \ + --create-home --shell /bin/bash vibecrafted + +# npm verifies registry integrity for these exact package versions. Their +# expected registry integrities are recorded in runtime-provider-lock.json. +RUN npm config set fund false \ + && npm config set audit false \ + && test "$(npm view @openai/codex@${CODEX_VERSION} dist.integrity)" = 'sha512-6q5pbcpFbJbqOpkubSDBwXmktQ55aD8eUzGzBF1zASob2DjwhBKDSNGtdZKalfrNJUdTDTPDMmzCXEXs5tMBYA==' \ + && test "$(npm view @anthropic-ai/claude-code@${CLAUDE_VERSION} dist.integrity)" = 'sha512-+7baJddJXZukgd6AgC7xStHGsMTVHDPlRcAoqTSPx2NQ+QwKGtvCZQLgbnKuhjkwq9v9vKvYwIhLOwGiE77mVQ==' \ + && test "$(npm view @google/gemini-cli@${GEMINI_VERSION} dist.integrity)" = 'sha512-q4oBfb/Oh/HNLMYBOJMp88/QQ8hLffnB0ykoVThi6A5isbGHJ/ylWLMosMGqukKY0Q1Jv/XRDpb46Q1BV+zQqw==' \ + && npm install --global --ignore-scripts=false \ + "@openai/codex@${CODEX_VERSION}" \ + "@anthropic-ai/claude-code@${CLAUDE_VERSION}" \ + "@google/gemini-cli@${GEMINI_VERSION}" \ + && codex --version \ + && claude --version \ + && gemini --version + +COPY vibecrafted-vm/runtime-provider-lock.json /usr/local/share/vibecrafted/runtime-provider-lock.json +COPY ${RUNTIME_PACK_ARCHIVE} /tmp/runtime-pack.tar.gz + +RUN test -n "$RUNTIME_PACK_CARRIER_BASENAME" \ + && test -n "$RUNTIME_PACK_SHA256" \ + && test -n "$RUNTIME_PACK_MANIFEST_SHA256" \ + && test -n "$VIBECRAFTED_SOURCE_REVISION" \ + && printf '%s %s\n' "$RUNTIME_PACK_SHA256" /tmp/runtime-pack.tar.gz | sha256sum -c - \ + && mkdir -p /opt/vibecrafted-runtime \ + && tar -xzf /tmp/runtime-pack.tar.gz -C /opt/vibecrafted-runtime --strip-components=1 \ + && test "$(sha256sum /opt/vibecrafted-runtime/runtime-pack-provenance.json | cut -d' ' -f1)" = "$RUNTIME_PACK_MANIFEST_SHA256" \ + && PYTHONPATH=/opt/vibecrafted-runtime/vibecrafted-core \ + /opt/vibecrafted-runtime/bin/python3 -m vibecrafted_core.runtime_pack_contract verify \ + --root /opt/vibecrafted-runtime \ + --carrier-basename "$RUNTIME_PACK_CARRIER_BASENAME" \ + --expected-source-revision "$VIBECRAFTED_SOURCE_REVISION" \ + --expected-platform linux --expected-architecture arm64 \ + && rm -f /tmp/runtime-pack.tar.gz \ + && chown -R root:root /opt/vibecrafted-runtime + +COPY vibecrafted-vm/runtime-entry.sh /usr/local/bin/runtime-entry +RUN chmod 0755 /usr/local/bin/runtime-entry \ + && for binary in vibecrafted vc-server loct loctree loctree-mcp loctree-lsp aicx aicx-mcp prview screenscribe vc-frame vc-terminal voc; do \ + test -x "/opt/vibecrafted-runtime/bin/$binary"; \ done -# semgrep (PyPI) — load-bearing quality gate, must succeed. -RUN pip3 install --break-system-packages --quiet semgrep \ - && semgrep --version >/dev/null 2>&1 && echo "semgrep ok" - -# screenscribe — NOT on PyPI (it's a Vetcoders foundation distributed out-of-band, -# like loct/aicx). Best-effort: try pip in case a private index is configured, -# otherwise skip — it is not load-bearing for the dev container. -RUN pip3 install --break-system-packages --quiet screenscribe 2>/dev/null \ - && echo "screenscribe ok" \ - || echo "[warn] screenscribe not on PyPI — skipped (install from loct.io/source if needed)" - -# Zig (arch-aware; for building zig projects inside the container) -ARG ZIG_VERSION -RUN ARCH="$(uname -m)"; \ - case "$ARCH" in \ - x86_64|amd64) ZIG_ARCH=x86_64 ;; \ - aarch64|arm64) ZIG_ARCH=aarch64 ;; \ - *) echo "unsupported arch=$ARCH" && exit 1 ;; \ - esac \ - && curl -fsSL "https://ziglang.org/download/${ZIG_VERSION}/zig-linux-${ZIG_ARCH}-${ZIG_VERSION}.tar.xz" \ - | tar -xJ -C /opt/ \ - && ln -sf /opt/zig-linux-${ZIG_ARCH}-${ZIG_VERSION}/zig /usr/local/bin/zig \ - && zig version - -# starship + atuin + mise -RUN curl -sS https://starship.rs/install.sh | sh -s -- -y && starship --version -RUN curl --proto '=https' --tlsv1.2 -sSf https://setup.atuin.sh | sh \ - && ln -sf /root/.atuin/bin/atuin /usr/local/bin/atuin \ - && atuin --version || echo "atuin installed" -RUN curl -fsSL https://mise.jdx.dev/install.sh | sh \ - && ln -sf /root/.local/bin/mise /usr/local/bin/mise - -# vc_frame (prebuilt release) -RUN ARCH="$(uname -m)"; \ - case "$ARCH" in \ - x86_64) VC_FRAME_ARCH=x86_64-unknown-linux-musl ;; \ - aarch64) VC_FRAME_ARCH=aarch64-unknown-linux-musl ;; \ - *) echo "unsupported arch $ARCH" && exit 1 ;; \ - esac; \ - curl -fsSL "https://github.com/vc_frame-org/vc_frame/releases/latest/download/vc_frame-${VC_FRAME_ARCH}.tar.gz" \ - | tar -xz -C /usr/local/bin/ && chmod +x /usr/local/bin/vc_frame \ - && vc_frame --version - -# Tailscale (userspace mode for containers, no kernel module needed) -RUN curl -fsSL https://tailscale.com/install.sh | sh \ - && tailscale --version - -# Vetcoders framework — official vibecrafted.io installer. -# Piping to bash is already non-interactive (no-TTY → compact path); `--yes` -# skips the consent prompt. VIBECRAFTED_HOME sets the location (no --prefix -# flag exists); /opt/vibecrafted so it does not write into the mounted -# /root/.vibecrafted volume. Non-fatal: foundations above are verified. -RUN curl -fsSL https://vibecrafted.io/install.sh -o /tmp/vc-install.sh \ - && (VIBECRAFTED_HOME=/opt/vibecrafted bash /tmp/vc-install.sh --yes \ - || echo "[warn] vibecrafted framework install non-fatal — foundations still present") \ - && rm -f /tmp/vc-install.sh -RUN for cand in /opt/vibecrafted/bin/vibecrafted \ - /opt/vibecrafted/tools/vibecrafted-current/bin/vibecrafted; do \ - [ -x "$cand" ] && ln -sf "$cand" /usr/local/bin/vibecrafted && break; \ - done; \ - vibecrafted --version 2>/dev/null || echo "[note] vibecrafted launcher not on PATH yet — check /opt/vibecrafted" - -# Shell setup -RUN chsh -s /usr/bin/zsh root \ - && mkdir -p /root/.config/vetcoders -COPY zshrc.template /root/.zshrc -COPY entry.sh /usr/local/bin/entry.sh -RUN chmod +x /usr/local/bin/entry.sh - +USER vibecrafted WORKDIR /workspace - -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD aicx --version >/dev/null 2>&1 && loct --version >/dev/null 2>&1 || exit 1 - -VOLUME ["/workspace", "/root/.aicx", "/root/.keys", "/root/.claude", "/root/.codex", "/root/.gemini", "/root/.vibecrafted", "/root/.config/vetcoders"] -VOLUME ["/var/lib/tailscale"] - -ENTRYPOINT ["/usr/local/bin/entry.sh"] -CMD ["zsh"] - -LABEL org.opencontainers.image.title="vc-workspace" -LABEL org.opencontainers.image.description="SoTA dev container for Vetcoders / vibecrafted / loctree / aicx stack with tailnet integration" -LABEL org.opencontainers.image.source="https://github.com/vetcoders/vc-workspace" -LABEL org.opencontainers.image.licenses="MIT" -LABEL org.opencontainers.image.authors="Vetcoders " -LABEL org.opencontainers.image.version="0.2.0" +ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/runtime-entry"] +CMD ["vibecrafted", "--help"] + +HEALTHCHECK --interval=30s --timeout=15s --start-period=5s --retries=3 \ + CMD PYTHONPATH=/opt/vibecrafted-runtime/vibecrafted-core \ + /opt/vibecrafted-runtime/bin/python3 -m vibecrafted_core.runtime_pack_contract verify \ + --root /opt/vibecrafted-runtime \ + --carrier-basename "$RUNTIME_PACK_CARRIER_BASENAME" \ + --expected-platform linux --expected-architecture arm64 >/dev/null || exit 1 diff --git a/vibecrafted-vm/README.md b/vibecrafted-vm/README.md index b944e7ae..82bdcf08 100644 --- a/vibecrafted-vm/README.md +++ b/vibecrafted-vm/README.md @@ -1,176 +1,64 @@ -# vc-workspace +# Linux arm64 Runtime Pack carrier -SoTA dev container for the **Vetcoders / vibecrafted / loctree / aicx** stack. -Debian 13 trixie base, multi-arch (linux/amd64 + linux/arm64), full framework +This directory owns the hardened image that will be consumed by the Workshop +`local-vm` backend in H2b3b. H2b3a does **not** enable that selector or create +per-run containers. -- 11 foundations + 21 vc-\* skills + 3 agent CLIs + tailnet integration. +The image has one input: the checksum-pinned Runtime Pack produced by the +repository's canonical `package-runtime-pack.sh` contract. It does not build +from sibling repositories, install mutable releases, mount operator state, or +provide a success stub for a missing tool. -Single image, mesh-wide consistency. Works on host-a (macOS arm64), host-b -(macOS), host-d (macOS), host-e (Linux), windows (WSL2) — same surface -everywhere. +## Build the Runtime Pack -> **Naming, once:** `vc-workspace` is **this container** (image + tailnet node + -> build folder). `vc-runtime` is the **multiroot repo tree** it mounts at -> `/workspace`. The container is named after the workspace it serves; the code -> it serves keeps its own name. One name → one thing. -> -> This is one of **three** container paths in the tree — see -> [`../WORKSPACE.md`](../WORKSPACE.md) § "Trzy ścieżki kontenera" for when to -> use this vs `.devcontainer/` (VS Code) vs `vibecrafted/docker/` (CI/minimal). - -## What's inside - -| Layer | Components | -| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Base** | `debian:trixie-slim` (multi-arch) | -| **Toolchains** | Rust (rustup stable) · Zig 0.13.0 · Node 22 LTS · Python 3 (uv) | -| **Vetcoders foundations** | `loct` · `loctree` · `loctree-mcp` · `loctree-lsp` · `aicx` · `aicx-mcp` (all via loct.io) · `screenscribe` · `semgrep` · `mise` · `starship` · `atuin` · `zoxide` · `vc_frame` | -| **Agent CLIs** | `claude` (`@anthropic-ai/claude-code`) · `codex` (`@openai/codex`) · `gemini` (`@google/gemini-cli`) | -| **Framework** | vibecrafted 21 vc-\* skills + agent symlinks + frontier config | -| **CLI niceties** | `eza` · `bat` · `fd` · `rg` · `just` · `tokei` | -| **Network** | tailscale (userspace mode — no `/dev/net/tun` kernel module needed) | -| **Shell** | zsh + starship + atuin + zoxide | - -## Quick start - -### 1. Setup non-secret environment - -```bash -cd vc-runtime/vc-workspace -cp .env.example .env -# Edit .env — set TAILSCALE_HOSTNAME + VC_RUNTIME_DIR -# Never put TAILSCALE_AUTHKEY in this file. -``` - -### 2. Build (multi-arch via buildx) +From a clean clone at the release commit: ```bash -docker buildx create --use --name vc-workspace-builder -docker buildx build --platform linux/amd64,linux/arm64 \ - -t vetcoders/vc-workspace:trixie \ - --push . # or --load for single-arch local +sha="$(git rev-parse HEAD)" +stage="$(mktemp -d "${TMPDIR:-/tmp}/vibecrafted-linux-arm64.XXXXXX")" +python3 scripts/distribution_manifest.py archive \ + --source "$PWD" --output "$stage/source.tar.gz" --root-name vibecrafted +tar -xzf "$stage/source.tar.gz" -C "$stage" +docker buildx build --platform linux/arm64 \ + -f "$stage/vibecrafted/vibecrafted-vm/RuntimePack.Containerfile" \ + --build-arg VIBECRAFTED_SOURCE_REVISION="$sha" \ + --output type=local,dest=build/linux-arm64-runtime-pack \ + "$stage/vibecrafted" ``` -### 3. Run (local dev with mounts) - -```bash -# Optional Tailscale: inject an ephemeral key from your secret manager into -# this process only. Without it, the container starts without joining tailnet. -TAILSCALE_AUTHKEY="$(your-secret-manager read tailscale-ephemeral-key)" \ - docker compose up -d -docker compose exec dev zsh -``` - -Or single-shot: - -```bash -docker compose run --rm dev -``` - -### 4. Verify tailnet access - -Inside container: - -```bash -tailscale status -# expect: vc-workspace- 100.x.y.z ... -``` +The manifest-owned public distribution stage supplies the closed +`source-provenance.json` carrier and excludes development/secret surfaces. The +builder then downloads only public immutable source archives, verifies their +SHA-256 digests, builds the native binaries, records executable versions and +provenance in `runtime-inventory.json`, and invokes the canonical Runtime Pack +packager. -From any other mesh node (host-a/host-b/host-d/ops) — works via **Tailscale SSH** -(no sshd in the image; `entry.sh` runs `tailscale up --ssh`, gated by ACLs): +## Build the exact image ```bash -ssh root@vc-workspace- -# or via tailnet IP +pack=build/linux-arm64-runtime-pack/Vibecrafted_RuntimePack_linux-arm64.tar.gz +pack_sha="$(sha256sum "$pack" | awk '{print $1}')" +manifest_sha="$(tar -xOzf "$pack" VibecraftedRuntime/runtime-pack-provenance.json | sha256sum | awk '{print $1}')" +sha="$(git rev-parse HEAD)" +docker build --platform linux/arm64 -f vibecrafted-vm/Containerfile \ + --build-arg RUNTIME_PACK_ARCHIVE="$pack" \ + --build-arg RUNTIME_PACK_CARRIER_BASENAME="$(basename "$pack")" \ + --build-arg RUNTIME_PACK_SHA256="$pack_sha" \ + --build-arg RUNTIME_PACK_MANIFEST_SHA256="$manifest_sha" \ + --build-arg VIBECRAFTED_SOURCE_REVISION="$sha" \ + -t vibecrafted-local-vm:"${sha:0:12}" . ``` -## Mount strategy - -The container expects these host paths (mounted automatically by -`compose.yaml`): - -| Host path | Container path | Purpose | -| ---------------------------- | -------------------------- | ------------------------------------------- | -| `~/.vibecrafted/vc-runtime/` | `/workspace/` | Operator repos — the multiroot (read-write) | -| `~/.aicx/` | `/root/.aicx/` | Canonical corpus (persistent) | -| `~/.keys/` | `/root/.keys/` (ro) | GPG passphrase, notary creds — read-only | -| `~/.claude/` | `/root/.claude/` | Claude sessions (persistent) | -| `~/.codex/` | `/root/.codex/` | Codex sessions (persistent) | -| `~/.gemini/` | `/root/.gemini/` | Gemini sessions (persistent) | -| `~/.vibecrafted/` | `/root/.vibecrafted/` | vibecrafted artifacts (plans, reports) | -| `~/.config/vetcoders/` | `/root/.config/vetcoders/` | Frontier config (starship, atuin, vc_frame) | -| `~/.gnupg/` | `/root/.gnupg/` (ro) | GPG keyring for release-tag signing | - -## Tailnet integration - -Tailscale runs in **userspace mode** (`tailscaled --tun=userspace-networking`), -no host kernel module needed. Container joins tailnet as a regular node: - -- Get an ephemeral auth key from https://login.tailscale.com/admin/settings/keys -- Inject `TAILSCALE_AUTHKEY` only into the `docker compose up` process; never - persist it in `.env`, generated config or shell history -- Optionally set `TAILSCALE_TAGS=tag:devbox` for ACL routing -- Container appears in tailnet as `${TAILSCALE_HOSTNAME}` on first boot - -Outbound: container reaches tailnet peers (aicx-mcp endpoints, ssh, etc.) via -tailnet IPs (100.x.y.z range). - -Inbound: tailnet peers ssh into container via hostname or tailnet IP. - -## Why not native macOS containers? - -Apple's `container` CLI (WWDC 2024) runs native macOS Mach-O binaries — would -require separate `container` build per Mac host + split surface from -Linux mesh nodes (host-e, etc.). For Vetcoders Rust cross-platform -framework, single Linux image (this) preserves mesh-wide consistency. - -If/when Metal-accelerated MLX embeddings become hot-path (e.g. -`aicx-embeddings/metal` feature), add a parallel `apple/container` track -specifically for M-series Mac dev. Until then, cloud embedder -(`qwen3-embedding:8b @ host-d`) handles embedding side via tailnet. - -## Authority - -- Built atop the host-side `bootstrap-modal.sh` install pattern (Modal / - Codespaces / bare-metal) — same 9-stage layout, here containerized + - framework-aware. -- Vetcoders foundations (`loct`, `loctree`, `loctree-mcp`, `loctree-lsp`, - `aicx`, `aicx-mcp`) installed **prebuilt** via the official loct.io installer - — GPG-verified, signed bundles per target triple (arm64 + x86_64 linux): - `curl -fsSL https://loct.io/install.sh | sh` (in the image: `INSTALL_DIR=/usr/local/bin`, - pin with `LOCTREE_VERSION`). No source compile → fast, reproducible builds. -- Vibecrafted framework installed via the **official installer**, newest stable. - Piping to bash is already non-interactive (no-TTY → compact path); `--yes` - skips the consent prompt; `VIBECRAFTED_HOME` sets the location: - `curl -fsSL https://vibecrafted.io/install.sh | bash -s -- --yes` - (in the image: `VIBECRAFTED_HOME=/opt/vibecrafted bash install.sh --yes`). - -## Caveats - -- **Tailscale auth keys are sensitive.** Use ephemeral keys - (`tskey-auth-..._ephemeral`) for short-lived dev containers. Persistent - keys = ssh in indefinitely. -- **Mounted `~/.keys/` is read-only** by design — container should never - modify host GPG keyring or notary creds. -- **Container is NOT a security boundary** — operator's mounted repos are - read-write, agent CLIs have full shell access. Treat as extension of host - workspace, not isolated sandbox. -- **First build is slow** (~10-20 min depending on host) — Rust workspace - compile of aicx + loctree-suite. Subsequent builds cache layer-by-layer. - -## Sister paths (same tree) - -- [`../.devcontainer/`](../.devcontainer/) — VS Code "Reopen in Container" - (Docker + microsandbox hybrid), for local IDE work -- [`../vibecrafted/docker/`](../vibecrafted/docker/) — minimal entrypoint that - seeds skills from `/opt/vibecrafted`, for CI / headless -- [`vibecrafted`](https://vibecrafted.io) — release engine for AI-built software -- [`loctree`](https://loctree.dev) — semantic-AST structural map - -## License - -MIT — see [LICENSE](./LICENSE). +The default process is UID/GID 10001. No `VOLUME` is declared and no provider +credential, session, home, key, XDG, or repository material is baked in. +Provider CLIs are installed at exact versions recorded in +`runtime-provider-lock.json`; no paid provider call is part of the carrier +proof. ---- +## Personal-dev compose and wizard -_𝚅𝚒𝚋𝚎𝚌𝚛𝚊𝚏𝚝𝚎𝚍. with AI Agents by Vetcoders (c)2024-2026 LibraxisAI_ +`compose.yaml` and `wizard/` are retained only as an operator-controlled +personal development convenience. They require an explicitly supplied +`VC_PERSONAL_DEV_IMAGE` and may mount broad host state. They are **not** a +security boundary, do not build this carrier, and are not the Workshop +selector backend. diff --git a/vibecrafted-vm/RUNBOOK.md b/vibecrafted-vm/RUNBOOK.md index f4ce2af0..49bc2cc4 100644 --- a/vibecrafted-vm/RUNBOOK.md +++ b/vibecrafted-vm/RUNBOOK.md @@ -1,5 +1,11 @@ # vc-workspace — Runbook dla teamu / Team Runbook +> **Personal-dev legacy surface only.** This runbook describes the optional +> broad-mount compose/wizard workflow. It is not the hardened Runtime Pack +> carrier, not a security boundary, and not the Workshop `local-vm` selector +> backend. Set `VC_PERSONAL_DEV_IMAGE` explicitly before using it. For the +> exact carrier build, use [`README.md`](README.md). + > **PL:** Praktyczny przewodnik dla pierwszych użytkowników. Co się spodziewać, jak używać, co kontrolować, gdzie szukać pomocy. > > **EN:** Practical guide for first-time users. What to expect, how to use it, what's tunable, where to get help. diff --git a/vibecrafted-vm/RuntimePack.Containerfile b/vibecrafted-vm/RuntimePack.Containerfile new file mode 100644 index 00000000..e7e6c811 --- /dev/null +++ b/vibecrafted-vm/RuntimePack.Containerfile @@ -0,0 +1,38 @@ +# syntax=docker/dockerfile:1.7 +FROM rust:1.95-bookworm@sha256:6258907abe69656e41cd992e0b705cdcfabcbbe3db374f92ed2d47121282d4a1 AS builder + +ENV DEBIAN_FRONTEND=noninteractive CARGO_TERM_COLOR=always +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates clang cmake curl g++ git libclang-dev libfontconfig1-dev \ + libfreetype6-dev libssl-dev libudev-dev libwayland-dev libx11-dev \ + libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev make ninja-build \ + npm perl pkg-config python3 python3-venv xz-utils \ + && rm -rf /var/lib/apt/lists/* +RUN rustup target add wasm32-unknown-unknown wasm32-wasip1 \ + && cargo install --locked cargo-leptos@0.3.7 \ + && curl -fL --proto '=https' --tlsv1.2 \ + https://github.com/astral-sh/uv/releases/download/0.8.14/uv-aarch64-unknown-linux-gnu.tar.gz \ + -o /tmp/uv.tar.gz \ + && printf '%s %s\n' \ + 69616218470b2ad053617efb9e7027b1518ea38918d933c2791e113d99cec507 \ + /tmp/uv.tar.gz | sha256sum -c - \ + && tar -xzf /tmp/uv.tar.gz -C /tmp \ + && install -m 0755 /tmp/uv-aarch64-unknown-linux-gnu/uv /usr/local/bin/uv \ + && rm -rf /tmp/uv.tar.gz /tmp/uv-aarch64-unknown-linux-gnu +# voc's Linux tray integration is a real runtime surface, so its native GTK +# development contract is explicit instead of depending on a rich base image. +RUN apt-get update && apt-get install -y --no-install-recommends \ + libayatana-appindicator3-dev libdbus-1-dev libgtk-3-dev libxdo-dev \ + protobuf-compiler \ + && rm -rf /var/lib/apt/lists/* +ENV PATH=/usr/local/cargo/bin:/usr/local/bin:/usr/bin:/bin +WORKDIR /src/vibecrafted +ARG VIBECRAFTED_SOURCE_REVISION +COPY . . +RUN test -n "$VIBECRAFTED_SOURCE_REVISION" \ + && VIBECRAFTED_SOURCE_OWNER_REPO=vetcoders/vibecrafted \ + VIBECRAFTED_SOURCE_REVISION="$VIBECRAFTED_SOURCE_REVISION" \ + scripts/build-linux-arm64-runtime-pack.sh /out/Vibecrafted_RuntimePack_linux-arm64.tar.gz + +FROM scratch AS export +COPY --from=builder /out/ / diff --git a/vibecrafted-vm/compose.yaml b/vibecrafted-vm/compose.yaml index 940ca618..dbbfe21e 100644 --- a/vibecrafted-vm/compose.yaml +++ b/vibecrafted-vm/compose.yaml @@ -1,4 +1,8 @@ -# vc-workspace — static quick-launch compose (no wizard needed). +# Personal-dev convenience only. This file is not the Workshop local-vm +# selector backend and is not a security boundary. It deliberately requires an +# operator-supplied development image; the hardened selector image is built +# from the exact Runtime Pack using the commands in README.md and must not be +# combined with these broad mounts. # ============================================================================ # One command: cp .env.example .env && $EDITOR .env && docker compose up -d --build # Enter shell: docker compose exec dev zsh @@ -19,10 +23,7 @@ services: dev: - image: vetcoders/vc-workspace:trixie - build: - context: . - dockerfile: Containerfile + image: ${VC_PERSONAL_DEV_IMAGE:?set VC_PERSONAL_DEV_IMAGE to an operator-owned development image} container_name: vc-workspace hostname: ${TAILSCALE_HOSTNAME:-vc-workspace} stdin_open: true diff --git a/vibecrafted-vm/runtime-entry.sh b/vibecrafted-vm/runtime-entry.sh new file mode 100755 index 00000000..f828093b --- /dev/null +++ b/vibecrafted-vm/runtime-entry.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +runtime_root="${VIBECRAFTED_RUNTIME_ROOT:-/opt/vibecrafted-runtime}" +for binary in vibecrafted vc-server loct loctree loctree-mcp loctree-lsp \ + aicx aicx-mcp prview screenscribe vc-frame vc-terminal voc; do + [[ -x "$runtime_root/bin/$binary" ]] || { + printf 'Runtime Pack inventory failure: %s is missing or not executable\n' "$binary" >&2 + exit 70 + } +done + +[[ "$(id -u)" != 0 ]] || { + printf 'Runtime Pack refuses to run providers as root\n' >&2 + exit 70 +} + +exec "$@" diff --git a/vibecrafted-vm/runtime-provider-lock.json b/vibecrafted-vm/runtime-provider-lock.json new file mode 100644 index 00000000..c1b86a4e --- /dev/null +++ b/vibecrafted-vm/runtime-provider-lock.json @@ -0,0 +1,17 @@ +{ + "schema": "io.vetcoders.vibecrafted.runtime-provider-lock.v1", + "packages": { + "@anthropic-ai/claude-code": { + "integrity": "sha512-+7baJddJXZukgd6AgC7xStHGsMTVHDPlRcAoqTSPx2NQ+QwKGtvCZQLgbnKuhjkwq9v9vKvYwIhLOwGiE77mVQ==", + "version": "2.1.245" + }, + "@google/gemini-cli": { + "integrity": "sha512-q4oBfb/Oh/HNLMYBOJMp88/QQ8hLffnB0ykoVThi6A5isbGHJ/ylWLMosMGqukKY0Q1Jv/XRDpb46Q1BV+zQqw==", + "version": "0.56.0" + }, + "@openai/codex": { + "integrity": "sha512-6q5pbcpFbJbqOpkubSDBwXmktQ55aD8eUzGzBF1zASob2DjwhBKDSNGtdZKalfrNJUdTDTPDMmzCXEXs5tMBYA==", + "version": "0.149.1" + } + } +}