Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
warning. `aid` passes the flag through. A verb that forwards no login says it is
ignoring it, as `--devcontainer` does; a global command refuses it.

- **A pane's row in [herdr](https://herdr.dev) says which account the agent in it is
running as**, reported as the display label `profile=<name>`. A profile is chosen per
launch and forwarded per session, so two tabs side by side can be two different
accounts with nothing on screen to tell them apart, and the failure that matters here
is not noticing: work pushed from the wrong identity is found out about later and
somewhere else. Reported under devlaunch's own source name, so it sits beside herdr's
labels rather than overwriting one, and cleared rather than left stale when a launch
forwards the default login. The label is reported before anything that can go wrong
with the manager's socket, because a launch whose forward never starts still opens a
session: reporting it last would have cleared a stale label on one path out of five
and left `profile=work` standing over a session on another account on the other four.

**A pane opened beside an agent inherits the profile that agent started with.** The
pane shell already opens in the workspace its tab holds; a workspace is not an account,
so it read the default login while the agent one pane over ran as another. It is read
from the agent's own argv rather than from a note kept anywhere, for the reason the
pane shell keeps nothing: the argv is what is true, and a record would be a second copy
of it that can go stale. An exact element match, and it stops at a bare `--`, so a
prompt that contains the words `--claude-profile work` is a prompt.

**Nothing is inherited across a boundary.** A pane in a tab holding no agent has no
account to inherit and gets the default login, and the environment of an agent already
running is fixed at exec, so switching profile reaches the next session and never the
one on screen.

### Fixed

- **`$CLAUDE_CONFIG_DIR` is now honoured on the host, so a host that has moved its
Expand Down
4 changes: 3 additions & 1 deletion rust/devlaunch-core/public-api.rest.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2756,7 +2756,9 @@ impl core::marker::StructuralPartialEq for devlaunch_core::flows::repo_manager::
pub mod devlaunch_core::flows::session_manager
pub enum devlaunch_core::flows::session_manager::PaneDestination
pub devlaunch_core::flows::session_manager::PaneDestination::HostShell
pub devlaunch_core::flows::session_manager::PaneDestination::Workspace(alloc::string::String)
pub devlaunch_core::flows::session_manager::PaneDestination::Workspace
pub devlaunch_core::flows::session_manager::PaneDestination::Workspace::claude_profile: core::option::Option<alloc::string::String>
pub devlaunch_core::flows::session_manager::PaneDestination::Workspace::workspace_id: alloc::string::String
impl core::clone::Clone for devlaunch_core::flows::session_manager::PaneDestination
pub fn devlaunch_core::flows::session_manager::PaneDestination::clone(&self) -> devlaunch_core::flows::session_manager::PaneDestination
impl core::cmp::Eq for devlaunch_core::flows::session_manager::PaneDestination
Expand Down
47 changes: 47 additions & 0 deletions rust/devlaunch-core/src/clients/herdr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1234,6 +1234,53 @@ pub(crate) fn pane_list_argv() -> Vec<String> {
vec!["pane".to_owned(), "list".to_owned()]
}

/// The source id devlaunch reports under, in herdr's `--source` sense.
///
/// One string, two callers: this module's argv builders and [`HOOK`], which spells it
/// as a shell literal because it is a shell script.
/// `the_hook_reports_under_the_source_this_module_names` is the test that diffs them.
pub(crate) const REPORT_SOURCE: &str = "devlaunch:claude";

/// The metadata token naming the Claude profile a pane's session forwards.
///
/// Display-only, and rendered as `$profile` in herdr's agent sidebar rows. A *token*
/// rather than `--display-agent`, which would fight the agent name `report-agent`
/// sets and which herdr uses to pick a detection manifest.
pub(crate) const PROFILE_TOKEN: &str = "profile";

