graphban-cli 0.3.0__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {graphban_cli-0.3.0 → graphban_cli-0.4.0}/PKG-INFO +21 -12
- {graphban_cli-0.3.0 → graphban_cli-0.4.0}/README.md +20 -11
- {graphban_cli-0.3.0 → graphban_cli-0.4.0}/pyproject.toml +1 -1
- {graphban_cli-0.3.0 → graphban_cli-0.4.0}/src/gban/cli.py +13 -8
- {graphban_cli-0.3.0 → graphban_cli-0.4.0}/src/gban/setup.py +313 -53
- {graphban_cli-0.3.0 → graphban_cli-0.4.0}/src/gban/skills/graphban-delegation/SKILL.md +16 -4
- {graphban_cli-0.3.0 → graphban_cli-0.4.0}/tests/test_setup.py +239 -5
- {graphban_cli-0.3.0 → graphban_cli-0.4.0}/.gitignore +0 -0
- {graphban_cli-0.3.0 → graphban_cli-0.4.0}/LICENSE +0 -0
- {graphban_cli-0.3.0 → graphban_cli-0.4.0}/src/gban/__init__.py +0 -0
- {graphban_cli-0.3.0 → graphban_cli-0.4.0}/src/gban/client.py +0 -0
- {graphban_cli-0.3.0 → graphban_cli-0.4.0}/src/gban/config.py +0 -0
- {graphban_cli-0.3.0 → graphban_cli-0.4.0}/src/gban/doctor.py +0 -0
- {graphban_cli-0.3.0 → graphban_cli-0.4.0}/src/gban/swamp.py +0 -0
- {graphban_cli-0.3.0 → graphban_cli-0.4.0}/tests/test_acts.py +0 -0
- {graphban_cli-0.3.0 → graphban_cli-0.4.0}/tests/test_doctor.py +0 -0
- {graphban_cli-0.3.0 → graphban_cli-0.4.0}/tests/test_login_projects.py +0 -0
- {graphban_cli-0.3.0 → graphban_cli-0.4.0}/tests/test_packaging.py +0 -0
- {graphban_cli-0.3.0 → graphban_cli-0.4.0}/tests/test_session.py +0 -0
- {graphban_cli-0.3.0 → graphban_cli-0.4.0}/tests/test_swamp.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: graphban-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: gban — a Graphban client for the human at a terminal
|
|
5
5
|
Project-URL: Homepage, https://github.com/asc-me/graphban
|
|
6
6
|
Project-URL: Repository, https://github.com/asc-me/graphban
|
|
@@ -127,28 +127,37 @@ is named in the refusal when it exists — logging in once inside one project mu
|
|
|
127
127
|
mint a credential for it while you are standing in another repository, and a key in the wrong
|
|
128
128
|
project is not a mistake anybody notices quickly.
|
|
129
129
|
|
|
130
|
-
`setup` mints a project-scoped credential, writes the `graphban` and `gbfleet` MCP entries
|
|
131
|
-
|
|
132
|
-
and then **verifies** rather than
|
|
133
|
-
|
|
130
|
+
`setup` mints a project-scoped credential, writes the `graphban` and `gbfleet` MCP entries
|
|
131
|
+
into every parent harness that would actually read them, installs the supervisor if it is
|
|
132
|
+
missing, drops the delegation skill into `.claude/skills/`, and then **verifies** rather than
|
|
133
|
+
asserting: it asks the new credential what it can actually see. Restart the session that will
|
|
134
|
+
call `delegate`/`spawn` afterwards — MCP servers are read at startup.
|
|
134
135
|
|
|
135
136
|
Three properties worth knowing, each of which is a bug this command exists to not have:
|
|
136
137
|
|
|
137
138
|
- **The credential does not expire.** `gban keys mint` produces a *wave* key, which lasts a
|
|
138
139
|
day (`FLEET_KEY_DAYS`); that is right for a wave and wrong for a project. Only seats expire.
|
|
139
|
-
- **It writes where the harness will actually read.**
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
140
|
+
- **It writes where the harness will actually read.** There is more than one. Claude Code
|
|
141
|
+
reads `~/.claude.json`'s per-project `mcpServers` (JSON), which outranks a repository
|
|
142
|
+
`.mcp.json` — writing the repository file under a stale entry leaves the agent on the old
|
|
143
|
+
key, and surfaces as a JSON parse error because the harness is parsing a 401 body. Grok
|
|
144
|
+
reads `~/.grok/config.toml`'s `mcp_servers` (TOML, snake_case; `mcpServers` parses and
|
|
145
|
+
loads nothing). Writing only Claude's file while Grok holds a different key reports
|
|
146
|
+
success and leaves `delegate` unadvertised (GRPH-825). Grok's `gbfleet` MCP also
|
|
147
|
+
gets `--workspace ~/.grok/gbfleet-wt/<repo>`: the sibling default is a path Grok's
|
|
148
|
+
sandbox cannot write, and spawn then dies as `git worktree add` 128 (GRPH-826).
|
|
149
|
+
Claude keeps the sibling default. `--scope user` is the default because a credential
|
|
150
|
+
outside the repository cannot be committed. Re-running a working setup still
|
|
151
|
+
*repairs* a missing `gbfleet` entry and a missing `--workspace`; reuse skips a mint,
|
|
152
|
+
never a write.
|
|
144
153
|
- **`--auto` matches, and says so.** A project carries no repository link — no remote, no
|
|
145
154
|
path — so `--auto` compares your project ids and names against this directory, what is in
|
|
146
155
|
it, and its siblings. One level, never a recursive walk. Two directories answering to one
|
|
147
156
|
project, or one directory answering to two, are **refused rather than guessed**: a
|
|
148
157
|
credential minted into the wrong repository is not a mistake anybody notices quickly.
|
|
149
158
|
- **It refuses to write a key into a file git tracks.** `--scope project` on a tracked
|
|
150
|
-
`.mcp.json` is refused rather than warned about, because a warning
|
|
151
|
-
credential still commits it.
|
|
159
|
+
`.mcp.json` or `.grok/config.toml` is refused rather than warned about, because a warning
|
|
160
|
+
attached to committing a credential still commits it.
|
|
152
161
|
|
|
153
162
|
## Wiring a checkout to Swamp
|
|
154
163
|
|
|
@@ -112,28 +112,37 @@ is named in the refusal when it exists — logging in once inside one project mu
|
|
|
112
112
|
mint a credential for it while you are standing in another repository, and a key in the wrong
|
|
113
113
|
project is not a mistake anybody notices quickly.
|
|
114
114
|
|
|
115
|
-
`setup` mints a project-scoped credential, writes the `graphban` and `gbfleet` MCP entries
|
|
116
|
-
|
|
117
|
-
and then **verifies** rather than
|
|
118
|
-
|
|
115
|
+
`setup` mints a project-scoped credential, writes the `graphban` and `gbfleet` MCP entries
|
|
116
|
+
into every parent harness that would actually read them, installs the supervisor if it is
|
|
117
|
+
missing, drops the delegation skill into `.claude/skills/`, and then **verifies** rather than
|
|
118
|
+
asserting: it asks the new credential what it can actually see. Restart the session that will
|
|
119
|
+
call `delegate`/`spawn` afterwards — MCP servers are read at startup.
|
|
119
120
|
|
|
120
121
|
Three properties worth knowing, each of which is a bug this command exists to not have:
|
|
121
122
|
|
|
122
123
|
- **The credential does not expire.** `gban keys mint` produces a *wave* key, which lasts a
|
|
123
124
|
day (`FLEET_KEY_DAYS`); that is right for a wave and wrong for a project. Only seats expire.
|
|
124
|
-
- **It writes where the harness will actually read.**
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
125
|
+
- **It writes where the harness will actually read.** There is more than one. Claude Code
|
|
126
|
+
reads `~/.claude.json`'s per-project `mcpServers` (JSON), which outranks a repository
|
|
127
|
+
`.mcp.json` — writing the repository file under a stale entry leaves the agent on the old
|
|
128
|
+
key, and surfaces as a JSON parse error because the harness is parsing a 401 body. Grok
|
|
129
|
+
reads `~/.grok/config.toml`'s `mcp_servers` (TOML, snake_case; `mcpServers` parses and
|
|
130
|
+
loads nothing). Writing only Claude's file while Grok holds a different key reports
|
|
131
|
+
success and leaves `delegate` unadvertised (GRPH-825). Grok's `gbfleet` MCP also
|
|
132
|
+
gets `--workspace ~/.grok/gbfleet-wt/<repo>`: the sibling default is a path Grok's
|
|
133
|
+
sandbox cannot write, and spawn then dies as `git worktree add` 128 (GRPH-826).
|
|
134
|
+
Claude keeps the sibling default. `--scope user` is the default because a credential
|
|
135
|
+
outside the repository cannot be committed. Re-running a working setup still
|
|
136
|
+
*repairs* a missing `gbfleet` entry and a missing `--workspace`; reuse skips a mint,
|
|
137
|
+
never a write.
|
|
129
138
|
- **`--auto` matches, and says so.** A project carries no repository link — no remote, no
|
|
130
139
|
path — so `--auto` compares your project ids and names against this directory, what is in
|
|
131
140
|
it, and its siblings. One level, never a recursive walk. Two directories answering to one
|
|
132
141
|
project, or one directory answering to two, are **refused rather than guessed**: a
|
|
133
142
|
credential minted into the wrong repository is not a mistake anybody notices quickly.
|
|
134
143
|
- **It refuses to write a key into a file git tracks.** `--scope project` on a tracked
|
|
135
|
-
`.mcp.json` is refused rather than warned about, because a warning
|
|
136
|
-
credential still commits it.
|
|
144
|
+
`.mcp.json` or `.grok/config.toml` is refused rather than warned about, because a warning
|
|
145
|
+
attached to committing a credential still commits it.
|
|
137
146
|
|
|
138
147
|
## Wiring a checkout to Swamp
|
|
139
148
|
|
|
@@ -56,14 +56,17 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
56
56
|
setup_cmd = sub.add_parser(
|
|
57
57
|
"setup", help="enable delegation on a project",
|
|
58
58
|
description=("Mints a project-scoped credential that does NOT expire, writes the "
|
|
59
|
-
"graphban and gbfleet MCP entries
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"
|
|
59
|
+
"graphban and gbfleet MCP entries into Claude Code (~/.claude.json) and "
|
|
60
|
+
"Grok (~/.grok/config.toml, TOML mcp_servers) — every file a parent "
|
|
61
|
+
"harness will actually read — and installs the supervisor. Only seats "
|
|
62
|
+
"expire; a credential that died overnight would make 'delegation is set "
|
|
63
|
+
"up' quietly stop being true. Re-running is safe: a working key is "
|
|
64
|
+
"reused, a missing gbfleet entry is still repaired."))
|
|
63
65
|
setup_cmd.add_argument(
|
|
64
66
|
"--scope", choices=["user", "project"], default="user",
|
|
65
|
-
help="user (default) writes ~/.claude.json,
|
|
66
|
-
"
|
|
67
|
+
help="user (default) writes ~/.claude.json and, when it exists, ~/.grok/config.toml; "
|
|
68
|
+
"project writes .mcp.json and .grok/config.toml beside the repo. A user file "
|
|
69
|
+
"outranks the repo copy and cannot be committed")
|
|
67
70
|
setup_cmd.add_argument(
|
|
68
71
|
"--auto", action="store_true",
|
|
69
72
|
help="every project with a repository here: matches this directory, what is in it and "
|
|
@@ -310,7 +313,8 @@ def cmd_setup(args) -> int:
|
|
|
310
313
|
if code == 0:
|
|
311
314
|
human.append(f"\n delegation is enabled on {project}. An agent can now "
|
|
312
315
|
f"`delegate(id=…, lane=…, tier=…, seat=true)` then `spawn`.")
|
|
313
|
-
human.append(
|
|
316
|
+
human.append(" restart Claude Code and/or Grok — whichever session will call "
|
|
317
|
+
"`delegate`/`spawn` — so it reads the new config.")
|
|
314
318
|
_out({"lines": lines, "ok": code == 0, "project": project, **made},
|
|
315
319
|
"\n".join(human), args.as_json)
|
|
316
320
|
return code
|
|
@@ -340,7 +344,8 @@ def _setup_auto(args, url: str, client) -> int:
|
|
|
340
344
|
done[pid], worst = {"repo": str(repo), "ok": code == 0, **made}, max(worst, code)
|
|
341
345
|
human = [doctor_mod.render(lines),
|
|
342
346
|
f"\n {sum(1 for d in done.values() if d['ok'])} of {len(done)} enabled. "
|
|
343
|
-
|
|
347
|
+
"Restart Claude Code and/or Grok — whichever session will call "
|
|
348
|
+
"`delegate`/`spawn` — so it reads the new config."]
|
|
344
349
|
_out({"lines": lines, "ok": worst == 0, "projects": done}, "\n".join(human), args.as_json)
|
|
345
350
|
return worst
|
|
346
351
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Enable delegation on a project (GRPH-792).
|
|
1
|
+
"""Enable delegation on a project (GRPH-792, GRPH-825).
|
|
2
2
|
|
|
3
3
|
Between "logged in" and "an agent can hand an item to a child" sit three mechanical acts: mint
|
|
4
4
|
a credential, put it where the harness will read it, and install the supervisor. None of the
|
|
@@ -13,18 +13,26 @@ a wave, and a credential that dies overnight turns "delegation is set up" into a
|
|
|
13
13
|
silently stops being true. The seat is the object with a TTL — thirty minutes, single use — and
|
|
14
14
|
that is where expiry belongs.
|
|
15
15
|
|
|
16
|
-
**Where the entry is written is a correctness question, not a preference.**
|
|
17
|
-
`~/.claude.json`'s per-project `mcpServers` in
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
16
|
+
**Where the entry is written is a correctness question, not a preference.** There is more than
|
|
17
|
+
one parent harness. Claude Code reads `~/.claude.json`'s per-project `mcpServers` (JSON) in
|
|
18
|
+
preference to a repository's `.mcp.json`. Grok reads `~/.grok/config.toml`'s `mcp_servers`
|
|
19
|
+
(TOML, snake_case — `mcpServers` parses and loads nothing, GRPH-575). Writing only Claude's
|
|
20
|
+
file while a Grok session holds a different key reports success and leaves `delegate`
|
|
21
|
+
unadvertised, which the agent reads as the tool not existing (GRPH-825). So setup writes
|
|
22
|
+
every file that would actually be read, and verifies a key from those files — not only the
|
|
23
|
+
Claude one.
|
|
24
|
+
|
|
25
|
+
A working credential that is missing `gbfleet` is not left alone. Reuse skips a *mint*, never
|
|
26
|
+
a write: a PASS report with no supervisor server is a lie.
|
|
22
27
|
"""
|
|
23
28
|
from __future__ import annotations
|
|
24
29
|
|
|
25
30
|
import json
|
|
26
31
|
import os
|
|
32
|
+
import re
|
|
27
33
|
import subprocess
|
|
34
|
+
import tomllib
|
|
35
|
+
from dataclasses import dataclass
|
|
28
36
|
from pathlib import Path
|
|
29
37
|
|
|
30
38
|
from gban import config, doctor as doctor_mod
|
|
@@ -48,6 +56,46 @@ def claude_home() -> Path:
|
|
|
48
56
|
return Path(os.environ.get("CLAUDE_CONFIG_PATH") or (Path.home() / ".claude.json"))
|
|
49
57
|
|
|
50
58
|
|
|
59
|
+
def grok_home() -> Path:
|
|
60
|
+
"""Grok's user MCP file. `GROK_CONFIG_PATH` is the test seam, matching `CLAUDE_CONFIG_PATH`.
|
|
61
|
+
|
|
62
|
+
Grok itself has no such env; the default path is what it actually reads.
|
|
63
|
+
"""
|
|
64
|
+
return Path(os.environ.get("GROK_CONFIG_PATH") or (Path.home() / ".grok" / "config.toml"))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def grok_workspace(repo: Path, grok_config: Path | None = None) -> Path:
|
|
68
|
+
"""Where Grok's gbfleet MCP cuts worktrees (GRPH-826).
|
|
69
|
+
|
|
70
|
+
Grok's `workspace` sandbox can write `~/.grok/` and the repository, and cannot write a
|
|
71
|
+
sibling of the repo. gbfleet's default (`<repo>-gbfleet`) is that sibling — right for
|
|
72
|
+
Claude, unusable here. This path sits next to Grok's config, outside git.
|
|
73
|
+
"""
|
|
74
|
+
return (grok_config or grok_home()).resolve().parent / "gbfleet-wt" / repo.name
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def workspace_writable(path: Path) -> tuple[bool, str]:
|
|
78
|
+
"""The same probe `gbfleet doctor` uses. Setup must not PASS a dest doctor would FAIL."""
|
|
79
|
+
try:
|
|
80
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
probe = path / ".gbfleet-setup"
|
|
82
|
+
probe.write_text("x", encoding="utf-8")
|
|
83
|
+
probe.unlink()
|
|
84
|
+
return True, ""
|
|
85
|
+
except OSError as exc:
|
|
86
|
+
return False, str(exc)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class Dest:
|
|
91
|
+
"""One file a parent harness will actually read."""
|
|
92
|
+
|
|
93
|
+
harness: str
|
|
94
|
+
scope: str
|
|
95
|
+
path: Path
|
|
96
|
+
dialect: str # "json" | "toml"
|
|
97
|
+
|
|
98
|
+
|
|
51
99
|
def shadowing_entry(repo: Path, home: Path | None = None) -> bool:
|
|
52
100
|
"""Does the user file already declare our server for this repository?
|
|
53
101
|
|
|
@@ -92,18 +140,61 @@ def target(repo: Path, scope: str, home: Path | None = None) -> tuple[str, Path,
|
|
|
92
140
|
return "user", user, ""
|
|
93
141
|
|
|
94
142
|
|
|
143
|
+
def destinations(repo: Path, scope: str, home: Path, grok: Path) -> tuple[list[Dest], list[dict]]:
|
|
144
|
+
"""Every parent-harness file that would actually be read, plus findings about the ones
|
|
145
|
+
that will not.
|
|
146
|
+
|
|
147
|
+
Claude is always a destination (created if missing) — that is the original setup. Grok's
|
|
148
|
+
user file is a destination only when it already exists: creating `~/.grok/config.toml` for
|
|
149
|
+
a machine that has never run Grok would be inventing a harness. `--scope project` also
|
|
150
|
+
writes `repo/.grok/config.toml` unless git tracks it, the same refusal as `.mcp.json`.
|
|
151
|
+
"""
|
|
152
|
+
lines: list[dict] = []
|
|
153
|
+
dests: list[Dest] = []
|
|
154
|
+
|
|
155
|
+
claude_scope, claude_path, why = target(repo, scope, home)
|
|
156
|
+
if why:
|
|
157
|
+
lines.append(_line("config", UNKNOWN, "scope", why))
|
|
158
|
+
if claude_scope == "project" and tracked_by_git(claude_path):
|
|
159
|
+
lines.append(_line("config", FAIL, "target",
|
|
160
|
+
f"git tracks {claude_path} — writing a key there would commit it. "
|
|
161
|
+
"Re-run with --scope user, which writes outside the repository"))
|
|
162
|
+
else:
|
|
163
|
+
dests.append(Dest("claude", claude_scope, claude_path, "json"))
|
|
164
|
+
|
|
165
|
+
if grok.exists():
|
|
166
|
+
dests.append(Dest("grok", "user", grok, "toml"))
|
|
167
|
+
|
|
168
|
+
if scope == "project":
|
|
169
|
+
grok_proj = repo / ".grok" / "config.toml"
|
|
170
|
+
if tracked_by_git(grok_proj):
|
|
171
|
+
lines.append(_line("config", FAIL, "target",
|
|
172
|
+
f"git tracks {grok_proj} — writing a key there would commit it. "
|
|
173
|
+
"Re-run with --scope user, which writes ~/.grok/config.toml"))
|
|
174
|
+
elif not any(d.path == grok_proj for d in dests):
|
|
175
|
+
dests.append(Dest("grok", "project", grok_proj, "toml"))
|
|
176
|
+
return dests, lines
|
|
177
|
+
|
|
178
|
+
|
|
95
179
|
# ---- the entries ----------------------------------------------------------------------------
|
|
96
180
|
|
|
97
|
-
def entries(url: str, project: str, key: str, repo: Path
|
|
181
|
+
def entries(url: str, project: str, key: str, repo: Path,
|
|
182
|
+
workspace: Path | None = None) -> dict:
|
|
98
183
|
"""The two servers a delegating planner holds: the ledger over HTTP, which arbitrates, and
|
|
99
184
|
the supervisor over stdio, which does not. The supervisor takes the credential through its
|
|
100
|
-
environment rather than its arguments, because `ps` is world-readable.
|
|
185
|
+
environment rather than its arguments, because `ps` is world-readable.
|
|
186
|
+
|
|
187
|
+
`workspace` is Grok-only (GRPH-826). Claude can write gbfleet's sibling default; Grok's
|
|
188
|
+
sandbox cannot. Passing it for Claude would move Claude's worktrees for no reason.
|
|
189
|
+
"""
|
|
190
|
+
args = ["mcp", "--repo", str(repo), "--server", url.rstrip("/"), "--project", project]
|
|
191
|
+
if workspace is not None:
|
|
192
|
+
args += ["--workspace", str(workspace)]
|
|
101
193
|
return {
|
|
102
194
|
LEDGER_SERVER: {"type": "http", "url": url.rstrip("/") + "/api/mcp",
|
|
103
195
|
"headers": {"X-API-Key": key}},
|
|
104
196
|
FLEET_SERVER: {"command": "gbfleet",
|
|
105
|
-
"args":
|
|
106
|
-
"--project", project],
|
|
197
|
+
"args": args,
|
|
107
198
|
"env": {doctor_mod.SUPERVISOR_KEY_ENV: key}},
|
|
108
199
|
}
|
|
109
200
|
|
|
@@ -127,6 +218,130 @@ def write_entries(path: Path, scope: str, repo: Path, servers: dict) -> None:
|
|
|
127
218
|
path.write_text(json.dumps(blob, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
128
219
|
|
|
129
220
|
|
|
221
|
+
_TABLE_HEADER = re.compile(r"^\[([^\[\]]+)\]\s*$")
|
|
222
|
+
_BARE_TOML_KEY = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _toml_string(value: str) -> str:
|
|
226
|
+
"""A TOML basic string. The api key and url arrive from the server; a `"` in either
|
|
227
|
+
would emit a file Grok fails to parse, which reads as having no tools."""
|
|
228
|
+
out = ['"']
|
|
229
|
+
for ch in value:
|
|
230
|
+
if ch == '"':
|
|
231
|
+
out.append('\\"')
|
|
232
|
+
elif ch == "\\":
|
|
233
|
+
out.append("\\\\")
|
|
234
|
+
elif ch == "\n":
|
|
235
|
+
out.append("\\n")
|
|
236
|
+
elif ch < " " or ch == "\x7f":
|
|
237
|
+
out.append(f"\\u{ord(ch):04X}")
|
|
238
|
+
else:
|
|
239
|
+
out.append(ch)
|
|
240
|
+
out.append('"')
|
|
241
|
+
return "".join(out)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _drop_grok_servers(text: str, names: set[str]) -> str:
|
|
245
|
+
"""Strip `[mcp_servers.<name>]` and nested subtables, leaving every other table.
|
|
246
|
+
|
|
247
|
+
Split on lines that *start* a table. `args = ["mcp", ...]` contains `[` and is not a
|
|
248
|
+
header — walking until the next `[` would stop in the middle of the array and leave garbage.
|
|
249
|
+
"""
|
|
250
|
+
out: list[str] = []
|
|
251
|
+
skipping = False
|
|
252
|
+
for line in text.splitlines(keepends=True):
|
|
253
|
+
matched = _TABLE_HEADER.match(line)
|
|
254
|
+
if matched:
|
|
255
|
+
parts = matched.group(1).split(".")
|
|
256
|
+
skipping = (len(parts) >= 2 and parts[0] == "mcp_servers" and parts[1] in names)
|
|
257
|
+
if not skipping:
|
|
258
|
+
out.append(line)
|
|
259
|
+
return "".join(out)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _render_grok_server(name: str, spec: dict) -> str:
|
|
263
|
+
table = f"mcp_servers.{name}"
|
|
264
|
+
if spec.get("url"):
|
|
265
|
+
lines = [f"[{table}]", f"url = {_toml_string(str(spec['url']))}", "enabled = true"]
|
|
266
|
+
headers = spec.get("headers") or {}
|
|
267
|
+
if headers:
|
|
268
|
+
lines.append("")
|
|
269
|
+
lines.append(f"[{table}.headers]")
|
|
270
|
+
for header, value in headers.items():
|
|
271
|
+
key = header if _BARE_TOML_KEY.match(header) else _toml_string(header)
|
|
272
|
+
lines.append(f"{key} = {_toml_string(str(value))}")
|
|
273
|
+
return "\n".join(lines)
|
|
274
|
+
lines = [f"[{table}]"]
|
|
275
|
+
if spec.get("command"):
|
|
276
|
+
lines.append(f"command = {_toml_string(str(spec['command']))}")
|
|
277
|
+
args = spec.get("args") or []
|
|
278
|
+
if args:
|
|
279
|
+
lines.append("args = [" + ", ".join(_toml_string(str(a)) for a in args) + "]")
|
|
280
|
+
env = spec.get("env") or {}
|
|
281
|
+
if env:
|
|
282
|
+
inner = ", ".join(f"{k} = {_toml_string(str(v))}" for k, v in env.items())
|
|
283
|
+
lines.append(f"env = {{ {inner} }}")
|
|
284
|
+
lines.append("enabled = true")
|
|
285
|
+
return "\n".join(lines)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def write_grok_toml(path: Path, servers: dict) -> None:
|
|
289
|
+
"""Merge our two servers into Grok's user/project config without rewriting the rest.
|
|
290
|
+
|
|
291
|
+
The file holds UI, hooks, sandbox, and other people's servers. `json.dumps` of a parsed
|
|
292
|
+
blob would drop comments and turn `mcp_servers` into the wrong shape. Replace only the
|
|
293
|
+
tables we own.
|
|
294
|
+
"""
|
|
295
|
+
try:
|
|
296
|
+
text = path.read_text(encoding="utf-8")
|
|
297
|
+
except OSError:
|
|
298
|
+
text = ""
|
|
299
|
+
text = _drop_grok_servers(text, set(servers)).rstrip()
|
|
300
|
+
blocks = [_render_grok_server(name, spec) for name, spec in servers.items()]
|
|
301
|
+
body = ("\n\n".join(blocks) + "\n") if not text else (text + "\n\n" + "\n\n".join(blocks) + "\n")
|
|
302
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
303
|
+
path.write_text(body, encoding="utf-8")
|
|
304
|
+
os.chmod(path, 0o600)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def write_dest(dest: Dest, repo: Path, servers: dict) -> None:
|
|
308
|
+
if dest.dialect == "toml":
|
|
309
|
+
write_grok_toml(dest.path, servers)
|
|
310
|
+
return
|
|
311
|
+
write_entries(dest.path, dest.scope, repo, servers)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def grok_key(path: Path) -> str:
|
|
315
|
+
try:
|
|
316
|
+
blob = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
317
|
+
except (OSError, ValueError, tomllib.TOMLDecodeError):
|
|
318
|
+
return ""
|
|
319
|
+
server = ((blob.get("mcp_servers") or {}).get(LEDGER_SERVER) or {})
|
|
320
|
+
return str(((server.get("headers") or {}).get("X-API-Key")) or "")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def grok_sandbox_profile(path: Path) -> str:
|
|
324
|
+
"""Grok's `[sandbox] profile`, or "" when unset or `off` (GRPH-838).
|
|
325
|
+
|
|
326
|
+
Read for what it does to CHILDREN: the profile is applied to the Grok process, `gbfleet
|
|
327
|
+
mcp` is its child, and every vendor spawned from there inherits it. Setup cannot fix that
|
|
328
|
+
— it is the operator's sandbox — but a setup that reports PASS and leaves the first wave
|
|
329
|
+
to die at exit 1 with four different vendor errors is the silence this line replaces.
|
|
330
|
+
"""
|
|
331
|
+
try:
|
|
332
|
+
blob = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
333
|
+
except (OSError, ValueError, tomllib.TOMLDecodeError):
|
|
334
|
+
return ""
|
|
335
|
+
profile = str(((blob.get("sandbox") or {}).get("profile")) or "")
|
|
336
|
+
return "" if profile == "off" else profile
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def dest_key(dest: Dest, repo: Path) -> str:
|
|
340
|
+
if dest.dialect == "toml":
|
|
341
|
+
return grok_key(dest.path)
|
|
342
|
+
return existing_key(dest.path, dest.scope, repo)
|
|
343
|
+
|
|
344
|
+
|
|
130
345
|
# ---- the ledger side -------------------------------------------------------------------------
|
|
131
346
|
|
|
132
347
|
def mint(client: Client, project: str, label: str = "") -> dict:
|
|
@@ -165,8 +380,7 @@ def verify(url: str, key: str, project: str) -> list[dict]:
|
|
|
165
380
|
f"{project} is writable" if project in writable else
|
|
166
381
|
f"the key cannot write {project} — you can read it but not write it, so "
|
|
167
382
|
"an agent on this key can claim nothing"))
|
|
168
|
-
missing = {t.get("
|
|
169
|
-
for t in (ctx.get("missing_tiers") or [])}
|
|
383
|
+
missing = {_tier_id(t) for t in (ctx.get("missing_tiers") or [])}
|
|
170
384
|
out.append(_line("ledger", PASS if "fleet" not in missing else FAIL, "fleet tools",
|
|
171
385
|
"delegate and mint_enrolment are in the manifest" if "fleet" not in missing
|
|
172
386
|
else "the fleet tier is missing, so `delegate` is not advertised — an "
|
|
@@ -174,6 +388,14 @@ def verify(url: str, key: str, project: str) -> list[dict]:
|
|
|
174
388
|
return out
|
|
175
389
|
|
|
176
390
|
|
|
391
|
+
def _tier_id(t) -> str:
|
|
392
|
+
"""Live `get_context` spells it `tier`. A mock that used `name` made verify PASS against
|
|
393
|
+
a key that could not see `delegate` — the Grok case, wearing test clothes (GRPH-825)."""
|
|
394
|
+
if isinstance(t, dict):
|
|
395
|
+
return str(t.get("tier") or t.get("name") or "")
|
|
396
|
+
return str(t)
|
|
397
|
+
|
|
398
|
+
|
|
177
399
|
def existing_key(path: Path, scope: str, repo: Path) -> str:
|
|
178
400
|
"""The key already configured here, if any. Idempotence rests on this: a second run that
|
|
179
401
|
re-minted would leave a live orphan credential behind on every invocation."""
|
|
@@ -189,7 +411,8 @@ def existing_key(path: Path, scope: str, repo: Path) -> str:
|
|
|
189
411
|
# ---- the act ---------------------------------------------------------------------------------
|
|
190
412
|
|
|
191
413
|
def run(client: Client, url: str, project: str, repo: Path, *, scope: str = "user",
|
|
192
|
-
install: bool = True, home: Path | None = None
|
|
414
|
+
install: bool = True, home: Path | None = None,
|
|
415
|
+
grok: Path | None = None) -> tuple[list[dict], int, dict]:
|
|
193
416
|
"""Enable delegation, and report what is now true rather than what was attempted."""
|
|
194
417
|
lines: list[dict] = []
|
|
195
418
|
if not is_repo(repo):
|
|
@@ -201,58 +424,95 @@ def run(client: Client, url: str, project: str, repo: Path, *, scope: str = "use
|
|
|
201
424
|
lines.append(_line("config", UNKNOWN, "repository",
|
|
202
425
|
f"{repo} is not a git repository — the ledger half is fine, but "
|
|
203
426
|
"gbfleet cuts a worktree per child and will refuse here"))
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
if scope == "project" and tracked_by_git(path):
|
|
209
|
-
# REFUSED, not warned. This command's whole job is to put a credential in a file; a
|
|
210
|
-
# version-controlled target turns that into "commit a key", and doing it with a
|
|
211
|
-
# warning attached still does it.
|
|
212
|
-
lines.append(_line("config", FAIL, "target",
|
|
213
|
-
f"git tracks {path} — writing a key there would commit it. "
|
|
214
|
-
"Re-run with --scope user, which writes outside the repository"))
|
|
427
|
+
dests, dest_lines = destinations(repo, scope, home or claude_home(), grok or grok_home())
|
|
428
|
+
lines += dest_lines
|
|
429
|
+
if not dests:
|
|
215
430
|
return lines, 1, {}
|
|
216
431
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
432
|
+
seen: list[str] = []
|
|
433
|
+
for dest in dests:
|
|
434
|
+
found = dest_key(dest, repo)
|
|
435
|
+
if found and found not in seen:
|
|
436
|
+
seen.append(found)
|
|
437
|
+
|
|
438
|
+
key, reused, minted_id = "", False, None
|
|
439
|
+
for candidate in seen:
|
|
440
|
+
checks = verify(url, candidate, project)
|
|
221
441
|
if all(c["status"] == PASS for c in checks):
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
lines += skill(repo)
|
|
226
|
-
lines += supervisor(install)
|
|
227
|
-
worst = max((doctor_mod.SEVERITY[l["status"]] for l in lines), default=0)
|
|
228
|
-
return lines, (1 if worst == doctor_mod.SEVERITY[FAIL] else 0), {"reused": True}
|
|
442
|
+
key, reused = candidate, True
|
|
443
|
+
break
|
|
444
|
+
if seen and not reused:
|
|
229
445
|
# A key that cannot do the job is replaced, never patched: `tool_tiers` are fixed at
|
|
230
446
|
# mint and there is no route that changes them.
|
|
231
447
|
lines.append(_line("config", UNKNOWN, "credential",
|
|
232
448
|
"the configured key cannot delegate, so a new one is being minted; "
|
|
233
449
|
"the old one is still live — remove it in Settings → API keys"))
|
|
234
|
-
|
|
235
|
-
try:
|
|
236
|
-
minted = mint(client, project)
|
|
237
|
-
except Refused as exc:
|
|
238
|
-
lines.append(_line("ledger", FAIL, "mint", str(exc)))
|
|
239
|
-
return lines, 1, {}
|
|
240
|
-
key = minted.get("plaintext") or ""
|
|
241
450
|
if not key:
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
451
|
+
try:
|
|
452
|
+
minted = mint(client, project)
|
|
453
|
+
except Refused as exc:
|
|
454
|
+
lines.append(_line("ledger", FAIL, "mint", str(exc)))
|
|
455
|
+
return lines, 1, {}
|
|
456
|
+
key = minted.get("plaintext") or ""
|
|
457
|
+
minted_id = minted.get("id")
|
|
458
|
+
if not key:
|
|
459
|
+
lines.append(_line("ledger", FAIL, "mint", "the server returned no key"))
|
|
460
|
+
return lines, 1, {}
|
|
461
|
+
|
|
462
|
+
for dest in dests:
|
|
463
|
+
workspace = None
|
|
464
|
+
if dest.harness == "grok":
|
|
465
|
+
workspace = grok_workspace(repo, grok or grok_home())
|
|
466
|
+
if tracked_by_git(workspace):
|
|
467
|
+
lines.append(_line("config", FAIL, "target",
|
|
468
|
+
f"git tracks {workspace} — worktrees there would be "
|
|
469
|
+
"committed. Re-run with --scope user"))
|
|
470
|
+
continue
|
|
471
|
+
ok, why = workspace_writable(workspace)
|
|
472
|
+
if not ok:
|
|
473
|
+
lines.append(_line("config", FAIL, dest.harness,
|
|
474
|
+
f"workspace {workspace} is not writable ({why}). "
|
|
475
|
+
"spawn would fail as git worktree add; pass --workspace "
|
|
476
|
+
"at a path this harness can write (Grok's sandbox allows "
|
|
477
|
+
"~/.grok/ and the repository, not a sibling)"))
|
|
478
|
+
continue
|
|
479
|
+
profile = grok_sandbox_profile(grok or grok_home())
|
|
480
|
+
if profile:
|
|
481
|
+
home = Path.home()
|
|
482
|
+
lines.append(_line("config", UNKNOWN, "sandbox",
|
|
483
|
+
f"Grok's [sandbox] profile = {profile!r} is inherited by "
|
|
484
|
+
"gbfleet and every child it spawns: a claude child reads "
|
|
485
|
+
"'Not logged in' (the Keychain is unreachable from inside), "
|
|
486
|
+
"qwen-code and cursor-agent die writing ~/.qwen and "
|
|
487
|
+
"~/.cursor unless a custom profile grants them (extends = "
|
|
488
|
+
f'"workspace", read_write = ["{home / ".qwen"}", '
|
|
489
|
+
f'"{home / ".cursor"}"]), and a grok child runs --sandbox '
|
|
490
|
+
"off inside this one. `gbfleet doctor --adapter <vendor>` "
|
|
491
|
+
"gives the verdict per vendor (GRPH-838)"))
|
|
492
|
+
servers = entries(url, project, key, repo, workspace=workspace)
|
|
493
|
+
try:
|
|
494
|
+
write_dest(dest, repo, servers)
|
|
495
|
+
except OSError as exc:
|
|
496
|
+
lines.append(_line("config", FAIL, dest.harness,
|
|
497
|
+
f"could not write {dest.path}: {exc}"))
|
|
498
|
+
continue
|
|
499
|
+
extra = f" workspace {workspace}" if workspace else ""
|
|
500
|
+
lines.append(_line("config", PASS, "written",
|
|
501
|
+
f"{LEDGER_SERVER} and {FLEET_SERVER} in {dest.path} "
|
|
502
|
+
f"({dest.harness} {dest.scope}){extra}"))
|
|
503
|
+
if dest.scope == "project":
|
|
504
|
+
lines.append(_line("config", UNKNOWN, "secret",
|
|
505
|
+
f"{dest.path} now holds a credential. Make sure it is gitignored"))
|
|
506
|
+
if reused:
|
|
507
|
+
paths = ", ".join(str(d.path) for d in dests)
|
|
508
|
+
lines.append(_line("config", PASS, "credential",
|
|
509
|
+
f"already configured in {paths} and it works"))
|
|
251
510
|
lines += verify(url, key, project)
|
|
252
511
|
lines += skill(repo)
|
|
253
512
|
lines += supervisor(install)
|
|
254
513
|
worst = max((doctor_mod.SEVERITY[l["status"]] for l in lines), default=0)
|
|
255
|
-
|
|
514
|
+
extra = {"reused": True} if reused else {"minted": minted_id}
|
|
515
|
+
return lines, (1 if worst == doctor_mod.SEVERITY[FAIL] else 0), extra
|
|
256
516
|
|
|
257
517
|
|
|
258
518
|
def supervisor(install: bool) -> list[dict]:
|
|
@@ -46,9 +46,14 @@ not pass the stored default just because the error mentions it — the error men
|
|
|
46
46
|
explain why it was refused.
|
|
47
47
|
|
|
48
48
|
That mints a project-scoped credential that does not expire, writes the `graphban` and
|
|
49
|
-
`gbfleet` MCP entries
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
`gbfleet` MCP entries into every parent harness that would actually read them — Claude Code
|
|
50
|
+
(`~/.claude.json`, JSON `mcpServers`) and Grok (`~/.grok/config.toml`, TOML `mcp_servers`,
|
|
51
|
+
with `--workspace` under `~/.grok/gbfleet-wt/` so spawn can write worktrees inside Grok's
|
|
52
|
+
sandbox) — installs the delegation skill, and verifies the result against a key those files
|
|
53
|
+
hold, not against a different harness's key. Read its output: every line is `PASS`, `FAIL`
|
|
54
|
+
or `UNKNOWN`, and `UNKNOWN` means a check could not run, not that it passed. A working key
|
|
55
|
+
with no `gbfleet` server, or a Grok gbfleet missing `--workspace`, is repaired, not left
|
|
56
|
+
alone.
|
|
52
57
|
|
|
53
58
|
**If it reports no session**, that is the one thing you cannot do. Give the person the command
|
|
54
59
|
to run, in exactly this form, and stop:
|
|
@@ -81,7 +86,8 @@ it, and its siblings against their project names. A project carries no repositor
|
|
|
81
86
|
that is a match and not a lookup — it refuses every ambiguity rather than guessing, and names
|
|
82
87
|
each project it could not place. Read those lines out; they are the ones needing a decision.
|
|
83
88
|
|
|
84
|
-
**When setup passes, ask for a restart
|
|
89
|
+
**When setup passes, ask for a restart of the session that will call `delegate`/`spawn` and
|
|
90
|
+
stop.** That is Claude Code, or Grok, or both — MCP servers are read at startup, so the
|
|
85
91
|
tools cannot appear in the session that configured them, however correct the config is. Say
|
|
86
92
|
that plainly — do not call `get_context` again hoping it changed, and do not retry `gban
|
|
87
93
|
setup`. It succeeded.
|
|
@@ -122,6 +128,12 @@ and a spawn without them exits before registering. That presents as a delegation
|
|
|
122
128
|
with nothing claimed, not as an error on the spawn, so check these first when a child never
|
|
123
129
|
appears.
|
|
124
130
|
|
|
131
|
+
If a child exits 1 before registering and the spawn's stderr tail shows `EPERM` on a path under
|
|
132
|
+
the home directory (`~/.qwen`, `~/.cursor`), `sandbox initialization failed`, or `Not logged in`
|
|
133
|
+
from a machine that is logged in, the parent harness is sandboxed and the child inherited it
|
|
134
|
+
(Grok's `[sandbox] profile`). `gbfleet doctor --adapter <vendor>` names it and the remedy;
|
|
135
|
+
`docs/fleet-adapters.md` "A sandboxed parent is every child's sandbox" has the measurements.
|
|
136
|
+
|
|
125
137
|
## 5. Read the outcome from the ledger
|
|
126
138
|
|
|
127
139
|
Nothing is pushed back to you. Read the item, or `fleet_status`. `spawn` returns an agent id at
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
"""`gban setup` — everything between a session and a delegating agent (GRPH-792).
|
|
1
|
+
"""`gban setup` — everything between a session and a delegating agent (GRPH-792, GRPH-825).
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
These tests exist because the failure they describe is invisible when it happens.
|
|
4
4
|
|
|
5
5
|
- **The shadowed write.** Claude Code reads `~/.claude.json`'s per-project `mcpServers` in
|
|
6
6
|
preference to a repository `.mcp.json`. A setup that wrote the repository file while a stale
|
|
@@ -10,11 +10,14 @@ Three of these tests exist because the failure they describe is invisible when i
|
|
|
10
10
|
tracked file that becomes "commit a key", and a warning attached to it still does it.
|
|
11
11
|
- **The tier that is not a permission.** A manifest missing `fleet` does not refuse `delegate`;
|
|
12
12
|
it does not advertise it. The symptom is an agent reporting that the tool does not exist.
|
|
13
|
+
- **The other harness.** Grok reads `~/.grok/config.toml` (`mcp_servers`, TOML). Writing only
|
|
14
|
+
Claude's file while Grok holds a different key reports success and leaves `delegate`
|
|
15
|
+
unadvertised (GRPH-825). Reuse that skips the write leaves `gbfleet` missing even on Claude.
|
|
13
16
|
"""
|
|
14
17
|
from __future__ import annotations
|
|
15
18
|
|
|
16
19
|
import json
|
|
17
|
-
import
|
|
20
|
+
import tomllib
|
|
18
21
|
from pathlib import Path
|
|
19
22
|
|
|
20
23
|
import pytest
|
|
@@ -34,11 +37,23 @@ def no_supervisor(monkeypatch):
|
|
|
34
37
|
monkeypatch.setattr(doctor_mod, "offer_to_install", lambda *a, **k: False)
|
|
35
38
|
|
|
36
39
|
|
|
40
|
+
@pytest.fixture(autouse=True)
|
|
41
|
+
def no_grok_config(tmp_path_factory, monkeypatch):
|
|
42
|
+
"""A test that does not create this file is a Claude-only setup, which is still the
|
|
43
|
+
common case. Tests that cover Grok write the file first. Pointed at the real
|
|
44
|
+
`~/.grok/config.toml`, every test here would rewrite the developer's harness."""
|
|
45
|
+
path = tmp_path_factory.mktemp("grokhome") / "config.toml"
|
|
46
|
+
monkeypatch.setattr(setup_mod, "grok_home", lambda: path)
|
|
47
|
+
return path
|
|
48
|
+
|
|
49
|
+
|
|
37
50
|
class Server:
|
|
38
51
|
"""Mints, and answers `get_context` as the deployed one does."""
|
|
39
52
|
|
|
40
|
-
def __init__(self, *, writable=("core",), missing=(), refuse=None, issued=()
|
|
53
|
+
def __init__(self, *, writable=("core",), missing=(), refuse=None, issued=(),
|
|
54
|
+
missing_for=None):
|
|
41
55
|
self.writable, self.missing, self.refuse = list(writable), list(missing), refuse
|
|
56
|
+
self.missing_for = dict(missing_for or {})
|
|
42
57
|
self.minted = []
|
|
43
58
|
#: Every key this deployment knows. Anything else 401s, as a real one does.
|
|
44
59
|
self.issued = set(issued)
|
|
@@ -60,12 +75,16 @@ def _context(server):
|
|
|
60
75
|
A mock that answered every key identically would make the stale-credential case
|
|
61
76
|
untestable, which is the case this command exists for: the key in the config is not
|
|
62
77
|
invalid in some abstract way, it is a credential the server does not know.
|
|
78
|
+
|
|
79
|
+
Live `missing_tiers` objects use `tier`, not `name` (`mcp_server.py`). Spelling it
|
|
80
|
+
`name` here made verify PASS against a key that could not see `delegate`.
|
|
63
81
|
"""
|
|
64
82
|
def call(self, method, path, body=None):
|
|
65
83
|
if self.api_key not in server.issued:
|
|
66
84
|
raise Refused(401, "invalid api key", "")
|
|
85
|
+
missing = server.missing_for.get(self.api_key, server.missing)
|
|
67
86
|
ctx = {"project_id": "core", "writable_projects": server.writable,
|
|
68
|
-
"missing_tiers": [{"
|
|
87
|
+
"missing_tiers": [{"tier": t} for t in missing]}
|
|
69
88
|
return {"result": {"content": [{"text": json.dumps(ctx)}]}}
|
|
70
89
|
return call
|
|
71
90
|
|
|
@@ -224,6 +243,29 @@ def test_a_second_run_reuses_the_working_credential(tmp_path, wired):
|
|
|
224
243
|
assert len(server.minted) == 1, "minted a second key for an already-working setup"
|
|
225
244
|
assert made.get("reused") is True
|
|
226
245
|
assert any("already configured" in l["detail"] for l in lines)
|
|
246
|
+
servers = json.loads(home.read_text())["projects"][str(repo.resolve())]["mcpServers"]
|
|
247
|
+
assert set(servers) == {"graphban", "gbfleet"}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_reuse_still_writes_a_missing_gbfleet_entry(tmp_path, wired):
|
|
251
|
+
"""THE SECOND DEFECT. Reuse skipped write_entries, so a Claude file that only had
|
|
252
|
+
`graphban` stayed that way while setup reported PASS (GRPH-825)."""
|
|
253
|
+
repo, home = tmp_path / "repo", tmp_path / ".claude.json"
|
|
254
|
+
repo.mkdir()
|
|
255
|
+
home.write_text(json.dumps({"projects": {str(repo.resolve()): {
|
|
256
|
+
"mcpServers": {"graphban": {"type": "http",
|
|
257
|
+
"url": URL + "/api/mcp",
|
|
258
|
+
"headers": {"X-API-Key": "gb_sk_ok"}}}}}}))
|
|
259
|
+
|
|
260
|
+
server = Server(issued={"gb_sk_ok"})
|
|
261
|
+
lines, code, made = _run(server, repo, home, wired=wired)
|
|
262
|
+
|
|
263
|
+
assert code == 0, lines
|
|
264
|
+
assert made.get("reused") is True
|
|
265
|
+
assert server.minted == [], "repaired by writing, which is not a reason to mint"
|
|
266
|
+
servers = json.loads(home.read_text())["projects"][str(repo.resolve())]["mcpServers"]
|
|
267
|
+
assert "gbfleet" in servers
|
|
268
|
+
assert doctor_mod.SUPERVISOR_KEY_ENV in servers["gbfleet"]["env"]
|
|
227
269
|
|
|
228
270
|
|
|
229
271
|
def test_a_configured_key_that_cannot_delegate_is_replaced_and_the_old_one_named(tmp_path, wired):
|
|
@@ -261,6 +303,156 @@ def test_other_servers_in_the_file_survive(tmp_path, wired):
|
|
|
261
303
|
assert "context7" in servers and "graphban" in servers
|
|
262
304
|
|
|
263
305
|
|
|
306
|
+
# ---- Grok (GRPH-825) ---------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
def test_setup_writes_grok_toml_mcp_servers_when_the_file_exists(
|
|
309
|
+
tmp_path, wired, no_grok_config):
|
|
310
|
+
"""THE MEASURED ONE. Grok reads `mcp_servers` in TOML. `mcpServers` parses and loads
|
|
311
|
+
nothing; JSON in a `.toml` path is the same silence (GRPH-575, now for the parent)."""
|
|
312
|
+
repo, home = tmp_path / "repo", tmp_path / ".claude.json"
|
|
313
|
+
repo.mkdir()
|
|
314
|
+
no_grok_config.write_text("# keep me\n[ui]\nmax_thoughts_width = 120\n")
|
|
315
|
+
|
|
316
|
+
lines, code, _ = _run(Server(), repo, home, wired=wired)
|
|
317
|
+
|
|
318
|
+
assert code == 0, lines
|
|
319
|
+
text = no_grok_config.read_text()
|
|
320
|
+
assert "# keep me" in text
|
|
321
|
+
parsed = tomllib.loads(text)
|
|
322
|
+
assert "mcp_servers" in parsed
|
|
323
|
+
assert "mcpServers" not in parsed
|
|
324
|
+
gb = parsed["mcp_servers"]["graphban"]
|
|
325
|
+
assert gb["url"].endswith("/api/mcp")
|
|
326
|
+
assert gb["headers"]["X-API-Key"].startswith("gb_sk_")
|
|
327
|
+
fleet = parsed["mcp_servers"]["gbfleet"]
|
|
328
|
+
assert fleet["command"] == "gbfleet"
|
|
329
|
+
assert "--project" in fleet["args"]
|
|
330
|
+
assert "--workspace" in fleet["args"]
|
|
331
|
+
ws = Path(fleet["args"][fleet["args"].index("--workspace") + 1])
|
|
332
|
+
assert ws == no_grok_config.parent / "gbfleet-wt" / repo.name, (
|
|
333
|
+
"Grok worktrees must sit next to Grok's config, not as a sibling of the repo "
|
|
334
|
+
"(GRPH-826)"
|
|
335
|
+
)
|
|
336
|
+
assert doctor_mod.SUPERVISOR_KEY_ENV in fleet["env"]
|
|
337
|
+
assert parsed["ui"]["max_thoughts_width"] == 120
|
|
338
|
+
claude = json.loads(home.read_text())["projects"][str(repo.resolve())]["mcpServers"]
|
|
339
|
+
assert claude["graphban"]["headers"]["X-API-Key"] == gb["headers"]["X-API-Key"]
|
|
340
|
+
assert "--workspace" not in claude["gbfleet"]["args"], (
|
|
341
|
+
"Claude can write the sibling default; moving it would be fixing the wrong harness"
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def test_a_working_claude_key_is_copied_onto_a_grok_key_that_cannot_delegate(
|
|
346
|
+
tmp_path, wired, no_grok_config):
|
|
347
|
+
"""Claude's key can see `fleet`. Grok's cannot. Verifying only Claude PASSed and left
|
|
348
|
+
Grok on the weak key — which is how this session opened."""
|
|
349
|
+
repo, home = tmp_path / "repo", tmp_path / ".claude.json"
|
|
350
|
+
repo.mkdir()
|
|
351
|
+
home.write_text(json.dumps({"projects": {str(repo.resolve()): {
|
|
352
|
+
"mcpServers": {"graphban": {"headers": {"X-API-Key": "gb_sk_good"}},
|
|
353
|
+
"gbfleet": {"command": "gbfleet"}}}}}))
|
|
354
|
+
no_grok_config.write_text(
|
|
355
|
+
'[mcp_servers.graphban]\nurl = "http://gb.invalid/api/mcp"\nenabled = true\n\n'
|
|
356
|
+
'[mcp_servers.graphban.headers]\nX-API-Key = "gb_sk_weak"\n'
|
|
357
|
+
)
|
|
358
|
+
server = Server(issued={"gb_sk_good", "gb_sk_weak"},
|
|
359
|
+
missing_for={"gb_sk_weak": ("fleet",)})
|
|
360
|
+
|
|
361
|
+
lines, code, made = _run(server, repo, home, wired=wired)
|
|
362
|
+
|
|
363
|
+
assert code == 0, lines
|
|
364
|
+
assert made.get("reused") is True
|
|
365
|
+
assert server.minted == [], "the working Claude key is the one to write, not a third"
|
|
366
|
+
grok = tomllib.loads(no_grok_config.read_text())
|
|
367
|
+
assert grok["mcp_servers"]["graphban"]["headers"]["X-API-Key"] == "gb_sk_good"
|
|
368
|
+
assert "gbfleet" in grok["mcp_servers"]
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def test_setup_does_not_invent_a_grok_config_when_grok_has_never_run(
|
|
372
|
+
tmp_path, wired, no_grok_config):
|
|
373
|
+
repo, home = tmp_path / "repo", tmp_path / ".claude.json"
|
|
374
|
+
repo.mkdir()
|
|
375
|
+
_run(Server(), repo, home, wired=wired)
|
|
376
|
+
assert not no_grok_config.exists()
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def test_a_git_tracked_grok_project_file_is_refused(tmp_path, wired, monkeypatch):
|
|
380
|
+
repo, home = tmp_path / "repo", tmp_path / ".claude.json"
|
|
381
|
+
repo.mkdir()
|
|
382
|
+
grok_proj = repo / ".grok" / "config.toml"
|
|
383
|
+
grok_proj.parent.mkdir()
|
|
384
|
+
grok_proj.write_text("[ui]\n")
|
|
385
|
+
monkeypatch.setattr(setup_mod, "tracked_by_git",
|
|
386
|
+
lambda path: path == grok_proj)
|
|
387
|
+
|
|
388
|
+
server = Server()
|
|
389
|
+
lines, code, _ = _run(server, repo, home, scope="project", wired=wired)
|
|
390
|
+
|
|
391
|
+
assert any("git tracks" in l["detail"] and ".grok/config.toml" in l["detail"]
|
|
392
|
+
for l in lines)
|
|
393
|
+
# Claude's project file is not tracked, so it is still written. The grok FAIL is
|
|
394
|
+
# a real finding and so the exit is 1 — a PASS report would hide the refusal.
|
|
395
|
+
assert code == 1
|
|
396
|
+
assert (repo / ".mcp.json").exists()
|
|
397
|
+
assert tomllib.loads(grok_proj.read_text()) == {"ui": {}}
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def test_reuse_repairs_a_gbfleet_entry_missing_workspace(tmp_path, wired, no_grok_config):
|
|
401
|
+
"""THE FOLLOW-ON. A Grok gbfleet written before GRPH-826 has no --workspace. Reuse
|
|
402
|
+
that skipped the write would leave spawn dying as git 128."""
|
|
403
|
+
repo, home = tmp_path / "repo", tmp_path / ".claude.json"
|
|
404
|
+
repo.mkdir()
|
|
405
|
+
home.write_text(json.dumps({"projects": {str(repo.resolve()): {
|
|
406
|
+
"mcpServers": {"graphban": {"headers": {"X-API-Key": "gb_sk_ok"}}}}}}))
|
|
407
|
+
no_grok_config.write_text(
|
|
408
|
+
'[mcp_servers.graphban]\nurl = "http://gb.invalid/api/mcp"\nenabled = true\n\n'
|
|
409
|
+
'[mcp_servers.graphban.headers]\nX-API-Key = "gb_sk_ok"\n\n'
|
|
410
|
+
'[mcp_servers.gbfleet]\ncommand = "gbfleet"\n'
|
|
411
|
+
'args = ["mcp", "--repo", "/old", "--server", "http://gb.invalid", '
|
|
412
|
+
'"--project", "core"]\n'
|
|
413
|
+
'env = { GBFLEET_API_KEY = "gb_sk_ok" }\nenabled = true\n'
|
|
414
|
+
)
|
|
415
|
+
server = Server(issued={"gb_sk_ok"})
|
|
416
|
+
_, code, made = _run(server, repo, home, wired=wired)
|
|
417
|
+
|
|
418
|
+
assert code == 0
|
|
419
|
+
assert made.get("reused") is True
|
|
420
|
+
assert server.minted == []
|
|
421
|
+
args = tomllib.loads(no_grok_config.read_text())["mcp_servers"]["gbfleet"]["args"]
|
|
422
|
+
assert "--workspace" in args
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def test_an_unwritable_grok_workspace_is_not_a_pass(tmp_path, wired, no_grok_config, monkeypatch):
|
|
426
|
+
"""gbfleet doctor already FAILs this. Setup reporting PASS is how spawn looked like git."""
|
|
427
|
+
repo, home = tmp_path / "repo", tmp_path / ".claude.json"
|
|
428
|
+
repo.mkdir()
|
|
429
|
+
no_grok_config.write_text("[ui]\n")
|
|
430
|
+
monkeypatch.setattr(setup_mod, "workspace_writable",
|
|
431
|
+
lambda path: (False, "Operation not permitted"))
|
|
432
|
+
|
|
433
|
+
lines, code, _ = _run(Server(), repo, home, wired=wired)
|
|
434
|
+
|
|
435
|
+
assert code == 1
|
|
436
|
+
assert any("not writable" in l["detail"] for l in lines)
|
|
437
|
+
assert "mcp_servers" not in tomllib.loads(no_grok_config.read_text())
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def test_args_arrays_are_not_mistaken_for_table_headers(tmp_path, wired, no_grok_config):
|
|
441
|
+
"""`args = ["mcp", ...]` contains `[`. Walking until the next `[` stops there and
|
|
442
|
+
leaves garbage that Grok cannot parse."""
|
|
443
|
+
repo, home = tmp_path / "repo", tmp_path / ".claude.json"
|
|
444
|
+
repo.mkdir()
|
|
445
|
+
no_grok_config.write_text(
|
|
446
|
+
'[mcp_servers.gbfleet]\ncommand = "gbfleet"\n'
|
|
447
|
+
'args = ["mcp", "--repo", "/old"]\n'
|
|
448
|
+
'env = { GBFLEET_API_KEY = "gb_sk_old" }\nenabled = true\n'
|
|
449
|
+
)
|
|
450
|
+
_run(Server(), repo, home, wired=wired)
|
|
451
|
+
parsed = tomllib.loads(no_grok_config.read_text())
|
|
452
|
+
assert parsed["mcp_servers"]["gbfleet"]["args"][0] == "mcp"
|
|
453
|
+
assert "--project" in parsed["mcp_servers"]["gbfleet"]["args"]
|
|
454
|
+
|
|
455
|
+
|
|
264
456
|
def test_every_route_setup_calls_is_documented(tmp_path, wired, monkeypatch):
|
|
265
457
|
"""The check `test_acts.py` makes for the verbs it drives, made here for the one it
|
|
266
458
|
cannot: a verb pointed at an invented path is a verb that 404s in front of somebody
|
|
@@ -331,6 +523,11 @@ def test_the_skill_tells_the_agent_the_two_things_it_cannot_work_out(tmp_path):
|
|
|
331
523
|
# It drives the CLI rather than restating the setup, which is what keeps it from becoming
|
|
332
524
|
# a fourth copy of the procedure that drifts from the other three.
|
|
333
525
|
assert "gban setup" in body
|
|
526
|
+
# Two parent harnesses. Naming only one is how GRPH-825 shipped.
|
|
527
|
+
assert "claude.json" in body
|
|
528
|
+
assert "config.toml" in body
|
|
529
|
+
assert "mcp_servers" in body
|
|
530
|
+
assert "gbfleet-wt" in body
|
|
334
531
|
|
|
335
532
|
|
|
336
533
|
def test_the_skill_hands_the_person_a_runnable_line_and_carries_the_rest(tmp_path):
|
|
@@ -746,3 +943,40 @@ def test_a_real_repository_says_nothing_about_it(tmp_path, wired):
|
|
|
746
943
|
lines, _, _ = _run(Server(), repo, home, wired=wired)
|
|
747
944
|
|
|
748
945
|
assert not any(l["name"] == "repository" for l in lines)
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
# ---- a sandboxed Grok is a sandboxed fleet (GRPH-838) --------------------------------------------
|
|
949
|
+
|
|
950
|
+
def test_setup_names_a_grok_sandbox_that_every_child_will_inherit(tmp_path, wired, no_grok_config):
|
|
951
|
+
"""Measured 2026-09-10: `[sandbox] profile = "workspace"` reached gbfleet and every vendor
|
|
952
|
+
it spawned, and four children died at exit 1 before registering, each with its own
|
|
953
|
+
vendor's error. Setup cannot change the operator's sandbox, but a PASS report that leaves
|
|
954
|
+
the first wave to find this out is the silence this line replaces."""
|
|
955
|
+
repo, home = tmp_path / "repo", tmp_path / ".claude.json"
|
|
956
|
+
repo.mkdir()
|
|
957
|
+
no_grok_config.write_text('[sandbox]\nprofile = "workspace"\n')
|
|
958
|
+
|
|
959
|
+
lines, code, _ = _run(Server(), repo, home, wired=wired)
|
|
960
|
+
|
|
961
|
+
assert code == 0, "a finding, not a refusal — grok children still spawn"
|
|
962
|
+
line = next(l for l in lines if l["name"] == "sandbox")
|
|
963
|
+
assert line["status"] == "UNKNOWN"
|
|
964
|
+
assert "Not logged in" in line["detail"], "the claude symptom, so it is recognised later"
|
|
965
|
+
assert ".qwen" in line["detail"] and ".cursor" in line["detail"]
|
|
966
|
+
assert "--sandbox off" in line["detail"]
|
|
967
|
+
grok = tomllib.loads(no_grok_config.read_text())
|
|
968
|
+
assert "gbfleet" in grok["mcp_servers"], "the entry is still written"
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
@pytest.mark.parametrize("body", ["[ui]\n", '[sandbox]\nprofile = "off"\n'])
|
|
972
|
+
def test_a_grok_config_without_a_live_sandbox_says_nothing_about_one(
|
|
973
|
+
tmp_path, wired, no_grok_config, body):
|
|
974
|
+
"""The control. Off and unset are the same fact, and a line that fires for both would be
|
|
975
|
+
noise on every machine — which is how the real one gets scrolled past."""
|
|
976
|
+
repo, home = tmp_path / "repo", tmp_path / ".claude.json"
|
|
977
|
+
repo.mkdir()
|
|
978
|
+
no_grok_config.write_text(body)
|
|
979
|
+
|
|
980
|
+
lines, _, _ = _run(Server(), repo, home, wired=wired)
|
|
981
|
+
|
|
982
|
+
assert not any(l["name"] == "sandbox" for l in lines)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|