Repository navigation
dl carries two tokens into a workspace and nothing else, so no session manager can see the agent it started #548
Description
The gap
Every session manager worth using — herdr, Agent View, gh-issues-tui, the
tmux-and-worktree family — is blind to an agent dl started, and all of them
fail the same way for the same reason. This is the priority: dl is the thing
that can fix it, once, for all of them.
A manager learns what an agent is doing by one of two routes:
-
Process and screen inspection on the host. Under
dlthe foreground
process issshand the pane is an ssh stream, so the process half finds no
agent. (The screen half may fare better than expected — see the measurement.) -
A hook the agent runs, reporting to the manager. herdr's is
representative and is the honest general case: a command, with coordinates in
the environment, talking to a unix socket:"$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" --state working
with
HERDR_BIN_PATH,HERDR_PANE_ID,HERDR_SOCKET_PATH,HERDR_ENV=1.
Route 2 is the one that can work through a container, and dl blocks all three
of its parts: the binary is a host path, the socket is a host socket, and the
coordinates are not forwarded. dl forwards exactly two environment variables,
both hardcoded and both credentials (GH_TOKEN, CLAUDE_CODE_OAUTH_TOKEN), and
forwards no sockets at all.
The failure is silent every time, which is what makes it expensive: with repos
shipping the claude-code feature, ~/.claude/hooks is mounted read-only into
the container, so the manager's hook script is present and does run — and
then finds nothing to talk to.
The mechanism is already here, and it was built anticipating this
Two facts from clients/ssh.rs change the shape of this from "new subsystem" to
"widen an existing one":
The permit list is already a list. send_env: &[String] is threaded through
to the ssh argv as -o SendEnv=<name> per entry, and into control_key, which
length-prefixes every field with a comment written against exactly this change:
a collision here is exactly the silent cross-permit-list reuse this key exists
to prevent. The prefixes are what make that impossible by construction rather
than by an argument about what an alias or an environment variable name is
allowed to contain — which is the kind of argument that stops holding the day
somebody widens one of them.
Today the list holds one name. Widening it is the mechanism working as designed,
and master reuse is already keyed on it, so two different permit lists cannot
share a socket.
The transport is real OpenSSH, so sockets can cross without a mount.
PROGRAM = "ssh", over the alias devpod publishes whose ProxyCommand tunnels
devpod ssh --stdio. That means -R /container/path:/host/path unix-socket
forwarding is available on this path.
That supersedes the bind mount proposed in #547, and is strictly better:
a bind mount lands only at container creation, so every existing workspace would
need dl <ws> recreate. A remote forward is negotiated per connection, so it
works on attach, on a container that has been running for a week, with nothing
to recreate. #547 should become a consumer of this map rather than its own
design.
Proposed shape
Two switches, both taking values, in the style of DEVLAUNCH_AID_AGENT rather
than the boolean ones:
| Variable | What it does |
|---|---|
DEVLAUNCH_FORWARD_ENV=NAME[,NAME...] |
Add names to the ssh SendEnv permit list for this launch |
DEVLAUNCH_FORWARD_SOCKET=/host/path[:/container/path] |
Forward a unix socket into the workspace with ssh -R |
Plus reuse of the existing lend path (the one that streams gh and claude
in as a tar over the channel already held) for the manager's own CLI, so
$..._BIN_PATH resolves inside.
Set those three and any manager's hook protocol works, with no per-manager code
in dl and no cooperation from the manager. herdr is the first consumer; nothing
about the facility knows its name.
Measure first
None of this is checked; there is no herdr, devpod or docker in the environment
this was written from. An agent on the machine should answer these before any
code, and the first two can each void the design.
- Does the sshd accept arbitrary names?
SendEnvis only half a handshake —
the server mustAcceptEnvthem or they are dropped silently.GH_TOKEN
arrives today, so either devpod's sshd setsAcceptEnv *or something
configures it. Find out which, because "it works for GH_TOKEN" does not imply
it works forHERDR_PANE_ID. Check the container's/etc/ssh/sshd_config
and confirm a forwarded novel name actually lands in the remote environment. - Does
-Runix forwarding survive theProxyCommand? Forward any socket
into a live workspace and connect to it from inside. AProxyCommand
transport carries the ssh protocol, so remote forwards should work, but this
has never been exercised here. - Does the forward interact with
ControlMaster? A remote forward belongs
to the master connection, so a later trip joining an existing master does
not get its own forward.control_keymust take the forward spec as a
field exactly as it takessend_env, or a second launch silently inherits the
first's socket — the precise failure the length-prefixing comment describes. - Is screen detection already working? Cheapest possible outcome, and worth
testing before building anything.dl <ws> -- claudepipes the agent's own
TUI through the pane, so a manager matching rules against screen content may
already classify correctly and only the process-name half may be failing. If
so, herdr'sHERDR_AGENT=<agent>(documented for "VMs and wrappers") set
host-side may be the entire fix, at zero cost todl.
If (4) answers yes, most of this map is unnecessary. Run it first.
Scope and constraints
- A credential is not a coordinate. The two existing forwards are secrets
and go through a private file and a permit list for that reason. These are
not, but the same care applies in reverse: an arbitrary caller-supplied name
list must not become a way to sweep the host's environment into a container a
repo you did not write can read. Decide whether names are allowlisted by
pattern, and say why in the code. - Which transports get it. There are two: real ssh for anything needing a
pty, and the devpod transport for one-shots. Socket forwarding exists only on
the first. Say plainly which launches carry the facility. - Off by default, no launch pays for an unused forward, and a stale
=0turns
nothing on. Pinned by a test, asDEVLAUNCH_ZELLIJis. - The README env table and
docs/workspace-tools.mdgain the variables;
flows::provision::lending_contractreads that file by heading, and
test_docs_prose.pybans em and en dashes there.
Activity
blooop commentedon Sep 1, 2026
Measured on the machine (#549)
Run on the host
ags-dell, 2026-09-01, against a scratch workspacedevlaunch-main-3j1tcreated withXDG_CACHE_HOME=/tmp/dl-measure/cache dl blooop/devlaunch up. herdr 0.8.2 (stable) was fetched to the scratch tree and runas
herdr server(headless); its socket API is what every state reading belowcomes from, so nothing here rests on me reading a screenshot correctly.
Released
dlthroughout: 0.25.0, which matters once, in E4.Transport confirmed first, as instructed:
E1 — screen detection is already working. Say it loudly.
Yes. With
HERDR_AGENT=claudeset host-side, herdr classifies adlworkspaceagent correctly and continuously, with no change to
dlat all. Not "partially",not "the idle case": idle, working and blocked all land, from Claude's own screen,
through ssh, through devpod, through the container.
The two runs differ in one
envword.Without it. Launch
dl <ws> -- claudein a herdr pane and poll for 40s:The process half fails exactly as #548 predicts. But the pane's screen at that
moment held the real thing:
That is
live_prompt_box's exact shape and herdr still saidunknown, because itnever decided the pane was a claude pane. The rules were never consulted.
The sharpest version of that control: I drove the same session into an
unmistakably blocked state and asked herdr what it saw.
A human is being waited on and the manager cannot see it. That is the bug, stated
as precisely as I can state it.
With it.
env HERDR_AGENT=claude dl <ws> -- claude, same pane, same everything:Transitions, polled every 3s with herdr's own
agent explainbeside each reading:and into blocked:
aidbehaves identically (aid devlaunch-main-3j1t "..."→claude working→claude idle), which is expected since it isdl <ws> -- <agent>underneath, butworth having measured rather than assumed.
Two things fell out of this that nobody asked for and both are useful.
explainnames the rule that fired. Every state above is attributable to amanifest rule by id, so a future regression is diagnosable rather than a shrug.
The pane title is already the workspace id. herdr reports
terminal_title_stripped: devlaunch-main-3j1ton the pane throughout.dlisalready publishing one coordinate any manager can read; it is the agent identity
and only the agent identity that is missing.
What this does to the map
#548 reduces to documenting one host-side environment variable. E2 to E4 are moot
for herdr. I ran them anyway because they were written and because two of them
turned out to carry information that changes the design if it is ever built, but
nothing below is a reason to build it.
One caveat worth putting in #548 rather than losing:
HERDR_AGENTfixes herdr,by name. A manager without an equivalent override is still blind, and the fix is
per-manager configuration rather than anything
dlprovides. If that isacceptable, the map is done. Note also that
aidalready knows which agent it isstarting (
--claude/--codex/--gemini), so it could set the variable itselfand make even the documentation unnecessary. That would put a herdr-specific name
in
dl, which #548 explicitly does not want. I am not deciding that here; I amsaying the option exists and costs one line.
E2 — the question does not apply, and the answer is yes anyway
#548 sentence that is wrong: "so either devpod's sshd sets
AcceptEnv *orsomething configures it. ... Check the container's
/etc/ssh/sshd_config".There is no sshd.
The far end is devpod's own agent speaking SSH in Go.
AcceptEnvis an OpenSSHsshd_configkeyword and there is nosshd_configto hold it, so the permit-listquestion has no configuration to inspect. What it has instead is behaviour, and
the behaviour is permissive:
Wildcards included. Verdict: novel names arrive; #548's env half needs nothing
from the server.
Two controls, because "it arrived" is worth nothing without them:
So there is no wholesale host-environment leak through the stdio tunnel, and
SendEnvis genuinely the thing that carried it. The container session has 23environment variables, not the host's.
And the premise behind the E2 question was right to be suspicious
#549 warned that "
GH_TOKENarriving today proves nothing about a novel name: itmay be arriving because something configures that one specifically." Stronger than
that:
GH_TOKENis not arriving bySendEnvat all. A baresshwith noSendEnvand an emptyGH_TOKENon the host still finds it set inside:It lives in a file devpod writes at container creation:
Three consequences worth carrying into #548:
-o SendEnv=GH_TOKENthatdlputs on the ssh line is belt-and-braces overa token that is already in the container's session environment by another route.
holds against herdr cannot see an agent inside a dl workspace: the hook arrives, its socket, binary and pane id do not #547's bind mount. The existing credential path already has the
property the map criticises.
it is the kind of thing that is cheaper to notice now than later.
I saw
SendEnv=GH_TOKENand nothing else on the wire:CLAUDE_CODE_OAUTH_TOKENwas absent because the host had none set;claudeinsideauthenticated from the mounted
~/.claude. So #548's "exactly two environmentvariables" is right about the code and, on this machine, one of the two never
travels.
E3 —
-Runix forwarding works, end to end, first tryVerdict: pass, and not narrowly. The socket arrives, the herdr CLI running
inside the container drives the herdr server on the host through it, and the
host's pane state changes as a result.
Transport:
There is no
nc, nosocatand nopython3inside the container, so I lent theherdr binary in over the same channel (
ssh ... 'cat > /tmp/herdr'), which is thelend path #548 wants anyway and it works unchanged:
Then the protocol, from inside:
Then the map's actual acceptance test:
Route 2 works today, over stock OpenSSH, with nothing added to
dl.Taking the three named failure modes in turn:
AllowStreamLocalForwardingis not a factor. Not configurable here and notrefused; there is no sshd (E2).
SO_PEERCRED: the call succeeds from container uid 1000 and from uid 0(
sudo -n ... pane list→rc=0), while the host user is uid 1000. So the uidmismatch Measure whether a session manager can already see into a workspace, before anything is built for it #549 anticipated would not have bitten even if the uids had differed.
Three hazards the design has to handle
1. A failed forward is invisible under
dl's own alias. Forwarding to a paththe container user cannot create fails, and devpod's ssh config sets
LogLevel error, which is below the level the warning is emitted at:The fix is one option, and it is clean in both directions:
DEVLAUNCH_FORWARD_SOCKETmust setExitOnForwardFailure=yesor it will fail theway #548 says this class of bug always fails.
2. The remote listen path is destroyed, not refused. devpod's Go server does
not implement OpenSSH's "refuse to bind over an existing path" rule. Without
StreamLocalBindUnlinkit overwrites whatever is there:A user-supplied container path in
DEVLAUNCH_FORWARD_SOCKET=/host:/containeristherefore a file-deleting primitive, and
StreamLocalBindUnlink=yesmakes it moreso rather than less. Whatever validation the "a credential is not a coordinate"
paragraph lands on needs to cover the destination half too, not just the names.
3. A dead socket is left behind. When the master goes, the socket file stays
in the container and stops answering:
Self-healing on the next launch that forwards (
StreamLocalBindUnlink=yes), but ahook firing between launches connects to a corpse. Since
~/.claude/hooksismounted read-only into every workspace and the hook does run, that is a real
window, not a theoretical one.
E4 — the prediction is wrong, and the conclusion survives anyway
#548 sentence that is wrong: "A remote forward belongs to the master
connection, so a later trip joining an existing master does not get its own
forward." It does.
Trip 1 opens the master with a forward to A only. Trip 2 joins it and asks for B:
Multiplexing verified, not assumed:
B was honoured. The real failure is the other direction, and it is worse.
Forwards accumulate on the master and outlive the trip that asked for them. Trip
3, which asks for no forward at all, gets both:
So the concrete
dlfailure is not "launch 2 silently inherits launch 1's socketinstead of its own". It is "launch 2, which asked for nothing, silently gets
launch 1's socket", for up to
CONTROL_PERSISTseconds. That breaks the map'sown "off by default, no launch pays for an unused forward" constraint at the
granularity of the master's lifetime, and it breaks it invisibly.
The ticket's instruction stands, in as many words: #548 must take the forward
spec as a
control_keyfield. The reasoning in the length-prefixing commenttransfers exactly and only needs its noun changed. A master does not filter
forwards against the joining client's request the way it filters
SendEnv; itsimply keeps every forward any client ever asked it for. Keying the socket on the
forward spec is what makes a no-forward launch unable to find a master that has
one, which is the same "unrepresentable rather than documented" move
send_envalready gets.
One version note that matters for reading this section. Multiplexing landed in
v0.26.0(18edab0 feat: one reusable OpenSSH connection per workspace); thereleased
dlon this machine is 0.25.0 and printswith no
ControlMasterat all. E4 was therefore measured with the option setclients::ssh::command_argsbuilds, by hand, rather than by watching 0.25.0 doit. The result applies from 0.26.0 onward, which is where the hazard exists.
Summary
HERDR_AGENT. The map collapses to documenting one variable.GH_TOKENnever used this route.-Rsurvive the ProxyCommand?report-agentcall. Three hazards, all fixable.ControlMaster?control_keymust take the spec.Cleanup
devlaunch-main-3j1tdeleted (dl ... rm --force, container13ba517aed25removed),
/tmp/dl-measureremoved, herdr server stopped.devpod listis back tothe same eight workspaces it held before this ran:
docker psshows only the pre-existing dagger engine, and~/.ssh/configholds noentry for the scratch workspace.
dl --purgewas not run at any point.Two things this did touch and did not put back, both under
~/.config/herdrand~/.local/state/herdr, which already existed from a previous herdr install:starting the server refreshed the agent-detection manifests (claude
2026.08.21.1→2026.08.29.1, plus codex and copilot) and rewrotesession.json. Both are herdr's own state, not this repo's, and the refresh iswhat any
herdrstart would have done.blooop commentedon Sep 1, 2026
Built, and the decision the measurement left open
#550. Two things this closes that
the measurement comment above deliberately left hanging.
The decision:
aidsets the variable, and the name is hardcodedThe measurement said, and then refused to settle:
It is decided now:
aidsets it, unconditionally, andHERDR_AGENTis writteninto
rust/aid/src/main.rsas a constant.That is a deviation from this issue's own constraint, so it should be on the record
as one rather than discovered later in the diff. The constraint reads:
That sentence is an argument for the generic forwarding facility: given the
facility, no manager needs naming. The measurement removed the facility, and with
it the thing that was buying the anonymity. What is left is one name in one
constant, or a documented variable every user exports by hand on every machine.
The trade was made in favour of the name.
Two smaller choices went the same way and are worth naming, because both invert
this issue's stated posture of "off by default":
Gating on
HERDR_ENV=1is a second thing to be wrong about, and being wrongabout it fails silently, which is the failure mode this whole issue is about. A
machine with no herdr pays one
setenvfor a name nothing reads.aidis what decides which agentstarts, so it is the only thing that can be right about it: a
HERDR_AGENT=codexleft in a profile is wrong the moment
aid --clauderuns, and a manager thatbelieved it would match codex's rules against Claude's screen and classify
nothing.
Plain
dlstill sets nothing, because plaindlhas no agent to name.The evidence for the mechanism, which the report above does not contain
The measurement above ran
env HERDR_AGENT=claude dl <ws> -- claude, where thevalue is in the top process's environment from its own exec. That is the opposite
of the path that shipped, and the difference is load-bearing:
aidcallsdl::runin-process, so it can onlysetenv, and asetenvafter start does notrewrite
/proc/<pid>/environ, which the kernel fixes at exec. The plausibleversion of this change is a silent no-op.
So it was measured again, after the change, with nothing in the launching
environment. Reading
HERDR_AGENTout of/proc/<pid>/environfor each of thepane's foreground processes:
aid's own row carries nothing, exactly as thesetenvsemantics predict. Thesshit spawns carries the name, because that child builds its environment at itsown exec. herdr walks the pane's foreground processes rather than reading only the
shell, which is what makes the child enough.
The behaviour that follows, patched
aid, empty environment:The
Noneat t=05s is the mechanism showing its own seam: beforesshexiststhere is nothing carrying the name. And into a blocking prompt:
No test in the tree holds any of this, and none can, since it is a fact about a
running herdr, a pty and a container. The unit tests hold the part that is in the
process: that every name in
aid's table is written verbatim, that a stale value isreplaced, and that a line starting no agent leaves the environment alone. This
comment is the record for the rest.
The scratch workspace was deleted afterwards and
devpod listis back to the eightit held before.
fix: a comment cited a test as proof of a fact the test cannot reach
fix: the agent's name reached one of dl's two transports
fix: two claims this branch's own code falsified
fix: the agent's name reached one of dl's two transports
fix: two claims this branch's own code falsified