From 2e226b7fc2ff7ef64d6b63c5977232097f83f7e3 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Sun, 23 Aug 2026 16:56:32 +0000 Subject: [PATCH 1/3] revert: herdr agent state needed a python3 most containers do not have Removes what #367 added in 0.8.0 -- the socket mount, the setup stage, the hook, the pane variables on the agent's command line, `DEVLAUNCH_NO_HERDR`, and `devlaunch_core::flows::herdr` whole. The requirement decides this rather than the code. mcr.microsoft.com/ devcontainers/base:ubuntu-24.04 -- this repo's own base image, and the family a large share of devcontainers start from -- carries no python at all: not /usr/bin/python3, not a versioned binary without the symlink, nothing on any entry of a login shell's PATH. So the stage's `command -v python3` failed and it exited before writing anything, on a container that had every other piece in place: the socket was mounted and reachable (a hand-rolled `pane.list` over it from inside the container answers), and the command line carried `HERDR_ENV=1 HERDR_PANE_ID=w8:p1 HERDR_SOCKET_PATH=...` exactly as intended. Every part of this that runs on the host works; the half that had to run in the container could not, and a feature whose majority case is "installs nothing" is not a feature. Writing the payload differently does not fix it. The hook has to talk to a unix socket and merge a JSON settings file, and 0.8.0's own history is the argument against shell for that: the version that shelled out installed an *empty* hook in a container with no `cat` and reported success. A hook that runs inside the developer's own agent needs an interpreter that is really there. The other half of why it goes is that the failure could not be read. The stage prints its reason to stderr precisely so a developer can act on it, but only the exit status becomes a ProvisionEvent -- so a launch said `: the herdr setup stage exited 1.` and nothing else, every time, with the sentence naming the cause discarded. Two leftovers, both inert, and neither worth an uninstall stage. A container that did get the hook keeps ~/.devlaunch/herdr-agent-state.py and its entries in that container's own ~/.claude/settings.json until `dl recreate`, and the socket mount lands and leaves only at creation. The hook returns immediately without the variables aid no longer sets, and it exits 0 in every state including every failure, which is what makes leaving it safe. A host running herdr can still get correct badges with nothing from dl: `herdr --remote .devpod` runs a herdr server inside the workspace, where the agent really is the pane's foreground process. The price is one view per workspace instead of one spanning them all, and a herdr in the container whose version matches the host's. That is in the changelog entry, which is where the README section's one still-true paragraph went. --- CHANGELOG.md | 44 + README.md | 106 +- rust/aid/src/main.rs | 5 +- rust/aid/src/rewrite.rs | 143 +- rust/devlaunch-core/public-api.txt | 43 - rust/devlaunch-core/src/flows/herdr.rs | 1363 -------------------- rust/devlaunch-core/src/flows/launch.rs | 55 +- rust/devlaunch-core/src/flows/mod.rs | 2 - rust/devlaunch-core/src/flows/provision.rs | 250 +--- rust/dl/src/lib.rs | 8 - 10 files changed, 124 insertions(+), 1895 deletions(-) delete mode 100644 rust/devlaunch-core/src/flows/herdr.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 88a7fae2..3363c56c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Removed + +- **Reporting agent state to a host-side herdr session, added in 0.8.0, is gone** — + the mount, the setup stage, the hook, the pane variables on the agent's command + line and `DEVLAUNCH_NO_HERDR`. The container half needed a `python3` and most + containers do not have one, which made the feature's majority case "installs + nothing". + + It is the requirement rather than the code that decides this. + `mcr.microsoft.com/devcontainers/base:ubuntu-24.04` — this repo's own base image, + and the family a large share of devcontainers start from — carries no python at + all: not `/usr/bin/python3`, not a versioned binary without the symlink, nothing + on any entry of a login shell's PATH. So the stage's `command -v python3` failed + and it exited before writing anything, on a container that had every other piece + in place. The hook needs to talk to a unix socket and merge a JSON settings file, + and 0.8.0's own history is the argument against doing that in shell: the version + that shelled out installed an *empty* hook in a container with no `cat` and + reported success. Neither half of that is fixable by writing the payload + differently; a hook that runs inside the developer's own agent needs an + interpreter that is really there. + + The other half of why it goes is that the failure could not be read. The stage + prints its reason to stderr precisely so a developer can act on it — but only the + exit status becomes a `ProvisionEvent`, so a launch said + `: the herdr setup stage exited 1.` and nothing else, on every launch, with + the sentence naming the cause discarded. A feature whose majority case is a + refusal has to be able to say which refusal. + + **What a host running herdr can still do, with nothing from `dl`:** + `herdr --remote .devpod` runs a herdr server *inside* the + workspace, where the agent really is the pane's foreground process, so the badges + are correct with no mount and no hook. The price is one view per workspace rather + than one spanning them all, and a herdr in the container whose version matches + the host's. + + Two leftovers, both inert. A container that did get the hook keeps + `~/.devlaunch/herdr-agent-state.py` and its entries in that container's own + `~/.claude/settings.json` until `dl recreate`, and the socket mount likewise + lands and leaves only at creation. Neither does anything: the hook returns + immediately without the variables `aid` no longer sets, and it exits 0 in every + state including every failure, which is why it was safe to leave. `DEVLAUNCH_NO_HERDR` + is no longer read, so a host that set it needs no change; `DEVLAUNCH_NO_TOOLS` is + untouched and still covers the rest of provisioning. + ## [0.11.0] - 2026-08-23 ### Changed diff --git a/README.md b/README.md index c918ba72..cc29505f 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ launching one again attaches to what is already there. [![Platform](https://img.shields.io/badge/platform-linux--64-blue)](https://github.com/blooop/devlaunch/releases) [![Pixi Badge](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/prefix-dev/pixi/main/assets/badge/v0.json)](https://pixi.sh) -**Start here:** [Features](#features) · [Installation](#installation) · [Usage](#usage) · [Workspace Commands](#workspace-commands) · [Global Commands](#global-commands) · [aid](#aid-start-a-coding-agent-in-a-workspace) · [A terminal beside the agent](#a-terminal-beside-the-agent) · [Agent state in herdr](#agent-state-in-a-herdr-session) · [GitHub auth](#github-authentication) · [Tools in every workspace](#tools-in-every-workspace) · [Shell completion](#shell-completion) +**Start here:** [Features](#features) · [Installation](#installation) · [Usage](#usage) · [Workspace Commands](#workspace-commands) · [Global Commands](#global-commands) · [aid](#aid-start-a-coding-agent-in-a-workspace) · [A terminal beside the agent](#a-terminal-beside-the-agent) · [GitHub auth](#github-authentication) · [Tools in every workspace](#tools-in-every-workspace) · [Shell completion](#shell-completion) **Reference:** [Options](#options) · [Workspace IDs](#workspace-ids) · [Cleaning up](#cleaning-up-purge-prune-reconcile) · [pixi cache](#the-shared-pixi-package-cache) · [Worktree backend](#worktree-backend) · [Launch timing](#measuring-launch-time) · [Development](#development) @@ -407,7 +407,6 @@ are unaffected, and `dl -- claude` still runs exactly what you typed. | `--rm` | Run the agent, then [delete the workspace when the session ends](#--rm-the-throwaway-workspace). Appendable to a recalled line, prompt and all. To delete one *now* instead, that is `dl rm` | | `DEVLAUNCH_AID_AGENT=` | Change the default agent | | `DEVLAUNCH_NO_TTY=1` | No prompt question, no pty: the old one-shot behaviour | -| `DEVLAUNCH_NO_HERDR=1` | Do not report agent state to a host-side [herdr](#agent-state-in-a-herdr-session) session | Everything after the workspace is the prompt, flags and all, so it never needs quoting to survive `aid`'s own parsing. Managing workspaces — listing, stopping, @@ -651,109 +650,6 @@ names the pane instead. The window title zellij then publishes to the outer terminal is ` | `, so the spec is what shows up in a kitty tab bar. -## Agent state in a herdr session - -[herdr](https://herdr.dev) is a terminal multiplexer that shows, per pane, whether -the coding agent in it is **working**, **idle** or **blocked** waiting for you. Run -`aid` in a herdr pane and it shows none of that, and the reason is structural: -herdr decides which agent a pane holds from the pane's foreground process, and -under `aid` the host's process tree is `aid → dl → ssh` with the agent inside the -container. There is no `claude` on the host to find. - -Nothing else about the hop is broken — the pane's screen bytes arrive intact, and -herdr's own screen rules match against them correctly *once it believes an agent is -there*. So `dl` supplies the missing fact and herdr does the rest: - -```bash -aid blooop/devlaunch@fix/42 fix the flaky test # in a herdr pane; the badge appears -``` - -Three things make that work, and all three are automatic: - -| | | -|---|---| -| **The socket** | The host's herdr socket is bind-mounted into the workspace at `/var/tmp/devlaunch-herdr.sock` | -| **The pane** | `aid` puts `HERDR_ENV`, `HERDR_PANE_ID` and `HERDR_SOCKET_PATH` on the agent's own command line, with the socket path rewritten to the container's | -| **The hook** | A setup-pass stage installs `~/.devlaunch/herdr-agent-state.py` and wires it to claude's `SessionStart`, `UserPromptSubmit`, `Notification`, `Stop` and `SessionEnd` hooks | - -`Notification` is the event that earns the feature: it is what claude fires when it -is waiting for a human, which is the one state a fleet of workspaces exists to -surface. - -This pairs with [the terminal title](#naming-the-terminal-after-the-workspace) -rather than competing with it. herdr can classify state from a title as well as -from the screen, and `aid` deliberately suppresses claude's own title so the -workspace id is what stands — so under `aid` the title is never the state signal, -which is exactly why the state comes from the hook. What you get is both halves -naming different things: the badge says what the agent is doing, and the pane name -says which workspace it is doing it in. - -### What it costs, and when it does nothing - -**On a host not running herdr, nothing happens at all** — no mount, no stage, no -notice, and every command line byte-identical to what it was. That is decided from -whether a herdr socket exists rather than from whether the feature is enabled, so it -is true of the payload and not merely of its effects. - -The stage is one more line in the setup pass every entry into Running already pays, -and it can never fail a launch: a container it cannot satisfy reports the stage and -opens exactly as it would have. It reports at info level rather than warning, -because failing is its majority case — three things have to be true: - -- **`python3` in the container.** The hook talks to a unix socket and merges a JSON - settings file, and neither is something shell can do. herdr's own integration - requires `python3` for the same reason, so this asks for nothing extra. -- **A claude configuration directory of the container's own.** If the settings file - or the hook's own path is inside anything bind-mounted from the host, the stage - **refuses** rather than merging: writing hooks there would edit your real claude - settings from inside every container, on every launch. A mounted *parent* counts, - which is the shape that actually occurs — `dl` never mounts `~/.claude` into a - workspace, but **this repo's own devcontainer does** (`.devcontainer/claude-code` - binds `settings.json` among others), so a workspace on devlaunch itself reports - the stage and installs nothing, by design. Arbitrary repos, which is what `dl` - launches, have a container-local `~/.claude` and get the badge. -- **The socket mount**, which only lands at container creation. A workspace created - before this feature — or by a `dl` on a machine with no herdr running — needs - **`dl recreate`**, not `restart`. This is the one place it differs from the - zellij stage above, which needs only a restart because nothing it installs is a - mount. - -The hook is written whole and moved into place, and it exits 0 in every state -including every failure. A herdr that stopped listening, a socket whose session has -ended, a claude that changed its payload: each is a badge that does not appear, and -none is a turn that fails. - -`--codex` and `--gemini` are unaffected — the hook devlaunch installs is claude's, -and telling another agent where to report would name a reporter that is not there. - -| Variable | Description | -|----------|-------------| -| `DEVLAUNCH_NO_HERDR=1` | Do not mount the herdr socket into workspaces and do not install the hook. The rest of tool provisioning is untouched | - -**Mounting the socket gives the container control of your herdr session** — it is -the same socket herdr's own CLI drives, so something in there could open panes or -read other panes' output. That is the trade, and it is why there is an opt-out; it -is on by default for the reason the forwarded GitHub token is, namely that a `dl` -workspace is already where you run an agent with `--dangerously-skip-permissions`. -`DEVLAUNCH_NO_TOOLS=1` turns this off along with the rest of tool provisioning, and -both read the same values as the variables above: anything but empty, `0`, `false` -or `no` means yes, turn it off. - -### Why not herdr's own integration - -It does not install herdr in the container, and it does not use herdr's own -`herdr integration install claude`. That integration's hook sends -`pane.report_agent_session` — session *identity* for a pane herdr already knows -holds an agent — which registers nothing by itself, so forwarding it would carry -session metadata for an agent herdr does not believe exists. The reports here are -the ones that make the pane appear. - -The other way round works too and needs nothing from `dl`: `herdr --remote -.devpod` runs a herdr server *inside* the workspace, where the agent -really is the pane's foreground process. That gives correct badges with no mount and -no hook, at the price of one view per workspace instead of one spanning them all, -and a herdr in the container whose version must match the host's exactly. - ## GitHub Authentication Every workspace `dl` opens inherits the host's GitHub login, so `gh` is already diff --git a/rust/aid/src/main.rs b/rust/aid/src/main.rs index 234e7e9a..8c0d4821 100644 --- a/rust/aid/src/main.rs +++ b/rust/aid/src/main.rs @@ -102,10 +102,7 @@ fn run(argv: &[String]) -> i32 { // a shell. Anything else — an inline prompt, a verb line, a pipe — comes back // unchanged with no boot, and takes the path it always took. let (parsed, boot) = interactive::collect_prompt(parsed); - // Read here rather than in `rewrite`, which is deliberately a pure - // string-to-strings module: the pane is a fact about this process. - let pane = dl::herdr::Session::from_env(); - let Some(dl_args) = rewrite::build_dl_args(&parsed, pane.as_ref()) else { + let Some(dl_args) = rewrite::build_dl_args(&parsed) else { // Unreachable by a command line: the parse only ever answers with an agent // from the table, and a line that starts no agent cannot fail to build one. // Reported rather than panicked on, in the words the refusal for an invented diff --git a/rust/aid/src/rewrite.rs b/rust/aid/src/rewrite.rs index 3297de79..4cdb4b01 100644 --- a/rust/aid/src/rewrite.rs +++ b/rust/aid/src/rewrite.rs @@ -6,7 +6,6 @@ //! containers itself is an `aid` that builds one `dl` would have reused, which is //! the drift `aid.py` was rewritten to end. -use dl::herdr; use dl::shell; /// How one coding agent is started inside the workspace. @@ -23,12 +22,6 @@ struct Agent { /// Variables set for the agent's process and nothing else, sorted by name — /// Python sorts them, and the order is in the payload's bytes. env: &'static [(&'static str, &'static str)], - /// The label this agent is reported to herdr under, when there is a herdr pane - /// to report to and devlaunch installs a hook for it. - /// - /// `None` for the agents whose lifecycle events devlaunch does not yet wire up: - /// the variables alone would tell a hook that is not there where to report. - herdr: Option<&'static str>, } /// The agent this build starts when nothing picks one. @@ -80,7 +73,6 @@ const AGENTS: &[(&str, Agent)] = &[ ("CLAUDE_CODE_DISABLE_TERMINAL_TITLE", "1"), ("IS_SANDBOX", "1"), ], - herdr: Some("claude"), }, ), ( @@ -89,7 +81,6 @@ const AGENTS: &[(&str, Agent)] = &[ command: &["codex"], prompt_flags: &[], env: &[], - herdr: None, }, ), ( @@ -98,7 +89,6 @@ const AGENTS: &[(&str, Agent)] = &[ command: &["gemini"], prompt_flags: &["--prompt-interactive"], env: &[], - herdr: None, }, ), ]; @@ -418,11 +408,7 @@ fn agent_flag(word: &str) -> Option<&'static str> { /// /// `None` is an agent this build has no entry for, which only a caller inventing a /// name can produce — [`parse_aid_args`] answers with a name from the table. -pub(crate) fn build_agent_command( - agent: &str, - prompt: &str, - herdr: Option<&herdr::Session>, -) -> Option { +pub(crate) fn build_agent_command(agent: &str, prompt: &str) -> Option { let (_, started) = AGENTS.iter().find(|(name, _)| *name == agent)?; // No prompt to be interactive about: start the agent's plain session, without // the flags that only make sense alongside one. @@ -439,16 +425,6 @@ pub(crate) fn build_agent_command( .iter() .map(|(name, value)| format!("{name}={}", shell::quote(value))) .collect(); - // Which pane to report to, when this aid is running in one and the agent is one - // devlaunch installs a hook for. Nothing is added otherwise, which is what keeps - // every command line off a herdr-less host byte-identical to what it was. - if let (Some(session), Some(_)) = (herdr, started.herdr) { - line.extend( - herdr::session_env(session) - .into_iter() - .map(|(name, value)| format!("{name}={}", shell::quote(&value))), - ); - } line.push(shell::join(words)); Some(line.join(" ")) } @@ -467,10 +443,7 @@ pub(crate) fn build_agent_command( /// thing to complain about ahead of the spelling that is the actual problem. The /// options still go *after* the spec so that a `--force` among them lands where dl /// reads it as the modifier rather than as the workspace's name. -pub(crate) fn build_dl_args( - parsed: &AidArgs, - herdr: Option<&herdr::Session>, -) -> Option> { +pub(crate) fn build_dl_args(parsed: &AidArgs) -> Option> { let mut args = parsed.dl_options.clone(); args.push(parsed.spec.clone()); // Behind the spec and ahead of the verb flags, which is where dl reads them as @@ -479,7 +452,7 @@ pub(crate) fn build_dl_args( match &parsed.task { Task::Agent { agent, prompt } => { args.push("--".to_owned()); - args.push(build_agent_command(agent, prompt, herdr)?); + args.push(build_agent_command(agent, prompt)?); } Task::Retired => {} } @@ -635,15 +608,18 @@ mod tests { Task::Retired ); assert_eq!( - build_dl_args(&parsed(&["owner/repo", "fix the bug", "--autorm"]), None) + build_dl_args(&parsed(&["owner/repo", "fix the bug", "--autorm"])) .expect("a retired-spelling line needs no agent"), ["owner/repo", "--autorm"] ); assert_eq!( - build_dl_args( - &parsed(&["--devcontainer", "robot", "owner/repo", "hi", "--stop"]), - None - ) + build_dl_args(&parsed(&[ + "--devcontainer", + "robot", + "owner/repo", + "hi", + "--stop", + ])) .expect("a retired-spelling line needs no agent"), ["--devcontainer", "robot", "owner/repo", "--stop"] ); @@ -689,14 +665,14 @@ mod tests { // --dangerously-skip-permissions under uid 0, and a devcontainer running as // root is ordinary. assert_eq!( - build_agent_command("claude", "fix the bug", None).as_deref(), + build_agent_command("claude", "fix the bug").as_deref(), Some( "CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 IS_SANDBOX=1 claude \ --dangerously-skip-permissions 'fix the bug'" ) ); assert_eq!( - build_agent_command("claude", "", None).as_deref(), + build_agent_command("claude", "").as_deref(), Some( "CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 IS_SANDBOX=1 claude \ --dangerously-skip-permissions" @@ -704,60 +680,10 @@ mod tests { ); } - /// Inside a herdr pane the agent is told which pane to report to, and where the - /// socket is *inside the container* — the host's path would be a variable that - /// is set and useless. - #[test] - fn a_herdr_pane_is_named_on_the_agents_own_command_line() { - let session = herdr::Session::for_pane("w1:p2"); - // The pane's variables come after the agent's own, which keeps every - // command line Python ever produced byte-identical: those are sorted - // together and this is a suffix nothing else has an opinion about. - assert_eq!( - build_agent_command("claude", "fix it", Some(&session)).as_deref(), - Some( - "CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 IS_SANDBOX=1 \ - HERDR_ENV=1 HERDR_PANE_ID=w1:p2 \ - HERDR_SOCKET_PATH=/var/tmp/devlaunch-herdr.sock \ - claude --dangerously-skip-permissions 'fix it'" - ) - ); - } - - /// The variables are for a hook devlaunch installs, and it installs one for - /// claude only: setting them for an agent with no hook would name a reporter - /// that is not there. - #[test] - fn an_agent_with_no_hook_is_told_nothing_about_herdr() { - let session = herdr::Session::for_pane("w1:p2"); - for agent in ["codex", "gemini"] { - let command = build_agent_command(agent, "hi", Some(&session)).expect("a known agent"); - assert!(!command.contains("HERDR"), "{command}"); - } - } - - /// Outside a pane, every command line is the one it always was. - #[test] - fn no_pane_changes_no_command_line() { - for agent in agent_names() { - for prompt in ["", "fix it"] { - assert_eq!( - build_agent_command(agent, prompt, None), - build_agent_command(agent, prompt, None), - ); - assert!( - !build_agent_command(agent, prompt, None) - .expect("a known agent") - .contains("HERDR") - ); - } - } - } - #[test] fn an_agent_that_needs_no_variable_gets_none() { for agent in ["codex", "gemini"] { - let command = build_agent_command(agent, "hi", None).expect("a known agent"); + let command = build_agent_command(agent, "hi").expect("a known agent"); assert!(!command.contains("IS_SANDBOX"), "{command}"); } } @@ -765,13 +691,10 @@ mod tests { #[test] fn gemini_gets_its_interactive_flag_only_beside_a_prompt() { assert_eq!( - build_agent_command("gemini", "hi", None).as_deref(), + build_agent_command("gemini", "hi").as_deref(), Some("gemini --prompt-interactive hi") ); - assert_eq!( - build_agent_command("gemini", "", None).as_deref(), - Some("gemini") - ); + assert_eq!(build_agent_command("gemini", "").as_deref(), Some("gemini")); } #[test] @@ -779,14 +702,14 @@ mod tests { // Python's `shlex.quote` spelling, byte for byte: the payload travels in // argv, and a second command cannot be smuggled into it. assert_eq!( - build_agent_command("claude", "don't break \"this\"", None).as_deref(), + build_agent_command("claude", "don't break \"this\"").as_deref(), Some( "CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 IS_SANDBOX=1 claude \ --dangerously-skip-permissions 'don'\"'\"'t break \"this\"'" ) ); assert_eq!( - build_agent_command("claude", "hi; rm -rf /", None).as_deref(), + build_agent_command("claude", "hi; rm -rf /").as_deref(), Some( "CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 IS_SANDBOX=1 claude \ --dangerously-skip-permissions 'hi; rm -rf /'" @@ -796,7 +719,7 @@ mod tests { #[test] fn an_agent_nothing_knows_has_no_command() { - assert_eq!(build_agent_command("clippy", "hi", None), None); + assert_eq!(build_agent_command("clippy", "hi"), None); } // ------------------------------------------- the dl command line @@ -804,8 +727,7 @@ mod tests { #[test] fn the_dl_command_line_is_options_then_spec_then_the_command() { assert_eq!( - build_dl_args(&parsed(&["owner/repo@branch", "fix", "it"]), None) - .expect("a known agent"), + build_dl_args(&parsed(&["owner/repo@branch", "fix", "it"])).expect("a known agent"), [ "owner/repo@branch", "--", @@ -814,7 +736,7 @@ mod tests { ] ); assert_eq!( - build_dl_args(&parsed(&["--devcontainer", "robot", "owner/repo"]), None) + build_dl_args(&parsed(&["--devcontainer", "robot", "owner/repo"])) .expect("a known agent"), [ "--devcontainer", @@ -832,11 +754,8 @@ mod tests { // The prompt survives dl's own parsing of `-- `: dl joins // everything after `--` with spaces, so the quoting aid applies has to live // inside a single argument rather than be spread across several. - let args = build_dl_args( - &parsed(&["owner/repo", "fix", "the", "flaky", "test"]), - None, - ) - .expect("a known agent"); + let args = build_dl_args(&parsed(&["owner/repo", "fix", "the", "flaky", "test"])) + .expect("a known agent"); let after = args .iter() .position(|word| word == "--") @@ -856,7 +775,7 @@ mod tests { .expect("a usable command line"); assert_eq!(chosen.agent(), Some(name)); - assert!(build_agent_command(name, "hi", None).is_some(), "{name}"); + assert!(build_agent_command(name, "hi").is_some(), "{name}"); } } @@ -879,7 +798,7 @@ mod tests { // Behind the spec, because that is where dl reads a flag as a modifier // rather than as the workspace's name, and ahead of the `--`, because // everything after that belongs to the workspace's command. - let built = build_dl_args(&parsed(&["owner/repo", "fix it", "--rm"]), None) + let built = build_dl_args(&parsed(&["owner/repo", "fix it", "--rm"])) .expect("an agent line builds"); assert_eq!( @@ -899,7 +818,7 @@ mod tests { // An unknown leading flag is passed through to dl, which is all `--rm` // needs: dl accepts it in any position, unlike `--force`. let built = - build_dl_args(&parsed(&["--rm", "owner/repo", "fix it"]), None).expect("an agent line"); + build_dl_args(&parsed(&["--rm", "owner/repo", "fix it"])).expect("an agent line"); assert_eq!(built[0], "--rm"); assert_eq!(built[1], "owner/repo"); @@ -969,7 +888,7 @@ mod tests { assert_eq!(typed_backwards.spec_options, ["--rm", "--force"]); assert_eq!(prompt(&typed_backwards), "fix it"); - let built = build_dl_args(&typed_backwards, None).expect("an agent line"); + let built = build_dl_args(&typed_backwards).expect("an agent line"); assert_eq!( built.iter().position(|word| word == "--force"), Some(2), @@ -987,7 +906,7 @@ mod tests { assert_eq!(chosen.task, Task::Retired); assert_eq!(chosen.spec_options, ["--rm", "--autorm"]); assert_eq!( - build_dl_args(&chosen, None).expect("a retired-spelling line"), + build_dl_args(&chosen).expect("a retired-spelling line"), ["owner/repo", "--rm", "--autorm"] ); } @@ -1045,7 +964,7 @@ mod tests { assert_eq!(prompt(&chosen), "fix the bug"); assert_eq!( - build_dl_args(&chosen, None).expect("an agent line"), + build_dl_args(&chosen).expect("an agent line"), [ "owner/repo", "--rm", @@ -1063,7 +982,7 @@ mod tests { .with_prompt("explain this".to_owned()); assert_eq!( - build_dl_args(&chosen, None).expect("an agent line"), + build_dl_args(&chosen).expect("an agent line"), [ "owner/repo", "--", @@ -1077,7 +996,7 @@ mod tests { let chosen = parsed(&["owner/repo"]).with_prompt(String::new()); assert_eq!( - build_dl_args(&chosen, None).expect("an agent line"), + build_dl_args(&chosen).expect("an agent line"), [ "owner/repo", "--", diff --git a/rust/devlaunch-core/public-api.txt b/rust/devlaunch-core/public-api.txt index 434c3217..d92fc5f0 100644 --- a/rust/devlaunch-core/public-api.txt +++ b/rust/devlaunch-core/public-api.txt @@ -799,48 +799,6 @@ impl core::fmt::Debug for devlaunch_core::flows::disk_usage::DiskUsage pub fn devlaunch_core::flows::disk_usage::DiskUsage::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::marker::StructuralPartialEq for devlaunch_core::flows::disk_usage::DiskUsage pub fn devlaunch_core::flows::disk_usage::describe_usage(&devlaunch_core::flows::disk_usage::DiskUsage) -> alloc::string::String -pub mod devlaunch_core::flows::herdr -pub enum devlaunch_core::flows::herdr::HerdrSwitch -pub devlaunch_core::flows::herdr::HerdrSwitch::Report -pub devlaunch_core::flows::herdr::HerdrSwitch::Skip -impl devlaunch_core::flows::herdr::HerdrSwitch -pub fn devlaunch_core::flows::herdr::HerdrSwitch::from_env() -> Self -impl core::clone::Clone for devlaunch_core::flows::herdr::HerdrSwitch -pub fn devlaunch_core::flows::herdr::HerdrSwitch::clone(&self) -> devlaunch_core::flows::herdr::HerdrSwitch -impl core::cmp::Eq for devlaunch_core::flows::herdr::HerdrSwitch -impl core::cmp::PartialEq for devlaunch_core::flows::herdr::HerdrSwitch -pub fn devlaunch_core::flows::herdr::HerdrSwitch::eq(&self, &devlaunch_core::flows::herdr::HerdrSwitch) -> bool -impl core::default::Default for devlaunch_core::flows::herdr::HerdrSwitch -pub fn devlaunch_core::flows::herdr::HerdrSwitch::default() -> devlaunch_core::flows::herdr::HerdrSwitch -impl core::fmt::Debug for devlaunch_core::flows::herdr::HerdrSwitch -pub fn devlaunch_core::flows::herdr::HerdrSwitch::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result -impl core::marker::Copy for devlaunch_core::flows::herdr::HerdrSwitch -impl core::marker::StructuralPartialEq for devlaunch_core::flows::herdr::HerdrSwitch -pub struct devlaunch_core::flows::herdr::HostSocket(_) -impl devlaunch_core::flows::herdr::HostSocket -pub fn devlaunch_core::flows::herdr::HostSocket::from_env() -> core::option::Option -impl core::clone::Clone for devlaunch_core::flows::herdr::HostSocket -pub fn devlaunch_core::flows::herdr::HostSocket::clone(&self) -> devlaunch_core::flows::herdr::HostSocket -impl core::cmp::Eq for devlaunch_core::flows::herdr::HostSocket -impl core::cmp::PartialEq for devlaunch_core::flows::herdr::HostSocket -pub fn devlaunch_core::flows::herdr::HostSocket::eq(&self, &devlaunch_core::flows::herdr::HostSocket) -> bool -impl core::fmt::Debug for devlaunch_core::flows::herdr::HostSocket -pub fn devlaunch_core::flows::herdr::HostSocket::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result -impl core::marker::StructuralPartialEq for devlaunch_core::flows::herdr::HostSocket -pub struct devlaunch_core::flows::herdr::Session -impl devlaunch_core::flows::herdr::Session -pub fn devlaunch_core::flows::herdr::Session::for_pane(impl core::convert::Into) -> Self -pub fn devlaunch_core::flows::herdr::Session::from_env() -> core::option::Option -pub fn devlaunch_core::flows::herdr::Session::pane_id(&self) -> &str -impl core::clone::Clone for devlaunch_core::flows::herdr::Session -pub fn devlaunch_core::flows::herdr::Session::clone(&self) -> devlaunch_core::flows::herdr::Session -impl core::cmp::Eq for devlaunch_core::flows::herdr::Session -impl core::cmp::PartialEq for devlaunch_core::flows::herdr::Session -pub fn devlaunch_core::flows::herdr::Session::eq(&self, &devlaunch_core::flows::herdr::Session) -> bool -impl core::fmt::Debug for devlaunch_core::flows::herdr::Session -pub fn devlaunch_core::flows::herdr::Session::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result -impl core::marker::StructuralPartialEq for devlaunch_core::flows::herdr::Session -pub fn devlaunch_core::flows::herdr::session_env(&devlaunch_core::flows::herdr::Session) -> alloc::vec::Vec<(alloc::string::String, alloc::string::String)> pub mod devlaunch_core::flows::launch pub enum devlaunch_core::flows::launch::BranchNotNamed pub devlaunch_core::flows::launch::BranchNotNamed::Cold(devlaunch_core::flows::launch::ColdRefused) @@ -2033,7 +1991,6 @@ impl core::fmt::Debug for devlaunch_core::flows::provision::HostLayout pub fn devlaunch_core::flows::provision::HostLayout::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::marker::StructuralPartialEq for devlaunch_core::flows::provision::HostLayout pub struct devlaunch_core::flows::provision::Switches -pub devlaunch_core::flows::provision::Switches::herdr: devlaunch_core::flows::herdr::HerdrSwitch pub devlaunch_core::flows::provision::Switches::tools: devlaunch_core::flows::provision::ToolsSwitch pub devlaunch_core::flows::provision::Switches::zellij: devlaunch_core::flows::provision::ZellijSwitch impl devlaunch_core::flows::provision::Switches diff --git a/rust/devlaunch-core/src/flows/herdr.rs b/rust/devlaunch-core/src/flows/herdr.rs deleted file mode 100644 index 74d5e3b1..00000000 --- a/rust/devlaunch-core/src/flows/herdr.rs +++ /dev/null @@ -1,1363 +0,0 @@ -//! Telling a host-side [herdr](https://herdr.dev) session what the agent in a -//! container is doing. -//! -//! herdr is a terminal multiplexer that shows, per pane, whether the coding agent -//! in it is working, idle or waiting for input. It decides *which* agent a pane -//! holds from the pane's foreground process, and that is the one signal a dl -//! workspace cannot supply: run `aid` in a herdr pane and the host's process tree -//! is `aid → dl → ssh`, with the agent inside the container where no process table -//! on the host can see it. herdr registers no agent for the pane and shows no -//! state. -//! -//! What it is *not* is a rendering problem. The pane's screen bytes cross the ssh -//! hop intact — herdr's own screen rules match against them correctly once it -//! believes an agent is there — so what is missing is the identification, and herdr -//! takes an answer for that over its socket: `pane.report_agent` names the pane, -//! the agent and the state, and holds until released. -//! -//! herdr's other state signal, the OSC title, is not available here and that is -//! deliberate rather than a loss: `aid` starts claude with -//! `CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1` so the workspace id -//! [`crate::flows::launch`] writes is the name that stands. Under `aid` the title -//! names the workspace and the hook reports the state, which is why the two do not -//! contend for one signal. -//! -//! So this module carries three things across the boundary and nothing else: -//! -//! 1. **The socket**, bind-mounted into the container at [`CONTAINER_SOCKET`], so -//! something inside can reach herdr at all ([`up_args`]). -//! 2. **The pane's identity**, as variables on the agent's own command line -//! ([`session_env`]) — not workspace configuration, for the reason -//! [`session_env`] gives. -//! 3. **A hook** that turns the agent's own lifecycle events into reports -//! ([`hook_script`]), because the state has to come from something that knows -//! it, and a held report that never changes is a badge that lies. -//! -//! Nothing here runs on a host that is not using herdr: every entry point starts -//! from [`Session::from_env`] or [`HostSocket::from_env`] answering `None`. -//! -//! # What this deliberately does not do -//! -//! It does not install herdr in the container, and it does not use herdr's own -//! `herdr integration install claude`. That integration's hook sends -//! `pane.report_agent_session` — session *identity* for a pane herdr already knows -//! holds an agent — which registers nothing on its own, so forwarding it would -//! carry session metadata for an agent herdr does not believe exists. The reports -//! here are the ones that make the pane appear. - -use std::path::{Path, PathBuf}; - -use crate::shell::quote; - -// =========================================================================== -// the constants both ends of the boundary are written against -// =========================================================================== - -/// Set this to opt a machine out of reporting agent state to herdr. -/// -/// A variable of its own rather than a value of -/// [`crate::flows::provision::DISABLE_VAR`], for the reason -/// [`crate::flows::provision::ZELLIJ_DISABLE_VAR`] is separate: the questions -/// differ. `DEVLAUNCH_NO_TOOLS` is "put nothing in my containers", which costs the -/// `gh` and `claude` guarantee; this is "do not mount my herdr socket into -/// them", which is a question about one socket and answers nothing about tools. -pub(crate) const DISABLE_VAR: &str = "DEVLAUNCH_NO_HERDR"; - -/// herdr's marker that a process is running inside one of its panes. -const ENV_MARK_VAR: &str = "HERDR_ENV"; - -/// The pane a report is about, as herdr numbers them (`w1:p2`). -const PANE_VAR: &str = "HERDR_PANE_ID"; - -/// Where herdr's control socket is. Read from the environment on the host, and -/// *written* — pointing at [`CONTAINER_SOCKET`] — for the agent in the container. -const SOCKET_VAR: &str = "HERDR_SOCKET_PATH"; - -/// Where the host's herdr socket is mounted inside a workspace. -/// -/// An absolute path under `/var/tmp` rather than anything below `$HOME`, for the -/// reason the pixi cache target is: a mount target is decided on the host, before -/// this launch has ever asked the container where its home directory is, and a -/// guess at that is a mount that lands somewhere nobody reads. -pub(crate) const CONTAINER_SOCKET: &str = "/var/tmp/devlaunch-herdr.sock"; - -/// The `source` every report carries. -/// -/// herdr keys held authority by source, so this is what lets a report be replaced -/// and released by its own author without disturbing anyone else's. -const REPORT_SOURCE: &str = "devlaunch"; - -/// Where the hook lands in the container, and the string that identifies this -/// feature's own entries in a settings file. -/// -/// One constant for both, because the command a wired hook runs *is* this path: a -/// separate marker would be a second thing to keep in step with the first. -/// -/// Under devlaunch's own directory, which is the same argument -/// [`crate::flows::provision`] makes for `$HOME/.devlaunch/pixi`: every path below -/// it is one devlaunch created, so nothing else syncs, prunes or rewrites it. -const HOOK_RELPATH: &str = ".devlaunch/herdr-agent-state.py"; - -/// Where the installer reads this container's mount table from. -/// -/// A variable rather than the one path, so the host-shared refusal can be tested -/// against a stated set of mounts. A test cannot mount anything -- that needs -/// privileges a test run has no business holding -- and a guard nothing exercises -/// is a guard that is wrong the first time it matters. -const MOUNTINFO_VAR: &str = "DEVLAUNCH_MOUNTINFO"; - -// =========================================================================== -// the opt-out -// =========================================================================== - -/// Whether this launch may report agent state to herdr. -/// -/// A type of its own rather than a bool, for the reason -/// [`crate::flows::provision::ZellijSwitch`] is one: it travels beside other -/// switches, and two bools next to each other in a signature are two a caller can -/// swap without the compiler noticing. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum HerdrSwitch { - /// The default: a machine that set no variable reports. - #[default] - Report, - Skip, -} - -impl HerdrSwitch { - /// What `DEVLAUNCH_NO_HERDR` set to `value` asks for. - /// - /// Through the same [`crate::flows::provision::provisioning_disabled`] the - /// other opt-outs read, so `DEVLAUNCH_NO_HERDR=0` means what - /// `DEVLAUNCH_NO_TOOLS=0` means. Two escape hatches a user spells the same way - /// must answer the same way. - pub(crate) fn requested(value: Option<&str>) -> Self { - if crate::flows::provision::provisioning_disabled(value) { - Self::Skip - } else { - Self::Report - } - } - - /// What the process environment asks for. - /// - /// A host with no herdr socket answers `Skip`, and that is not an optimisation: - /// the stage this gates prints a line whenever it cannot do its work, at the - /// level [`crate::flows::provision::HERDR_STAGE`] declares — so a machine that - /// has never heard of herdr would be told, on every single launch, that a stage - /// it did not ask for exited 1. Deciding it here rather than inside the stage is - /// what makes "nothing changes on a host not running herdr" true of the payload - /// rather than only of its effects. - /// - /// Resolved separately from the socket [`crate::flows::launch::Host`] carries, - /// and the two can in principle disagree — a herdr that stopped between the two - /// reads. Harmless in both directions: a mount with no stage installs no hook, - /// and a stage with no mount is the case the stage's own first check names. - pub fn from_env() -> Self { - Self::decide( - crate::osext::env_str(DISABLE_VAR).as_deref(), - HostSocket::from_env().as_ref(), - ) - } - - /// [`Self::from_env`]'s decision, as a function of its two inputs. - /// - /// Split out for the reason [`crate::flows::provision::provisioning_disabled`] - /// is a parameter rather than a read: a test states the host it means instead of - /// mutating an environment the whole binary shares. - fn decide(disable: Option<&str>, socket: Option<&HostSocket>) -> Self { - match (Self::requested(disable), socket) { - (Self::Skip, _) | (Self::Report, None) => Self::Skip, - (Self::Report, Some(_)) => Self::Report, - } - } -} - -// =========================================================================== -// the host's half: is there a herdr, and which pane are we in -// =========================================================================== - -/// A herdr control socket on this host, which exists and is a socket. -/// -/// Checked rather than assumed, because the whole of what this is for is being -/// bind-mounted, and `--mount` is strict about its source in a way `-v` is not: -/// `-v` on a missing path invents a directory, where `--mount` refuses with -/// `bind source path does not exist` and takes `devpod up` — and so the launch — -/// down with it. Resolving the socket first is what keeps a herdr that is not -/// running from costing a workspace. -/// -/// It narrows that to a race rather than closing it: a herdr that stops between -/// this check and the `up` leaves a mount whose source has gone, and that launch -/// fails. Nothing the host can read rules it out, and the window is one launch's -/// setup. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct HostSocket(PathBuf); - -impl HostSocket { - /// The socket this host's herdr is listening on, if it is listening. - /// - /// `HERDR_SOCKET_PATH` first, because inside a pane that is herdr's own answer - /// and it is authoritative. Falling back to the XDG location covers the case - /// this feature needs it for: the container is created by a `dl` that is *not* - /// in a pane — a prewarm, a `dl up`, an `aid` in a plain terminal — and a - /// mount only lands at creation, so a socket found only inside a pane would - /// leave every such workspace unable to report later without a - /// `dl recreate`. - pub fn from_env() -> Option { - let named = crate::osext::env_str(SOCKET_VAR) - .filter(|value| !value.is_empty()) - .map(PathBuf::from); - let candidate = match named { - Some(path) => path, - None => default_socket()?, - }; - Self::at(&candidate) - } - - /// The socket at `path`, if there is a socket there and it can be named in a - /// mount specification. - /// - /// The second condition is not fussiness. A specification is comma-delimited - /// `key=value` pairs, so a comma in the path does not produce a wrong mount — - /// it produces a `docker run` that rejects its own command line, which fails - /// `devpod up` and with it the launch. Refusing here is what keeps that - /// impossible: no socket is a state the whole module already handles, and the - /// mount is the one part of this that a stage's "can never fail a launch" - /// contract does not cover. - fn at(path: &Path) -> Option { - use std::os::unix::fs::FileTypeExt; - let name = path.to_str()?; - if name.contains(',') || name.chars().any(char::is_control) { - return None; - } - let kind = std::fs::metadata(path).ok()?.file_type(); - kind.is_socket().then(|| Self(path.to_owned())) - } - - /// The path, for a mount argument. - pub(crate) fn as_str(&self) -> Option<&str> { - self.0.to_str() - } -} - -/// herdr's socket where XDG says its config lives. -/// -/// `XDG_CONFIG_HOME` and not a bare `~/.config`, so a host that moved its config -/// tree is followed rather than guessed past. This is only ever used to *find* a -/// socket that must already exist, so a wrong answer here fails closed. -fn default_socket() -> Option { - let config = match crate::osext::env_str("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) { - Some(dir) => PathBuf::from(dir), - None => crate::osext::home_dir()?.join(".config"), - }; - Some(config.join("herdr").join("herdr.sock")) -} - -/// The herdr pane this process is running in. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Session { - pane_id: String, -} - -impl Session { - /// The pane this process is in, if it is in one. - /// - /// Both signals are required. `HERDR_ENV` alone says a herdr is around but not - /// which pane a report would be about, and a pane id alone (an inherited - /// variable, a copied shell) names a pane nothing here is running in. herdr's - /// own hook makes the same pair of checks before it reports anything. - pub fn from_env() -> Option { - let marked = crate::osext::env_str(ENV_MARK_VAR)?; - if crate::osext::strip(&marked) != "1" { - return None; - } - let pane_id = crate::osext::env_str(PANE_VAR).filter(|value| !value.is_empty())?; - Some(Self { pane_id }) - } - - /// The pane, as herdr names it. - pub fn pane_id(&self) -> &str { - &self.pane_id - } - - /// A named pane, for a caller that already knows which one it is in. - /// - /// Public because the one thing built on top of this lives in another crate: - /// `aid` puts a session's variables on the agent's command line, and a test of - /// that has to be able to say which pane it means without setting variables the - /// rest of the test binary shares. - pub fn for_pane(pane_id: impl Into) -> Self { - Self { - pane_id: pane_id.into(), - } - } -} - -// =========================================================================== -// what a launch adds -// =========================================================================== - -/// The `devpod up` flags that put the herdr socket in the container. -/// -/// A bind mount of the socket *file*, not of the directory holding it — the -/// argument this repo's own devcontainer makes for mounting two files out of -/// `~/.ssh` rather than the directory: a directory mount carries everything else -/// in it too, and herdr's config directory holds this developer's keybindings and -/// its own state. -/// -/// Empty without a socket, which is what a host with no herdr gets, and is why -/// this can be called unconditionally. -pub(crate) fn up_args(socket: Option<&HostSocket>, switch: HerdrSwitch) -> Vec { - let (HerdrSwitch::Report, Some(socket)) = (switch, socket) else { - return Vec::new(); - }; - let Some(source) = socket.as_str() else { - return Vec::new(); - }; - vec![ - "--mount".to_owned(), - format!("type=bind,source={source},target={CONTAINER_SOCKET}"), - ] -} - -/// The variables the agent's own command line carries, so the hook knows where to -/// report. -/// -/// On the command line and **not** through `--workspace-env`, which is where the -/// pixi cache's variable goes. Two reasons, and either alone decides it: -/// -/// - A pane id is a fact about *this session*, not about the workspace. Workspace -/// environment is written by an `up`, and attaching to a workspace that is -/// already running skips the `up` entirely — so the container would keep -/// whichever pane id was current when it was last built and report this session's -/// state into a pane that has since become something else. -/// - `aid` already passes `IS_SANDBOX=1` this way, so the mechanism is one the -/// agent command already uses rather than a second one invented here. -/// -/// The socket path is rewritten to [`CONTAINER_SOCKET`] rather than passed through. -/// Forwarding the host's path is the one failure that is silent: the hook finds a -/// variable set, tries a path that does not exist inside the container, and gives -/// up without reporting anything. -pub fn session_env(session: &Session) -> Vec<(String, String)> { - vec![ - (ENV_MARK_VAR.to_owned(), "1".to_owned()), - (PANE_VAR.to_owned(), session.pane_id.clone()), - (SOCKET_VAR.to_owned(), CONTAINER_SOCKET.to_owned()), - ] -} - -// =========================================================================== -// the container's half: the hook -// =========================================================================== - -/// The shell script that installs the state-reporting hook in a workspace. -/// -/// One stage's command, and like every other stage it may fail without costing the -/// launch anything: a container with no `python3`, or one whose claude -/// configuration directory is shared with the host, is a container that reports no -/// agent state and opens exactly as it would have. -/// -/// Two shell tests and then **one** `python3`, which does all of the work: resolve -/// the configuration directory, refuse a shared one, write the hook, make it -/// executable, and merge the settings. Shell does none of it, and that is the -/// lesson of the version that did — it wrote the hook with `cat > "$hook" < String { - hook_script_at(CONTAINER_SOCKET) -} - -/// [`hook_script`] against a named socket, so a test can run the real script -/// against a socket of its own rather than against the one absolute path a -/// container has. -fn hook_script_at(socket: &str) -> String { - [ - "set -u".to_owned(), - // A stage shares the probe's stdout, and both readers split marked lines on - // spaces. Nothing here is the answer to anything, so send the lot to stderr - // rather than risk one line being read as protocol. - "exec >&2".to_owned(), - // The socket first, because it is the one refusal with something the - // developer can do about it, and the reason has to be printed here: a - // stage's exit status is all the host learns. - format!( - "if [ ! -S {} ]; then echo 'devlaunch: no herdr socket in this container; \ - dl recreate adds it (a mount only lands at creation)'; exit 1; fi", - quote(socket) - ), - "if ! command -v python3 >/dev/null 2>&1; then echo 'devlaunch: no python3 in this \ - container; herdr agent state needs one'; exit 1; fi" - .to_owned(), - format!("python3 - <<'{DELIMITER}'\n{}\n{DELIMITER}", installer()), - ] - .join("\n") -} - -/// The heredoc delimiter the installer is written with. -/// -/// Quoted at the opening (`<<'…'`) so the shell expands nothing inside: the payload -/// is python, and it is full of `$`. -const DELIMITER: &str = "DEVLAUNCH_HERDR_EOF"; - -/// The python that installs the hook and wires it up. -/// -/// It writes the hook with the interpreter running it as the hook's own shebang -/// (`sys.executable`) rather than `#!/usr/bin/env python3`. The python that -/// installed the hook is by construction one that exists and runs in this -/// container; `env` resolving `python3` again at hook time is a second lookup that -/// can answer differently — a different PATH under claude, or none. -fn installer() -> String { - let events = HOOK_EVENTS - .iter() - .map(|(event, action)| format!(" (\"{event}\", \"{action}\"),")) - .collect::>() - .join("\n"); - format!( - r####"import json -import os -import stat -import sys - -EVENTS = [ -{events} -] -HOOK_RELPATH = "{hook_relpath}" -# The table that says which of this container's paths are shared with the host. -# Overridable so the refusal below can be tested against a stated set of mounts: -# the alternative is a test that has to mount something, which needs privileges a -# test run does not have. -MOUNTINFO = os.environ.get("{mountinfo_var}") or "/proc/self/mountinfo" -HOOK_SOURCE = r'''{hook_source}''' - -home = os.path.expanduser("~") -config_dir = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.join(home, ".claude") - -hook = os.path.join(home, HOOK_RELPATH) -settings_path = os.path.join(config_dir, "settings.json") - - -def host_shared(path): - """The mount that would carry a write to `path` back to the host, or None. - - A path is host-shared when the most specific mount covering it is a bind of - somewhere else -- which is what mountinfo's root field says: it is "/" for a - filesystem mounted whole (the container's own root, a tmpfs on /tmp) and the - source subpath for a bind. Asking whether `path` is *itself* a mount point is - the version of this that does not work, because the common shape is a mounted - parent: this repo's own devcontainer binds ~/.claude/hooks and ~/.claude/agents - while ~/.claude itself is container-local, and a container that binds ~/.config - with CLAUDE_CONFIG_DIR inside it shares the settings file without ever making - it a mount point. - """ - try: - with open(MOUNTINFO, encoding="utf-8") as handle: - lines = handle.read().splitlines() - except FileNotFoundError: - # No mountinfo is no evidence of sharing, and this is the ordinary state - # of anything that is not Linux. - return None - covering = None - target = os.path.realpath(path) - for line in lines: - fields = line.split(" ") - if len(fields) < 5: - continue - root, point = fields[3], os.path.realpath(fields[4]) - if target == point or target.startswith(point.rstrip("/") + "/"): - if covering is None or len(point) >= len(covering[1]): - covering = (root, point) - if covering and covering[0] != "/": - return covering[1] - return None - - -# Writing into a directory the host shares would edit the developer's own claude -# settings, from inside every container, on every launch. Refused rather than -# merged into: the reports this buys are not worth a machine-wide edit nobody -# asked for. An unreadable mountinfo is not taken as permission. -try: - shared = host_shared(settings_path) or host_shared(hook) -except Exception: - print("devlaunch: cannot tell whether %s is shared with the host; not writing hooks" % config_dir) - raise SystemExit(1) -if shared: - print("devlaunch: %s is mounted from the host; not writing hooks into it" % shared) - raise SystemExit(1) - -os.makedirs(os.path.dirname(hook), exist_ok=True) -os.makedirs(config_dir, exist_ok=True) - -# Written whole and then moved, so a hook that exists is a hook that is complete: -# claude runs this file on every event, and a truncated one is a syntax error per -# turn. The shebang is this interpreter, which is one that demonstrably runs here. -source = "#!%s\n%s" % (sys.executable, HOOK_SOURCE) -tmp = hook + ".devlaunch.tmp" -with open(tmp, "w", encoding="utf-8") as handle: - handle.write(source) -os.chmod(tmp, stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH) -os.replace(tmp, hook) - -# A merge and not a write. A container's settings file can hold the repo's own -# hooks, a base image's, or a developer's dotfiles' -- and this runs on every -# launch, so a write would delete them all, repeatedly. -try: - with open(settings_path, encoding="utf-8") as handle: - settings = json.load(handle) -except FileNotFoundError: - settings = {{}} -except Exception: - # An unreadable settings file is somebody else's: truncating it to install a - # badge would cost more than the badge is worth. - print("devlaunch: %s is not readable JSON; leaving it alone" % settings_path) - raise SystemExit(1) -if not isinstance(settings, dict): - print("devlaunch: %s is not a JSON object; leaving it alone" % settings_path) - raise SystemExit(1) - -hooks = settings.setdefault("hooks", {{}}) -if not isinstance(hooks, dict): - print("devlaunch: the hooks in %s are not an object; leaving them alone" % settings_path) - raise SystemExit(1) - -for event, action in EVENTS: - entries = hooks.get(event) - if not isinstance(entries, list): - entries = [] - # Ours out, everyone else's untouched, ours back in. Found by the hook's own - # settings_path rather than by an exact command match, so an entry written by an older - # devlaunch is replaced rather than duplicated. - kept = [] - for entry in entries: - inner = entry.get("hooks") if isinstance(entry, dict) else None - if isinstance(inner, list) and any( - isinstance(item, dict) and HOOK_RELPATH in str(item.get("command", "")) - for item in inner - ): - continue - kept.append(entry) - kept.append( - {{"hooks": [{{"type": "command", "command": "%s %s" % (hook, action)}}]}} - ) - hooks[event] = kept - -tmp = settings_path + ".devlaunch.tmp" -with open(tmp, "w", encoding="utf-8") as handle: - json.dump(settings, handle, indent=2) - handle.write("\n") -os.replace(tmp, settings_path) -"####, - events = events, - hook_relpath = HOOK_RELPATH, - mountinfo_var = MOUNTINFO_VAR, - hook_source = hook_source(), - ) -} - -/// The hook itself, as it lands in the container: one python file. -/// -/// Python and not a shell script wrapping python, which is what herdr's own -/// integration ships. The shell half of that shape exists to spool claude's JSON -/// into a temporary file, so it needs `mktemp`, `cat` and `rm` at hook time — three -/// more commands a container can lack, on a path that runs on every event of every -/// turn. Reading `sys.stdin` needs none of them. -/// -/// Exits 0 in every state, including every failure. A hook that fails a tool call, -/// or interrupts a session, to report a badge has cost more than the badge is -/// worth — and this one runs inside the developer's own agent, not beside it. -fn hook_source() -> String { - format!( - r##"# installed by devlaunch; edits are overwritten on the next launch. -import json -import os -import socket -import sys -import time - -# The state each event means. Two of claude's events are deliberately absent: -# PreToolUse/PostToolUse would report "working" many times a turn for a state Stop -# already ends, and SubagentStop is a completion that can arrive after the main -# turn has stopped -- reporting idle from it would end a turn still running. -STATES = {{ - "session-start": "idle", - "prompt": "working", - "notification": "blocked", - "stop": "idle", -}} -RELEASE = "session-end" - - -def main(): - action = sys.argv[1] if len(sys.argv) > 1 else "" - pane_id = os.environ.get("{pane}") - socket_path = os.environ.get("{socket}") - if os.environ.get("{mark}") != "1" or not pane_id or not socket_path: - return - - try: - text = sys.stdin.read() - except Exception: - text = "" - try: - payload = json.loads(text) if text.strip() else {{}} - except Exception: - payload = {{}} - if not isinstance(payload, dict): - payload = {{}} - - # A subagent's events are not the session's state: claude runs them inside a - # turn that is still working, and letting one report idle would end it early. - if payload.get("agent_id"): - return - - # Ordering, not identity: reports can cross on the socket, and herdr keeps the - # newest by this number rather than by arrival. - params = {{ - "pane_id": pane_id, - "source": "{source}", - "agent": "claude", - "seq": time.time_ns(), - }} - - if action == RELEASE: - method = "pane.release_agent" - else: - state = STATES.get(action) - if state is None: - return - method = "pane.report_agent" - params["state"] = state - session_id = payload.get("session_id") - if isinstance(session_id, str) and session_id: - params["agent_session_id"] = session_id - transcript = payload.get("transcript_path") - if isinstance(transcript, str) and transcript: - params["agent_session_path"] = transcript - - request = {{ - "id": "{source}:%d" % params["seq"], - "method": method, - "params": params, - }} - client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - client.settimeout(0.5) - try: - client.connect(socket_path) - client.sendall((json.dumps(request) + "\n").encode()) - try: - client.recv(4096) - except Exception: - pass - finally: - client.close() - - -try: - main() -except Exception: - # Never the agent's problem. A herdr that is not listening, a socket whose - # session has ended, a claude that changed its payload: all of them are a - # badge that does not appear, and none of them is a turn that fails. - pass -"##, - mark = ENV_MARK_VAR, - pane = PANE_VAR, - socket = SOCKET_VAR, - source = REPORT_SOURCE, - ) -} - -/// Which of claude's hook events this reports from, and the word the hook is -/// called with for each. -/// -/// The four that bound a turn, plus the end of the session. `Notification` is the -/// one that earns this feature: it is what claude fires when it is waiting for a -/// human, which is the state a fleet view exists to surface. -const HOOK_EVENTS: [(&str, &str); 5] = [ - ("SessionStart", "session-start"), - ("UserPromptSubmit", "prompt"), - ("Notification", "notification"), - ("Stop", "stop"), - ("SessionEnd", "session-end"), -]; - -#[cfg(test)] -mod tests { - use super::*; - - use std::fs; - use std::os::unix::net::UnixListener; - use std::process::Command; - - /// Run `script` with exactly `env` and nothing else — the hermetic shape - /// [`crate::flows::provision`]'s script tests use, for the same reason: a test - /// of "this container has no python3" must not find the developer's own. - fn bash_with(script: &str, env: &[(&str, &str)]) -> std::process::Output { - let bash = which("bash").unwrap_or_else(|| PathBuf::from("/bin/bash")); - let mut command = Command::new(bash); - command.arg("-c").arg(script).env_clear(); - for (name, value) in env { - command.env(name, value); - } - command.output().expect("bash ran") - } - - /// The first `program` on the real PATH — python3 is in the project - /// environment rather than in `/usr/bin`, so a hardcoded list would not find it. - fn which(program: &str) -> Option { - std::env::var_os("PATH").and_then(|path| { - std::env::split_paths(&path) - .map(|dir| dir.join(program)) - .find(|candidate| candidate.is_file()) - }) - } - - /// A PATH carrying only the commands named, linked out of the real ones. - fn sysbin(scratch: &Path, commands: &[&str]) -> PathBuf { - let sysbin = scratch.join("sysbin"); - fs::create_dir_all(&sysbin).expect("a scratch sysbin"); - for command in commands { - let found = which(command).unwrap_or_else(|| panic!("the test host needs {command}")); - let link = sysbin.join(command); - if !link.exists() { - std::os::unix::fs::symlink(found, link).expect("a linked command"); - } - } - sysbin - } - - /// Everything the installer script shells out to. - const INSTALLER_COMMANDS: [&str; 1] = ["python3"]; - - /// A scratch home, and a real socket standing in for the mounted one. - fn a_container(scratch: &Path) -> (PathBuf, PathBuf, UnixListener) { - let home = scratch.join("home"); - fs::create_dir_all(&home).expect("a scratch home"); - let socket_path = scratch.join("herdr.sock"); - let listener = UnixListener::bind(&socket_path).expect("a scratch socket"); - (home, socket_path, listener) - } - - #[test] - fn the_opt_out_reads_the_values_every_other_opt_out_reads() { - assert_eq!(HerdrSwitch::requested(None), HerdrSwitch::Report); - for falsey in ["", "0", "false", "no", "NO", " false "] { - assert_eq!( - HerdrSwitch::requested(Some(falsey)), - HerdrSwitch::Report, - "{falsey:?} must not turn it off" - ); - } - for truthy in ["1", "yes", "true", "anything"] { - assert_eq!( - HerdrSwitch::requested(Some(truthy)), - HerdrSwitch::Skip, - "{truthy:?} must turn it off" - ); - } - } - - #[test] - fn no_socket_is_no_mount() { - assert_eq!(up_args(None, HerdrSwitch::Report), Vec::::new()); - } - - #[test] - fn the_opt_out_beats_a_socket_that_is_there() { - let socket = HostSocket(PathBuf::from("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/run/herdr.sock")); - assert_eq!( - up_args(Some(&socket), HerdrSwitch::Skip), - Vec::::new() - ); - } - - #[test] - fn a_socket_is_mounted_as_a_file_at_the_fixed_target() { - let socket = HostSocket(PathBuf::from("/home/dev/.config/herdr/herdr.sock")); - assert_eq!( - up_args(Some(&socket), HerdrSwitch::Report), - vec![ - "--mount".to_owned(), - format!( - "type=bind,source=/home/dev/.config/herdr/herdr.sock,target={CONTAINER_SOCKET}" - ), - ] - ); - } - - /// The one failure that would be silent: the container told to talk to a path - /// that only exists on the host. - #[test] - fn the_session_carries_the_container_socket_not_the_hosts() { - let session = Session { - pane_id: "w1:p2".to_owned(), - }; - let env = session_env(&session); - assert_eq!( - env, - vec![ - ("HERDR_ENV".to_owned(), "1".to_owned()), - ("HERDR_PANE_ID".to_owned(), "w1:p2".to_owned()), - ("HERDR_SOCKET_PATH".to_owned(), CONTAINER_SOCKET.to_owned()), - ] - ); - } - - /// A mount specification is comma-delimited, so a comma anywhere in the source - /// path does not make a bad mount — it makes `docker run` reject the whole - /// command line, which fails `devpod up` and with it the launch. This module - /// promises never to cost a launch anything, and the mount is the one part of it - /// that is not inside a stage's protection. - #[test] - fn a_socket_whose_path_would_break_a_mount_specification_is_refused() { - let scratch = tempfile::tempdir().expect("a scratch directory"); - for name in ["co,mma", "new\nline"] { - let dir = scratch.path().join(name); - fs::create_dir_all(&dir).expect("a scratch directory"); - let path = dir.join("herdr.sock"); - let _listener = UnixListener::bind(&path).expect("a scratch socket"); - assert_eq!( - HostSocket::at(&path), - None, - "{name:?} must not reach a mount specification" - ); - } - } - - #[test] - fn a_path_that_is_not_a_socket_is_not_one() { - let dir = std::env::temp_dir(); - assert_eq!(HostSocket::at(&dir), None); - assert_eq!(HostSocket::at(&dir.join("devlaunch-absent-xyz")), None); - } - - #[test] - fn the_installer_refuses_before_it_writes() { - let script = hook_script(); - let socket_check = script - .find(&format!("-S {}", quote(CONTAINER_SOCKET))) - .expect("a socket check"); - let python_check = script.find("command -v python3").expect("a python3 check"); - let shared_check = script.find("mountinfo").expect("a shared-config check"); - let write = script - .find("os.replace(tmp, hook)") - .expect("the hook write"); - assert!(socket_check < write, "the socket check must come first"); - assert!(python_check < write, "the python3 check must come first"); - assert!( - shared_check < write, - "the shared-config check must come first" - ); - // Nothing but python does the work, and that is load-bearing: the version - // that shelled out wrote an empty hook in a container with no `cat`. - for command in ["cat ", "mkdir ", "dirname ", "chmod ", "grep "] { - assert!( - !script.contains(command), - "the installer must not depend on {command:?}" - ); - } - } - - #[test] - fn every_wired_event_is_one_the_hook_maps() { - let body = hook_source(); - for (event, action) in HOOK_EVENTS { - assert!( - body.contains(&format!("\"{action}\"")), - "{event} sends {action}, which the hook does not handle" - ); - } - } - - /// The states are herdr's spelling of them, not ours: the socket refuses - /// anything else, and a refused report is a badge that never appears. - #[test] - fn the_reported_states_are_the_ones_herdr_accepts() { - let body = hook_source(); - for state in ["idle", "working", "blocked"] { - assert!(body.contains(&format!("\"{state}\"")), "{state} is missing"); - } - assert!(body.contains("pane.report_agent")); - assert!(body.contains("pane.release_agent")); - assert!( - body.contains(&format!("\"source\": \"{REPORT_SOURCE}\"")) - || body.contains(REPORT_SOURCE) - ); - } - - /// A turn that is still running must not be ended by something inside it. - #[test] - fn a_subagents_events_are_ignored() { - assert!(hook_source().contains("agent_id")); - } - - #[test] - fn the_merge_keeps_hooks_it_did_not_write() { - let merge = installer(); - assert!(merge.contains("kept.append(entry)"), "others are kept"); - assert!(merge.contains(HOOK_RELPATH), "ours are found by the marker"); - assert!(merge.contains("os.replace"), "the write is atomic"); - for (event, _) in HOOK_EVENTS { - assert!(merge.contains(event), "{event} is not wired"); - } - } - - #[test] - fn a_host_with_no_herdr_socket_does_no_herdr_work() { - // The switch, not the stage, is where this is decided — see `decide`. A - // machine that never heard of herdr must not be told about a stage. - assert_eq!(HerdrSwitch::decide(None, None), HerdrSwitch::Skip); - let socket = HostSocket(PathBuf::from("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/run/herdr.sock")); - assert_eq!( - HerdrSwitch::decide(None, Some(&socket)), - HerdrSwitch::Report - ); - // And the opt-out still beats a socket that is there. - assert_eq!( - HerdrSwitch::decide(Some("1"), Some(&socket)), - HerdrSwitch::Skip - ); - } - - // ======================================================================= - // the installer, actually run - // ======================================================================= - - /// The script is the shipped behaviour, so it is run rather than inspected. - #[test] - fn the_installer_writes_the_hook_and_wires_every_event() { - let scratch = tempfile::tempdir().expect("a scratch directory"); - let (home, socket, _listener) = a_container(scratch.path()); - let path = sysbin(scratch.path(), &INSTALLER_COMMANDS); - - let ran = bash_with( - &hook_script_at(&socket.to_string_lossy()), - &[ - ("HOME", &home.to_string_lossy()), - ("PATH", &path.to_string_lossy()), - ], - ); - - assert!( - ran.status.success(), - "the installer failed: {}", - String::from_utf8_lossy(&ran.stderr) - ); - let hook = home.join(HOOK_RELPATH); - assert!(hook.is_file(), "no hook was written"); - let settings = fs::read_to_string(home.join(".claude/settings.json")).expect("settings"); - let parsed: serde_json::Value = serde_json::from_str(&settings).expect("valid JSON"); - for (event, action) in HOOK_EVENTS { - let entries = parsed["hooks"][event] - .as_array() - .unwrap_or_else(|| panic!("{event} is not wired")); - assert_eq!(entries.len(), 1, "{event} should have exactly one entry"); - let command = entries[0]["hooks"][0]["command"] - .as_str() - .expect("a command"); - assert!(command.ends_with(&format!(" {action}")), "{command}"); - assert!( - command.starts_with(&hook.to_string_lossy().to_string()), - "{command}" - ); - } - } - - /// It runs on every launch, so running it twice has to be running it once. - #[test] - fn the_installer_is_idempotent_and_keeps_foreign_hooks() { - let scratch = tempfile::tempdir().expect("a scratch directory"); - let (home, socket, _listener) = a_container(scratch.path()); - let path = sysbin(scratch.path(), &INSTALLER_COMMANDS); - // Somebody else's hook on an event this feature also wants, and a whole - // event it does not touch: both have to survive. - fs::create_dir_all(home.join(".claude")).expect("a claude dir"); - fs::write( - home.join(".claude/settings.json"), - r#"{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"echo theirs"}]}], - "PreToolUse":[{"hooks":[{"type":"command","command":"echo untouched"}]}]}, - "model":"opus"}"#, - ) - .expect("seeded settings"); - - let script = hook_script_at(&socket.to_string_lossy()); - let env = [ - ("HOME", home.to_string_lossy().into_owned()), - ("PATH", path.to_string_lossy().into_owned()), - ]; - let env: Vec<(&str, &str)> = env - .iter() - .map(|(name, value)| (*name, value.as_str())) - .collect(); - for pass in 1..=3 { - let ran = bash_with(&script, &env); - assert!( - ran.status.success(), - "pass {pass} failed: {}", - String::from_utf8_lossy(&ran.stderr) - ); - } - - let settings = fs::read_to_string(home.join(".claude/settings.json")).expect("settings"); - let parsed: serde_json::Value = serde_json::from_str(&settings).expect("valid JSON"); - // Three passes, one entry of ours per event: the marker found the old one. - let stop = parsed["hooks"]["Stop"].as_array().expect("Stop"); - assert_eq!(stop.len(), 2, "theirs plus exactly one of ours: {stop:?}"); - assert_eq!(stop[0]["hooks"][0]["command"], "echo theirs"); - assert!( - stop[1]["hooks"][0]["command"] - .as_str() - .expect("a command") - .contains(HOOK_RELPATH) - ); - // An event this feature never mentions, and a setting beside the hooks. - assert_eq!( - parsed["hooks"]["PreToolUse"][0]["hooks"][0]["command"], - "echo untouched" - ); - assert_eq!(parsed["model"], "opus"); - } - - /// The three refusals, each proved by removing exactly one thing. - #[test] - fn the_installer_refuses_rather_than_writing_half_of_itself() { - let scratch = tempfile::tempdir().expect("a scratch directory"); - let (home, socket, _listener) = a_container(scratch.path()); - let full = sysbin(scratch.path(), &INSTALLER_COMMANDS); - - // No socket: the workspace predates the mount. - let ran = bash_with( - &hook_script_at(&scratch.path().join("absent.sock").to_string_lossy()), - &[ - ("HOME", &home.to_string_lossy()), - ("PATH", &full.to_string_lossy()), - ], - ); - assert!(!ran.status.success(), "a missing socket must refuse"); - assert!(String::from_utf8_lossy(&ran.stderr).contains("recreate")); - assert!(!home.join(HOOK_RELPATH).exists(), "nothing may be written"); - - // No python3: neither the merge nor the hook's transport can work. - let without_python = sysbin(&scratch.path().join("nopython"), &[]); - let ran = bash_with( - &hook_script_at(&socket.to_string_lossy()), - &[ - ("HOME", &home.to_string_lossy()), - ("PATH", &without_python.to_string_lossy()), - ], - ); - assert!(!ran.status.success(), "no python3 must refuse"); - assert!(String::from_utf8_lossy(&ran.stderr).contains("python3")); - assert!(!home.join(HOOK_RELPATH).exists(), "nothing may be written"); - } - - /// A settings file inside a *mounted parent* is shared with the host just as - /// surely as one that is itself a mount point, and that is the common shape - /// rather than the exotic one: this repo's own devcontainer binds - /// `~/.claude/hooks` and `~/.claude/agents` while `~/.claude` stays - /// container-local, and a container that binds `~/.config` with - /// `CLAUDE_CONFIG_DIR` inside it shares the settings file without ever making - /// it a mount point. - #[test] - fn a_settings_file_under_a_mounted_parent_is_refused() { - let scratch = tempfile::tempdir().expect("a scratch directory"); - let (home, socket, _listener) = a_container(scratch.path()); - let path = sysbin(scratch.path(), &INSTALLER_COMMANDS); - let config = home.join(".claude"); - fs::create_dir_all(&config).expect("a config dir"); - - // mountinfo's shape: id parent major:minor root mount-point ... - // The bind is of the *parent*; the config directory itself is never named. - let mountinfo = scratch.path().join("mountinfo"); - fs::write( - &mountinfo, - format!( - "23 1 0:24 / / rw - overlay overlay rw\n\ - 24 23 0:25 /home/on-the-host {} rw - ext4 /dev/sda1 rw\n", - home.display() - ), - ) - .expect("a scratch mountinfo"); - - let ran = bash_with( - &hook_script_at(&socket.to_string_lossy()), - &[ - ("HOME", &home.to_string_lossy()), - ("PATH", &path.to_string_lossy()), - ("DEVLAUNCH_MOUNTINFO", &mountinfo.to_string_lossy()), - ], - ); - - assert!( - !ran.status.success(), - "a mounted parent must refuse: {}", - String::from_utf8_lossy(&ran.stdout) - ); - assert!( - String::from_utf8_lossy(&ran.stderr).contains("mounted from the host"), - "{}", - String::from_utf8_lossy(&ran.stderr) - ); - assert!( - !config.join("settings.json").exists(), - "the host's settings must not be written" - ); - assert!(!home.join(HOOK_RELPATH).exists(), "nor the hook"); - } - - /// The refusal must not fire on an ordinary container, where the paths written - /// are under a filesystem mounted whole (root `/`) rather than a bind. - #[test] - fn a_container_local_settings_file_is_written() { - let scratch = tempfile::tempdir().expect("a scratch directory"); - let (home, socket, _listener) = a_container(scratch.path()); - let path = sysbin(scratch.path(), &INSTALLER_COMMANDS); - let mountinfo = scratch.path().join("mountinfo"); - // A tmpfs on the scratch root and an overlay on /: both mounted whole, so - // neither shares anything with a host. - fs::write( - &mountinfo, - format!( - "23 1 0:24 / / rw - overlay overlay rw\n\ - 25 23 0:26 / {} rw - tmpfs tmpfs rw\n", - scratch.path().display() - ), - ) - .expect("a scratch mountinfo"); - - let ran = bash_with( - &hook_script_at(&socket.to_string_lossy()), - &[ - ("HOME", &home.to_string_lossy()), - ("PATH", &path.to_string_lossy()), - ("DEVLAUNCH_MOUNTINFO", &mountinfo.to_string_lossy()), - ], - ); - - assert!( - ran.status.success(), - "an ordinary container must install: {}", - String::from_utf8_lossy(&ran.stderr) - ); - assert!(home.join(".claude/settings.json").is_file()); - assert!(home.join(HOOK_RELPATH).is_file()); - } - - /// What the hook actually puts on the socket, checked against herdr's schema: - /// the method names, the required parameters and the state spellings are all - /// herdr's, and a report it refuses is a badge that never appears. - #[test] - fn the_hook_sends_herdrs_own_request_shape() { - let scratch = tempfile::tempdir().expect("a scratch directory"); - let (home, socket, listener) = a_container(scratch.path()); - let path = sysbin(scratch.path(), &["python3"]); - let installed = bash_with( - &hook_script_at(&socket.to_string_lossy()), - &[ - ("HOME", &home.to_string_lossy()), - ("PATH", &path.to_string_lossy()), - ], - ); - assert!(installed.status.success()); - let hook = home.join(HOOK_RELPATH); - - for (action, expected) in [ - ("prompt", "working"), - ("notification", "blocked"), - ("stop", "idle"), - ] { - let ran = bash_with( - &format!( - "printf '%s' '{{\"session_id\":\"s-1\",\"transcript_path\":\"/t.jsonl\"}}' | {} {action}", - quote(&hook.to_string_lossy()) - ), - &[ - ("HOME", &home.to_string_lossy()), - ("PATH", &path.to_string_lossy()), - (ENV_MARK_VAR, "1"), - (PANE_VAR, "w1:p2"), - (SOCKET_VAR, &socket.to_string_lossy()), - ], - ); - assert!(ran.status.success(), "the hook must never fail the agent"); - - let request = read_one_request(&listener); - assert_eq!(request["method"], "pane.report_agent"); - assert_eq!(request["params"]["pane_id"], "w1:p2"); - assert_eq!(request["params"]["source"], REPORT_SOURCE); - assert_eq!(request["params"]["agent"], "claude"); - assert_eq!(request["params"]["state"], expected, "{action}"); - assert_eq!(request["params"]["agent_session_id"], "s-1"); - assert_eq!(request["params"]["agent_session_path"], "/t.jsonl"); - assert!(request["params"]["seq"].is_number(), "seq orders reports"); - } - - // The end of a session releases the authority rather than pinning a state: - // a held report nothing updates is a badge that lies. - let ran = bash_with( - &format!( - "printf '%s' '{{\"session_id\":\"s-1\"}}' | {} session-end", - quote(&hook.to_string_lossy()) - ), - &[ - ("HOME", &home.to_string_lossy()), - ("PATH", &path.to_string_lossy()), - (ENV_MARK_VAR, "1"), - (PANE_VAR, "w1:p2"), - (SOCKET_VAR, &socket.to_string_lossy()), - ], - ); - assert!(ran.status.success()); - let request = read_one_request(&listener); - assert_eq!(request["method"], "pane.release_agent"); - assert_eq!(request["params"]["pane_id"], "w1:p2"); - assert!(request["params"].get("state").is_none()); - } - - /// A subagent's Stop must not end a turn that is still running. - #[test] - fn the_hook_says_nothing_for_a_subagent_or_an_unknown_event() { - let scratch = tempfile::tempdir().expect("a scratch directory"); - let (home, socket, listener) = a_container(scratch.path()); - let path = sysbin(scratch.path(), &["python3"]); - assert!( - bash_with( - &hook_script_at(&socket.to_string_lossy()), - &[ - ("HOME", &home.to_string_lossy()), - ("PATH", &path.to_string_lossy()), - ], - ) - .status - .success() - ); - let hook = home.join(HOOK_RELPATH); - - for (input, action) in [ - (r#"{"session_id":"s","agent_id":"sub-1"}"#, "stop"), - (r#"{"session_id":"s"}"#, "pre-tool-use"), - ] { - let ran = bash_with( - &format!( - "printf '%s' '{input}' | {} {action}", - quote(&hook.to_string_lossy()) - ), - &[ - ("HOME", &home.to_string_lossy()), - ("PATH", &path.to_string_lossy()), - (ENV_MARK_VAR, "1"), - (PANE_VAR, "w1:p2"), - (SOCKET_VAR, &socket.to_string_lossy()), - ], - ); - assert!(ran.status.success()); - } - // Nothing connected, so nothing is waiting to be accepted. - listener - .set_nonblocking(true) - .expect("a non-blocking listener"); - assert!( - listener.accept().is_err(), - "a subagent or an unmapped event must send nothing" - ); - } - - /// Outside a herdr pane the hook is inert, which is what lets it be installed - /// in a container that is sometimes launched from one and sometimes not. - #[test] - fn the_hook_is_inert_with_no_pane_to_report_to() { - let scratch = tempfile::tempdir().expect("a scratch directory"); - let (home, socket, listener) = a_container(scratch.path()); - let path = sysbin(scratch.path(), &["python3"]); - assert!( - bash_with( - &hook_script_at(&socket.to_string_lossy()), - &[ - ("HOME", &home.to_string_lossy()), - ("PATH", &path.to_string_lossy()), - ], - ) - .status - .success() - ); - let hook = home.join(HOOK_RELPATH); - let ran = bash_with( - &format!( - "printf '%s' '{{}}' | {} prompt", - quote(&hook.to_string_lossy()) - ), - &[ - ("HOME", &home.to_string_lossy()), - ("PATH", &path.to_string_lossy()), - ], - ); - assert!(ran.status.success(), "no pane is not an error"); - listener - .set_nonblocking(true) - .expect("a non-blocking listener"); - assert!(listener.accept().is_err(), "nothing may be sent"); - } - - /// One request, as the hook writes it: one line of JSON and then a close. - /// - /// Bounded rather than a bare `accept`, so a hook that reports nothing fails the - /// test with a sentence instead of hanging it forever. - fn read_one_request(listener: &UnixListener) -> serde_json::Value { - use std::io::Read as _; - let mut stream = accept_within(listener, std::time::Duration::from_secs(10)); - stream - .set_read_timeout(Some(std::time::Duration::from_secs(10))) - .expect("a read timeout"); - let mut text = String::new(); - stream.read_to_string(&mut text).expect("a request"); - let line = text.lines().next().expect("one line"); - serde_json::from_str(line).expect("valid JSON") - } - - /// Accept one connection, or fail saying none came. - fn accept_within( - listener: &UnixListener, - within: std::time::Duration, - ) -> std::os::unix::net::UnixStream { - listener - .set_nonblocking(true) - .expect("a nonblocking listener"); - let deadline = std::time::Instant::now() + within; - loop { - match listener.accept() { - Ok((stream, _)) => { - listener - .set_nonblocking(false) - .expect("a blocking listener"); - stream.set_nonblocking(false).expect("a blocking stream"); - return stream; - } - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - assert!( - std::time::Instant::now() < deadline, - "the hook never connected" - ); - std::thread::sleep(std::time::Duration::from_millis(20)); - } - Err(error) => panic!("the listener failed: {error}"), - } - } - } - - /// Every embedded heredoc has to be closed, or the stage is a shell syntax - /// error that reports as a failure with no reason. - #[test] - fn the_heredocs_are_balanced() { - let script = hook_script(); - assert_eq!( - script.matches(DELIMITER).count() % 2, - 0, - "an unbalanced heredoc delimiter" - ); - assert!(script.contains(&format!("<<'{DELIMITER}'"))); - } -} diff --git a/rust/devlaunch-core/src/flows/launch.rs b/rust/devlaunch-core/src/flows/launch.rs index ed4d287e..830db8b7 100644 --- a/rust/devlaunch-core/src/flows/launch.rs +++ b/rust/devlaunch-core/src/flows/launch.rs @@ -69,7 +69,6 @@ use crate::domain::locks::{self, Contention, LockError}; use crate::domain::metadata::MetadataStorage; use crate::domain::spec::{self, DevcontainerPath, SpecIdentity, WorkspaceSpec}; use crate::domain::workspace_id::{NamePart, UnsafeName, WorkspaceId, validate_ref_name}; -use crate::flows::herdr; use crate::flows::lifecycle::{ self, KnownWorkspace, LifecycleNotice, Refresh, RefreshReason, StopOutcome, }; @@ -171,15 +170,6 @@ pub struct Host { /// difference is a case worth serving: `dl -- make test > log` has /// redirected stdout and still has a terminal to name. pub(crate) stderr_tty: bool, - /// The host's herdr control socket, if it has one listening. - /// - /// A resolved socket rather than the variable naming it, because whether the - /// path is really a socket is the question a mount depends on and answering it - /// twice could answer it differently. `None` on every host not running herdr, - /// which is what makes [`herdr::up_args`] add nothing there. - pub(crate) herdr_socket: Option, - /// `DEVLAUNCH_NO_HERDR`. - pub(crate) herdr: herdr::HerdrSwitch, /// `~/.ssh/config`, where devpod publishes its host aliases. `None` on a /// machine with no home directory, which reads the same as a config with no /// alias in it: fall back to the devpod transport. @@ -207,8 +197,6 @@ impl Host { stdin_tty: is_a_terminal(libc::STDIN_FILENO), stdout_tty: is_a_terminal(libc::STDOUT_FILENO), stderr_tty: is_a_terminal(libc::STDERR_FILENO), - herdr_socket: herdr::HostSocket::from_env(), - herdr: herdr::HerdrSwitch::from_env(), ssh_config: ssh::config_path(), cache_dir: cache_dir.into(), devpod_home: lifecycle::devpod_home(), @@ -826,7 +814,6 @@ pub(crate) fn up_args( options: &ContextOptions, pixi: &PixiCache, token: &[String], - herdr: &HerdrMount, ) -> Vec { let mut args = vec!["up".to_owned(), request.source.to_owned()]; if let Some(id) = request.naming.create_as() { @@ -849,36 +836,9 @@ pub(crate) fn up_args( args.extend(options.up_args()); args.extend(pixi.up_args()); args.extend(token.iter().cloned()); - args.extend(herdr.up_args()); args } -/// The herdr socket this launch may bind into the container, if any. -/// -/// A type of its own rather than a second `&[String]` beside `token`: two slices of -/// pre-built flags in one signature are two a caller can pass in the wrong order -/// with nothing to catch it, and these two carry very different things. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub(crate) struct HerdrMount { - socket: Option, - switch: herdr::HerdrSwitch, -} - -impl HerdrMount { - /// What this host asks for. - pub(crate) fn of(host: &Host) -> Self { - Self { - socket: host.herdr_socket.clone(), - switch: host.herdr, - } - } - - /// The `devpod up` flags, which are none on a host with no herdr. - fn up_args(&self) -> Vec { - herdr::up_args(self.socket.as_ref(), self.switch) - } -} - // =========================================================================== // serializing two `up`s of one workspace // =========================================================================== @@ -1181,7 +1141,7 @@ fn up_under_stage( .as_ref() .map(StagedToken::up_args) .unwrap_or_default(); - let args = up_args(request, &options, &pixi, &token_args, &HerdrMount::of(host)); + let args = up_args(request, &options, &pixi, &token_args); // This launch is the one paying for the `up`, so no prewarm saved it from // anything — whether or not one was fired. @@ -3920,13 +3880,7 @@ mod tests { }, ); - let args = up_args( - &request, - &ContextOptions::default(), - &pixi, - &[], - &HerdrMount::default(), - ); + let args = up_args(&request, &ContextOptions::default(), &pixi, &[]); assert_eq!( args, @@ -3970,7 +3924,6 @@ mod tests { source: PathBuf::from("/nope"), }, &[], - &HerdrMount::default(), ); assert_eq!( @@ -3999,7 +3952,6 @@ mod tests { source: PathBuf::from("/nope"), }, &[], - &HerdrMount::default(), ); assert_eq!( @@ -4037,7 +3989,6 @@ mod tests { &ContextOptions::default(), ¬hing, &[], - &HerdrMount::default(), ) }; @@ -4068,7 +4019,6 @@ mod tests { source: PathBuf::from("/nope"), }, &[], - &HerdrMount::default(), ); assert_eq!( @@ -4097,7 +4047,6 @@ mod tests { source: PathBuf::from("/nope"), }, &[], - &HerdrMount::default(), ); assert_eq!( diff --git a/rust/devlaunch-core/src/flows/mod.rs b/rust/devlaunch-core/src/flows/mod.rs index ee36781f..3be471d1 100644 --- a/rust/devlaunch-core/src/flows/mod.rs +++ b/rust/devlaunch-core/src/flows/mod.rs @@ -8,8 +8,6 @@ pub mod completion_cache; // binary surface — not part of the frozen wf API (#251 §7) pub mod disk_usage; // binary surface — not part of the frozen wf API (#251 §7) -pub mod herdr; -// binary surface — not part of the frozen wf API (#251 §7) pub mod launch; // binary surface — not part of the frozen wf API (#251 §7) pub mod lifecycle; diff --git a/rust/devlaunch-core/src/flows/provision.rs b/rust/devlaunch-core/src/flows/provision.rs index 2c369e83..f28d35fb 100644 --- a/rust/devlaunch-core/src/flows/provision.rs +++ b/rust/devlaunch-core/src/flows/provision.rs @@ -72,7 +72,6 @@ use sha2::{Digest as _, Sha256}; use crate::clients::devpod::{self, Call, NotRun}; use crate::domain::workspace_id::hostname_of; -use crate::flows::herdr::{self, HerdrSwitch}; use crate::notices::Notices; use crate::runner::interrupt; use crate::runner::{Exit, OsFailure, Runner}; @@ -198,13 +197,6 @@ pub(crate) const HOSTNAME_STAGE: StageName = StageName::new("hostname"); /// of round trips: it rides the pass every entry into Running already pays. pub(crate) const ZELLIJ_STAGE: StageName = StageName::new("zellij"); -/// The stage that wires the agent's lifecycle events to a host-side herdr session -/// (see [`crate::flows::herdr`]). Free of round trips for the same reason, and -/// [`Stage::quieter`] because most containers will not satisfy it: it needs a -/// `python3`, a claude configuration directory of the container's own, and the -/// socket mount, and the launch is unharmed by any of those being absent. -pub(crate) const HERDR_STAGE: StageName = StageName::new("herdr"); - /// The stage that teaches the shell to keep naming the terminal after this /// workspace. Rides the same trip as the three above it. pub(crate) const TITLE_STAGE: StageName = StageName::new("title"); @@ -383,26 +375,23 @@ impl ZellijSwitch { pub struct Switches { pub tools: ToolsSwitch, pub zellij: ZellijSwitch, - pub herdr: HerdrSwitch, } impl Switches { - /// Every switch as the process environment sets it. + /// Both switches as the process environment sets them. pub fn from_env() -> Self { Self { tools: ToolsSwitch::from_env(), zellij: ZellijSwitch::from_env(), - herdr: HerdrSwitch::from_env(), } } - /// Every switch on — the default a machine that set no variable gets, and the - /// shape most tests want. + /// Both switches on — the default a machine that set neither variable gets, + /// and the shape most tests want. #[cfg_attr(not(test), allow(dead_code))] pub(crate) const INSTALLING: Self = Self { tools: ToolsSwitch::Install, zellij: ZellijSwitch::Install, - herdr: HerdrSwitch::Report, }; } @@ -995,7 +984,6 @@ pub(crate) fn setup_stages( workspace: &str, tools: ToolsSwitch, zellij: ZellijSwitch, - herdr: HerdrSwitch, title: Option<&str>, ) -> Vec { let mut stages = vec![ @@ -1034,20 +1022,6 @@ pub(crate) fn setup_stages( format!("bash -c {} >&2", quote(&zellij_script())), )); } - // Gated the same way and for the same reason: writing a hook into the - // container is tool provisioning, so `DEVLAUNCH_NO_TOOLS` covers it, and - // `DEVLAUNCH_NO_HERDR` covers only this. A nested `bash -c` for the reason the - // zellij stage needs one — a stage is interpolated into `if ; then`, - // which is one line, and this script is not. - if let (ToolsSwitch::Install, HerdrSwitch::Report) = (tools, herdr) { - stages.push( - Stage::new( - HERDR_STAGE, - format!("bash -c {} >&2", quote(&herdr::hook_script())), - ) - .quieter(), - ); - } if let Some(title) = title { stages.push( Stage::new( @@ -1961,13 +1935,7 @@ fn setup_pass( title: Option<&str>, events: &mut dyn Notices, ) -> Result { - let stages = setup_stages( - workspace, - switches.tools, - switches.zellij, - switches.herdr, - title, - ); + let stages = setup_stages(workspace, switches.tools, switches.zellij, title); let call = Call::new([ "ssh", workspace, @@ -2945,13 +2913,7 @@ fi // The round trip Python's own tests assert with `shlex.split(runner.script())`: // whatever the quoting, the remote shell has to recover exactly `bash`, the // flag, and one script. - let stages = setup_stages( - "myws", - ToolsSwitch::Install, - ZellijSwitch::Install, - HerdrSwitch::Report, - None, - ); + let stages = setup_stages("myws", ToolsSwitch::Install, ZellijSwitch::Install, None); for (payload, flag, script) in [ ( format!("bash -lc {}", quote(&setup_script(&stages))), @@ -2978,13 +2940,7 @@ fi fn the_zellij_stage_is_one_word_the_pass_shell_hands_to_a_nested_bash() { // The stage is interpolated into `if ; then`, so the quoting has to // survive being read by the *pass's* shell before the nested bash sees it. - let stages = setup_stages( - "myws", - ToolsSwitch::Install, - ZellijSwitch::Install, - HerdrSwitch::Report, - None, - ); + let stages = setup_stages("myws", ToolsSwitch::Install, ZellijSwitch::Install, None); let command = &stages[1].command; let words = shlex::split(command).expect("a stage a shell can read"); assert_eq!( @@ -3031,7 +2987,6 @@ fi "myws", ToolsSwitch::Install, ZellijSwitch::Install, - HerdrSwitch::Skip, None, )); assert_eq!( @@ -3042,7 +2997,6 @@ fi "myws", ToolsSwitch::Skip, ZellijSwitch::Install, - HerdrSwitch::Skip, None, )); assert_eq!( @@ -3809,7 +3763,6 @@ fi "myws", ToolsSwitch::Install, ZellijSwitch::Install, - HerdrSwitch::Skip, None, )); assert!(script.contains(&probe_script())); @@ -3820,13 +3773,7 @@ fi // Order, and it is not cosmetic: the probe exits early when a tool is // missing, which is the commonest cold-path answer, so a stage placed behind // it would report "not reached" on the very launches the fold exists for. - let stages = setup_stages( - "myws", - ToolsSwitch::Install, - ZellijSwitch::Install, - HerdrSwitch::Report, - None, - ); + let stages = setup_stages("myws", ToolsSwitch::Install, ZellijSwitch::Install, None); let script = setup_script(&stages); let probe_at = script.find(&probe_script()).expect("the probe is in there"); for stage in &stages { @@ -3849,7 +3796,6 @@ fi id, ToolsSwitch::Install, ZellijSwitch::Install, - HerdrSwitch::Skip, None, )); @@ -3867,7 +3813,6 @@ fi "myws", ToolsSwitch::Install, ZellijSwitch::Install, - HerdrSwitch::Skip, None, )); assert!(!script.contains("set -e")); @@ -3881,7 +3826,6 @@ fi name, ToolsSwitch::Install, ZellijSwitch::Install, - HerdrSwitch::Skip, None, )); assert!(script.contains(&format!("hostname {}", quote(name)))); @@ -3919,7 +3863,6 @@ fi workspace, ToolsSwitch::Install, ZellijSwitch::Install, - HerdrSwitch::Skip, None, )); let ran = bash_with( @@ -3944,13 +3887,7 @@ fi fn outcome_of(report: &str, stage: StageName) -> Option { stage_outcomes( report, - &setup_stages( - "myws", - ToolsSwitch::Install, - ZellijSwitch::Install, - HerdrSwitch::Report, - None, - ), + &setup_stages("myws", ToolsSwitch::Install, ZellijSwitch::Install, None), ) .into_iter() .find(|outcome| outcome.stage == stage) @@ -3977,7 +3914,6 @@ fi "devlaunch-main-zovomobo", ToolsSwitch::Install, ZellijSwitch::Install, - HerdrSwitch::Skip, None, )); @@ -4053,16 +3989,11 @@ fi /// the one stage because every assertion below is about how one reported line is /// *read*. fn hostname_outcome(report: &str) -> Vec { - let stages: Vec = setup_stages( - "myws", - ToolsSwitch::Install, - ZellijSwitch::Install, - HerdrSwitch::Report, - None, - ) - .into_iter() - .filter(|stage| stage.name == HOSTNAME_STAGE) - .collect(); + let stages: Vec = + setup_stages("myws", ToolsSwitch::Install, ZellijSwitch::Install, None) + .into_iter() + .filter(|stage| stage.name == HOSTNAME_STAGE) + .collect(); assert!(!stages.is_empty(), "the pass no longer names the container"); stage_outcomes(report, &stages) } @@ -4172,7 +4103,6 @@ fi "myws", ToolsSwitch::Install, ZellijSwitch::Install, - HerdrSwitch::Report, None, )) ); @@ -4407,7 +4337,6 @@ fi Switches { tools: ToolsSwitch::Skip, zellij: ZellijSwitch::Install, - herdr: HerdrSwitch::Skip, }, ¬hing_to_lend(), ); @@ -4423,7 +4352,6 @@ fi "myws", ToolsSwitch::Skip, ZellijSwitch::Install, - HerdrSwitch::Skip, None, )) ); @@ -4465,36 +4393,6 @@ fi } } - // ======================================================================= - // DEVLAUNCH_NO_HERDR: the other narrow opt-out - // ======================================================================= - - #[test] - fn the_herdr_stage_is_carried_only_when_both_switches_ask_for_it() { - let names = |tools, herdr| -> Vec { - setup_stages("myws", tools, ZellijSwitch::Skip, herdr, None) - .iter() - .map(|stage| stage.name) - .collect() - }; - - // Both on: the stage rides the pass every launch already pays for. - let both = names(ToolsSwitch::Install, HerdrSwitch::Report); - assert!(both.contains(&HERDR_STAGE), "{both:?}"); - - // Either off is enough to drop it, and neither costs the hostname — - // installing a hook is tools work, naming a container is not. - for (tools, herdr) in [ - (ToolsSwitch::Install, HerdrSwitch::Skip), - (ToolsSwitch::Skip, HerdrSwitch::Report), - (ToolsSwitch::Skip, HerdrSwitch::Skip), - ] { - let names = names(tools, herdr); - assert!(!names.contains(&HERDR_STAGE), "{tools:?}/{herdr:?}"); - assert!(names.contains(&HOSTNAME_STAGE), "{tools:?}/{herdr:?}"); - } - } - // ======================================================================= // DEVLAUNCH_NO_ZELLIJ: the narrower opt-out // ======================================================================= @@ -4510,22 +4408,16 @@ fi "myws", ToolsSwitch::Install, ZellijSwitch::Install, - HerdrSwitch::Skip, Some("blooop/devlaunch@main"), ) .iter() .map(|stage| stage.name) .collect(); - let unnamed: Vec = setup_stages( - "myws", - ToolsSwitch::Install, - ZellijSwitch::Install, - HerdrSwitch::Skip, - None, - ) - .iter() - .map(|stage| stage.name) - .collect(); + let unnamed: Vec = + setup_stages("myws", ToolsSwitch::Install, ZellijSwitch::Install, None) + .iter() + .map(|stage| stage.name) + .collect(); assert!(named.contains(&TITLE_STAGE), "{named:?}"); assert!(!unnamed.contains(&TITLE_STAGE), "{unnamed:?}"); @@ -4541,16 +4433,11 @@ fi (ToolsSwitch::Skip, ZellijSwitch::Install), (ToolsSwitch::Install, ZellijSwitch::Skip), ] { - let names: Vec = setup_stages( - "myws", - tools, - zellij, - HerdrSwitch::Skip, - Some("blooop/devlaunch@main"), - ) - .iter() - .map(|stage| stage.name) - .collect(); + let names: Vec = + setup_stages("myws", tools, zellij, Some("blooop/devlaunch@main")) + .iter() + .map(|stage| stage.name) + .collect(); assert!( names.contains(&TITLE_STAGE), @@ -4609,7 +4496,6 @@ fi "myws", ToolsSwitch::Install, ZellijSwitch::Install, - HerdrSwitch::Skip, Some("blooop/devlaunch@main"), ); let stage = stages @@ -4633,7 +4519,6 @@ fi "myws", ToolsSwitch::Skip, ZellijSwitch::Skip, - HerdrSwitch::Skip, Some("blooop/devlaunch@main"), ); let stage = stages @@ -4722,16 +4607,11 @@ fi // installed keeps everything `DEVLAUNCH_NO_TOOLS` would have cost it — // the container is still named, and the pass still probes for the `gh` // and `claude` the workspace is guaranteed. - let names: Vec = setup_stages( - "myws", - ToolsSwitch::Install, - ZellijSwitch::Skip, - HerdrSwitch::Skip, - None, - ) - .iter() - .map(|stage| stage.name) - .collect(); + let names: Vec = + setup_stages("myws", ToolsSwitch::Install, ZellijSwitch::Skip, None) + .iter() + .map(|stage| stage.name) + .collect(); assert!(!names.contains(&ZELLIJ_STAGE), "{names:?}"); assert!(names.contains(&HOSTNAME_STAGE), "{names:?}"); @@ -4744,7 +4624,6 @@ fi Switches { tools: ToolsSwitch::Install, zellij: ZellijSwitch::Skip, - herdr: HerdrSwitch::Skip, }, false, Some(0), @@ -4764,18 +4643,11 @@ fi "myws", ToolsSwitch::Install, ZellijSwitch::Skip, - HerdrSwitch::Skip, None, )); for tools in [ToolsSwitch::Install, ToolsSwitch::Skip] { assert_eq!( - setup_script(&setup_stages( - "myws", - tools, - ZellijSwitch::Skip, - HerdrSwitch::Skip, - None, - )), + setup_script(&setup_stages("myws", tools, ZellijSwitch::Skip, None)), without, "{tools:?}" ); @@ -4785,7 +4657,6 @@ fi "myws", ToolsSwitch::Skip, ZellijSwitch::Install, - HerdrSwitch::Skip, None, )), without @@ -4795,7 +4666,6 @@ fi "myws", ToolsSwitch::Install, ZellijSwitch::Install, - HerdrSwitch::Skip, None, )), without, @@ -4818,7 +4688,6 @@ fi Switches { tools: ToolsSwitch::Install, zellij: ZellijSwitch::Skip, - herdr: HerdrSwitch::Skip, }, &host, ); @@ -5236,16 +5105,11 @@ fi Stage::new(StageName::new("x"), "true").failure_level, FailureLevel::Warning ); - let levels: Vec<(StageName, FailureLevel)> = setup_stages( - "myws", - ToolsSwitch::Install, - ZellijSwitch::Install, - HerdrSwitch::Report, - None, - ) - .iter() - .map(|stage| (stage.name, stage.failure_level)) - .collect(); + let levels: Vec<(StageName, FailureLevel)> = + setup_stages("myws", ToolsSwitch::Install, ZellijSwitch::Install, None) + .iter() + .map(|stage| (stage.name, stage.failure_level)) + .collect(); assert_eq!( levels, vec![ @@ -5254,13 +5118,6 @@ fi // degradation of a container, so it takes the default rather than // declaring an exception. (ZELLIJ_STAGE, FailureLevel::Warning), - // The herdr stage is the other direction: most containers cannot - // satisfy it — no `python3`, no claude configuration directory of - // their own, no socket because the workspace predates the mount — - // and none of that degrades the container for anything else. A - // warning per launch for a badge that did not appear is noise, so it - // declares the exception the hostname does. - (HERDR_STAGE, FailureLevel::Info), ] ); } @@ -5710,13 +5567,7 @@ fi ) -> (PathBuf, Output, String) { let (home, sysbin, log) = zellij_sandbox(scratch, has_zellij, pixi_exit); let ran = bash_with( - &setup_script(&setup_stages( - "myws", - switches.tools, - switches.zellij, - switches.herdr, - None, - )), + &setup_script(&setup_stages("myws", switches.tools, switches.zellij, None)), &[ ("HOME", &home.to_string_lossy()), ("PATH", &sysbin.to_string_lossy()), @@ -5747,16 +5598,11 @@ fi // The guarantee, stated where it is made: the pass every entry into Running // goes through carries a zellij stage. No dotfiles, no devcontainer.json, no // repo cooperation — the ask comes from the invocation. - let names: Vec = setup_stages( - "myws", - ToolsSwitch::Install, - ZellijSwitch::Install, - HerdrSwitch::Report, - None, - ) - .iter() - .map(|stage| stage.name) - .collect(); + let names: Vec = + setup_stages("myws", ToolsSwitch::Install, ZellijSwitch::Install, None) + .iter() + .map(|stage| stage.name) + .collect(); assert!(names.contains(&ZELLIJ_STAGE), "{names:?}"); } @@ -5882,16 +5728,11 @@ fi // the hostname stage, which is not tools work and is deliberately left // outside that switch — a machine that turned tool installs off has not // thereby asked for unnamed containers. - let names: Vec = setup_stages( - "myws", - ToolsSwitch::Skip, - ZellijSwitch::Install, - HerdrSwitch::Skip, - None, - ) - .iter() - .map(|stage| stage.name) - .collect(); + let names: Vec = + setup_stages("myws", ToolsSwitch::Skip, ZellijSwitch::Install, None) + .iter() + .map(|stage| stage.name) + .collect(); assert!(!names.contains(&ZELLIJ_STAGE), "{names:?}"); assert!(names.contains(&HOSTNAME_STAGE), "{names:?}"); @@ -5901,7 +5742,6 @@ fi Switches { tools: ToolsSwitch::Skip, zellij: ZellijSwitch::Install, - herdr: HerdrSwitch::Skip, }, false, Some(0), diff --git a/rust/dl/src/lib.rs b/rust/dl/src/lib.rs index 3d0a8ed4..32c08dcd 100644 --- a/rust/dl/src/lib.rs +++ b/rust/dl/src/lib.rs @@ -42,14 +42,6 @@ use devlaunch_core::timing; /// composes one. pub use devlaunch_core::shell; -/// Which herdr pane this process is in, for the entry point that starts an agent: -/// `aid` puts the pane's identity on the agent's own command line so the hook -/// devlaunch installs in the container knows where to report. Re-exported here for -/// the reason [`shell`] is — `aid` sees devlaunch through `dl` and nothing else — -/// and only the reading of the pane is exposed, not the mount or the hook, which -/// are a launch's business and happen inside [`run`]. -pub use devlaunch_core::flows::herdr; - /// Python's `repr()`, for the entry point that quotes an untrusted name the way /// Python did: `aid` names a bad `DEVLAUNCH_AID_AGENT` value with `{name!r}`, and /// reaches the one renderer through here rather than carrying a second copy, so a From 039d8bcbcc19bff924a3c23e496f2fd826dfcf31 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Sun, 23 Aug 2026 17:03:39 +0000 Subject: [PATCH 2/3] fix: dropping the herdr stage left two stage counts saying three and four TITLE_STAGE's doc said it "rides the same trip as the three above it" when two are left, and setup_stages' said `title` was "last of the four" when it is last of three. Both counted the herdr stage, and both are the kind of number a reader uses to check they have seen every stage -- which is exactly the check they now fail. Not testable: no Rust test can read a count out of a doc comment, and no test that pins the stage inventory would have gone red on either sentence. The payload itself is pinned, and stayed pinned through the removal -- `the_setup_pass_is_the_script_python_composes` asserts the setup pass byte for byte against Python's own constants and still passes, so the stages these comments count are unchanged. --- rust/devlaunch-core/src/flows/provision.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rust/devlaunch-core/src/flows/provision.rs b/rust/devlaunch-core/src/flows/provision.rs index f28d35fb..ac666bdf 100644 --- a/rust/devlaunch-core/src/flows/provision.rs +++ b/rust/devlaunch-core/src/flows/provision.rs @@ -198,7 +198,7 @@ pub(crate) const HOSTNAME_STAGE: StageName = StageName::new("hostname"); pub(crate) const ZELLIJ_STAGE: StageName = StageName::new("zellij"); /// The stage that teaches the shell to keep naming the terminal after this -/// workspace. Rides the same trip as the three above it. +/// workspace. Rides the same trip as the two above it. pub(crate) const TITLE_STAGE: StageName = StageName::new("title"); // =========================================================================== @@ -969,7 +969,7 @@ impl Stage { /// touches the hostname, which is not tools work under either variable. /// `title` is the name a shell in this container should keep putting on the terminal, /// or `None` for a launch that wants none — which is `DEVLAUNCH_NO_TITLE`, or a spec -/// that resolved no triple, both decided by the caller. Last of the four because it +/// that resolved no triple, both decided by the caller. Last of the three because it /// is the one that is not a switch, and neither tools switch touches it either: /// naming a pane is no more tool provisioning than naming a container is. /// From 8bb0a5c9d84fd7575c4ad5eac67736ac7a3a4629 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Sun, 23 Aug 2026 17:05:36 +0000 Subject: [PATCH 3/3] Release 0.12.0 --- CHANGELOG.md | 2 ++ rust/Cargo.lock | 10 +++++----- rust/Cargo.toml | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3363c56c..49dbfaae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.12.0] - 2026-08-23 + ### Removed - **Reporting agent state to a host-side herdr session, added in 0.8.0, is gone** — diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 4622a1cc..b994be9f 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -13,7 +13,7 @@ dependencies = [ [[package]] name = "aid" -version = "0.11.0" +version = "0.12.0" dependencies = [ "devlaunch-test-support", "dl", @@ -452,7 +452,7 @@ dependencies = [ [[package]] name = "devlaunch-core" -version = "0.11.0" +version = "0.12.0" dependencies = [ "devlaunch-runner", "devlaunch-test-support", @@ -470,7 +470,7 @@ dependencies = [ [[package]] name = "devlaunch-runner" -version = "0.11.0" +version = "0.12.0" dependencies = [ "libc", "tempfile", @@ -478,7 +478,7 @@ dependencies = [ [[package]] name = "devlaunch-test-support" -version = "0.11.0" +version = "0.12.0" dependencies = [ "devlaunch-runner", "serde_json", @@ -525,7 +525,7 @@ dependencies = [ [[package]] name = "dl" -version = "0.11.0" +version = "0.12.0" dependencies = [ "assert_cmd", "clap", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index e210f9ce..d25d2039 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -11,7 +11,7 @@ members = [ # The single source of the version (docs/rust-rewrite-plan.md: cutover ships # 0.1.0, version read from Cargo.toml). [workspace.package] -version = "0.11.0" +version = "0.12.0" edition = "2024" license = "MIT" repository = "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/blooop/devlaunch"