Skip to content

Do credentials reach a workspace nested two levels deep? #100

Closed

Description

@blooop

Question

Do credentials reach a workspace nested two levels deep, and if not, what
supplies them?

On the host, dl forwards the host's gh login into every workspace as
GH_TOKEN (devlaunch/gh_auth.py, ssh_args_and_env()), and this repo's
devcontainer additionally bind-mounts ~/.ssh and ~/.config/gh from the host
for containers started some other way. Both mechanisms assume one level of
nesting.

With dl running inside the devcontainer, a workspace it creates is two levels
from the host, and each mechanism has to be checked for whether it chains:

  • SSH_AUTH_SOCK=/home/vscode/.ssh/agent.sock is a bind mount of the host's
    agent socket. Does a socket bind-mounted into a container survive being
    bind-mounted onward into a container that the inner daemon creates — whose
    view of the filesystem is the devcontainer's, not the host's?
  • Does gh_auth's GH_TOKEN forwarding work from inside, where the token's
    source is itself a forwarded GH_TOKEN rather than a gh login? CLAUDE.md
    says dl "forwards the host's token into every workspace it opens as
    GH_TOKEN" — check whether the inner dl has anything to read.
  • Can the innermost container clone a private repo over SSH, and can it run gh?
  • Does dropping --network=host (see the networking ticket) change any of this?

Where a mechanism does not chain, the answer names what does — and prefers the
smallest thing that works over a second credential path.

How the outcome gets tested. Demonstrated in the running container: a clone
and a gh api user from two levels in, pasted here. Anything the repo has to
carry as a result lands through a build ticket with a test.

