Skip to content

feat(launchpad): STEP 4 -- goose config.yaml read-merge-write (#239 STEP 4) - #262

Merged
tucktuck101 merged 7 commits into
launchpadfrom
feat/issue-239-projector-step4
Aug 23, 2026
Merged

feat(launchpad): STEP 4 -- goose config.yaml read-merge-write (#239 STEP 4)#262
tucktuck101 merged 7 commits into
launchpadfrom
feat/issue-239-projector-step4

Conversation

@serina-mcfall

@serina-mcfall serina-mcfall commented Aug 20, 2026

Copy link
Copy Markdown

Summary

STEP 4 of issue #239's plan: read-merge-write logic for goose's config.yaml, built from scratch since the existing Rust code only reads it. review-code ran per the plan's own gate for this step and found 6 real defects (1 Blocker); all fixed in a follow-up commit before this body was written.

Related issue

Refs #239

Issue type

Task


Agent provenance

Field Value
Harness / provider Claude Code
Model claude-sonnet-5
Session reference N/A - harness does not expose a stable run id/URL for this session
Initiating human @serina-mcfall

Objective

Add launchpad/agents/goose_config.py, giving the projector a way to enable goose's developer (shell/write) extension without hand-editing an operator's config.yaml.

Impacted components

launchpad/agents/goose_config.py
launchpad/agents/test_goose_config.py

Approach and rejected alternatives

Used ruamel.yaml's round-trip mode instead of plain PyYAML (the convention used elsewhere in this repo, e.g. launchpad/the-professor/tools/server.py) specifically because review-code demonstrated plain load+dump silently strips an operator's comments and re-quotes scalars on every write — confirmed by writing a fixture with a # managed by ansible comment and a quoted host value, both of which vanished after one round trip through yaml.safe_load/yaml.safe_dump. Rejected keeping plain PyYAML and documenting the loss as a known limitation instead of fixing it — Serina's call, since it adds a new dependency (python3-ruamel.yaml apt package, or pip install ruamel.yaml) not used anywhere else in the repo yet; she chose to add it.

Also rejected implementing file locking across the read-modify-write window (a review-code Medium finding: a goose process rewriting its own config at the same moment this script runs could lose one side's update). Documented as a known limitation in the module docstring instead — the plan's own atomicity requirement is about surviving a crash mid-write (solved: temp file + rename), not about concurrent writers, and this module does not attempt the latter.

Verification

Command run:

python3 -m unittest discover -s launchpad/agents -p "test_goose_config.py" -v

Raw output:

test_creates_file_when_none_exists (test_goose_config.EnableDeveloperExtensionEndToEndTests.test_creates_file_when_none_exists) ... ok
test_defaults_to_goose_config_path_when_no_path_given (test_goose_config.EnableDeveloperExtensionEndToEndTests.test_defaults_to_goose_config_path_when_no_path_given) ... ok
test_preserves_unrelated_provider_block_and_other_extension (test_goose_config.EnableDeveloperExtensionEndToEndTests.test_preserves_unrelated_provider_block_and_other_extension) ... ok
test_second_run_is_a_byte_for_byte_no_op (test_goose_config.EnableDeveloperExtensionEndToEndTests.test_second_run_is_a_byte_for_byte_no_op) ... ok
test_defaults_to_home_config_goose_when_unset (test_goose_config.GooseConfigPathTests.test_defaults_to_home_config_goose_when_unset) ... ok
test_empty_goose_path_root_mirrors_rust_treating_it_as_set (test_goose_config.GooseConfigPathTests.test_empty_goose_path_root_mirrors_rust_treating_it_as_set) ... ok
test_uses_goose_path_root_when_set (test_goose_config.GooseConfigPathTests.test_uses_goose_path_root_when_set) ... ok
test_adds_developer_extension_when_absent (test_goose_config.MergeDeveloperExtensionTests.test_adds_developer_extension_when_absent) ... ok
test_does_not_mutate_the_input (test_goose_config.MergeDeveloperExtensionTests.test_does_not_mutate_the_input) ... ok
test_is_idempotent_when_developer_already_enabled (test_goose_config.MergeDeveloperExtensionTests.test_is_idempotent_when_developer_already_enabled) ... ok
test_non_mapping_extensions_key_raises_goose_config_error (test_goose_config.MergeDeveloperExtensionTests.test_non_mapping_extensions_key_raises_goose_config_error) ... ok
test_overwrites_a_disabled_developer_entry_to_enabled (test_goose_config.MergeDeveloperExtensionTests.test_overwrites_a_disabled_developer_entry_to_enabled) ... ok
test_preserves_other_extensions (test_goose_config.MergeDeveloperExtensionTests.test_preserves_other_extensions) ... ok
test_preserves_unrelated_top_level_keys (test_goose_config.MergeDeveloperExtensionTests.test_preserves_unrelated_top_level_keys) ... ok
test_empty_file_returns_empty_dict (test_goose_config.ReadConfigTests.test_empty_file_returns_empty_dict) ... ok
test_invalid_yaml_raises_goose_config_error (test_goose_config.ReadConfigTests.test_invalid_yaml_raises_goose_config_error) ... ok
test_missing_file_returns_empty_dict (test_goose_config.ReadConfigTests.test_missing_file_returns_empty_dict) ... ok
test_non_mapping_top_level_raises_goose_config_error (test_goose_config.ReadConfigTests.test_non_mapping_top_level_raises_goose_config_error) ... ok
test_preserves_comments_on_round_trip (test_goose_config.ReadConfigTests.test_preserves_comments_on_round_trip) ... ok
test_reads_existing_mapping (test_goose_config.ReadConfigTests.test_reads_existing_mapping) ... ok
test_creates_parent_directory_and_file (test_goose_config.WriteConfigAtomicTests.test_creates_parent_directory_and_file) ... ok
test_leaves_no_temp_files_behind_on_success (test_goose_config.WriteConfigAtomicTests.test_leaves_no_temp_files_behind_on_success) ... ok
test_overwrites_existing_file (test_goose_config.WriteConfigAtomicTests.test_overwrites_existing_file) ... ok
test_preserves_existing_file_permissions (test_goose_config.WriteConfigAtomicTests.test_preserves_existing_file_permissions) ... ok
test_writes_through_a_symlink_rather_than_replacing_it (test_goose_config.WriteConfigAtomicTests.test_writes_through_a_symlink_rather_than_replacing_it) ... ok

----------------------------------------------------------------------
Ran 25 tests in 0.031s

OK

Also manually ran the real CLI entry point (python3 launchpad/agents/goose_config.py --enable-developer against a hand-written fixture with a # hand-edited, do not clobber comment and an inline # my favourite comment) and confirmed both comments and the existing my-mcp extension survive, with developer cleanly appended.

  • Tests or checks were run and the raw output is pasted above
  • The diff is confined to the scope of the linked issue
  • No secrets, keys, tokens or hostnames were added to tracked files

Not verified

Not run against a real, running goose process — only against static fixture files. The plan's own STEP 7 (live end-to-end proof) is what actually exercises this against a live goose+buzz-acp session; this PR only proves the file-merge logic in isolation. Also not verified: behavior on Windows path semantics (this sandbox is Linux-only), and the documented file-locking gap (concurrent writers) was reasoned about, not exercised under real concurrency.

Security implications

This is the first code in the repo that writes to a file outside the repo/build tree (an operator's ~/.config/goose/config.yaml or $GOOSE_PATH_ROOT equivalent). review-code specifically flagged this class of change (citing #9's PR #238, where review-code found a real security bug in far less risky read-only code) and found: a symlinked config would have been silently replaced instead of written through (fixed — now resolves and writes through the real target); file permissions would have been silently reset to 0600 on every write (fixed — now preserves the original mode); malformed YAML would have crashed with a raw traceback instead of a clear error (fixed — raises GooseConfigError). Remaining, documented rather than fixed: no locking across the read-modify-write window (see Approach above).

Escalations

Two decisions raised to Serina rather than made unilaterally: (1) whether to add ruamel.yaml as a new dependency to fix the comment-stripping Blocker, vs. documenting it as a known limitation — she chose to add it, and ran the sudo apt-get install python3-ruamel.yaml herself since it needs a real terminal for the password prompt. (2) implicitly, whether the no-locking gap needed fixing now — treated as out of the plan's stated scope (crash-mid-write atomicity, not concurrent-writer safety) and documented rather than escalated as a blocking question, since the plan itself doesn't ask for it.

Adds launchpad/agents/goose_config.py: read-merge-write logic for goose's
config.yaml, built from scratch since goose.rs
(desktop/src-tauri/src/managed_agents/config_bridge/goose.rs) is entirely
read-only and nothing in the repo writes this file today.

enable_developer_extension() reads the existing file if present (empty
mapping otherwise), preserves every other top-level key and every other
extension untouched, sets extensions.developer = {type: builtin, enabled:
true}, and writes atomically via a temp file + rename in the same
directory so a crash mid-write cannot leave a half-written config an
operator's next goose invocation trips over. Running it twice against the
same file is a byte-for-byte no-op, not an append-again.

goose_config_path() mirrors goose.rs's own path resolution (GOOSE_PATH_ROOT
env var, else ~/.config/goose/config.yaml).

Independent of STEPs 1-3 per the plan; converges with the projector script
at STEP 5.

Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
@serina-mcfall
serina-mcfall marked this pull request as ready for review August 20, 2026 21:45
…239)

review-code on PR #262 found six real defects in the first pass:

- Blocker: plain PyYAML load+dump strips comments and re-quotes scalars on
  every write. Switched to ruamel.yaml's round-trip mode (preserve_quotes),
  confirmed a hand-written comment and inline comment both survive a merge.
- High: GOOSE_PATH_ROOT="" was treated as unset, diverging from goose.rs's
  actual std::env::var() behavior (Ok("") for a set-but-empty var). Now
  mirrors Rust exactly, even though that edge case is arguably a footgun --
  diverging would mean this script patches a different file than the one
  goose itself reads.
- Medium: writing through a symlinked config.yaml replaced the symlink
  itself instead of writing through it. write_config_atomic now resolves a
  symlink target first and renames onto the real file.
- Medium: tempfile.mkstemp always created the replacement at mode 0600,
  silently narrowing an existing file's permissions. Now preserves the
  original file's mode when overwriting.
- Medium: malformed YAML or a non-mapping top-level/extensions value
  crashed with a raw traceback. Both now raise GooseConfigError with a
  clear message, matching project-pack.py's fail-loudly convention.
- Medium (documented, not fixed): no file locking across the
  read-modify-write window. Noted as a known limitation in the module
  docstring -- the plan's own atomicity requirement is about surviving a
  crash mid-write, not concurrent writers, and this module does not
  attempt the latter.

Adds 6 new tests covering each fix. Requires ruamel.yaml (python3-ruamel.yaml
apt package, or pip install ruamel.yaml) -- noted in the module docstring.

Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
@serina-mcfall

Copy link
Copy Markdown
Author

review-code ran on the first push and found 6 real defects (1 Blocker, 1 High, 4 Medium). All addressed in b921efc:

  • Blocker — plain PyYAML load+dump stripped comments and re-quoted scalars on every write (confirmed: a # managed by ansible comment and a quoted host value both vanished). Fixed by switching to ruamel.yaml's round-trip mode. Requires the python3-ruamel.yaml apt package (or pip install ruamel.yaml) — noted in the module docstring.
  • HighGOOSE_PATH_ROOT="" was treated as unset, diverging from goose.rs's actual std::env::var() behavior (Ok("") for a set-but-empty var). Now mirrors Rust exactly.
  • Medium — writing through a symlinked config.yaml replaced the symlink itself. write_config_atomic now resolves the symlink and writes through it.
  • Mediumtempfile.mkstemp always created the replacement at mode 0600, silently narrowing an existing file's permissions. Now preserves the original mode on overwrite.
  • Medium — malformed YAML or a non-mapping top-level/extensions value crashed with a raw traceback. Both now raise GooseConfigError with a clear message.
  • Medium (documented, not fixed) — no file locking across the read-modify-write window. Noted as a known limitation in the module docstring; the plan's own atomicity requirement is about surviving a crash mid-write, not concurrent writers.

6 new tests added (25 total, all passing) covering each fix, including a manual CLI sanity check confirming comments survive a real --enable-developer run.

Still draft — has not gone through review-final (not due until the whole issue merges per the plan), and depends on PR #259 (h2 fix) merging first to clear the Security check.

@serina-mcfall serina-mcfall added the by:agent Filed or authored by an AI agent, not a human label Aug 20, 2026
@ciaran-slow
ciaran-slow self-requested a review August 21, 2026 01:22
@ciaran-slow

Copy link
Copy Markdown

Review pipeline — PR #262

Stages run: review-code, review-tests, review-a11y, review-adjudicate, review-final.
Diffed against the true merge base. Plan read first: launchpad/plans/2026-08-20-issue-239-route-3-projector.md, STEP 4 at :134.

Not applicable, declared rather than faked:

  • review-a11y — a CLI script that rewrites a YAML file. No UI.
  • check-ledger.sh — the plan uses STEP N headings, not the ### Task N: shape the checker greps for, and no .superpowers/sdd/ ledger exists in this repo; the checker would exit 1 on its vacuity guard. I walked the plan's step graph by hand instead.

I could not run this PR's tests. python3 -c "import ruamel.yaml"ModuleNotFoundError: No module named 'ruamel'. That is finding 1, and it is why findings 2 and 3 are stated as untested claims rather than as reproduced failures.


Findings

1. High — ruamel.yaml is a new third-party dependency that nothing records and nothing installs

launchpad/agents/goose_config.py:47

from ruamel.yaml import YAML sits at module scope, so it runs before anything else in the file. The dependency is real and the choice is well-argued — PyYAML genuinely does strip comments and quoting — but it is recorded nowhere a machine reads:

  • No dependency manifest covers it. The only pyproject.toml files in the tree are under benchmarks/harbor-buzz-orchestra/, unrelated to launchpad/.
  • No workflow installs it. Grepping every file in .github/workflows/ on this head for ruamel returns nothing.
  • The docstring at :19-22 names the requirement in prose and defers recording it: "PERSONA_PACK_SPEC.md's tooling notes should mention this once STEP 5 wires this module into the projector CLI."

The repo has already paid for this exact mistake once. .github/workflows/launchpad-review-agent-controls.yml:41-42 installs PyYAML with a comment explaining why: "The setup-python tool cache ships pip only, so without this the first CI run goes red for a reason unrelated to containment." That lesson is on disk and was not applied here.

Two concrete failures, and the second is the serious one:

  1. All 24 tests in test_goose_config.py die at _SPEC.loader.exec_module(m) (:29) with ModuleNotFoundError, before any assertion runs. I reproduced this on a clean checkout. It is invisible today only because no CI job runs launchpad/agents/ at all — see the cross-cutting note below.
  2. STEP 5's spec is "project-pack.py the-professor does the env-var emission and the goose-config patch in one deterministic run — no other human step." project-pack.py is stdlib-only today. The moment STEP 5 imports this module, python3 launchpad/agents/project-pack.py the-professor --help exits with a traceback on any machine without ruamel — including the "freshly-cloned repo" STEP 5's own done-when names. task: Route 3 projector — resolve The Professor's pack into a real, write-capable runtime #239's DoD bullet 1 is "the pack changed, run this, the runtime matches it," and an unrecorded pip install is exactly the extra human step that bullet forbids.

Fix: add launchpad/agents/requirements.txt with ruamel.yaml, and install it in whichever workflow ends up running these tests — mirroring the pip install pyyaml line that already exists. Worth doing as well, independently: guard the import and raise GooseConfigError naming the pip command, so a missing dependency fails with this module's own loud, actionable error instead of a stack trace. That is what the rest of the file's discipline would predict.

2. Medium — the guarantee this module was built for is tested on the one path that does not exercise it

launchpad/agents/test_goose_config.py:82

The docstring's headline justification (goose_config.py:16-19) is: "Uses ruamel.yaml's round-trip mode (not PyYAML) specifically so an operator's comments and quoting style survive a merge."

test_preserves_comments_on_round_trip calls read_config and then write_config_atomic. It never calls merge_developer_extension. So the tested path is read→write; the claimed path is read→merge→write, and the merge is the only step that could lose the comments.

The merge is where the risk lives. :109 does merged = config.copy() and :114-118 does raw_extensions.copy(). Whether CommentedMap.copy() carries the comment attachments along with the keys is precisely the thing that decides whether the guarantee holds, and no test asks.

Concrete failure: if .copy() returns a mapping without the comment metadata, enable_developer_extension strips every comment from an operator's config.yaml on its first run — and the entire suite stays green, test_preserves_comments_on_round_trip included, because it never merges. The casualty would be the exact line the docstring cites as its motivation: # managed by ansible -- do not edit by hand.

I do not know which way it goes. I could not run it — ruamel is not installed here, per finding 1 — and I am not going to assert a defect I could not reproduce. The finding is that the claim is unpinned, and that nobody can currently pin it, including CI.

Fix: one test. Write a file with a comment, call enable_developer_extension on it, assert the comment is still present. Three lines, and it converts the module's headline claim from an argument into a check.

3. Medium — byte-for-byte idempotency is proven only against a file this module itself wrote

launchpad/agents/test_goose_config.py:198

_fixture_path builds its fixture with m.write_config_atomic(path, fixture) from a plain Python dict. So by the time test_second_run_is_a_byte_for_byte_no_op (:240) runs, the input is already ruamel-normalised output. The test compares run 2 against run 1 — both on normalised input — and never compares run 1 against what was there before.

This satisfies the plan literally. STEP 4's done-when (plan:145) asks for "a fixture config.yaml carrying an unrelated provider block and one other extension", and that is what the fixture is. So this is a coverage gap, not a deviation.

Concrete failure: an operator's hand-written config using enabled: no (a YAML 1.1 boolean), four-space indentation, or single-quoted hosts is reformatted by the first run. Run 1 produces a large silent diff to a file whose docstring promises to "preserve every existing key untouched"; runs 2..n are stable, so the suite's notion of idempotency is fully satisfied while the damage happened before it started measuring. test_preserves_comments_on_round_trip is the only test whose input is hand-written YAML, and it asserts substring presence, not byte equality.

Fix: have _fixture_path write a hand-authored YAML string via path.write_text(...) — comments, mixed quoting, a non-canonical boolean — and add an assertion that the first run's output differs from the original only by the developer entry. That is the plan's "both survive byte-for-byte" read strictly, and it is the case an operator will actually hit.

4. Medium — the code that grants shell access does not say that it grants shell access, and the plan reserved that decision

launchpad/agents/goose_config.py:124

extensions["developer"] = {"type": "builtin", "enabled": True} is goose's write/shell capability. The plan's OPEN item 2 (plan:233-242) reserves the judgement: "Who arbitrates whether goose's developer extension is safe enough to enable for a live/unattended run later — not decided here… a live, unattended agent with real shell access is a materially different blast radius than a human-triggered local session under BYOK."

The OPEN item does stay open on this PR, and I want to be clear about that — I checked for exactly the failure where a builder quietly answers a reserved decision. This is a human-invoked script behind an explicit --enable-developer flag, with parser.error("nothing to do -- pass --enable-developer") at :197 making the flag mandatory. That is squarely inside what the plan's DECIDED section already covers. No decision was taken here.

What is missing is the signal. The 35-line docstring covers atomicity, symlinks, permission modes and a concurrency caveat, and says nothing about write/shell capability or the pending arbitration. STEP 5's spec is "one deterministic run… no other human step."

Concrete failure: STEP 5's implementer reads this docstring, sees a carefully-caveated config merger with no risk note, and calls enable_developer_extension() from the projector's happy path. The explicit flag becomes an implicit side effect of running the projector, and the blast-radius decision the plan reserved gets answered by omission — nobody ever arbitrates, because nothing at the call site says an arbitration is pending.

Fix: one paragraph in the docstring — that developer is goose's shell/write extension, that enabling it for unattended operation is an open decision on #239, and that --enable-developer is deliberately explicit and should not be made implicit by a caller. Costs nothing and survives into STEP 5, which prose in a plan file will not.

5. Low — --help prints the whole 35-line docstring

launchpad/agents/goose_config.py:179 passes description=__doc__. The sibling module written for the same plan uses description=__doc__.splitlines()[0] (project-pack.py:210). argparse's default formatter re-wraps the full docstring into an unreadable block, and the two adjacent files from one plan now disagree on the same convention.


Cross-cutting: no CI job runs launchpad/agents/

Same as reported on #260, repeated here only because it changes how finding 1 reads. Every launchpad Python job is scoped to launchpad/scripts (launchpad-adr-check.yml:63, launchpad-pr-check.yml:100, launchpad-security-audit.yml:56), and no workflow mentions launchpad/agents. So this PR's 24 tests execute nowhere — which is also why a hard ModuleNotFoundError at import can sit in a green merge box.

I would not block this PR on it. It is pre-existing and fleet-wide, and it also covers the suites added by #261, #263, #264, #266 and #267 plus five test_*.py files already on launchpad. One issue to wire both launchpad/agents/ and launchpad/review-agent/ test suites into CI is the right remedy, referenced from these PRs rather than blocking them.

What I looked for and did not find

  • Claims about goose.rs that don't hold. Both check out. It is read-only for config.yamlstd::fs::read_to_string at goose.rs:12 and nothing else — and no other file in the repo writes it (agent_config.rs's only config.yaml mention is a doc comment at :146). The docstring's premise is accurate.
  • The GOOSE_PATH_ROOT mirroring. This is unusually careful work and worth naming. goose.rs:157-163 is if let Ok(root) = std::env::var("GOOSE_PATH_ROOT"), and std::env::var returns Ok("") for a set-but-empty variable — so Rust does not treat empty as unset. "GOOSE_PATH_ROOT" in env at goose_config.py:71 mirrors that exactly rather than doing the intuitive-but-wrong env.get(...) truthiness check, and test_empty_goose_path_root_mirrors_rust_treating_it_as_set (:41) pins it with the reasoning in a comment. Getting this backwards would have patched a different file than the one goose reads.
  • Atomicity actually being atomic. Temp file via mkstemp(dir=real_path.parent) so it lands on the same filesystem, os.replace for the rename, and os.unlink of the temp on any exception. Correct, and the symlink and permission-mode handling at :143 and :146-153 are both real problems solved rather than hypothetical ones — mkstemp does create at 0600, so without :152-153 an operator's 0644 config would silently narrow on every run.
  • Input mutation on the production path. test_does_not_mutate_the_input (:127) passes a plain dict, so it exercises the CommentedMap(config) branch rather than the config.copy() branch that read_config output actually takes. I traced the CommentedMap path anyway: extensions is a separate copy and merged["extensions"] = extensions rebinds the top-level key, so the input's nested mapping is never written through. Not a finding — the logic prevents it — but the branch is untested.
  • Error handling that lies. read_config raises GooseConfigError on invalid YAML and on a non-mapping top level rather than returning an empty dict and silently discarding the file; main catches it and returns 1. Loud where the design says loud. Consistent throughout.
  • os.chmod(fd, ...) at :153 — passing a file descriptor is supported on Linux and macOS, the only platforms this targets. Checked rather than assumed.
  • The documented locking caveat at :24-29. A limitation the code states in a comment is not a finding, and the distinction it draws — lost update, not corruption — is correct.
  • Tests that cannot fail. I asked the question of all 24. None asserts on a mock, none computes its expected value; fixtures and expectations are literals. No loops or conditionals in any test body.
  • Scope. STEP 4 is tagged [independent, converges with 2-3 at step 5], and this PR correctly does not wire into project-pack.py. :31-32 says so explicitly. No creep.

CI

The single check: FAILURE in the merge box is stale. It is launchpad — PR body check at 22:13:50Z failing on a missing ### Issue type section; two later runs of the same check on the same head SHA pass at 22:18:57Z. Verified via the check-runs API. Nothing to re-push.

This PR carries the by:agent label — the only one of the nine open PRs that does.

Triage of deferred items

Nothing arrived deferred or parked; no prior reviews or comments on this PR.

Merge readiness

A reader of #239 STEP 4 would find its done-when met on the terms the plan set: an unrelated provider block and a second extension both survive, and a second run is byte-identical. The atomic-write, symlink and permission work goes beyond what the plan asked for and each piece solves a real failure rather than a decorative one. The GOOSE_PATH_ROOT mirroring is the kind of detail that is usually got wrong.

They would also find a module that cannot be imported on a machine without an unrecorded pip dependency, a headline guarantee about comments that no test exercises, an idempotency proof that only covers input the module itself produced, and no in-code trace of the shell-access decision the plan deliberately left open. Findings 2 and 3 are each a few lines of test. Finding 1 is a requirements.txt and one CI line. Finding 4 is a paragraph. None is rated blocking, but finding 1 becomes blocking for STEP 5 rather than for this PR.

What I could not check: I could not execute any of this code, because the dependency it needs is not installed and installing it would not have told me what CI does. Every claim above about runtime behaviour is from reading the source and the ruamel API contract, and I have flagged the one place — finding 2 — where reading was not enough to settle the question.

Independence and tools

Independent of the code under review: I did not write it and had no part in it. Not independent across pipeline stages — one context ran the reviewers, the adjudicator and the final pass, where the skills call for a fresh context per stage. Findings 1–5 are self-adjudicated. Treat that as a limit on this report.

Tools actually held and used: Bash (git, git grep, gh, gh api, python3), Read, Edit, Write. No Grep or Glob tool was available in this session — every search was git grep/grep through Bash.

Nothing found at Blocker.

CONFIRMED	High	launchpad/agents/goose_config.py:47	ruamel.yaml dependency unrecorded and uninstalled; module fails at import
CONFIRMED	Medium	launchpad/agents/test_goose_config.py:82	comment-preservation tested on read→write, never through the merge
CONFIRMED	Medium	launchpad/agents/test_goose_config.py:198	idempotency fixture is the module's own output, not operator-authored YAML
CONFIRMED	Medium	launchpad/agents/goose_config.py:124	enables shell/write capability with no in-code note of plan OPEN item 2
CONFIRMED	Low	launchpad/agents/goose_config.py:179	--help dumps the full 35-line docstring; sibling module uses first line only

Handed 5 findings, confirmed 5, refuted 0, merged 0. No reviewer report arrived without its REVIEW COMPLETE marker, because all stages ran in one context; stated as a limit, not a pass. I did not author any of the code under review.

ADJUDICATION COMPLETE

REVIEW COMPLETE


Per launchpad/AGENTS.md §5 rule 1 — an agent drafts and raises, never approves or clears. This is a report, not an approval; the merge decision is @ciaran-slow's.

@ciaran-slow

Copy link
Copy Markdown

Filed the CI gap from my review above as #270 (task: run launchpad/agents Python test suites in CI), so this PR does not need to carry it.

Scoped to launchpad/agents/ only. The launchpad/review-agent/ half has the same symptom but is already owned by #118 STEP 10, which requires check_adjudication.py to be registered in run_controls.py's CONTROLS list "so #120's single CI entry point picks it up and no second workflow is added" — so widening #270 to cover it would build the second entry point that plan rules out. Recorded in #270's Out of scope.

#270 also carries the ruamel.yaml dependency half, since a workflow that runs launchpad/agents/ without installing it just goes red on ModuleNotFoundError instead of passing vacuously.

@ciaran-slow ciaran-slow self-assigned this Aug 21, 2026
A second independent review-code pass on PR #262 found five more findings
(1 High, 3 Medium, 1 Low), all confirmed. Fixes:

- High: ruamel.yaml was recorded nowhere and installed by nothing, so the
  module raised ImportError on any machine without it and no CI job could
  have caught that -- because NO CI job ran launchpad/agents tests at all.
  The same High was found independently on PR #260 (its 20 tests also never
  executed). Adds launchpad/agents/requirements.txt (the dependency, with
  why ruamel and not PyYAML) and .github/workflows/launchpad-agents-tests.yml,
  which installs it and runs the suite. The workflow fails if it discovers
  zero test files, since `unittest discover` exits 0 on an empty suite and
  a vacuous pass is exactly the gap being closed.

- Medium: comment preservation -- the guarantee this module exists for --
  was only asserted across read_config -> write_config_atomic, which skips
  merge_developer_extension entirely. Since the merge copies the mapping, a
  copy that dropped ruamel's comment attachments would have lost every
  comment on the real path while the test still passed. Verified the real
  path is in fact correct (comments do survive), so this was a coverage gap
  rather than a live bug -- but it was proving the wrong thing.

- Medium: every fixture was built by calling this module's own writer, so
  "before" and "after" had both been through the same serializer -- the one
  shape that cannot detect a serializer mangling human-authored YAML. Adds
  OPERATOR_AUTHORED_CONFIG as raw text (top-of-file comment, inline comment,
  comment nested two levels deep, deliberately quoted scalar, inline comment
  inside `extensions`) and four controls through the real entry point,
  including one asserting the developer block is the ONLY line added.

- Medium: nothing in the code said that enabling goose's `developer`
  extension grants shell and filesystem access, or that the plan's OPEN
  item 2 leaves the live/unattended decision explicitly unsettled. Now
  stated in enable_developer_extension's own docstring, quoting the plan.

- Low: --help dumped the whole 40-line docstring. Now first line only,
  matching project-pack.py's own `__doc__.splitlines()[0]`.

Mutation-checked the new coverage: reverting the dumper to PyYAML makes the
suite fail (1 failure, 8 errors) rather than pass. 29 tests, all green.

Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
Codex reviewed the previous commit as the independent cross-vendor pass and
returned REQUEST CHANGES on one Medium plus two Lows. All three were right.

- Medium: the "Confirm tests were discovered" guard counted FILES, not test
  cases -- so it still permitted the exact vacuous pass it was added to
  prevent. Codex's repro: leave test_goose_config.py in place but rename
  every `test_*` method to `check_*`; the guard reports one test file while
  `unittest discover` collects zero cases and exits 0. Reproduced it
  verbatim (old guard: files=1 -> PASS; new guard: cases=0 -> FAIL) and
  replaced the check with unittest's own loader plus countTestCases().

- Low: test_only_addition_is_the_developer_block claimed the operator's file
  "survives verbatim" but compared line MEMBERSHIP, which is weaker than the
  claim in three ways Codex named -- splitlines() hides a missing trailing
  newline, hides a CRLF/LF conversion, and would tolerate the three added
  lines being interleaved anywhere among the originals. Now compares the
  whole file against the fixture plus the appended block, which is genuinely
  byte-exact and needs no separate ordering argument.

- Low: requirements.txt's rationale was factually wrong -- it claimed an
  exact pin "would reject the newer wheel pip installs", which is not how
  pip works. Corrected to state the real reason (the apt/pip floor split),
  and added the <0.20 ceiling Codex asked for: an open upper bound let a
  future major series in, and a serializer change there could silently alter
  an operator's hand-written config with no commit here to point at.

Codex confirmed the five findings from the previous pass are substantively
fixed, and found no Critical or High. 29 tests, all green.

Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
Second cross-vendor pass from Codex found a real behavioural bug, not just a
coverage gap: write_config_atomic rewrote a CRLF file to LF. Reproduced
independently before fixing -- a 5-CRLF input came back with 0, and the
operator's original bytes were not a prefix of the output. This repository
supports Windows (there is a Windows Rust CI job), so a Windows operator's
hand-maintained config.yaml being silently converted is not hypothetical, and
it is the same class of unasked-for edit as dropping their comments, which is
the whole reason this module uses a round-trip parser.

write_config_atomic now detects the target file's own convention (any CRLF
present => CRLF) and passes it to the writer via `newline=`, since the YAML
dumper always emits "\n" and Python translates on the way out. Mixed endings
normalise to CRLF rather than being preserved per-line; stated in the
docstring rather than left to be discovered.

Two test changes, both from the same review:
- test_only_addition_is_the_developer_block now compares read_bytes() against
  an encoded expected value. It used read_text(), which normalises newlines --
  so it passed while the implementation was actively rewriting CRLF to LF.
  That is precisely why the bug survived the previous pass.
- Adds test_preserves_crlf_line_endings (asserts no bare LF survives, the
  original bytes are an exact prefix, and the full expected byte string) and
  test_lf_file_stays_lf, so preserving CRLF cannot regress into emitting CRLF
  into a file that never had it.

Also corrects requirements.txt's ceiling rationale: <0.20 is a conservative
"untested release series" boundary, not a major-version boundary -- ruamel's
own docs put its major transition at 1.0. The specifier is unchanged; the
reasoning attached to it was wrong.

31 tests, all green.

Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
Codex's approving pass noted, non-blocking, that "keeps its line-ending
convention" overclaims: a lone-CR (pre-OS X Mac) file normalises to LF
rather than being preserved. Taken because an overclaiming docstring is
exactly the defect class this PR's whole review chain has been about --
the claim is now "LF-or-CRLF", with the lone-CR case named as a
limitation of the function rather than left for a reader to discover.

No behaviour change; docstring only. 31 tests, all green.

Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
@serina-mcfall

Copy link
Copy Markdown
Author

Second review-code pass (5 findings) addressed, then put through cross-vendor review — Codex reviewing Claude-authored code, three passes, two of which returned REQUEST CHANGES with real findings.

The five review findings

Finding Fix
High ruamel.yaml recorded nowhere, installed by nothing → ImportError launchpad/agents/requirements.txt + .github/workflows/launchpad-agents-tests.yml
Medium comment preservation asserted only across read_configwrite_config_atomic, skipping the merge four controls through the real entry point
Medium every fixture built by the module's own writer OPERATOR_AUTHORED_CONFIG as raw human-authored text
Medium nothing in code said this grants shell access, or that plan OPEN item 2 is unsettled stated in enable_developer_extension's docstring, quoting the plan
Low --help dumped the whole 40-line docstring first line only, matching project-pack.py

Root cause of the High, worth stating plainly: no CI job ran anything under launchpad/agents/. The same High was found independently on #260 — its 20 tests never executed either. The new workflow covers both once they land.

What cross-vendor review then caught that I had missed

Pass 1 → REQUEST CHANGES. My "Confirm tests were discovered" guard counted files, not test cases — so it still permitted the exact vacuous pass it was added to prevent. Codex's repro: leave test_goose_config.py in place, rename every test_* method to check_*; guard reports 1 file, unittest discover collects 0 cases and exits 0. Reproduced verbatim (old: files=1 → PASS; new: cases=0 → FAIL) and replaced it with countTestCases().

Pass 2 → REQUEST CHANGES, and this one was a real bug, not a coverage gap. write_config_atomic was rewriting CRLF files to LF. Measured before fixing: 5 CRLF in → 0 out, original bytes not a prefix of the output. It survived the earlier pass because test_only_addition_is_the_developer_block used read_text(), which normalises newlines and so could not see it. A Windows operator's hand-maintained config.yaml would have been silently converted — the same class of unasked-for edit as dropping their comments, which is the entire reason this module uses a round-trip parser. Fixed by detecting the target's own convention and passing it via newline=; test now compares read_bytes().

Pass 3 → APPROVE. Codex executed the suite itself (31 tests, OK) and probed mixed line endings, lone-CR, symlink + permission interaction, empty files, and CRLF idempotency across two runs. No blockers, no regressions.

Also corrected two rationales that were simply wrong: requirements.txt claimed an exact pin "would reject newer wheel pip installs" (not how pip works), and called <0.20 a major-version boundary (ruamel's own docs put its major transition at 1.0).

Verification: 31 tests, all green. Mutation-checked — reverting the dumper to PyYAML makes the suite fail (1 failure, 8 errors) rather than pass.

Still draft: no review-final, which the plan schedules before the whole issue merges rather than per step. Independence caveat: Codex reviewed, but I both authored the fixes and relayed its verdicts here.

@ciaran-slow ciaran-slow 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.

Comment review recording the pipeline result. Not an approval and not a change-request — the merge decision is unchanged by this.

Reviewed via the full pipeline — detail in my comment on this PR.

The careful parts are genuinely careful. goose.rs really is read-only for config.yaml and nothing else in the repo writes it — both premises check out. The GOOSE_PATH_ROOT mirroring is the detail most people get wrong: std::env::var returns Ok("") for a set-but-empty variable, so Rust does not treat empty as unset, and "GOOSE_PATH_ROOT" in env mirrors that exactly rather than doing the intuitive-but-wrong truthiness check. The atomic write, symlink handling and permission-mode preservation each solve a real failure rather than a decorative one.

One High finding — must be fixed before this merges.

goose_config.py:47ruamel.yaml is a new dependency that nothing records and nothing installs. No manifest under launchpad/ declares it; no workflow installs it; the docstring names it in prose and defers recording it to STEP 5. python3 -c "import ruamel.yaml"ModuleNotFoundError on a clean checkout, so all 24 tests die at import before any assertion — I could not run this PR's suite at all. And STEP 5's spec is "one deterministic run… no other human step" against a freshly-cloned repo, which an unrecorded pip install defeats. The fix pattern is already on disk: launchpad-review-agent-controls.yml:41-42 does pip install pyyaml with a comment giving exactly this reason. Please add launchpad/agents/requirements.txt and, ideally, guard the import so a missing dependency raises GooseConfigError naming the pip command rather than a traceback. (#270 covers the CI side.)

Three Medium items, non-blocking:

  1. test_goose_config.py:82 — the module's headline claim is that comments survive a merge, but test_preserves_comments_on_round_trip never calls merge_developer_extension. Whether CommentedMap.copy() carries comment metadata is the thing that decides the guarantee, and no test asks. I could not settle it either, because of the High above. One three-line test pins it.
  2. test_goose_config.py:198_fixture_path builds its fixture with the module's own write_config_atomic, so byte-for-byte idempotency is only proven against already-normalised input. An operator's hand-written config (enabled: no, four-space indent, quoted hosts) would be reformatted by the first run and the test would not notice.
  3. goose_config.py:124 — this line grants goose's shell/write capability, and the 35-line docstring never says so. The plan's OPEN item 2 reserves who arbitrates that for unattended use. STEP 5 wraps this in "one run, no other human step", so a sentence here is what survives into STEP 5 when the plan file does not.

@ciaran-slow ciaran-slow 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.

Change-request review. The finding(s) below are the author's to resolve before this merges — full detail, probes and line citations are in my pipeline comment on this PR.

Reviewed via the full pipeline — detail in my comment on this PR.

The careful parts are genuinely careful. goose.rs really is read-only for config.yaml and nothing else in the repo writes it — both premises check out. The GOOSE_PATH_ROOT mirroring is the detail most people get wrong: std::env::var returns Ok("") for a set-but-empty variable, so Rust does not treat empty as unset, and "GOOSE_PATH_ROOT" in env mirrors that exactly rather than doing the intuitive-but-wrong truthiness check. The atomic write, symlink handling and permission-mode preservation each solve a real failure rather than a decorative one.

One High finding — must be fixed before this merges.

goose_config.py:47ruamel.yaml is a new dependency that nothing records and nothing installs. No manifest under launchpad/ declares it; no workflow installs it; the docstring names it in prose and defers recording it to STEP 5. python3 -c "import ruamel.yaml"ModuleNotFoundError on a clean checkout, so all 24 tests die at import before any assertion — I could not run this PR's suite at all. And STEP 5's spec is "one deterministic run… no other human step" against a freshly-cloned repo, which an unrecorded pip install defeats. The fix pattern is already on disk: launchpad-review-agent-controls.yml:41-42 does pip install pyyaml with a comment giving exactly this reason. Please add launchpad/agents/requirements.txt and, ideally, guard the import so a missing dependency raises GooseConfigError naming the pip command rather than a traceback. (#270 covers the CI side.)

Three Medium items, non-blocking:

  1. test_goose_config.py:82 — the module's headline claim is that comments survive a merge, but test_preserves_comments_on_round_trip never calls merge_developer_extension. Whether CommentedMap.copy() carries comment metadata is the thing that decides the guarantee, and no test asks. I could not settle it either, because of the High above. One three-line test pins it.
  2. test_goose_config.py:198_fixture_path builds its fixture with the module's own write_config_atomic, so byte-for-byte idempotency is only proven against already-normalised input. An operator's hand-written config (enabled: no, four-space indent, quoted hosts) would be reformatted by the first run and the test would not notice.
  3. goose_config.py:124 — this line grants goose's shell/write capability, and the 35-line docstring never says so. The plan's OPEN item 2 reserves who arbitrates that for unattended use. STEP 5 wraps this in "one run, no other human step", so a sentence here is what survives into STEP 5 when the plan file does not.

@serina-mcfall

Copy link
Copy Markdown
Author

Independent adjudication — all four findings are already fixed

Adjudicated in a fresh context, separate from the pipeline that produced the findings.

The change-request review is stale, not wrong. All four findings were real and accurately cited at the head they were written against; all four were fixed 36 minutes before the review restating them was submitted.

Time (2026-08-21 UTC) Event
01:38 Pipeline comment written. Head was b921efc96.
03:21 a3f6b7879 — "address second review-code pass on STEP 4"
03:27 / 03:34 / 03:37 cross-vendor fixes (test-count guard, CRLF preservation)
03:52 COMMENTED review — re-posts the 01:38 body verbatim
03:57 CHANGES_REQUESTED — re-posts the same body again

Each citation was verified at b921efc96 before being called resolved, so this is not "the author says it's fixed" — it is the finding confirmed real, then confirmed closed.

Findings

1. goose_config.py:47 — undeclared ruamel.yaml — RESOLVED (a3f6b7879).

Real at review time: line 47 was from ruamel.yaml import YAML, launchpad/agents/ held no manifest, and no agents-test workflow existed. The reviewer's pip install pyyaml precedent in launchpad-review-agent-controls.yml also checks out.

All three legs now closed: launchpad/agents/requirements.txt pins ruamel.yaml>=0.17.21,<0.20 with both bounds reasoned; launchpad-agents-tests.yml:49 installs it; the module docstring names both instead of deferring to STEP 5. And the workflow has actually run green on this head:

$ gh api repos/launchpad-26/buzz/actions/runs/32445343580 --jq '{name,conclusion,head_sha}'
{"conclusion":"success","head_sha":"eaeec02b1...","name":"launchpad — agents tests"}

Suite: Ran 31 tests ... OK in test_goose_config.py, Ran 51 tests ... OK across launchpad/agents/.

Severity, had it survived: Blocker, up one rung from the reviewer's High. A 24-test suite that dies at import is not a partial check, it is zero checks wearing the costume of 24 — wrong now, and against STEP 5's stated "one deterministic run, no other human step". The review body said "must be fixed before this merges" while the ladder said High; the ladder should have carried that.

Honest gap: the original ModuleNotFoundError could not be reproduced here — this host has python3-ruamel.yaml 0.17.21 from apt, so the clean-checkout claim is neither confirmed nor disproved. Moot now the manifest has landed, but not the same as having checked.

2. test_goose_config.py:82 — comment preservation never tested through the merge — RESOLVED.

Real at review time. Now closed by a new OperatorAuthoredConfigTests class: test_merge_preserves_every_comment_and_quoting_style calls enable_developer_extension — merge included — and asserts five comments plus the quoting style survive.

The question the review said it could not settle is now settled. Probed directly: CommentedMap.copy() does carry comment attachments (copy has .ca attr: True, with the inline CommentToken present). So the docstring's headline guarantee holds on the merge path, and it is pinned by a passing test rather than by argument.

3. test_goose_config.py:198 — idempotency proven only against the module's own output — RESOLVED, with a residual.

OPERATOR_AUTHORED_CONFIG is now raw hand-authored text written via path.write_text(...), and test_only_addition_is_the_developer_block makes the byte-exact assertion using read_bytes() specifically so a newline rewrite cannot hide.

Residual, found by feeding the reviewer's own adversarial input — a genuinely new observation, Low. Two of the three traits are handled and one is not: the comment survives, the single quotes survive, enabled: no survives as no — but four-space indentation is silently renormalised to two on the first run. The committed fixture uses two-space indent, so the byte-exact test cannot see it, and write_config_atomic's docstring enumerates what it preserves (permission mode, LF-or-CRLF, the lone-CR limitation) without mentioning indent width.

Deliberately not promoted into finding 3 — the cited gap is closed and the requested assertion exists. Same class as the CRLF issue the cross-vendor pass caught, so it likely deserves its own issue rather than being smuggled in under a resolved finding's number.

4. goose_config.py:124 — undocumented shell/write grant — RESOLVED.

Real at review time, though slightly overstated: the docstring did already mention write/shell capability in passing, so "says nothing about" was not strictly accurate — but the pending-arbitration half was genuinely absent, and that was the substance.

The whole docstring was read before ruling. enable_developer_extension now carries a dedicated WHAT THIS GRANTS, STATED WHERE IT HAPPENS paragraph covering all three asks: what it grants ("the ability to run commands and write files as the invoking user… a real expansion of blast radius"), that the decision is open (OPEN item 2 quoted directly), and that a caller must not make it implicit. A documented limitation is not a finding.

The reviewer's fifth item (Low — --help dumping the full docstring) is also fixed: description=__doc__.splitlines()[0], matching project-pack.py.

Verdict

state severity file:line summary
RESOLVED Blocker (moved up from High) launchpad/agents/goose_config.py:47 Undeclared ruamel.yaml broke every test at import; fixed by requirements.txt + launchpad-agents-tests.yml, green on head
RESOLVED Medium launchpad/agents/test_goose_config.py:82 Merge-path comment preservation now tested; CommentedMap.copy() probed and does carry comments
RESOLVED Medium launchpad/agents/test_goose_config.py:198 Fixture is now hand-authored with a byte-exact assertion; residual Low — 4-space indent silently renormalised
RESOLVED Medium launchpad/agents/goose_config.py:124 Docstring now states the shell/write grant and quotes plan OPEN item 2

Handed 4. 0 confirmed, 0 refuted, 4 resolved. Nothing survives to block the merge.

Total non-survival usually means a broken adjudicator rather than a flawless PR, so to be explicit about why this case differs: the suite was executed (31 and 51 tests, green), the new CI workflow was confirmed green on the exact head SHA, the one runtime question the review declared unsettled was answered, and one residual gap was found by probing the reviewer's own adversarial input. This is resolution verified, not accepted.

Requesting re-review against current head@ciaran-slow, the finding set was correct when written at 01:38 and re-posted unchanged at 03:52 and 03:57 without re-reading the branch.

Cross-stack note: this PR is on the #239 stack and shares no root cause with the #118 chain (#261/#263/#264/#266/#267), as expected. The one genuine cross-cutting item — no CI job running launchpad/agents/ — was correctly filed separately as #270 rather than counted here.

🤖 Adjudicated by Claude Code (claude-opus-5) for @serina-mcfall. I authored none of these findings and none of the code under review; this pass was read-only.

@benmitchell11 benmitchell11 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.

Independent pass, done after reading @ciaran-slow's existing COMMENTED/CHANGES_REQUESTED review and checking it against current HEAD (eaeec02b1) rather than trusting either the review or the PR body at face value.

All previously-flagged issues appear already resolved at HEAD. Concretely verified, not just read:

  • Missing dependency manifest (ciaran-slow's High). launchpad/agents/requirements.txt now pins ruamel.yaml>=0.17.21,<0.20 with a well-argued floor/ceiling rationale, and .github/workflows/launchpad-agents-tests.yml installs it before running tests, gated on pull_request/push paths touching launchpad/agents/**. I installed fresh from that requirements file (ruamel.yaml was not present in my environment beforehand) and confirmed the suite actually imports and runs — this is no longer a "trust the docstring" claim.
  • Comment-preservation-through-merge gap (Medium #1). test_goose_config.py's new OperatorAuthoredConfigTests.test_merge_preserves_every_comment_and_quoting_style now drives the real end-to-end path (enable_developer_extension, merge included) against a hand-authored fixture with a top-of-file comment, an inline comment, a nested comment, and a quoted scalar, and asserts all five survive. This is the gap the earlier test_preserves_comments_on_round_trip test left open (it never called merge_developer_extension).
  • Idempotency fixture built by the module's own writer (Medium #2). OPERATOR_AUTHORED_CONFIG is now a raw string literal (not built via write_config_atomic), and test_second_run_on_operator_authored_file_is_byte_for_byte_no_op / test_only_addition_is_the_developer_block exercise it. test_only_addition_is_the_developer_block in particular does a byte-exact comparison against "input + exactly the appended developer block," which is a strong guarantee against silent reformatting of an operator's file.
  • Undisclosed capability grant (Medium #3). enable_developer_extension's docstring now has an explicit "WHAT THIS GRANTS, STATED WHERE IT HAPPENS" section naming the shell/write capability and citing the plan's OPEN item 2.

Ran the suite myself (pip install -r launchpad/agents/requirements.txt, then python3 -m unittest discover -s launchpad/agents -p "test_goose_config.py" -v, on Windows): 28/31 pass. The 3 failures are all platform artifacts, not logic bugs, and match exactly what the PR's own "Not verified" section discloses ("behavior on Windows path semantics... this sandbox is Linux-only"):

  • test_writes_through_a_symlink_rather_than_replacing_itos.symlink needs an elevated privilege Windows doesn't grant by default (WinError 1314).
  • test_preserves_existing_file_permissions — Windows has no POSIX permission bits; chmod(0o644) doesn't round-trip.
  • test_only_addition_is_the_developer_block — the test's own path.write_text(OPERATOR_AUTHORED_CONFIG) fixture setup silently gets LF→CRLF-translated by Python's default text-mode write on Windows, before the module under test ever runs. Since CI runs on ubuntu-latest, none of these should surface there — worth a one-line note in the test module if Windows contributors are expected, but not a blocker.

Checked the failure-mode classes this category of change typically has, per this repo's own review conventions:

  • Preserves structure it doesn't own: yes, verified above through the real merge path, not just read→write.
  • Partial write on failure: write_config_atomic writes to a tempfile.mkstemp sibling, os.replaces it in, and the except clause unlinks the temp file and re-raises — no window where a crash leaves a half-written file at the real path.
  • "Merge" that's actually clobber: no — confirmed via test_preserves_unrelated_provider_block_and_other_extension, test_merge_adds_developer_without_disturbing_existing_extension, and the byte-exact test above.

One minor nit, not blocking: the PR body's "Verification" section pastes a run showing "Ran 25 tests," but HEAD's suite now has 31 (OperatorAuthoredConfigTests and the CRLF tests were added in later fixup commits after that body text was apparently written). Worth refreshing the pasted output so it matches what's actually on the branch.

No new blocking findings from me. Given the prior CHANGES_REQUESTED review's concerns look genuinely addressed at this commit, this looks close to mergeable pending the reviewer re-confirming their own findings against current HEAD (I'm not the one who can clear that review).

@serina-mcfall

Copy link
Copy Markdown
Author

Requested changes are done — clear for approval

@ciaran-slow's change-request is satisfied, and I am independent of this one: the fixes were written by a different session, and the verification was done by a separate adjudication pass, not by whoever wrote them.

All four findings were real and accurately cited at the head they were written against (b921efc96), and all four were fixed in a3f6b7879 at 03:21Z — thirty-six minutes before the review restating them was submitted at 03:57Z. The change-request was stale on arrival, not wrong.

Verified at head eaeec02b1:

  • ruamel.yamllaunchpad/agents/requirements.txt pins it with both bounds; launchpad-agents-tests.yml:49 installs it; and that workflow ran green on this exact head.
  • Comment preservation — a new OperatorAuthoredConfigTests class tests it through enable_developer_extension, i.e. the merge path. The question the review said it couldn't settle was settled: CommentedMap.copy() does carry comment attachments.
  • Fixture idempotency — fixture is now raw hand-authored text with a byte-exact read_bytes() assertion.
  • Undocumented shell grant — the docstring now carries a WHAT THIS GRANTS paragraph quoting plan OPEN item 2.

Suite: 31 tests in test_goose_config.py, 51 across launchpad/agents/. CI green, no failures.

One residual found while probing rather than reading, filed separately and not blocking: four-space indentation is silently renormalised to two on first write, invisible to the committed fixture because it uses two-space indent.

Ready for a human approval. I am an agent and do not approve — the command is below for whoever picks it up, and it deliberately is not run here.

# ---- NOT RUN BY ME. For a human to run, when they choose: ----
gh pr review 262 --approve

@tucktuck101
tucktuck101 merged commit 8f42935 into launchpad Aug 23, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

by:agent Filed or authored by an AI agent, not a human

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants