Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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)

Expand Down
41 changes: 39 additions & 2 deletions skills/GITHUB_ACCESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<owner>/<repo>` | `403` — "GitHub access is not enabled for this session" |
| `gh api repos/<owner>/<repo>/pulls` | `403` — same |
| `gh api repos/<owner>/<repo>/issues` | `403` — same |
| `gh api repos/<owner>/<repo>/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 |
Expand Down
33 changes: 27 additions & 6 deletions tests/test_gh_surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand All @@ -25,13 +26,27 @@
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')

# Shared prose pages describe the surface rather than driving it.
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"
Expand Down Expand Up @@ -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 "
Expand All @@ -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'(?<![(/\w])((?:\w[\w.-]*/)+GITHUB_ACCESS\.md)', text):
if not (home.parent / rel).is_file():
broken.append(f"{name} -> {rel}")
assert not broken, broken


Expand Down
Loading