From 394d8b47b5b1512b7b66252964d711f89fcc60a1 Mon Sep 17 00:00:00 2001 From: div0-space Date: Mon, 24 Aug 2026 15:50:01 +0200 Subject: [PATCH 01/46] [claude/interactive] fix(release): the retired runtime_paths shim stays dead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit release-rehearsal caught it: the manifest and the wave's test both named scripts/runtime_paths.py, but this line deliberately retired that PEP 562 shim (a2469bb0 — control-plane sync calls the module; one module object per truth). My earlier manifest addition followed the stale test instead of the repo. Manifest entry and test expectations now agree with the retire decision; the git-drift parametrize row points at a file that exists. Gates: distribution manifest suite 107 passed. Authored-By: claude session_id: 97e159e7-6b2b-466a-95ba-55c33e4c3191 time: 2026-08-24T15:50:01+02:00 runtime: claude-code --- scripts/distribution_manifest.py | 1 - tests/tui/test_distribution_manifest.py | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/scripts/distribution_manifest.py b/scripts/distribution_manifest.py index c5a88935..434a5bbb 100755 --- a/scripts/distribution_manifest.py +++ b/scripts/distribution_manifest.py @@ -53,7 +53,6 @@ "install.ps1", "install.toml", "scripts/distribution_manifest.py", - "scripts/runtime_paths.py", "scripts/vetcoders_install.py", "scripts/vibecrafted", "scripts/verify-vibecrafted-product.sh", diff --git a/tests/tui/test_distribution_manifest.py b/tests/tui/test_distribution_manifest.py index d48a8ca8..3aa6a716 100644 --- a/tests/tui/test_distribution_manifest.py +++ b/tests/tui/test_distribution_manifest.py @@ -37,7 +37,6 @@ "install.toml", "scripts/distribution_manifest.py", "scripts/vetcoders_install.py", - "scripts/runtime_paths.py", "scripts/vibecrafted", "scripts/verify-vibecrafted-product.sh", "vibecrafted-core/pyproject.toml", @@ -1417,7 +1416,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"), ], ) From 6380810c600397d02d9f43229523436b5eaca95a Mon Sep 17 00:00:00 2001 From: div0-space Date: Mon, 24 Aug 2026 15:59:23 +0200 Subject: [PATCH 02/46] [claude/interactive] fix(release): /private/tmp is a generic root, not a build-host name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The payload anonymity gate walks from the checkout root up to the first directory every machine has. That list carried /tmp and /private/var but not /private/tmp — the realpath of /tmp on macOS. Building from a scratchpad worktree under /private/tmp therefore poisoned the forbidden literal set with the generic /private/tmp itself, and the gate FATALed on ten committed occurrences of that string (pytest fixtures, keychain examples, tmp-normalization docs) in eight shipped files. Both the in-build assert and release-rehearsal reproduced it on Vibecrafted_4.2.4-20260824-c7485299-portable.tar.gz. - scripts/lib/payload-hygiene.sh: add /private/tmp to _PAYLOAD_HYGIENE_GENERIC_ROOTS; a checkout at /private/tmp/x/repo now forbids /private/tmp/x (still host-specific), never the generic root. - tests/tui/test_payload_hygiene.py: pin both directions — /private/tmp/solo yields no workshop, /private/tmp/scratch/repo yields /private/tmp/scratch. Authored-By: claude session_id: 97e159e7-6b2b-466a-95ba-55c33e4c3191 time: 2026-08-24T15:58:00+02:00 runtime: claude-code --- scripts/lib/payload-hygiene.sh | 2 +- tests/tui/test_payload_hygiene.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) 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/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 From 176b71d8f11fe130de79c7b6f130823d113c3af2 Mon Sep 17 00:00:00 2001 From: div0-space Date: Mon, 24 Aug 2026 16:02:46 +0200 Subject: [PATCH 03/46] [claude/interactive] fix(privacy): shipped docs must not name the build host scratch root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebuilt tarball at df5e7064 passed the generic-root fix and the gate promptly earned its keep: two shipped files still carried the literal /private/tmp/claude-501/... — the vc-aicx skill (EN and PL) documented the background-task output path with the operator's real uid-scoped scratch directory baked in. PLAN_23 (unshipped, but on the deprivatize branch) carried three more /tmp/claude-501/ mentions plus a /Users/tester placeholder the host-paths gate refuses on principle. All now use /tmp/claude-//... and ~/vc-workspace/... shapes; loct find --regex 'claude-501' over 1213 of 1213 indexed files returns zero hits. Authored-By: claude session_id: 97e159e7-6b2b-466a-95ba-55c33e4c3191 time: 2026-08-24T16:08:00+02:00 runtime: claude-code --- .../docs/plans/PLAN_23_AGENT_OPERATOR_DASHBOARD.md | 8 ++++---- .../vibecrafted_core/skills/pl/vc-aicx/SKILL.md | 2 +- vibecrafted-core/vibecrafted_core/skills/vc-aicx/SKILL.md | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) 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-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/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 ``` From 3d67378d01fb9ead570de2833252ec5e66653460 Mon Sep 17 00:00:00 2001 From: div0-space Date: Mon, 24 Aug 2026 16:15:00 +0200 Subject: [PATCH 04/46] [claude/interactive] fix(app): NotificationManager singleton survives Swift 6 strict concurrency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First `make dmg` after the fp-wave merge was also the first time grok's native-notifications code (0a398d00) met the app build gate: Swift 6 refuses `static let shared` on a non-Sendable class (#MutableGlobalVariable), killing the DMG leg at SwiftCompile. @MainActor would be a lie — handleIpcEvent fires on whatever thread posts IpcEvent from the FFI. The house idiom for callback carriers is already in this AppDelegate (EventObserver: @unchecked Sendable), and the invariant holds: `started` and `presentWindow` are written only on the main thread during launch; cross-thread callbacks read, never mutate. Declared @unchecked Sendable with that invariant documented on the class. Authored-By: claude session_id: 97e159e7-6b2b-466a-95ba-55c33e4c3191 time: 2026-08-24T16:40:00+02:00 runtime: claude-code --- .../shell-agent/app/Vibecrafted/NotificationManager.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/vibecrafted-app/shell-agent/app/Vibecrafted/NotificationManager.swift b/vibecrafted-app/shell-agent/app/Vibecrafted/NotificationManager.swift index 01564f75..4c278383 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" From c7eca02db79564977cb7ffdeaea64436b4437940 Mon Sep 17 00:00:00 2001 From: div0-space Date: Mon, 24 Aug 2026 21:09:09 +0200 Subject: [PATCH 05/46] [codex/interactive] fix(installer): make runtime install reversible Moves native App hydration behind the same receipt-owning installer used by CLI and source channels. The signed Runtime Pack now carries the installer closure, records exact owned files and backups, and rejects conflicts or tampered receipt paths before teardown. The App remains an optional transport overlay: uninstall preserves the application while removing only proven runtime, config, state, cache, and launcher ownership so first-run testing can begin again. Authored-By: codex session_id: 01a03396-782e-7703-93b5-84e7659ddb28 time: 2026-08-24T21:08:57+02:00 runtime: codex --- docs/installer/REQUIRED-SET.md | 48 +- install.sh | 1 + scripts/build-vibecrafted-release.sh | 10 + scripts/distribution_manifest.py | 1 + scripts/vetcoders_install.py | 624 ++++++++++++++++++ scripts/vibecrafted | 10 +- tests/tui/test_install_bootstrap.py | 1 + tests/tui/test_installer_doctor.py | 5 +- tests/tui/test_installer_uninstall.py | 172 +++++ tests/tui/test_keys.py | 17 +- tests/tui/test_release_contract.py | 12 +- tests/tui/test_unified_app_contract.py | 37 +- tests/tui/test_uv_bootstrap.py | 1 + .../app/Vibecrafted/AppDelegate.swift | 350 +++------- .../tests/test_runtime_receipt.py | 5 +- .../vibecrafted_core/deck/vibecrafted | 10 +- .../vibecrafted_core/product_contract.py | 3 + 17 files changed, 979 insertions(+), 328 deletions(-) diff --git a/docs/installer/REQUIRED-SET.md b/docs/installer/REQUIRED-SET.md index e90f578c..1297ca15 100644 --- a/docs/installer/REQUIRED-SET.md +++ b/docs/installer/REQUIRED-SET.md @@ -9,13 +9,15 @@ 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 @@ -30,13 +32,16 @@ Every row is load-bearing: delete it and the named flow stops working. | 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` | +| 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 | -| 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 | +| Required tools | `loct`, `loctree-mcp`, `aicx`, `prview`, `screenscribe` plus the `vc-*` projections | complete agent product; missing Loctree/AICX is fail-closed, missing PRView/ScreenScribe is warned | +| 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: **second and later generations are never required.** One tools generation, one release, one provider @@ -56,8 +61,10 @@ generation. Every other generation is retained history with no consumer. | 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/vc-frame/`, `~/.config/vetcoders/frontier/` | generated symlink farms plus their own `.bak*` / `.stale*` snapshots | +| 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 | @@ -74,7 +81,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 +91,20 @@ 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 before teardown. A locally modified launcher/config is +a conflict and stops the operation before the service or generation is removed. +Pre-install collisions are copied under `/.installer-backups/` +and restored during a successful reset. + +**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 +139,10 @@ 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. +- Fetch/install adapters for the required third-party payloads. The required set + is now explicit, but bundling/installing Loctree, AICX, PRView and ScreenScribe + is the next payload cut after deterministic uninstall; a DMG lacking them is + still incomplete and must not be described as the full product. - `$TMPDIR` test scratch. Owned by the test suite, not by the installer. _𝚅𝚒𝚋𝚎𝚌𝚛𝚊𝚏𝚝𝚎𝚍. with AI Agents by Vetcoders (c)2024-2026 LibraxisAI_ diff --git a/install.sh b/install.sh index bd41b912..0e4c8ac4 100644 --- a/install.sh +++ b/install.sh @@ -231,6 +231,7 @@ REQUIRED_FILES = frozenset( "install.ps1", "install.toml", "scripts/distribution_manifest.py", + "scripts/installer_brand.py", "scripts/vetcoders_install.py", "scripts/vibecrafted", "scripts/verify-vibecrafted-product.sh", diff --git a/scripts/build-vibecrafted-release.sh b/scripts/build-vibecrafted-release.sh index d1939559..1fd0926f 100755 --- a/scripts/build-vibecrafted-release.sh +++ b/scripts/build-vibecrafted-release.sh @@ -539,6 +539,16 @@ 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" /bin/cp -R "$REPO_ROOT/bin/." "$runtime/bin/" /bin/cp -R "$REPO_ROOT/vibecrafted-core/vibecrafted_core" \ "$runtime/vibecrafted-core/" diff --git a/scripts/distribution_manifest.py b/scripts/distribution_manifest.py index 434a5bbb..8677767e 100755 --- a/scripts/distribution_manifest.py +++ b/scripts/distribution_manifest.py @@ -53,6 +53,7 @@ "install.ps1", "install.toml", "scripts/distribution_manifest.py", + "scripts/installer_brand.py", "scripts/vetcoders_install.py", "scripts/vibecrafted", "scripts/verify-vibecrafted-product.sh", diff --git a/scripts/vetcoders_install.py b/scripts/vetcoders_install.py index a22364e4..68a78cf6 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 @@ -2998,7 +3003,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, } @@ -13805,6 +13813,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() @@ -14108,6 +14127,590 @@ 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 / 'bin/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 _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) + + +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", {}) + if _path_present(path) and str(path) not in previous_owned: + _backup_runtime_collision(path, runtime_home=runtime_home, receipt=receipt) + _atomic_text(path, body, mode=mode) + _record_owned_file(receipt, path) + + +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), + "frame": str(generation / "bin/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_dirs": list(previous.get("owned_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"], + ): + directory.mkdir(parents=True, exist_ok=True) + + 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: + shutil.copy2(Path(args.frame_helper).expanduser(), bin_dir / "vc-frame") + (bin_dir / "vc-frame").chmod(0o755) + _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) + if str(generation) not in receipt["owned_dirs"]: + receipt["owned_dirs"].append(str(generation)) + + terminal_host = ( + Path(args.terminal_host).expanduser().resolve() + if args.terminal_host + else generation / "bin/vc-terminal" + ) + required = [ + generation / "bin/vibecrafted", + generation / "bin/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", + ] + missing = [str(path) for path in required if not os.access(path, os.X_OK)] + if missing: + raise RuntimeError("Runtime Pack is incomplete: " + ", ".join(missing)) + + 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)) + 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 not frame_config.exists(): + shutil.copytree( + generation / "vibecrafted-core/vibecrafted_core/config/vc-frame", + frame_config, + ) + receipt["owned_dirs"].append(str(frame_config)) + 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)) + _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"} 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, + ) + + 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, + ) + + 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, + ) + 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()}"), + ] + ) + resolved = path.resolve(strict=False) + return any( + resolved == root.resolve(strict=False) + or _is_subpath(resolved, root.resolve(strict=False)) + for root in allowed + ) + + +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", {}) + for raw_path in owned_files: + 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_dirs", []): + if not _receipt_path_is_allowed(Path(raw_path), paths): + raise RuntimeError(f"receipt path escapes managed 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): + raise RuntimeError( + f"receipt restore path escapes managed roots: {destination}" + ) + resolved_backup = backup.resolve(strict=False) + resolved_backup_root = backup_root.resolve(strict=False) + if resolved_backup != resolved_backup_root and not _is_subpath( + resolved_backup, resolved_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 + ] + 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 + if not dry_run: + _teardown_owned_runtime_for_uninstall(paths["crafted_home"], dry_run=False) + + for raw_path, installed_hash in sorted(owned_files.items(), reverse=True): + path = Path(raw_path) + if not _receipt_path_is_allowed(path, paths): + 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) + + 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 +14864,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 +14898,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..3fe57d0c 100755 --- a/scripts/vibecrafted +++ b/scripts/vibecrafted @@ -5068,10 +5068,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 +5081,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_install_bootstrap.py b/tests/tui/test_install_bootstrap.py index a7531af4..b12fb497 100644 --- a/tests/tui/test_install_bootstrap.py +++ b/tests/tui/test_install_bootstrap.py @@ -32,6 +32,7 @@ "install.ps1", "install.toml", "scripts/distribution_manifest.py", + "scripts/installer_brand.py", "scripts/vetcoders_install.py", "scripts/vibecrafted", "scripts/verify-vibecrafted-product.sh", diff --git a/tests/tui/test_installer_doctor.py b/tests/tui/test_installer_doctor.py index cbe0d16e..88148228 100644 --- a/tests/tui/test_installer_doctor.py +++ b/tests/tui/test_installer_doctor.py @@ -862,6 +862,9 @@ 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" @@ -889,7 +892,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) == 12 assert ( frozenset(_RUNTIME_GENERATION_FIXTURE_SOURCES) == installer._RUNTIME_GENERATION_REQUIRED_HASHES diff --git a/tests/tui/test_installer_uninstall.py b/tests/tui/test_installer_uninstall.py index b6e01320..0ffa8560 100644 --- a/tests/tui/test_installer_uninstall.py +++ b/tests/tui/test_installer_uninstall.py @@ -26,6 +26,178 @@ 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", + "vc-server", + "vc-server-supervisor", + "vc-start", + "vc-workflow", + ): + _write_executable(payload / "bin" / name) + (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") + shell = payload / "vibecrafted-core/vibecrafted_core/runtime/shell" + shell.mkdir(parents=True) + (shell / "vetcoders.sh").write_text("# shell\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)) + monkeypatch.setattr( + installer, "_teardown_owned_runtime_for_uninstall", lambda *_args, **_kwargs: [] + ) + 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") + 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 (generation / "bin/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() + assert "VIBECRAFTED_RUNTIME_ROOT=" in original_launcher.read_text(encoding="utf-8") + assert (config_home / "vibecrafted/vc-frame/config.kdl").is_file() + + # 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=False, emit_result=True)) == 0 + ) + removed = json.loads(capsys.readouterr().out) + assert removed["status"] == "removed" + assert original_launcher.read_text(encoding="utf-8") == "operator-owned\n" + assert not runtime_home.exists() + assert not crafted_home.exists() + assert not (config_home / "vibecrafted").exists() + assert app_root.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_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 _setup_installed_surface( tmp_path: Path, monkeypatch ) -> tuple[Path, Path, Path, Path, Path]: diff --git a/tests/tui/test_keys.py b/tests/tui/test_keys.py index acd46965..b152e609 100644 --- a/tests/tui/test_keys.py +++ b/tests/tui/test_keys.py @@ -335,23 +335,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 +344,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_release_contract.py b/tests/tui/test_release_contract.py index 37846730..61d533d9 100644 --- a/tests/tui/test_release_contract.py +++ b/tests/tui/test_release_contract.py @@ -384,6 +384,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 +397,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 ) diff --git a/tests/tui/test_unified_app_contract.py b/tests/tui/test_unified_app_contract.py index 89d88868..f6f7a42e 100644 --- a/tests/tui/test_unified_app_contract.py +++ b/tests/tui/test_unified_app_contract.py @@ -790,28 +790,23 @@ 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 ( - '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 + assert 'appendingPathComponent("scripts/vetcoders_install.py")' in delegate + assert '"runtime-install"' in delegate + assert '"runtime-uninstall"' in delegate + assert '"--payload-root", runtime.path' in delegate + assert '"--terminal-host", terminalHost.path' in delegate + assert '"--frame-helper", frameHelper.path' in delegate + assert "JSONDecoder().decode(CanonicalRuntimeInstall.self" 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 @@ -3132,6 +3127,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 +3154,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,8 +3176,8 @@ 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 diff --git a/tests/tui/test_uv_bootstrap.py b/tests/tui/test_uv_bootstrap.py index b9e65986..46f72b26 100644 --- a/tests/tui/test_uv_bootstrap.py +++ b/tests/tui/test_uv_bootstrap.py @@ -53,6 +53,7 @@ "install.ps1", "install.toml", "scripts/distribution_manifest.py", + "scripts/installer_brand.py", "scripts/vetcoders_install.py", "scripts/vibecrafted", "scripts/verify-vibecrafted-product.sh", diff --git a/vibecrafted-app/shell-agent/app/Vibecrafted/AppDelegate.swift b/vibecrafted-app/shell-agent/app/Vibecrafted/AppDelegate.swift index cef55ad1..b83cc099 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,20 @@ 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" + } } final class EventObserver: @unchecked Sendable, EventCallback { @@ -51,6 +65,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() @@ -251,254 +274,81 @@ 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( + let runtime = 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 { - 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") 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 runRuntimeInstaller(arguments: [ + "runtime-install", + "--payload-root", runtime.path, + "--app-root", appRoot.path, + "--terminal-host", terminalHost.path, + "--frame-helper", frameHelper.path, + ]) + 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 runRuntimeInstaller(arguments: ["runtime-uninstall"]) } - private func assertNoSymlinks(below root: URL) throws { - let keys: [URLResourceKey] = [.isSymbolicLinkKey] - if try root.resourceValues(forKeys: Set(keys)).isSymbolicLink == true { + private func runRuntimeInstaller(arguments: [String]) throws -> Data { + let runtime = Bundle.main.bundleURL.appendingPathComponent( + "Contents/Resources/runtime", isDirectory: true) + let python = runtime.appendingPathComponent("bin/python3") + let installer = runtime.appendingPathComponent("scripts/vetcoders_install.py") + for required in [python, installer] + where !FileManager.default.isExecutableFile(atPath: required.path) + { 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 installer entry is missing: \(required.path)" ]) } - 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 = python + process.arguments = [installer.path] + arguments + var environment = ProcessInfo.processInfo.environment + environment["PYTHONNOUSERSITE"] = "1" + environment["PYTHONDONTWRITEBYTECODE"] = "1" + 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)" @@ -518,44 +368,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() { diff --git a/vibecrafted-core/tests/test_runtime_receipt.py b/vibecrafted-core/tests/test_runtime_receipt.py index b7fad0a0..be24e5f8 100644 --- a/vibecrafted-core/tests/test_runtime_receipt.py +++ b/vibecrafted-core/tests/test_runtime_receipt.py @@ -21,7 +21,10 @@ } _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", @@ -279,7 +282,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"]) == 12 assert ( pc.verify_installed_runtime_generation(generation, expected_entrypoint=deck) == manifest diff --git a/vibecrafted-core/vibecrafted_core/deck/vibecrafted b/vibecrafted-core/vibecrafted_core/deck/vibecrafted index f1ab0cc2..3fe57d0c 100755 --- a/vibecrafted-core/vibecrafted_core/deck/vibecrafted +++ b/vibecrafted-core/vibecrafted_core/deck/vibecrafted @@ -5068,10 +5068,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 +5081,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/product_contract.py b/vibecrafted-core/vibecrafted_core/product_contract.py index 1b79d9f5..b93d20e1 100644 --- a/vibecrafted-core/vibecrafted_core/product_contract.py +++ b/vibecrafted-core/vibecrafted_core/product_contract.py @@ -107,7 +107,10 @@ 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", From cf536a48eaba1d870606e4c9ea08d7f2e7252889 Mon Sep 17 00:00:00 2001 From: div0-space Date: Mon, 24 Aug 2026 21:40:14 +0200 Subject: [PATCH 06/46] [codex/interactive] fix(uninstall): quiesce owned runtime processes Blacky runtime verification proved the previous teardown could delete the generation while App, terminal, shell, and vc-frame processes kept running on unlinked files. Census canonical product executables and runtime shell scripts by stable Darwin birth identity and argv, exclude the installer ancestry, and re-prove each PID before signaling. Treat an unlocked persistent supervisor.lock as historical state rather than a live service, while retaining fail-closed behavior for held, foreign, unreadable, or symlinked locks. macOS 27 remote-login EPERM uses a strict absolute-ps fallback only for the conservative ancestor exclusion set. Authored-By: codex session_id: 01a03396-782e-7703-93b5-84e7659ddb28 time: 2026-08-24T21:40:14+02:00 runtime: codex --- scripts/vetcoders_install.py | 204 ++++++++++++++++++++++++-- tests/tui/test_installer_uninstall.py | 140 ++++++++++++++++++ 2 files changed, 331 insertions(+), 13 deletions(-) diff --git a/scripts/vetcoders_install.py b/scripts/vetcoders_install.py index 68a78cf6..70e3d3c3 100755 --- a/scripts/vetcoders_install.py +++ b/scripts/vetcoders_install.py @@ -3275,7 +3275,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] @@ -3656,14 +3656,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", @@ -3671,6 +3696,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) @@ -4267,8 +4294,81 @@ 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() -> 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", + ] + 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() -> 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() + 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]) @@ -4277,10 +4377,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: @@ -4313,7 +4416,27 @@ 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( @@ -4387,6 +4510,13 @@ def _teardown_owned_runtime_for_uninstall( raise OSError( "retired vc-frame.real processes remain after teardown" ) + owned = _owned_runtime_process_census() + 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(): + 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. @@ -5327,6 +5457,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. @@ -13870,6 +14049,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)) @@ -13899,9 +14079,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: diff --git a/tests/tui/test_installer_uninstall.py b/tests/tui/test_installer_uninstall.py index 0ffa8560..30ee7e33 100644 --- a/tests/tui/test_installer_uninstall.py +++ b/tests/tui/test_installer_uninstall.py @@ -405,6 +405,70 @@ 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") + births = {pid: (f"darwin:{pid}:1", os.geteuid(), 8) for pid in range(101, 107)} + 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"),), + } + + 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, + ) + + +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: @@ -555,6 +619,50 @@ 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: 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: @@ -587,6 +695,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: From 8ebe4be42f3ca0382e90ada3893dc25b86bddf06 Mon Sep 17 00:00:00 2001 From: div0-space Date: Mon, 24 Aug 2026 22:15:30 +0200 Subject: [PATCH 07/46] [codex/interactive] fix(app): launch the installed runtime generation Decode installer paths as filesystem URLs so AppKit can spawn the bundled terminal. Resolve server binaries, site assets, and launchd environment from the active runtime generation. Authored-By: codex session_id: 01a03396-78e0-74e0-94c2-9adc16febc5c time: 2026-08-24T22:15:54+02:00 runtime: interactive --- scripts/vibecrafted | 44 +++++++++++++---- tests/tui/test_unified_app_contract.py | 23 +++++++++ .../app/Vibecrafted/AppDelegate.swift | 27 +++++++++++ .../tests/test_server_supervisor.py | 8 ++++ .../vibecrafted_core/deck/vibecrafted | 44 +++++++++++++---- .../vibecrafted_core/server_supervisor.py | 48 ++++++++++++------- 6 files changed, 158 insertions(+), 36 deletions(-) diff --git a/scripts/vibecrafted b/scripts/vibecrafted index 3fe57d0c..f04b7a00 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)" @@ -3510,6 +3525,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 +3543,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 +3572,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 +3608,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 +3909,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 +3943,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 diff --git a/tests/tui/test_unified_app_contract.py b/tests/tui/test_unified_app_contract.py index f6f7a42e..a109211d 100644 --- a/tests/tui/test_unified_app_contract.py +++ b/tests/tui/test_unified_app_contract.py @@ -797,6 +797,12 @@ def test_native_app_bootstraps_and_launches_only_the_canonical_product_entry() - 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 @@ -3184,6 +3190,23 @@ def test_terminal_policy_uses_operator_toml_and_primary_shell_chain() -> None: 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/vibecrafted-app/shell-agent/app/Vibecrafted/AppDelegate.swift b/vibecrafted-app/shell-agent/app/Vibecrafted/AppDelegate.swift index b83cc099..aa850c26 100644 --- a/vibecrafted-app/shell-agent/app/Vibecrafted/AppDelegate.swift +++ b/vibecrafted-app/shell-agent/app/Vibecrafted/AppDelegate.swift @@ -31,6 +31,32 @@ private struct CanonicalRuntimeInstall: Decodable { 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) + } } final class EventObserver: @unchecked Sendable, EventCallback { @@ -358,6 +384,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() diff --git a/vibecrafted-core/tests/test_server_supervisor.py b/vibecrafted-core/tests/test_server_supervisor.py index cc734fca..700e6830 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( diff --git a/vibecrafted-core/vibecrafted_core/deck/vibecrafted b/vibecrafted-core/vibecrafted_core/deck/vibecrafted index 3fe57d0c..f04b7a00 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)" @@ -3510,6 +3525,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 +3543,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 +3572,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 +3608,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 +3909,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 +3943,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 diff --git a/vibecrafted-core/vibecrafted_core/server_supervisor.py b/vibecrafted-core/vibecrafted_core/server_supervisor.py index 57f9dafb..c6ace71b 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) From 3fc7046b640c8de5d8043ef5733e75a5582917d5 Mon Sep 17 00:00:00 2001 From: div0-space Date: Mon, 24 Aug 2026 22:22:18 +0200 Subject: [PATCH 08/46] chore(app): refresh generated Xcode project --- .../shell-agent/app/Vibecrafted.xcodeproj/project.pbxproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vibecrafted-app/shell-agent/app/Vibecrafted.xcodeproj/project.pbxproj b/vibecrafted-app/shell-agent/app/Vibecrafted.xcodeproj/project.pbxproj index 569d2fae..e792b97a 100644 --- a/vibecrafted-app/shell-agent/app/Vibecrafted.xcodeproj/project.pbxproj +++ b/vibecrafted-app/shell-agent/app/Vibecrafted.xcodeproj/project.pbxproj @@ -165,6 +165,7 @@ }; }; buildConfigurationList = 562EBA724EB39ABF14AC75F6 /* Build configuration list for PBXProject "Vibecrafted" */; + compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -174,7 +175,6 @@ mainGroup = 3907FEAC328882CF9D256BA5; minimizedProjectReferenceProxies = 1; preferredProjectObjectVersion = 77; - productRefGroup = 7C875D8B1DE1322A5D94E60B /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( From cc6be1f8c5736ada435e20fa6b0da6786e46d2a7 Mon Sep 17 00:00:00 2001 From: div0-space Date: Mon, 24 Aug 2026 23:37:36 +0200 Subject: [PATCH 09/46] [codex/interactive] fix(runtime): unify reversible onboarding authority Projects one active release into the CLI, App, and agent discovery views while preserving operator collisions through a checkpointed receipt. Adds deterministic interrupted-install reset and refuses drift, injected receipt paths, or symlinked projection ancestors. Exposes live server health, reconcile, diagnostics, About, and Help in the native tray. Authored-By: codex session_id: 01a03595-0d19-7943-af2a-0a5eff007ac3 time: 2026-08-24T23:37:36+02:00 runtime: vc-terminal --- docs/installer/REQUIRED-SET.md | 101 +++--- scripts/vetcoders_install.py | 330 +++++++++++++++++- tests/tui/test_installer_uninstall.py | 241 +++++++++++++ tests/tui/test_unified_app_contract.py | 11 + .../app/Vibecrafted/AppDelegate.swift | 268 +++++++++++++- 5 files changed, 885 insertions(+), 66 deletions(-) diff --git a/docs/installer/REQUIRED-SET.md b/docs/installer/REQUIRED-SET.md index 1297ca15..69ff9e19 100644 --- a/docs/installer/REQUIRED-SET.md +++ b/docs/installer/REQUIRED-SET.md @@ -24,53 +24,55 @@ it and does not write the installation itself. Regression coverage: 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` | -| 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; missing Loctree/AICX is fail-closed, missing PRView/ScreenScribe is warned | -| 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: **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 | -| 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 | +| 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 @@ -99,10 +101,13 @@ removed; unrelated operator state remains. ## 4. Invariants **Receipt installs refuse drift before remove.** Runtime Pack uninstall hashes -every owned regular file before teardown. A locally modified launcher/config is -a conflict and stops the operation before the service or generation is removed. -Pre-install collisions are copied under `/.installer-backups/` -and restored during a successful reset. +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 diff --git a/scripts/vetcoders_install.py b/scripts/vetcoders_install.py index 70e3d3c3..9f94777c 100755 --- a/scripts/vetcoders_install.py +++ b/scripts/vetcoders_install.py @@ -14427,6 +14427,13 @@ def _load_runtime_install_receipt(path: Path) -> dict[str, Any]: 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: @@ -14440,6 +14447,9 @@ def _backup_runtime_collision( 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: @@ -14456,10 +14466,179 @@ def _write_runtime_owned_file( previous: dict[str, Any], ) -> None: previous_owned = previous.get("owned_files", {}) - if _path_present(path) and str(path) not in previous_owned: - _backup_runtime_collision(path, runtime_home=runtime_home, receipt=receipt) + 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 _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( @@ -14524,7 +14703,9 @@ def cmd_runtime_install(args: argparse.Namespace) -> int: "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", {})), } @@ -14538,6 +14719,13 @@ def cmd_runtime_install(args: argparse.Namespace) -> int: paths["launcher_home"], ): directory.mkdir(parents=True, exist_ok=True) + _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)) @@ -14560,8 +14748,6 @@ def cmd_runtime_install(args: argparse.Namespace) -> int: if staging.exists(): shutil.rmtree(staging) _assert_runtime_tree_has_no_symlinks(generation) - if str(generation) not in receipt["owned_dirs"]: - receipt["owned_dirs"].append(str(generation)) terminal_host = ( Path(args.terminal_host).expanduser().resolve() @@ -14577,16 +14763,38 @@ def cmd_runtime_install(args: argparse.Namespace) -> int: 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) + ) ] - missing = [str(path) for path in required if not 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" @@ -14613,6 +14821,7 @@ def cmd_runtime_install(args: argparse.Namespace) -> int: frame_config, ) 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", []): @@ -14626,6 +14835,7 @@ def cmd_runtime_install(args: argparse.Namespace) -> int: ) 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" @@ -14697,6 +14907,14 @@ def cmd_runtime_install(args: argparse.Namespace) -> int: 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, @@ -14719,6 +14937,9 @@ def cmd_runtime_install(args: argparse.Namespace) -> int: 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 @@ -14738,7 +14959,9 @@ def _receipt_path_is_allowed(path: Path, roots: Mapping[str, Path]) -> bool: Path(f"/tmp/vc-frame-{os.getuid()}"), ] ) - resolved = path.resolve(strict=False) + # 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)) @@ -14746,6 +14969,38 @@ def _receipt_path_is_allowed(path: Path, roots: Mapping[str, Path]) -> bool: ) +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 cmd_runtime_uninstall(args: argparse.Namespace) -> int: """Undo one Runtime Pack install from its ownership receipt.""" paths = _runtime_install_paths() @@ -14774,17 +15029,43 @@ def cmd_runtime_uninstall(args: argparse.Namespace) -> int: 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: - if not _receipt_path_is_allowed(Path(raw_path), paths): + 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_projection_dir_is_allowed(Path(raw_path)): + 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): + 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}" ) @@ -14801,6 +15082,15 @@ def cmd_runtime_uninstall(args: argparse.Namespace) -> int: 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", @@ -14814,9 +15104,20 @@ def cmd_runtime_uninstall(args: argparse.Namespace) -> int: if not dry_run: _teardown_owned_runtime_for_uninstall(paths["crafted_home"], dry_run=False) + 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): + 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 @@ -14854,6 +15155,17 @@ def cmd_runtime_uninstall(args: argparse.Namespace) -> int: destination.parent.mkdir(parents=True, exist_ok=True) _restore_path_from_backup(backup, destination) + for raw_path in sorted( + receipt.get("owned_empty_dirs", []), + key=lambda value: len(Path(value).parts), + reverse=True, + ): + path = Path(raw_path) + if path.is_dir() and not path.is_symlink() and not any(path.iterdir()): + actions.append(f"remove empty {path}") + if not dry_run: + path.rmdir() + roots_created = receipt.get("roots_created", {}) for name in ("product_config", "crafted_home", "runtime_home", "launcher_home"): root = paths[name] diff --git a/tests/tui/test_installer_uninstall.py b/tests/tui/test_installer_uninstall.py index 30ee7e33..3c79a288 100644 --- a/tests/tui/test_installer_uninstall.py +++ b/tests/tui/test_installer_uninstall.py @@ -49,6 +49,14 @@ def _runtime_pack_fixture(root: Path) -> tuple[Path, Path, Path]: 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" @@ -78,6 +86,11 @@ def test_runtime_pack_installer_and_uninstaller_round_trip_from_one_tool( launcher_home.mkdir(parents=True) original_launcher = launcher_home / "vc-start" original_launcher.write_text("operator-owned\n", encoding="utf-8") + 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), @@ -93,8 +106,30 @@ def test_runtime_pack_installer_and_uninstaller_round_trip_from_one_tool( assert (generation / "bin/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. @@ -108,6 +143,11 @@ def test_runtime_pack_installer_and_uninstaller_round_trip_from_one_tool( removed = json.loads(capsys.readouterr().out) assert removed["status"] == "removed" assert original_launcher.read_text(encoding="utf-8") == "operator-owned\n" + 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 / "vibecrafted").exists() @@ -151,6 +191,207 @@ def test_runtime_pack_uninstall_preserves_locally_modified_managed_launcher( 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) + 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: diff --git a/tests/tui/test_unified_app_contract.py b/tests/tui/test_unified_app_contract.py index a109211d..85318089 100644 --- a/tests/tui/test_unified_app_contract.py +++ b/tests/tui/test_unified_app_contract.py @@ -777,7 +777,18 @@ def test_native_app_bootstraps_and_launches_only_the_canonical_product_entry() - assert "\tLSUIElement\n\t" in info assert 'withTitle: "Open Console"' in delegate assert 'withTitle: "Open vc-terminal"' in delegate + assert 'withTitle: "Restart Server"' in delegate + assert 'withTitle: "Server Diagnostics…"' in delegate + assert 'withTitle: "About Vibecrafted"' in delegate + assert 'withTitle: "Help"' in delegate assert 'withTitle: "Quit"' in delegate + assert 'appendingPathComponent("server/supervisor.status.json")' in delegate + assert 'title: "Server: RESTARTING…"' in delegate + assert 'process.arguments = ["server", "service", "reconcile"]' 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: [])" diff --git a/vibecrafted-app/shell-agent/app/Vibecrafted/AppDelegate.swift b/vibecrafted-app/shell-agent/app/Vibecrafted/AppDelegate.swift index aa850c26..af9d1605 100644 --- a/vibecrafted-app/shell-agent/app/Vibecrafted/AppDelegate.swift +++ b/vibecrafted-app/shell-agent/app/Vibecrafted/AppDelegate.swift @@ -59,6 +59,48 @@ private struct CanonicalRuntimeInstall: Decodable { } } +private struct ServerSupervisorSnapshot: Decodable { + 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? + + enum CodingKeys: String, CodingKey { + case state + case lastError = "last_error" + case supervisorPID = "supervisor_pid" + case managedPair = "managed_pair" + } +} + +private enum TrayServerHealth { + case checking + case healthy + case degraded + case failed + case stopped + + var color: NSColor { + switch self { + case .checking: return .systemGray + case .healthy: return .systemGreen + case .degraded: return .systemOrange + case .failed: return .systemRed + case .stopped: return .systemGray + } + } +} + final class EventObserver: @unchecked Sendable, EventCallback { func onEvent(eventJson: String) { DispatchQueue.main.async { @@ -73,10 +115,18 @@ 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 restartServerMenuItem: NSMenuItem? + private var trayBaseIcon: NSImage? + private var statusRefreshTimer: Timer? private var terminalProcess: Process? + private var serverActionProcess: Process? + private var canonicalInstall: CanonicalRuntimeInstall? + private var canonicalRuntimeEnvironment: [String: String]? private var workspaceLaunchFailureReported = false private var eyeReconcileProcess: Process? let eventObserver = EventObserver() @@ -145,6 +195,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } func applicationWillTerminate(_ notification: Notification) { + statusRefreshTimer?.invalidate() NotificationManager.shared.clearHeartbeat(craftedHome: craftedHomeURL()) } @@ -175,12 +226,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( @@ -235,6 +287,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 @@ -283,7 +337,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. @@ -335,8 +390,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { let python = runtime.appendingPathComponent("bin/python3") let installer = runtime.appendingPathComponent("scripts/vetcoders_install.py") for required in [python, installer] - where !FileManager.default.isExecutableFile(atPath: required.path) - { + where !FileManager.default.isExecutableFile(atPath: required.path) { throw NSError( domain: "io.vetcoders.vibecrafted.install", code: 1, userInfo: [ @@ -362,7 +416,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { 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)? + let detail = + String(data: failure.isEmpty ? result : failure, encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "installer exited \(process.terminationStatus)" throw NSError( @@ -404,22 +459,143 @@ 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( + menu.delegate = self + let serverStatus = menu.addItem(withTitle: "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 Console", action: #selector(openConsoleFromStatusItem), keyEquivalent: "") - menu.addItem( + console.target = self + let terminal = menu.addItem( withTitle: "Open vc-terminal", action: #selector(openTerminalFromStatusItem), keyEquivalent: "") + terminal.target = self + let restart = menu.addItem( + withTitle: "Restart Server", action: #selector(restartServerFromStatusItem), + keyEquivalent: "") + restart.target = self + restartServerMenuItem = restart + let diagnostics = menu.addItem( + withTitle: "Server Diagnostics…", action: #selector(showServerDiagnostics), + keyEquivalent: "") + diagnostics.target = self + menu.addItem(.separator()) + menu.addItem( + withTitle: "About Vibecrafted", + action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "") + let help = menu.addItem( + withTitle: "Help", action: #selector(showStatusItemHelp), keyEquivalent: "") + help.target = self menu.addItem(.separator()) menu.addItem( withTitle: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") 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? { + guard let url = supervisorStatusURL(), let data = try? Data(contentsOf: url) else { + return nil + } + return try? JSONDecoder().decode(ServerSupervisorSnapshot.self, from: data) + } + + 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 canonicalInstall != nil else { + applyServerStatus( + title: "Server: WAITING FOR RUNTIME", detail: "Runtime onboarding has not completed", + health: .checking) + return + } + guard let snapshot = readSupervisorSnapshot() else { + applyServerStatus( + title: "Server: NOT INSTALLED", detail: "No supervisor status receipt", + health: .failed) + return + } + + let state = snapshot.state.lowercased() + let pairHealthy = + snapshot.managedPair?.serverPID != nil && snapshot.managedPair?.guardianPID != nil + let health: TrayServerHealth + if state == "healthy" && pairHealthy { + health = .healthy + } else if state == "starting" || state == "stopping" { + health = .degraded + } else if state == "backoff" || state == "stop-failed" { + health = .failed + } else { + health = .stopped + } + let reason = conciseServerReason(snapshot.lastError) + let detail = reason ?? "Supervisor PID \(snapshot.supervisorPID.map(String.init) ?? "—")" + applyServerStatus( + title: "Server: \(snapshot.state.uppercased())", detail: detail, health: health) + } + + private func applyServerStatus(title: String, detail: String, health: TrayServerHealth) { + serverStatusMenuItem?.title = title + serverDetailMenuItem?.title = detail + serverDetailMenuItem?.isHidden = detail.isEmpty + restartServerMenuItem?.isEnabled = serverActionProcess?.isRunning != true + statusItem?.button?.image = statusIcon(health: health) + statusItem?.button?.toolTip = "Vibecrafted — \(title)" } @objc private func openConsoleFromStatusItem() { @@ -439,6 +615,80 @@ class AppDelegate: NSObject, NSApplicationDelegate { launchWorkspaceTerminal() } + @objc private func restartServerFromStatusItem() { + guard serverActionProcess?.isRunning != true else { return } + guard let install = canonicalInstall, let environment = canonicalRuntimeEnvironment else { + reportWorkspaceLaunchFailure("Cannot restart the 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() + process.executableURL = deck + // Reconcile is the service-owner operation: it starts a stopped pair and + // replaces a stale supervisor generation without creating a second owner. + process.arguments = ["server", "service", "reconcile"] + process.environment = environment + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + do { + try process.run() + serverActionProcess = process + applyServerStatus( + title: "Server: RESTARTING…", detail: "Reconciling the installed supervisor", + health: .degraded) + } catch { + let alert = NSAlert() + alert.alertStyle = .critical + alert.messageText = "Vibecrafted could not restart the server" + 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 the local server: green is healthy, orange is transitioning, red needs attention. Open Console for live runs, or Server Diagnostics for the exact supervisor receipt." + alert.addButton(withTitle: "OK") + alert.runModal() + } + private func buildMainMenu() { let mainMenu = NSMenu() From a5227366346adca206cf528aa568c54dc8286c28 Mon Sep 17 00:00:00 2001 From: div0-space Date: Mon, 24 Aug 2026 23:56:02 +0200 Subject: [PATCH 10/46] [codex/interactive] fix(release): stop tracking XcodeGen output Makes project.yml the sole Xcode project authority, ignores the generated .xcodeproj, and makes the release fail closed if generated output is tracked again. This removes XcodeGen-version churn that dirtied the source tree after a successful build and blocked provenance signing. Authored-By: codex session_id: 01a03595-0d19-7943-af2a-0a5eff007ac3 time: 2026-08-24T23:56:02+02:00 runtime: vc-terminal --- .gitignore | 1 + scripts/build-vibecrafted-release.sh | 4 + tests/tui/test_release_contract.py | 13 + .../app/Vibecrafted.xcodeproj/project.pbxproj | 487 ------------------ .../contents.xcworkspacedata | 7 - 5 files changed, 18 insertions(+), 494 deletions(-) delete mode 100644 vibecrafted-app/shell-agent/app/Vibecrafted.xcodeproj/project.pbxproj delete mode 100644 vibecrafted-app/shell-agent/app/Vibecrafted.xcodeproj/project.xcworkspace/contents.xcworkspacedata 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/scripts/build-vibecrafted-release.sh b/scripts/build-vibecrafted-release.sh index 1fd0926f..a54c8d54 100755 --- a/scripts/build-vibecrafted-release.sh +++ b/scripts/build-vibecrafted-release.sh @@ -464,6 +464,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" diff --git a/tests/tui/test_release_contract.py b/tests/tui/test_release_contract.py index 61d533d9..c4d7accd 100644 --- a/tests/tui/test_release_contract.py +++ b/tests/tui/test_release_contract.py @@ -280,6 +280,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: 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 e792b97a..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" */; - compatibilityVersion = "Xcode 14.0"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - Base, - en, - ); - mainGroup = 3907FEAC328882CF9D256BA5; - minimizedProjectReferenceProxies = 1; - preferredProjectObjectVersion = 77; - 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 @@ - - - - - From 169bc7e5495934eb4685ed2fb1ad624d0c77a2a8 Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 25 Aug 2026 00:25:09 +0200 Subject: [PATCH 11/46] [codex/interactive] feat(runtime): embed complete agent foundations Stages pinned Loctree, AICX, PRView, and ScreenScribe payloads into the immutable Runtime Pack and rejects incomplete packs before launcher publication. Authored-By: codex session_id: 01a03595-0d19-7943-af2a-0a5eff007ac3 time: 2026-08-25T00:36:00+02:00 runtime: vc-terminal --- docs/installer/REQUIRED-SET.md | 9 ++- scripts/build-vibecrafted-release.sh | 16 +++- scripts/stage-runtime-foundations.sh | 108 ++++++++++++++++++++++++++ scripts/vetcoders_install.py | 6 ++ tests/tui/test_installer_uninstall.py | 31 ++++++++ tests/tui/test_release_contract.py | 20 +++++ 6 files changed, 185 insertions(+), 5 deletions(-) create mode 100755 scripts/stage-runtime-foundations.sh diff --git a/docs/installer/REQUIRED-SET.md b/docs/installer/REQUIRED-SET.md index 69ff9e19..b951832c 100644 --- a/docs/installer/REQUIRED-SET.md +++ b/docs/installer/REQUIRED-SET.md @@ -144,10 +144,11 @@ 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. -- Fetch/install adapters for the required third-party payloads. The required set - is now explicit, but bundling/installing Loctree, AICX, PRView and ScreenScribe - is the next payload cut after deterministic uninstall; a DMG lacking them is - still incomplete and must not be described as the full product. +- 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. - `$TMPDIR` test scratch. Owned by the test suite, not by the installer. _𝚅𝚒𝚋𝚎𝚌𝚛𝚊𝚏𝚝𝚎𝚍. with AI Agents by Vetcoders (c)2024-2026 LibraxisAI_ diff --git a/scripts/build-vibecrafted-release.sh b/scripts/build-vibecrafted-release.sh index a54c8d54..8d752ac5 100755 --- a/scripts/build-vibecrafted-release.sh +++ b/scripts/build-vibecrafted-release.sh @@ -559,6 +559,8 @@ build_product() { 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 @@ -578,7 +580,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" @@ -628,6 +630,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" diff --git a/scripts/stage-runtime-foundations.sh b/scripts/stage-runtime-foundations.sh new file mode 100755 index 00000000..2c2e53ba --- /dev/null +++ b/scripts/stage-runtime-foundations.sh @@ -0,0 +1,108 @@ +#!/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" +AICX_VERSION="0.12.5" +PRVIEW_VERSION="0.6.0" + +case "$(uname -s):$(uname -m)" in + Darwin:arm64) + LOCTREE_PACKAGE="@loctree/loctree-darwin-arm64" + AICX_ASSET="aicx-v${AICX_VERSION}-aarch64-apple-darwin-slim.zip" + AICX_ARCHIVE_TYPE="zip" + EXE_SUFFIX="" + ;; + Linux:x86_64) + LOCTREE_PACKAGE="@loctree/loctree-linux-x64-gnu" + AICX_ASSET="aicx-v${AICX_VERSION}-x86_64-linux-gnu-slim.tar.gz" + AICX_ARCHIVE_TYPE="tar.gz" + EXE_SUFFIX="" + ;; + MINGW*:x86_64|MSYS*:x86_64|CYGWIN*:x86_64) + LOCTREE_PACKAGE="@loctree/loctree-win32-x64-msvc" + AICX_ASSET="aicx-v${AICX_VERSION}-x86_64-pc-windows-msvc-slim.zip" + AICX_ARCHIVE_TYPE="zip" + EXE_SUFFIX=".exe" + ;; + *) die "no complete Runtime Foundations payload for $(uname -s)/$(uname -m)" ;; +esac + +for tool in curl npm cargo python3; do require "$tool"; done +[[ "$AICX_ARCHIVE_TYPE" != "zip" ]] || require unzip + +WORK="$(mktemp -d "${TMPDIR:-/tmp}/vibecrafted-foundations.XXXXXX")" +trap 'rm -rf "$WORK"' 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. +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 + +# AICX release assets publish a checksum beside every platform archive. The +# npm platform package uses the same assets; downloading them directly keeps +# the Runtime Pack independent of Node at customer install time. +AICX_BASE="https://github.com/Loctree/aicx/releases/download/v${AICX_VERSION}" +curl -fsSL "$AICX_BASE/$AICX_ASSET" -o "$WORK/aicx/$AICX_ASSET" +curl -fsSL "$AICX_BASE/$AICX_ASSET.sha256" -o "$WORK/aicx/$AICX_ASSET.sha256" +( + cd "$WORK/aicx" + shasum -a 256 -c "$AICX_ASSET.sha256" >/dev/null 2>&1 \ + || sha256sum -c "$AICX_ASSET.sha256" >/dev/null +) +if [[ "$AICX_ARCHIVE_TYPE" == "zip" ]]; then + unzip -q "$WORK/aicx/$AICX_ASSET" -d "$WORK/aicx/unpacked" +else + mkdir -p "$WORK/aicx/unpacked" + tar -xzf "$WORK/aicx/$AICX_ASSET" -C "$WORK/aicx/unpacked" +fi +for name in aicx aicx-mcp; do + source_path="$(find "$WORK/aicx/unpacked" -type f -name "${name}${EXE_SUFFIX}" -print -quit)" + [[ -n "$source_path" ]] || die "$AICX_ASSET contains no ${name}${EXE_SUFFIX}" + install -m 0755 "$source_path" "$OUTPUT_BIN_DIR/${name}${EXE_SUFFIX}" +done + +# 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. +cargo install --locked --version "$PRVIEW_VERSION" --root "$WORK/prview" prview +install -m 0755 "$WORK/prview/bin/prview${EXE_SUFFIX}" \ + "$OUTPUT_BIN_DIR/prview${EXE_SUFFIX}" + +"$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, + "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/vetcoders_install.py b/scripts/vetcoders_install.py index 9f94777c..cd92a5e1 100755 --- a/scripts/vetcoders_install.py +++ b/scripts/vetcoders_install.py @@ -14756,6 +14756,12 @@ def cmd_runtime_install(args: argparse.Namespace) -> int: ) 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 / "bin/vc-server", generation / "bin/vc-server-supervisor", diff --git a/tests/tui/test_installer_uninstall.py b/tests/tui/test_installer_uninstall.py index 3c79a288..e5a1c5fe 100644 --- a/tests/tui/test_installer_uninstall.py +++ b/tests/tui/test_installer_uninstall.py @@ -30,6 +30,12 @@ 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", @@ -154,6 +160,31 @@ def test_runtime_pack_installer_and_uninstaller_round_trip_from_one_tool( assert app_root.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: diff --git a/tests/tui/test_release_contract.py b/tests/tui/test_release_contract.py index c4d7accd..c1782409 100644 --- a/tests/tui/test_release_contract.py +++ b/tests/tui/test_release_contract.py @@ -166,6 +166,26 @@ def test_publication_boundary_step_still_asserts_both_channel_names() -> None: 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 + 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 "runtime-foundations.json" in stager + assert "cargo install --locked" 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( From 49d0e899e4548970298447ccfb15f5e6faa83a88 Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 25 Aug 2026 00:58:41 +0200 Subject: [PATCH 12/46] [codex/interactive] fix(release): rebuild path-clean AICX payload Pins AICX v0.12.5 to commit ced57997 and applies Rust plus native compiler path remaps because the published release binaries retain their CI builder home. Authored-By: codex session_id: 01a03595-0d19-7943-af2a-0a5eff007ac3 time: 2026-08-25T00:57:00+02:00 runtime: vc-terminal --- docs/installer/REQUIRED-SET.md | 4 +++ scripts/stage-runtime-foundations.sh | 50 +++++++++++++--------------- tests/tui/test_release_contract.py | 2 ++ 3 files changed, 29 insertions(+), 27 deletions(-) diff --git a/docs/installer/REQUIRED-SET.md b/docs/installer/REQUIRED-SET.md index b951832c..d6931cde 100644 --- a/docs/installer/REQUIRED-SET.md +++ b/docs/installer/REQUIRED-SET.md @@ -149,6 +149,10 @@ removed and not backed up. 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/scripts/stage-runtime-foundations.sh b/scripts/stage-runtime-foundations.sh index 2c2e53ba..4abc9ca3 100755 --- a/scripts/stage-runtime-foundations.sh +++ b/scripts/stage-runtime-foundations.sh @@ -8,32 +8,26 @@ require() { command -v "$1" >/dev/null 2>&1 || die "$1 is required"; } OUTPUT_BIN_DIR="$1" LOCTREE_VERSION="0.14.4" AICX_VERSION="0.12.5" +AICX_REVISION="ced57997dd97a2b08960f35e3a657d7b0c49a200" PRVIEW_VERSION="0.6.0" case "$(uname -s):$(uname -m)" in Darwin:arm64) LOCTREE_PACKAGE="@loctree/loctree-darwin-arm64" - AICX_ASSET="aicx-v${AICX_VERSION}-aarch64-apple-darwin-slim.zip" - AICX_ARCHIVE_TYPE="zip" EXE_SUFFIX="" ;; Linux:x86_64) LOCTREE_PACKAGE="@loctree/loctree-linux-x64-gnu" - AICX_ASSET="aicx-v${AICX_VERSION}-x86_64-linux-gnu-slim.tar.gz" - AICX_ARCHIVE_TYPE="tar.gz" EXE_SUFFIX="" ;; MINGW*:x86_64|MSYS*:x86_64|CYGWIN*:x86_64) LOCTREE_PACKAGE="@loctree/loctree-win32-x64-msvc" - AICX_ASSET="aicx-v${AICX_VERSION}-x86_64-pc-windows-msvc-slim.zip" - AICX_ARCHIVE_TYPE="zip" EXE_SUFFIX=".exe" ;; *) die "no complete Runtime Foundations payload for $(uname -s)/$(uname -m)" ;; esac -for tool in curl npm cargo python3; do require "$tool"; done -[[ "$AICX_ARCHIVE_TYPE" != "zip" ]] || require unzip +for tool in git npm cargo python3; do require "$tool"; done WORK="$(mktemp -d "${TMPDIR:-/tmp}/vibecrafted-foundations.XXXXXX")" trap 'rm -rf "$WORK"' EXIT @@ -50,26 +44,27 @@ for name in loct loctree loctree-mcp loctree-lsp; do "$OUTPUT_BIN_DIR/${name}${EXE_SUFFIX}" done -# AICX release assets publish a checksum beside every platform archive. The -# npm platform package uses the same assets; downloading them directly keeps -# the Runtime Pack independent of Node at customer install time. -AICX_BASE="https://github.com/Loctree/aicx/releases/download/v${AICX_VERSION}" -curl -fsSL "$AICX_BASE/$AICX_ASSET" -o "$WORK/aicx/$AICX_ASSET" -curl -fsSL "$AICX_BASE/$AICX_ASSET.sha256" -o "$WORK/aicx/$AICX_ASSET.sha256" -( - cd "$WORK/aicx" - shasum -a 256 -c "$AICX_ASSET.sha256" >/dev/null 2>&1 \ - || sha256sum -c "$AICX_ASSET.sha256" >/dev/null -) -if [[ "$AICX_ARCHIVE_TYPE" == "zip" ]]; then - unzip -q "$WORK/aicx/$AICX_ASSET" -d "$WORK/aicx/unpacked" -else - mkdir -p "$WORK/aicx/unpacked" - tar -xzf "$WORK/aicx/$AICX_ASSET" -C "$WORK/aicx/unpacked" -fi +# 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. +git clone --quiet --depth 1 --branch "v${AICX_VERSION}" \ + https://github.com/Loctree/aicx.git "$WORK/aicx/source" +[[ "$(git -C "$WORK/aicx/source" rev-parse HEAD)" == "$AICX_REVISION" ]] \ + || die "AICX v${AICX_VERSION} does not resolve to pinned $AICX_REVISION" +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="$(find "$WORK/aicx/unpacked" -type f -name "${name}${EXE_SUFFIX}" -print -quit)" - [[ -n "$source_path" ]] || die "$AICX_ASSET contains no ${name}${EXE_SUFFIX}" + 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 @@ -100,6 +95,7 @@ for path in sorted(root.iterdir()): payload = { "schema": "io.vetcoders.vibecrafted.runtime-foundations.v1", "versions": versions, + "source_revisions": {"aicx": "ced57997dd97a2b08960f35e3a657d7b0c49a200"}, "files": files, } (root.parent / "runtime-foundations.json").write_text( diff --git a/tests/tui/test_release_contract.py b/tests/tui/test_release_contract.py index c1782409..ef3d41a7 100644 --- a/tests/tui/test_release_contract.py +++ b/tests/tui/test_release_contract.py @@ -183,6 +183,8 @@ def test_native_carrier_embeds_every_required_agent_foundation() -> None: assert f'generation / "bin/{command}"' in installer assert 'generation / "bin/screenscribe"' in installer assert "runtime-foundations.json" in stager + assert "ced57997dd97a2b08960f35e3a657d7b0c49a200" in stager + assert "remap-path-prefix" in stager assert "cargo install --locked" in stager From 6a32b89a29df12857adf369e24e4b8abd2e5c631 Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 25 Aug 2026 01:24:19 +0200 Subject: [PATCH 13/46] [codex/interactive] fix(release): make PRView independent of Homebrew Build PRView against static OpenSSL on macOS and reject carrier binaries that retain non-system dynamic library dependencies. This keeps the mandatory PRView foundation runnable on clean customer machines. Authored-By: codex session_id: 01a03595-0d19-7943-af2a-0a5eff007ac3 time: 2026-08-25T01:27:00+02:00 runtime: vc-terminal --- scripts/stage-runtime-foundations.sh | 21 +++++++++++++++++++-- tests/tui/test_release_contract.py | 2 ++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/scripts/stage-runtime-foundations.sh b/scripts/stage-runtime-foundations.sh index 4abc9ca3..2cc51964 100755 --- a/scripts/stage-runtime-foundations.sh +++ b/scripts/stage-runtime-foundations.sh @@ -70,11 +70,28 @@ done # 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. -cargo install --locked --version "$PRVIEW_VERSION" --root "$WORK/prview" prview +# 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 diff --git a/tests/tui/test_release_contract.py b/tests/tui/test_release_contract.py index ef3d41a7..9bed1a05 100644 --- a/tests/tui/test_release_contract.py +++ b/tests/tui/test_release_contract.py @@ -183,6 +183,8 @@ def test_native_carrier_embeds_every_required_agent_foundation() -> None: assert f'generation / "bin/{command}"' in installer assert 'generation / "bin/screenscribe"' 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 From 50c36205a19d58133450b0b29ffb73a7a3537cf2 Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 25 Aug 2026 02:43:17 +0200 Subject: [PATCH 14/46] [codex/interactive] fix(runtime): unify pack authority and doctor Publishes one manifest-bound Runtime Pack, canonicalizes vc-frame configuration, and makes doctor validate the active generation rather than legacy path projections. Authored-By: codex session_id: 01a03595-0d19-7943-af2a-0a5eff007ac3 time: 2026-08-25T02:43:10+02:00 runtime: vc-terminal --- scripts/build-vibecrafted-release.sh | 17 ++ scripts/distribution_manifest.py | 67 ++++- scripts/install-foundations.sh | 9 +- scripts/vc-frame-product-entry.sh | 16 +- scripts/vetcoders_install.py | 265 ++++++++++++++---- tests/tui/test_distribution_manifest.py | 37 +++ tests/tui/test_installer_doctor.py | 39 ++- tests/tui/test_installer_uninstall.py | 136 ++++++++- tests/tui/test_release_contract.py | 13 + tests/tui/test_staged_tools_sync.py | 6 +- tests/tui/test_unified_app_contract.py | 4 +- vibecrafted-core/tests/test_doctor.py | 35 ++- .../tests/test_vc_frame_delivery.py | 59 ++-- vibecrafted-core/vibecrafted_core/cli.py | 4 +- .../config/vc-frame/config.kdl | 9 +- .../config/vc-frame/copy-scrollback.sh | 3 +- .../config/vc-frame/scrollback-select.sh | 3 +- .../config/vc-frame/vc-composer.sh | 7 +- vibecrafted-core/vibecrafted_core/doctor.py | 52 +++- .../vibecrafted_core/product_contract.py | 4 +- .../vibecrafted_core/vc_frame_delivery.py | 51 ++-- 21 files changed, 659 insertions(+), 177 deletions(-) diff --git a/scripts/build-vibecrafted-release.sh b/scripts/build-vibecrafted-release.sh index 8d752ac5..e0cc0bb0 100755 --- a/scripts/build-vibecrafted-release.sh +++ b/scripts/build-vibecrafted-release.sh @@ -442,7 +442,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" @@ -553,6 +562,14 @@ build_product() { "$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/" diff --git a/scripts/distribution_manifest.py b/scripts/distribution_manifest.py index 8677767e..4eddef9e 100755 --- a/scripts/distribution_manifest.py +++ b/scripts/distribution_manifest.py @@ -864,6 +864,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 +2233,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 +2251,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 +2295,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..6feb9af9 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 diff --git a/scripts/vc-frame-product-entry.sh b/scripts/vc-frame-product-entry.sh index 0d794d04..19b13730 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. @@ -47,7 +47,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 +58,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/vetcoders_install.py b/scripts/vetcoders_install.py index cd92a5e1..0601b582 100755 --- a/scripts/vetcoders_install.py +++ b/scripts/vetcoders_install.py @@ -38,6 +38,7 @@ import re import runpy import select +import shlex import shutil import signal import stat @@ -1458,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.""" @@ -2607,6 +2626,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 = [ @@ -2984,9 +3029,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" @@ -4307,13 +4350,15 @@ def _darwin_caller_ancestor_pids() -> frozenset[int]: return frozenset(ancestors) -def _owned_runtime_process_roots() -> tuple[Path, ...]: +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) @@ -4338,7 +4383,9 @@ def managed_path(raw: str) -> bool: return any(managed_path(argument) for argument in argv[1:]) -def _owned_runtime_process_census() -> tuple[_RetiredVcFrameProcess, ...]: +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 () @@ -4346,7 +4393,7 @@ def _owned_runtime_process_census() -> tuple[_RetiredVcFrameProcess, ...]: if not process_ids: return () excluded = _darwin_caller_ancestor_pids() - roots = _owned_runtime_process_roots() + roots = _owned_runtime_process_roots(app_root=app_root) records: list[_RetiredVcFrameProcess] = [] for pid in process_ids: if pid in excluded: @@ -4440,7 +4487,7 @@ def _terminate_owned_runtime_processes( 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": @@ -4510,12 +4557,12 @@ def _teardown_owned_runtime_for_uninstall( raise OSError( "retired vc-frame.real processes remain after teardown" ) - owned = _owned_runtime_process_census() + 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(): + 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 @@ -8182,7 +8229,11 @@ 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}" @@ -8572,8 +8623,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) @@ -8585,7 +8637,7 @@ def _run_runtime_verifier_semantic_command( ) return subprocess.run( [ - sys.executable, + str(python_executable), "-I", "-S", "-B", @@ -8624,6 +8676,19 @@ def _assert_runtime_verifier_semantic_failure( def _validate_runtime_verifier_semantics(runtime_root: Path) -> None: """Validate captured candidate code/schema and exercise its real public entrypoints.""" + runtime_python = runtime_root / "bin/python3" + 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}") + + 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: @@ -8666,7 +8731,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, ) @@ -8684,9 +8749,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 @@ -8697,9 +8760,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 @@ -8717,7 +8778,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", @@ -8758,7 +8819,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", @@ -8780,7 +8841,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", @@ -8793,7 +8854,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, ), @@ -8833,9 +8894,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", ) @@ -8889,9 +8948,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", ) @@ -9827,7 +9884,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" @@ -10141,10 +10198,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" ] @@ -10160,14 +10220,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 @@ -10900,10 +10968,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 [ @@ -11063,9 +11140,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() @@ -11672,6 +11750,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) @@ -14404,7 +14485,7 @@ def _runtime_launcher_body( 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 / 'bin/vc-frame'))}", + 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"', @@ -14740,8 +14821,32 @@ def cmd_runtime_install(args: argparse.Namespace) -> int: ) (bin_dir / "vc-terminal").chmod(0o755) if args.frame_helper: - shutil.copy2(Path(args.frame_helper).expanduser(), bin_dir / "vc-frame") + 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) + 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: @@ -14763,6 +14868,7 @@ def cmd_runtime_install(args: argparse.Namespace) -> int: 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", @@ -14821,13 +14927,19 @@ def cmd_runtime_install(args: argparse.Namespace) -> int: ) frame_config = product_config / "vc-frame" - if not frame_config.exists(): - shutil.copytree( - generation / "vibecrafted-core/vibecrafted_core/config/vc-frame", - frame_config, - ) + 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) + _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", []): @@ -14846,7 +14958,10 @@ def cmd_runtime_install(args: argparse.Namespace) -> int: bin_dir = generation / "bin" for entry in sorted(bin_dir.iterdir(), key=lambda item: item.name): - if entry.name in {"python3", "vc-terminal"} or not entry.is_file(): + 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 @@ -14868,6 +14983,21 @@ def cmd_runtime_install(args: argparse.Namespace) -> int: 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, @@ -15007,6 +15137,21 @@ def _receipt_empty_projection_dir_is_allowed(path: Path) -> bool: return path.resolve(strict=False) 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() @@ -15075,11 +15220,7 @@ def cmd_runtime_uninstall(args: argparse.Namespace) -> int: raise RuntimeError( f"receipt restore path escapes managed roots: {destination}" ) - resolved_backup = backup.resolve(strict=False) - resolved_backup_root = backup_root.resolve(strict=False) - if resolved_backup != resolved_backup_root and not _is_subpath( - resolved_backup, resolved_backup_root - ): + if not _receipt_backup_path_is_allowed(backup, backup_root): raise RuntimeError(f"receipt backup path escapes backup root: {backup}") conflicts = [ raw_path @@ -15107,8 +15248,12 @@ def cmd_runtime_uninstall(args: argparse.Namespace) -> int: if getattr(args, "emit_result", True): print(json.dumps(result, sort_keys=True)) return 1 - if not dry_run: - _teardown_owned_runtime_for_uninstall(paths["crafted_home"], dry_run=False) + 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) diff --git a/tests/tui/test_distribution_manifest.py b/tests/tui/test_distribution_manifest.py index 3aa6a716..7569d694 100644 --- a/tests/tui/test_distribution_manifest.py +++ b/tests/tui/test_distribution_manifest.py @@ -185,6 +185,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", [ diff --git a/tests/tui/test_installer_doctor.py b/tests/tui/test_installer_doctor.py index 88148228..ffcaca01 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 @@ -902,6 +907,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) @@ -914,6 +926,29 @@ 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_installer_release_contract_assets_fail_closed_for_missing_or_exact_byte_drift( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -1966,7 +2001,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() @@ -2002,7 +2037,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 e5a1c5fe..de4547a7 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) @@ -52,6 +93,11 @@ def _runtime_pack_fixture(root: Path) -> tuple[Path, Path, Path]: 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") @@ -84,14 +130,26 @@ def test_runtime_pack_installer_and_uninstaller_round_trip_from_one_tool( monkeypatch.setenv("VIBECRAFTED_LAUNCHER_BIN", str(launcher_home)) monkeypatch.setenv("VIBECRAFTED_HOME", str(crafted_home)) monkeypatch.setenv("XDG_CONFIG_HOME", str(config_home)) - monkeypatch.setattr( - installer, "_teardown_owned_runtime_for_uninstall", lambda *_args, **_kwargs: [] - ) + 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") @@ -109,7 +167,10 @@ def test_runtime_pack_installer_and_uninstaller_round_trip_from_one_tool( installed = json.loads(capsys.readouterr().out) generation = runtime_home / "releases/9.9.9+g12345678" assert Path(installed["root"]) == generation - assert (generation / "bin/vc-frame").read_bytes() == frame_helper.read_bytes() + 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" @@ -143,12 +204,26 @@ def test_runtime_pack_installer_and_uninstaller_round_trip_from_one_tool( 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() @@ -274,6 +349,9 @@ def test_runtime_pack_install_refuses_symlinked_agent_projection_ancestor( 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]), @@ -470,6 +548,44 @@ def mark_teardown(*_args, **_kwargs) -> list[str]: 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]: @@ -683,7 +799,8 @@ def test_owned_runtime_census_matches_product_processes_without_killing_editors( home = tmp_path / "home" runtime_releases = home / "runtime/releases" app_executable = Path("/Applications/Vibecrafted.app/Contents/MacOS/Vibecrafted") - births = {pid: (f"darwin:{pid}:1", os.geteuid(), 8) for pid in range(101, 107)} + 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),), @@ -695,6 +812,7 @@ def test_owned_runtime_census_matches_product_processes_without_killing_editors( ), 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)) @@ -718,6 +836,10 @@ def test_owned_runtime_census_matches_product_processes_without_killing_editors( 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( @@ -919,7 +1041,9 @@ def test_runtime_teardown_terminates_owned_runtime_processes_and_proves_zero( 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: next(censuses) + installer, + "_owned_runtime_process_census", + lambda *, app_root=None: next(censuses), ) monkeypatch.setattr( installer, diff --git a/tests/tui/test_release_contract.py b/tests/tui/test_release_contract.py index 9bed1a05..1ded2952 100644 --- a/tests/tui/test_release_contract.py +++ b/tests/tui/test_release_contract.py @@ -178,10 +178,16 @@ def test_native_carrier_embeds_every_required_agent_foundation() -> None: 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 @@ -557,6 +563,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_staged_tools_sync.py b/tests/tui/test_staged_tools_sync.py index 2c69c63f..00e9fa5b 100644 --- a/tests/tui/test_staged_tools_sync.py +++ b/tests/tui/test_staged_tools_sync.py @@ -83,6 +83,10 @@ def _write_complete_source( 1, ) _write_executable(root / "scripts" / "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) @@ -6488,7 +6492,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 85318089..fb394181 100644 --- a/tests/tui/test_unified_app_contract.py +++ b/tests/tui/test_unified_app_contract.py @@ -437,7 +437,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( @@ -1814,7 +1814,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) 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_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/vibecrafted_core/cli.py b/vibecrafted-core/vibecrafted_core/cli.py index 282020a0..c7856d6f 100644 --- a/vibecrafted-core/vibecrafted_core/cli.py +++ b/vibecrafted-core/vibecrafted_core/cli.py @@ -250,12 +250,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", 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/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-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/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/product_contract.py b/vibecrafted-core/vibecrafted_core/product_contract.py index b93d20e1..d3cdac39 100644 --- a/vibecrafted-core/vibecrafted_core/product_contract.py +++ b/vibecrafted-core/vibecrafted_core/product_contract.py @@ -99,7 +99,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" @@ -4006,7 +4006,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/vc_frame_delivery.py b/vibecrafted-core/vibecrafted_core/vc_frame_delivery.py index 6999f7a8..60d9ff85 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", @@ -114,14 +112,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 +312,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 +390,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 +413,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: From 302cec531dee1a1be954908fc986ee5ba75d06ce Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 25 Aug 2026 04:58:05 +0200 Subject: [PATCH 15/46] [codex/interactive] feat(workspaces): add interactive Agent dashboard Ship a dedicated Agents workspace tab with New agent and voc doors. Keep interactive init/resume inside the current vc-frame tab and project live Agent faces from vc-frame pane truth. Authored-By: codex session_id: 01a03595-0d19-7943-af2a-0a5eff007ac3 time: 2026-08-25T04:57:47+02:00 runtime: vc-terminal --- docs/VC-FRAME.md | 18 +- tests/tui/test_vc_frame_config.py | 10 +- vibecrafted-core/tests/test_agent_workshop.py | 93 ++++ .../tests/test_vc_frame_tab_gc.py | 5 + .../config/vc-frame/layouts/operator.kdl | 18 +- .../config/vc-frame/vc-agent-workshop.py | 426 ++++++++++++++++++ .../vibecrafted_core/vc_frame_delivery.py | 1 + .../vibecrafted_core/vc_frame_staging.py | 3 +- .../vibecrafted_core/vc_frame_tab_gc.py | 2 +- 9 files changed, 565 insertions(+), 11 deletions(-) create mode 100644 vibecrafted-core/tests/test_agent_workshop.py create mode 100755 vibecrafted-core/vibecrafted_core/config/vc-frame/vc-agent-workshop.py 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/tests/tui/test_vc_frame_config.py b/tests/tui/test_vc_frame_config.py index 066a16bf..0b8a1167 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 'tab name="voc"' in payload assert 'guide_mode "mission-control"' 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 diff --git a/vibecrafted-core/tests/test_agent_workshop.py b/vibecrafted-core/tests/test_agent_workshop.py new file mode 100644 index 00000000..3ef2ca9c --- /dev/null +++ b/vibecrafted-core/tests/test_agent_workshop.py @@ -0,0 +1,93 @@ +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", + ] + assert workshop.launch_argv("claude", "resume") == [ + "vibecrafted", + "resume", + "claude", + ] + with pytest.raises(ValueError, match="interactive ritual"): + workshop.launch_argv("codex", "operator") + + +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_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/vibecrafted_core/config/vc-frame/layouts/operator.kdl b/vibecrafted-core/vibecrafted_core/config/vc-frame/layouts/operator.kdl index afc863b3..5de88b32 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). // @@ -43,10 +45,22 @@ layout { } } + 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/vc-agent-workshop.py b/vibecrafted-core/vibecrafted_core/config/vc-frame/vc-agent-workshop.py new file mode 100755 index 00000000..39f7ab49 --- /dev/null +++ b/vibecrafted-core/vibecrafted_core/config/vc-frame/vc-agent-workshop.py @@ -0,0 +1,426 @@ +#!/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 + +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") + + +def launch_argv(agent: str, ritual: 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 ritual == "init": + # `init` defaults to opening another vc-frame tab. The workshop's law + # is stricter: this exact floating panel becomes the Agent TTY. + return ["vibecrafted", "init", agent, "--runtime", "plain"] + 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 + + +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.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) + 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 - 8) // 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) + ) + rows = (agent_line, ritual_line, 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, + ) + _safe_addstr( + self.window, + top + 4, + left, + "│ Enter = interactive TTY on this Agents tab".ljust(card_width - 1) + "│", + curses.A_DIM, + ) + _safe_addstr( + self.window, + top + 5, + left, + "└─ ↑/↓ row · ←/→ choice · type path · Enter launch · Esc cancel " + + "─" * max(0, card_width - 67) + + "┘", + ) + if self.error: + _safe_addstr( + self.window, min(height - 1, top + 7), 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) % 3 + return + if key in (curses.KEY_DOWN, ord("\t")): + self.row = (self.row + 1) % 3 + 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) + elif self.row == 1: + self.ritual = (self.ritual + delta) % len(RITUALS) + return + if key in (10, 13, curses.KEY_ENTER): + self.launch() + return + if self.row == 2: + if key in (curses.KEY_BACKSPACE, 127, 8): + self.path = self.path[:-1] + elif 32 <= key <= 126: + self.path += chr(key) + + 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) + argv = launch_argv(AGENTS[self.agent], RITUALS[self.ritual]) + 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/vc_frame_delivery.py b/vibecrafted-core/vibecrafted_core/vc_frame_delivery.py index 60d9ff85..f44b43af 100644 --- a/vibecrafted-core/vibecrafted_core/vc_frame_delivery.py +++ b/vibecrafted-core/vibecrafted_core/vc_frame_delivery.py @@ -55,6 +55,7 @@ "scrollback-select.sh", "vc-quick-cmd.sh", "vc-deck.sh", + "vc-agent-workshop.py", ) _CORE_VIEW_NAMES: tuple[str, ...] = ("config.kdl", "layouts", "themes") diff --git a/vibecrafted-core/vibecrafted_core/vc_frame_staging.py b/vibecrafted-core/vibecrafted_core/vc_frame_staging.py index 601938e7..a7ddb654 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"}) 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") From cdedc9155a37867ac83ffba2068f9dd6312ead47 Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 25 Aug 2026 05:24:13 +0200 Subject: [PATCH 16/46] [codex/interactive] fix(runtime): stop vc-frame wrapper recursion Resolves the installed adjacent libexec binary before ambient candidates and rejects shell wrappers as native vc-frame providers. Adds executable regressions for the installed layout and recursive data-root shadow. Authored-By: codex session_id: 01a03396-78e0-74e0-94c2-9adc16febc5c time: 2026-08-25T05:24:01+02:00 runtime: vc-terminal --- scripts/vc-frame-product-entry.sh | 23 +++---- tests/tui/test_product_entry_policy.py | 86 +++++++++++++++++++++++++- 2 files changed, 95 insertions(+), 14 deletions(-) diff --git a/scripts/vc-frame-product-entry.sh b/scripts/vc-frame-product-entry.sh index 19b13730..42ecc0c0 100755 --- a/scripts/vc-frame-product-entry.sh +++ b/scripts/vc-frame-product-entry.sh @@ -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 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") From 02eb5b3ed9cf3341c027edb5ac22c9a2560403bd Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 25 Aug 2026 05:41:42 +0200 Subject: [PATCH 17/46] [codex/interactive] fix(release): preserve successful carrier builds Treat cleanup of the disposable Foundations staging tree as best-effort so a macOS metadata race cannot replace a successful carrier build with ENOTEMPTY. Lock the release contract with a focused regression assertion. Authored-By: codex session_id: 01a03396-782e-7703-93b5-84e7659ddb28 time: 2026-08-25T05:41:42+02:00 runtime: vc-terminal --- scripts/stage-runtime-foundations.sh | 8 +++++++- tests/tui/test_release_contract.py | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/stage-runtime-foundations.sh b/scripts/stage-runtime-foundations.sh index 2cc51964..ec280ef1 100755 --- a/scripts/stage-runtime-foundations.sh +++ b/scripts/stage-runtime-foundations.sh @@ -30,7 +30,13 @@ esac for tool in git npm cargo python3; do require "$tool"; done WORK="$(mktemp -d "${TMPDIR:-/tmp}/vibecrafted-foundations.XXXXXX")" -trap 'rm -rf "$WORK"' EXIT +# 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 diff --git a/tests/tui/test_release_contract.py b/tests/tui/test_release_contract.py index 1ded2952..e49a1bf5 100644 --- a/tests/tui/test_release_contract.py +++ b/tests/tui/test_release_contract.py @@ -194,6 +194,7 @@ def test_native_carrier_embeds_every_required_agent_foundation() -> None: 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: From 9060cf7d0019aeac96f46a863fdb4795a9940768 Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 25 Aug 2026 05:50:26 +0200 Subject: [PATCH 18/46] [codex/headless] feat(policy): unify runtime permission wiring Make spawn.py the single provider policy normalizer used by headless launches, interactive CLI entrypoints, and Agent Workspaces. The complete provider/runtime/permission matrix fails closed on unsupported semantics and reports host capability separately. Authored-By: codex session_id: 01a036f1-7603-7b43-a4d7-021030c00153 time: 2026-08-25T05:50:00+02:00 runtime: headless --- docs/runtime/AGENT_INTERACTIVE_CONTRACT.md | 3 + scripts/vibecrafted | 5 +- tests/tui/test_operator_mode.py | 5 + tests/tui/test_vibecrafted_launcher.py | 2 +- vibecrafted-core/tests/test_agent_workshop.py | 13 + .../tests/test_provider_policy.py | 122 +++++++ .../config/vc-frame/vc-agent-workshop.py | 167 ++++++++- .../vibecrafted_core/deck/vibecrafted | 5 +- .../runtime/shell/lib/operator.sh | 71 +--- .../runtime/shell/lib/operator_entrypoints.sh | 13 +- .../runtime/shell/lib/prompts.sh | 12 + vibecrafted-core/vibecrafted_core/spawn.py | 328 +++++++++++++++++- 12 files changed, 652 insertions(+), 94 deletions(-) create mode 100644 vibecrafted-core/tests/test_provider_policy.py 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/scripts/vibecrafted b/scripts/vibecrafted index f04b7a00..77d07724 100755 --- a/scripts/vibecrafted +++ b/scripts/vibecrafted @@ -994,16 +994,19 @@ 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] [--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 '\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 ' vc-init codex\n' printf '\n' } 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_vibecrafted_launcher.py b/tests/tui/test_vibecrafted_launcher.py index 25630c58..f6beba0c 100644 --- a/tests/tui/test_vibecrafted_launcher.py +++ b/tests/tui/test_vibecrafted_launcher.py @@ -613,7 +613,7 @@ def test_init_codex_uses_interactive_tab_without_exec_mode(tmp_path: Path) -> No ("agent", "command_needle"), [ ("agy", "agy --dangerously-skip-permissions --add-dir . --prompt-interactive "), - ("junie", "junie --task="), + ("junie", "junie --prompt="), ( "grok", # Interactive TUI: positional prompt, NO --single (one-shot headless). diff --git a/vibecrafted-core/tests/test_agent_workshop.py b/vibecrafted-core/tests/test_agent_workshop.py index 3ef2ca9c..3e21c10e 100644 --- a/vibecrafted-core/tests/test_agent_workshop.py +++ b/vibecrafted-core/tests/test_agent_workshop.py @@ -52,6 +52,10 @@ def test_launcher_commands_keep_interactive_agent_in_this_panel() -> None: "codex", "--runtime", "plain", + "--policy-runtime", + "local-native", + "--permissions", + "bypass", ] assert workshop.launch_argv("claude", "resume") == [ "vibecrafted", @@ -62,6 +66,15 @@ def test_launcher_commands_keep_interactive_agent_in_this_panel() -> None: workshop.launch_argv("codex", "operator") +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") + + def test_workspace_path_is_full_resolved_and_must_exist(tmp_path: Path) -> None: workshop = _load() child = tmp_path / "project" diff --git a/vibecrafted-core/tests/test_provider_policy.py b/vibecrafted-core/tests/test_provider_policy.py new file mode 100644 index 00000000..ffd8b3b9 --- /dev/null +++ b/vibecrafted-core/tests/test_provider_policy.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import io +import itertools +import shlex +import sys + +import pytest +from vibecrafted_core.spawn import ( + PERMISSION_POLICIES, + POLICY_MODES, + POLICY_PROVIDERS, + RUNTIME_POLICIES, + interactive_policy_command, + main, + resolve_provider_policy, +) + + +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_non_native_runtimes_are_honestly_unsupported(provider: str) -> None: + assert ( + "worktree cut contract" + in resolve_provider_policy( + provider, "local-worktrees", "bypass", "interactive" + ).reason + ) + 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_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", + ] 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 index 39f7ab49..38a2949f 100755 --- a/vibecrafted-core/vibecrafted_core/config/vc-frame/vc-agent-workshop.py +++ b/vibecrafted-core/vibecrafted_core/config/vc-frame/vc-agent-workshop.py @@ -19,22 +19,47 @@ from pathlib import Path from typing import Any +from vibecrafted_core.spawn import ( + PERMISSION_POLICIES, + RUNTIME_POLICIES, + 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") -def launch_argv(agent: str, ritual: str) -> list[str]: +def launch_argv( + agent: str, + ritual: str, + runtime: str = "local-native", + permissions: str = "bypass", +) -> 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 ritual == "init": + decision = resolve_provider_policy(agent, runtime, permissions, "interactive") + if not decision.supported: + raise ValueError(decision.reason) # `init` defaults to opening another vc-frame tab. The workshop's law # is stricter: this exact floating panel becomes the Agent TTY. - return ["vibecrafted", "init", agent, "--runtime", "plain"] + return [ + "vibecrafted", + "init", + agent, + "--runtime", + "plain", + "--policy-runtime", + runtime, + "--permissions", + permissions, + ] return ["vibecrafted", "resume", agent] @@ -126,6 +151,21 @@ def _safe_addstr( 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 @@ -134,6 +174,8 @@ def __init__(self, window: curses.window, *, mode: str) -> None: self.row = 0 self.agent = 2 # codex is the least surprising neutral default here self.ritual = 0 + self.runtime = 0 + self.permissions = 0 self.path = str(Path.cwd()) self.error = "" self.mouse_targets: list[tuple[int, int, int, int, str]] = [] @@ -236,7 +278,7 @@ 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 - 8) // 2) + top = max(1, (height - 10) // 2) inner = max(20, card_width - 4) _safe_addstr( self.window, @@ -252,7 +294,29 @@ def draw_launcher(self) -> None: f"«{name}»" if index == self.ritual else f"[{name}]" for index, name in enumerate(RITUALS) ) - rows = (agent_line, ritual_line, f" path {self.path}") + 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) + ) + rows = ( + agent_line, + ritual_line, + runtime_line, + permission_line, + f" path {self.path}", + ) for index, line in enumerate(rows): attr = curses.A_REVERSE if index == self.row else 0 _safe_addstr( @@ -265,24 +329,58 @@ def draw_launcher(self) -> None: + " │", attr, ) - _safe_addstr( + _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 + 4, + left + 2 + len(" permits "), + PERMISSION_POLICIES, + tuple( + resolve_provider_policy( + provider, + RUNTIME_POLICIES[self.runtime], + name, + "interactive", + ).supported + for name in PERMISSION_POLICIES + ), + ) + _safe_addstr( + self.window, + top + 6, left, "│ Enter = interactive TTY on this Agents tab".ljust(card_width - 1) + "│", curses.A_DIM, ) _safe_addstr( self.window, - top + 5, + top + 7, 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 + 8, + left, + "Unavailable — " + " · ".join(unavailable), + curses.A_DIM, + ) if self.error: _safe_addstr( - self.window, min(height - 1, top + 7), left, self.error, curses.A_BOLD + self.window, min(height - 1, top + 9), left, self.error, curses.A_BOLD ) def handle_home_key(self, key: int) -> None: @@ -302,27 +400,65 @@ def handle_launcher_key(self, key: int) -> None: if key == 27: raise SystemExit(0) if key == curses.KEY_UP: - self.row = (self.row - 1) % 3 + self.row = (self.row - 1) % 5 return if key in (curses.KEY_DOWN, ord("\t")): - self.row = (self.row + 1) % 3 + self.row = (self.row + 1) % 5 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_permission_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) return if key in (10, 13, curses.KEY_ENTER): self.launch() return - if self.row == 2: + if self.row == 4: if key in (curses.KEY_BACKSPACE, 127, 8): self.path = self.path[:-1] elif 32 <= key <= 126: 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_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 handle_mouse(self) -> None: try: _, x, y, _, state = curses.getmouse() @@ -387,7 +523,16 @@ def open_voc(self) -> None: def launch(self) -> None: try: workspace = normalized_workspace(self.path) - argv = launch_argv(AGENTS[self.agent], RITUALS[self.ritual]) + runtime_name = RUNTIME_POLICIES[self.runtime] + capability = runtime_policy_capabilities(AGENTS[self.agent])[runtime_name] + if not capability["available"]: + raise ValueError(str(capability["reason"])) + argv = launch_argv( + AGENTS[self.agent], + RITUALS[self.ritual], + runtime_name, + PERMISSION_POLICIES[self.permissions], + ) except ValueError as exc: self.error = str(exc) return diff --git a/vibecrafted-core/vibecrafted_core/deck/vibecrafted b/vibecrafted-core/vibecrafted_core/deck/vibecrafted index f04b7a00..77d07724 100755 --- a/vibecrafted-core/vibecrafted_core/deck/vibecrafted +++ b/vibecrafted-core/vibecrafted_core/deck/vibecrafted @@ -994,16 +994,19 @@ 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] [--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 '\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 ' vc-init codex\n' printf '\n' } diff --git a/vibecrafted-core/vibecrafted_core/runtime/shell/lib/operator.sh b/vibecrafted-core/vibecrafted_core/runtime/shell/lib/operator.sh index 89390691..8c6ffcb7 100644 --- a/vibecrafted-core/vibecrafted_core/runtime/shell/lib/operator.sh +++ b/vibecrafted-core/vibecrafted_core/runtime/shell/lib/operator.sh @@ -57,35 +57,18 @@ _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 python_spec py import_root + 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" | PYTHONPATH="$import_root${PYTHONPATH:+:$PYTHONPATH}" \ + "$py" -m vibecrafted_core.spawn policy-command "$tool" --runtime "$policy_runtime" --permissions "$permissions" + else + printf '%s' "$init_prompt" | "$py" -m vibecrafted_core.spawn policy-command "$tool" --runtime "$policy_runtime" --permissions "$permissions" + fi } # Operator-mode launcher helpers — parallel to init helpers above. @@ -125,33 +108,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..8221270f 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")" || 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..f0f5cc33 100644 --- a/vibecrafted-core/vibecrafted_core/runtime/shell/lib/prompts.sh +++ b/vibecrafted-core/vibecrafted_core/runtime/shell/lib/prompts.sh @@ -33,6 +33,8 @@ _vetcoders_contract_reset() { _vetcoders_contract_count="" _vetcoders_contract_depth="" _vetcoders_contract_runtime="" + _vetcoders_contract_policy_runtime="" + _vetcoders_contract_permissions="" _vetcoders_contract_root="" _vetcoders_contract_tail="" _vetcoders_contract_dry_run="" @@ -119,6 +121,16 @@ _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" + ;; --root) shift [[ $# -gt 0 ]] || { echo "Missing value for --root" >&2; return 1; } diff --git a/vibecrafted-core/vibecrafted_core/spawn.py b/vibecrafted-core/vibecrafted_core/spawn.py index 712d1ff8..3eb93ce2 100644 --- a/vibecrafted-core/vibecrafted_core/spawn.py +++ b/vibecrafted-core/vibecrafted_core/spawn.py @@ -4,11 +4,13 @@ import argparse import datetime as dt +import inspect import json import os import re import shlex import subprocess +import sys import threading from collections.abc import Callable, Sequence from dataclasses import dataclass, field @@ -32,6 +34,247 @@ 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") + + +@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, + } + + +_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_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": + return ProviderPolicy( + provider, + runtime, + permissions, + mode, + False, + reason="git dispatch manages worktrees, but canonical init has no worktree cut contract", + ) + 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_found = which(provider, path=agent_tool_search_path()) is not None + 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, + "reason": "" if provider_found else f"{provider} executable not found", + }, + "local-worktrees": { + "available": False, + "substrate": worktree_substrate, + "reason": "no canonical init worktree cut" + if worktree_substrate + else "git/dispatch manage_worktrees unavailable", + }, + "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 +) -> 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) + if provider == "claude": + return ["claude", "--verbose", *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", + ] + return ["grok", "--cwd", ".", *flags, "--no-alt-screen", prompt] + + ANSI_PATTERN = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]") SESSION_PATTERNS = ( re.compile( @@ -156,22 +399,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 +429,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 +458,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 +477,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 +495,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 +515,7 @@ def _stdin_command(agent: str) -> list[str]: "grok", "--cwd", ".", - "--permission-mode", - "bypassPermissions", + *flags, "--no-alt-screen", "--output-format", "streaming-json", @@ -1595,6 +1856,15 @@ 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") + sub.add_parser( + "policy-matrix", help="Print the complete provider policy matrix as JSON." + ) return parser @@ -1639,6 +1909,30 @@ 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 == "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 From 34730dbcc0c26f94dbba1aa8522ea1f9ef07f3ae Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 25 Aug 2026 05:51:11 +0200 Subject: [PATCH 19/46] [codex/headless] feat(app): align menu and safe quit Rename the tray actions to the product-facing VC names, keep server status first, and route both Quit actions through a control-plane active-lane warning including worktree-backed runs. Authored-By: codex session_id: 01a036f1-7603-7b43-a4d7-021030c00153 time: 2026-08-25T05:52:00+02:00 runtime: headless --- tests/tui/test_unified_app_contract.py | 10 ++- .../app/Vibecrafted/AppDelegate.swift | 78 ++++++++++++++++--- 2 files changed, 73 insertions(+), 15 deletions(-) diff --git a/tests/tui/test_unified_app_contract.py b/tests/tui/test_unified_app_contract.py index fb394181..af673f2b 100644 --- a/tests/tui/test_unified_app_contract.py +++ b/tests/tui/test_unified_app_contract.py @@ -775,13 +775,15 @@ 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: "Restart Server"' in delegate + assert 'withTitle: "VC Console"' in delegate + assert 'withTitle: "VC Terminal"' in delegate + assert 'withTitle: "VC Server"' in delegate assert 'withTitle: "Server Diagnostics…"' in delegate - assert 'withTitle: "About Vibecrafted"' in delegate + assert 'withTitle: "About"' in delegate assert 'withTitle: "Help"' in delegate assert 'withTitle: "Quit"' in delegate + assert "#selector(requestQuit)" in delegate + assert 'process.arguments = ["status", "--json"]' in delegate assert 'appendingPathComponent("server/supervisor.status.json")' in delegate assert 'title: "Server: RESTARTING…"' in delegate assert 'process.arguments = ["server", "service", "reconcile"]' in delegate diff --git a/vibecrafted-app/shell-agent/app/Vibecrafted/AppDelegate.swift b/vibecrafted-app/shell-agent/app/Vibecrafted/AppDelegate.swift index af9d1605..bdacb00b 100644 --- a/vibecrafted-app/shell-agent/app/Vibecrafted/AppDelegate.swift +++ b/vibecrafted-app/shell-agent/app/Vibecrafted/AppDelegate.swift @@ -83,6 +83,16 @@ private struct ServerSupervisorSnapshot: Decodable { } } +private struct RuntimeStatusSnapshot: Decodable { + struct Run: Decodable { + let state: String + let health: String? + let root: String + } + + let runs: [Run] +} + private enum TrayServerHealth { case checking case healthy @@ -476,14 +486,14 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { serverDetailMenuItem = serverDetail menu.addItem(.separator()) let console = menu.addItem( - withTitle: "Open Console", action: #selector(openConsoleFromStatusItem), keyEquivalent: "") + withTitle: "VC Console", action: #selector(openConsoleFromStatusItem), keyEquivalent: "") console.target = self let terminal = menu.addItem( - withTitle: "Open vc-terminal", action: #selector(openTerminalFromStatusItem), + withTitle: "VC Terminal", action: #selector(openTerminalFromStatusItem), keyEquivalent: "") terminal.target = self let restart = menu.addItem( - withTitle: "Restart Server", action: #selector(restartServerFromStatusItem), + withTitle: "VC Server", action: #selector(restartServerFromStatusItem), keyEquivalent: "") restart.target = self restartServerMenuItem = restart @@ -493,15 +503,14 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { diagnostics.target = self menu.addItem(.separator()) menu.addItem( - withTitle: "About Vibecrafted", + withTitle: "About", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "") let help = menu.addItem( withTitle: "Help", action: #selector(showStatusItemHelp), keyEquivalent: "") help.target = self menu.addItem(.separator()) - menu.addItem( - withTitle: "Quit", action: #selector(NSApplication.terminate(_:)), - keyEquivalent: "q") + let quit = menu.addItem(withTitle: "Quit", action: #selector(requestQuit), keyEquivalent: "q") + quit.target = self item.menu = menu statusItem = item statusRefreshTimer = Timer.scheduledTimer( @@ -689,13 +698,60 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { alert.runModal() } + private func activeRunSummary() -> (lanes: Int, worktrees: Int)? { + guard let install = canonicalInstall, let environment = canonicalRuntimeEnvironment else { + return nil + } + let deck = install.root.appendingPathComponent("bin/vibecrafted") + guard FileManager.default.isExecutableFile(atPath: deck.path) else { return nil } + let output = Pipe() + let process = Process() + process.executableURL = deck + process.arguments = ["status", "--json"] + process.environment = environment + process.standardOutput = output + process.standardError = FileHandle.nullDevice + do { + try process.run() + let data = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + let status = try JSONDecoder().decode(RuntimeStatusSnapshot.self, from: data) + let active = status.runs.filter { + ["active", "running", "launching"].contains($0.state) || $0.health == "active" + } + let worktrees = active.filter { $0.root.contains("/.vibecrafted/worktrees/") } + return (active.count, worktrees.count) + } catch { + installLog.error("Cannot inspect active lanes before quit: \(error.localizedDescription, privacy: .public)") + return nil + } + } + + @objc private func requestQuit() { + guard let summary = activeRunSummary(), summary.lanes > 0 else { + NSApp.terminate(nil) + return + } + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "Active Vibecrafted lanes are still running" + alert.informativeText = + "\(summary.lanes) active 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: "Keep Running") + alert.addButton(withTitle: "Quit Anyway") + if alert.runModal() == .alertSecondButtonReturn { + NSApp.terminate(nil) + } + } + private func buildMainMenu() { let mainMenu = NSMenu() // Application menu let appMenu = NSMenu() appMenu.addItem( - withTitle: "About Vibecrafted", + withTitle: "About", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "") appMenu.addItem(.separator()) appMenu.addItem( @@ -708,9 +764,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { 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 From c67d018f8741afb30c98d3b6c0aac0f4e4216bd4 Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 25 Aug 2026 06:06:22 +0200 Subject: [PATCH 20/46] [codex/interactive] fix(runtime): bind vc-frame to native provider Return the installed libexec binary to AppDelegate for VIBECRAFTED_VC_FRAME_BIN instead of feeding the public wrapper back into itself. Add a Runtime Pack round-trip assertion that catches the first-launch recursion observed on Blacky. Authored-By: codex session_id: 01a03396-782e-7703-93b5-84e7659ddb28 time: 2026-08-25T06:06:22+02:00 runtime: vc-terminal --- scripts/vetcoders_install.py | 6 +++++- tests/tui/test_installer_uninstall.py | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/vetcoders_install.py b/scripts/vetcoders_install.py index 0601b582..69728af1 100755 --- a/scripts/vetcoders_install.py +++ b/scripts/vetcoders_install.py @@ -14735,7 +14735,11 @@ def _runtime_install_result( "root": str(generation), "terminal": str(generation / "bin/vc-terminal"), "terminal_host": str(terminal_host), - "frame": str(generation / "bin/vc-frame"), + # 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"), diff --git a/tests/tui/test_installer_uninstall.py b/tests/tui/test_installer_uninstall.py index de4547a7..fa2ef0c6 100644 --- a/tests/tui/test_installer_uninstall.py +++ b/tests/tui/test_installer_uninstall.py @@ -167,6 +167,7 @@ def teardown( 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" ) From 7ea57238b50db82746488a5793c174e1d1c35734 Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 25 Aug 2026 06:54:19 +0200 Subject: [PATCH 21/46] [codex/interactive] fix(runtime): materialize canonical CLI entrypoint Publish the canonical command deck at bin/vibecrafted before the immutable generation is audited and activated. Keep source install and DMG Runtime Pack installation on the same manifest-bound entrypoint, with doctor, rollback, and uninstall fixtures covering the contract. Authored-By: codex session_id: 01a03396-782e-7703-93b5-84e7659ddb28 time: 2026-08-25T06:54:19+02:00 runtime: vc-terminal --- scripts/vetcoders_install.py | 15 +++++++++++++++ tests/tui/test_installer_doctor.py | 4 +++- tests/tui/test_installer_uninstall.py | 1 + tests/tui/test_makefile_installer_contract.py | 10 ++++++++-- tests/tui/test_staged_tools_sync.py | 19 ++++++++++--------- 5 files changed, 37 insertions(+), 12 deletions(-) diff --git a/scripts/vetcoders_install.py b/scripts/vetcoders_install.py index 69728af1..03e71c22 100755 --- a/scripts/vetcoders_install.py +++ b/scripts/vetcoders_install.py @@ -8240,6 +8240,19 @@ def _materialize_vc_frame_generation(runtime_root: Path) -> None: ) +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. @@ -9100,6 +9113,7 @@ 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) audit_errors = _runtime_generation_audit_errors(staging, source_root=src) if audit_errors: raise OSError("\n".join(audit_errors)) @@ -14837,6 +14851,7 @@ def cmd_runtime_install(args: argparse.Namespace) -> int: ) (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") diff --git a/tests/tui/test_installer_doctor.py b/tests/tui/test_installer_doctor.py index ffcaca01..184680e5 100644 --- a/tests/tui/test_installer_doctor.py +++ b/tests/tui/test_installer_doctor.py @@ -874,7 +874,9 @@ def test_installer_doctor_fails_when_walkaround_runner_launcher_is_missing() -> 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" ), diff --git a/tests/tui/test_installer_uninstall.py b/tests/tui/test_installer_uninstall.py index fa2ef0c6..1eedd6a3 100644 --- a/tests/tui/test_installer_uninstall.py +++ b/tests/tui/test_installer_uninstall.py @@ -83,6 +83,7 @@ def _runtime_pack_fixture(root: Path) -> tuple[Path, Path, Path]: "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) diff --git a/tests/tui/test_makefile_installer_contract.py b/tests/tui/test_makefile_installer_contract.py index 911a5acd..e4f65fc5 100644 --- a/tests/tui/test_makefile_installer_contract.py +++ b/tests/tui/test_makefile_installer_contract.py @@ -524,7 +524,12 @@ 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, @@ -548,7 +553,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" diff --git a/tests/tui/test_staged_tools_sync.py b/tests/tui/test_staged_tools_sync.py index 00e9fa5b..edfc60c1 100644 --- a/tests/tui/test_staged_tools_sync.py +++ b/tests/tui/test_staged_tools_sync.py @@ -83,6 +83,9 @@ 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', @@ -331,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( @@ -5994,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"], @@ -6376,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 == [ @@ -6436,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") From dfb518ec4b664250284cb6d723eaeb995aaacf52 Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 25 Aug 2026 07:29:38 +0200 Subject: [PATCH 22/46] [codex/interactive] fix(app): polarize safe quit on lifecycle truth Expose an unfiltered active/stalled lifecycle projection from the canonical control plane and make every macOS termination path fail safe against that versioned contract. Cover old, overflow, stalled, worktree, malformed, and subprocess-failure cases; align the stale Claude launcher expectation with the live provider policy. Authored-By: codex session_id: 01a03748-3488-7e62-b1a6-dbc085a22266 time: 2026-08-25T07:29:32+02:00 runtime: vc-terminal --- tests/tui/test_safe_quit.py | 93 +++++++++++++++++++ tests/tui/test_unified_app_contract.py | 5 +- tests/tui/test_vibecrafted_launcher.py | 2 +- .../app/Vibecrafted/AppDelegate.swift | 72 +++++++------- .../app/Vibecrafted/QuitSafety.swift | 41 ++++++++ vibecrafted-core/tests/test_run_board.py | 89 ++++++++++++++++++ .../vibecrafted_core/run_board.py | 59 +++++++++++- 7 files changed, 321 insertions(+), 40 deletions(-) create mode 100644 tests/tui/test_safe_quit.py create mode 100644 vibecrafted-app/shell-agent/app/Vibecrafted/QuitSafety.swift create mode 100644 vibecrafted-core/tests/test_run_board.py 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_unified_app_contract.py b/tests/tui/test_unified_app_contract.py index af673f2b..d6929cd8 100644 --- a/tests/tui/test_unified_app_contract.py +++ b/tests/tui/test_unified_app_contract.py @@ -783,7 +783,10 @@ def test_native_app_bootstraps_and_launches_only_the_canonical_product_entry() - assert 'withTitle: "Help"' in delegate assert 'withTitle: "Quit"' in delegate assert "#selector(requestQuit)" in delegate - assert 'process.arguments = ["status", "--json"]' 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 'title: "Server: RESTARTING…"' in delegate assert 'process.arguments = ["server", "service", "reconcile"]' in delegate diff --git a/tests/tui/test_vibecrafted_launcher.py b/tests/tui/test_vibecrafted_launcher.py index f6beba0c..486bd36e 100644 --- a/tests/tui/test_vibecrafted_launcher.py +++ b/tests/tui/test_vibecrafted_launcher.py @@ -556,7 +556,7 @@ 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 "claude --verbose --permission-mode bypassPermissions " in script_body assert "/vc-init" in script_body assert " -p " not in script_body diff --git a/vibecrafted-app/shell-agent/app/Vibecrafted/AppDelegate.swift b/vibecrafted-app/shell-agent/app/Vibecrafted/AppDelegate.swift index bdacb00b..7eec5213 100644 --- a/vibecrafted-app/shell-agent/app/Vibecrafted/AppDelegate.swift +++ b/vibecrafted-app/shell-agent/app/Vibecrafted/AppDelegate.swift @@ -83,16 +83,6 @@ private struct ServerSupervisorSnapshot: Decodable { } } -private struct RuntimeStatusSnapshot: Decodable { - struct Run: Decodable { - let state: String - let health: String? - let root: String - } - - let runs: [Run] -} - private enum TrayServerHealth { case checking case healthy @@ -196,6 +186,32 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { 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 } @@ -698,16 +714,18 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { alert.runModal() } - private func activeRunSummary() -> (lanes: Int, worktrees: Int)? { + private func activeRunSummary() -> RuntimeActivityTruth { guard let install = canonicalInstall, let environment = canonicalRuntimeEnvironment else { - return nil + return .unavailable("canonical runtime onboarding is incomplete") } let deck = install.root.appendingPathComponent("bin/vibecrafted") - guard FileManager.default.isExecutableFile(atPath: deck.path) else { return nil } + 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", "--json"] + process.arguments = ["status", "--activity", "--json"] process.environment = environment process.standardOutput = output process.standardError = FileHandle.nullDevice @@ -715,34 +733,14 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { try process.run() let data = output.fileHandleForReading.readDataToEndOfFile() process.waitUntilExit() - guard process.terminationStatus == 0 else { return nil } - let status = try JSONDecoder().decode(RuntimeStatusSnapshot.self, from: data) - let active = status.runs.filter { - ["active", "running", "launching"].contains($0.state) || $0.health == "active" - } - let worktrees = active.filter { $0.root.contains("/.vibecrafted/worktrees/") } - return (active.count, worktrees.count) + return decodeRuntimeActivityTruth(data: data, terminationStatus: process.terminationStatus) } catch { - installLog.error("Cannot inspect active lanes before quit: \(error.localizedDescription, privacy: .public)") - return nil + return .unavailable(error.localizedDescription) } } @objc private func requestQuit() { - guard let summary = activeRunSummary(), summary.lanes > 0 else { - NSApp.terminate(nil) - return - } - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = "Active Vibecrafted lanes are still running" - alert.informativeText = - "\(summary.lanes) active 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: "Keep Running") - alert.addButton(withTitle: "Quit Anyway") - if alert.runModal() == .alertSecondButtonReturn { - NSApp.terminate(nil) - } + NSApp.terminate(nil) } private func buildMainMenu() { 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-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/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 From 3950d4b7d56972fe716685509488323f7f51c325 Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 25 Aug 2026 08:08:12 +0200 Subject: [PATCH 23/46] [codex/interactive] feat(runtime): ship one receipted Runtime Pack installer Makes DMG onboarding and macOS CLI consume the same signed binary payload, with pack-owned install/uninstall, an ownership receipt, and cold release verification. Keeps the compiler-heavy portable source lane explicit as install-source. Authored-By: codex session_id: 01a03396-782e-7703-93b5-84e7659ddb28 time: 2026-08-25T08:08:03+02:00 runtime: codex --- .github/workflows/release.yml | 8 +- Makefile | 43 ++- README.md | 27 +- docs/QUICK_START.md | 18 +- docs/RELEASE_CHECKLIST.md | 18 +- docs/RELEASE_KICKOFF.md | 12 +- docs/public/getting-started/install.md | 24 +- scripts/build-vibecrafted-release.sh | 15 + scripts/install-runtime-pack.sh | 193 ++++++++++++ scripts/package-runtime-pack.sh | 75 +++++ scripts/publish-vibecrafted-release.sh | 97 +++++- scripts/vetcoders_install.py | 71 ++++- tests/tui/test_installer_uninstall.py | 43 ++- tests/tui/test_makefile_installer_contract.py | 52 +-- tests/tui/test_release_contract.py | 38 ++- tests/tui/test_runtime_pack_cli.py | 295 ++++++++++++++++++ 16 files changed, 952 insertions(+), 77 deletions(-) create mode 100755 scripts/install-runtime-pack.sh create mode 100755 scripts/package-runtime-pack.sh create mode 100644 tests/tui/test_runtime_pack_cli.py 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/Makefile b/Makefile index 27aeac1a..28362e84 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 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: @@ -258,9 +274,21 @@ endif # Headless entrypoint for install.sh (curl|bash). Mirrors the full # non-interactive install. Was previously undefined, so the piped # `curl ... | bash` path ran `make install-auto` as a silent no-op. -install-auto: install +install-auto: install-source install: + @if [ "$$(uname -s)" = "Darwin" ]; then \ + VIBECRAFTED_RUNTIME_PACK="$(RUNTIME_PACK)" bash "$(RUNTIME_PACK_INSTALLER)"; \ + else \ + printf 'Binary Runtime Pack is not published for %s yet; using the explicit source lane.\n' "$$(uname -s)"; \ + $(MAKE) --no-print-directory install-source; \ + fi + +# 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 +304,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, @@ -622,7 +649,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..63c1b02b 100644 --- a/README.md +++ b/README.md @@ -177,15 +177,30 @@ 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 +Until Linux/WSL binary Runtime Packs are published, `make install` on those +platforms routes to this same explicit source lane instead of looking for a +Darwin artifact. + +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 +223,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/docs/QUICK_START.md b/docs/QUICK_START.md index cf316c9c..fe1de279 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. `make install-source` and +`make install-auto` are the explicit compiler/source lane used by the portable +carrier. On Linux/WSL, `make install` currently routes to that source lane until +a native binary Runtime Pack exists. 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/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/scripts/build-vibecrafted-release.sh b/scripts/build-vibecrafted-release.sh index e0cc0bb0..bf21d4bf 100755 --- a/scripts/build-vibecrafted-release.sh +++ b/scripts/build-vibecrafted-release.sh @@ -66,6 +66,11 @@ 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-$(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}.tar.gz" +RUNTIME_PACK="$DIST_DIR/$RUNTIME_PACK_NAME" +RUNTIME_PACK_CHECKSUM="$RUNTIME_PACK.sha256" +RUNTIME_PACK_SIGNATURE="$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" @@ -741,6 +746,15 @@ emit_release_tuple() { ) } +emit_runtime_pack() { + log "Packaging the standalone Runtime Pack carrier" + rm -f "$RUNTIME_PACK" "$RUNTIME_PACK_CHECKSUM" "$RUNTIME_PACK_SIGNATURE" + "$REPO_ROOT/scripts/package-runtime-pack.sh" \ + --app "$APP" --output "$RUNTIME_PACK" + /usr/bin/openssl dgst -sha256 -sign "$SIGNING_KEY" \ + -out "$RUNTIME_PACK_SIGNATURE" "$RUNTIME_PACK" +} + if [[ "$MODE" == "notarize" ]]; then [[ -d "$APP" ]] || die "missing $APP; run make dmg-signed first" notarize_product @@ -749,6 +763,7 @@ if [[ "$MODE" == "notarize" ]]; then fi build_product +emit_runtime_pack [[ "$MODE" == "app" ]] && exit 0 if [[ "$MODE" == "dmg" ]]; then create_dmg diff --git a/scripts/install-runtime-pack.sh b/scripts/install-runtime-pack.sh new file mode 100755 index 00000000..0d18c4b5 --- /dev/null +++ b/scripts/install-runtime-pack.sh @@ -0,0 +1,193 @@ +#!/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" + +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 + ;; + --dry-run|-n) + dry_run="1" + shift + ;; + --help|-h) + printf 'usage: %s [--pack ] [--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 [[ "$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 + if [[ -d "$REPO_ROOT/dist/Vibecrafted.app/Contents/Resources/runtime" ]]; then + pack="$REPO_ROOT/dist/Vibecrafted.app" + else + shopt -s nullglob + candidates=("$REPO_ROOT"/dist/Vibecrafted_RuntimePack_*.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 Runtime Pack found; set VIBECRAFTED_RUNTIME_PACK or run 'make runtime-pack'" + fi + 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" + +app_root="" +terminal_host="" +frame_helper="" +payload_root="" + +if [[ -d "$pack" ]]; then + if [[ "$pack" == *.app ]]; then + app_root="$pack" + payload_root="$pack/Contents/Resources/runtime" + terminal_host="$pack/Contents/Helpers/vc-terminal.app/Contents/MacOS/alacritty" + frame_helper="$pack/Contents/Helpers/vc-frame" + else + payload_root="$pack" + fi +elif [[ -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 is not a directory, app, or .tar.gz archive: $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" + +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 + [[ -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/package-runtime-pack.sh b/scripts/package-runtime-pack.sh new file mode 100755 index 00000000..9c7e470c --- /dev/null +++ b/scripts/package-runtime-pack.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +die() { printf 'Runtime Pack packaging failed: %s\n' "$*" >&2; exit 1; } + +app="" +output="" +while (($#)); do + case "$1" in + --app) + (($# >= 2)) || die "--app requires a path" + app="$2" + shift 2 + ;; + --output) + (($# >= 2)) || die "--output requires a path" + output="$2" + shift 2 + ;; + --help|-h) + printf 'usage: %s --app --output \n' "$0" + exit 0 + ;; + *) die "unknown argument: $1" ;; + esac +done + +[[ -n "$app" && -n "$output" ]] || die "--app and --output are required" +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" + +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 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" + +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-terminal bin/vc-frame \ + libexec/vc-frame scripts/vetcoders_install.py; do + [[ -e "$root/$required" ]] || die "standalone Runtime Pack is missing $required" +done + +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..4b1f3ecf 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,26 @@ test -s "$DMG_CHECKSUM" || die "missing $DMG_CHECKSUM" xcrun stapler validate "$DMG" spctl --assess --type open --context context:primary-signature --verbose=2 "$DMG" +RELEASE_DATE="${DMG_NAME#Vibecrafted_"${VERSION}"-}" +RELEASE_DATE="${RELEASE_DATE%-"${HEAD_SHA:0:8}".dmg}" +RUNTIME_PACK_PLATFORM="darwin-$(uname -m | sed 's/^arm64$/arm64/; s/^aarch64$/arm64/; s/^x86_64$/x64/')" +RUNTIME_PACK_NAME="Vibecrafted_RuntimePack_${VERSION}-${RELEASE_DATE}-${HEAD_SHA:0:8}-${RUNTIME_PACK_PLATFORM}.tar.gz" +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" + # 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 +122,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 +138,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 +149,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 +159,13 @@ 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" uv run --project vibecrafted-core verify-vibecrafted-walkaround verify-release \ --release-output "$DOWNLOAD_DIR/release-output.json" \ @@ -159,14 +194,37 @@ 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 +238,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 +314,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/vetcoders_install.py b/scripts/vetcoders_install.py index 03e71c22..bf9218ec 100755 --- a/scripts/vetcoders_install.py +++ b/scripts/vetcoders_install.py @@ -14646,6 +14646,28 @@ def _ensure_runtime_projection_directory( _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, *, @@ -14817,7 +14839,9 @@ def cmd_runtime_install(args: argparse.Namespace) -> int: paths["crafted_home"] / "control_plane", paths["launcher_home"], ): - directory.mkdir(parents=True, exist_ok=True) + _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"]: @@ -15156,6 +15180,23 @@ def _receipt_empty_projection_dir_is_allowed(path: Path) -> bool: 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() @@ -15224,7 +15265,7 @@ def cmd_runtime_uninstall(args: argparse.Namespace) -> int: 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_projection_dir_is_allowed(Path(raw_path)): + if not _receipt_empty_dir_is_allowed(Path(raw_path), paths): raise RuntimeError( f"receipt empty directory escapes projection roots: {raw_path}" ) @@ -15325,17 +15366,6 @@ def cmd_runtime_uninstall(args: argparse.Namespace) -> int: destination.parent.mkdir(parents=True, exist_ok=True) _restore_path_from_backup(backup, destination) - for raw_path in sorted( - receipt.get("owned_empty_dirs", []), - key=lambda value: len(Path(value).parts), - reverse=True, - ): - path = Path(raw_path) - if path.is_dir() and not path.is_symlink() and not any(path.iterdir()): - actions.append(f"remove empty {path}") - if not dry_run: - path.rmdir() - roots_created = receipt.get("roots_created", {}) for name in ("product_config", "crafted_home", "runtime_home", "launcher_home"): root = paths[name] @@ -15349,6 +15379,21 @@ def cmd_runtime_uninstall(args: argparse.Namespace) -> int: 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: diff --git a/tests/tui/test_installer_uninstall.py b/tests/tui/test_installer_uninstall.py index 1eedd6a3..c2b181c0 100644 --- a/tests/tui/test_installer_uninstall.py +++ b/tests/tui/test_installer_uninstall.py @@ -233,10 +233,51 @@ def teardown( assert not (home / ".codex/commands").exists() assert not runtime_home.exists() assert not crafted_home.exists() - assert not (config_home / "vibecrafted").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: diff --git a/tests/tui/test_makefile_installer_contract.py b/tests/tui/test_makefile_installer_contract.py index e4f65fc5..d29d3ae8 100644 --- a/tests/tui/test_makefile_installer_contract.py +++ b/tests/tui/test_makefile_installer_contract.py @@ -189,12 +189,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,33 +203,42 @@ 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" ]' in install_block + assert "$(MAKE) --no-print-directory install-source" 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. - assert "install-auto: install" in text + # The curl/portable source bootstrap remains explicit and cannot silently + # select a host or App Runtime Pack. + assert "install-auto: install-source" in text # setup-dev opens the uv meta-installer in advanced mode. Advanced is an # interactive surface, so it never carries the auto-approve `--yes`. @@ -588,13 +594,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] @@ -1378,7 +1384,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_release_contract.py b/tests/tui/test_release_contract.py index e49a1bf5..9eb691aa 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,9 +161,14 @@ 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", ): @@ -230,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"', @@ -258,6 +270,20 @@ 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_NAME="Vibecrafted_RuntimePack_' 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 "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( @@ -298,6 +324,12 @@ 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}.tar.gz"' + in builder + ) + assert '"$REPO_ROOT/scripts/package-runtime-pack.sh"' in builder + assert '-out "$RUNTIME_PACK_SIGNATURE" "$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 diff --git a/tests/tui/test_runtime_pack_cli.py b/tests/tui/test_runtime_pack_cli.py new file mode 100644 index 00000000..8078fbe3 --- /dev/null +++ b/tests/tui/test_runtime_pack_cli.py @@ -0,0 +1,295 @@ +from __future__ import annotations + +import hashlib +import json +import os +import subprocess +import tarfile +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +INSTALLER = REPO_ROOT / "scripts/install-runtime-pack.sh" +PACKAGER = REPO_ROOT / "scripts/package-runtime-pack.sh" + + +def _fake_runtime_payload(root: Path, capture: Path) -> None: + (root / "bin").mkdir(parents=True) + (root / "scripts").mkdir(parents=True) + python = root / "bin/python3" + python.write_text( + '#!/usr/bin/env bash\nprintf "%s\\n" "$@" > "$CAPTURE"\n', + encoding="utf-8", + ) + python.chmod(0o755) + (root / "scripts/vetcoders_install.py").write_text("# fixture\n", encoding="utf-8") + capture.parent.mkdir(parents=True, exist_ok=True) + + +def _run( + *arguments: str, env: dict[str, str] | None = None +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["bash", str(INSTALLER), *arguments], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + env={**os.environ, **(env or {})}, + ) + + +def test_runtime_pack_directory_uses_pack_owned_python(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, result.stderr + assert capture.read_text(encoding="utf-8").splitlines() == [ + str(payload / "scripts/vetcoders_install.py"), + "runtime-install", + "--payload-root", + str(payload), + ] + + +def test_runtime_pack_app_supplies_native_helpers(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, result.stderr + assert capture.read_text(encoding="utf-8").splitlines() == [ + str(payload / "scripts/vetcoders_install.py"), + "runtime-install", + "--payload-root", + str(payload), + "--app-root", + str(app), + "--terminal-host", + str(terminal), + "--frame-helper", + str(frame), + ] + + +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) + + result = _run( + "--uninstall", + "--pack", + str(payload), + env={"HOME": str(home), "CAPTURE": str(capture)}, + ) + + assert result.returncode == 0, result.stderr + assert capture.read_text(encoding="utf-8").splitlines() == [ + str(payload / "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/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) + path.write_text("fixture\n", encoding="utf-8") + if relative.startswith("bin/"): + path.chmod(0o755) + 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)], + 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/libexec/vc-frame" 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 + ) + + +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 = tmp_path / "Vibecrafted_RuntimePack_fixture.tar.gz" + 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, + ) + 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()) From c89273390233c57cd1969917076fb87d23aa977f Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 25 Aug 2026 09:42:56 +0200 Subject: [PATCH 24/46] [codex/interactive] fix(release): polarize Runtime Pack provenance Builds the Runtime Pack carrier once inside Vibecrafted.app and projects the exact same signed bytes as the standalone asset. Binds source and donor revisions plus payload digests into closed internal provenance and the signed release receipt, with App, CLI, publisher, and installed-generation verification failing before runtime mutation. Authored-By: codex session_id: 01a037af-b3e5-71a0-8061-879a92c37610 time: 2026-08-25T09:42:45+02:00 runtime: codex --- scripts/build-vibecrafted-release.sh | 42 ++- scripts/install-runtime-pack.sh | 95 ++++-- scripts/package-runtime-pack.sh | 38 ++- scripts/publish-vibecrafted-release.sh | 23 +- scripts/unified_product_manifest.py | 43 +++ scripts/vetcoders_install.py | 20 ++ tests/tui/test_installer_doctor.py | 6 +- tests/tui/test_release_contract.py | 10 +- tests/tui/test_runtime_pack_cli.py | 273 +++++++++++++----- tests/tui/test_unified_app_contract.py | 87 +++++- .../app/Vibecrafted/AppDelegate.swift | 61 +++- .../tests/test_runtime_receipt.py | 3 +- .../vibecrafted_core/product_contract.py | 115 ++++++++ .../vibecrafted_core/runtime_pack_contract.py | 261 +++++++++++++++++ .../schemas/unified_product.schema.v1.json | 40 +++ 15 files changed, 983 insertions(+), 134 deletions(-) create mode 100644 vibecrafted-core/vibecrafted_core/runtime_pack_contract.py diff --git a/scripts/build-vibecrafted-release.sh b/scripts/build-vibecrafted-release.sh index bf21d4bf..a256f4b7 100755 --- a/scripts/build-vibecrafted-release.sh +++ b/scripts/build-vibecrafted-release.sh @@ -71,6 +71,10 @@ RUNTIME_PACK_NAME="Vibecrafted_RuntimePack_${VERSION}-${RELEASE_DATE}-${ROOT_SHA 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" @@ -404,6 +408,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 "$(uname -m | sed 's/^aarch64$/arm64/; s/^x86_64$/x64/')" + /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 @@ -683,6 +708,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[@]}"} @@ -735,7 +761,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 \ @@ -747,16 +774,19 @@ emit_release_tuple() { } emit_runtime_pack() { - log "Packaging the standalone Runtime Pack carrier" + log "Projecting the exact App-embedded Runtime Pack bytes as the standalone asset" rm -f "$RUNTIME_PACK" "$RUNTIME_PACK_CHECKSUM" "$RUNTIME_PACK_SIGNATURE" - "$REPO_ROOT/scripts/package-runtime-pack.sh" \ - --app "$APP" --output "$RUNTIME_PACK" - /usr/bin/openssl dgst -sha256 -sign "$SIGNING_KEY" \ - -out "$RUNTIME_PACK_SIGNATURE" "$RUNTIME_PACK" + 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 diff --git a/scripts/install-runtime-pack.sh b/scripts/install-runtime-pack.sh index 0d18c4b5..d9ba2490 100755 --- a/scripts/install-runtime-pack.sh +++ b/scripts/install-runtime-pack.sh @@ -9,6 +9,13 @@ 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="" cleanup() { if [[ -n "$temporary" && -d "$temporary" ]]; then @@ -28,12 +35,28 @@ while (($#)); do operation="uninstall" shift ;; + --verify-only) + verify_only="1" + shift + ;; + --app-root|--terminal-host|--frame-helper|--expected-source-revision|--expected-terminal-revision|--expected-frame-revision) + (($# >= 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" ;; + esac + shift 2 + ;; --dry-run|-n) dry_run="1" shift ;; --help|-h) - printf 'usage: %s [--pack ] [--uninstall [--dry-run]]\n' "$0" + 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" ;; @@ -43,6 +66,16 @@ done if [[ "$operation" == "install" && "$dry_run" == "1" ]]; then die "--dry-run is only valid with --uninstall" 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}" @@ -73,19 +106,15 @@ if [[ "$operation" == "uninstall" ]]; then fi if [[ -z "$pack" ]]; then - if [[ -d "$REPO_ROOT/dist/Vibecrafted.app/Contents/Resources/runtime" ]]; then - pack="$REPO_ROOT/dist/Vibecrafted.app" + shopt -s nullglob + candidates=("$REPO_ROOT"/dist/Vibecrafted_RuntimePack_*.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 - shopt -s nullglob - candidates=("$REPO_ROOT"/dist/Vibecrafted_RuntimePack_*.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 Runtime Pack found; set VIBECRAFTED_RUNTIME_PACK or run 'make runtime-pack'" - fi + die "no Runtime Pack found; set VIBECRAFTED_RUNTIME_PACK or run 'make runtime-pack'" fi fi @@ -94,21 +123,9 @@ pack_parent="$(cd "$(dirname "$pack")" 2>/dev/null && pwd)" \ || die "cannot resolve Runtime Pack path: $pack" pack="$pack_parent/$pack_name" -app_root="" -terminal_host="" -frame_helper="" payload_root="" -if [[ -d "$pack" ]]; then - if [[ "$pack" == *.app ]]; then - app_root="$pack" - payload_root="$pack/Contents/Resources/runtime" - terminal_host="$pack/Contents/Helpers/vc-terminal.app/Contents/MacOS/alacritty" - frame_helper="$pack/Contents/Helpers/vc-frame" - else - payload_root="$pack" - fi -elif [[ -f "$pack" && "$pack" == *.tar.gz ]]; then +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" @@ -161,7 +178,7 @@ elif [[ -f "$pack" && "$pack" == *.tar.gz ]]; then die "links are forbidden in extracted Runtime Pack archives" fi else - die "Runtime Pack is not a directory, app, or .tar.gz archive: $pack" + die "Runtime Pack must be the canonical .tar.gz carrier: $pack" fi [[ -d "$payload_root" ]] || die "runtime payload missing: $payload_root" @@ -169,6 +186,24 @@ 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_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) @@ -177,6 +212,12 @@ 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+=( diff --git a/scripts/package-runtime-pack.sh b/scripts/package-runtime-pack.sh index 9c7e470c..de5bbe55 100755 --- a/scripts/package-runtime-pack.sh +++ b/scripts/package-runtime-pack.sh @@ -5,6 +5,12 @@ die() { printf 'Runtime Pack packaging failed: %s\n' "$*" >&2; exit 1; } app="" output="" +source_revision="" +terminal_revision="" +frame_revision="" +version="" +platform="" +architecture="" while (($#)); do case "$1" in --app) @@ -17,15 +23,29 @@ while (($#)); do 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 --output \n' "$0" + printf 'usage: %s --app --output --source-revision --terminal-revision --frame-revision --version --platform --architecture \n' "$0" exit 0 ;; *) die "unknown argument: $1" ;; esac done -[[ -n "$app" && -n "$output" ]] || die "--app and --output are required" +for required_value in app output source_revision terminal_revision frame_revision version platform architecture; do + [[ -n "${!required_value}" ]] || die "--$required_value is required" +done app_name="${app##*/}" app_parent="$(cd "$(dirname "$app")" 2>/dev/null && pwd)" \ || die "cannot resolve app path: $app" @@ -56,10 +76,22 @@ if find "$root" -type l -print -quit | grep -q .; then fi for required in \ VERSION bin/python3 bin/vibecrafted bin/vc-terminal bin/vc-frame \ - libexec/vc-frame scripts/vetcoders_install.py; do + libexec/vc-frame 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 diff --git a/scripts/publish-vibecrafted-release.sh b/scripts/publish-vibecrafted-release.sh index 4b1f3ecf..d1ecec4d 100755 --- a/scripts/publish-vibecrafted-release.sh +++ b/scripts/publish-vibecrafted-release.sh @@ -63,10 +63,7 @@ test -s "$DMG_CHECKSUM" || die "missing $DMG_CHECKSUM" xcrun stapler validate "$DMG" spctl --assess --type open --context context:primary-signature --verbose=2 "$DMG" -RELEASE_DATE="${DMG_NAME#Vibecrafted_"${VERSION}"-}" -RELEASE_DATE="${RELEASE_DATE%-"${HEAD_SHA:0:8}".dmg}" -RUNTIME_PACK_PLATFORM="darwin-$(uname -m | sed 's/^arm64$/arm64/; s/^aarch64$/arm64/; s/^x86_64$/x64/')" -RUNTIME_PACK_NAME="Vibecrafted_RuntimePack_${VERSION}-${RELEASE_DATE}-${HEAD_SHA:0:8}-${RUNTIME_PACK_PLATFORM}.tar.gz" +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" @@ -82,6 +79,15 @@ test -s "$RUNTIME_PACK_PUBLIC_KEY" || die "missing trusted Runtime Pack public k 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 @@ -166,6 +172,13 @@ 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" \ @@ -221,8 +234,6 @@ 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" 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/vetcoders_install.py b/scripts/vetcoders_install.py index bf9218ec..c4dd14ed 100755 --- a/scripts/vetcoders_install.py +++ b/scripts/vetcoders_install.py @@ -2577,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", @@ -3087,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", @@ -3179,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", @@ -8598,6 +8606,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( diff --git a/tests/tui/test_installer_doctor.py b/tests/tui/test_installer_doctor.py index 184680e5..61fc0ce6 100644 --- a/tests/tui/test_installer_doctor.py +++ b/tests/tui/test_installer_doctor.py @@ -880,6 +880,9 @@ def test_installer_doctor_fails_when_walkaround_runner_launcher_is_missing() -> 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" ), @@ -899,7 +902,7 @@ def _write_release_contract_runtime_manifest( current_tools: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - assert len(_RUNTIME_GENERATION_FIXTURE_SOURCES) == 12 + assert len(_RUNTIME_GENERATION_FIXTURE_SOURCES) == 13 assert ( frozenset(_RUNTIME_GENERATION_FIXTURE_SOURCES) == installer._RUNTIME_GENERATION_REQUIRED_HASHES @@ -1106,6 +1109,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", diff --git a/tests/tui/test_release_contract.py b/tests/tui/test_release_contract.py index 9eb691aa..6062203f 100644 --- a/tests/tui/test_release_contract.py +++ b/tests/tui/test_release_contract.py @@ -275,10 +275,13 @@ def test_macos_publisher_cold_verifies_runtime_pack_install_and_uninstall() -> N encoding="utf-8" ) - assert 'RUNTIME_PACK_NAME="Vibecrafted_RuntimePack_' in publisher + 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 @@ -329,7 +332,10 @@ def test_builder_emits_the_canonical_versioned_dmg_and_checksum() -> None: in builder ) assert '"$REPO_ROOT/scripts/package-runtime-pack.sh"' in builder - assert '-out "$RUNTIME_PACK_SIGNATURE" "$RUNTIME_PACK"' 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 diff --git a/tests/tui/test_runtime_pack_cli.py b/tests/tui/test_runtime_pack_cli.py index 8078fbe3..576b3400 100644 --- a/tests/tui/test_runtime_pack_cli.py +++ b/tests/tui/test_runtime_pack_cli.py @@ -3,21 +3,42 @@ import hashlib import json import os +import shutil import subprocess +import sys import tarfile from pathlib import Path +from vibecrafted_core.runtime_pack_contract import 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\nprintf "%s\\n" "$@" > "$CAPTURE"\n', + "#!/usr/bin/env bash\n" + 'if [[ "${1:-}" == "-m" ]]; then\n' + f' exec "{sys.executable}" "$@"\n' + "fi\n" + 'printf "%s\\n" "$@" > "$CAPTURE"\n', encoding="utf-8", ) python.chmod(0o755) @@ -25,6 +46,80 @@ def _fake_runtime_payload(root: Path, capture: Path) -> None: 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-arm64", + 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]: @@ -38,23 +133,19 @@ def _run( ) -def test_runtime_pack_directory_uses_pack_owned_python(tmp_path: Path) -> None: +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, result.stderr - assert capture.read_text(encoding="utf-8").splitlines() == [ - str(payload / "scripts/vetcoders_install.py"), - "runtime-install", - "--payload-root", - str(payload), - ] + assert result.returncode != 0 + assert "canonical .tar.gz carrier" in result.stderr + assert not capture.exists() -def test_runtime_pack_app_supplies_native_helpers(tmp_path: Path) -> None: +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" @@ -68,19 +159,9 @@ def test_runtime_pack_app_supplies_native_helpers(tmp_path: Path) -> None: result = _run("--pack", str(app), env={"CAPTURE": str(capture)}) - assert result.returncode == 0, result.stderr - assert capture.read_text(encoding="utf-8").splitlines() == [ - str(payload / "scripts/vetcoders_install.py"), - "runtime-install", - "--payload-root", - str(payload), - "--app-root", - str(app), - "--terminal-host", - str(terminal), - "--frame-helper", - str(frame), - ] + 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: @@ -125,16 +206,25 @@ def test_runtime_uninstall_recovers_from_pack_when_projection_is_missing( capture = tmp_path / "argv" _fake_runtime_payload(payload, capture) + archive, public_key = _sealed_archive(tmp_path, payload) result = _run( "--uninstall", "--pack", - str(payload), - env={"HOME": str(home), "CAPTURE": str(capture)}, + 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() == [ - str(payload / "scripts/vetcoders_install.py"), + next( + line + for line in capture.read_text(encoding="utf-8").splitlines() + if line.endswith("scripts/vetcoders_install.py") + ), "runtime-uninstall", ] @@ -161,9 +251,26 @@ def test_runtime_packager_emits_one_closed_root_and_checksum(tmp_path: Path) -> for relative in required: path = runtime / relative path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("fixture\n", encoding="utf-8") + 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/"): 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): @@ -173,7 +280,26 @@ def test_runtime_packager_emits_one_closed_root_and_checksum(tmp_path: Path) -> output = tmp_path / "Vibecrafted_RuntimePack_fixture.tar.gz" result = subprocess.run( - ["bash", str(PACKAGER), "--app", str(app), "--output", str(output)], + [ + "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-arm64", + "--architecture", + "arm64", + ], cwd=REPO_ROOT, capture_output=True, text=True, @@ -190,6 +316,7 @@ def test_runtime_packager_emits_one_closed_root_and_checksum(tmp_path: Path) -> assert "VibecraftedRuntime/bin/vc-terminal" in names assert "VibecraftedRuntime/bin/vc-frame" in names assert "VibecraftedRuntime/libexec/vc-frame" in names + assert "VibecraftedRuntime/runtime-pack-provenance.json" in names assert not any( member.issym() or member.islnk() for member in archive.getmembers() ) @@ -227,47 +354,7 @@ def test_signed_archive_bootstraps_without_ambient_python_and_cleans_temp( payload = tmp_path / "source/VibecraftedRuntime" capture = tmp_path / "argv" _fake_runtime_payload(payload, capture) - archive = tmp_path / "Vibecrafted_RuntimePack_fixture.tar.gz" - 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, - ) + archive, public_key = _sealed_archive(tmp_path, payload) fake_bin = tmp_path / "fake-bin" fake_bin.mkdir() ambient_python = fake_bin / "python3" @@ -293,3 +380,51 @@ def test_signed_archive_bootstraps_without_ambient_python_and_cleans_temp( 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() diff --git a/tests/tui/test_unified_app_contract.py b/tests/tui/test_unified_app_contract.py index d6929cd8..a44efcdb 100644 --- a/tests/tui/test_unified_app_contract.py +++ b/tests/tui/test_unified_app_contract.py @@ -10,6 +10,7 @@ import struct import subprocess import sys +import tarfile import tempfile from collections.abc import Callable from contextlib import contextmanager @@ -18,6 +19,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 +202,38 @@ 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") + _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 +357,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 +419,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", @@ -551,6 +591,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 +642,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": { @@ -806,10 +876,19 @@ 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 'appendingPathComponent("scripts/vetcoders_install.py")' in delegate - assert '"runtime-install"' in delegate - assert '"runtime-uninstall"' in delegate - assert '"--payload-root", runtime.path' 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 ( + '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 diff --git a/vibecrafted-app/shell-agent/app/Vibecrafted/AppDelegate.swift b/vibecrafted-app/shell-agent/app/Vibecrafted/AppDelegate.swift index 7eec5213..f084ccfb 100644 --- a/vibecrafted-app/shell-agent/app/Vibecrafted/AppDelegate.swift +++ b/vibecrafted-app/shell-agent/app/Vibecrafted/AppDelegate.swift @@ -382,17 +382,46 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private func installCanonicalRuntime() throws -> CanonicalRuntimeInstall { let appRoot = Bundle.main.bundleURL - let runtime = appRoot.appendingPathComponent( - "Contents/Resources/runtime", isDirectory: true) + 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: "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 frameHelper = appRoot.appendingPathComponent("Contents/Helpers/vc-frame") - let output = try runRuntimeInstaller(arguments: [ - "runtime-install", - "--payload-root", runtime.path, + 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) @@ -407,32 +436,34 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } private func uninstallCanonicalRuntime() throws { - _ = try runRuntimeInstaller(arguments: ["runtime-uninstall"]) + _ = try runRuntimePackInstaller(arguments: ["--uninstall"]) } - private func runRuntimeInstaller(arguments: [String]) throws -> Data { - let runtime = Bundle.main.bundleURL.appendingPathComponent( - "Contents/Resources/runtime", isDirectory: true) - let python = runtime.appendingPathComponent("bin/python3") - let installer = runtime.appendingPathComponent("scripts/vetcoders_install.py") - for required in [python, installer] - where !FileManager.default.isExecutableFile(atPath: required.path) { + 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: 1, userInfo: [ NSLocalizedDescriptionKey: - "signed Runtime Pack installer entry is missing: \(required.path)" + "signed Runtime Pack bootstrap or trust root is missing" ]) } let process = Process() let output = Pipe() let errors = Pipe() - process.executableURL = python + 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 diff --git a/vibecrafted-core/tests/test_runtime_receipt.py b/vibecrafted-core/tests/test_runtime_receipt.py index be24e5f8..498a4ad3 100644 --- a/vibecrafted-core/tests/test_runtime_receipt.py +++ b/vibecrafted-core/tests/test_runtime_receipt.py @@ -28,6 +28,7 @@ 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" @@ -282,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"]) == 12 + assert len(manifest["hashes"]) == 13 assert ( pc.verify_installed_runtime_generation(generation, expected_entrypoint=deck) == manifest diff --git a/vibecrafted-core/vibecrafted_core/product_contract.py b/vibecrafted-core/vibecrafted_core/product_contract.py index d3cdac39..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" @@ -114,6 +127,7 @@ def is_canonical_release_dmg_name( 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", @@ -2297,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], *, @@ -2384,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) 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..3d3de0ce --- /dev/null +++ b/vibecrafted-core/vibecrafted_core/runtime_pack_contract.py @@ -0,0 +1,261 @@ +"""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" +SOURCE_PROVENANCE_SCHEMA = "vibecrafted.source-provenance.v2" +GIT_SHA = re.compile(r"[0-9a-f]{40}") +SHA256 = re.compile(r"[0-9a-f]{64}") + + +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 _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 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") + _source_provenance(payload_root, expected_revision=source_revision) + 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, +) -> 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" + ) + _source_provenance(payload_root, expected_revision=revisions["vibecrafted"]) + 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") + 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, + ) + 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, From d824630b987a1f209cc0eef3fbf87b36d94c5a79 Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 25 Aug 2026 11:11:53 +0200 Subject: [PATCH 25/46] [codex/interactive] fix(release): hydrate v4.3 distribution contract Project the 4.3.0 product version through packaged declarations and staged release metadata, with a drift gate in the unified product contract. Make Runtime Pack selection fail closed on version, platform, architecture, source, and donor identity. Keep source compilation explicit while the public bootstrap, App, and make install converge on the receipted Runtime Pack installer. Extend the canonical packager for preassembled Linux payloads and require macOS/Linux bootstrap contract jobs on every Portable Checks event. Authored-By: codex session_id: 01a03811-8d76-73d1-9192-e3435c6ac4db time: 2026-08-25T12:00:00+02:00 runtime: codex --- .github/workflows/portable.yml | 70 ++++------------ CHANGELOG.md | 20 +++++ Makefile | 15 ++-- README.md | 7 +- VERSION | 2 +- docs/QUICK_START.md | 8 +- install.sh | 61 ++++++++++++-- packaging/homebrew/Casks/vibecrafted-app.rb | 4 +- packaging/homebrew/Formula/vibecrafted.rb | 6 +- plugin.json | 2 +- scripts/build-vibecrafted-release.sh | 7 +- scripts/install-runtime-pack.sh | 38 ++++++++- scripts/package-runtime-pack.sh | 56 ++++++++----- scripts/version_bump.py | 79 +++++++++++++++++- tests/tui/test_install_bootstrap.py | 2 + tests/tui/test_makefile_installer_contract.py | 21 ++++- tests/tui/test_runtime_pack_cli.py | 83 ++++++++++++++++++- vibecrafted-app/Cargo.lock | 2 +- vibecrafted-core/pyproject.toml | 2 +- vibecrafted-core/vibecrafted_core/VERSION | 2 +- .../vibecrafted_core/runtime_pack_contract.py | 19 +++++ vibecrafted-mcp/pyproject.toml | 2 +- vibecrafted-mcp/vibecrafted_mcp/VERSION | 2 +- vibecrafted-server/Cargo.lock | 4 +- vibecrafted-server/control-core/Cargo.toml | 2 +- vibecrafted-server/web/Cargo.toml | 2 +- 26 files changed, 392 insertions(+), 126 deletions(-) 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/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 28362e84..25ed232d 100644 --- a/Makefile +++ b/Makefile @@ -36,7 +36,7 @@ if [ ! -d "$$stable_root/vibecrafted-core" ]; then \ fi endef -.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 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 +.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" @@ -164,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 \ @@ -196,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..."; \ @@ -274,15 +278,10 @@ endif # Headless entrypoint for install.sh (curl|bash). Mirrors the full # non-interactive install. Was previously undefined, so the piped # `curl ... | bash` path ran `make install-auto` as a silent no-op. -install-auto: install-source +install-auto: install install: - @if [ "$$(uname -s)" = "Darwin" ]; then \ - VIBECRAFTED_RUNTIME_PACK="$(RUNTIME_PACK)" bash "$(RUNTIME_PACK_INSTALLER)"; \ - else \ - printf 'Binary Runtime Pack is not published for %s yet; using the explicit source lane.\n' "$$(uname -s)"; \ - $(MAKE) --no-print-directory install-source; \ - fi + @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, diff --git a/README.md b/README.md index 63c1b02b..6694ba36 100644 --- a/README.md +++ b/README.md @@ -196,9 +196,10 @@ make install-source make help-dev # the full target surface ``` -Until Linux/WSL binary Runtime Packs are published, `make install` on those -platforms routes to this same explicit source lane instead of looking for a -Darwin artifact. +`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 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 fe1de279..98ba7430 100644 --- a/docs/QUICK_START.md +++ b/docs/QUICK_START.md @@ -93,10 +93,10 @@ Use `vibecrafted help` for the full operator surface. ## Developer checkout path -`make install` consumes a closed Runtime Pack. `make install-source` and -`make install-auto` are the explicit compiler/source lane used by the portable -carrier. On Linux/WSL, `make install` currently routes to that source lane until -a native binary Runtime Pack exists. A developer checkout also exposes the +`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/install.sh b/install.sh index 0e4c8ac4..fad5b1b8 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. @@ -1077,6 +1077,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 @@ -1111,6 +1113,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" @@ -1136,6 +1148,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" @@ -1157,20 +1172,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 @@ -1222,9 +1239,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 @@ -1236,6 +1257,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" @@ -1246,6 +1272,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`. @@ -1391,6 +1428,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-vibecrafted-release.sh b/scripts/build-vibecrafted-release.sh index a256f4b7..da215de6 100755 --- a/scripts/build-vibecrafted-release.sh +++ b/scripts/build-vibecrafted-release.sh @@ -66,8 +66,9 @@ 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-$(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}.tar.gz" +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" @@ -424,7 +425,7 @@ embed_runtime_pack() { --frame-revision "$(git_sha "$FRAME_REPO")" \ --version "$RUNTIME_VERSION" \ --platform "$RUNTIME_PACK_PLATFORM" \ - --architecture "$(uname -m | sed 's/^aarch64$/arm64/; s/^x86_64$/x64/')" + --architecture "$RUNTIME_PACK_ARCHITECTURE" /usr/bin/openssl dgst -sha256 -sign "$SIGNING_KEY" \ -out "$EMBEDDED_RUNTIME_PACK_SIGNATURE" "$EMBEDDED_RUNTIME_PACK" } diff --git a/scripts/install-runtime-pack.sh b/scripts/install-runtime-pack.sh index d9ba2490..fadb4aa2 100755 --- a/scripts/install-runtime-pack.sh +++ b/scripts/install-runtime-pack.sh @@ -16,6 +16,9 @@ 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 @@ -39,7 +42,7 @@ while (($#)); do verify_only="1" shift ;; - --app-root|--terminal-host|--frame-helper|--expected-source-revision|--expected-terminal-revision|--expected-frame-revision) + --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" ;; @@ -48,6 +51,9 @@ while (($#)); do --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 ;; @@ -66,6 +72,27 @@ 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 @@ -107,14 +134,14 @@ fi if [[ -z "$pack" ]]; then shopt -s nullglob - candidates=("$REPO_ROOT"/dist/Vibecrafted_RuntimePack_*.tar.gz) + 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 Runtime Pack found; set VIBECRAFTED_RUNTIME_PACK or run 'make runtime-pack'" + die "no ${expected_platform}/${expected_architecture} Runtime Pack found; set VIBECRAFTED_RUNTIME_PACK to the prebuilt release asset" fi fi @@ -197,6 +224,11 @@ contract_arguments=( && 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" diff --git a/scripts/package-runtime-pack.sh b/scripts/package-runtime-pack.sh index de5bbe55..49d83376 100755 --- a/scripts/package-runtime-pack.sh +++ b/scripts/package-runtime-pack.sh @@ -4,6 +4,7 @@ set -euo pipefail die() { printf 'Runtime Pack packaging failed: %s\n' "$*" >&2; exit 1; } app="" +payload_root="" output="" source_revision="" terminal_revision="" @@ -18,6 +19,11 @@ while (($#)); do 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" @@ -36,40 +42,52 @@ while (($#)); do shift 2 ;; --help|-h) - printf 'usage: %s --app --output --source-revision --terminal-revision --frame-revision --version --platform --architecture \n' "$0" + 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 app output source_revision terminal_revision frame_revision version platform architecture; do +for required_value in output source_revision terminal_revision frame_revision version platform architecture; do [[ -n "${!required_value}" ]] || die "--$required_value is required" done -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 [[ -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 command -v ditto >/dev/null 2>&1; then - /usr/bin/ditto "$runtime" "$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 - cp -R "$runtime/." "$root/" + payload_root="$(cd "$payload_root" 2>/dev/null && pwd -P)" \ + || die "cannot resolve payload root" + cp -R "$payload_root/." "$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" if find "$root" -type l -print -quit | grep -q .; then die "standalone Runtime Pack contains symlinks" diff --git a/scripts/version_bump.py b/scripts/version_bump.py index f25e066f..c4ce3f2e 100755 --- a/scripts/version_bump.py +++ b/scripts/version_bump.py @@ -40,6 +40,57 @@ Path("vibecrafted-app/Cargo.lock"): ("control-core",), } PLUGIN_MANIFEST_RELATIVE = Path("plugin.json") +RELEASE_TEXT_PROJECTIONS = { + 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 +285,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 +330,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 +359,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/tests/tui/test_install_bootstrap.py b/tests/tui/test_install_bootstrap.py index b12fb497..682d278b 100644 --- a/tests/tui/test_install_bootstrap.py +++ b/tests/tui/test_install_bootstrap.py @@ -359,6 +359,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_makefile_installer_contract.py b/tests/tui/test_makefile_installer_contract.py index d29d3ae8..44ca13b8 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] @@ -217,8 +232,8 @@ def test_makefile_keeps_install_as_terminal_first_front_door() -> None: )[0] assert 'VIBECRAFTED_RUNTIME_PACK="$(RUNTIME_PACK)"' in install_block assert 'bash "$(RUNTIME_PACK_INSTALLER)"' in install_block - assert 'if [ "$$(uname -s)" = "Darwin" ]' in install_block - assert "$(MAKE) --no-print-directory install-source" 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( @@ -238,7 +253,7 @@ def test_makefile_keeps_install_as_terminal_first_front_door() -> None: # The curl/portable source bootstrap remains explicit and cannot silently # select a host or App Runtime Pack. - assert "install-auto: install-source" in text + assert "install-auto: install" in text # setup-dev opens the uv meta-installer in advanced mode. Advanced is an # interactive surface, so it never carries the auto-approve `--yes`. diff --git a/tests/tui/test_runtime_pack_cli.py b/tests/tui/test_runtime_pack_cli.py index 576b3400..f0d8719e 100644 --- a/tests/tui/test_runtime_pack_cli.py +++ b/tests/tui/test_runtime_pack_cli.py @@ -71,7 +71,7 @@ def _sealed_archive( payload, carrier_basename=name, version=VERSION, - platform="darwin-arm64", + platform="darwin", architecture="arm64", source_revision=source_revision, terminal_revision=TERMINAL_SHA, @@ -124,7 +124,17 @@ def _run( *arguments: str, env: dict[str, str] | None = None ) -> subprocess.CompletedProcess[str]: return subprocess.run( - ["bash", str(INSTALLER), *arguments], + [ + "bash", + str(INSTALLER), + "--expected-version", + VERSION, + "--expected-platform", + "darwin", + "--expected-architecture", + "arm64", + *arguments, + ], cwd=REPO_ROOT, capture_output=True, text=True, @@ -296,7 +306,7 @@ def test_runtime_packager_emits_one_closed_root_and_checksum(tmp_path: Path) -> "--version", VERSION, "--platform", - "darwin-arm64", + "darwin", "--architecture", "arm64", ], @@ -328,6 +338,49 @@ def test_runtime_packager_emits_one_closed_root_and_checksum(tmp_path: Path) -> == expected ) + # The same canonical writer also accepts a release-built Linux payload; + # consumers never compile it. Missing helpers still fail through the + # common required-file contract below. + 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, linux.stderr + with tarfile.open(linux_output, "r:gz") as archive: + provenance = json.load( + archive.extractfile("VibecraftedRuntime/runtime-pack-provenance.json") + ) + assert provenance["platform"] == "linux" + assert provenance["architecture"] == "arm64" + def test_runtime_pack_archive_requires_release_signature(tmp_path: Path) -> None: archive = tmp_path / "Vibecrafted_RuntimePack_fixture.tar.gz" @@ -428,3 +481,27 @@ def test_signed_carrier_rejects_expected_donor_mismatch_before_installer( 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/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-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/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/runtime_pack_contract.py b/vibecrafted-core/vibecrafted_core/runtime_pack_contract.py index 3d3de0ce..b7899b40 100644 --- a/vibecrafted-core/vibecrafted_core/runtime_pack_contract.py +++ b/vibecrafted-core/vibecrafted_core/runtime_pack_contract.py @@ -143,6 +143,9 @@ def verify_provenance( 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 @@ -204,6 +207,16 @@ def verify_provenance( 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" + ) _source_provenance(payload_root, expected_revision=revisions["vibecrafted"]) observed = _payload_files(payload_root) if files != observed: @@ -233,6 +246,9 @@ def main(argv: list[str] | None = None) -> int: 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( @@ -252,6 +268,9 @@ def main(argv: list[str] | None = None) -> int: 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 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/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" From d8e0c2118f949b7069901b9a4784530a5e2c0071 Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 25 Aug 2026 12:00:38 +0200 Subject: [PATCH 26/46] [codex/interactive] feat(app): hydrate the H2a user front door Turns the native status item into an honest VC Server control surface backed by the installed service owner, and replaces the static Start Here donor with an actionable shipped pane for Agent Workspaces, Shell, Console, and help. Adds behavioral policy, staging, layout, supervisor-log, and deep-link tests while preserving Safe Quit and the single Runtime Pack ownership contract. Authored-By: codex session_id: 01a0383a-16d5-7a81-a6b7-12128487854d time: 2026-08-25T12:00:20+02:00 runtime: codex-app --- scripts/version_bump.py | 3 + scripts/vibecrafted | 3 +- tests/tui/test_native_server_menu.py | 130 ++++++++ tests/tui/test_release_contract.py | 4 +- tests/tui/test_unified_app_contract.py | 19 +- tests/tui/test_vc_frame_config.py | 8 +- .../app/Vibecrafted/AppDelegate.swift | 284 ++++++++++++------ .../app/Vibecrafted/NotificationManager.swift | 9 + .../app/Vibecrafted/ServerMenuPolicy.swift | 217 +++++++++++++ vibecrafted-app/shell-agent/app/project.yml | 3 +- .../tests/test_native_notifications.py | 7 + .../tests/test_server_supervisor.py | 36 +++ vibecrafted-core/tests/test_start_here.py | 105 +++++++ .../config/vc-frame/layouts/operator.kdl | 7 +- .../config/vc-frame/vc-start-here.py | 255 ++++++++++++++++ .../vibecrafted_core/deck/vibecrafted | 3 +- .../vibecrafted_core/server_supervisor.py | 20 +- .../vibecrafted_core/vc_frame_delivery.py | 1 + .../vibecrafted_core/vc_frame_staging.py | 2 +- 19 files changed, 1004 insertions(+), 112 deletions(-) create mode 100644 tests/tui/test_native_server_menu.py create mode 100644 vibecrafted-app/shell-agent/app/Vibecrafted/ServerMenuPolicy.swift create mode 100644 vibecrafted-core/tests/test_start_here.py create mode 100755 vibecrafted-core/vibecrafted_core/config/vc-frame/vc-start-here.py diff --git a/scripts/version_bump.py b/scripts/version_bump.py index c4ce3f2e..8821d36a 100755 --- a/scripts/version_bump.py +++ b/scripts/version_bump.py @@ -41,6 +41,9 @@ } 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 ), diff --git a/scripts/vibecrafted b/scripts/vibecrafted index 77d07724..f394b5ee 100755 --- a/scripts/vibecrafted +++ b/scripts/vibecrafted @@ -1083,7 +1083,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' @@ -1095,6 +1095,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' } 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_release_contract.py b/tests/tui/test_release_contract.py index 6062203f..993eee91 100644 --- a/tests/tui/test_release_contract.py +++ b/tests/tui/test_release_contract.py @@ -328,7 +328,7 @@ def test_builder_emits_the_canonical_versioned_dmg_and_checksum() -> None: 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}.tar.gz"' + '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 @@ -437,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 diff --git a/tests/tui/test_unified_app_contract.py b/tests/tui/test_unified_app_contract.py index a44efcdb..acd90a51 100644 --- a/tests/tui/test_unified_app_contract.py +++ b/tests/tui/test_unified_app_contract.py @@ -845,21 +845,26 @@ 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: "VC Console"' in delegate - assert 'withTitle: "VC Terminal"' 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"' in delegate - assert 'withTitle: "Help"' in delegate - assert 'withTitle: "Quit"' 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 'title: "Server: RESTARTING…"' in delegate - assert 'process.arguments = ["server", "service", "reconcile"]' 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 diff --git a/tests/tui/test_vc_frame_config.py b/tests/tui/test_vc_frame_config.py index 0b8a1167..d0c8dd91 100644 --- a/tests/tui/test_vc_frame_config.py +++ b/tests/tui/test_vc_frame_config.py @@ -162,7 +162,7 @@ def test_operator_layout_matches_vibecrafted_standard() -> None: assert 'tab name="Agents"' in payload assert 'tab name="Shell"' in payload assert 'tab name="voc"' in payload - assert 'guide_mode "mission-control"' 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 @@ -182,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/vibecrafted-app/shell-agent/app/Vibecrafted/AppDelegate.swift b/vibecrafted-app/shell-agent/app/Vibecrafted/AppDelegate.swift index f084ccfb..be742998 100644 --- a/vibecrafted-app/shell-agent/app/Vibecrafted/AppDelegate.swift +++ b/vibecrafted-app/shell-agent/app/Vibecrafted/AppDelegate.swift @@ -59,44 +59,14 @@ private struct CanonicalRuntimeInstall: Decodable { } } -private struct ServerSupervisorSnapshot: Decodable { - 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? - - enum CodingKeys: String, CodingKey { - case state - case lastError = "last_error" - case supervisorPID = "supervisor_pid" - case managedPair = "managed_pair" - } -} - -private enum TrayServerHealth { - case checking - case healthy - case degraded - case failed - case stopped - +extension TrayServerHealth { var color: NSColor { switch self { case .checking: return .systemGray case .healthy: return .systemGreen - case .degraded: return .systemOrange + case .transitioning: return .systemOrange case .failed: return .systemRed - case .stopped: return .systemGray + case .neutral: return .systemGray } } } @@ -120,11 +90,17 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { 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 @@ -524,7 +500,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { let menu = NSMenu() menu.delegate = self - let serverStatus = menu.addItem(withTitle: "Server: CHECKING…", action: nil, keyEquivalent: "") + let serverStatus = menu.addItem( + withTitle: "VC Server: CHECKING…", action: nil, keyEquivalent: "") serverStatus.isEnabled = false serverStatusMenuItem = serverStatus let serverDetail = menu.addItem( @@ -533,30 +510,46 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { serverDetailMenuItem = serverDetail menu.addItem(.separator()) let console = menu.addItem( - withTitle: "VC Console", action: #selector(openConsoleFromStatusItem), keyEquivalent: "") + withTitle: "Open VC Console", action: #selector(openConsoleFromStatusItem), keyEquivalent: "") console.target = self let terminal = menu.addItem( - withTitle: "VC Terminal", action: #selector(openTerminalFromStatusItem), + withTitle: "Open VC Terminal", action: #selector(openTerminalFromStatusItem), keyEquivalent: "") terminal.target = self - let restart = menu.addItem( - withTitle: "VC Server", action: #selector(restartServerFromStatusItem), - keyEquivalent: "") + 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: "About", + withTitle: "About Vibecrafted", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "") let help = menu.addItem( - withTitle: "Help", action: #selector(showStatusItemHelp), keyEquivalent: "") + withTitle: "Vibecrafted Help", action: #selector(showStatusItemHelp), keyEquivalent: "") help.target = self menu.addItem(.separator()) - let quit = menu.addItem(withTitle: "Quit", action: #selector(requestQuit), keyEquivalent: "q") + let quit = menu.addItem( + withTitle: "Quit Vibecrafted", action: #selector(requestQuit), keyEquivalent: "q") quit.target = self item.menu = menu statusItem = item @@ -596,10 +589,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } private func readSupervisorSnapshot() -> ServerSupervisorSnapshot? { - guard let url = supervisorStatusURL(), let data = try? Data(contentsOf: url) else { - return nil - } - return try? JSONDecoder().decode(ServerSupervisorSnapshot.self, from: data) + 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? { @@ -613,45 +608,65 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } private func refreshServerStatus() { - guard canonicalInstall != nil else { - applyServerStatus( - title: "Server: WAITING FOR RUNTIME", detail: "Runtime onboarding has not completed", - health: .checking) + guard let install = canonicalInstall, let environment = canonicalRuntimeEnvironment else { + applyServerMenuState( + deriveServerMenuState( + supervisorData: nil, serviceData: nil, actionInFlight: nil, runtimeReady: false)) return } - guard let snapshot = readSupervisorSnapshot() else { - applyServerStatus( - title: "Server: NOT INSTALLED", detail: "No supervisor status receipt", - health: .failed) + 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 state = snapshot.state.lowercased() - let pairHealthy = - snapshot.managedPair?.serverPID != nil && snapshot.managedPair?.guardianPID != nil - let health: TrayServerHealth - if state == "healthy" && pairHealthy { - health = .healthy - } else if state == "starting" || state == "stopping" { - health = .degraded - } else if state == "backoff" || state == "stop-failed" { - health = .failed - } else { - health = .stopped + 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)) } - let reason = conciseServerReason(snapshot.lastError) - let detail = reason ?? "Supervisor PID \(snapshot.supervisorPID.map(String.init) ?? "—")" - applyServerStatus( - title: "Server: \(snapshot.state.uppercased())", detail: detail, health: health) } - private func applyServerStatus(title: String, detail: String, health: TrayServerHealth) { - serverStatusMenuItem?.title = title - serverDetailMenuItem?.title = detail - serverDetailMenuItem?.isHidden = detail.isEmpty - restartServerMenuItem?.isEnabled = serverActionProcess?.isRunning != true - statusItem?.button?.image = statusIcon(health: health) - statusItem?.button?.toolTip = "Vibecrafted — \(title)" + 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() { @@ -671,10 +686,23 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { 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 restart the server before runtime onboarding completes") + reportWorkspaceLaunchFailure( + "Cannot \(action.rawValue) VC Server before runtime onboarding completes") return } let deck = install.root.appendingPathComponent("bin/vibecrafted") @@ -684,23 +712,101 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } let process = Process() + let output = Pipe() + let errors = Pipe() process.executableURL = deck - // Reconcile is the service-owner operation: it starts a stopped pair and - // replaces a stale supervisor generation without creating a second owner. - process.arguments = ["server", "service", "reconcile"] + process.arguments = serverActionArguments(for: action) process.environment = environment - process.standardOutput = FileHandle.nullDevice - process.standardError = FileHandle.nullDevice + 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 - applyServerStatus( - title: "Server: RESTARTING…", detail: "Reconciling the installed supervisor", - health: .degraded) + 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 restart the server" + alert.messageText = "Vibecrafted could not open VC Server logs" alert.informativeText = error.localizedDescription alert.runModal() } @@ -740,7 +846,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { alert.alertStyle = .informational alert.messageText = "Vibecrafted Help" alert.informativeText = - "The tray dot reports the local server: green is healthy, orange is transitioning, red needs attention. Open Console for live runs, or Server Diagnostics for the exact supervisor receipt." + "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() } @@ -780,7 +886,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // Application menu let appMenu = NSMenu() appMenu.addItem( - withTitle: "About", + withTitle: "About Vibecrafted", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "") appMenu.addItem(.separator()) appMenu.addItem( diff --git a/vibecrafted-app/shell-agent/app/Vibecrafted/NotificationManager.swift b/vibecrafted-app/shell-agent/app/Vibecrafted/NotificationManager.swift index 4c278383..3f4d228c 100644 --- a/vibecrafted-app/shell-agent/app/Vibecrafted/NotificationManager.swift +++ b/vibecrafted-app/shell-agent/app/Vibecrafted/NotificationManager.swift @@ -63,11 +63,20 @@ final class NotificationManager: NSObject, UNUserNotificationCenterDelegate, @un 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/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-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_server_supervisor.py b/vibecrafted-core/tests/test_server_supervisor.py index 700e6830..e4e7f72b 100644 --- a/vibecrafted-core/tests/test_server_supervisor.py +++ b/vibecrafted-core/tests/test_server_supervisor.py @@ -1435,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/vibecrafted_core/config/vc-frame/layouts/operator.kdl b/vibecrafted-core/vibecrafted_core/config/vc-frame/layouts/operator.kdl index 5de88b32..0262d068 100644 --- a/vibecrafted-core/vibecrafted_core/config/vc-frame/layouts/operator.kdl +++ b/vibecrafted-core/vibecrafted_core/config/vc-frame/layouts/operator.kdl @@ -37,11 +37,8 @@ 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" } } 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/deck/vibecrafted b/vibecrafted-core/vibecrafted_core/deck/vibecrafted index 77d07724..f394b5ee 100755 --- a/vibecrafted-core/vibecrafted_core/deck/vibecrafted +++ b/vibecrafted-core/vibecrafted_core/deck/vibecrafted @@ -1083,7 +1083,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' @@ -1095,6 +1095,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' } diff --git a/vibecrafted-core/vibecrafted_core/server_supervisor.py b/vibecrafted-core/vibecrafted_core/server_supervisor.py index c6ace71b..70d1ba63 100644 --- a/vibecrafted-core/vibecrafted_core/server_supervisor.py +++ b/vibecrafted-core/vibecrafted_core/server_supervisor.py @@ -2584,6 +2584,7 @@ def _build_parser() -> argparse.ArgumentParser: "start", "stop", "status", + "logs", "uninstall", ), ) @@ -2745,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() @@ -2768,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/vc_frame_delivery.py b/vibecrafted-core/vibecrafted_core/vc_frame_delivery.py index f44b43af..cffa7cb5 100644 --- a/vibecrafted-core/vibecrafted_core/vc_frame_delivery.py +++ b/vibecrafted-core/vibecrafted_core/vc_frame_delivery.py @@ -56,6 +56,7 @@ "vc-quick-cmd.sh", "vc-deck.sh", "vc-agent-workshop.py", + "vc-start-here.py", ) _CORE_VIEW_NAMES: tuple[str, ...] = ("config.kdl", "layouts", "themes") diff --git a/vibecrafted-core/vibecrafted_core/vc_frame_staging.py b/vibecrafted-core/vibecrafted_core/vc_frame_staging.py index a7ddb654..bbe427a3 100644 --- a/vibecrafted-core/vibecrafted_core/vc_frame_staging.py +++ b/vibecrafted-core/vibecrafted_core/vc_frame_staging.py @@ -18,7 +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"}) +_EXECUTABLE_CONFIG_NAMES = frozenset({"vc-agent-workshop.py", "vc-start-here.py"}) def resolve_pane_shell(path_env: str | None = None) -> str: From 323f93599f308e82e3c2460ac72328c410aa308f Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 25 Aug 2026 13:19:03 +0200 Subject: [PATCH 27/46] [codex/interactive] feat(runtime): hydrate native and worktree agent workspaces Route the exact interactive init surface through one portable spawn adapter that validates provider policy, prepares canonical worktrees, preserves parent workspace UUID attribution, and writes inspectable control-plane receipts before the Agent TTY takes over. Keep local native direct, make worktrees the recommended supported local mode, retain VM and cloud as fail-closed roadmap choices, and state the H2b2 supervision boundary in product copy. Add fail-first, collision, dirty/non-git, creation-failure, fake-provider exec, Runtime Pack, workshop, and launcher regression coverage. Authored-By: codex session_id: 01a03879-518f-7f80-bbd2-ad8325676c87 time: 2026-08-25T13:16:00+02:00 runtime: codex-app --- tests/tui/test_vibecrafted_launcher.py | 35 ++- vibecrafted-core/tests/test_agent_workshop.py | 21 ++ .../tests/test_provider_policy.py | 278 ++++++++++++++++- .../config/vc-frame/vc-agent-workshop.py | 56 +++- .../vibecrafted_core/dispatch/worktrees.py | 21 ++ .../runtime/shell/lib/operator.sh | 7 +- vibecrafted-core/vibecrafted_core/spawn.py | 285 +++++++++++++++++- 7 files changed, 666 insertions(+), 37 deletions(-) diff --git a/tests/tui/test_vibecrafted_launcher.py b/tests/tui/test_vibecrafted_launcher.py index 486bd36e..d46ce304 100644 --- a/tests/tui/test_vibecrafted_launcher.py +++ b/tests/tui/test_vibecrafted_launcher.py @@ -556,7 +556,10 @@ 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 --permission-mode bypassPermissions " in script_body + assert ( + "vibecrafted_core.spawn interactive-launch claude --runtime local-native " + "--permissions bypass --root" + ) in script_body assert "/vc-init" in script_body assert " -p " not in script_body @@ -604,25 +607,24 @@ def test_init_codex_uses_interactive_tab_without_exec_mode(tmp_path: Path) -> No 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 ( + "vibecrafted_core.spawn interactive-launch codex --runtime local-native " + "--permissions bypass --root" + ) in script_body assert "/vc-init" in script_body assert "codex exec" not in script_body @pytest.mark.parametrize( - ("agent", "command_needle"), + ("agent", "permissions"), [ - ("agy", "agy --dangerously-skip-permissions --add-dir . --prompt-interactive "), - ("junie", "junie --prompt="), - ( - "grok", - # Interactive TUI: positional prompt, NO --single (one-shot headless). - "grok --cwd . --permission-mode bypassPermissions --no-alt-screen ", - ), + ("agy", "bypass"), + ("junie", "auto"), + ("grok", "bypass"), ], ) def test_init_fleet_agents_resolve_skill_init_helpers( - agent: str, command_needle: str, tmp_path: Path + agent: str, permissions: str, tmp_path: Path ) -> None: """Regression: vibecrafted init must not fail with Missing helper -skill-init. Fleet surface is five agents; wrappers for only @@ -677,7 +679,10 @@ def test_init_fleet_agents_resolve_skill_init_helpers( command_script = _spawned_command_script(payload) script_body = command_script.read_text(encoding="utf-8") - assert command_needle in script_body + assert ( + f"vibecrafted_core.spawn interactive-launch {agent} --runtime local-native " + f"--permissions {permissions} --root" + ) in script_body assert "/vc-init" in script_body if agent == "grok": assert " --single " not in script_body @@ -728,9 +733,9 @@ def test_init_grok_is_interactive_tui_not_single_shot(tmp_path: Path) -> None: capture_file.read_text(encoding="utf-8") ).read_text(encoding="utf-8") assert ( - "grok --cwd . --permission-mode bypassPermissions --no-alt-screen" - in script_body - ) + "vibecrafted_core.spawn interactive-launch grok --runtime local-native " + "--permissions bypass --root" + ) in script_body assert "/vc-init" in script_body assert "--single" not in script_body assert "streaming-json" not in script_body diff --git a/vibecrafted-core/tests/test_agent_workshop.py b/vibecrafted-core/tests/test_agent_workshop.py index 3e21c10e..ed2e1f48 100644 --- a/vibecrafted-core/tests/test_agent_workshop.py +++ b/vibecrafted-core/tests/test_agent_workshop.py @@ -73,6 +73,27 @@ def test_launcher_refuses_unsupported_policy_instead_of_approximating() -> None: 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 "H2b2 supervision is not configured" 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: diff --git a/vibecrafted-core/tests/test_provider_policy.py b/vibecrafted-core/tests/test_provider_policy.py index ffd8b3b9..448f6ba9 100644 --- a/vibecrafted-core/tests/test_provider_policy.py +++ b/vibecrafted-core/tests/test_provider_policy.py @@ -2,8 +2,12 @@ import io import itertools +import json +import os import shlex +import subprocess import sys +from pathlib import Path import pytest from vibecrafted_core.spawn import ( @@ -12,11 +16,251 @@ POLICY_PROVIDERS, RUNTIME_POLICIES, interactive_policy_command, + interactive_workspace_command, main, + prepare_interactive_workspace_launch, resolve_provider_policy, ) +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="codex", + runtime="local-worktrees", + permissions="read-only", + selected_root=repo, + prompt="/vc-init", + run_id="init-260825-123102-00001", + executable=sys.executable, + worker_pid=4242, + ) + + 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="codex", + runtime="local-native", + permissions="read-only", + selected_root=repo, + prompt="/vc-init", + run_id="init-260825-123102-00002", + executable=sys.executable, + ) + + 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="codex", + runtime="local-worktrees", + permissions="read-only", + selected_root=repo, + prompt="/vc-init", + run_id=f"init-260825-123102-0000{index}", + executable=sys.executable, + ) + 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 / "codex" + provider.write_text( + "#!/usr/bin/env python3\n" + "import json, os, pathlib, sys\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", + "codex", + "--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["liveness"] == "active" + + +@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="codex", + runtime="local-worktrees", + permissions="read-only", + selected_root=repo, + prompt="/vc-init", + run_id="init-260825-123102-00005", + executable=sys.executable, + ) + + 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="codex", + runtime="local-worktrees", + permissions="read-only", + selected_root=repo, + prompt="/vc-init", + run_id="init-260825-123102-00006", + executable=sys.executable, + ) + + 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) @@ -31,13 +275,15 @@ def test_every_runtime_permission_provider_mode_cell_is_explicit() -> None: @pytest.mark.parametrize("provider", POLICY_PROVIDERS) -def test_non_native_runtimes_are_honestly_unsupported(provider: str) -> None: - assert ( - "worktree cut contract" - in resolve_provider_policy( - provider, "local-worktrees", "bypass", "interactive" - ).reason - ) +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 @@ -95,6 +341,24 @@ def test_interactive_command_uses_contract_flags() -> None: interactive_policy_command("codex", "/vc-init", "local-native", "accept-edits") +def test_interactive_workspace_command_wraps_the_exact_init_route( + tmp_path: Path, +) -> None: + command = interactive_workspace_command( + "codex", "/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 + + def test_policy_cli_reads_the_same_contract(monkeypatch, capsys) -> None: monkeypatch.setattr(sys, "stdin", io.StringIO("/vc-init")) 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 index 38a2949f..8f69f14b 100755 --- a/vibecrafted-core/vibecrafted_core/config/vc-frame/vc-agent-workshop.py +++ b/vibecrafted-core/vibecrafted_core/config/vc-frame/vc-agent-workshop.py @@ -30,6 +30,21 @@ # 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; H2b2 supervision is not configured.", + ), + "local-vm": ( + "Coming in H2b3; disabled until selected-workspace container launch and live proof exist.", + "", + ), + "cloud-soon": ("Coming soon; disabled.", ""), +} def launch_argv( @@ -60,6 +75,10 @@ def launch_argv( "--permissions", permissions, ] + if runtime != "local-native": + raise ValueError( + "worktree resume supervision belongs to H2b2 and is not configured yet" + ) return ["vibecrafted", "resume", agent] @@ -174,7 +193,7 @@ def __init__(self, window: curses.window, *, mode: str) -> None: self.row = 0 self.agent = 2 # codex is the least surprising neutral default here self.ritual = 0 - self.runtime = 0 + self.runtime = 1 # safe recommended local default when the provider supports it self.permissions = 0 self.path = str(Path.cwd()) self.error = "" @@ -188,6 +207,7 @@ def configure(self) -> None: curses.cbreak() self.window.keypad(True) self.window.timeout(500) + self._normalize_runtime_choice() try: curses.mousemask(curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION) except curses.error: @@ -278,7 +298,7 @@ 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 - 10) // 2) + top = max(1, (height - 12) // 2) inner = max(20, card_width - 4) _safe_addstr( self.window, @@ -351,17 +371,32 @@ def draw_launcher(self) -> None: for name in PERMISSION_POLICIES ), ) + runtime_help = RUNTIME_HELP[RUNTIME_POLICIES[self.runtime]] _safe_addstr( self.window, top + 6, left, - "│ Enter = interactive TTY on this Agents tab".ljust(card_width - 1) + "│", + ("│ " + _clip(runtime_help[0], inner)).ljust(card_width - 1) + "│", curses.A_DIM, ) _safe_addstr( self.window, top + 7, left, + ("│ " + _clip(runtime_help[1], inner)).ljust(card_width - 1) + "│", + curses.A_DIM, + ) + _safe_addstr( + self.window, + top + 8, + left, + "│ Enter = interactive TTY on this Agents tab".ljust(card_width - 1) + "│", + curses.A_DIM, + ) + _safe_addstr( + self.window, + top + 9, + left, "└─ ↑/↓ row · ←/→ choice · type path · Enter launch · Esc cancel " + "─" * max(0, card_width - 67) + "┘", @@ -373,14 +408,14 @@ def draw_launcher(self) -> None: ] _safe_addstr( self.window, - top + 8, + top + 10, left, "Unavailable — " + " · ".join(unavailable), curses.A_DIM, ) if self.error: _safe_addstr( - self.window, min(height - 1, top + 9), left, self.error, curses.A_BOLD + self.window, min(height - 1, top + 11), left, self.error, curses.A_BOLD ) def handle_home_key(self, key: int) -> None: @@ -409,6 +444,7 @@ def handle_launcher_key(self, key: int) -> None: 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() elif self.row == 1: self.ritual = (self.ritual + delta) % len(RITUALS) @@ -446,6 +482,16 @@ def _cycle_permissions(self, delta: int) -> None: 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] 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/runtime/shell/lib/operator.sh b/vibecrafted-core/vibecrafted_core/runtime/shell/lib/operator.sh index 8c6ffcb7..d17cdaed 100644 --- a/vibecrafted-core/vibecrafted_core/runtime/shell/lib/operator.sh +++ b/vibecrafted-core/vibecrafted_core/runtime/shell/lib/operator.sh @@ -64,10 +64,11 @@ _vetcoders_init_command_text() { py="${python_spec%%$'\t'*}" import_root="${python_spec#*$'\t'}" if [[ -n "$import_root" ]]; then - printf '%s' "$init_prompt" | PYTHONPATH="$import_root${PYTHONPATH:+:$PYTHONPATH}" \ - "$py" -m vibecrafted_core.spawn policy-command "$tool" --runtime "$policy_runtime" --permissions "$permissions" + 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" --root "${_vetcoders_contract_root:-$(_vetcoders_repo_root)}" else - printf '%s' "$init_prompt" | "$py" -m vibecrafted_core.spawn policy-command "$tool" --runtime "$policy_runtime" --permissions "$permissions" + printf '%s' "$init_prompt" | "$py" -m vibecrafted_core.spawn interactive-command "$tool" --runtime "$policy_runtime" --permissions "$permissions" --root "${_vetcoders_contract_root:-$(_vetcoders_repo_root)}" fi } diff --git a/vibecrafted-core/vibecrafted_core/spawn.py b/vibecrafted-core/vibecrafted_core/spawn.py index 3eb93ce2..35028785 100644 --- a/vibecrafted-core/vibecrafted_core/spawn.py +++ b/vibecrafted-core/vibecrafted_core/spawn.py @@ -20,7 +20,7 @@ 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, @@ -67,6 +67,22 @@ def as_dict(self) -> dict[str, Any]: } +@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] + + _PERMISSION_CONTRACT: dict[str, dict[str, tuple[tuple[str, ...], str] | None]] = { "codex": { "bypass": ( @@ -176,14 +192,14 @@ def resolve_provider_policy( False, reason="Docker/Colima may be present, but canonical init has no VM entrypoint", ) - if runtime == "local-worktrees": + if runtime == "local-worktrees" and mode != "interactive": return ProviderPolicy( provider, runtime, permissions, mode, False, - reason="git dispatch manages worktrees, but canonical init has no worktree cut contract", + reason="local worktrees are available only for interactive Agent Workspaces", ) cell = _PERMISSION_CONTRACT[provider][permissions] if cell is None: @@ -232,11 +248,15 @@ def runtime_policy_capabilities(provider: str) -> dict[str, dict[str, Any]]: "reason": "" if provider_found else f"{provider} executable not found", }, "local-worktrees": { - "available": False, + "available": provider_found and worktree_substrate, "substrate": worktree_substrate, - "reason": "no canonical init worktree cut" - if worktree_substrate - else "git/dispatch manage_worktrees unavailable", + "reason": "" + if provider_found and worktree_substrate + else ( + f"{provider} executable not found" + if not provider_found + else "git/dispatch manage_worktrees unavailable" + ), }, "local-vm": { "available": False, @@ -275,6 +295,214 @@ def interactive_policy_command( return ["grok", "--cwd", ".", *flags, "--no-alt-screen", prompt] +def interactive_workspace_command( + provider: str, + prompt: str, + runtime: str, + permissions: str, + root: str | os.PathLike[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) + command = [ + sys.executable, + "-m", + "vibecrafted_core.spawn", + "interactive-launch", + provider, + "--runtime", + runtime, + "--permissions", + permissions, + "--root", + str(Path(root).expanduser().resolve()), + "--prompt", + prompt, + ] + 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 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, +) -> 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") + + 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" + receipt: dict[str, Any] = { + "created_at": now_iso, + "updated_at": now_iso, + "status": "active", + "run_id": effective_run_id, + "agent": provider, + "skill": "init", + "mode": "interactive", + "runtime_policy": runtime, + "permission_policy": permissions, + "root": str(effective), + "parent_root": str(parent), + "effective_worktree_path": str(effective) if geometry else "", + "input": str(prompt_path), + "worker_pid": int(worker_pid or os.getpid()), + "launcher_pid": int(worker_pid or os.getpid()), + "liveness": "active", + "executable": str(Path(resolved_executable).expanduser()), + **identity.to_meta_fields(), + } + if geometry is not None: + receipt.update( + branch=geometry.branch, + baseline_sha=geometry.baseline_sha, + artifact_path=geometry.artifact_path, + ) + _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, + ) + + +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 "" + + +def launch_interactive_workspace( + provider: str, + prompt: str, + runtime: str, + permissions: str, + root: str | os.PathLike[str], +) -> None: + """Prepare one workspace, then replace this process with the Agent TTY.""" + command = interactive_policy_command(provider, prompt, runtime, permissions) + child_env = os.environ.copy() + resolved = _resolve_agent_command(provider, command, child_env) + launch = prepare_interactive_workspace_launch( + provider=provider, + runtime=runtime, + permissions=permissions, + selected_root=root, + prompt=prompt, + executable=resolved[0], + worker_pid=os.getpid(), + ) + 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, + } + ) + try: + os.chdir(launch.effective_root) + os.execvpe(resolved[0], resolved, child_env) + except OSError as exc: + receipt = { + **launch.receipt, + "updated_at": dt.datetime.now(dt.timezone.utc).isoformat(), + "status": "failed", + "liveness": "failed", + "error": str(exc), + } + _write_meta(launch.meta_path, receipt) + append_event( + "lifecycle:failed", + launch.run_id, + "interactive Agent Workspace exec failed", + {**receipt, "meta": str(launch.meta_path)}, + ) + raise + + ANSI_PATTERN = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]") SESSION_PATTERNS = ( re.compile( @@ -1862,6 +2090,29 @@ def _build_parser() -> argparse.ArgumentParser: 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("--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) sub.add_parser( "policy-matrix", help="Print the complete provider policy matrix as JSON." ) @@ -1919,6 +2170,26 @@ def main(argv: Sequence[str] | None = None) -> int: 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 + ) + except ValueError as exc: + print(str(exc), file=sys.stderr) + return 2 + print(shlex.join(command)) + return 0 + if args.command == "interactive-launch": + try: + launch_interactive_workspace( + args.provider, args.prompt, args.runtime, args.permissions, args.root + ) + except (OSError, RuntimeError, ValueError) as exc: + print(str(exc), file=sys.stderr) + return 2 + return 0 if args.command == "policy-matrix": print( json.dumps( From 4fdfdc340d11aadaf0455671ee72bb0b9a576695 Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 25 Aug 2026 14:22:34 +0200 Subject: [PATCH 28/46] [codex/headless] fix(runtime): own interactive provider lifecycle Keeps Vibecrafted alive as the provider child owner, records ordered terminal truth, and makes Python and Rust projections agree on owner/provider liveness. Authored-By: codex session_id: 01a038b6-e3d1-7c61-b256-60d6166e56aa time: 2026-08-25T14:22:34+02:00 runtime: headless --- .../tests/fixtures/interactive_provider.py | 30 ++ vibecrafted-core/tests/test_control_plane.py | 43 +++ .../tests/test_provider_policy.py | 261 +++++++++++++++++- .../vibecrafted_core/control_plane.py | 46 ++- vibecrafted-core/vibecrafted_core/spawn.py | 233 +++++++++++++--- vibecrafted-server/control-core/src/model.rs | 8 + vibecrafted-server/control-core/src/read.rs | 84 +++++- 7 files changed, 665 insertions(+), 40 deletions(-) create mode 100755 vibecrafted-core/tests/fixtures/interactive_provider.py diff --git a/vibecrafted-core/tests/fixtures/interactive_provider.py b/vibecrafted-core/tests/fixtures/interactive_provider.py new file mode 100755 index 00000000..f46d54e8 --- /dev/null +++ b/vibecrafted-core/tests/fixtures/interactive_provider.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Deterministic local provider used by installed-wheel lifecycle smoke tests.""" + +from __future__ import annotations + +import json +import os +import time +from pathlib import Path + +capture = 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"], + "parent_root": os.environ["VIBECRAFTED_PARENT_ROOT"], + "effective_root": os.environ["VIBECRAFTED_EFFECTIVE_ROOT"], + } + ) + + "\n", + encoding="utf-8", +) +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_control_plane.py b/vibecrafted-core/tests/test_control_plane.py index 9c2cfe29..eb5b48da 100644 --- a/vibecrafted-core/tests/test_control_plane.py +++ b/vibecrafted-core/tests/test_control_plane.py @@ -710,6 +710,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_provider_policy.py b/vibecrafted-core/tests/test_provider_policy.py index 448f6ba9..1938e6d3 100644 --- a/vibecrafted-core/tests/test_provider_policy.py +++ b/vibecrafted-core/tests/test_provider_policy.py @@ -4,9 +4,12 @@ import itertools import json import os +import pty import shlex +import signal import subprocess import sys +import time from pathlib import Path import pytest @@ -17,12 +20,58 @@ RUNTIME_POLICIES, interactive_policy_command, interactive_workspace_command, + launch_interactive_workspace, main, prepare_interactive_workspace_launch, resolve_provider_policy, ) +def _fake_interactive_provider(path: Path) -> None: + path.write_text( + "#!/usr/bin/env python3\n" + "import json, os, pathlib, sys, time\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" + "}) + '\\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 _interactive_argv(repo: Path) -> list[str]: + return [ + sys.executable, + "-m", + "vibecrafted_core.spawn", + "interactive-launch", + "codex", + "--runtime", + "local-native", + "--permissions", + "read-only", + "--root", + str(repo), + "--prompt", + "/vc-init", + ] + + def _git(repo: Path, *args: str) -> str: completed = subprocess.run( ["git", *args], cwd=repo, check=True, capture_output=True, text=True @@ -202,7 +251,217 @@ def test_interactive_worktree_execs_provider_inside_canonical_checkout( assert meta["effective_worktree_path"] == str(effective) assert meta["runtime_policy"] == "local-worktrees" assert meta["permission_policy"] == "read-only" - assert meta["liveness"] == "active" + 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 / "codex") + 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"], + ) + 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" + 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) + + +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 / "codex") + 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" + + +@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 / "codex") + 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 / "codex" + _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( + "codex", "/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"]) diff --git a/vibecrafted-core/vibecrafted_core/control_plane.py b/vibecrafted-core/vibecrafted_core/control_plane.py index 20c6657b..c4279746 100644 --- a/vibecrafted-core/vibecrafted_core/control_plane.py +++ b/vibecrafted-core/vibecrafted_core/control_plane.py @@ -1073,6 +1073,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 +1387,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 +1835,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", @@ -2065,6 +2103,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 +2126,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/spawn.py b/vibecrafted-core/vibecrafted_core/spawn.py index 35028785..0d50b4cd 100644 --- a/vibecrafted-core/vibecrafted_core/spawn.py +++ b/vibecrafted-core/vibecrafted_core/spawn.py @@ -9,6 +9,7 @@ import os import re import shlex +import signal import subprocess import sys import threading @@ -81,6 +82,8 @@ class InteractiveWorkspaceLaunch: vibecrafted_session_id: str meta_path: Path receipt: dict[str, Any] + 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]] = { @@ -340,6 +343,7 @@ def prepare_interactive_workspace_launch( run_id: str | None = None, executable: str | None = None, worker_pid: int | None = None, + publish: bool = True, ) -> InteractiveWorkspaceLaunch: """Resolve identity/root and publish truth only after launch preparation succeeds.""" decision = resolve_provider_policy(provider, runtime, permissions, "interactive") @@ -382,10 +386,12 @@ def prepare_interactive_workspace_launch( 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, - "status": "active", + "started_at": now_iso, + "status": "active" if publish else "prepared", "run_id": effective_run_id, "agent": provider, "skill": "init", @@ -396,25 +402,30 @@ def prepare_interactive_workspace_launch( "parent_root": str(parent), "effective_worktree_path": str(effective) if geometry else "", "input": str(prompt_path), - "worker_pid": int(worker_pid or os.getpid()), - "launcher_pid": int(worker_pid or os.getpid()), - "liveness": "active", + "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, ) - _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}, - ) + 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: @@ -439,6 +450,8 @@ def prepare_interactive_workspace_launch( vibecrafted_session_id=identity.vibecrafted_session_id, meta_path=meta_path, receipt=receipt, + worktree_manager=manager, + worktree_geometry=geometry, ) @@ -455,8 +468,8 @@ def launch_interactive_workspace( runtime: str, permissions: str, root: str | os.PathLike[str], -) -> None: - """Prepare one workspace, then replace this process with the Agent TTY.""" +) -> int: + """Own one provider child while preserving the inherited interactive TTY.""" command = interactive_policy_command(provider, prompt, runtime, permissions) child_env = os.environ.copy() resolved = _resolve_agent_command(provider, command, child_env) @@ -467,7 +480,7 @@ def launch_interactive_workspace( selected_root=root, prompt=prompt, executable=resolved[0], - worker_pid=os.getpid(), + publish=False, ) child_env.update( { @@ -483,24 +496,183 @@ def launch_interactive_workspace( } ) try: - os.chdir(launch.effective_root) - os.execvpe(resolved[0], resolved, child_env) - except OSError as exc: - receipt = { - **launch.receipt, - "updated_at": dt.datetime.now(dt.timezone.utc).isoformat(), - "status": "failed", - "liveness": "failed", - "error": str(exc), - } + # 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, + } + 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) - append_event( - "lifecycle:failed", - launch.run_id, - "interactive Agent Workspace exec failed", - {**receipt, "meta": str(launch.meta_path)}, + 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 + try: + provider_returncode = child.wait() + except Exception as exc: + if child.poll() is None: + child.terminate() + 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 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 _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 _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]") @@ -2183,13 +2355,12 @@ def main(argv: Sequence[str] | None = None) -> int: return 0 if args.command == "interactive-launch": try: - launch_interactive_workspace( + return launch_interactive_workspace( args.provider, args.prompt, args.runtime, args.permissions, args.root ) except (OSError, RuntimeError, ValueError) as exc: print(str(exc), file=sys.stderr) return 2 - return 0 if args.command == "policy-matrix": print( json.dumps( diff --git a/vibecrafted-server/control-core/src/model.rs b/vibecrafted-server/control-core/src/model.rs index de55b379..303c3fc2 100644 --- a/vibecrafted-server/control-core/src/model.rs +++ b/vibecrafted-server/control-core/src/model.rs @@ -520,6 +520,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, @@ -1048,6 +1051,7 @@ impl LifecycleRun { session_id: String::new(), current_loop: None, total_loops: None, + owner_pid: None, worker_pid: None, worker_pgid: None, worker_alive: None, @@ -1298,6 +1302,8 @@ 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, @@ -1438,6 +1444,7 @@ 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, @@ -1533,6 +1540,7 @@ 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), diff --git a/vibecrafted-server/control-core/src/read.rs b/vibecrafted-server/control-core/src/read.rs index 59113135..5f6e934e 100644 --- a/vibecrafted-server/control-core/src/read.rs +++ b/vibecrafted-server/control-core/src/read.rs @@ -460,6 +460,7 @@ 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"), @@ -776,6 +777,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 +819,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 +1031,11 @@ fn normalize_event(event: &Event, existing: Option<&RunStatus>, now: DateTime, now: DateTime 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 +1366,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 +1454,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,6 +1621,7 @@ 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, @@ -1721,6 +1751,7 @@ 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, @@ -2150,6 +2181,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!( From ee905f90b9cf646d0f7f6414d3bcc60df3956379 Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 25 Aug 2026 15:26:54 +0200 Subject: [PATCH 29/46] [codex/headless] feat(runtime): enforce measured interactive quota Add a typed safe/unlimited/custom quota contract, exact Claude session transcript attribution, monotonic receipt growth, and quota-exhausted terminal truth. Keep unsupported provider cells fail-closed and prove the installed-wheel lifecycle. Authored-By: codex session_id: 01a038f2-7718-79d1-875c-f92f50ba2327 time: 2026-08-25T15:27:52+02:00 runtime: interactive --- scripts/vibecrafted | 5 +- tests/tui/test_vibecrafted_launcher.py | 103 ++-- .../tests/delivery/test_delivery_e2e.py | 8 + .../tests/fixtures/interactive_provider.py | 65 +++ .../tests/test_provider_policy.py | 350 ++++++++++++- vibecrafted-core/tests/test_run_triage.py | 1 + .../vibecrafted_core/control_plane.py | 1 + .../vibecrafted_core/deck/vibecrafted | 5 +- .../vibecrafted_core/lifecycle_runner.py | 7 +- .../vibecrafted_core/run_triage.py | 4 + .../runtime/shell/lib/operator.sh | 5 +- .../runtime/shell/lib/operator_entrypoints.sh | 2 +- .../runtime/shell/lib/prompts.sh | 6 + vibecrafted-core/vibecrafted_core/spawn.py | 462 +++++++++++++++++- vibecrafted-server/control-core/src/model.rs | 13 +- 15 files changed, 929 insertions(+), 108 deletions(-) diff --git a/scripts/vibecrafted b/scripts/vibecrafted index f394b5ee..3edf95af 100755 --- a/scripts/vibecrafted +++ b/scripts/vibecrafted @@ -994,7 +994,7 @@ 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] [--policy-runtime local-native|local-worktrees|local-vm|cloud-soon] [--permissions bypass|auto|accept-edits|read-only] [--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] [--prompt ""]\n' printf ' vc-init [claude|codex|agy|junie|grok]\n' printf '\n' printf '%bRuntime:%b\n' "$_bold" "$_reset" @@ -1002,6 +1002,9 @@ cmd_init_help() { 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 '\n' printf '%bExamples:%b\n' "$_bold" "$_reset" printf ' vibecrafted init claude\n' diff --git a/tests/tui/test_vibecrafted_launcher.py b/tests/tui/test_vibecrafted_launcher.py index d46ce304..86be232c 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"', ] ) @@ -535,6 +537,7 @@ 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) @@ -558,13 +561,15 @@ def test_init_claude_uses_interactive_tab_without_print_mode( script_body = command_script.read_text(encoding="utf-8") assert ( "vibecrafted_core.spawn interactive-launch claude --runtime local-native " - "--permissions bypass --root" + "--permissions bypass --token-budget safe --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_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" @@ -586,50 +591,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 ( - "vibecrafted_core.spawn interactive-launch codex --runtime local-native " - "--permissions bypass --root" - ) 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", "permissions"), - [ - ("agy", "bypass"), - ("junie", "auto"), - ("grok", "bypass"), - ], + "agent", + ["agy", "junie", "grok"], ) -def test_init_fleet_agents_resolve_skill_init_helpers( - agent: str, permissions: 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" @@ -651,6 +641,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) @@ -664,37 +655,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 ( - f"vibecrafted_core.spawn interactive-launch {agent} --runtime local-native " - f"--permissions {permissions} --root" - ) 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" @@ -716,6 +684,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) @@ -728,17 +697,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 ( - "vibecrafted_core.spawn interactive-launch grok --runtime local-native " - "--permissions bypass --root" - ) 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-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/fixtures/interactive_provider.py b/vibecrafted-core/tests/fixtures/interactive_provider.py index f46d54e8..fa521cbc 100755 --- a/vibecrafted-core/tests/fixtures/interactive_provider.py +++ b/vibecrafted-core/tests/fixtures/interactive_provider.py @@ -5,9 +5,18 @@ 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] capture = Path(os.environ["SMOKE_CAPTURE"]) capture.write_text( json.dumps( @@ -17,6 +26,7 @@ "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"], } @@ -24,6 +34,61 @@ + "\n", encoding="utf-8", ) +usage_tokens = int(os.environ.get("SMOKE_USAGE_TOKENS", "0")) +transcript: Path | None = None +if usage_tokens: + 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) diff --git a/vibecrafted-core/tests/test_provider_policy.py b/vibecrafted-core/tests/test_provider_policy.py index 1938e6d3..f459f930 100644 --- a/vibecrafted-core/tests/test_provider_policy.py +++ b/vibecrafted-core/tests/test_provider_policy.py @@ -18,12 +18,16 @@ POLICY_MODES, POLICY_PROVIDERS, RUNTIME_POLICIES, + ProviderUsageCapability, + _ClaudeTranscriptUsage, interactive_policy_command, interactive_workspace_command, launch_interactive_workspace, main, prepare_interactive_workspace_launch, resolve_provider_policy, + resolve_provider_usage_capability, + resolve_quota_policy, ) @@ -31,11 +35,19 @@ 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" "}) + '\\n', encoding='utf-8')\n" "if os.environ.get('SMOKE_BLOCK') == '1':\n" " while True: time.sleep(0.05)\n" @@ -60,7 +72,7 @@ def _interactive_argv(repo: Path) -> list[str]: "-m", "vibecrafted_core.spawn", "interactive-launch", - "codex", + "claude", "--runtime", "local-native", "--permissions", @@ -72,6 +84,15 @@ def _interactive_argv(repo: Path) -> list[str]: ] +_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 @@ -100,7 +121,7 @@ def test_interactive_worktree_launch_uses_canonical_owner_and_parent_identity( baseline = _repo(repo) launch = prepare_interactive_workspace_launch( - provider="codex", + provider="claude", runtime="local-worktrees", permissions="read-only", selected_root=repo, @@ -108,6 +129,9 @@ def test_interactive_worktree_launch_uses_canonical_owner_and_parent_identity( 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) @@ -132,13 +156,16 @@ def test_local_native_keeps_selected_checkout_and_creates_no_worktree( baseline = _repo(repo) launch = prepare_interactive_workspace_launch( - provider="codex", + 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()) @@ -156,13 +183,16 @@ def test_two_interactive_worktree_launches_cannot_collide( launches = [ prepare_interactive_workspace_launch( - provider="codex", + 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) ] @@ -183,10 +213,16 @@ def test_interactive_worktree_execs_provider_inside_canonical_checkout( capture = tmp_path / "provider.json" fake_bin = tmp_path / "bin" fake_bin.mkdir() - provider = fake_bin / "codex" + 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" @@ -213,7 +249,7 @@ def test_interactive_worktree_execs_provider_inside_canonical_checkout( "-m", "vibecrafted_core.spawn", "interactive-launch", - "codex", + "claude", "--runtime", "local-worktrees", "--permissions", @@ -266,7 +302,7 @@ def test_interactive_owner_keeps_distinct_live_provider_on_inherited_tty( capture = tmp_path / "provider.json" fake_bin = tmp_path / "bin" fake_bin.mkdir() - _fake_interactive_provider(fake_bin / "codex") + _fake_interactive_provider(fake_bin / "claude") env = os.environ.copy() env.pop("PYTHONPATH", None) env.update( @@ -303,6 +339,14 @@ def test_interactive_owner_keeps_distinct_live_provider_on_inherited_tty( 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"] os.kill(meta["owner_pid"], 0) os.kill(meta["worker_pid"], 0) owner.send_signal(signal.SIGTERM) @@ -319,6 +363,91 @@ def test_interactive_owner_keeps_distinct_live_provider_on_inherited_tty( os.close(master_fd) +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: @@ -328,7 +457,7 @@ def test_interactive_nonzero_exit_terminalizes_and_returns_provider_status( capture = tmp_path / "provider.json" fake_bin = tmp_path / "bin" fake_bin.mkdir() - _fake_interactive_provider(fake_bin / "codex") + _fake_interactive_provider(fake_bin / "claude") env = os.environ.copy() env.pop("PYTHONPATH", None) env.update( @@ -357,6 +486,7 @@ def test_interactive_nonzero_exit_terminalizes_and_returns_provider_status( assert meta["liveness"] == "terminal" assert meta["exit_code"] == 7 assert meta["terminal_reason"] == "provider_exit_nonzero" + assert meta["status"] != "quota_exhausted" @pytest.mark.parametrize( @@ -372,7 +502,7 @@ def test_interactive_owner_signal_terminalizes_without_surviving_child( capture = tmp_path / "provider.json" fake_bin = tmp_path / "bin" fake_bin.mkdir() - _fake_interactive_provider(fake_bin / "codex") + _fake_interactive_provider(fake_bin / "claude") env = os.environ.copy() env.pop("PYTHONPATH", None) env.update( @@ -422,7 +552,7 @@ def test_child_spawn_failure_publishes_no_false_active_and_removes_clean_worktre _repo(repo) fake_bin = tmp_path / "bin" fake_bin.mkdir() - provider = fake_bin / "codex" + provider = fake_bin / "claude" _fake_interactive_provider(provider) monkeypatch.setenv("VIBECRAFTED_HOME", str(home)) monkeypatch.setenv("VIBECRAFTED_RUNTIME_BIN", str(fake_bin)) @@ -450,7 +580,7 @@ def break_once(*_args: object, **_kwargs: object): with pytest.raises(OSError, match="spawn denied"): launch_interactive_workspace( - "codex", "/vc-init", "local-worktrees", "read-only", repo + "claude", "/vc-init", "local-worktrees", "read-only", repo ) meta_path = next((home / "control_plane/runtime_runs").glob("*/meta.json")) @@ -479,13 +609,16 @@ def test_invalid_worktree_parent_fails_before_runtime_truth( with pytest.raises((ValueError, RuntimeError), match="git repository|clean"): prepare_interactive_workspace_launch( - provider="codex", + 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() @@ -508,13 +641,16 @@ def test_worktree_creation_failure_has_no_accepted_or_spawned_truth( with pytest.raises(RuntimeError, match="create failed"): prepare_interactive_workspace_launch( - provider="codex", + 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() @@ -601,10 +737,14 @@ def test_interactive_command_uses_contract_flags() -> None: def test_interactive_workspace_command_wraps_the_exact_init_route( - tmp_path: Path, + 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( - "codex", "/vc-init", "local-worktrees", "read-only", tmp_path + "claude", "/vc-init", "local-worktrees", "read-only", tmp_path ) assert command[:4] == [ @@ -618,6 +758,186 @@ def test_interactive_workspace_command_wraps_the_exact_init_route( 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")) 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/vibecrafted_core/control_plane.py b/vibecrafted-core/vibecrafted_core/control_plane.py index c4279746..5358979c 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", } diff --git a/vibecrafted-core/vibecrafted_core/deck/vibecrafted b/vibecrafted-core/vibecrafted_core/deck/vibecrafted index f394b5ee..3edf95af 100755 --- a/vibecrafted-core/vibecrafted_core/deck/vibecrafted +++ b/vibecrafted-core/vibecrafted_core/deck/vibecrafted @@ -994,7 +994,7 @@ 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] [--policy-runtime local-native|local-worktrees|local-vm|cloud-soon] [--permissions bypass|auto|accept-edits|read-only] [--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] [--prompt ""]\n' printf ' vc-init [claude|codex|agy|junie|grok]\n' printf '\n' printf '%bRuntime:%b\n' "$_bold" "$_reset" @@ -1002,6 +1002,9 @@ cmd_init_help() { 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 '\n' printf '%bExamples:%b\n' "$_bold" "$_reset" printf ' vibecrafted init claude\n' diff --git a/vibecrafted-core/vibecrafted_core/lifecycle_runner.py b/vibecrafted-core/vibecrafted_core/lifecycle_runner.py index 4dc472ae..cf908b12 100644 --- a/vibecrafted-core/vibecrafted_core/lifecycle_runner.py +++ b/vibecrafted-core/vibecrafted_core/lifecycle_runner.py @@ -71,13 +71,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 ) 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 d17cdaed..125e0ae2 100644 --- a/vibecrafted-core/vibecrafted_core/runtime/shell/lib/operator.sh +++ b/vibecrafted-core/vibecrafted_core/runtime/shell/lib/operator.sh @@ -59,6 +59,7 @@ _vetcoders_init_command_text() { local init_prompt="$2" local policy_runtime="${3:-local-native}" local permissions="${4:-bypass}" + local token_budget="${5:-safe}" local python_spec py import_root python_spec="$(_vetcoders_core_python_spec)" || return 1 py="${python_spec%%$'\t'*}" @@ -66,9 +67,9 @@ _vetcoders_init_command_text() { 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" --root "${_vetcoders_contract_root:-$(_vetcoders_repo_root)}" + "$py" -m vibecrafted_core.spawn interactive-command "$tool" --runtime "$policy_runtime" --permissions "$permissions" --token-budget "$token_budget" --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" --root "${_vetcoders_contract_root:-$(_vetcoders_repo_root)}" + printf '%s' "$init_prompt" | "$py" -m vibecrafted_core.spawn interactive-command "$tool" --runtime "$policy_runtime" --permissions "$permissions" --token-budget "$token_budget" --root "${_vetcoders_contract_root:-$(_vetcoders_repo_root)}" fi } 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 8221270f..bd0e9480 100644 --- a/vibecrafted-core/vibecrafted_core/runtime/shell/lib/operator_entrypoints.sh +++ b/vibecrafted-core/vibecrafted_core/runtime/shell/lib/operator_entrypoints.sh @@ -24,7 +24,7 @@ _vetcoders_skill_init() { init_prompt="$(_vetcoders_compose_init_prompt "$_vetcoders_contract_prompt" "$_vetcoders_contract_file")" || 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")" || return 1 + command_text="$(_vetcoders_init_command_text "$tool" "$init_prompt" "${_vetcoders_contract_policy_runtime:-local-native}" "$permissions" "${_vetcoders_contract_token_budget:-safe}")" || 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 diff --git a/vibecrafted-core/vibecrafted_core/runtime/shell/lib/prompts.sh b/vibecrafted-core/vibecrafted_core/runtime/shell/lib/prompts.sh index f0f5cc33..4d817e20 100644 --- a/vibecrafted-core/vibecrafted_core/runtime/shell/lib/prompts.sh +++ b/vibecrafted-core/vibecrafted_core/runtime/shell/lib/prompts.sh @@ -35,6 +35,7 @@ _vetcoders_contract_reset() { _vetcoders_contract_runtime="" _vetcoders_contract_policy_runtime="" _vetcoders_contract_permissions="" + _vetcoders_contract_token_budget="" _vetcoders_contract_root="" _vetcoders_contract_tail="" _vetcoders_contract_dry_run="" @@ -131,6 +132,11 @@ _vetcoders_parse_contract() { [[ $# -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" + ;; --root) shift [[ $# -gt 0 ]] || { echo "Missing value for --root" >&2; return 1; } diff --git a/vibecrafted-core/vibecrafted_core/spawn.py b/vibecrafted-core/vibecrafted_core/spawn.py index 0d50b4cd..8ef541c6 100644 --- a/vibecrafted-core/vibecrafted_core/spawn.py +++ b/vibecrafted-core/vibecrafted_core/spawn.py @@ -13,8 +13,11 @@ 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 @@ -39,6 +42,9 @@ 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 @dataclass(frozen=True) @@ -68,6 +74,45 @@ def as_dict(self) -> dict[str, Any]: } +@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 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.""" @@ -82,6 +127,9 @@ class InteractiveWorkspaceLaunch: 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) @@ -162,6 +210,122 @@ class InteractiveWorkspaceLaunch: } +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 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, @@ -233,7 +397,9 @@ def resolve_provider_policy( def runtime_policy_capabilities(provider: str) -> dict[str, dict[str, Any]]: """Report host substrate separately from canonical-launcher availability.""" - provider_found = which(provider, path=agent_tool_search_path()) is not None + 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 @@ -247,18 +413,32 @@ def runtime_policy_capabilities(provider: str) -> dict[str, dict[str, Any]]: vm_found = which("docker") is not None or which("colima") is not None return { "local-native": { - "available": provider_found, - "reason": "" if provider_found else f"{provider} executable not found", + "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, + "available": provider_found and worktree_substrate and usage.supported, "substrate": worktree_substrate, + "usage_capability": usage.as_dict(), "reason": "" - if provider_found and worktree_substrate + 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" + else ( + "git/dispatch manage_worktrees unavailable" + if not worktree_substrate + else usage.reason + ) ), }, "local-vm": { @@ -273,7 +453,12 @@ def runtime_policy_capabilities(provider: str) -> dict[str, dict[str, Any]]: def interactive_policy_command( - provider: str, prompt: str, runtime: str, permissions: str + provider: str, + prompt: str, + runtime: str, + permissions: str, + *, + provider_session_id: str | None = None, ) -> list[str]: """Build one interactive argv from the canonical policy decision.""" decision = resolve_provider_policy(provider, runtime, permissions, "interactive") @@ -281,7 +466,10 @@ def interactive_policy_command( raise ValueError(decision.reason) flags = list(decision.flags) if provider == "claude": - return ["claude", "--verbose", *flags, prompt] + session_flags = ( + ["--session-id", provider_session_id] if provider_session_id else [] + ) + return ["claude", "--verbose", *flags, *session_flags, prompt] if provider == "codex": return ["codex", *flags, prompt] if provider == "agy": @@ -304,11 +492,16 @@ def interactive_workspace_command( runtime: str, permissions: str, root: str | os.PathLike[str], + token_budget: str | int | None = None, ) -> 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) command = [ sys.executable, "-m", @@ -319,6 +512,8 @@ def interactive_workspace_command( runtime, "--permissions", permissions, + "--token-budget", + quota.selection, "--root", str(Path(root).expanduser().resolve()), "--prompt", @@ -344,6 +539,9 @@ def prepare_interactive_workspace_launch( 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, ) -> InteractiveWorkspaceLaunch: """Resolve identity/root and publish truth only after launch preparation succeeds.""" decision = resolve_provider_policy(provider, runtime, permissions, "interactive") @@ -355,6 +553,17 @@ def prepare_interactive_workspace_launch( 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, @@ -398,6 +607,23 @@ def prepare_interactive_workspace_launch( "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, "root": str(effective), "parent_root": str(parent), "effective_worktree_path": str(effective) if geometry else "", @@ -450,6 +676,9 @@ def prepare_interactive_workspace_launch( 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, ) @@ -462,17 +691,163 @@ def _git_output(root: Path, *args: str) -> str: 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, ) -> int: """Own one provider child while preserving the inherited interactive TTY.""" - command = interactive_policy_command(provider, prompt, runtime, permissions) + quota = resolve_quota_policy(token_budget, runtime=runtime) child_env = os.environ.copy() + provider_session_id = str(uuid.uuid4()) + command = interactive_policy_command( + provider, + prompt, + runtime, + permissions, + provider_session_id=provider_session_id, + ) 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, @@ -481,7 +856,20 @@ def launch_interactive_workspace( prompt=prompt, executable=resolved[0], publish=False, + quota_policy=quota, + usage_capability=capability, + provider_session_id=provider_session_id, ) + 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, @@ -574,12 +962,42 @@ def _forward_owner_signal(signum: int, _frame: Any) -> None: # 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: - provider_returncode = child.wait() + 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() - child.wait() + try: + child.wait(timeout=5) + except subprocess.TimeoutExpired: + child.kill() + child.wait() _terminalize_interactive_launch( launch, receipt, @@ -598,7 +1016,11 @@ def _forward_owner_signal(signum: int, _frame: Any) -> None: if provider_returncode < 0 else provider_returncode ) - if received_signal: + 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}" @@ -2272,6 +2694,7 @@ def _build_parser() -> argparse.ArgumentParser: interactive_command.add_argument( "--permissions", choices=PERMISSION_POLICIES, default="bypass" ) + interactive_command.add_argument("--token-budget", default="safe") interactive_command.add_argument("--root", required=True) interactive_launch = sub.add_parser( "interactive-launch", help="Prepare and exec an interactive Agent Workspace." @@ -2285,6 +2708,7 @@ def _build_parser() -> argparse.ArgumentParser: ) interactive_launch.add_argument("--root", required=True) interactive_launch.add_argument("--prompt", required=True) + interactive_launch.add_argument("--token-budget", default="safe") sub.add_parser( "policy-matrix", help="Print the complete provider policy matrix as JSON." ) @@ -2346,7 +2770,12 @@ def main(argv: Sequence[str] | None = None) -> int: prompt = sys.stdin.read() try: command = interactive_workspace_command( - args.provider, prompt, args.runtime, args.permissions, args.root + args.provider, + prompt, + args.runtime, + args.permissions, + args.root, + args.token_budget, ) except ValueError as exc: print(str(exc), file=sys.stderr) @@ -2356,7 +2785,12 @@ def main(argv: Sequence[str] | None = None) -> int: if args.command == "interactive-launch": try: return launch_interactive_workspace( - args.provider, args.prompt, args.runtime, args.permissions, args.root + args.provider, + args.prompt, + args.runtime, + args.permissions, + args.root, + args.token_budget, ) except (OSError, RuntimeError, ValueError) as exc: print(str(exc), file=sys.stderr) diff --git a/vibecrafted-server/control-core/src/model.rs b/vibecrafted-server/control-core/src/model.rs index 303c3fc2..8b30b6e0 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", ]; @@ -787,7 +790,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", @@ -800,6 +803,7 @@ fn is_unsettled_settlement_terminal(run: &RunStatus) -> bool { "contract_failed", "recovery_required", "timed_out", + "quota_exhausted", "gc", "ghost", "stalled", @@ -1592,6 +1596,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); From e3342b65b8de8138202a9831cdf1af78b8120b49 Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 25 Aug 2026 16:47:40 +0200 Subject: [PATCH 30/46] [codex/headless] feat(runtime): add distinct operator agent supervision Add typed none/auto/claude launch policy, reserve a bidirectional Operator Agent relationship before ACTIVE, and keep the existing interactive owner as the sole process signal/reap authority. Project structured truth through Python, Rust, shell, and Agent Workshop surfaces with deterministic settlement, quota, signal, spawn-failure, and worktree cleanup coverage. Authored-By: codex session_id: 01a03920-422f-7261-be4d-bf86755ec27a time: 2026-08-25T16:47:27+02:00 runtime: interactive --- tests/tui/test_vibecrafted_launcher.py | 4 +- .../tests/fixtures/interactive_provider.py | 69 +- vibecrafted-core/tests/test_agent_workshop.py | 4 +- vibecrafted-core/tests/test_control_plane.py | 33 + .../tests/test_provider_policy.py | 598 +++++++++++++ .../config/vc-frame/vc-agent-workshop.py | 12 +- .../vibecrafted_core/control_plane.py | 7 + .../runtime/shell/lib/operator.sh | 5 +- .../runtime/shell/lib/operator_entrypoints.sh | 2 +- .../runtime/shell/lib/prompts.sh | 6 + vibecrafted-core/vibecrafted_core/spawn.py | 841 ++++++++++++++++++ vibecrafted-server/control-core/src/model.rs | 113 ++- vibecrafted-server/control-core/src/read.rs | 42 +- 13 files changed, 1723 insertions(+), 13 deletions(-) diff --git a/tests/tui/test_vibecrafted_launcher.py b/tests/tui/test_vibecrafted_launcher.py index 86be232c..9d9e98da 100644 --- a/tests/tui/test_vibecrafted_launcher.py +++ b/tests/tui/test_vibecrafted_launcher.py @@ -544,7 +544,7 @@ def test_init_claude_uses_interactive_tab_without_print_mode( 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, @@ -561,7 +561,7 @@ def test_init_claude_uses_interactive_tab_without_print_mode( script_body = command_script.read_text(encoding="utf-8") assert ( "vibecrafted_core.spawn interactive-launch claude --runtime local-native " - "--permissions bypass --token-budget safe --root" + "--permissions bypass --token-budget safe --operator auto --root" ) in script_body assert "/vc-init" in script_body assert " -p " not in script_body diff --git a/vibecrafted-core/tests/fixtures/interactive_provider.py b/vibecrafted-core/tests/fixtures/interactive_provider.py index fa521cbc..263bd367 100755 --- a/vibecrafted-core/tests/fixtures/interactive_provider.py +++ b/vibecrafted-core/tests/fixtures/interactive_provider.py @@ -17,7 +17,13 @@ raise SystemExit(0) session_id = sys.argv[sys.argv.index("--session-id") + 1] -capture = Path(os.environ["SMOKE_CAPTURE"]) +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( { @@ -29,14 +35,73 @@ "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: +if usage_tokens and role == "agent": transcript = ( Path(os.environ["CLAUDE_CONFIG_DIR"]) / "projects" diff --git a/vibecrafted-core/tests/test_agent_workshop.py b/vibecrafted-core/tests/test_agent_workshop.py index ed2e1f48..22fce211 100644 --- a/vibecrafted-core/tests/test_agent_workshop.py +++ b/vibecrafted-core/tests/test_agent_workshop.py @@ -56,6 +56,8 @@ def test_launcher_commands_keep_interactive_agent_in_this_panel() -> None: "local-native", "--permissions", "bypass", + "--operator", + "none", ] assert workshop.launch_argv("claude", "resume") == [ "vibecrafted", @@ -90,7 +92,7 @@ def test_runtime_help_preserves_product_truth_and_recommended_default() -> None: 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 "H2b2 supervision is not configured" 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 diff --git a/vibecrafted-core/tests/test_control_plane.py b/vibecrafted-core/tests/test_control_plane.py index eb5b48da..28ad74eb 100644 --- a/vibecrafted-core/tests/test_control_plane.py +++ b/vibecrafted-core/tests/test_control_plane.py @@ -217,6 +217,39 @@ 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", + }, + } + ), + 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" + + def test_operator_stop_is_sticky_over_late_failure_and_artifact_aliases( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: diff --git a/vibecrafted-core/tests/test_provider_policy.py b/vibecrafted-core/tests/test_provider_policy.py index f459f930..af97252e 100644 --- a/vibecrafted-core/tests/test_provider_policy.py +++ b/vibecrafted-core/tests/test_provider_policy.py @@ -20,11 +20,13 @@ RUNTIME_POLICIES, ProviderUsageCapability, _ClaudeTranscriptUsage, + _validate_operator_protocol_event, interactive_policy_command, interactive_workspace_command, launch_interactive_workspace, main, prepare_interactive_workspace_launch, + resolve_operator_agent_policy, resolve_provider_policy, resolve_provider_usage_capability, resolve_quota_policy, @@ -66,6 +68,41 @@ def _wait_for(path: Path, *, timeout: float = 5.0) -> None: 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, @@ -363,6 +400,567 @@ def test_interactive_owner_keeps_distinct_live_provider_on_inherited_tty( 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 ( + 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: 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 index 8f69f14b..dee8706e 100755 --- a/vibecrafted-core/vibecrafted_core/config/vc-frame/vc-agent-workshop.py +++ b/vibecrafted-core/vibecrafted_core/config/vc-frame/vc-agent-workshop.py @@ -20,8 +20,10 @@ from typing import Any from vibecrafted_core.spawn import ( + OPERATOR_POLICIES, PERMISSION_POLICIES, RUNTIME_POLICIES, + resolve_operator_agent_policy, resolve_provider_policy, runtime_policy_capabilities, ) @@ -37,7 +39,7 @@ ), "local-worktrees": ( "Safe recommended local default; one canonical worktree per Agent launch.", - "Maximum local concurrency; unattended pipelines require an Operator Agent; H2b2 supervision is not configured.", + "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.", @@ -52,6 +54,7 @@ def launch_argv( ritual: str, runtime: str = "local-native", permissions: str = "bypass", + operator: str = "none", ) -> list[str]: """Return the one canonical interactive command for a launcher choice.""" if agent not in AGENTS: @@ -62,6 +65,11 @@ def launch_argv( 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. return [ @@ -74,6 +82,8 @@ def launch_argv( runtime, "--permissions", permissions, + "--operator", + operator_decision.selection, ] if runtime != "local-native": raise ValueError( diff --git a/vibecrafted-core/vibecrafted_core/control_plane.py b/vibecrafted-core/vibecrafted_core/control_plane.py index 5358979c..1f15f738 100644 --- a/vibecrafted-core/vibecrafted_core/control_plane.py +++ b/vibecrafted-core/vibecrafted_core/control_plane.py @@ -1863,6 +1863,13 @@ 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", ): if key in payload and payload.get(key) not in (None, ""): extra[key] = payload[key] diff --git a/vibecrafted-core/vibecrafted_core/runtime/shell/lib/operator.sh b/vibecrafted-core/vibecrafted_core/runtime/shell/lib/operator.sh index 125e0ae2..b8a3a098 100644 --- a/vibecrafted-core/vibecrafted_core/runtime/shell/lib/operator.sh +++ b/vibecrafted-core/vibecrafted_core/runtime/shell/lib/operator.sh @@ -60,6 +60,7 @@ _vetcoders_init_command_text() { local policy_runtime="${3:-local-native}" local permissions="${4:-bypass}" local token_budget="${5:-safe}" + local operator_policy="${6:-none}" local python_spec py import_root python_spec="$(_vetcoders_core_python_spec)" || return 1 py="${python_spec%%$'\t'*}" @@ -67,9 +68,9 @@ _vetcoders_init_command_text() { 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" --root "${_vetcoders_contract_root:-$(_vetcoders_repo_root)}" + "$py" -m vibecrafted_core.spawn interactive-command "$tool" --runtime "$policy_runtime" --permissions "$permissions" --token-budget "$token_budget" --operator "$operator_policy" --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" --root "${_vetcoders_contract_root:-$(_vetcoders_repo_root)}" + printf '%s' "$init_prompt" | "$py" -m vibecrafted_core.spawn interactive-command "$tool" --runtime "$policy_runtime" --permissions "$permissions" --token-budget "$token_budget" --operator "$operator_policy" --root "${_vetcoders_contract_root:-$(_vetcoders_repo_root)}" fi } 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 bd0e9480..5c915780 100644 --- a/vibecrafted-core/vibecrafted_core/runtime/shell/lib/operator_entrypoints.sh +++ b/vibecrafted-core/vibecrafted_core/runtime/shell/lib/operator_entrypoints.sh @@ -24,7 +24,7 @@ _vetcoders_skill_init() { init_prompt="$(_vetcoders_compose_init_prompt "$_vetcoders_contract_prompt" "$_vetcoders_contract_file")" || 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}")" || return 1 + 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}")" || 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 diff --git a/vibecrafted-core/vibecrafted_core/runtime/shell/lib/prompts.sh b/vibecrafted-core/vibecrafted_core/runtime/shell/lib/prompts.sh index 4d817e20..efc05048 100644 --- a/vibecrafted-core/vibecrafted_core/runtime/shell/lib/prompts.sh +++ b/vibecrafted-core/vibecrafted_core/runtime/shell/lib/prompts.sh @@ -36,6 +36,7 @@ _vetcoders_contract_reset() { _vetcoders_contract_policy_runtime="" _vetcoders_contract_permissions="" _vetcoders_contract_token_budget="" + _vetcoders_contract_operator="" _vetcoders_contract_root="" _vetcoders_contract_tail="" _vetcoders_contract_dry_run="" @@ -137,6 +138,11 @@ _vetcoders_parse_contract() { [[ $# -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" + ;; --root) shift [[ $# -gt 0 ]] || { echo "Missing value for --root" >&2; return 1; } diff --git a/vibecrafted-core/vibecrafted_core/spawn.py b/vibecrafted-core/vibecrafted_core/spawn.py index 8ef541c6..80d01964 100644 --- a/vibecrafted-core/vibecrafted_core/spawn.py +++ b/vibecrafted-core/vibecrafted_core/spawn.py @@ -45,6 +45,10 @@ QUOTA_PRESET_TOKENS = 250_000 QUOTA_MAX_TOKENS = 10_000_000 QUOTA_EXHAUSTED_EXIT_CODE = 75 +OPERATOR_POLICIES = ("none", "auto", "claude") +USER_OBSERVED_WARNING = ( + "User-observed only: no Operator Agent is supervising this Agent Workspace." +) @dataclass(frozen=True) @@ -92,6 +96,29 @@ def as_dict(self) -> dict[str, Any]: } +@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 ProviderUsageCapability: """Provider-specific proof that live usage can be attributed to one child.""" @@ -493,6 +520,7 @@ def interactive_workspace_command( permissions: str, root: str | os.PathLike[str], token_budget: str | int | None = None, + operator: str = "none", ) -> list[str]: """Build the portable wrapper argv used by the exact ``init`` route.""" decision = resolve_provider_policy(provider, runtime, permissions, "interactive") @@ -502,6 +530,9 @@ def interactive_workspace_command( 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) command = [ sys.executable, "-m", @@ -514,6 +545,8 @@ def interactive_workspace_command( permissions, "--token-budget", quota.selection, + "--operator", + operator_policy.selection, "--root", str(Path(root).expanduser().resolve()), "--prompt", @@ -528,6 +561,51 @@ def interactive_workspace_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, @@ -832,8 +910,22 @@ def launch_interactive_workspace( permissions: str, root: str | os.PathLike[str], token_budget: str | int | None = None, + operator: str = "none", ) -> 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) + if operator_policy.provider is not None: + return _launch_supervised_interactive_workspace( + provider=provider, + prompt=prompt, + runtime=runtime, + permissions=permissions, + root=root, + token_budget=token_budget, + operator_policy=operator_policy, + ) quota = resolve_quota_policy(token_budget, runtime=runtime) child_env = os.environ.copy() provider_session_id = str(uuid.uuid4()) @@ -881,6 +973,8 @@ def launch_interactive_workspace( "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 "", } ) try: @@ -915,6 +1009,14 @@ def launch_interactive_workspace( "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] = {} @@ -1048,6 +1150,725 @@ def _forward_owner_signal(signum: int, _frame: Any) -> 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, +) -> 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) + base_env = os.environ.copy() + child_session_id = str(uuid.uuid4()) + child_command = interactive_policy_command( + provider, + prompt, + runtime, + permissions, + provider_session_id=child_session_id, + ) + 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, + executable=child_resolved[0], + publish=False, + quota_policy=quota, + usage_capability=child_capability, + provider_session_id=child_session_id, + ) + 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, + ) + 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), + } + 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, + } + 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: @@ -1060,6 +1881,18 @@ def _cleanup_unspawned_interactive_launch(launch: InteractiveWorkspaceLaunch) -> 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], @@ -2695,6 +3528,9 @@ def _build_parser() -> argparse.ArgumentParser: "--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("--root", required=True) interactive_launch = sub.add_parser( "interactive-launch", help="Prepare and exec an interactive Agent Workspace." @@ -2709,6 +3545,9 @@ def _build_parser() -> argparse.ArgumentParser: 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" + ) sub.add_parser( "policy-matrix", help="Print the complete provider policy matrix as JSON." ) @@ -2776,6 +3615,7 @@ def main(argv: Sequence[str] | None = None) -> int: args.permissions, args.root, args.token_budget, + args.operator, ) except ValueError as exc: print(str(exc), file=sys.stderr) @@ -2791,6 +3631,7 @@ def main(argv: Sequence[str] | None = None) -> int: args.permissions, args.root, args.token_budget, + args.operator, ) except (OSError, RuntimeError, ValueError) as exc: print(str(exc), file=sys.stderr) diff --git a/vibecrafted-server/control-core/src/model.rs b/vibecrafted-server/control-core/src/model.rs index 8b30b6e0..d399d8bb 100644 --- a/vibecrafted-server/control-core/src/model.rs +++ b/vibecrafted-server/control-core/src/model.rs @@ -489,6 +489,52 @@ 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, +} + +#[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, @@ -536,6 +582,9 @@ 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, #[serde(default, skip_serializing_if = "is_false")] pub recovery_required: bool, #[serde(default, skip_serializing_if = "String::is_empty")] @@ -1059,6 +1108,7 @@ impl LifecycleRun { worker_pid: None, worker_pgid: None, worker_alive: None, + operator_agent: None, recovery_required: false, stop_reason: String::new(), agent_session_id: String::new(), @@ -1314,6 +1364,18 @@ pub struct AgentMeta { #[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 supervision: Option, + #[serde(default)] + pub stop_actor_run_id: String, + #[serde(default)] pub recovery_required: bool, #[serde(default)] pub stop_reason: String, @@ -1452,6 +1514,19 @@ impl AgentMeta { 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, + }, recovery_required: self.recovery_required, stop_reason: self.stop_reason.clone(), agent_session_id: self.agent_session_id.clone(), @@ -1548,6 +1623,10 @@ pub fn merge_status(existing: Option, incoming: RunStatus) -> RunStat 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()), 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), @@ -1583,11 +1662,43 @@ 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" + } + }); + 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"); + } + #[test] fn delivery_axes_mid_flight_are_not_failed() { let axes = delivery_axes_for_receipt("promise", None, None, None); diff --git a/vibecrafted-server/control-core/src/read.rs b/vibecrafted-server/control-core/src/read.rs index 5f6e934e..a493fb12 100644 --- a/vibecrafted-server/control-core/src/read.rs +++ b/vibecrafted-server/control-core/src/read.rs @@ -23,9 +23,10 @@ use chrono::{DateTime, Utc}; use crate::events::EventStream; use crate::model::{ AgentMeta, DeliverySealRef, Event, FINAL_STATES, Health, LifecycleRun, LifecycleRunSummary, - 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, + OperatorAgentPolicyProjection, OperatorAgentProjection, RECENT_RUN_LIMIT, RUN_STALL_SECONDS, + RunStatus, SettlementBoard, SettlementTui, 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. @@ -464,6 +465,7 @@ impl ControlPlane { 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), recovery_required: boolean("recovery_required").unwrap_or(false), stop_reason: value("stop_reason"), agent_session_id: value("agent_session_id"), @@ -1129,6 +1131,7 @@ fn normalize_event(event: &Event, existing: Option<&RunStatus>, 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 event_has_test_provenance(event: &Event, home: &Path) -> bool { if is_pytest_temp_path(home) { return false; @@ -1625,6 +1659,7 @@ fn normalize_lock(path: &Path, now: DateTime) -> Option { worker_pid: None, worker_pgid: None, worker_alive: None, + operator_agent: None, recovery_required: false, stop_reason: String::new(), agent_session_id: String::new(), @@ -1755,6 +1790,7 @@ impl MarblesState { worker_pid: None, worker_pgid: None, worker_alive: None, + operator_agent: None, recovery_required: false, stop_reason: String::new(), agent_session_id: String::new(), From fb4228bcd20a4b5bfc2346369c195a2b8c47e172 Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 25 Aug 2026 17:43:32 +0200 Subject: [PATCH 31/46] [codex/interactive] feat(runtime): add fail-closed continuity selector Resolve full-lineage, fresh, and explicit bare-fork policies before interactive spawn. Project bounded continuity receipts through Workshop, shell/deck, Python, and Rust control-plane surfaces while preserving lifecycle, quota, and Operator Agent ownership. Authored-By: codex session_id: 01a0396b-227e-7e63-9dbd-435fad721445 time: 2026-08-25T17:43:32+02:00 runtime: interactive --- scripts/vibecrafted | 5 +- tests/tui/test_vibecrafted_launcher.py | 20 +- vibecrafted-core/tests/test_agent_workshop.py | 38 ++ vibecrafted-core/tests/test_control_plane.py | 16 + .../tests/test_provider_policy.py | 250 +++++++++ .../config/vc-frame/vc-agent-workshop.py | 116 ++++- .../vibecrafted_core/control_plane.py | 2 + .../vibecrafted_core/deck/vibecrafted | 5 +- .../runtime/shell/lib/operator.sh | 10 +- .../runtime/shell/lib/operator_entrypoints.sh | 2 +- .../runtime/shell/lib/prompts.sh | 18 + vibecrafted-core/vibecrafted_core/spawn.py | 479 +++++++++++++++++- vibecrafted-server/control-core/src/model.rs | 42 ++ vibecrafted-server/control-core/src/read.rs | 23 +- 14 files changed, 995 insertions(+), 31 deletions(-) diff --git a/scripts/vibecrafted b/scripts/vibecrafted index 3edf95af..42426f4b 100755 --- a/scripts/vibecrafted +++ b/scripts/vibecrafted @@ -994,7 +994,7 @@ 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] [--policy-runtime local-native|local-worktrees|local-vm|cloud-soon] [--permissions bypass|auto|accept-edits|read-only] [--token-budget safe|unlimited|N] [--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" @@ -1005,11 +1005,14 @@ cmd_init_help() { 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' } diff --git a/tests/tui/test_vibecrafted_launcher.py b/tests/tui/test_vibecrafted_launcher.py index 9d9e98da..d28b86c9 100644 --- a/tests/tui/test_vibecrafted_launcher.py +++ b/tests/tui/test_vibecrafted_launcher.py @@ -561,12 +561,30 @@ def test_init_claude_uses_interactive_tab_without_print_mode( script_body = command_script.read_text(encoding="utf-8") assert ( "vibecrafted_core.spawn interactive-launch claude --runtime local-native " - "--permissions bypass --token-budget safe --operator auto --root" + "--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_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: diff --git a/vibecrafted-core/tests/test_agent_workshop.py b/vibecrafted-core/tests/test_agent_workshop.py index 22fce211..5b9ed52f 100644 --- a/vibecrafted-core/tests/test_agent_workshop.py +++ b/vibecrafted-core/tests/test_agent_workshop.py @@ -58,6 +58,8 @@ def test_launcher_commands_keep_interactive_agent_in_this_panel() -> None: "bypass", "--operator", "none", + "--continuity", + "fresh", ] assert workshop.launch_argv("claude", "resume") == [ "vibecrafted", @@ -68,6 +70,42 @@ def test_launcher_commands_keep_interactive_agent_in_this_panel() -> None: 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() diff --git a/vibecrafted-core/tests/test_control_plane.py b/vibecrafted-core/tests/test_control_plane.py index 28ad74eb..66dd85aa 100644 --- a/vibecrafted-core/tests/test_control_plane.py +++ b/vibecrafted-core/tests/test_control_plane.py @@ -239,6 +239,14 @@ def test_agent_meta_projects_structured_operator_relationship(tmp_path: Path) -> "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", @@ -248,6 +256,14 @@ def test_agent_meta_projects_structured_operator_relationship(tmp_path: Path) -> 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( diff --git a/vibecrafted-core/tests/test_provider_policy.py b/vibecrafted-core/tests/test_provider_policy.py index af97252e..d0526be5 100644 --- a/vibecrafted-core/tests/test_provider_policy.py +++ b/vibecrafted-core/tests/test_provider_policy.py @@ -11,21 +11,27 @@ 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, @@ -50,6 +56,13 @@ def _fake_interactive_provider(path: Path) -> None: " '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" @@ -347,6 +360,11 @@ def test_interactive_owner_keeps_distinct_live_provider_on_inherited_tty( 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() @@ -384,6 +402,11 @@ def test_interactive_owner_keeps_distinct_live_provider_on_inherited_tty( } 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) @@ -540,6 +563,8 @@ def test_operator_auto_creates_distinct_supervising_agent_relationship( 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"], @@ -1561,3 +1586,228 @@ def test_policy_cli_reads_the_same_contract(monkeypatch, capsys) -> None: "--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/vibecrafted_core/config/vc-frame/vc-agent-workshop.py b/vibecrafted-core/vibecrafted_core/config/vc-frame/vc-agent-workshop.py index dee8706e..f492c906 100755 --- a/vibecrafted-core/vibecrafted_core/config/vc-frame/vc-agent-workshop.py +++ b/vibecrafted-core/vibecrafted_core/config/vc-frame/vc-agent-workshop.py @@ -20,9 +20,11 @@ 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, @@ -55,12 +57,16 @@ def launch_argv( 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: @@ -72,7 +78,7 @@ def launch_argv( 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. - return [ + command = [ "vibecrafted", "init", agent, @@ -84,7 +90,18 @@ def launch_argv( 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" @@ -205,6 +222,8 @@ def __init__(self, window: curses.window, *, mode: str) -> None: 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]] = [] @@ -218,6 +237,7 @@ def configure(self) -> None: 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: @@ -308,7 +328,7 @@ 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 - 12) // 2) + top = max(1, (height - 15) // 2) inner = max(20, card_width - 4) _safe_addstr( self.window, @@ -340,11 +360,24 @@ def draw_launcher(self) -> None: 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): @@ -366,6 +399,15 @@ def draw_launcher(self) -> None: 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, @@ -384,28 +426,28 @@ def draw_launcher(self) -> None: runtime_help = RUNTIME_HELP[RUNTIME_POLICIES[self.runtime]] _safe_addstr( self.window, - top + 6, + top + 8, left, ("│ " + _clip(runtime_help[0], inner)).ljust(card_width - 1) + "│", curses.A_DIM, ) _safe_addstr( self.window, - top + 7, + top + 9, left, ("│ " + _clip(runtime_help[1], inner)).ljust(card_width - 1) + "│", curses.A_DIM, ) _safe_addstr( self.window, - top + 8, + top + 10, left, "│ Enter = interactive TTY on this Agents tab".ljust(card_width - 1) + "│", curses.A_DIM, ) _safe_addstr( self.window, - top + 9, + top + 11, left, "└─ ↑/↓ row · ←/→ choice · type path · Enter launch · Esc cancel " + "─" * max(0, card_width - 67) @@ -418,14 +460,22 @@ def draw_launcher(self) -> None: ] _safe_addstr( self.window, - top + 10, + top + 12, left, - "Unavailable — " + " · ".join(unavailable), + "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 + 11), left, self.error, curses.A_BOLD + self.window, min(height - 1, top + 13), left, self.error, curses.A_BOLD ) def handle_home_key(self, key: int) -> None: @@ -445,10 +495,10 @@ def handle_launcher_key(self, key: int) -> None: if key == 27: raise SystemExit(0) if key == curses.KEY_UP: - self.row = (self.row - 1) % 5 + self.row = (self.row - 1) % 7 return if key in (curses.KEY_DOWN, ord("\t")): - self.row = (self.row + 1) % 5 + 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 @@ -456,21 +506,30 @@ def handle_launcher_key(self, key: int) -> None: 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 == 4: + if self.row in (5, 6): if key in (curses.KEY_BACKSPACE, 127, 8): - self.path = self.path[:-1] + if self.row == 5: + self.continuity_parent = self.continuity_parent[:-1] + else: + self.path = self.path[:-1] elif 32 <= key <= 126: - self.path += chr(key) + 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]) @@ -515,6 +574,25 @@ def _normalize_permission_choice(self) -> None: 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() @@ -583,11 +661,21 @@ def launch(self) -> None: 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) diff --git a/vibecrafted-core/vibecrafted_core/control_plane.py b/vibecrafted-core/vibecrafted_core/control_plane.py index 1f15f738..699b6a6f 100644 --- a/vibecrafted-core/vibecrafted_core/control_plane.py +++ b/vibecrafted-core/vibecrafted_core/control_plane.py @@ -1870,6 +1870,8 @@ def _normalize_agent_meta(path: Path) -> RunStatus | None: "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] diff --git a/vibecrafted-core/vibecrafted_core/deck/vibecrafted b/vibecrafted-core/vibecrafted_core/deck/vibecrafted index 3edf95af..42426f4b 100755 --- a/vibecrafted-core/vibecrafted_core/deck/vibecrafted +++ b/vibecrafted-core/vibecrafted_core/deck/vibecrafted @@ -994,7 +994,7 @@ 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] [--policy-runtime local-native|local-worktrees|local-vm|cloud-soon] [--permissions bypass|auto|accept-edits|read-only] [--token-budget safe|unlimited|N] [--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" @@ -1005,11 +1005,14 @@ cmd_init_help() { 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' } diff --git a/vibecrafted-core/vibecrafted_core/runtime/shell/lib/operator.sh b/vibecrafted-core/vibecrafted_core/runtime/shell/lib/operator.sh index b8a3a098..c4a28fcf 100644 --- a/vibecrafted-core/vibecrafted_core/runtime/shell/lib/operator.sh +++ b/vibecrafted-core/vibecrafted_core/runtime/shell/lib/operator.sh @@ -61,16 +61,22 @@ _vetcoders_init_command_text() { 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" --root "${_vetcoders_contract_root:-$(_vetcoders_repo_root)}" + "$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" --root "${_vetcoders_contract_root:-$(_vetcoders_repo_root)}" + 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 } 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 5c915780..c22a7230 100644 --- a/vibecrafted-core/vibecrafted_core/runtime/shell/lib/operator_entrypoints.sh +++ b/vibecrafted-core/vibecrafted_core/runtime/shell/lib/operator_entrypoints.sh @@ -24,7 +24,7 @@ _vetcoders_skill_init() { init_prompt="$(_vetcoders_compose_init_prompt "$_vetcoders_contract_prompt" "$_vetcoders_contract_file")" || 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}")" || return 1 + 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 diff --git a/vibecrafted-core/vibecrafted_core/runtime/shell/lib/prompts.sh b/vibecrafted-core/vibecrafted_core/runtime/shell/lib/prompts.sh index efc05048..ab0aa85e 100644 --- a/vibecrafted-core/vibecrafted_core/runtime/shell/lib/prompts.sh +++ b/vibecrafted-core/vibecrafted_core/runtime/shell/lib/prompts.sh @@ -37,6 +37,9 @@ _vetcoders_contract_reset() { _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="" @@ -143,6 +146,21 @@ _vetcoders_parse_contract() { [[ $# -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/spawn.py b/vibecrafted-core/vibecrafted_core/spawn.py index 80d01964..beea3e40 100644 --- a/vibecrafted-core/vibecrafted_core/spawn.py +++ b/vibecrafted-core/vibecrafted_core/spawn.py @@ -4,6 +4,7 @@ import argparse import datetime as dt +import hashlib import inspect import json import os @@ -46,6 +47,22 @@ 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." ) @@ -119,6 +136,49 @@ def as_dict(self) -> dict[str, Any]: } +@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.""" @@ -271,6 +331,271 @@ def resolve_quota_policy( 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, *, @@ -486,16 +811,25 @@ def interactive_policy_command( 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] @@ -510,7 +844,24 @@ def interactive_policy_command( "--skip-update-check", "--use-local-cache", ] - return ["grok", "--cwd", ".", *flags, "--no-alt-screen", prompt] + 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( @@ -521,6 +872,9 @@ def interactive_workspace_command( 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") @@ -533,6 +887,12 @@ def interactive_workspace_command( 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", @@ -547,11 +907,23 @@ def interactive_workspace_command( 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 @@ -620,6 +992,7 @@ def prepare_interactive_workspace_launch( 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") @@ -702,6 +1075,11 @@ def prepare_interactive_workspace_launch( "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 "", @@ -911,30 +1289,86 @@ def launch_interactive_workspace( 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=prompt, + 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 = os.environ.copy() + child_env = _fresh_child_environment(os.environ.copy(), continuity_policy) provider_session_id = str(uuid.uuid4()) command = interactive_policy_command( provider, - prompt, + 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]) @@ -945,12 +1379,14 @@ def launch_interactive_workspace( runtime=runtime, permissions=permissions, selected_root=root, - prompt=prompt, + 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( @@ -975,6 +1411,8 @@ def launch_interactive_workspace( "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: @@ -1159,11 +1597,14 @@ def _launch_supervised_interactive_workspace( 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) - base_env = os.environ.copy() + 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, @@ -1171,6 +1612,7 @@ def _launch_supervised_interactive_workspace( 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( @@ -1184,11 +1626,13 @@ def _launch_supervised_interactive_workspace( 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( @@ -1268,6 +1712,9 @@ def _launch_supervised_interactive_workspace( 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 @@ -1318,6 +1765,8 @@ def _launch_supervised_interactive_workspace( "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") @@ -1368,6 +1817,8 @@ def _launch_supervised_interactive_workspace( "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. @@ -3531,6 +3982,11 @@ def _build_parser() -> argparse.ArgumentParser: 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." @@ -3548,6 +4004,11 @@ def _build_parser() -> argparse.ArgumentParser: 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." ) @@ -3616,6 +4077,9 @@ def main(argv: Sequence[str] | None = None) -> int: 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) @@ -3632,6 +4096,9 @@ def main(argv: Sequence[str] | None = None) -> int: 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) diff --git a/vibecrafted-server/control-core/src/model.rs b/vibecrafted-server/control-core/src/model.rs index d399d8bb..34e8e270 100644 --- a/vibecrafted-server/control-core/src/model.rs +++ b/vibecrafted-server/control-core/src/model.rs @@ -503,6 +503,28 @@ pub struct OperatorAgentPolicyProjection { 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")] @@ -585,6 +607,9 @@ pub struct RunStatus { /// 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")] @@ -1109,6 +1134,7 @@ impl LifecycleRun { worker_pgid: None, worker_alive: None, operator_agent: None, + continuity: None, recovery_required: false, stop_reason: String::new(), agent_session_id: String::new(), @@ -1372,6 +1398,8 @@ pub struct AgentMeta { #[serde(default)] pub operator_policy: Option, #[serde(default)] + pub continuity: Option, + #[serde(default)] pub supervision: Option, #[serde(default)] pub stop_actor_run_id: String, @@ -1527,6 +1555,7 @@ impl AgentMeta { } _ => None, }, + continuity: self.continuity.clone(), recovery_required: self.recovery_required, stop_reason: self.stop_reason.clone(), agent_session_id: self.agent_session_id.clone(), @@ -1627,6 +1656,10 @@ pub fn merge_status(existing: Option, incoming: RunStatus) -> RunStat .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), @@ -1682,6 +1715,11 @@ mod status_thread_tests { "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"); @@ -1697,6 +1735,10 @@ mod status_thread_tests { 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] diff --git a/vibecrafted-server/control-core/src/read.rs b/vibecrafted-server/control-core/src/read.rs index a493fb12..753d269c 100644 --- a/vibecrafted-server/control-core/src/read.rs +++ b/vibecrafted-server/control-core/src/read.rs @@ -22,11 +22,11 @@ use chrono::{DateTime, Utc}; use crate::events::EventStream; use crate::model::{ - AgentMeta, DeliverySealRef, Event, FINAL_STATES, Health, LifecycleRun, LifecycleRunSummary, - OperatorAgentPolicyProjection, OperatorAgentProjection, RECENT_RUN_LIMIT, RUN_STALL_SECONDS, - RunStatus, SettlementBoard, SettlementTui, SettlementVerdict, SupervisionRelationProjection, - TrustReceiptV1, coerce_int_value, is_final_state, merge_status, operator_session_name, - parse_iso, skill_from_code, state_health, + AgentMeta, ContinuityPolicyProjection, DeliverySealRef, Event, FINAL_STATES, Health, + LifecycleRun, LifecycleRunSummary, OperatorAgentPolicyProjection, OperatorAgentProjection, + RECENT_RUN_LIMIT, RUN_STALL_SECONDS, RunStatus, SettlementBoard, SettlementTui, + 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. @@ -466,6 +466,7 @@ impl ControlPlane { 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"), @@ -1132,6 +1133,12 @@ fn normalize_event(event: &Event, existing: Option<&RunStatus>, now: DateTime Option 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; @@ -1660,6 +1671,7 @@ fn normalize_lock(path: &Path, now: DateTime) -> Option { worker_pgid: None, worker_alive: None, operator_agent: None, + continuity: None, recovery_required: false, stop_reason: String::new(), agent_session_id: String::new(), @@ -1791,6 +1803,7 @@ impl MarblesState { worker_pgid: None, worker_alive: None, operator_agent: None, + continuity: None, recovery_required: false, stop_reason: String::new(), agent_session_id: String::new(), From 7915498f211bed8178070df07a93d3751b0db41b Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 25 Aug 2026 21:07:01 +0200 Subject: [PATCH 32/46] [codex/vc-agents] feat(runtime-pack): hydrate Linux arm64 carrier Build every required Linux arm64 runtime component from immutable public inputs, seal executable provenance into the canonical pack contract, and replace the local VM image with a checksum-verified non-root carrier. Keep all native build outputs outside the staged source tree and release each component target immediately after staging its binaries so clean Linux arm64 assembly remains payload-safe and bounded in peak disk use. Authored-By: codex session_id: 01a039ab-d284-7621-a93f-c4a78f210596 time: 2026-08-25T21:01:00+02:00 runtime: vc-agents --- .dockerignore | 8 + install.sh | 4 + scripts/build-linux-arm64-runtime-pack.sh | 176 ++++++++++ scripts/distribution_manifest.py | 4 + scripts/stage-runtime-foundations.sh | 94 ++++- tests/tui/test_distribution_manifest.py | 4 + tests/tui/test_install_bootstrap.py | 4 + tests/tui/test_linux_arm64_runtime_pack.py | 106 ++++++ tests/tui/test_runtime_pack_cli.py | 17 +- tests/tui/test_uv_bootstrap.py | 4 + vibecrafted-app/tui-agent/src/config.rs | 4 + .../vibecrafted_core/runtime_pack_contract.py | 82 +++++ vibecrafted-vm/Containerfile | 331 +++++------------- vibecrafted-vm/README.md | 210 +++-------- vibecrafted-vm/RUNBOOK.md | 6 + vibecrafted-vm/RuntimePack.Containerfile | 38 ++ vibecrafted-vm/compose.yaml | 11 +- vibecrafted-vm/runtime-entry.sh | 18 + vibecrafted-vm/runtime-provider-lock.json | 17 + 19 files changed, 708 insertions(+), 430 deletions(-) create mode 100755 scripts/build-linux-arm64-runtime-pack.sh create mode 100644 tests/tui/test_linux_arm64_runtime_pack.py create mode 100644 vibecrafted-vm/RuntimePack.Containerfile create mode 100755 vibecrafted-vm/runtime-entry.sh create mode 100644 vibecrafted-vm/runtime-provider-lock.json 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/install.sh b/install.sh index fad5b1b8..2e3e2253 100644 --- a/install.sh +++ b/install.sh @@ -231,6 +231,7 @@ 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", @@ -249,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( diff --git a/scripts/build-linux-arm64-runtime-pack.sh b/scripts/build-linux-arm64-runtime-pack.sh new file mode 100755 index 00000000..bb3c23c6 --- /dev/null +++ b/scripts/build-linux-arm64-runtime-pack.sh @@ -0,0 +1,176 @@ +#!/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 +install -m 0755 "$voc_target/release/voc" "$payload/bin/voc" +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/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\n' "$version" > "$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/distribution_manifest.py b/scripts/distribution_manifest.py index 4eddef9e..86db074c 100755 --- a/scripts/distribution_manifest.py +++ b/scripts/distribution_manifest.py @@ -53,6 +53,7 @@ "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", @@ -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 = ( diff --git a/scripts/stage-runtime-foundations.sh b/scripts/stage-runtime-foundations.sh index ec280ef1..edc29fd2 100755 --- a/scripts/stage-runtime-foundations.sh +++ b/scripts/stage-runtime-foundations.sh @@ -7,9 +7,13 @@ 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) @@ -20,6 +24,14 @@ case "$(uname -s):$(uname -m)" in 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" @@ -27,7 +39,26 @@ case "$(uname -s):$(uname -m)" in *) die "no complete Runtime Foundations payload for $(uname -s)/$(uname -m)" ;; esac -for tool in git npm cargo python3; do require "$tool"; done +[[ "${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 @@ -42,22 +73,40 @@ 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. -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 +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. -git clone --quiet --depth 1 --branch "v${AICX_VERSION}" \ - https://github.com/Loctree/aicx.git "$WORK/aicx/source" -[[ "$(git -C "$WORK/aicx/source" rev-parse HEAD)" == "$AICX_REVISION" ]] \ - || die "AICX v${AICX_VERSION} does not resolve to pinned $AICX_REVISION" +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" \ @@ -73,6 +122,7 @@ for name in aicx aicx-mcp; do [[ -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 @@ -118,7 +168,25 @@ for path in sorted(root.iterdir()): payload = { "schema": "io.vetcoders.vibecrafted.runtime-foundations.v1", "versions": versions, - "source_revisions": {"aicx": "ced57997dd97a2b08960f35e3a657d7b0c49a200"}, + "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( diff --git a/tests/tui/test_distribution_manifest.py b/tests/tui/test_distribution_manifest.py index 7569d694..82d16002 100644 --- a/tests/tui/test_distribution_manifest.py +++ b/tests/tui/test_distribution_manifest.py @@ -36,6 +36,7 @@ "install.ps1", "install.toml", "scripts/distribution_manifest.py", + "scripts/build-linux-arm64-runtime-pack.sh", "scripts/vetcoders_install.py", "scripts/vibecrafted", "scripts/verify-vibecrafted-product.sh", @@ -55,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", } diff --git a/tests/tui/test_install_bootstrap.py b/tests/tui/test_install_bootstrap.py index 682d278b..8e1363ac 100644 --- a/tests/tui/test_install_bootstrap.py +++ b/tests/tui/test_install_bootstrap.py @@ -32,6 +32,7 @@ "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", @@ -50,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", 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..7616547b --- /dev/null +++ b/tests/tui/test_linux_arm64_runtime_pack.py @@ -0,0 +1,106 @@ +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 '"$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 + + 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_runtime_pack_cli.py b/tests/tui/test_runtime_pack_cli.py index f0d8719e..16c35351 100644 --- a/tests/tui/test_runtime_pack_cli.py +++ b/tests/tui/test_runtime_pack_cli.py @@ -338,9 +338,10 @@ def test_runtime_packager_emits_one_closed_root_and_checksum(tmp_path: Path) -> == expected ) - # The same canonical writer also accepts a release-built Linux payload; - # consumers never compile it. Missing helpers still fail through the - # common required-file contract below. + # 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) @@ -373,13 +374,9 @@ def test_runtime_packager_emits_one_closed_root_and_checksum(tmp_path: Path) -> text=True, check=False, ) - assert linux.returncode == 0, linux.stderr - with tarfile.open(linux_output, "r:gz") as archive: - provenance = json.load( - archive.extractfile("VibecraftedRuntime/runtime-pack-provenance.json") - ) - assert provenance["platform"] == "linux" - assert provenance["architecture"] == "arm64" + assert linux.returncode != 0 + assert "Linux arm64 Runtime Pack inventory is invalid" in linux.stderr + assert not linux_output.exists() def test_runtime_pack_archive_requires_release_signature(tmp_path: Path) -> None: diff --git a/tests/tui/test_uv_bootstrap.py b/tests/tui/test_uv_bootstrap.py index 46f72b26..ab75ae57 100644 --- a/tests/tui/test_uv_bootstrap.py +++ b/tests/tui/test_uv_bootstrap.py @@ -53,6 +53,7 @@ "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", @@ -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", } FIXTURE_REQUIRED_SURFACES = { "bin/vc-workflow", 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-core/vibecrafted_core/runtime_pack_contract.py b/vibecrafted-core/vibecrafted_core/runtime_pack_contract.py index b7899b40..f7a69f29 100644 --- a/vibecrafted-core/vibecrafted_core/runtime_pack_contract.py +++ b/vibecrafted-core/vibecrafted_core/runtime_pack_contract.py @@ -14,9 +14,27 @@ 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", + } +) class RuntimePackContractError(RuntimeError): @@ -94,6 +112,66 @@ def _source_provenance(root: Path, *, expected_revision: str) -> dict[str, Any]: 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, *, @@ -118,6 +196,8 @@ def write_provenance( if not version or version != version.strip(): raise RuntimePackContractError("Runtime Pack version is invalid") _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, @@ -218,6 +298,8 @@ def verify_provenance( f"Runtime Pack {field} disagrees with the selected release asset" ) _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( 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" + } + } +} From abfb6921a67404916cf0724c0708e52b07cf7c63 Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 25 Aug 2026 23:37:45 +0200 Subject: [PATCH 33/46] [codex/vc-agents] fix(voc): stop idle full-state refresh loop Move control-plane projection and Polarize discovery behind debounced filesystem invalidation while keeping render and Observe cadences independent. Preserve explicit refresh semantics, stable selection, fallback polling, and add runtime tracing plus scheduler regression coverage. Authored-By: codex session_id: 01a03ac6-d8b3-73f0-afa0-6333bacae860 time: 2026-08-25T23:37:39+02:00 runtime: vc-agents --- vibecrafted-app/tui-agent/README.md | 13 + vibecrafted-app/tui-agent/src/app.rs | 91 +++++-- vibecrafted-app/tui-agent/src/lib.rs | 310 ++++++++++++++++++++-- vibecrafted-app/tui-agent/src/polarize.rs | 2 +- 4 files changed, 380 insertions(+), 36 deletions(-) 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/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); } From 0bd83d8df10a3a93f0986565a2bbfcf78497c324 Mon Sep 17 00:00:00 2001 From: div0-space Date: Wed, 26 Aug 2026 01:43:53 +0200 Subject: [PATCH 34/46] [codex/vc-agents] fix(runtime-pack): close Linux installer payload contract Build and package the native vc-start entrypoint and repo-owned installer launcher, stamp the embedded Python package with the selected source revision, and fail provenance verification when required installer executables are absent or non-executable. Add focused archive and contract regressions plus unified fixture coverage. Authored-By: codex session_id: 01a03ae8-7067-70d0-91ab-50bf06057e2d time: 2026-08-26T01:43:46+02:00 runtime: vc-agents --- scripts/build-linux-arm64-runtime-pack.sh | 7 +- scripts/package-runtime-pack.sh | 4 +- tests/tui/test_linux_arm64_runtime_pack.py | 10 +++ tests/tui/test_runtime_pack_cli.py | 65 ++++++++++++++++++- tests/tui/test_unified_app_contract.py | 8 +++ .../vibecrafted_core/runtime_pack_contract.py | 23 +++++++ 6 files changed, 111 insertions(+), 6 deletions(-) diff --git a/scripts/build-linux-arm64-runtime-pack.sh b/scripts/build-linux-arm64-runtime-pack.sh index bb3c23c6..ebbd99a7 100755 --- a/scripts/build-linux-arm64-runtime-pack.sh +++ b/scripts/build-linux-arm64-runtime-pack.sh @@ -60,8 +60,9 @@ 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 + --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" @@ -74,13 +75,15 @@ 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\n' "$version" > "$payload/vibecrafted-core/vibecrafted_core/VERSION" +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 \ diff --git a/scripts/package-runtime-pack.sh b/scripts/package-runtime-pack.sh index 49d83376..a4ec6bee 100755 --- a/scripts/package-runtime-pack.sh +++ b/scripts/package-runtime-pack.sh @@ -93,8 +93,8 @@ 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-terminal bin/vc-frame \ - libexec/vc-frame scripts/vetcoders_install.py \ + 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 diff --git a/tests/tui/test_linux_arm64_runtime_pack.py b/tests/tui/test_linux_arm64_runtime_pack.py index 7616547b..e7e4f795 100644 --- a/tests/tui/test_linux_arm64_runtime_pack.py +++ b/tests/tui/test_linux_arm64_runtime_pack.py @@ -88,6 +88,11 @@ def test_linux_arm64_builder_uses_pinned_public_inputs() -> None: 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 ( @@ -98,6 +103,11 @@ def test_linux_arm64_builder_uses_pinned_public_inputs() -> None: 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" diff --git a/tests/tui/test_runtime_pack_cli.py b/tests/tui/test_runtime_pack_cli.py index 16c35351..e28a4e9a 100644 --- a/tests/tui/test_runtime_pack_cli.py +++ b/tests/tui/test_runtime_pack_cli.py @@ -9,7 +9,11 @@ import tarfile from pathlib import Path -from vibecrafted_core.runtime_pack_contract import write_provenance +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" @@ -35,6 +39,7 @@ def _fake_runtime_payload(root: Path, capture: Path) -> None: 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" @@ -42,7 +47,13 @@ def _fake_runtime_payload(root: Path, capture: Path) -> None: 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) @@ -254,7 +265,9 @@ def test_runtime_packager_emits_one_closed_root_and_checksum(tmp_path: Path) -> required = ( "VERSION", "bin/python3", + "bin/vc-start", "bin/vibecrafted", + "scripts/vibecrafted", "scripts/vc-frame-product-entry.sh", "scripts/vetcoders_install.py", ) @@ -271,7 +284,7 @@ def test_runtime_packager_emits_one_closed_root_and_checksum(tmp_path: Path) -> f"{VERSION}\n" if relative == "VERSION" else "fixture\n", encoding="utf-8", ) - if relative.startswith("bin/"): + if relative.startswith("bin/") or relative == "scripts/vibecrafted": path.chmod(0o755) contract_dir = runtime / "vibecrafted-core/vibecrafted_core" contract_dir.mkdir(parents=True) @@ -325,8 +338,10 @@ def test_runtime_packager_emits_one_closed_root_and_checksum(tmp_path: Path) -> ) 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() ) @@ -379,6 +394,52 @@ def test_runtime_packager_emits_one_closed_root_and_checksum(tmp_path: Path) -> 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" diff --git a/tests/tui/test_unified_app_contract.py b/tests/tui/test_unified_app_contract.py index acd90a51..04b4cb99 100644 --- a/tests/tui/test_unified_app_contract.py +++ b/tests/tui/test_unified_app_contract.py @@ -209,6 +209,14 @@ def _runtime_pack_fixture(app: Path) -> str: 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", { diff --git a/vibecrafted-core/vibecrafted_core/runtime_pack_contract.py b/vibecrafted-core/vibecrafted_core/runtime_pack_contract.py index f7a69f29..f5c0f1b8 100644 --- a/vibecrafted-core/vibecrafted_core/runtime_pack_contract.py +++ b/vibecrafted-core/vibecrafted_core/runtime_pack_contract.py @@ -35,6 +35,12 @@ "voc", } ) +RUNTIME_INSTALLER_EXECUTABLES = frozenset( + { + "bin/vc-start", + "scripts/vibecrafted", + } +) class RuntimePackContractError(RuntimeError): @@ -53,6 +59,21 @@ 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") @@ -195,6 +216,7 @@ def write_provenance( 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) @@ -297,6 +319,7 @@ def verify_provenance( 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) From 05811488b6e07000f96bb3c503c59b31c3d85f49 Mon Sep 17 00:00:00 2001 From: div0-space Date: Wed, 26 Aug 2026 02:18:34 +0200 Subject: [PATCH 35/46] [grok/workflow] fix(launch): one invocation one parseable receipt A successful `vibecrafted workflow --json` launch that already created a control-plane run could return exit 0 with empty or unparseable stdout, so a defensive retry minted a sibling run. Emit one flushed JSON receipt with run_id, agent, root, and accepted/status; fail closed if that write cannot be proven. Fingerprint live launches so a retry replays the same run instead of spawning another dispatcher. Authored-By: grok session_id: 1eb18fff-64ec-42b1-b413-17f63ae7aef4 time: 2026-08-26T02:18:00+02:00 runtime: headless --- vibecrafted-core/tests/test_cli.py | 132 ++++- vibecrafted-core/tests/test_workflow.py | 128 ++++- vibecrafted-core/vibecrafted_core/cli.py | 79 ++- .../vibecrafted_core/lifecycle_runner.py | 8 +- vibecrafted-core/vibecrafted_core/workflow.py | 495 ++++++++++++++---- 5 files changed, 733 insertions(+), 109 deletions(-) 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_workflow.py b/vibecrafted-core/tests/test_workflow.py index dd570002..25716002 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 @@ -245,6 +245,132 @@ 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 test_one_logical_launch_creates_one_run_and_replays_on_retry( + 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) == 1 + assert second["run_id"] == first["run_id"] + assert second.get("replayed") is True + assert second["accepted"] is True + assert second.get("idempotency_key") + + +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")) + 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: diff --git a/vibecrafted-core/vibecrafted_core/cli.py b/vibecrafted-core/vibecrafted_core/cli.py index c7856d6f..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"} @@ -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/lifecycle_runner.py b/vibecrafted-core/vibecrafted_core/lifecycle_runner.py index cf908b12..67bbfefd 100644 --- a/vibecrafted-core/vibecrafted_core/lifecycle_runner.py +++ b/vibecrafted-core/vibecrafted_core/lifecycle_runner.py @@ -1584,7 +1584,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/workflow.py b/vibecrafted-core/vibecrafted_core/workflow.py index 4f0b9f11..2a8e4e22 100644 --- a/vibecrafted-core/vibecrafted_core/workflow.py +++ b/vibecrafted-core/vibecrafted_core/workflow.py @@ -79,6 +79,9 @@ "timed_out", "ghost", } +LAUNCH_IDEMPOTENCY_SCHEMA = "vibecrafted.launch-idempotency.v1" +LAUNCH_IDEMPOTENCY_KEY_ENV = "VIBECRAFTED_LAUNCH_IDEMPOTENCY_KEY" +LAUNCH_RECEIPT_SCHEMA = "vibecrafted.launch_receipt.v1" @dataclass(frozen=True) @@ -1903,6 +1906,258 @@ 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) -> str: + """Stable fingerprint of one operator launch invocation. + + An explicit ``VIBECRAFTED_LAUNCH_IDEMPOTENCY_KEY`` wins. Otherwise the key + is the SHA-256 of skill, agent, runtime, root, and source prompt bytes so a + defensive retry of the same command reuses the first run. + """ + override = str(os.environ.get(LAUNCH_IDEMPOTENCY_KEY_ENV) or "").strip() + if override: + return override + try: + prompt = _source_prompt(spec) + except OSError: + prompt = spec.prompt or spec.file + root = str(Path(spec.root or "").expanduser().resolve(strict=False)) + material = "\n".join( + [ + spec.skill, + spec.agent, + spec.runtime, + spec.mode, + str(spec.count or ""), + str(spec.depth or ""), + spec.model, + root, + spec.file, + hashlib.sha256(prompt.encode("utf-8")).hexdigest(), + ] + ) + return hashlib.sha256(material.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()), + "receipt": _json_plain(payload.get("receipt") or {}), + "updated_at": now, + } + 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 _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'}" + ), + } + if not payload["accepted"] and status == "launching": + payload["accepted"] = True + payload["status"] = "launching" + return payload + + +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_pid = int(record.get("owner_pid") or 0) + if owner_pid and _pid_is_alive(owner_pid): + return _replay_launch_payload(record, status="launching") + return None + if state != "dispatched": + return None + run = lookup_run(run_id) + if run is not None and _run_is_terminal(run): + return None + return _replay_launch_payload(record, status="launching") + + +def _claim_launch_idempotency( + spec: WorkflowLaunchSpec, key: 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) + replay = _replay_launch_if_current(existing) + if replay is not None: + 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(), + "receipt": { + "run_id": run_id, + "agent": spec.agent, + "skill": spec.skill, + "root": spec.root, + "accepted": True, + "status": "launching", + }, + }, + ) + return None, run_id + + +def _finish_launch_idempotency(key: str, payload: dict[str, Any]) -> 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(), + "receipt": _json_plain(result), + }, + ) + return result + + +def recover_launch_receipt(spec: WorkflowLaunchSpec) -> 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) + record = _read_launch_idempotency_record(key) + 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 +2215,21 @@ 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 = "" + claimed_run_id = "" + if _launch_idempotency_enabled(): + idem_key = launch_idempotency_key(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) + 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 +2291,25 @@ 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}, + }, + ) 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 +2523,32 @@ 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(), + }, + ) launcher_pid = host.pid else: proc = subprocess.Popen( @@ -2332,23 +2612,30 @@ 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(), + }, + ) append_event( kind="launch", run_id=run_id, @@ -2394,15 +2681,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 +2705,51 @@ 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}, - } + ) def stop_run( From d482c0a272b5b319e5dd9528859204edf93a7cb2 Mon Sep 17 00:00:00 2001 From: div0-space Date: Wed, 26 Aug 2026 03:37:31 +0200 Subject: [PATCH 36/46] [codex/interactive] fix(launch): bind retries to explicit invocation identity Makes fresh identical payloads independent while binding retries to caller-owned identities and canonical process proof. Extends lifecycle and dispatch transports with stable attempt identities and adversarial recovery coverage. Authored-By: codex session_id: 01a03b82-4841-75f1-8bbf-a1d669d1f3d6 time: 2026-08-26T03:37:19+02:00 runtime: interactive --- .../tests/dispatch/test_supervisor.py | 15 +- .../tests/test_lifecycle_runner.py | 9 + vibecrafted-core/tests/test_workflow.py | 521 +++++++++++++++++- .../vibecrafted_core/dispatch/supervisor.py | 14 +- .../vibecrafted_core/lifecycle_runner.py | 16 + vibecrafted-core/vibecrafted_core/loop.py | 6 +- vibecrafted-core/vibecrafted_core/workflow.py | 308 +++++++++-- 7 files changed, 828 insertions(+), 61 deletions(-) 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/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_workflow.py b/vibecrafted-core/tests/test_workflow.py index 25716002..217b11d8 100644 --- a/vibecrafted-core/tests/test_workflow.py +++ b/vibecrafted-core/tests/test_workflow.py @@ -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" @@ -277,7 +316,7 @@ def fake_popen(*args: Any, **kwargs: Any) -> Any: monkeypatch.setattr(workflow.subprocess, "Popen", fake_popen) -def test_one_logical_launch_creates_one_run_and_replays_on_retry( +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")) @@ -299,11 +338,472 @@ def test_one_logical_launch_creates_one_run_and_replays_on_retry( assert first["accepted"] is True assert first["run_id"] - assert len(pops) == 1 - assert second["run_id"] == first["run_id"] - assert second.get("replayed") is True + assert len(pops) == 2 + assert second["run_id"] != first["run_id"] + assert second.get("replayed") is not True assert second["accepted"] is True - assert second.get("idempotency_key") + 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 + + +def test_reserved_record_never_replays_accepted_and_pid_reuse_is_reclaimed( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> 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": 1, + "spec_digest": workflow._launch_spec_digest(spec), + "receipt": {"accepted": False, "status": "reserved"}, + }, + ) + monkeypatch.setattr(workflow, "_pid_is_alive", lambda _pid: True) + 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 + + +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, True]) + monkeypatch.setattr( + workflow, + "validate_process_identity", + lambda *_args, **_kwargs: ( + next(identity_checks), + "process_identity_current", + None, + ), + ) + 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( @@ -342,6 +842,15 @@ 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"}, @@ -1386,7 +1895,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/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/lifecycle_runner.py b/vibecrafted-core/vibecrafted_core/lifecycle_runner.py index 67bbfefd..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]: @@ -915,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) 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/workflow.py b/vibecrafted-core/vibecrafted_core/workflow.py index 2a8e4e22..3bb17c6c 100644 --- a/vibecrafted-core/vibecrafted_core/workflow.py +++ b/vibecrafted-core/vibecrafted_core/workflow.py @@ -81,6 +81,8 @@ } 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" @@ -1946,36 +1948,34 @@ def _launch_idempotency_enabled() -> bool: return raw not in {"0", "false", "off", "no"} -def launch_idempotency_key(spec: WorkflowLaunchSpec) -> str: - """Stable fingerprint of one operator launch invocation. +def launch_idempotency_key( + spec: WorkflowLaunchSpec, *, env: dict[str, str] | None = None +) -> str: + """Return caller-supplied launch identity, or ``""`` for a fresh invocation. - An explicit ``VIBECRAFTED_LAUNCH_IDEMPOTENCY_KEY`` wins. Otherwise the key - is the SHA-256 of skill, agent, runtime, root, and source prompt bytes so a - defensive retry of the same command reuses the first run. + 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. """ - override = str(os.environ.get(LAUNCH_IDEMPOTENCY_KEY_ENV) or "").strip() + source = os.environ if env is None else env + override = str(source.get(LAUNCH_IDEMPOTENCY_KEY_ENV) or "").strip() if override: return override - try: - prompt = _source_prompt(spec) - except OSError: - prompt = spec.prompt or spec.file - root = str(Path(spec.root or "").expanduser().resolve(strict=False)) - material = "\n".join( - [ - spec.skill, - spec.agent, - spec.runtime, - spec.mode, - str(spec.count or ""), - str(spec.depth or ""), - spec.model, - root, - spec.file, - hashlib.sha256(prompt.encode("utf-8")).hexdigest(), - ] - ) - return hashlib.sha256(material.encode("utf-8")).hexdigest() + 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: @@ -2021,15 +2021,61 @@ def _write_launch_idempotency_record(key: str, payload: dict[str, Any]) -> None: "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, } - 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) + 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]: @@ -2050,12 +2096,107 @@ def _replay_launch_payload(record: dict[str, Any], *, status: str) -> dict[str, stored.get("message") or f"Replayed launch for {run_id or 'existing run'}" ), } - if not payload["accepted"] and status == "launching": - payload["accepted"] = True - payload["status"] = "launching" 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 _record_owner_is_current(record: dict[str, Any]) -> bool: + """Qualify a reservation owner by full process identity, never PID alone.""" + run_id = str(record.get("run_id") or "") + owner_pid = int(record.get("owner_pid") or 0) + receipt = record.get("owner_identity") + if owner_pid <= 0 or not isinstance(receipt, dict): + return False + expected_pgid = receipt.get("pgid") + try: + pgid = int(expected_pgid) if expected_pgid is not None else None + except (TypeError, ValueError): + return False + current, _reason, _identity = validate_process_identity( + receipt, + expected_pid=owner_pid, + expected_pgid=pgid, + expected_run_id=run_id, + ) + return current + + +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: @@ -2065,25 +2206,60 @@ def _replay_launch_if_current(record: dict[str, Any]) -> dict[str, Any] | None: return None state = str(record.get("state") or "") if state == "reserved": - owner_pid = int(record.get("owner_pid") or 0) - if owner_pid and _pid_is_alive(owner_pid): - return _replay_launch_payload(record, status="launching") - return None + if _record_owner_is_current(record): + 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 None: + return None + if _run_is_terminal(run) or _run_has_current_process_proof(run_id, run): + return _canonical_run_launch_payload(record, run) + 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 not None and _run_is_terminal(run): - return None - return _replay_launch_payload(record, status="launching") + 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: 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": @@ -2099,20 +2275,24 @@ def _claim_launch_idempotency( "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": True, - "status": "launching", + "accepted": False, + "status": "reserved", }, }, ) return None, run_id -def _finish_launch_idempotency(key: str, payload: dict[str, Any]) -> dict[str, Any]: +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 @@ -2128,18 +2308,34 @@ def _finish_launch_idempotency(key: str, payload: dict[str, Any]) -> dict[str, A "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) -> dict[str, Any] | None: +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) + 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) @@ -2216,16 +2412,24 @@ def launch_workflow( # not a git repository / stubbed subprocess / unreadable context → allow idem_key = "" + idem_spec_digest = "" claimed_run_id = "" if _launch_idempotency_enabled(): - idem_key = launch_idempotency_key(spec) + 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) + replay, claimed_run_id = _claim_launch_idempotency( + spec, idem_key, spec_digest=idem_spec_digest + ) if replay is not None: return replay @@ -2309,6 +2513,7 @@ def launch_workflow( "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) @@ -2548,6 +2753,7 @@ def launch_workflow( **model_receipt, "control_plane": sync_state(), }, + spec_digest=idem_spec_digest, ) launcher_pid = host.pid else: @@ -2635,6 +2841,7 @@ def launch_workflow( **model_receipt, "control_plane": sync_state(), }, + spec_digest=idem_spec_digest, ) append_event( kind="launch", @@ -2749,6 +2956,7 @@ def launch_workflow( # launch acknowledgement path. "control_plane": {"sync": "deferred", "run_id": run_id}, }, + spec_digest=idem_spec_digest, ) From fa245d3e7920deb356e645abc0ce8abbcc794ba8 Mon Sep 17 00:00:00 2001 From: div0-space Date: Wed, 26 Aug 2026 04:15:14 +0200 Subject: [PATCH 37/46] [codex/headless] fix(launch): fail closed on ambiguous reservation owner Classify reservation owners as current, proven stale, or ambiguous at the reclaim boundary. Preserve canonical recovery while refusing ambiguous identity without spawning or rewriting reservation ownership. Authored-By: codex session_id: 01a03bc9-c4aa-78c2-ac2d-502a4d0a5a01 time: 2026-08-26T04:15:08+02:00 runtime: headless --- vibecrafted-core/tests/test_workflow.py | 120 ++++++++++++++++-- vibecrafted-core/vibecrafted_core/workflow.py | 40 ++++-- 2 files changed, 140 insertions(+), 20 deletions(-) diff --git a/vibecrafted-core/tests/test_workflow.py b/vibecrafted-core/tests/test_workflow.py index 217b11d8..decd4379 100644 --- a/vibecrafted-core/tests/test_workflow.py +++ b/vibecrafted-core/tests/test_workflow.py @@ -460,8 +460,16 @@ def test_transport_env_argument_supplies_canonical_retry_identity( assert len(pops) == 1 -def test_reserved_record_never_replays_accepted_and_pid_reuse_is_reclaimed( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path +@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") @@ -479,12 +487,23 @@ def test_reserved_record_never_replays_accepted_and_pid_reuse_is_reclaimed( "root": spec.root, "state": "reserved", "accepted": False, - "owner_pid": 1, + "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, "_pid_is_alive", lambda _pid: True) + monkeypatch.setattr( + workflow, + "validate_process_identity", + lambda *_args, **_kwargs: (False, stale_reason, None), + ) monkeypatch.setattr( workflow, "_stdin_command", @@ -500,6 +519,86 @@ def test_reserved_record_never_replays_accepted_and_pid_reuse_is_reclaimed( 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" + + def test_phantom_dispatched_record_returns_retryable_fail_closed_receipt( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: @@ -641,15 +740,16 @@ def test_stale_reservation_recovers_canonical_live_run_without_respawn( "launcher_identity": {"pid": os.getpid(), "pgid": os.getpgrp()}, }, ) - identity_checks = iter([False, True]) + 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), - "process_identity_current", - None, - ), + lambda *_args, **_kwargs: next(identity_checks), ) pops: list[object] = [] _patch_launch_popen(monkeypatch, pops) diff --git a/vibecrafted-core/vibecrafted_core/workflow.py b/vibecrafted-core/vibecrafted_core/workflow.py index 3bb17c6c..0976d137 100644 --- a/vibecrafted-core/vibecrafted_core/workflow.py +++ b/vibecrafted-core/vibecrafted_core/workflow.py @@ -2153,25 +2153,36 @@ def _canonical_run_launch_payload( return payload -def _record_owner_is_current(record: dict[str, Any]) -> bool: - """Qualify a reservation owner by full process identity, never PID alone.""" +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 "") - owner_pid = int(record.get("owner_pid") or 0) + 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 False + 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 False - current, _reason, _identity = validate_process_identity( + return "ambiguous", "process_identity_invalid" + current, reason, _identity = validate_process_identity( receipt, expected_pid=owner_pid, expected_pgid=pgid, expected_run_id=run_id, ) - return current + 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: @@ -2206,17 +2217,26 @@ def _replay_launch_if_current(record: dict[str, Any]) -> dict[str, Any] | None: return None state = str(record.get("state") or "") if state == "reserved": - if _record_owner_is_current(record): + 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 - if _run_is_terminal(run) or _run_has_current_process_proof(run_id, run): - return _canonical_run_launch_payload(record, run) return _retryable_launch_payload( record, status="retryable_unproven_liveness", From dc7a43b910efb8637fc83a381bdd0e79f8569ec3 Mon Sep 17 00:00:00 2001 From: div0-space Date: Wed, 26 Aug 2026 05:04:32 +0200 Subject: [PATCH 38/46] [codex/headless] fix(launch): reject malformed owner receipts Validate stored process identity receipts before OS recapture so malformed or context-conflicting evidence remains ambiguous and byte-stable. Preserve reclaim for complete receipts proven stale only after live identity capture. Authored-By: codex session_id: c432acb0-c870-4328-9188-e5954f6d951c runtime: headless time: 2026-08-26T05:05:01+02:00 --- .../tests/test_process_control.py | 53 +++++++- vibecrafted-core/tests/test_workflow.py | 117 ++++++++++++++++++ .../vibecrafted_core/process_control.py | 66 +++++++--- 3 files changed, 217 insertions(+), 19 deletions(-) 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_workflow.py b/vibecrafted-core/tests/test_workflow.py index decd4379..5ee86191 100644 --- a/vibecrafted-core/tests/test_workflow.py +++ b/vibecrafted-core/tests/test_workflow.py @@ -316,6 +316,38 @@ def fake_popen(*args: Any, **kwargs: Any) -> Any: 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: @@ -599,6 +631,91 @@ def test_ambiguous_reservation_owner_refuses_without_spawn_or_receipt_success( 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: 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 From ca1e5a250422bf02639b8c699490a5b2f645a4f2 Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 25 Aug 2026 05:22:42 +0200 Subject: [PATCH 39/46] fix(app): target status menu actions explicitly --- tests/tui/test_unified_app_contract.py | 43 ++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/tui/test_unified_app_contract.py b/tests/tui/test_unified_app_contract.py index 04b4cb99..893ae544 100644 --- a/tests/tui/test_unified_app_contract.py +++ b/tests/tui/test_unified_app_contract.py @@ -5,6 +5,7 @@ import json import os import plistlib +import re import shutil import stat import struct @@ -939,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 = [ From 2bfb92ddec8dd132e15d557e7f5e3079108181f8 Mon Sep 17 00:00:00 2001 From: div0-space Date: Wed, 26 Aug 2026 01:35:11 +0200 Subject: [PATCH 40/46] [claude/interactive] docs(skills): transfer AGENT_CANARY doctrine into vc-canary v2.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retire the docstring-WRITE cataloger brief: references/canary-agent-brief.md (en+pl) is now the mutation-free per-axis truth-radar contract — one-instrument law, machine-checkable absence proofs (offset==0, emitted==total, truncated==false, scan_complete), pair verdicts with row dispositions, and an honest NOT_ASSESSED exit. SKILL.md and FLOW.md (en+pl) gain the run-verdict vocabulary (AXES_CLOSED_CANDIDATE/AXES_OPEN/INSTRUMENT_INCOMPLETE/ LAUNCHER_CONTRACT_CONFLICT), the references-not-definitions census rule and the loctree-fail/UNRESOLVED contract learned in the codescribe canary and W2 re-entry (seed spec: codescribe/AGENT_CANARY.md). Authored-By: claude session_id: 76a219e6-7c55-4b91-b3c1-c8ec1acdf216 date: 2026-08-26T01:35:11 CEST runtime: claude time: 2026-08-26T01:35:18+02:00 --- .../skills/pl/vc-canary/FLOW.md | 5 +- .../skills/pl/vc-canary/SKILL.md | 62 ++++++++- .../references/canary-agent-brief.md | 122 +++++++++++------- .../vibecrafted_core/skills/vc-canary/FLOW.md | 5 +- .../skills/vc-canary/SKILL.md | 59 ++++++++- .../references/canary-agent-brief.md | 122 +++++++++++------- 6 files changed, 273 insertions(+), 102 deletions(-) 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-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. ``` From 7e89b43ee106a691ec31e9a6a8417fad865f2a7f Mon Sep 17 00:00:00 2001 From: div0-space Date: Wed, 26 Aug 2026 12:49:01 +0200 Subject: [PATCH 41/46] [claude/interactive] fix(install): verify source-staged generations with the installer interpreter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 50c36205 made the semantic verifier run with the candidate's own bin/python3, which only a Runtime Pack carries. A source-staged generation (_sync_control_plane_tree_locked) never provisions an interpreter — it is bound after publication by uv tool install — so every make install-source died with "candidate runtime Python is not executable". Pick the carried interpreter when present, the installer's when none is carried, and keep a carried-but-broken interpreter fail-closed (dangling symlink included). Authored-By: claude session_id: 8c1fe161-8f74-4373-ac59-07d525365ac9 time: 2026-08-26T12:49:01+02:00 runtime: interactive --- scripts/vetcoders_install.py | 19 +++++++++++++++++-- tests/tui/test_installer_doctor.py | 25 +++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/scripts/vetcoders_install.py b/scripts/vetcoders_install.py index c4dd14ed..3c0fa8e6 100755 --- a/scripts/vetcoders_install.py +++ b/scripts/vetcoders_install.py @@ -8707,11 +8707,26 @@ def _assert_runtime_verifier_semantic_failure( ) -def _validate_runtime_verifier_semantics(runtime_root: Path) -> None: - """Validate captured candidate code/schema and exercise its real public entrypoints.""" +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 diff --git a/tests/tui/test_installer_doctor.py b/tests/tui/test_installer_doctor.py index 61fc0ce6..4be4715f 100644 --- a/tests/tui/test_installer_doctor.py +++ b/tests/tui/test_installer_doctor.py @@ -954,6 +954,31 @@ def fake_run(argv, **_kwargs): 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, From 8714fa5da1a4b179b18bbe71b11e343470b2069a Mon Sep 17 00:00:00 2001 From: div0-space Date: Wed, 26 Aug 2026 18:17:27 +0200 Subject: [PATCH 42/46] [claude/interactive] fix(install): one launcher truth and a generation-carried vc-frame entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two source-install twins left by the Runtime Pack unification (50c36205): - ~/.local/bin/vibecrafted was linked to the package deck while the runtime-generation doctor demands the manifest-bound bin/vibecrafted entrypoint (byte-identical), so every clean install reported "launcher neither resolves to nor wraps the entrypoint". The launcher now targets _RUNTIME_GENERATION_ENTRYPOINT — one truth for installer and doctor. - install-foundations.sh wrote the vc-frame product wrapper INTO the published generation and symlinked ~/.local/bin/vc-frame at it; the next publish staged a fresh generation without it and the symlink dangled (doctor: raw cargo binary on PATH). Publication now materializes bin/vc-frame from scripts/vc-frame-product-entry.sh in every staged generation, and the foundations installer no longer mutates a generation that already carries it. Fixtures updated to the entrypoint contract; test_staged_tools_sync 167/167, installer doctor/keys/makefile suites 150/150. Authored-By: claude session_id: 8c1fe161-8f74-4373-ac59-07d525365ac9 time: 2026-08-26T18:17:27+02:00 runtime: interactive --- scripts/install-foundations.sh | 8 +++-- scripts/vetcoders_install.py | 33 +++++++++++++++---- tests/tui/test_installer_doctor.py | 5 +++ tests/tui/test_keys.py | 8 ++++- tests/tui/test_makefile_installer_contract.py | 5 +++ 5 files changed, 49 insertions(+), 10 deletions(-) diff --git a/scripts/install-foundations.sh b/scripts/install-foundations.sh index 6feb9af9..7fa31565 100755 --- a/scripts/install-foundations.sh +++ b/scripts/install-foundations.sh @@ -678,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/vetcoders_install.py b/scripts/vetcoders_install.py index 3c0fa8e6..df65d758 100755 --- a/scripts/vetcoders_install.py +++ b/scripts/vetcoders_install.py @@ -8248,6 +8248,24 @@ def _materialize_vc_frame_generation(runtime_root: Path) -> None: ) +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 = ( @@ -9149,6 +9167,7 @@ def _sync_control_plane_tree_locked( 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)) @@ -12876,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 ) diff --git a/tests/tui/test_installer_doctor.py b/tests/tui/test_installer_doctor.py index 4be4715f..d70e59dc 100644 --- a/tests/tui/test_installer_doctor.py +++ b/tests/tui/test_installer_doctor.py @@ -803,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) diff --git a/tests/tui/test_keys.py b/tests/tui/test_keys.py index b152e609..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: diff --git a/tests/tui/test_makefile_installer_contract.py b/tests/tui/test_makefile_installer_contract.py index 44ca13b8..c225cb5a 100644 --- a/tests/tui/test_makefile_installer_contract.py +++ b/tests/tui/test_makefile_installer_contract.py @@ -552,6 +552,11 @@ def fake_stage( "_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, "_write_runtime_generation_manifest", From fd87dcd1b3ae2bba3d4247a554ec05cd909c5c43 Mon Sep 17 00:00:00 2001 From: div0-space Date: Wed, 26 Aug 2026 18:27:03 +0200 Subject: [PATCH 43/46] [claude/interactive] fix(install): make install-tools expect the manifest-bound launcher entrypoint install-tools-held still verified ~/.local/bin/vibecrafted against the package deck path after 8714fa5d moved the launcher to bin/vibecrafted, so the very first install after that cut died at the entrypoint check ("resolves to .../bin/vibecrafted, expected installed target .../deck/vibecrafted") and left the launchd service on a superseded supervisor version (EX_CONFIG). Makefile and its contract test now agree with _launcher_symlink_target. Authored-By: claude session_id: 8c1fe161-8f74-4373-ac59-07d525365ac9 time: 2026-08-26T18:27:03+02:00 runtime: interactive --- Makefile | 2 +- tests/tui/test_makefile_installer_contract.py | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index 25ed232d..0034721b 100644 --- a/Makefile +++ b/Makefile @@ -417,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; \ diff --git a/tests/tui/test_makefile_installer_contract.py b/tests/tui/test_makefile_installer_contract.py index c225cb5a..7db8cc53 100644 --- a/tests/tui/test_makefile_installer_contract.py +++ b/tests/tui/test_makefile_installer_contract.py @@ -911,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" From 2d5ac77878b9f9f3c3fa9575837d336667abf748 Mon Sep 17 00:00:00 2001 From: div0-space Date: Wed, 26 Aug 2026 18:49:03 +0200 Subject: [PATCH 44/46] [claude/interactive] fix(deck): never run this generation's core under another generation's python MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inside a product session launcher.sh exports VIBECRAFTED_PYTHON pinned to the release generation. Its bin/python3 wrapper overwrites PYTHONPATH with that generation's vibecrafted-core, so a source-installed deck honouring the env ran vibecrafted_core.cli doctor with a foreign core whose _installer_module() loaded the release's old scripts/vetcoders_install.py — six phantom fails on a healthy host (manifest "corrupt", slack-provider, walkaround "drifted", ~/.config/vc-frame paths, vibecrafted:not-uv-tool). Same host with the env stripped: one fail (.zshrc). An installed deck (living inside vibecrafted-current) now honours VIBECRAFTED_PYTHON only when it lives in that same generation; otherwise the uv-tool shim shebang wins. A checkout deck keeps honouring an explicit VIBECRAFTED_PYTHON (dev runs, tests). Deck twin synced byte-for-byte; resolver tests cover both directions. Authored-By: claude session_id: 8c1fe161-8f74-4373-ac59-07d525365ac9 time: 2026-08-26T18:49:03+02:00 runtime: interactive --- scripts/vibecrafted | 21 +++++++- tests/tui/test_vibecrafted_launcher.py | 48 +++++++++++++++++++ .../vibecrafted_core/deck/vibecrafted | 21 +++++++- 3 files changed, 88 insertions(+), 2 deletions(-) diff --git a/scripts/vibecrafted b/scripts/vibecrafted index 42426f4b..777918ea 100755 --- a/scripts/vibecrafted +++ b/scripts/vibecrafted @@ -461,9 +461,28 @@ _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). + deck_real="$(cd "$(dirname "${BASH_SOURCE[0]}")" 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 diff --git a/tests/tui/test_vibecrafted_launcher.py b/tests/tui/test_vibecrafted_launcher.py index d28b86c9..a5961b8b 100644 --- a/tests/tui/test_vibecrafted_launcher.py +++ b/tests/tui/test_vibecrafted_launcher.py @@ -183,6 +183,54 @@ 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) + + 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 "{launcher_copy}"; _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": diff --git a/vibecrafted-core/vibecrafted_core/deck/vibecrafted b/vibecrafted-core/vibecrafted_core/deck/vibecrafted index 42426f4b..777918ea 100755 --- a/vibecrafted-core/vibecrafted_core/deck/vibecrafted +++ b/vibecrafted-core/vibecrafted_core/deck/vibecrafted @@ -461,9 +461,28 @@ _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). + deck_real="$(cd "$(dirname "${BASH_SOURCE[0]}")" 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 From 63e9571fbba27c6a7483858c960c466fa3651f5a Mon Sep 17 00:00:00 2001 From: div0-space Date: Wed, 26 Aug 2026 18:56:16 +0200 Subject: [PATCH 45/46] [claude/interactive] fix(deck): resolve the deck's own symlink before the generation-ownership check 2d5ac778 decided "is this deck installed inside vibecrafted-current" from the directory of BASH_SOURCE[0], but the public command ~/.local/bin/vibecrafted is a symlink into the generation, so the installed deck looked like a checkout deck and kept honouring the foreign VIBECRAFTED_PYTHON (runtime proof: 7 phantom doctor fails survived install #7). readlink -f the deck file first. Resolver test now sources the deck through a symlinked public launcher. Authored-By: claude session_id: 8c1fe161-8f74-4373-ac59-07d525365ac9 time: 2026-08-26T18:56:16+02:00 runtime: interactive --- scripts/vibecrafted | 5 ++++- tests/tui/test_vibecrafted_launcher.py | 7 ++++++- vibecrafted-core/vibecrafted_core/deck/vibecrafted | 5 ++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/scripts/vibecrafted b/scripts/vibecrafted index 777918ea..88099061 100755 --- a/scripts/vibecrafted +++ b/scripts/vibecrafted @@ -473,7 +473,10 @@ _vibecrafted_python_owned_by_current() { # 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). - deck_real="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd -P)" || return 0 + # ~/.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/"* ]] diff --git a/tests/tui/test_vibecrafted_launcher.py b/tests/tui/test_vibecrafted_launcher.py index a5961b8b..4984353b 100644 --- a/tests/tui/test_vibecrafted_launcher.py +++ b/tests/tui/test_vibecrafted_launcher.py @@ -212,13 +212,18 @@ def test_python_resolver_ignores_inherited_python_from_another_generation( 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 "{launcher_copy}"; _vibecrafted_python'], + ["bash", "-c", f'source "{public_launcher}"; _vibecrafted_python'], cwd=REPO_ROOT, env=env, capture_output=True, diff --git a/vibecrafted-core/vibecrafted_core/deck/vibecrafted b/vibecrafted-core/vibecrafted_core/deck/vibecrafted index 777918ea..88099061 100755 --- a/vibecrafted-core/vibecrafted_core/deck/vibecrafted +++ b/vibecrafted-core/vibecrafted_core/deck/vibecrafted @@ -473,7 +473,10 @@ _vibecrafted_python_owned_by_current() { # 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). - deck_real="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd -P)" || return 0 + # ~/.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/"* ]] From 1582952897f0548f3d57f4d564a1fde4285d65d5 Mon Sep 17 00:00:00 2001 From: div0-space Date: Wed, 26 Aug 2026 19:05:45 +0200 Subject: [PATCH 46/46] [claude/interactive] chore(format): prettier pass on three inherited files blocking pre-push release-dmg.yml, .loctree/canary/JOURNAL.md and AGENTS.md carried formatting drift from earlier commits (231347e1, 9fa04e40, 720714e1); the full-repo prettier gate in pre-push refused the branch. Formatting only, no content change. Authored-By: claude session_id: 8c1fe161-8f74-4373-ac59-07d525365ac9 time: 2026-08-26T19:05:45+02:00 runtime: interactive --- .github/workflows/release-dmg.yml | 1 - .loctree/canary/JOURNAL.md | 2 +- AGENTS.md | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) 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/.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