Skip to content

Stop silently launching unauthenticated workspaces - #119

Merged
blooop merged 6 commits into
mainfrom
wayfinder/devlaunch-115
Aug 8, 2026
Merged

blooop merged 6 commits into
mainfrom
wayfinder/devlaunch-115

Conversation

@blooop

@blooop blooop commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Closes #115.

Two halves of one defect: the repo taught an instruction that breaks gh auth, and the breakage was invisible.

This has been mis-instructing users on the host, not only in the container

The scratch-run recipe this repo teaches — in the agent instructions and in dev.sh, twice — scoped XDG_CONFIG_HOME alongside XDG_CACHE_HOME. Doing so hides the host's gh login from gh auth token, so the token forwarded into the workspace is no token at all.

That is not a container-only effect. Verified read-only on this host, before and after the change:

$ XDG_CONFIG_HOME=/tmp/dl-scratch/config … resolve_token()
WARNING:root:gh auth token exited 1, so this workspace opens without a GitHub login.
gh read its config from /tmp/dl-scratch/config -- if you are logged in on this host,
that directory is the thing to check before `gh auth login`.
token forwarded: False

$ …resolve_token()          # normal shell, nothing scoped
token forwarded: True

GH_TOKEN/GITHUB_TOKEN are unset in an ordinary host shell, so the resolver falls straight through to the gh the recipe just broke. Every run made the documented way has been opening a workspace with no GitHub credentials, on the host as much as inside a container — the container only made it noticeable.

Dropping the variable is a trade, not a free removal. It costs a real guard: a personal config.toml under XDG_CONFIG_HOME can pin repos_dir at an absolute path that beats XDG_CACHE_HOME outright (worktree/config.py), which is exactly the blast radius the recipe exists to contain, and test/conftest.py scopes both for that reason. Demonstrated on metal in scratch directories, with a stand-in HOME so the real ~/.cache/devlaunch was never read or written:

only XDG_CACHE_HOME scoped, config.toml present under $HOME/.config:
  repos_dir -> …/repodemo/pretend-real-cache/devlaunch/repos   (outside the scratch cache)
both scoped, that config.toml not on the search path at all:
  repos_dir -> …/repodemo/cache/devlaunch/repos                (contained)

The deletion still stands, because the two harms are not the same size: the gh breakage happens on every run made the documented way, while the repos_dir hazard needs a config.toml most hosts (including this one) do not have. So AGENTS.md and dev.sh name it as that trade rather than claiming the variable guards nothing, and test/conftest.py now says why the suite makes the opposite call — it has already given up gh forwarding, so it has nothing to lose by scoping both.

The failure was unreachable, not merely quiet

Logging is pinned at INFO with no environment override and no CLI flag, so the five debug lines in the auth module could never be seen by any user. The only tell was an absence — a missing flag in one INFO line — and the devpod up path had no tell at all.

Now warned on: a non-zero gh exit, gh raising or timing out, stdout that is not a token, and either failure to stage the token file. Deliberately still silent: forwarding opted out via the escape hatch, and gh not installed — those are choices, not failures.

The non-zero-exit message names the effective XDG_CONFIG_HOME. Without it, the message tells a logged-in user to run gh auth login, which in exactly this case fixes nothing. The resolver's cache caps this at one warning per process.

Credential handling

No token value reaches a log record on any path — and every site with a token or a maybe-credential in scope now asserts that the same way, through one helper checking four layers: the rendered message, the format string, the record arguments, and the record's own attributes. Four, because no single layer holds: a lazily interpolated %s argument renders nothing into record.msg, and anything smuggled through extra= is invisible to both getMessage() and caplog.text. The two staging tests also capture at DEBUG now, so a leak logged below the warning is caught while the level assertion still pins exactly one WARNING. Verified by mutation — a token interpolated into the mkstemp warning, a token smuggled via extra= at the fdopen site, and a debug-level leak beside the warning each fail the suite now and passed it before. Every literal in the tests is an obviously-fake placeholder.

One spelling for the config home

XDG_CONFIG_HOME was read in two places that disagreed: the new warning treated an empty value as unset (correct per the XDG basedir spec), while worktree/config.py did not, and so resolved config.toml relative to the working directory. Both now go through devlaunch/xdg.py, so the directory the warning names is the one the loader actually reads. The three copies of the XDG_CACHE_HOME lookup (dl.py, worktree/storage.py, worktree/config.py) are the obvious next tenant of that module, but moving cache paths around is not this change's business.

Not here

No --verbose flag. That gap is recorded separately and this change does not depend on it. No type change either: the resolving investigation established that the optional return is an honest absence rather than a sentinel, and neither caller branches on the reason.

Tests

Six behaviours pinned at the existing seam (the token resolver and the devpod up flag context manager, with the subprocess and the PATH lookup mocked at the process boundary): warns and names the config dir on a non-zero exit; names the home-relative default when the variable is unset; warns when gh hangs; warns on junk stdout without repeating it; stays silent when gh is absent; stays silent and spawns nothing when opted out; warns once however often a run asks. Both staging sites are covered — the existing temp-dir test extended, and a new one for the write failure that the ticket's list would otherwise have left unexercised. Plus two on where config.toml is looked for, one of which was red before devlaunch/xdg.py existed.

Full non-e2e suite: 956 passed. Format, ruff, ty, pylint and prek clean.

🤖 Generated with Claude Code

Summary by Sourcery

Ensure GitHub CLI credential forwarding failures are surfaced and avoid misconfigurations that silently open unauthenticated workspaces.

Bug Fixes:

  • Prevent scratch runs from hiding the host GitHub CLI login by no longer scoping XDG_CONFIG_HOME in dev instructions and scripts.
  • Stop workspaces from launching without GitHub credentials without any user-visible indication by elevating auth failures from silent debug logs to warnings.

Enhancements:

  • Add warning messages for GitHub token resolution failures, including non-zero gh exits, timeouts, invalid stdout, and token staging errors, and name the gh config directory consulted.
  • Tighten credential handling to avoid logging any potential token values while still reporting malformed or missing credentials.

Documentation:

  • Update agent and development script documentation to only scope XDG_CACHE_HOME, explain the impact of scoping XDG_CONFIG_HOME on GitHub login, and describe the new auth warning behavior.

Tests:

  • Add unit tests covering visible warnings for gh failures, default config directory naming, junk stdout handling without leaking secrets, silent behavior when gh is absent or auth forwarding is disabled, single-warning caching, and token staging write failures.

Two halves of one defect: the repo taught an instruction that breaks gh
auth, and the breakage was invisible.

The instruction. The scratch-run recipe in the agent instructions and in
dev.sh scoped XDG_CONFIG_HOME as well as XDG_CACHE_HOME. dl writes
nothing under XDG_CONFIG_HOME -- its only consumer reads, and the one
thing dl does write under a config dir hardcodes the home directory --
so that half guarded nothing while hiding the host's gh login from
`gh auth token`. Deleted, with one clause on why; XDG_CACHE_HOME does
all the real guarding and stays.

The invisibility. logging is pinned at INFO with no override, so the
five debug lines in gh_auth could never be seen by any user: a workspace
opened logged out and said nothing. Those failure paths now warn -- a
non-zero gh exit, gh raising or timing out, non-token stdout, and either
staging failure. The non-zero-exit message names the effective
XDG_CONFIG_HOME, without which it tells a logged-in user to run
`gh auth login`, which in exactly this case fixes nothing.

Not warned on: forwarding opted out, and gh not installed. Those are
choices, not failures.

No value is ever logged, including the malformed stdout case, and the
test for it asserts absence across the rendered message, the format
string, the record arguments and the record's attributes.

Closes #115

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @blooop, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@sourcery-ai

sourcery-ai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Reviewer's Guide

Makes GitHub auth failures visible and fixes workspace launch instructions so they no longer hide the host’s gh login, while ensuring token values are never logged and failures don’t block workspace launches.

Sequence diagram for GitHub token resolution and warning behavior

sequenceDiagram
    participant dl
    participant gh_auth
    participant gh_cli as gh
    participant logging

    dl->>gh_auth: _token_from_gh_cli()
    alt gh not in PATH (shutil.which)
        gh_auth-->>dl: None
    else gh in PATH
        gh_auth->>gh_cli: subprocess.run(['gh','auth','token'])
        alt OSError or SubprocessError
            gh_auth->>logging: warning("Could not read a GitHub token from gh (%s)...")
            gh_auth-->>dl: None
        else non_zero_exit
            gh_auth->>logging: warning("gh auth token exited %s...%s", returncode, _gh_config_home())
            gh_auth-->>dl: None
        else stdout not _is_token
            gh_auth->>logging: warning("gh auth token printed something that is not a token...")
            gh_auth-->>dl: None
        else valid _is_token(stdout)
            gh_auth-->>dl: token
        end
    end
Loading

File-Level Changes

Change Details Files
Surface GitHub CLI credential resolution failures as warnings while keeping token values out of logs.
  • Introduce helper to determine effective gh config directory based on XDG_CONFIG_HOME or home .config.
  • Change gh auth subprocess error, timeout, non-zero exit, and malformed stdout handling from debug logs to structured warnings that explain the workspace opens without a GitHub login.
  • Ensure warnings never include the actual token or potentially secret stdout content from gh.
devlaunch/gh_auth.py
Warn when staging the token file fails but still allow workspace startup without credentials.
  • Upgrade failure to create the token temp file from debug log to warning that GitHub login cannot be forwarded.
  • Upgrade failure to write the token file from debug log to warning and clean up partial file, without logging the token value.
devlaunch/gh_auth.py
Extend tests to cover new warning behaviors and credential-handling edge cases.
  • Add unit tests that assert warnings on gh non-zero exit, timeout, malformed stdout, and staging failures, including naming the effective config dir.
  • Add tests that ensure no warnings are emitted when gh is absent or forwarding is explicitly opted out, and that gh is only invoked once per process.
  • Add assertions that secret-like junk output from gh never appears in any log message, args, or record attributes.
test/unit/test_gh_auth.py
Fix developer instructions so scratch runs only scope XDG_CACHE_HOME and no longer hide host gh login.
  • Update AGENTS.md to remove XDG_CONFIG_HOME from scratch run recipe and explain that scoping it hides the host’s gh login.
  • Change dev.sh comments and echoed helper text to only scope XDG_CACHE_HOME and add a note that scoping XDG_CONFIG_HOME hides gh login.
AGENTS.md
dev.sh
Document the new behavior of warning (not failing) when no GitHub token can be found, including naming the gh config directory consulted.
  • Extend README to describe stderr warnings when no token is found and that the warning names the config directory gh consulted, highlighting XDG_CONFIG_HOME scoping as the usual cause.
README.md

Assessment against linked issues

Issue Objective Addressed Explanation
#115 Remove use of XDG_CONFIG_HOME from the taught scratch-run instructions (docs and scripts), keeping only XDG_CACHE_HOME, and explain why.
#115 Make GitHub token resolution failures visible via warnings at INFO-level logging: warn on gh subprocess errors/timeout, non-zero exit, non-token stdout, and staging failures; include the effective XDG_CONFIG_HOME (or default config dir) in the non-zero-exit warning; stay silent when forwarding is disabled or gh is not on PATH; do not change types or add a --verbose flag; avoid logging any token value.
#115 Add and extend tests to cover the new gh_auth warning behavior, including: warning with config dir in message on non-zero exit; default config dir named when XDG_CONFIG_HOME unset; no warning when gh absent; no warning and no subprocess spawn when forwarding is opted out; warning on junk stdout without echoing the junk; single warning for multiple resolve_token calls (lru_cache); and extended mkstemp/write-failure staging tests.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@codecov

codecov Bot commented Aug 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.68%. Comparing base (75ee8ec) to head (84389c1).
⚠️ Report is 7 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #119      +/-   ##
==========================================
+ Coverage   90.41%   90.68%   +0.27%     
==========================================
  Files          17       18       +1     
  Lines        1982     1987       +5     
==========================================
+ Hits         1792     1802      +10     
+ Misses        190      185       -5     
Files with missing lines Coverage Δ
devlaunch/gh_auth.py 100.00% <100.00%> (+6.41%) ⬆️
devlaunch/worktree/config.py 93.75% <100.00%> (ø)
devlaunch/xdg.py 100.00% <100.00%> (ø)

Impacted file tree graph

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@blooop blooop left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was generated by AI during review.

Reviewed in fresh context against two independent axes, at the merge-base fixed point
efcaf2d...6b270f4. Preflight green: all CI (py310–py313), prek, GitGuardian and both
codecov checks pass. Claims were verified by reproduction and mutation, not read.

Standards

1. AGENTS.md and dev.sh now state a falsehood, in the file that teaches agents how not to destroy state — blocking (one-sentence reword).

AGENTS.md:55-57 and dev.sh:16-18 assert that scoping XDG_CONFIG_HOME "guards nothing —
dl writes nothing under it". The first clause is true; the inference is not. config.toml
can pin repos_dir to an absolute path, and it wins outright over XDG_CACHE_HOME:

# devlaunch/worktree/config.py:78
repos_dir=Path(worktree_data.get("repos_dir", _get_cache_base() / "repos")),

Demonstrated on metal, scratch dirs only, with a config.toml containing
[worktree] repos_dir = "…/.cache/devlaunch/repos":

scratch XDG_CACHE_HOME only (what this PR now teaches):
  repos_dir resolves to: /home/…/.cache/devlaunch/repos      <-- the REAL one
both scoped (what this PR deleted):
  repos_dir resolves to: …/scratchpad/demo/cache/devlaunch/repos

That is precisely the "costs someone their workspace list" hazard the same bullet warns
about two sentences earlier. The repo has this written down already, and it says the
opposite of the new prose — test/conftest.py:47-49:

"XDG_CONFIG_HOME goes with it: config.toml can point repos_dir back at the real cache,
which would defeat the isolation from the other direction."

The command lines are right to drop the variable — the gh breakage is real and worse.
What needs fixing is the reason given. Reword to the actual trade-off ("scoping it hides
your gh login; leaving it unscoped lets a personal config.toml redirect repos_dir")
instead of the absolute "guards nothing". Latent for anyone with no config.toml, which
is why it is cheap to fix and easy to trust by mistake.

2. _gh_config_home() is a second, divergent spelling of an existing concept — non-blocking.

gh_auth.py:69 is os.environ.get("XDG_CONFIG_HOME") or str(Path.home()/".config");
worktree/config.py:89 is Path(os.environ.get("XDG_CONFIG_HOME", Path.home()/".config")).
They disagree on XDG_CONFIG_HOME="": the new or form treats empty as unset (correct per
XDG), the older form does not. The new one is the better spelling — the smell is that it is
the second copy. The new from pathlib import Path is justified; gh_auth.py had no prior
Path use.

3. Message overclaims what was consulted — non-blocking.
gh_auth.py:62 says "the config directory gh just consulted" and :98 prints "gh read its
config from %s", but gh reads $XDG_CONFIG_HOME/gh and honours GH_CONFIG_DIR ahead of
it. The named directory is still the actionable one; the phrasing is just stronger than the code.

Checked and clean. Root-logger logging.<level> is the house convention (43 call sites
vs 3 getLogger(__name__), all in worktree/) — the PR matches it. The five repeated
"…without a GitHub login." clauses are a deliberate and correct call: whole sentences at
their own sites, and log strings are a poor DRY target. No test asserts on message wording,
so rewording won't break them. No ordering dependence: the autouse fixture
(test/unit/test_gh_auth.py:15-23) cache_clear()s both sides of the yield; 32 passed
across repeat runs, and ::TestFailuresAreVisible passes alone. fail_but_do_not_leak_the_fd
correctly closes the fd before raising. dev.sh:12 is an under-filled reflow (58 chars vs
74–81 for neighbours) — cosmetic.

Spec

Every acceptance criterion in #115 is met. No blocking Spec findings.

Half A's premise independently confirmed, not taken on faith. The spec says
"grep -rn get_config_path finds no writer" — confirmed: one caller, load_config()
(worktree/config.py:93), which tomli.loads and returns; save_config survives only as a
CHANGELOG.md:222 reference to a removed function. The spec says "completion.py:29-33
hardcodes Path.home()" — confirmed at completion.py:28-33, with only a
DEVLAUNCH_COMPLETION_FILE override. The premise holds, so the deletion is right.

All three sites done (AGENTS.md:47-56, dev.sh:12-19, dev.sh:131-132). A whole-repo grep
for XDG_CONFIG_HOME leaves no site still teaching the bad recipe: the remaining hits are the
new don't-do-this prose, container paths pointing at the real /home/vscode/.config, test
scaffolding, and readers.

"The rc≠0 message must include the effective XDG_CONFIG_HOME" — reproduced on metal,
read-only, real gh, no gh state touched, boolean-only token check:

WARNING:root:gh auth token exited 1, so this workspace opens without a GitHub login.
gh read its config from …/demo/ghscratch -- if you are logged in on this host, that
directory is the thing to check before `gh auth login`.
token forwarded: False

(unscoped, ordinary host shell)   token forwarded: True

Emitted at the basicConfig(level=INFO) dl.py:75 actually sets, so it reaches a real user —
which the five logging.debug sites it replaces never could. That is the defect and the fix
in one pair of runs.

Five warn sites present (gh_auth.py:89,95,107,141,151): raise/timeout, rc≠0, non-token
stdout, and both staging sites. The two deliberate silences are asserted, not merely
absent
— proved by mutation in an isolated copy, not by inspection: adding a
logging.warning to the shutil.which branch fails test_no_gh_installed_says_nothing, and
adding one to the forwarding_disabled() branch fails
test_opting_out_says_nothing_and_asks_gh_nothing. Both go green again on restore (32/32).

All six enumerated tests exist and test what was asked. The seventh (fdopen write failure)
is in scope, not creep: the spec requires "staging failure (both sites)", and the
enumerated list would have left the second site shipping unexercised. Calling that out rather
than quietly padding the count is the right instinct — as is saying plainly that three tests
passed on arrival.

"Do not add a --verbose flag" — none added. "No type change" — Optional[str]
unchanged; no evidence found against that ruling, and nothing here warrants relitigating it.

Spec note, non-blocking. The spec's own "XDG_CACHE_HOME does all the real guarding" is
the same overstatement flagged under Standards, and the spec went further: it argued that
hiding config.toml makes a scratch run "less faithful". test/conftest.py:47-49 records
the opposite conclusion for the same mechanism. The PR implemented the spec faithfully; the
spec is what was incomplete.

Credential safety: clean, and stress-tested. No real credential value appears anywhere in
the diff, fixtures or emitted log lines — the only literals are gho_secret and a junk value
deliberately not token-shaped. I mutated the credential-safety test to confirm it earns its keep:

mutation at the junk-stdout site result
value passed as a lazy log arg — warning("…: %s", stdout) caught by record.getMessage()
value smuggled via extra={"gh_stdout": …} caught by repr(vars(record)) only — invisible to getMessage() and caplog.text

The four-layer assertion is not belt-and-braces padding; the second case defeats every
single-layer check. This test is the strongest thing in the PR.

But that rigor is not applied evenly — non-blocking, and the one thing I'd add. The two
staging sites have a real token in scope, and their assertions are weaker. Two more
mutations, both of which the full suite passes 32/32:

mutation result
interpolate token into the mkstemp failure warning not caught — that test asserts levelno only
smuggle token via extra={"tok": token} at the fdopen site not caught — that test asserts caplog.text only

The spec only demanded credential-safety on the junk test, so this is not a spec miss. But the
PR description claims "no token value is logged on any path", and today nothing holds that
line at the two sites where a token actually exists. Lifting the junk test's four-layer check
into a shared helper and using it at all three sites would make the claim true by test.

Verdict

Request changes — small and surgical. The engineering is genuinely good: the premise was
verified rather than asserted, the failure is reproduced and fixed in the same run, the
deliberate silences are pinned by tests that actually fail when broken, and the
credential-safety test survives a mutation that defeats the obvious version of itself.

One blocking item, fixable in one sentence:

  1. Standards #1 — reword the AGENTS.md:55-57 / dev.sh:16-18 justification. Keep the
    deletion; drop "guards nothing" for the real trade-off. As written it contradicts
    test/conftest.py:47-49 and understates a workspace-list hazard in the file agents read
    to avoid exactly that.

Recommended, not blocking: the shared credential-absence assertion at the two staging sites
(Spec, final section), and the _gh_config_home() duplication (Standards #2).

No finding contradicts a decision recorded on the map. The type-change ruling stands.

The scratch-run prose justified the deletion with "guards nothing -- dl
writes nothing under it". The first clause is true and the inference is
not: a config.toml under XDG_CONFIG_HOME can pin repos_dir at an absolute
path that beats XDG_CACHE_HOME outright, which is the blast radius the
recipe exists to contain, and test/conftest.py already said so.

The deletion still stands -- the gh breakage is unconditional while the
repos_dir hazard needs a config.toml most hosts do not have -- so AGENTS.md
and dev.sh now name it as that trade instead, and conftest.py says why the
suite makes the opposite call (it has already opted out of gh forwarding).

Also: extend the four-layer "no credential reached a log record" assertion
to the two staging sites, where a real token is in scope, via one shared
helper; both now capture at DEBUG so a leak below WARNING is caught too.
And give XDG_CONFIG_HOME one spelling in devlaunch/xdg.py, which fixes the
older reader resolving config.toml relative to the cwd when the variable is
exported empty.
@blooop

blooop commented Aug 8, 2026

Copy link
Copy Markdown
Owner Author

This was generated by AI.

All three findings addressed in 63c6ac5. CI green on that head (py310–py313, prek, GitGuardian).

1 (blocking) — the prose now states the trade, not the falsehood

You were right, and the demonstration reproduces. Re-run here in scratch directories with a stand-in HOME, so the real ~/.cache/devlaunch was neither read nor written:

only XDG_CACHE_HOME scoped, config.toml present under $HOME/.config:
  config file: …/repodemo/fakehome/.config/devlaunch/config.toml
  repos_dir  : …/repodemo/pretend-real-cache/devlaunch/repos     <-- outside the scratch cache
both scoped, that config.toml not on the search path at all:
  repos_dir  : …/repodemo/cache/devlaunch/repos                  <-- contained

AGENTS.md now reads:

Only that one variable, and it is a trade rather than a free simplification.
Scoping XDG_CONFIG_HOME too would guard one more thing — a personal
config.toml under it can pin repos_dir back at the real cache and beat
XDG_CACHE_HOME outright, which is why test/conftest.py scopes both — but it
also hides the host's gh login from gh auth token, so every workspace opens
with no GitHub credentials. The credential loss happens on every run; the
repos_dir hazard needs a config.toml most hosts do not have.

dev.sh's header comment says the same thing in the same shape. The echoed hint at the
bottom of the script ((that variable only -- a scratch XDG_CONFIG_HOME hides your gh login)) was already true and is unchanged — it never claimed the variable guarded nothing.

One addition beyond the reword: test/conftest.py:47-51 now says why the suite makes the
opposite call, so the two sites read as one decision rather than a contradiction a future
reader has to adjudicate — the suite has already opted out of gh forwarding two fixtures
down, so scoping both costs it nothing.

The PR description carried the same overstatement in its own words; it has been rewritten
with the trade and the demonstration above.

2 (should-fix) — one helper, three sites, verified by mutation

Both weak assertions were exactly where you said. The four-layer check is now
assert_reached_no_log_record(secret, caplog) at the top of test/unit/test_gh_auth.py,
used by the junk-stdout test and by both staging tests. Both staging tests also moved from
caplog.at_level(WARNING) to DEBUG, so a leak logged below the warning is caught too,
while assert [r.levelno …] == [WARNING] still pins that exactly one warning is emitted.

Your two mutations, plus a third for the level change — each run against the full file, each
reverted afterwards (fake token gho_secret throughout, no real credential anywhere):

mutation before now
token interpolated into the mkstemp warning 32/32 passed fails test_an_unusable_temp_dir_costs_the_login_not_the_launch on record.getMessage()
extra={"tok": token} at the fdopen site 32/32 passed fails test_a_token_that_cannot_be_written_is_reported_not_swallowed on repr(vars(record))
logging.debug("staging failed for %s", token) beside the mkstemp warning would have passed (records below WARNING were not captured) fails on both the level list and the absence check

The description's "no token value is logged on any path" is now backed rather than asserted.

3 (non-blocking) — unified, in the direction you called correct

Both readers now go through a new devlaunch/xdg.py:

def config_home() -> Path:
    return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")

gh_auth._gh_config_home() is gone (its explanation survives as a comment at the warning
site, which is the only place it was load-bearing) and worktree/config.py:89 calls
config_home(). That fixes the divergence in the direction you identified: a test written
red first shows the old spelling resolved config.toml to PosixPath('devlaunch/config.toml')
— relative to the working directory — when XDG_CONFIG_HOME is exported empty.
TestConfigPath in test/test_worktree_config.py pins both cases; get_config_path() had
no tests at all before.

Deliberately not moved: the three copies of the XDG_CACHE_HOME lookup (dl.py:142,
worktree/storage.py:43, worktree/config.py:13). They are the obvious next tenant of that
module, but they resolve real cache paths on a host holding live workspaces, and shuffling
those is not something a credential-visibility PR should smuggle in.

Not redone

Everything you confirmed as met — spec coverage, both asserted silences, the README
placement, the seventh test — is untouched.

Suite: 956 passed (954 + the two new config-path tests), 21 e2e deselected. The e2e suite was
not run: this host's Docker daemon is shared with other agents.

@blooop

blooop commented Aug 8, 2026

Copy link
Copy Markdown
Owner Author

This was generated by AI during review.

Verification pass on 63c6ac5 — focused re-check of the three findings, not a fresh
review. Every claim below was re-derived here rather than read off the reply's tables:
mutations run in this worktree, the repos_dir and gh demonstrations reproduced on metal
in scratch directories with a stand-in HOME. Full non-e2e suite re-run: 956 passed, 21
deselected
, matching the reply. Tree restored clean at 63c6ac5 afterwards.

1 (was blocking) — verified

The prose is now true clause by clause, and I checked the load-bearing one myself rather than
taking the citation. worktree/config.py:80 is

repos_dir=Path(worktree_data.get("repos_dir", _get_cache_base() / "repos")),

_get_cache_base() — the only thing that reads XDG_CACHE_HOME — is the default
argument
, so a config.toml value beats it outright rather than combining with it. That is
the precedence claim, and it is structural, not incidental. Reproduced end to end in scratch
dirs (real ~/.cache/devlaunch and ~/.config/devlaunch neither read nor written):

only XDG_CACHE_HOME scoped, config.toml present under the stand-in $HOME/.config:
  config file: …/repodemo/fakehome/.config/devlaunch/config.toml   exists: True
  repos_dir  : …/repodemo/pretend-real-cache/devlaunch/repos       <-- outside the scratch cache
both scoped:
  config file: …/repodemo/config/devlaunch/config.toml             exists: False
  repos_dir  : …/repodemo/cache/devlaunch/repos                    <-- contained

The other half of the trade too, read-only on this host, exit codes only, no gh state
touched:

XDG_CONFIG_HOME=<empty scratch dir>  gh auth token -> rc 1
unscoped                             gh auth token -> rc 0

So both arms the new wording weighs against each other are real, and the asymmetry it asserts
holds here: this host has no ~/.config/devlaunch/config.toml (only completions.sh), which
is what makes the credential loss the larger harm. "guards nothing" is gone from AGENTS.md,
dev.sh and the whole tree; the one occurrence left in the PR description is a negation
("rather than claiming the variable guards nothing"), which is fine. test/conftest.py:47-51
now explains the opposite call and is accurate — no_gh_token_forwarding two fixtures down
does set DEVLAUNCH_NO_GH_TOKEN, so the suite really has nothing left to lose. A reader now
gets the right idea and the right action.

Two wording nits, neither false, neither blocking:

  • AGENTS.md:47 still opens "Everything it stores resolves through XDG_CACHE_HOME" — the
    next paragraph immediately qualifies it for config.toml, but completion.py:33 writes
    completions.sh under a hardcoded ~/.config/devlaunch/, outside both variables. Pre-existing
    and already on the map as untouched.
  • dev.sh:16-21 says scoping "would also hide a personal config.toml … but it hides the
    host's gh login too". The parallel "hide … hides … too" reads for a beat as though hiding
    the config.toml were also a cost. AGENTS.md's "would guard one more thing" is the
    clearer of the two sentences; worth copying if the file is touched again.

2 — verified by my own mutations, one each, run against the full file and reverted

mutation result at 63c6ac5
token concatenated into the mkstemp warning string fails test_an_unusable_temp_dir_costs_the_login_not_the_launch on record.getMessage()
extra={"tok": token} at the fdopen site fails test_a_token_that_cannot_be_written_is_reported_not_swallowed on repr(vars(record)) — invisible to getMessage() and caplog.text, as the helper's docstring says
logging.debug("staging failed for %s", token) beside the mkstemp warning fails on the level list ([30, 10] == [30])

Both of the first two, applied together against the pre-fix test file (6b270f4), pass
32/32 — so the "before" column is confirmed independently, not assumed.

One extra mutation the reply did not claim, because the third row above stops at the level
assertion and never reaches the absence check: I deleted the level assertion and re-ran the
debug-leak mutation. It still fails, at assert_reached_no_log_record line 41. So
at_level(DEBUG) is genuinely load-bearing on its own — a sub-warning leak is caught by the
secret check, not merely by the record count. That is the stronger property and it holds.

3 — verified; no live path moves for a normal user

xdg.config_home() against the old spelling, stand-in HOME, run from a directory that is
not the checkout:

XDG_CONFIG_HOME old Path(get(var, home/".config")) xdg.config_home() same?
unset <home>/.config <home>/.config yes
"" PosixPath('.')PosixPath('devlaunch/config.toml') <home>/.config no
set the value the value yes

The claimed cwd-relative PosixPath('devlaunch/config.toml') reproduces exactly. Since unset
and set are byte-identical, nothing moves for a normal userrepos_dir resolution is
untouched on any host that is not exporting the variable empty. The one case that does change
is the broken one, and it changes in the correct direction (per the basedir spec), which is
the fix. TestConfigPath pins both. XDG_CONFIG_HOME now has exactly one reader in
devlaunch/ — grep confirms. devlaunch/__init__.py is empty, so the new import introduces
no cycle.

On leaving the three XDG_CACHE_HOME copies: right call for this PR, but the stated
reason is a little stronger than the facts. dl.py:138-143, worktree/storage.py:41-47 and
worktree/config.py:13-18 are semantically identical — all three treat an empty value as
unset — so unifying them would move no path at all, unlike the XDG_CONFIG_HOME case where a
real (correct) behaviour change was involved. Worth recording so the follow-up ticket is
scoped as the pure de-dup it is. The nearer un-unified neighbour is actually
completion.py:33, which hardcodes ~/.config/devlaunch/ to write — the same concept
xdg.py now owns — and the map already carries that as an open inconsistency, so leaving it
contradicts nothing.

New surface and description

devlaunch/xdg.py is 21 lines, one function, a docstring that says why the two callers must
agree rather than what the code does. Right size, right home. Its title says "the XDG base
directories" (plural) while it answers one of them — which is exactly the invitation the reply
names, so no objection.

PR description: "guards nothing" replaced with the trade and the demonstration; "no token
value is logged on any path" is now followed by the mutation evidence rather than left as an
assertion. No real credential value anywhere — the only literals in the diff and the suite are
gho_secret, gho_cached, gho_fromcli, gho_fromenv, gho_hosttoken, gho_secretvalue,
ghp_fromenv and a junk string deliberately not token-shaped. GitGuardian passes.

Nothing new introduced. Everything the previous pass confirmed as met is untouched.

devpod list: unchanged by this pass — I created and deleted nothing, ran no dl, built no
container. (bencher-main-kivagede appeared during the pass from another agent on this host;
not mine and not touched.)

Verdict

Approve. All three findings genuinely addressed, and finding 2 came back stronger than
asked — the at_level(DEBUG) move makes the shared helper catch leaks the level assertion
alone would not. The two residual items are wording polish in AGENTS.md:47 / dev.sh:16-21
and a footnote on why the XDG_CACHE_HOME de-dup is cheaper than the reply implies; neither
should hold the merge.

@blooop blooop closed this Aug 8, 2026
@blooop blooop reopened this Aug 8, 2026
blooop added 2 commits August 8, 2026 12:13
v0.0.15 was cut from #121 while #106 and #118 were already merged into it, so
its section described only the aid change. Both are now recorded under 0.0.15,
where they shipped, and this PR's fix becomes 0.0.16.
@blooop
blooop merged commit 85e27b1 into main Aug 8, 2026
7 checks passed
@blooop
blooop deleted the wayfinder/devlaunch-115 branch August 8, 2026 11:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stop silently launching unauthenticated workspaces

1 participant