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
10 changes: 10 additions & 0 deletions contrib/herdr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,16 @@ blocked / done / idle) reads. The agent edits your actual worktree files —
`/workspace` is a bind mount, not a copy, so there's no sync step in either
direction.

The pane's herdr identity (`HERDR_SOCKET_PATH`, `HERDR_PANE_ID`,
`HERDR_WORKSPACE_ID`, `HERDR_TAB_ID`) is forwarded into the guest at
attach/exec time, as exports in the pane's remote command, so the in-box
agent's herdr hook attributes state to the right pane. (`lilbox agent` also
accepts transient `--env KEY=VAL` pairs, but the pane flow provisions without
a task, so the attach path is the one that carries the identity.) When a
herdr socket is known, the pane also brings up the guest's unix→vsock relay
listener for it. `HERDR_BIN_PATH` is never forwarded: it's a host path,
meaningless inside the box.

Ctrl+click a published `*.ts.net` URL in any pane to shell into the box serving
it.

Expand Down
45 changes: 43 additions & 2 deletions contrib/herdr/bin/lilbox-herdr
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,43 @@ lilbox() { command "$LILBOX_BIN" "$@"; }

herdr_bin() { printf '%s' "${HERDR_BIN_PATH:-herdr}"; }

# --- herdr pane identity ------------------------------------------------------
# The pane's herdr identity as KEY=VAL lines, forwarded into the guest so the
# in-box agent's herdr hook attributes agent state to this pane (#131).
# HERDR_BIN_PATH is deliberately excluded: it's a host path, meaningless inside
# the box — the guest hook talks to HERDR_SOCKET_PATH over the vsock relay.
herdr_identity_kv() {
[ -n "${HERDR_ENV:-}" ] || return 0
printf 'HERDR_ENV=%s\n' "$HERDR_ENV"
local k v
for k in HERDR_SOCKET_PATH HERDR_PANE_ID HERDR_WORKSPACE_ID HERDR_TAB_ID; do
eval "v=\${$k:-}"
[ -n "$v" ] && printf '%s=%s\n' "$k" "$v"
done
}

# The agent pane's in-guest command: export the identity (attach path — the
# provision-time `lilbox agent --env` forwarding in ensure_box never runs when
# the box boots without a task), then make sure the unix→vsock relay listener
# is up. #130 wires the listener into the task launch only; a box provisioned
# for a pane never starts one, so start it here, idempotently, keyed off the
# socket's existence. Port 47100 must match HERDR_VSOCK_PORT in src/sandbox.rs.
agent_inner() {
local kv exports=() prefix=
while IFS= read -r kv; do
printf -v kv '%s=%q' "${kv%%=*}" "${kv#*=}"
exports+=("$kv")
done < <(herdr_identity_kv)
# export over a bare assignment prefix: the latter's persistence across
# `exec` isn't portable to busybox ash, which is what boxes ship.
[ ${#exports[@]} -gt 0 ] && prefix="export ${exports[*]}; "
local inner="${prefix}exec $LILBOX_AGENT_CMD"
if [ -n "${HERDR_SOCKET_PATH:-}" ]; then
inner='[ -S "$HERDR_SOCKET_PATH" ] || { mkdir -p "${HERDR_SOCKET_PATH%/*}" && socat "UNIX-LISTEN:$HERDR_SOCKET_PATH,fork,reuseaddr" "VSOCK-CONNECT:2:47100" >/dev/null & }; '"$inner"
fi
printf '%s' "$inner"
}

notify() {
[ -n "${HERDR_ENV:-}" ] || return 0
"$(herdr_bin)" notification show lilbox --body "$1" >/dev/null 2>&1 || true
Expand Down Expand Up @@ -144,9 +181,13 @@ ensure_box() {
"" | gone)
[ -d "$path" ] || die "worktree is gone: $path"
printf 'booting %s for %s ...\n' "$name" "$path"
# Forward the pane's herdr identity as transient --env pairs (#131),
# never persisted sandbox config.
local env_args=() kv
while IFS= read -r kv; do env_args+=(--env "$kv"); done < <(herdr_identity_kv)
# Unquoted on purpose: LILBOX_AGENT_ARGS is a flag string.
# shellcheck disable=SC2086
lilbox agent "$name" --workspace "$path" --image "$LILBOX_IMAGE" $LILBOX_AGENT_ARGS
lilbox agent "$name" --workspace "$path" --image "$LILBOX_IMAGE" "${env_args[@]}" $LILBOX_AGENT_ARGS
;;
*)
printf 'starting %s (was %s) ...\n' "$name" "$status"
Expand Down Expand Up @@ -526,7 +567,7 @@ main() {
esac
case $cmd in
open) cmd_open "exec $LILBOX_SHELL -l" ;;
agent) cmd_open "exec $LILBOX_AGENT_CMD" ;;
agent) cmd_open "$(agent_inner)" ;;
watch) cmd_watch ;;
status) cmd_status ;;
expose) cmd_expose ;;
Expand Down
43 changes: 43 additions & 0 deletions contrib/herdr/tests/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ set -euo pipefail

SHIM=$(realpath "$(dirname "$0")/../bin/lilbox-herdr")

# Hermetic: the session running these tests may itself live under herdr, so
# drop any ambient pane identity; tests that want it pass it inline per call.
unset HERDR_ENV HERDR_SOCKET_PATH HERDR_PANE_ID HERDR_WORKSPACE_ID HERDR_TAB_ID

PASS=0
FAIL=0
TEST=
Expand Down Expand Up @@ -352,6 +356,45 @@ assert_contains "$LILBOX_STUB_STATE/herdr-calls.log" \
"pane id recovered from HERDR_PLUGIN_EVENT_JSON"
teardown

TEST=19 # agent action forwards the pane's herdr identity into the box
setup
HERDR_ENV=1 HERDR_SOCKET_PATH=/tmp/herdr-test.sock HERDR_PANE_ID=pane-42 \
HERDR_WORKSPACE_ID=ws-7 HERDR_TAB_ID=tab-3 \
HERDR_PLUGIN_CONTEXT_JSON="{\"worktree\":{\"checkout_path\":\"$ALIVE_DIR\"}}" shim agent
calls=$(cat "$LILBOX_STUB_STATE/calls.log")
case $calls in *"agent "*"--env HERDR_PANE_ID=pane-42"*) ok "lilbox agent gets --env HERDR_PANE_ID" ;; *) bad "lilbox agent gets --env HERDR_PANE_ID (got: $calls)" ;; esac
case $calls in *"--env HERDR_SOCKET_PATH=/tmp/herdr-test.sock"*) ok "lilbox agent gets --env HERDR_SOCKET_PATH" ;; *) bad "lilbox agent gets --env HERDR_SOCKET_PATH (got: $calls)" ;; esac
case $calls in *"--env HERDR_WORKSPACE_ID=ws-7"*"--env HERDR_TAB_ID=tab-3"*) ok "lilbox agent gets workspace+tab ids" ;; *) bad "lilbox agent gets workspace+tab ids (got: $calls)" ;; esac
case $calls in *"--env HERDR_ENV=1"*) ok "lilbox agent gets HERDR_ENV=1" ;; *) bad "lilbox agent gets HERDR_ENV=1 (got: $calls)" ;; esac
case $calls in *HERDR_BIN_PATH*) bad "HERDR_BIN_PATH never forwarded (host path, invalid in guest)" ;; *) ok "HERDR_BIN_PATH never forwarded (host path, invalid in guest)" ;; esac
case $calls in *ssh*export*HERDR_PANE_ID=pane-42*"exec claude"*) ok "attach path exports identity for the guest agent" ;; *) bad "attach path exports identity for the guest agent (got: $calls)" ;; esac
case $calls in *ssh*"VSOCK-CONNECT:2:47100"*) ok "attach path starts the unix->vsock relay listener" ;; *) bad "attach path starts the unix->vsock relay listener (got: $calls)" ;; esac
teardown

TEST=20 # agent action forwards nothing when not running under herdr
setup
HERDR_PLUGIN_CONTEXT_JSON="{\"worktree\":{\"checkout_path\":\"$ALIVE_DIR\"}}" shim agent
calls=$(cat "$LILBOX_STUB_STATE/calls.log")
case $calls in *"--env"*) bad "no --env without HERDR_ENV (got: $calls)" ;; *) ok "no --env without HERDR_ENV" ;; esac
case $calls in *"export HERDR"*) bad "no identity export without HERDR_ENV (got: $calls)" ;; *) ok "no identity export without HERDR_ENV" ;; esac
case $calls in *VSOCK-CONNECT*) bad "no relay listener without HERDR_SOCKET_PATH" ;; *) ok "no relay listener without HERDR_SOCKET_PATH" ;; esac
case $calls in *ssh*"; exec claude"*) ok "attach still execs the agent" ;; *) bad "attach still execs the agent (got: $calls)" ;; esac
teardown