Activity

  1. self-assigned this
    on Aug 8, 2026
  2. blooop commented on Aug 8, 2026

    @blooop
    OwnerAuthor

    breadcrumb: Two host-side checks that change the shape of this ticket, both read-only.

    1. The scratch-XDG footgun is not container-specific — it fires on the host too. #96 framed it as "inside a container it is actively harmful". It is harmful everywhere:

    $ XDG_CONFIG_HOME=/tmp/wf100-scratch-cfg gh auth token
    no oauth token found for github.com          rc=1
    $ gh auth token                              rc=0
    

    On this host GH_TOKEN and GITHUB_TOKEN are both unset in a normal shell, so gh_auth.resolve_token() falls straight through HOST_TOKEN_VARS to gh auth token (gh_auth.py:99-103) — which the scratch dir has just broken. Every XDG_CONFIG_HOME=/tmp/dl-scratch/config dl-next … run on the host has been launching unauthenticated workspaces. The container only made it visible, because two levels down there is a nested GH_TOKEN=[unset] to notice.

    2. devlaunch stores nothing under XDG_CONFIG_HOME, so the scratch dir guards nothing. The instruction's premise ("everything it stores resolves through XDG_CACHE_HOME and XDG_CONFIG_HOME") is wrong about the config half:

    • XDG_CONFIG_HOME has exactly one consumer — worktree/config.py:87-89 get_config_path() → $XDG_CONFIG_HOME/devlaunch/config.toml, and its only caller is load_config() at :95-99, which reads. grep -rn get_config_path finds no writer anywhere.
    • The one thing devlaunch does write under a config dir — completion.py:29-33 _completion_file_path() → ~/.config/devlaunch/completions.sh — hardcodes Path.home() and ignores XDG_CONFIG_HOME entirely (it has its own override, DEVLAUNCH_COMPLETION_FILE).

    So the scratch XDG_CONFIG_HOME protects zero writes, and its only two effects are (a) killing gh, and (b) hiding the user's real config.toml from the run, which makes the scratch test less faithful, not safer. XDG_CACHE_HOME is the one that earns its place: dl.py:79-86, worktree/storage.py:29-30, worktree/config.py:11-16 all resolve real written state through it.

  3. blooop commented on Aug 8, 2026

    @blooop
    OwnerAuthor

    breadcrumb: The "logs at debug" half of the defect is worse than #96 stated: those log lines are unreachable, not merely quiet.

    dl.py:75 is logging.basicConfig(level=logging.INFO, format="%(message)s") — hardcoded, with no env override (grep -rn 'LOG_LEVEL\|setLevel' devlaunch/ → nothing) and no CLI flag (grep add_argument devlaunch/dl.py | grep -i 'verbose\|debug\|quiet' → rc=1). So there is no supported way for a user to see any of the five logging.debug lines in gh_auth.py (:77, :80, :84, :115, :121). The module's entire failure vocabulary is dead code from the operator's point of view.

    What a user can actually observe today, and only by absence:

    • workspace_ssh logs SSH command: devpod ssh <ws> … at INFO (dl.py:1010), so a successful forward shows up as a trailing --send-env GH_TOKEN. A failed one shows up as… that flag not being there. You have to know the flag exists to miss it.
    • The devpod up path logs nothing at all — run_devpod's Running: %s is itself logging.debug (dl.py:782) — so a token lost before up is invisible with no tell whatsoever.

    That is the illegal state (principle 3), and note where it lives: not in the type. resolve_token() -> Optional[str] is an honest absence, not a sentinel — _is_token() (:56-57) parses at the boundary so an empty or garbage string can never escape as a token. Both callers do the identical if not token: <forward nothing> (:142-146, :168-170); no caller branches on the reason, so a sum type over the five ways to get None would add variants nobody matches on. The unrepresentable-state to fix is behavioural: a credential was available on this machine, was dropped, and nobody was told. The reason is already known at each logging.debug site. Fixing it needs a level change, not a type.

  4. blooop commented on Aug 8, 2026

    @blooop
    OwnerAuthor

    agent-decided (simplicity):

    Credentials reach two levels down, unaided, and nothing about nesting is broken. #96 demonstrated the chain end to end; this ticket checked the mechanism in source and found it deliberate rather than lucky, so no second credential path is needed and none is proposed. What is broken is one line of guidance and one logging level — and the guidance is fixed by deleting half of it, not by qualifying it.

    Three answers, in the order asked.


    1. CLAUDE.md's scratch-XDG instruction: delete the XDG_CONFIG_HOME half everywhere

    The instruction (AGENTS.md:44-52; CLAUDE.md is a symlink to it, and dev.sh:11-16 + dev.sh:128-130 repeat it) rests on a claim that is false:

    Everything it stores resolves through XDG_CACHE_HOME and XDG_CONFIG_HOME

    devlaunch stores nothing under XDG_CONFIG_HOME.

    • XDG_CONFIG_HOME has exactly one consumer: worktree/config.py:87-89 get_config_path() → $XDG_CONFIG_HOME/devlaunch/config.toml. Its only caller is load_config() (:95-99), which reads. grep -rn get_config_path finds no writer in the tree.
    • The one thing devlaunch does write under a config dir — completion.py:29-33 → ~/.config/devlaunch/completions.sh — hardcodes Path.home() and ignores XDG_CONFIG_HOME entirely (it has its own override, DEVLAUNCH_COMPLETION_FILE).

    So the scratch XDG_CONFIG_HOME guards zero writes. Its two actual effects are both bad: it hides the user's real config.toml from the run (making the scratch test less faithful than the real one), and it destroys gh.

    And it destroys gh on the host, not just in the container — which corrects #96's framing. Verified here, read-only:

    $ XDG_CONFIG_HOME=/tmp/wf100-scratch-cfg gh auth token
    no oauth token found for github.com          rc=1
    $ gh auth token                              rc=0
    

    GH_TOKEN/GITHUB_TOKEN are unset in a normal shell on this host, so resolve_token() falls through HOST_TOKEN_VARS (gh_auth.py:99-103) straight to gh auth token — the thing the scratch dir just broke. Every XDG_CONFIG_HOME=/tmp/dl-scratch/config dl-next … run this repo has ever taught has been launching unauthenticated workspaces on the host too. The container did not create the bug; it made it visible, because two levels down there is a nested GH_TOKEN=[unset] to trip over.

    This extends #95 rather than contradicting it. #95 demoted scratch XDG from isolation mechanism to blast-radius guard. The guard is real — but it is XDG_CACHE_HOME doing all of the guarding (dl.py:79-86 CACHE_DIR, worktree/storage.py:29-30 metadata.json, worktree/config.py:11-16 the bare-clone cache). XDG_CONFIG_HOME was never guarding anything; it was along for the ride. Keep the guard, drop the passenger. Deleting beats adding, and deleting beats caveating: a rule that says "except inside the container" is a rule people get wrong, whereas a recipe that never mentions XDG_CONFIG_HOME cannot be got wrong anywhere.

    2. The silent loss is the real defect — and it is a level change, not a type change

    Worse than #96 said: those log lines are unreachable, not merely quiet. dl.py:75 is logging.basicConfig(level=logging.INFO) with no env override (grep -rn 'LOG_LEVEL\|setLevel' devlaunch/ → nothing) and no CLI flag (add_argument grep for verbose|debug|quiet → rc=1). There is no supported way for any user to see the five logging.debug calls in gh_auth.py (:77 :80 :84 :115 :121). The only tell that exists today is an absence: workspace_ssh logs SSH command: devpod ssh … at INFO (dl.py:1010), so a working forward shows a trailing --send-env GH_TOKEN and a broken one shows nothing where that flag would be. The devpod up path has no tell at all — run_devpod's Running: %s is itself debug (dl.py:782).

    Should the absence become a type? No. resolve_token() -> Optional[str] is not a sentinel — _is_token() (:56-57) parses at the boundary, so an empty or malformed string can never escape as a token; None is an honest absence. And no caller branches on the reason: up_args (:142-146) and ssh_args_and_env (:168-170) both do the identical if not token: forward nothing. A sum type over the five ways to get None would add variants nobody matches on — structure with no invariant behind it. Principle 3 correctly identifies the illegal state, but it is behavioural, not structural: a credential was available on this machine, was dropped, and nobody was told. Each site already knows its own reason. Principle 2 therefore picks the fix: promote the level, keep the type.

    Which absences speak, and which stay quiet, is the whole design:

    path today decision
    forwarding_disabled() :97 silent stay silent — the user asked for this
    gh not on PATH :62 silent stay silent — nothing was promised; no login to lose
    gh auth token rc≠0 :79 debug warn — gh is here and gave nothing. This is the scratch-XDG case.
    gh raised / timed out :76 debug warn
    gh printed a non-token :83 debug warn
    token staging failed :115 :121 debug warn — a token definitely existed and was dropped; the most severe of the five

    Two details that make the warning cheap rather than noisy:

    • resolve_token is already @functools.lru_cache(maxsize=1) (:89), so a dl run that consults it twice (once for up, once for ssh) warns once per process. Free, and worth a test that pins it.
    • The rc≠0 message must carry the effective gh config dir (os.environ.get("XDG_CONFIG_HOME", …)), not just "run gh auth login". Without it the message actively misleads the exact user this ticket is about — someone who is logged in and whose config was hidden by a scratch dir. With it, the scratch path appears in the message and diagnoses itself. One extra format argument, no branching. The remedies named are gh auth login / export GH_TOKEN / DEVLAUNCH_NO_GH_TOKEN=1 to silence — the opt-out already exists and is already documented (README:203), so nothing new has to be invented to shut the warning up.

    The messages must never contain the token or the rejected stdout. They do not today; a test should keep it that way.

    3. Broken vs merely documented

    Nothing is broken two levels down. Every mechanism chains, and the GH_TOKEN one chains by design, not by accident: HOST_TOKEN_VARS = ("GH_TOKEN", "GITHUB_TOKEN") is consulted before paying for a gh subprocess (gh_auth.py:36, 99-103), and the comment at :33-35 says why in as many words — "a devlaunch running inside a devlaunch workspace passes its own forwarded token further down." That is level 3 working, and it is covered by test_host_env_wins_over_the_gh_cli (test/unit/test_gh_auth.py:33-42). The devcontainer, which is not a dl workspace and so has no inherited GH_TOKEN, is fed instead by the ${localEnv:HOME}/.config/gh bind mount — and note that XDG_CONFIG_HOME=/home/vscode/.config in containerEnv is simply the default $HOME/.config for user vscode, so it is belt-and-braces documentation of the default rather than load-bearing configuration. Leave it.

    So the split is clean:

    • Documented, not built: the scratch-XDG recipe. No mechanism, no code, a deletion.
    • Built, not documented: the silent loss. Principle 2 prefers the documented number over a new mechanism — but this is not a new mechanism. It is debug → warning on four existing call sites, and a silent credential loss is not a documentation problem. Note the two halves interlock: the doc deletion stops the current instance, and the warning is what stops the next one, whatever its cause.

    Build slices

    A — stop teaching a scratch XDG_CONFIG_HOME. AGENTS.md:44-52 (prose + the example line), dev.sh:11-16 (header comment) and dev.sh:128-130 (the echoed hint). Keep XDG_CACHE_HOME; remove XDG_CONFIG_HOME from both the prose and both command lines, and say in one clause why it must not be overridden (gh auth token reads it, so overriding it silently unauthenticates the workspace; devlaunch writes nothing there). Prose has no test — the enforceable residue is that grep -rn 'XDG_CONFIG_HOME=' over the repo finds no recommendation, and slice B is what catches the regression in practice.

    B — make the loss audible. devlaunch/gh_auth.py, four logging.debug → logging.warning (:76-78, :79-81, :83-85, and both staging sites :115/:121), leaving :62 and :97 silent; rc≠0 message gains the effective config dir and the three remedies. Tests in test/unit/test_gh_auth.py with caplog (precedent: test/test_dl.py); its autouse fixture at :15-23 already undoes the suite-wide DEVLAUNCH_NO_GH_TOKEN=1 from test/conftest.py:41-46, so the setup is free:

    1. gh exits non-zero → exactly one WARNING, whose text contains the XDG_CONFIG_HOME value in force.
    2. gh not on PATH → no WARNING (a machine without gh is not a misconfigured one).
    3. DEVLAUNCH_NO_GH_TOKEN=1 → no WARNING and no gh spawn.
    4. gh prints junk → WARNING, and the junk string appears in no log record. Load-bearing: this is the credential-safety regression test.
    5. two resolve_token() calls (what one dl run makes) → still exactly one WARNING, pinning the lru_cache.
    6. extend the existing mkstemp OSError test (:141-146) to assert the WARNING.

    C — README, folded into B's PR. One sentence in the gh section (~README.md:180): dl says so when it has a gh but no token to forward, and DEVLAUNCH_NO_GH_TOKEN=1 silences it. Keeps the README-in-sync rule.

    New questions worth their own tickets

    1. devlaunch has two different "config dirs". worktree/config.py:89 honours XDG_CONFIG_HOME to read config.toml; completion.py:33 hardcodes ~/.config/devlaunch/ to write completions.sh. One tool, two answers. Small, real, nothing to do with nesting.
    2. Five debug log sites that nothing can turn on. dl.py:75 pins INFO with no flag and no env override, so gh_auth's diagnostics and run_devpod's Running: %s are unreachable by any supported means. Slice B deliberately does not depend on a --verbose flag existing — but the gap is real and outlives this ticket.

    What was not re-verified

    No container was built for this ticket, deliberately: #96 demonstrated the chain on metal at all three levels and several agents are building concurrently on this host. What was checked instead is the mechanism in source (HOST_TOKEN_VARS ahead of the gh subprocess) plus the two host-side facts #96 did not state — that the footgun fires on the host, and that XDG_CONFIG_HOME guards no writes at all. Both are read-only checks, reproduced above. Nothing was created and nothing needs cleaning up.

  5. blooop commented on Aug 8, 2026

    @blooop
    OwnerAuthor

    Correction to this ticket's resolution, established on metal by PR #119's reviewer.

    This ticket concluded the scratch-XDG_CONFIG_HOME half "guards zero writes". The first clause of that reasoning is true — get_config_path() has no writer — but the inference is false. config.toml can pin repos_dir to an absolute path that overrides XDG_CACHE_HOME entirely (worktree/config.py:78). Demonstrated: with only XDG_CACHE_HOME scoped, repos_dir resolved to the real ~/.cache/devlaunch/repos; with both scoped, it stayed in the scratch dir. That is exactly the blast radius the recipe exists to contain.

    The repo had already recorded this, and this ticket missed it — test/conftest.py:47-49: "XDG_CONFIG_HOME goes with it: config.toml can point repos_dir back at the real cache."

    The conclusion survives; the reasoning does not. Deleting the XDG_CONFIG_HOME half is still right, because the gh breakage is unconditional while the repos_dir hazard requires a config.toml that does not exist on this host. But it is a trade-off between two blast radii, not a free removal of a passenger, and the docs must say the true thing. The map's Decisions entry has been corrected.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions