graphban-cli 0.8.3__tar.gz → 0.8.5__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.8.3 → graphban_cli-0.8.5}/PKG-INFO +14 -3
- {graphban_cli-0.8.3 → graphban_cli-0.8.5}/README.md +13 -2
- {graphban_cli-0.8.3 → graphban_cli-0.8.5}/pyproject.toml +1 -1
- {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/cli.py +59 -6
- {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/client.py +9 -0
- {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/doctor.py +90 -10
- graphban_cli-0.8.5/src/gban/machine.py +91 -0
- {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/setup.py +68 -3
- graphban_cli-0.8.5/src/gban/skills/graphban-answer/SKILL.md +103 -0
- {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/skills/graphban-delegation/SKILL.md +2 -1
- {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/skills/graphban-review/SKILL.md +2 -0
- {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/skills/graphban-wave/SKILL.md +2 -0
- {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/skills/graphban-work/SKILL.md +6 -0
- {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/skills/watch-wave/SKILL.md +4 -3
- {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/swamp.py +6 -4
- {graphban_cli-0.8.3 → graphban_cli-0.8.5}/tests/test_doctor.py +42 -0
- graphban_cli-0.8.5/tests/test_login_pair.py +46 -0
- graphban_cli-0.8.5/tests/test_machine_report.py +81 -0
- {graphban_cli-0.8.3 → graphban_cli-0.8.5}/tests/test_setup.py +191 -1
- {graphban_cli-0.8.3 → graphban_cli-0.8.5}/tests/test_swamp.py +15 -1
- {graphban_cli-0.8.3 → graphban_cli-0.8.5}/uv.lock +1 -1
- {graphban_cli-0.8.3 → graphban_cli-0.8.5}/.gitignore +0 -0
- {graphban_cli-0.8.3 → graphban_cli-0.8.5}/LICENSE +0 -0
- {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/__init__.py +0 -0
- {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/config.py +0 -0
- {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/data/stack_sdks.toml +0 -0
- {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/gitignore.py +0 -0
- {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/skills/graphban-grill/SKILL.md +0 -0
- {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/skills/graphban-orient/SKILL.md +0 -0
- {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/skills/graphban-prd/SKILL.md +0 -0
- {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/skills/graphban-scan/SKILL.md +0 -0
- {graphban_cli-0.8.3 → graphban_cli-0.8.5}/src/gban/stackmap.py +0 -0
- {graphban_cli-0.8.3 → graphban_cli-0.8.5}/tests/test_acts.py +0 -0
- {graphban_cli-0.8.3 → graphban_cli-0.8.5}/tests/test_login_projects.py +0 -0
- {graphban_cli-0.8.3 → graphban_cli-0.8.5}/tests/test_packaging.py +0 -0
- {graphban_cli-0.8.3 → graphban_cli-0.8.5}/tests/test_session.py +0 -0
- {graphban_cli-0.8.3 → graphban_cli-0.8.5}/tests/test_stackmap.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: graphban-cli
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.5
|
|
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
|
|
@@ -87,6 +87,7 @@ Releasing is [docs/releasing.md](https://github.com/asc-me/graphban/blob/main/do
|
|
|
87
87
|
```bash
|
|
88
88
|
gban login --server https://cloud.agentldgr.dev
|
|
89
89
|
gban doctor # both halves: the ledger, and the local fleet
|
|
90
|
+
# (also reports the result to the server; --no-report opts out)
|
|
90
91
|
gban agents # the roster, and why an agent is stuck
|
|
91
92
|
gban agents role SA-A4 planner # what used to need a browser
|
|
92
93
|
gban seats issue worker worker planner # one entry per agent
|
|
@@ -171,6 +172,15 @@ Three properties worth knowing, each of which is a bug this command exists to no
|
|
|
171
172
|
committed. It asks git (`check-ignore`), so an equivalent pattern already in the
|
|
172
173
|
file is left alone.
|
|
173
174
|
|
|
175
|
+
## Machine reports
|
|
176
|
+
|
|
177
|
+
`gban setup` and `gban doctor` send the server a report of their PASS/FAIL/UNKNOWN lines, tagged
|
|
178
|
+
with a random machine id kept in `~/.graphban/machine.json` (delete it and you are a new
|
|
179
|
+
machine). A report whose every line is PASS turns the dashboard's "Connect your agent" step ✓ (an UNKNOWN is never a pass); the list is
|
|
180
|
+
under Settings → Machines. Pass `--no-report` to skip it. When the report cannot be delivered,
|
|
181
|
+
the command prints `UNKNOWN report could not report to <server>: <reason>` and still exits on
|
|
182
|
+
its own checks — an undelivered report never counts as proof.
|
|
183
|
+
|
|
174
184
|
## Wiring a checkout to Swamp
|
|
175
185
|
|
|
176
186
|
```bash
|
|
@@ -180,7 +190,8 @@ gban swamp setup
|
|
|
180
190
|
Steps 3 and 5 of [the Swamp runbook](https://github.com/asc-me/graphban/blob/main/docs/swamp.md):
|
|
181
191
|
`repo init`, `extension source add`, `vault create`, and the **gate** credential — minted,
|
|
182
192
|
piped to `swamp vault put` on stdin, then checked for the scopes it actually came back with.
|
|
183
|
-
Every step is skipped when already done.
|
|
193
|
+
Every step is skipped when already done. When the vault secret is already set it is left alone;
|
|
194
|
+
`gban swamp setup --replace` mints a new gate credential and overwrites that vault entry.
|
|
184
195
|
|
|
185
196
|
Two things it will not do. It does not install Swamp, because that install pipes a remote
|
|
186
197
|
script into a shell. And it never writes the gate key into an MCP config: `gban setup`'s agent
|
|
@@ -220,7 +231,7 @@ A real post needs a git commit (there is nothing to send for a repo with no HEAD
|
|
|
220
231
|
It refuses without one, rather than prompting. `getpass` falls back to a plain **echoing**
|
|
221
232
|
read when it cannot turn echo off — it warns, but the warning arrives after the person has
|
|
222
233
|
decided to type — so a login through a pipe, a heredoc or an editor's command runner would
|
|
223
|
-
put the password in the scrollback.
|
|
234
|
+
put the password in the scrollback. `gban login --pair` is the alternative with no password prompt: it prints a short code, and a person signed in to the web app approves it in the quick-start dialog (step ③). The session you get is the approver's, and the code is single-use and expires after 10 minutes. There is no unattended login yet (PRD-40 open
|
|
224
235
|
question 2: an API key cannot reach the JWT routes, so CI would need a service session).
|
|
225
236
|
|
|
226
237
|
## Why not `gb`
|
|
@@ -72,6 +72,7 @@ Releasing is [docs/releasing.md](https://github.com/asc-me/graphban/blob/main/do
|
|
|
72
72
|
```bash
|
|
73
73
|
gban login --server https://cloud.agentldgr.dev
|
|
74
74
|
gban doctor # both halves: the ledger, and the local fleet
|
|
75
|
+
# (also reports the result to the server; --no-report opts out)
|
|
75
76
|
gban agents # the roster, and why an agent is stuck
|
|
76
77
|
gban agents role SA-A4 planner # what used to need a browser
|
|
77
78
|
gban seats issue worker worker planner # one entry per agent
|
|
@@ -156,6 +157,15 @@ Three properties worth knowing, each of which is a bug this command exists to no
|
|
|
156
157
|
committed. It asks git (`check-ignore`), so an equivalent pattern already in the
|
|
157
158
|
file is left alone.
|
|
158
159
|
|
|
160
|
+
## Machine reports
|
|
161
|
+
|
|
162
|
+
`gban setup` and `gban doctor` send the server a report of their PASS/FAIL/UNKNOWN lines, tagged
|
|
163
|
+
with a random machine id kept in `~/.graphban/machine.json` (delete it and you are a new
|
|
164
|
+
machine). A report whose every line is PASS turns the dashboard's "Connect your agent" step ✓ (an UNKNOWN is never a pass); the list is
|
|
165
|
+
under Settings → Machines. Pass `--no-report` to skip it. When the report cannot be delivered,
|
|
166
|
+
the command prints `UNKNOWN report could not report to <server>: <reason>` and still exits on
|
|
167
|
+
its own checks — an undelivered report never counts as proof.
|
|
168
|
+
|
|
159
169
|
## Wiring a checkout to Swamp
|
|
160
170
|
|
|
161
171
|
```bash
|
|
@@ -165,7 +175,8 @@ gban swamp setup
|
|
|
165
175
|
Steps 3 and 5 of [the Swamp runbook](https://github.com/asc-me/graphban/blob/main/docs/swamp.md):
|
|
166
176
|
`repo init`, `extension source add`, `vault create`, and the **gate** credential — minted,
|
|
167
177
|
piped to `swamp vault put` on stdin, then checked for the scopes it actually came back with.
|
|
168
|
-
Every step is skipped when already done.
|
|
178
|
+
Every step is skipped when already done. When the vault secret is already set it is left alone;
|
|
179
|
+
`gban swamp setup --replace` mints a new gate credential and overwrites that vault entry.
|
|
169
180
|
|
|
170
181
|
Two things it will not do. It does not install Swamp, because that install pipes a remote
|
|
171
182
|
script into a shell. And it never writes the gate key into an MCP config: `gban setup`'s agent
|
|
@@ -205,7 +216,7 @@ A real post needs a git commit (there is nothing to send for a repo with no HEAD
|
|
|
205
216
|
It refuses without one, rather than prompting. `getpass` falls back to a plain **echoing**
|
|
206
217
|
read when it cannot turn echo off — it warns, but the warning arrives after the person has
|
|
207
218
|
decided to type — so a login through a pipe, a heredoc or an editor's command runner would
|
|
208
|
-
put the password in the scrollback.
|
|
219
|
+
put the password in the scrollback. `gban login --pair` is the alternative with no password prompt: it prints a short code, and a person signed in to the web app approves it in the quick-start dialog (step ③). The session you get is the approver's, and the code is single-use and expires after 10 minutes. There is no unattended login yet (PRD-40 open
|
|
209
220
|
question 2: an API key cannot reach the JWT routes, so CI would need a service session).
|
|
210
221
|
|
|
211
222
|
## Why not `gb`
|
|
@@ -13,12 +13,14 @@ import os
|
|
|
13
13
|
import shutil
|
|
14
14
|
import subprocess
|
|
15
15
|
import sys
|
|
16
|
+
import time
|
|
16
17
|
from pathlib import Path
|
|
17
18
|
|
|
18
|
-
from gban import config, doctor as doctor_mod, setup as setup_mod, stackmap, swamp as swamp_mod
|
|
19
|
+
from gban import config, doctor as doctor_mod, machine as machine_mod, setup as setup_mod, stackmap, swamp as swamp_mod
|
|
19
20
|
from gban.client import (EXIT_NO_SESSION, EXIT_NO_SUPERVISOR, EXIT_REFUSED, EXIT_UNREACHABLE,
|
|
20
21
|
Client, NoSession,
|
|
21
|
-
Refused, Unreachable, authenticated, login
|
|
22
|
+
Refused, Unreachable, authenticated, login, pair_poll,
|
|
23
|
+
pair_start)
|
|
22
24
|
|
|
23
25
|
PROG = "gban"
|
|
24
26
|
|
|
@@ -44,14 +46,20 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
44
46
|
"written to session.json at mode 600; the access token is never stored, "
|
|
45
47
|
"because it is renewed on every invocation anyway."))
|
|
46
48
|
login_cmd.add_argument("--email", default=None)
|
|
49
|
+
login_cmd.add_argument(
|
|
50
|
+
"--pair", action="store_true",
|
|
51
|
+
help=("sign in without typing a password here: prints a short code that a signed-in "
|
|
52
|
+
"person approves in the web app's quick-start dialog"))
|
|
47
53
|
|
|
48
54
|
sub.add_parser("logout", help="end the session, here and on the server")
|
|
49
|
-
sub.add_parser(
|
|
55
|
+
doctor_cmd = sub.add_parser(
|
|
50
56
|
"doctor", help="check both halves: the ledger, and the local fleet",
|
|
51
57
|
description=("Everything that can be checked before anything is spawned. The ledger "
|
|
52
58
|
"half runs here; the local half is `gbfleet doctor`, run as a subprocess. "
|
|
53
59
|
"Neither half silences the other: what could not be checked prints "
|
|
54
60
|
"UNKNOWN with its reason, never a pass."))
|
|
61
|
+
doctor_cmd.add_argument("--no-report", action="store_true",
|
|
62
|
+
help="do not send the result to the server (Settings → Machines)")
|
|
55
63
|
|
|
56
64
|
setup_cmd = sub.add_parser(
|
|
57
65
|
"setup", help="enable delegation on a project",
|
|
@@ -75,6 +83,8 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
75
83
|
"lookup")
|
|
76
84
|
setup_cmd.add_argument("--no-install", action="store_true",
|
|
77
85
|
help="do not install the supervisor")
|
|
86
|
+
setup_cmd.add_argument("--no-report", action="store_true",
|
|
87
|
+
help="do not send the result to the server (Settings → Machines)")
|
|
78
88
|
|
|
79
89
|
swamp_cmd = sub.add_parser(
|
|
80
90
|
"swamp", help="wire this checkout to Swamp's Graphban adapter",
|
|
@@ -88,6 +98,9 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
88
98
|
swamp_setup = swamp_do.add_parser("setup", help="init, source, vault, and the gate key")
|
|
89
99
|
swamp_setup.add_argument("--adapter", default=swamp_mod.DEFAULT_ADAPTER,
|
|
90
100
|
help=f"the graphban-swamp checkout (default: {swamp_mod.DEFAULT_ADAPTER})")
|
|
101
|
+
swamp_setup.add_argument("--replace", action="store_true",
|
|
102
|
+
help="when the vault secret is already set, mint a new gate "
|
|
103
|
+
"credential and overwrite it")
|
|
91
104
|
swamp_setup.add_argument("--no-init", action="store_true",
|
|
92
105
|
help="refuse rather than run `swamp repo init` here")
|
|
93
106
|
|
|
@@ -180,6 +193,8 @@ def _server(args) -> str:
|
|
|
180
193
|
|
|
181
194
|
def cmd_login(args) -> int:
|
|
182
195
|
url = _server(args)
|
|
196
|
+
if args.pair:
|
|
197
|
+
return _login_pair(args, url)
|
|
183
198
|
# A TERMINAL, OR NOTHING. `getpass` falls back to a plain echoing read when it cannot
|
|
184
199
|
# turn echo off, and warns about it — which is the wrong trade for a password: the
|
|
185
200
|
# warning arrives after the person has already decided to type. Without a tty the
|
|
@@ -202,7 +217,38 @@ def cmd_login(args) -> int:
|
|
|
202
217
|
# same rule criterion 4 applies to an expired session applies to a cancelled login.
|
|
203
218
|
print(f"\n{PROG}: cancelled", file=sys.stderr)
|
|
204
219
|
return EXIT_REFUSED
|
|
205
|
-
|
|
220
|
+
return _finish_login(args, url, email, login(url, email, password))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _login_pair(args, url: str) -> int:
|
|
224
|
+
"""Device-code sign-in (GRPH-1366): the identity is whoever approves the code in the app."""
|
|
225
|
+
started = pair_start(url)
|
|
226
|
+
code = started.get("user_code") or ""
|
|
227
|
+
print(f"{PROG}: open the quick-start dialog in {url} (step 3) and enter this code:\n\n"
|
|
228
|
+
f" {code}\n\n waiting for approval (expires in "
|
|
229
|
+
f"{int(started.get('expires_in', 0)) // 60} min)…", file=sys.stderr)
|
|
230
|
+
deadline = time.monotonic() + float(started.get("expires_in", 600))
|
|
231
|
+
interval = max(float(started.get("interval", 3)), 0.0)
|
|
232
|
+
try:
|
|
233
|
+
while time.monotonic() < deadline:
|
|
234
|
+
time.sleep(interval)
|
|
235
|
+
try:
|
|
236
|
+
got = pair_poll(url, started.get("device_code", ""))
|
|
237
|
+
except Refused:
|
|
238
|
+
print(f"{PROG}: the code expired or was already used. Run `{PROG} login --pair` "
|
|
239
|
+
f"again.", file=sys.stderr)
|
|
240
|
+
return EXIT_REFUSED
|
|
241
|
+
if got.get("status") == "approved":
|
|
242
|
+
return _finish_login(args, url, got.get("email") or "", got)
|
|
243
|
+
except KeyboardInterrupt:
|
|
244
|
+
print(f"\n{PROG}: cancelled", file=sys.stderr)
|
|
245
|
+
return EXIT_REFUSED
|
|
246
|
+
print(f"{PROG}: the code expired before anyone approved it. Run `{PROG} login --pair` "
|
|
247
|
+
f"again.", file=sys.stderr)
|
|
248
|
+
return EXIT_REFUSED
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _finish_login(args, url: str, email: str, pair: dict) -> int:
|
|
206
252
|
refresh = pair.get("refresh_token") or ""
|
|
207
253
|
if not refresh:
|
|
208
254
|
print(f"{PROG}: the server returned no refresh token", file=sys.stderr)
|
|
@@ -322,8 +368,10 @@ def _cwd_project_mismatch(client, configured: str) -> str:
|
|
|
322
368
|
def cmd_doctor(args) -> int:
|
|
323
369
|
url = config.resolve(args.server, config.URL_ENV, "url")
|
|
324
370
|
project = config.resolve(args.project, config.PROJECT_ENV, "project")
|
|
325
|
-
api_key = doctor_mod.resolve_supervisor_key(project)
|
|
371
|
+
api_key = doctor_mod.resolve_supervisor_key(project, url=url)
|
|
326
372
|
lines, code = doctor_mod.run(url, project, api_key)
|
|
373
|
+
if not getattr(args, "no_report", False):
|
|
374
|
+
lines += machine_mod.send(url, project, lines) # UNKNOWN when undelivered: exit unchanged
|
|
327
375
|
_out({"lines": lines, "ok": code == 0}, doctor_mod.render(lines), args.as_json)
|
|
328
376
|
return code
|
|
329
377
|
|
|
@@ -351,6 +399,8 @@ def cmd_setup(args) -> int:
|
|
|
351
399
|
return EXIT_REFUSED
|
|
352
400
|
lines, code, made = setup_mod.run(client, url, project, Path.cwd(), scope=args.scope,
|
|
353
401
|
install=not args.no_install)
|
|
402
|
+
if not args.no_report:
|
|
403
|
+
lines += machine_mod.send(url, project, lines)
|
|
354
404
|
human = [doctor_mod.render(lines)]
|
|
355
405
|
if how != "named":
|
|
356
406
|
human.insert(0, f"{PROG}: {project} — {how}")
|
|
@@ -385,6 +435,8 @@ def _setup_auto(args, url: str, client) -> int:
|
|
|
385
435
|
got, code, made = setup_mod.run(client, url, pid, repo, scope=args.scope,
|
|
386
436
|
install=not args.no_install)
|
|
387
437
|
lines += [{**l, "name": f"{pid}: {l['name']}"} for l in got]
|
|
438
|
+
if not args.no_report:
|
|
439
|
+
lines += [{**l, "name": f"{pid}: {l['name']}"} for l in machine_mod.send(url, pid, got)]
|
|
388
440
|
done[pid], worst = {"repo": str(repo), "ok": code == 0, **made}, max(worst, code)
|
|
389
441
|
human = [doctor_mod.render(lines),
|
|
390
442
|
f"\n {sum(1 for d in done.values() if d['ok'])} of {len(done)} enabled. "
|
|
@@ -415,7 +467,8 @@ def cmd_swamp(args) -> int:
|
|
|
415
467
|
print(f"{PROG}: {exc}", file=sys.stderr)
|
|
416
468
|
return EXIT_REFUSED
|
|
417
469
|
lines, code = swamp_mod.setup(client, url, project, repo,
|
|
418
|
-
Path(args.adapter).expanduser(), init=not args.no_init
|
|
470
|
+
Path(args.adapter).expanduser(), init=not args.no_init,
|
|
471
|
+
replace=args.replace)
|
|
419
472
|
human = [f"{PROG}: {project} — {how}" if how != "named" else f"{PROG}: {project}",
|
|
420
473
|
doctor_mod.render(lines)]
|
|
421
474
|
if code == 0:
|
|
@@ -135,6 +135,15 @@ def login(url: str, email: str, password: str) -> dict:
|
|
|
135
135
|
{"email": email, "password": password})
|
|
136
136
|
|
|
137
137
|
|
|
138
|
+
def pair_start(url: str) -> dict:
|
|
139
|
+
"""Begin a device-code sign-in: no password, no credential yet (GRPH-1366)."""
|
|
140
|
+
return Client(url).call("POST", "/api/cli/pair")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def pair_poll(url: str, device_code: str) -> dict:
|
|
144
|
+
return Client(url).call("POST", "/api/cli/pair/token", {"device_code": device_code})
|
|
145
|
+
|
|
146
|
+
|
|
138
147
|
def authenticated(url: str, *, act: str = "") -> Client:
|
|
139
148
|
"""A client for this invocation: one refresh exchange, access token held in memory.
|
|
140
149
|
|
|
@@ -249,7 +249,62 @@ def find_supervisor() -> str:
|
|
|
249
249
|
SUPERVISOR_KEY_ENV = "GBFLEET_API_KEY"
|
|
250
250
|
|
|
251
251
|
|
|
252
|
-
def
|
|
252
|
+
def supervisor_key_candidates(project: str, repo: Path | None = None) -> list[tuple[str, str]]:
|
|
253
|
+
"""Every key we could hand the supervisor, as (source name, key), in preference order.
|
|
254
|
+
|
|
255
|
+
The names are for messages; the values never are (GRPH-1346).
|
|
256
|
+
"""
|
|
257
|
+
found: list[tuple[str, str]] = []
|
|
258
|
+
env_key = (os.environ.get(config.API_KEY_ENV) or "").strip()
|
|
259
|
+
if env_key:
|
|
260
|
+
found.append((f"${config.API_KEY_ENV}", env_key))
|
|
261
|
+
stored = config.stored_supervisor_key(project)
|
|
262
|
+
if stored:
|
|
263
|
+
found.append(("the key `gban setup` stored for this project", stored))
|
|
264
|
+
for key in _harness_supervisor_keys(repo):
|
|
265
|
+
found.append(("a key `gban setup` wrote into a harness config", key))
|
|
266
|
+
unique: list[tuple[str, str]] = []
|
|
267
|
+
for name, key in found:
|
|
268
|
+
if key not in [k for _, k in unique]:
|
|
269
|
+
unique.append((name, key))
|
|
270
|
+
return unique
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# GRPH-1346: the same body as `gbfleet.client.OBSERVATION_PROBE`. It must validate: the
|
|
274
|
+
# route's `item_id` is required, and a body without it is 422 before the credential check,
|
|
275
|
+
# so every key would read as able. A real kind and an item no key resolves: 401/403 is the
|
|
276
|
+
# refusal, 404 "no such item" is the pass, and nothing is written either way.
|
|
277
|
+
PROBE_BODY = {"item_id": "__gbfleet_probe_no_such_item__", "kind": "cut_from"}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def can_supervise(url: str, key: str, timeout: float = 5.0) -> bool | None:
|
|
281
|
+
"""Whether the observations route accepts this key: False on 401/403, True on 404 (the
|
|
282
|
+
probe item does not exist, so the credential gate passed) or 2xx, None when the server
|
|
283
|
+
could not be asked or answered anything else — a check that did not run is not a pass."""
|
|
284
|
+
import json
|
|
285
|
+
import urllib.error
|
|
286
|
+
import urllib.request
|
|
287
|
+
|
|
288
|
+
if not url.startswith(("http://", "https://")):
|
|
289
|
+
# urlopen would read `file:` and friends too, and send the credential with it.
|
|
290
|
+
return None
|
|
291
|
+
req = urllib.request.Request(
|
|
292
|
+
url.rstrip("/") + "/api/fleet/observations", data=json.dumps(PROBE_BODY).encode(),
|
|
293
|
+
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
|
|
294
|
+
method="POST")
|
|
295
|
+
try:
|
|
296
|
+
# bandit B310: the scheme is checked to be http(s) above.
|
|
297
|
+
urllib.request.urlopen(req, timeout=timeout).close() # nosec B310
|
|
298
|
+
except urllib.error.HTTPError as exc:
|
|
299
|
+
if exc.code in (401, 403):
|
|
300
|
+
return False
|
|
301
|
+
return True if exc.code == 404 else None
|
|
302
|
+
except Exception: # noqa: BLE001 — could not ask is not a verdict
|
|
303
|
+
return None
|
|
304
|
+
return True
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def resolve_supervisor_key(project: str, repo: Path | None = None, url: str = "") -> str:
|
|
253
308
|
"""The agent key we hand `child_environment` as `api_key`.
|
|
254
309
|
|
|
255
310
|
Order (GRPH-782 remaining hole): `$GRAPHBAN_API_KEY`, then the project-scoped key
|
|
@@ -257,19 +312,44 @@ def resolve_supervisor_key(project: str, repo: Path | None = None) -> str:
|
|
|
257
312
|
harness dest for this repository. `$GBFLEET_API_KEY` the caller set is preserved
|
|
258
313
|
inside `child_environment`, so it is not resolved here.
|
|
259
314
|
|
|
315
|
+
With `url`, a candidate the observations route refuses is passed over for the next one
|
|
316
|
+
that it accepts (GRPH-1346): a key that can build but not supervise cost a whole wave its
|
|
317
|
+
base, lease and PR records. When none can, the first is returned and `until` refuses it
|
|
318
|
+
by name; `supervisor_key_verdict` says why. Without `url` the order alone decides.
|
|
319
|
+
|
|
260
320
|
Never the login session. A refresh token is a different kind of credential.
|
|
261
321
|
"""
|
|
262
|
-
|
|
263
|
-
if
|
|
264
|
-
return
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
322
|
+
candidates = supervisor_key_candidates(project, repo)
|
|
323
|
+
if not candidates:
|
|
324
|
+
return ""
|
|
325
|
+
if url:
|
|
326
|
+
for _, key in candidates:
|
|
327
|
+
if can_supervise(url, key) is not False:
|
|
328
|
+
return key
|
|
329
|
+
return candidates[0][1]
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def supervisor_key_verdict(project: str, url: str, repo: Path | None = None) -> str:
|
|
333
|
+
"""Why no candidate key can supervise, naming sources and never values; "" when one can
|
|
334
|
+
or when the server could not be asked."""
|
|
335
|
+
candidates = supervisor_key_candidates(project, repo)
|
|
336
|
+
if not candidates or not url:
|
|
337
|
+
return ""
|
|
338
|
+
results = [(name, can_supervise(url, key)) for name, key in candidates]
|
|
339
|
+
if any(r is not False for _, r in results):
|
|
340
|
+
return ""
|
|
341
|
+
return ("no key can supervise (the server refused each for observations; recording needs "
|
|
342
|
+
"the planner role and a fleet wave or project scope): "
|
|
343
|
+
+ "; ".join(name for name, _ in results))
|
|
269
344
|
|
|
270
345
|
|
|
271
346
|
def _harness_supervisor_key(repo: Path | None) -> str:
|
|
272
|
-
|
|
347
|
+
keys = _harness_supervisor_keys(repo)
|
|
348
|
+
return keys[0] if keys else ""
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _harness_supervisor_keys(repo: Path | None) -> list[str]:
|
|
352
|
+
"""The keys `gban setup` already wrote into a parent-harness dest.
|
|
273
353
|
|
|
274
354
|
Lazy-imports setup: that module imports this one, and a top-level cycle would
|
|
275
355
|
make `gban doctor` fail to import on a machine that has never run setup.
|
|
@@ -285,7 +365,7 @@ def _harness_supervisor_key(repo: Path | None) -> str:
|
|
|
285
365
|
key = (setup_mod.dest_key(dest, repo) or "").strip()
|
|
286
366
|
if key.startswith(config.AGENT_KEY_PREFIX) and key not in seen:
|
|
287
367
|
seen.append(key)
|
|
288
|
-
return seen
|
|
368
|
+
return seen
|
|
289
369
|
|
|
290
370
|
|
|
291
371
|
def child_environment(api_key: str) -> dict:
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Machine identity and the report `gban setup` / `gban doctor` send (GRPH-1442).
|
|
2
|
+
|
|
3
|
+
The machine id is random and lives in `machine.json` in gban's home. Wiping that directory makes
|
|
4
|
+
a new machine, which is the intent: the id names an installation, not a person or a label.
|
|
5
|
+
|
|
6
|
+
A report that cannot be delivered NEVER counts. It comes back as an UNKNOWN line naming the
|
|
7
|
+
server and the reason, and the caller still exits on its own checks.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import platform
|
|
12
|
+
import shutil
|
|
13
|
+
import socket
|
|
14
|
+
import subprocess # nosec B404 - fixed argv, no shell
|
|
15
|
+
import uuid
|
|
16
|
+
from importlib import metadata
|
|
17
|
+
|
|
18
|
+
from gban import config
|
|
19
|
+
from gban.client import NoSession, Refused, Unreachable, authenticated
|
|
20
|
+
from gban.doctor import UNKNOWN, _line
|
|
21
|
+
|
|
22
|
+
MACHINE_FILE = "machine.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def machine_id() -> str:
|
|
26
|
+
"""The stored id, minted on first use. An unwritable home still yields a (volatile) id."""
|
|
27
|
+
path = config.home() / MACHINE_FILE
|
|
28
|
+
existing = config._read(path).get("machine_id")
|
|
29
|
+
if isinstance(existing, str) and existing:
|
|
30
|
+
return existing
|
|
31
|
+
fresh = "gbm_" + uuid.uuid4().hex
|
|
32
|
+
try:
|
|
33
|
+
config._write(path, {"machine_id": fresh})
|
|
34
|
+
except OSError:
|
|
35
|
+
pass
|
|
36
|
+
return fresh
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _version(dist: str) -> str:
|
|
40
|
+
try:
|
|
41
|
+
return metadata.version(dist)
|
|
42
|
+
except metadata.PackageNotFoundError:
|
|
43
|
+
return ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _gbfleet_version() -> str:
|
|
47
|
+
exe = shutil.which("gbfleet")
|
|
48
|
+
if not exe:
|
|
49
|
+
return ""
|
|
50
|
+
try:
|
|
51
|
+
done = subprocess.run([exe, "--version"], capture_output=True, text=True, # nosec B603
|
|
52
|
+
timeout=5)
|
|
53
|
+
return (done.stdout or "").strip().split()[-1] if done.returncode == 0 and done.stdout.strip() else ""
|
|
54
|
+
except (OSError, subprocess.SubprocessError):
|
|
55
|
+
return ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _harnesses() -> list[str]:
|
|
59
|
+
return [name for name, binary in (("claude", "claude"), ("grok", "grok"), ("codex", "codex"))
|
|
60
|
+
if shutil.which(binary)]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def payload(project: str, lines: list[dict]) -> dict:
|
|
64
|
+
return {
|
|
65
|
+
"machine_id": machine_id(), "label": socket.gethostname().split(".")[0],
|
|
66
|
+
"os": f"{platform.system()} {platform.release()}".strip(),
|
|
67
|
+
"gban_version": _version("gban"), "gbfleet_version": _gbfleet_version(),
|
|
68
|
+
"harnesses": _harnesses(), "project": project,
|
|
69
|
+
"lines": [{"status": l["status"], "name": l["name"], "detail": l.get("detail", "")}
|
|
70
|
+
for l in lines],
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def send(url: str, project: str, lines: list[dict]) -> list[dict]:
|
|
75
|
+
"""Deliver the report. Returns the line to append when it could not be delivered, else []."""
|
|
76
|
+
try:
|
|
77
|
+
authenticated(url, act="report").call("POST", "/api/machines/report",
|
|
78
|
+
payload(project, lines))
|
|
79
|
+
except NoSession as exc:
|
|
80
|
+
return [_unreported(url, exc.why)]
|
|
81
|
+
except Refused as exc:
|
|
82
|
+
return [_unreported(url, exc.detail)]
|
|
83
|
+
except Unreachable as exc:
|
|
84
|
+
return [_unreported(url, str(exc))]
|
|
85
|
+
except Exception as exc: # noqa: BLE001 - a report must never take the command down
|
|
86
|
+
return [_unreported(url, f"{type(exc).__name__}: {exc}")]
|
|
87
|
+
return []
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _unreported(url: str, reason: str) -> dict:
|
|
91
|
+
return _line("report", UNKNOWN, "report", f"could not report to {url}: {reason}")
|
|
@@ -49,6 +49,26 @@ TIERS = ["fleet", "prd"]
|
|
|
49
49
|
LEDGER_SERVER = "graphban"
|
|
50
50
|
FLEET_SERVER = "gbfleet"
|
|
51
51
|
|
|
52
|
+
#: The agents `gban setup` writes a harness config for (see `destinations`). The web
|
|
53
|
+
#: quick-start's agent picker offers the gban route to exactly these and switches the rest to
|
|
54
|
+
#: paste-a-key (GRPH-P67 G9). `scripts/gen_gban_agents.py` exports this to the web bundle;
|
|
55
|
+
#: `cli/tests/test_setup.py` fails when that file drifts, so adding a harness here is the
|
|
56
|
+
#: one edit that changes what the dialog offers.
|
|
57
|
+
SETUP_AGENTS = ("claude", "grok")
|
|
58
|
+
|
|
59
|
+
#: The commands the quick-start dialog's gban route shows, in order, with `{server}` and
|
|
60
|
+
#: `{project}` filled in by the web app. Exported beside SETUP_AGENTS so the dialog never
|
|
61
|
+
#: types its own copy; `cli/tests/test_setup.py` parses each with `gban.cli._parser()`, so a
|
|
62
|
+
#: flag that moves (e.g. `--server` is a top-level option, not a `login` one) fails a test
|
|
63
|
+
#: rather than a person's terminal.
|
|
64
|
+
QUICKSTART_COMMANDS = (
|
|
65
|
+
"uv tool install graphban-cli && uv tool install graphban-fleet",
|
|
66
|
+
"gban --server {server} login",
|
|
67
|
+
"gban --server {server} login --pair",
|
|
68
|
+
"gban setup",
|
|
69
|
+
"gban --project {project} setup",
|
|
70
|
+
)
|
|
71
|
+
|
|
52
72
|
|
|
53
73
|
# ---- where the config goes ------------------------------------------------------------------
|
|
54
74
|
|
|
@@ -375,7 +395,8 @@ def mint(client: Client, project: str, label: str = "") -> dict:
|
|
|
375
395
|
"project_id": project,
|
|
376
396
|
# NULL, deliberately. See the module docstring: only seats expire.
|
|
377
397
|
"expires_in_days": None,
|
|
378
|
-
"tool_tiers": TIERS
|
|
398
|
+
"tool_tiers": TIERS,
|
|
399
|
+
"origin": "gban_setup"})
|
|
379
400
|
|
|
380
401
|
|
|
381
402
|
def verify(url: str, key: str, project: str) -> list[dict]:
|
|
@@ -474,8 +495,9 @@ def run(client: Client, url: str, project: str, repo: Path, *, scope: str = "use
|
|
|
474
495
|
# A key that cannot do the job is replaced, never patched: `tool_tiers` are fixed at
|
|
475
496
|
# mint and there is no route that changes them.
|
|
476
497
|
lines.append(_line("config", UNKNOWN, "credential",
|
|
477
|
-
"the configured
|
|
478
|
-
"
|
|
498
|
+
"the configured credential no longer works (revoked, or it cannot "
|
|
499
|
+
"delegate), so a new one is being minted; if the old one still "
|
|
500
|
+
"works, remove it in Settings → API keys"))
|
|
479
501
|
if not key:
|
|
480
502
|
try:
|
|
481
503
|
minted = mint(client, project)
|
|
@@ -750,6 +772,40 @@ def match(projects: list[dict], here: Path) -> tuple[dict, list[dict]]:
|
|
|
750
772
|
return by_project, notes
|
|
751
773
|
|
|
752
774
|
|
|
775
|
+
def normalize_remote(raw: str) -> str:
|
|
776
|
+
"""`host/owner/name`, lowercased, no scheme, credentials, port or `.git`. The same rule as
|
|
777
|
+
backend/app/services/projects.py:normalize_repo_url (a separate distribution cannot import
|
|
778
|
+
it); tests on both sides pin the same table. "" when it does not look like a remote."""
|
|
779
|
+
value = (raw or "").strip()
|
|
780
|
+
m = re.match(r"^(?:[a-z][a-z0-9+.-]*://(?:[^@/]*@)?([^/:]+)(?::\d*)?|(?:[^@/]*@)?([^/:]+):?)/?(.+)$",
|
|
781
|
+
value, re.IGNORECASE)
|
|
782
|
+
if not m or any(ch.isspace() for ch in value):
|
|
783
|
+
return ""
|
|
784
|
+
path = m.group(3).strip("/")
|
|
785
|
+
path = path[:-4] if path.endswith(".git") else path
|
|
786
|
+
return f"{m.group(1) or m.group(2)}/{path}".lower() if "/" in path else ""
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def git_remotes(here: Path) -> list[str]:
|
|
790
|
+
"""Normalized URLs of every remote of the checkout at `here`, `origin` first. [] when it is
|
|
791
|
+
not a checkout or git is absent — which is "no signal", and falls through to the name match."""
|
|
792
|
+
try:
|
|
793
|
+
names_out = subprocess.run(["git", "-C", str(here), "remote"], capture_output=True,
|
|
794
|
+
text=True, timeout=10)
|
|
795
|
+
if names_out.returncode != 0:
|
|
796
|
+
return []
|
|
797
|
+
out = []
|
|
798
|
+
for name in sorted(names_out.stdout.split(), key=lambda n: n != "origin"):
|
|
799
|
+
got = subprocess.run(["git", "-C", str(here), "remote", "get-url", name],
|
|
800
|
+
capture_output=True, text=True, timeout=10)
|
|
801
|
+
norm = normalize_remote(got.stdout) if got.returncode == 0 else ""
|
|
802
|
+
if norm and norm not in out:
|
|
803
|
+
out.append(norm)
|
|
804
|
+
return out
|
|
805
|
+
except (OSError, subprocess.SubprocessError):
|
|
806
|
+
return []
|
|
807
|
+
|
|
808
|
+
|
|
753
809
|
class Unresolved(Exception):
|
|
754
810
|
"""The directory does not name a project on this deployment (GRPH-795)."""
|
|
755
811
|
|
|
@@ -772,6 +828,15 @@ def resolve_project(client, here: Path, asked: str = "", stored: str = "") -> tu
|
|
|
772
828
|
return asked, "named"
|
|
773
829
|
rows = client.call("GET", "/api/projects")
|
|
774
830
|
projects = [p for p in (rows if isinstance(rows, list) else []) if isinstance(p, dict)]
|
|
831
|
+
# The git remote is the stronger signal: a directory can be renamed, a remote names the repo.
|
|
832
|
+
remotes = git_remotes(here)
|
|
833
|
+
by_remote = [p for p in projects if str(p.get("repo_url") or "") in remotes]
|
|
834
|
+
if len(by_remote) == 1:
|
|
835
|
+
return str(by_remote[0].get("id") or ""), f"matched the git remote {by_remote[0].get('repo_url')!r}"
|
|
836
|
+
if len(by_remote) > 1:
|
|
837
|
+
raise Unresolved(
|
|
838
|
+
f"this checkout's remote matches several projects "
|
|
839
|
+
f"({', '.join(sorted(str(p.get('id')) for p in by_remote))}), so none was chosen — name one with --project")
|
|
775
840
|
hits = [p for p in projects if slug(here.name) in names(p)]
|
|
776
841
|
if len(hits) == 1:
|
|
777
842
|
return str(hits[0].get("id") or ""), f"matched the directory name {here.name!r}"
|