TEST=21 # identity values with spaces survive %q quoting without splitting
setup
HERDR_ENV=1 HERDR_PANE_ID='pane 42' \
HERDR_PLUGIN_CONTEXT_JSON="{\"worktree\":{\"checkout_path\":\"$ALIVE_DIR\"}}" shim agent
calls=$(cat "$LILBOX_STUB_STATE/calls.log")
# %q escapes the space, so the export round-trips as one word; an unquoted
# value would split into `HERDR_PANE_ID=pane` + a stray `42` token. Check the
# attach (ssh) line only: the provision `--env` pair is argv, where a raw
# space is legitimate.
ssh_line=$(grep '^ssh ' "$LILBOX_STUB_STATE/calls.log")
case $ssh_line in *"HERDR_PANE_ID=pane\\ 42"*) ok "space escaped as one shell word" ;; *) bad "space escaped as one shell word (got: $ssh_line)" ;; esac
case $ssh_line in *"HERDR_PANE_ID=pane 42"*) bad "unescaped space would split the export" ;; *) ok "unescaped space would split the export" ;; esac
teardown

# --- summary ----------------------------------------------------------------
echo
if [ "$FAIL" -gt 0 ]; then
Expand Down
5 changes: 5 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,11 @@ pub(crate) struct AgentArgs {
/// Don't inherit the host's Claude login into the box; fall back to ANTHROPIC_API_KEY injection.
#[arg(long)]
pub(crate) no_claude_config: bool,
/// Extra KEY=VAL environment for the guest agent exec. Transient: passed to
/// the exec call only, never persisted in the sandbox config (the herdr
/// plugin uses this to forward pane identity, #131).
#[arg(long = "env", value_name = "KEY=VAL")]
pub(crate) env: Vec<String>,
#[arg(last = true)]
pub(crate) task: Vec<String>,
}
61 changes: 60 additions & 1 deletion src/commands/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,32 @@ async fn chmod_guest(sandbox: &Sandbox, path: &str, mode: u32) {
}
}

/// Parse repeatable `--env KEY=VAL` pairs into exec env for the guest.
/// `split_once` keeps any `=` in the value intact.
fn parse_exec_env(pairs: &[String]) -> Result<Vec<(String, String)>> {
pairs
.iter()
.map(|pair| {
let (key, value) = pair
.split_once('=')
.ok_or_else(|| anyhow!("--env expects KEY=VAL, got '{pair}'"))?;
// Fail fast on anything that isn't a valid shell identifier — a
// bad key would fail opaquely in the guest otherwise.
let valid = !key.is_empty()
&& key.chars().enumerate().all(|(i, c)| {
c == '_'
|| c.is_ascii_uppercase()
|| c.is_ascii_lowercase()
|| (i > 0 && c.is_ascii_digit())
});
if !valid {
bail!("--env key '{key}' is not a valid name ([A-Za-z_][A-Za-z0-9_]*)");
}
Ok((key.to_owned(), value.to_owned()))
})
.collect()
}

/// Build the guest command that runs the agent task.
/// When the herdr relay route is active (`relay_socket` is the mirrored guest
/// path for the host's herdr socket), the script first spawns the unix→vsock
Expand Down Expand Up @@ -135,6 +161,7 @@ fn agent_launch_command(task: &str, relay_socket: Option<&std::path::Path>) -> V
}

