From 841e82bb49a24febf1474eeb61d4de35d2de288c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 01:45:34 +0000 Subject: [PATCH 1/2] docs: put the GitHub-surface fact where every session already reads it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remote sessions have no `gh`, and skills that drive `gh` already point at skills/GITHUB_ACCESS.md — but only skill bodies do. An ordinary chat that never loads one of those skills discovers the problem by running `gh` and getting `command not found`, every time. Add the fact as a third bullet in the auto-loaded AGENTS.md remote-sessions block, alongside the pytest and session_bootstrap lines, so it is in context before anyone reaches for `gh`. Also record why installing `gh` is not the fix, measured in a remote session: `apt-get install -y gh` succeeds and `gh api user` works, but every repo-scoped call 403s — 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. GraphQL being pinned means `gh` cannot be a drop-in even if an org admin connected the GitHub App, so the page's existing "do not install gh" now carries the reason rather than asking to be taken on trust. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NHQzyeUkEHqMmEZDo9zsNX --- AGENTS.md | 13 ++++++++++++- skills/GITHUB_ACCESS.md | 41 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 51 insertions(+), 3 deletions(-) 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 | From 4cf85e7284582e413f90cd4a96abbaa772637a65 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 01:50:47 +0000 Subject: [PATCH 2/2] =?UTF-8?q?test:=20guard=20the=20gh=E2=86=92MCP=20poin?= =?UTF-8?q?ter=20across=20PyAutoMind's=20skills=20too?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard walked PyAutoBrain/skills only, so the one gh-driving skill body outside it — PyAutoMind's create_issue — went uncovered and drifted. The mapping page lives here, so this suite is the only one positioned to guard both roots; walk PyAutoMind/skills as well, skipping that leg when the sibling is not checked out (the convention test_hygiene_conductor.py already uses for repos.yaml). Pointer resolution now checks both spellings: the relative markdown link a same-repo body uses, and the bare workspace path a cross-repo one needs (../../../PyAutoBrain/... would be noise). Offenders are reported repo-qualified so the path is actionable from the workspace root. Verified by regression: removing create_issue's pointer, and typo'ing its path, each fail the suite. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NHQzyeUkEHqMmEZDo9zsNX --- tests/test_gh_surface.py | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) 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