Skip to content

fix(github-pat): level every GITHUB_PAT line, not just the first (#2016) - #2025

Merged
vybe merged 3 commits into
devfrom
fix/2016-duplicate-pat-line
Aug 6, 2026
Merged

fix(github-pat): level every GITHUB_PAT line, not just the first (#2016)#2025
vybe merged 3 commits into
devfrom
fix/2016-duplicate-pat-line

Conversation

@dolho

@dolho dolho commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Summary

_patch_env_github_pat replaced the first matching line (count=1) while the agent's own .env reader is last-wins. On a file carrying a duplicate, the rotation wrote the new token to line 1, the revoked token survived below it, and the agent kept authenticating with the revoked one — while propagate_pat_to_all_agents reported that agent as updated.

in:  GITHUB_PAT="old-token"          out: GITHUB_PAT="ghp_NEW"
     FOO=1                                FOO=1
     GITHUB_PAT="old-token"               GITHUB_PAT="old-token"   <-- survives, and wins

agent reads GITHUB_PAT = 'old-token'

Same silent-success failure #1967 exists to close, reached from the other side: there the rotation never reached the agent; here it reaches the agent and the agent ignores the result.

Where the duplicate comes from

Not from this function — it appends only when the key is absent, and substitutes when present. It arrives from the other writers of that file: an agent editing its own .env (#1999), an operator appending over SSH or docker exec, or a restored/hand-merged file. Uncommon, which is why this is P3; silent and credential-shaped, which is why it's worth the seven-character fix.

Levelled, not de-duplicated

Both were on the table. After levelling, every copy carries the same value, so last-wins reads the right token whichever line it lands on — and the file keeps whatever structure the operator gave it. Removing lines would be a second behaviour change for no correctness gain, so I didn't.

Test plan

  • tests/unit/test_2016_duplicate_pat_line.py — 21 tests. Every assertion goes through what the agent reads, not what the file contains; that distinction is the bug, since the file did contain the new token, on a line nothing read
  • Covers: the reproduction; the revoked token surviving nowhere in the file (shells and greps read .env too); 2/3/5 copies; each of the three Wire the agent GitHub PAT for the gh CLI + REST API (not just git) #1574 mirrored keys independently, since each runs its own substitution and a cap left on one reopens the bug for that key alone; interleaved duplicates of different keys; the messy real-world duplicate shapes (indented, tabbed, unquoted, single-quoted, empty-value); a commented duplicate deliberately left alone; lookalike keys untouched; and the single-line path unchanged including idempotence
  • Mutation-verified against both spellings of the cap — count=1 and a positional third argument — 15 of 21 fail on each
  • 1101 adjacent tests green

One test-quality note worth recording: the structural guard reads the .sub() call via ast, not the source text. This function's own docstring explains the bug and therefore contains the string count=1, so a textual scan passes on the prose with the cap restored. My first draft did exactly that and failed here — the same trap the ledger records for #1871 and ent#314, and that ent#237's auth guard hit last week when ast.dump rendered a docstring.

Merge order

⚠️ Conflicts with #2024 (issue #2017) on one line — both change line_re.sub(...). The resolution is to keep both edits:

out = line_re.sub(lambda _match: new_line, out)     # #2017 callable + #2016 no cap

Whichever lands second takes that line. Kept as separate PRs because they are separate defects with separate tests; branched off dev rather than stacked so both get the full pytest/CodeQL matrix, which a feature-branch base would skip.

Observed once, not reproduced

During adjacent-suite runs I saw a single collection error in test_subscription_auto_switch_pingpong.py — a file this PR doesn't touch. It did not reproduce in five subsequent runs including three fixed seeds, and my new file passes when run directly alongside it. It looks like the pre-existing order-dependent sys.modules interference this suite already has, perturbed by adding a file. Flagging rather than omitting, since I can't prove it isn't mine.

Closes #2016

`_patch_env_github_pat` replaced the first matching line (`count=1`) while the
agent's own `.env` reader is **last-wins**. On a file carrying a duplicate the
rotation wrote the new token to line 1, the revoked token survived below it,
and the agent went on authenticating with the revoked one — while
`propagate_pat_to_all_agents` reported that agent as `updated`.

Same silent-success failure #1967 exists to close, reached from the other side:
there the rotation never reached the agent; here it reaches the agent and the
agent ignores the result.

The duplicate is not created here — this function appends only when the key is
absent. It arrives from the paths that can also write the file: an agent
editing its own `.env` (#1999), an operator appending over SSH or
`docker exec`, or a restored/hand-merged file.

Levelled, not de-duplicated. After this every copy carries the same value, so
last-wins reads the right token whichever line it lands on, and the file keeps
whatever structure the operator gave it. Removing lines would be a second
behaviour change for no correctness gain.

Every assertion goes through what the AGENT READS rather than what the file
contains — that distinction is the bug itself: the file did contain the new
token, on a line nothing read.

The structural guard reads the `.sub()` call via `ast` rather than the source
text, because this function's own docstring explains the bug and therefore
contains the string `count=1` — a textual scan passes on the prose with the cap
restored. My first draft did exactly that and failed here, which is the same
trap the ledger records for #1871 and ent#314, and that ent#237's auth guard hit
when `ast.dump` rendered a docstring.

21 tests; mutation-verified against both spellings of the cap (`count=1` and a
positional third argument) — 15 fail on each.

Closes #2016

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@obasilakis obasilakis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving. The fix is right, the reasoning about why it is a bug is right, and the test that pins it is the best-constructed guard in this batch — I tried to defeat it two different ways and could not.

The core insight is the part worth keeping: the file did contain the new token, on a line nothing read. Asserting through the agent's own last-wins parser instead of through the file contents is what makes these tests mean anything, and the module docstring says so explicitly.

Verified

Fix behaves as described — 21 tests pass on the branch.

The guard survives mutation, both spellings. I reverted the fix two ways on the merged tree and re-ran test_the_substitution_is_not_capped_at_one:

restore `count=1` keyword       -> FAIL (red)   correct
restore positional `sub(x, y, 1)` -> FAIL (red)   correct

The second one is the reason this guard is good. assert len(call.args) <= 2 catches the cap written positionally, which a keyword-only check would have missed — and that is exactly how a cap tends to come back during a refactor. The AST approach also sidesteps the trap your own docstring records:

this function's own docstring explains the bug and therefore contains the string count=1, so a textual scan passes on the prose with the cap restored. My first draft did exactly that and failed here.

Worth flagging that the sibling PR #2024 did not clear that trap — its test_the_replacement_is_a_callable_not_a_string substring-matches "lambda" against inspect.getsource(...), and its own added comment contains the word lambda, so it passes with the fix reverted and the bug live. I have asked for it to adopt the shape you used here. Same function, same batch, one got it and one didn't — which is itself the argument for the AST form being the house pattern.

Level rather than de-duplicate is the right call and the docstring justifies it properly: after the change every copy carries the same value, so last-wins reads correctly whichever line it lands on, and the operator's file structure is preserved. Removing lines would be a second behaviour change buying nothing. The tests cover the shapes that actually occur — differently-formatted duplicate, commented duplicate left alone, lookalike keys (MY_GITHUB_PAT, GITHUB_PATX) untouched, each of the three #1574 mirror keys levelled independently.

agent_reads is byte-faithful to docker/base-image/agent_server/routers/credentials.py:379-386 — checked line by line (strip, skip blank/#/no-=, partition("="), key.strip(), value.strip().strip('"').strip("'"), last write wins).

Composition with #2024 — they conflict, and neither side is correct alone

Both PRs rewrite the same three lines. The conflict is:

<<<<<<< HEAD
            out = line_re.sub(lambda _match: new_line, out, count=1)
=======
            out = line_re.sub(new_line, out)
>>>>>>> origin/fix/2016-duplicate-pat-line

Correct resolution is the composition of both:

out = line_re.sub(lambda _match: new_line, out)

Taking your side wholesale drops #2024's callable and reopens the backslash crash; taking theirs restores count=1 and reopens this bug. Your guard catches the second direction, which is the more dangerous one — so if you land first, CI will protect you. I confirmed the two fixes compose: with the line above, all of #2024's, #2025's and #2018's PAT tests behave as expected on the merged tree.

Whichever of you lands second: also delete the matching xfail(strict=True) from tests/unit/test_pat_propagation_properties.py (#2018) — for this PR that is test_a_duplicated_pat_line_still_rotates, which otherwise turns the suite red by passing.

Note, not blocking

tests/registry.json conflicts against dev. Re-serializing from parsed JSON rather than splicing the conflict markers avoids losing the separating comma. Noise, not a finding.

No credential values in the diff or the fixtures.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

⚠️ Nightly unit-suite check skipped — merge conflict against dev.

Resolve by running git merge dev locally and pushing the result. The next nightly run will re-test once the conflict is gone.

@vybe

vybe commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Rebased onto dev. Only tests/registry.json conflicted (this branch's entry against the ones that landed today); resolved from the merge stages rather than by splicing markers — 131 dev entries + this branch's one = 132, no duplicates, valid JSON. github_pat_propagation_service.py merged clean. 21/21 tests pass locally after the rebase.

The reasoning for levelling duplicates rather than de-duplicating them is the right call and worth keeping in the docstring: the agent's .env reader is last-wins, so making every copy carry the same value fixes the read whichever line wins, while deleting lines would be a second behaviour change with no correctness gain on a file an operator may have structured deliberately.

Merging on the existing approval.

@vybe
vybe enabled auto-merge (squash) August 6, 2026 14:01
@vybe
vybe merged commit 31ba8d9 into dev Aug 6, 2026
19 of 20 checks passed
dolho added a commit that referenced this pull request Aug 10, 2026
…text

The guard was `assert "lambda" in inspect.getsource(_patch_env_github_pat)`.
That function's own comment opens with `# \`lambda _: new_line\`, NOT the string
itself (#2017)`, so the assertion was satisfied by the prose: reverting the fix
while keeping the comment — which is exactly what a bad resolution of the #2025
conflict does — restored `re.error: invalid group reference` in production and
the guard still reported PASS. Every behavioural test above it stayed green too,
because none of them run under a reverted implementation.

Now it walks the AST of the function, finds the `.sub()` call, and asserts the
replacement node is an `ast.Lambda`.

Lambda ONLY, deliberately. My first AST draft allowed `Lambda | Name |
Attribute` and the reverted bug passed again: its shape is
`line_re.sub(new_line, out)`, whose replacement IS an `ast.Name`, and a Name is
statically ambiguous — the str variable that caused #2017 and a named callable
are the same node. The node set has to be chosen from what the BUG looks like,
not from what correct code looks like.

Mutation-verified 4/4 red, fixed code green:
  sub(new_line, out)        -> FAIL   (the actual #2017 revert)
  sub(f"{new_line}", out)   -> FAIL
  sub("K=v", out)           -> FAIL
  sub(new_line + "", out)   -> FAIL
  fixed                     -> 29 passed

Adds the learnings entry for the class (fourth instance), including the second
trap: moving a guard to the AST does not by itself make it sound.

Related to #2017

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dolho added a commit that referenced this pull request Aug 10, 2026
CI's regression diff flagged this as a HEAD-only failure:

  [F] test_pat_propagation_properties.TestKnownGaps::test_a_duplicated_pat_line_still_rotates

It is an XPASS(strict), not a broken test. The marker asserted finding 2 — that
`count=1` replaced only the FIRST GITHUB_PAT line while the agent's .env parser
is last-wins, so a duplicated line left the agent authenticating with the
REVOKED token while the rotation reported `updated`. `31ba8d98` (#2016 via
#2025) levels every occurrence on dev, so the strict marker went loud exactly
as designed: the bug died and the alarm fired instead of going quiet.

Retired to a plain regression test that now guards the fix instead of the
defect, with the history in its docstring.

The sibling markers stay: finding 3 (backslash parsed as an re.sub group
reference) and finding 4 (writer escapes a quote the reader never unescapes)
are still live on dev — their fixes are #2024 and #2030, both still open — and
both correctly report XFAIL here.

  47 passed, 5 xfailed (this file: 19 passed, 4 xfailed)

Also merged latest dev.

Related to #2015

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Aug 10, 2026
…analysis of #1985 and #1979 (#2018)

* test(edge-cases): audit hash chain + PAT propagation edge/property analysis

/edge-cases pass over the audit-chain (#1985) and PAT-rotation (#1979)
features merged to dev. 84 passing cases plus 6 strict-xfails, each naming the
issue it pins:

- #2015 — enabling the audit hash chain is in-memory only, so a backend
  restart silently turns the integrity control off. #1985 made verify_chain
  honest about unhashed ranges; this is why ranges keep going unhashed.
- #2016 — a duplicated GITHUB_PAT line survives `count=1` and wins under the
  agent's last-wins parser, so the agent keeps the revoked token while the
  rotation reports `updated`.
- #2017 — a backslash in the token raises re.error (the line is an re.sub
  replacement), and the .env writer escapes a quote the reader never unescapes.

The PAT contract is stated as a Hypothesis round-trip property against a copy
of the agent's own .env parser, since what the agent reads back is the only
definition of a successful rotation. It holds for every realistic single-line
.env; the xfails are the inputs where it does not.

Product code deliberately unchanged — findings are reported, fixing is a
separate decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(2018): make the #2015 alarms fire when the bug dies, not go quiet

Both alarms for finding 1 were inert against the very fix they guard (#2026).

1. The strict xfail asserted `svc_b._hash_chain_enabled`. #2026 deletes that
   attribute when the flag moves to `system_settings`, and `xfail` treats the
   resulting AttributeError as an expected failure exactly like the assertion
   failure it replaces — so the marker would keep reporting 'BUG: enabling the
   audit hash chain is in-memory only' against a codebase where that is no
   longer true. `strict=True` exists to go loud when the bug dies; this went
   quiet, permanently. Now asserts the PUBLIC seam (`svc_b.hash_chain_enabled`),
   so the marker flips to XPASS(strict).

2. The backstop read only `routers/audit_log.py`. #2026 puts the write in the
   SERVICE (`db.set_setting`) and leaves the router a thin passthrough, so the
   router-only check passes with the fix in place. It now also inspects the
   setter the route delegates to, resolved through the import rather than a
   fixed filename — over the AST, because the fix's own docstring explains the
   persistence it adds and a substring scan matches that prose.

3. `test_enabling_is_reflected_in_the_verdict` forced only the private
   attribute, which is the third failure on the merged tree. It now forces both
   seams (`raising=False` on each), so it reads the same answer before and
   after the flag moves.

Verified on both trees:
  this branch alone (bug present):  28 passed, 1 xfailed
  merged with #2026 (bug fixed):    XPASS(strict) + backstop failure — both
                                    loud, telling you to retire the finding;
                                    the verdict test passes

Related to #2015

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(2018): retire the finding-2 xfail — #2016 is fixed on dev

CI's regression diff flagged this as a HEAD-only failure:

  [F] test_pat_propagation_properties.TestKnownGaps::test_a_duplicated_pat_line_still_rotates

It is an XPASS(strict), not a broken test. The marker asserted finding 2 — that
`count=1` replaced only the FIRST GITHUB_PAT line while the agent's .env parser
is last-wins, so a duplicated line left the agent authenticating with the
REVOKED token while the rotation reported `updated`. `31ba8d98` (#2016 via
#2025) levels every occurrence on dev, so the strict marker went loud exactly
as designed: the bug died and the alarm fired instead of going quiet.

Retired to a plain regression test that now guards the fix instead of the
defect, with the history in its docstring.

The sibling markers stay: finding 3 (backslash parsed as an re.sub group
reference) and finding 4 (writer escapes a quote the reader never unescapes)
are still live on dev — their fixes are #2024 and #2030, both still open — and
both correctly report XFAIL here.

  47 passed, 5 xfailed (this file: 19 passed, 4 xfailed)

Also merged latest dev.

Related to #2015

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(2018): retire the #2015 xfail — #2026 landed

#2026 merged to `dev` (b0126f3), so `enable_hash_chain` now persists to
`system_settings` and the finding-1 marker would flip to XPASS(strict),
turning `dev` red the moment this branch lands. Per obasilakis's review the
marker belongs to whoever merges second; #2026 went first, so it falls here.

- `test_enabling_the_hash_chain_survives_a_restart` drops the xfail and keeps
  its assertion verbatim — the behaviour it describes is now the correct one,
  so it becomes the named regression test for #2015.
- `test_the_enable_route_persists_nothing` → `..._persists_durably`, with both
  halves inverted: the router still delegates, and the AST check now requires
  a real `set_setting` call in the service rather than forbidding one. Kept as
  an AST assertion for the original reason — a docstring describing
  persistence must not be able to satisfy it.

The #2017 marker is deliberately left alone: #2024 is not merged, so that bug
is still live on `dev` and the marker is doing its job.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: trinity-ability <trinity-ability@users.noreply.github.com>
Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com>
dolho added a commit that referenced this pull request Aug 11, 2026
…text

The guard was `assert "lambda" in inspect.getsource(_patch_env_github_pat)`.
That function's own comment opens with `# \`lambda _: new_line\`, NOT the string
itself (#2017)`, so the assertion was satisfied by the prose: reverting the fix
while keeping the comment — which is exactly what a bad resolution of the #2025
conflict does — restored `re.error: invalid group reference` in production and
the guard still reported PASS. Every behavioural test above it stayed green too,
because none of them run under a reverted implementation.

Now it walks the AST of the function, finds the `.sub()` call, and asserts the
replacement node is an `ast.Lambda`.

Lambda ONLY, deliberately. My first AST draft allowed `Lambda | Name |
Attribute` and the reverted bug passed again: its shape is
`line_re.sub(new_line, out)`, whose replacement IS an `ast.Name`, and a Name is
statically ambiguous — the str variable that caused #2017 and a named callable
are the same node. The node set has to be chosen from what the BUG looks like,
not from what correct code looks like.

Mutation-verified 4/4 red, fixed code green:
  sub(new_line, out)        -> FAIL   (the actual #2017 revert)
  sub(f"{new_line}", out)   -> FAIL
  sub("K=v", out)           -> FAIL
  sub(new_line + "", out)   -> FAIL
  fixed                     -> 29 passed

Adds the learnings entry for the class (fourth instance), including the second
trap: moving a guard to the AST does not by itself make it sound.

Related to #2017

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

4 participants