pub(crate) async fn cmd_agent(app: &App, args: AgentArgs) -> Result<i32> {
let exec_env = parse_exec_env(&args.env)?;
let name = match args.name {
Some(name) => name,
None => random_name(app)?,
Expand Down Expand Up @@ -324,7 +351,7 @@ pub(crate) async fn cmd_agent(app: &App, args: AgentArgs) -> Result<i32> {
// path string as the guest-side path.
let relay = herdr_vsock_route(herdr_socket_path_from_env()).map(|(path, _)| path);
let command = agent_launch_command(&task, relay.as_deref());
run_guest(&sandbox, &command, Some(AGENT_WORKDIR)).await
run_guest(&sandbox, &command, Some(AGENT_WORKDIR), &exec_env).await
}

#[cfg(test)]
Expand Down Expand Up @@ -387,6 +414,38 @@ mod tests {
assert!(cmd[2].contains("VSOCK-CONNECT:2:47100"));
}

#[test]
fn parse_exec_env_accepts_pairs_and_preserves_value_equals() {
let env = parse_exec_env(&[
"HERDR_PANE_ID=pane-42".to_string(),
"HERDR_ENV=1".to_string(),
"TOKEN=a=b=c".to_string(),
])
.unwrap();
assert_eq!(
env,
vec![
("HERDR_PANE_ID".to_string(), "pane-42".to_string()),
("HERDR_ENV".to_string(), "1".to_string()),
("TOKEN".to_string(), "a=b=c".to_string()),
]
);
}

#[test]
fn parse_exec_env_rejects_malformed_pairs() {
assert!(parse_exec_env(&["NOEQUALS".to_string()]).is_err());
assert!(parse_exec_env(&["=value".to_string()]).is_err());
}

#[test]
fn parse_exec_env_rejects_invalid_key_names() {
assert!(parse_exec_env(&["1ST=x".to_string()]).is_err());
assert!(parse_exec_env(&["MY-KEY=x".to_string()]).is_err());
assert!(parse_exec_env(&["MY KEY=x".to_string()]).is_err());
assert!(parse_exec_env(&["_OK_9=x".to_string()]).is_ok());
}

#[tokio::test]
async fn agent_secret_is_stored_as_an_environment_reference() {
let config = with_secret_env(
Expand Down
21 changes: 18 additions & 3 deletions src/sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,13 @@ pub(crate) async fn connect_box(app: &App, name: &str, auto_resume: bool) -> Res
}
}

/// `exec_env` is transient: applied to this exec/attach call only, never
/// written into the sandbox's persisted config.
pub(crate) async fn run_guest(
sandbox: &Sandbox,
command: &[String],
cwd: Option<&str>,
exec_env: &[(String, String)],
) -> Result<i32> {
if command.is_empty() {
bail!("nothing to run");
Expand All @@ -80,13 +83,19 @@ pub(crate) async fn run_guest(
return Ok(sandbox
.attach_with(cmd, |a| {
let a = a.args(args.iter().cloned());
let a = exec_env
.iter()
.fold(a, |a, (k, v)| a.env(k.as_str(), v.as_str()));
if let Some(cwd) = cwd { a.cwd(cwd) } else { a }
})
.await?);
}
let mut handle = sandbox
.exec_stream_with(cmd, |e| {
let e = e.args(args.iter().cloned());
let e = exec_env
.iter()
.fold(e, |e, (k, v)| e.env(k.as_str(), v.as_str()));
let e = if stdin_is_terminal {
e.stdin_null()
} else {
Expand Down Expand Up @@ -181,7 +190,13 @@ pub(crate) async fn exec(app: &App, args: ExecArgs) -> Result<i32> {
if args.cmd.is_empty() {
bail!("nothing to run - usage: lilbox exec NAME -- <cmd>");
}
run_guest(&connect_box(app, &args.name, true).await?, &args.cmd, None).await
run_guest(
&connect_box(app, &args.name, true).await?,
&args.cmd,
None,
&[],
)
.await
}

pub(crate) async fn ssh(app: &App, args: ExecArgs) -> Result<i32> {
Expand All @@ -198,7 +213,7 @@ pub(crate) async fn ssh(app: &App, args: ExecArgs) -> Result<i32> {
if args.cmd.is_empty() {
return Ok(sandbox.attach_shell().await?);
}
run_guest(&sandbox, &args.cmd, None).await
run_guest(&sandbox, &args.cmd, None, &[]).await
}

pub(crate) async fn run(app: &App, args: RunArgs) -> Result<i32> {
Expand All @@ -213,7 +228,7 @@ pub(crate) async fn run(app: &App, args: RunArgs) -> Result<i32> {
let code = if args.cmd.is_empty() {
sandbox.attach_shell().await?
} else {
run_guest(&sandbox, &args.cmd, None).await?
run_guest(&sandbox, &args.cmd, None, &[]).await?
};
sandbox.stop().await?;
Ok(code)
Expand Down
Loading