/// The argv that labels a pane with the Claude profile its session runs as, or clears
/// the label when the session names no profile.
///
/// **Clearing matters as much as setting.** A pane is reused: a launch that named
/// `work` and a later launch in the same pane that named nothing would otherwise leave
/// the sidebar claiming an account the running session is not using, which is the exact
/// mislabelling `--claude-profiles` exists to prevent, moved somewhere more visible.
///
/// Verified against herdr 0.8.2: `pane report-metadata [OPTIONS] --source <ID>
/// <PANE_ID>`, with `--token NAME=VALUE` and `--clear-token NAME`. The pane id is given
/// first, as [`HOOK`] gives it to `report-agent`, which is the invocation measured
/// against a live herdr.
pub(crate) fn profile_metadata_argv(pane_id: &str, profile: Option<&str>) -> Vec<String> {
let mut args = vec![
"pane".to_owned(),
"report-metadata".to_owned(),
pane_id.to_owned(),
"--source".to_owned(),
REPORT_SOURCE.to_owned(),
];
match profile {
Some(name) => {
args.push("--token".to_owned());
args.push(format!("{PROFILE_TOKEN}={name}"));
}
None => {
args.push("--clear-token".to_owned());
args.push(PROFILE_TOKEN.to_owned());
}
}
args
}

/// The argv that asks herdr what one pane is running.
pub(crate) fn process_info_argv(pane_id: &str) -> Vec<String> {
vec![
Expand Down
70 changes: 70 additions & 0 deletions rust/devlaunch-core/src/flows/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2182,6 +2182,27 @@ fn begin_reporting(
reporting: herdr::Reporting,
notices: &mut dyn Notices<LaunchNotice>,
) -> Option<(herdr::Reporting, session_manager::Forward)> {
// Which account this session runs as, so the manager's sidebar says so rather than
// leaving the name of a directory as the only clue. Called even when no profile was
// named, because a pane is reused and a stale label is worse than none.
//
// **First, and before every early return below.** This sat in the `Ready` arm at
// the bottom, which is one exit out of five: no ssh config, no published alias, a
// forward that would not start and a container that refused all return `None` from
// here, and every one of them still opens a session (the notices they raise are
// never fatal). So a pane that had run `dl ws --claude-profile work` and was reused
// for a `dl ws2` whose alias devpod had not published yet kept asserting
// `profile=work` over a session on the default login -- exactly the stale label the
// clearing exists to prevent, on the paths where clearing never ran.
//
// Safe this early because the label is host-side: `report_profile` runs herdr's own
// binary against a pane id, and needs neither the forward nor a prepared container.
session_manager::report_profile(
session.runner,
&reporting,
session.host.claude.profile.as_deref(),
);

// The alias, resolved without asking whether dl is on a terminal.
//
// Deliberately not [`Terminal`], which answers a different question and is
Expand Down Expand Up @@ -6918,6 +6939,55 @@ mod tests {
);
}

/// A Scene whose pane reports to a session manager, and which resolves no ssh
/// alias, so `begin_reporting` takes one of its four early returns.
///
/// The five herdr variables are what `Reporting::resolve` asks for; the `Scene`
/// leaves `ssh_config` unset, which is the "no ssh config" arm.
fn reporting_in_a_pane(mut scene: Scene) -> Scene {
scene.host.herdr = crate::clients::herdr::HostEnv {
enabled: Some("1".to_owned()),
in_pane: Some("1".to_owned()),
pane_id: Some("w1:p3".to_owned()),
socket: Some("/run/herdr.sock".to_owned()),
binary: Some("/opt/herdr/bin/herdr".to_owned()),
};
scene
}

#[test]
fn the_profile_label_is_reported_even_when_the_manager_cannot_be_reached() {
// The stale-label clear used to sit in `begin_reporting`'s `Ready` arm, which
// is one exit out of five. No ssh config, no published alias, a forward that
// would not start and a container that refused all return early, and every one
// of them still opens the session -- so a pane that had run
// `dl ws --claude-profile work` and was then reused for a launch that took one
// of those paths kept asserting `profile=work` over a session on another
// account. Reported first now, before any of them.
let scene = reporting_in_a_pane(
Scene::new()
.on_a_terminal(&["myws"])
.with_running("myws")
.naming_a_claude_profile("work", true),
);

let _ = a_session_on_our_own_claude(&scene, Some("claude"));

let labelled: Vec<Vec<String>> = scene
.runner
.calls_to("/opt/herdr/bin/herdr")
.iter()
.map(|call| call.invocation().argv().to_vec())
.filter(|argv| argv.iter().any(|word| word == "report-metadata"))
.collect();
assert!(
labelled
.iter()
.any(|argv| argv.iter().any(|word| word.contains("profile=work"))),
"no profile label was reported: {labelled:?}"
);
}

/// The refusal `clients::claude` builds and nothing used to read.
///
/// `NoToken::ProfileUnreadable` is documented where it is declared as "the one
Expand Down
Loading
Loading