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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: graphban-cli
3
- Version: 0.3.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
- installs the supervisor if it is missing, drops the delegation skill into `.claude/skills/`,
132
- and then **verifies** rather than asserting: it asks the new credential what it can actually
133
- see. Restart the harness afterwards — MCP servers are read at startup.
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.** `~/.claude.json`'s per-project
140
- `mcpServers` outranks a repository `.mcp.json`, so writing the repository file under a
141
- stale entry leaves the agent on the old key — which surfaces as a JSON parse error, because
142
- the harness is parsing a 401 body. `--scope user` is the default for that reason, and
143
- because a credential outside the repository cannot be committed.
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 attached to committing a
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
- installs the supervisor if it is missing, drops the delegation skill into `.claude/skills/`,
117
- and then **verifies** rather than asserting: it asks the new credential what it can actually
118
- see. Restart the harness afterwards — MCP servers are read at startup.
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.** `~/.claude.json`'s per-project
125
- `mcpServers` outranks a repository `.mcp.json`, so writing the repository file under a
126
- stale entry leaves the agent on the old key — which surfaces as a JSON parse error, because
127
- the harness is parsing a 401 body. `--scope user` is the default for that reason, and
128
- because a credential outside the repository cannot be committed.
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 attached to committing a
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "graphban-cli"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "gban — a Graphban client for the human at a terminal"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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 where the harness will actually read "
60
- "them, and installs the supervisor. Only seats expire; a credential that "
61
- "died overnight would make 'delegation is set up' quietly stop being "
62
- "true. Re-running is safe: a working configuration is left alone."))
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, which OUTRANKS a repo .mcp.json and "
66
- "cannot be committed; project writes .mcp.json beside the repo")
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(f" restart the harness so it reads the new config.")
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
- f"Restart the harness so it reads the new config."]
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.** Claude Code reads
17
- `~/.claude.json`'s per-project `mcpServers` in preference to a repository's `.mcp.json`, so
18
- writing the repository file while a stale entry shadows it leaves the agent authenticating with
19
- the old key. That failure does not present as a permission problem: the harness reports a JSON
20
- parse error, because it is parsing a 401 body. So the default target is the user file — which
21
- also happens to be the one a credential cannot be committed from.
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) -> dict:
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": ["mcp", "--repo", str(repo), "--server", url.rstrip("/"),
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("name") if isinstance(t, dict) else t
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) -> tuple[list[dict], int, dict]:
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
- scope, path, why = target(repo, scope, home)
205
- if why:
206
- lines.append(_line("config", UNKNOWN, "scope", why))
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
- key = existing_key(path, scope, repo)
218
- reused = bool(key)
219
- if reused:
220
- checks = verify(url, key, project)
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
- lines.append(_line("config", PASS, "credential",
223
- f"already configured in {path} and it works"))
224
- lines += checks
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
- lines.append(_line("ledger", FAIL, "mint", "the server returned no key"))
243
- return lines, 1, {}
244
-
245
- write_entries(path, scope, repo, entries(url, project, key, repo))
246
- lines.append(_line("config", PASS, "written",
247
- f"{LEDGER_SERVER} and {FLEET_SERVER} in {path} ({scope} scope)"))
248
- if scope == "project":
249
- lines.append(_line("config", UNKNOWN, "secret",
250
- f"{path} now holds a credential. Make sure it is gitignored"))
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
- return lines, (1 if worst == doctor_mod.SEVERITY[FAIL] else 0), {"minted": minted.get("id")}
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 where the harness will actually read them, installs the delegation
50
- skill, and verifies the result. Read its output: every line is `PASS`, `FAIL` or `UNKNOWN`,
51
- and `UNKNOWN` means a check could not run, not that it passed.
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 and stop.** MCP servers are read at startup, so the
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
- Three of these tests exist because the failure they describe is invisible when it happens.
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 subprocess
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": [{"name": t} for t in server.missing]}
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