diff --git a/AGENTS.md b/AGENTS.md index a3060c5f..95fc43d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -160,7 +160,7 @@ Like the other PyAuto repos, PyAutoBrain runs from its checkout (no pip install) it resolves the sibling `pyauto-heart` and `autohands` binaries from PATH or the `~/Code/PyAutoLabs/` checkouts. -## Remote sessions: two lines that pay for themselves +## Remote sessions: three lines that pay for themselves Measured in a web/mobile container, where this file is loaded and little else is. @@ -174,6 +174,17 @@ Measured in a web/mobile container, where this file is loaded and little else is only). `bin/pyauto-brain` already calls it before every verb. The symptom of skipping it is a pytest that is not this workspace's — collection `ImportError`s naming `yaml`, or `No module named pytest`. +- **There is no `gh`, and installing one does not help.** A remote session + reaches GitHub through the `mcp__github__*` tools, already scoped to the + session's repos. `gh` itself is two seconds away (`apt-get install gh`) and + is a trap: it authenticates — `gh api user` returns your login — and then + 403s every repo-scoped call, because the egress proxy serves neither the + REST repo paths nor GraphQL beyond a pinned set of PR-review operations. So + `gh pr list`, `gh issue list`, `gh repo view` and `gh api repos/...` all + fail *after* the install, which is worse than the honest `command not + found`. Read [`skills/GITHUB_ACCESS.md`](skills/GITHUB_ACCESS.md) at the top + of any run that touches GitHub; it maps each `gh` operation onto its MCP + tool. ## The command surface (Brain implicit) diff --git a/skills/GITHUB_ACCESS.md b/skills/GITHUB_ACCESS.md index cf1be0b2..46349c6e 100644 --- a/skills/GITHUB_ACCESS.md +++ b/skills/GITHUB_ACCESS.md @@ -33,12 +33,49 @@ command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1 \ - **`gh`** — follow the skill's commands as written. - **`mcp`** — the `gh` lines are *the operation to perform*, not the command to - run. Translate with the table below. Do not try to install `gh`, and do not - report the task as blocked: the MCP surface can do everything the close-out + run. Translate with the table below. Do not try to install `gh` (the next + section says why it would not help), and do not report the task as blocked: the MCP surface can do everything the close-out needs — deleting a branch is not one of the things it needs (below). A session cannot be half-and-half. Probe once; don't re-probe per step. +## Installing `gh` does not work — measured, 2026-08-27 + +The probe above says "no `gh`", and the obvious next thought is to fix that: +the session runs as root, `apt-get install -y gh` succeeds in about two +seconds, and Ubuntu ships 2.45.0. Do not. What you get is a `gh` that +authenticates and then fails everything that matters, which is strictly worse +than the honest `command not found` — the binary looks healthy, so a run +spends its turns re-trying instead of switching surface. + +What was actually measured in a remote session, with `gh` installed: + +| Call | Result | +|---|---| +| `gh api user` | **works** — returns the login | +| `gh api rate_limit` | **works** | +| `gh api repos//` | `403` — "GitHub access is not enabled for this session" | +| `gh api repos///pulls` | `403` — same | +| `gh api repos///issues` | `403` — same | +| `gh api repos///actions/runs` | `403` — same | +| `gh pr list`, `gh issue list`, `gh repo view` | `403` — "This GraphQL query is not enabled for this session" | +| `gh auth status` | reports the token invalid (it is a proxy placeholder) | + +Two independent walls, either one of which is fatal: + +1. **REST repo paths are not served.** The egress proxy answers every + `api.github.com/repos/...` request with a 403 pointing at the Claude GitHub + App. The same URL through `mcp__github__*` succeeds, because the MCP surface + carries the session's own repo-scoped credential — a different credential + from the one raw `api.github.com` sees. +2. **GraphQL is pinned to a small set of PR-review operations.** Most of `gh`'s + porcelain (`pr list`, `issue list`, `repo view`, `pr status`) is GraphQL, so + it stays broken even if the first wall were removed. + +Because of the second wall, `gh` could not be a drop-in here even if an org +admin connected the GitHub App. The MCP surface is not a workaround for a +missing `gh` — on this surface it is *the* GitHub client. Use the table above. + ## The mapping | Operation | `gh` | MCP tool | diff --git a/tests/test_gh_surface.py b/tests/test_gh_surface.py index aa72adb5..3df6e309 100644 --- a/tests/test_gh_surface.py +++ b/tests/test_gh_surface.py @@ -9,8 +9,9 @@ Two rules, pinned here: -1. a skill body that drives `gh` links to `skills/GITHUB_ACCESS.md`, which maps - each operation onto its MCP tool; +1. a skill body that drives `gh` — here or in PyAutoMind, the other repo that + ships skills — points at `skills/GITHUB_ACCESS.md`, which maps each + operation onto its MCP tool; 2. a script that needs `gh` says so in a way that names the alternative, rather than letting an empty command substitution be read as a real answer. """ @@ -25,6 +26,11 @@ ACCESS_PAGE = SKILLS / "GITHUB_ACCESS.md" GH_HELPER = BRAIN_HOME / "bin" / "_gh.sh" +# PyAutoMind ships skills too, and its bodies drive `gh` for the same reasons. +# The page lives here, so this repo's suite is the only one positioned to guard +# both; when the sibling is not checked out that leg simply does not run. +MIND_SKILLS = BRAIN_HOME.parent / "PyAutoMind" / "skills" + # A real `gh` invocation, not the word in prose. GH_CALL = re.compile(r'\bgh (pr|api|issue|run|repo|workflow|auth|search|release)\b') @@ -32,6 +38,15 @@ EXEMPT = {"GITHUB_ACCESS.md", "COMMANDS.md", "WORKFLOW.md", "OPERATIONS.md"} +def _skill_bodies(): + """Every skill markdown body in the workspace, as (repo_home, path) pairs.""" + for root in (SKILLS, MIND_SKILLS): + if not root.is_dir(): + continue # sibling not checked out here + for path in sorted(root.glob("*/*.md")): + yield root.parent, path + + def _gh_free_env(tmp_path): """A PATH with the tools these scripts call, minus `gh`.""" lean = tmp_path / "nogh" @@ -60,14 +75,14 @@ def test_access_page_ships_and_maps_the_operations(): def test_every_gh_driving_skill_points_at_the_mapping(): offenders = [] - for path in sorted(SKILLS.glob("*/*.md")): + for home, path in _skill_bodies(): if path.name in EXEMPT: continue text = path.read_text(encoding="utf-8", errors="replace") if not GH_CALL.search(text): continue if "GITHUB_ACCESS.md" not in text: - offenders.append(str(path.relative_to(BRAIN_HOME))) + offenders.append(str(path.relative_to(home.parent))) assert not offenders, ( "these drive `gh` without telling a gh-less session what to do " "instead — add a pointer to skills/GITHUB_ACCESS.md:\n " @@ -77,11 +92,17 @@ def test_every_gh_driving_skill_points_at_the_mapping(): def test_every_pointer_resolves(): broken = [] - for path in sorted(SKILLS.glob("*/*.md")): + for home, path in _skill_bodies(): text = path.read_text(encoding="utf-8", errors="replace") + name = path.relative_to(home.parent) + # A same-repo pointer is a relative markdown link. for rel in re.findall(r'\]\((\.\.?/[^)]*GITHUB_ACCESS\.md)\)', text): if not (path.parent / rel).is_file(): - broken.append(f"{path.relative_to(BRAIN_HOME)} -> {rel}") + broken.append(f"{name} -> {rel}") + # A cross-repo one is a bare workspace path; ../.. would be noise. + for rel in re.findall(r'(? {rel}") assert not broken, broken