graphban-cli 0.3.0__tar.gz → 0.5.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.5.0}/.gitignore +18 -0
- {graphban_cli-0.3.0 → graphban_cli-0.5.0}/PKG-INFO +33 -12
- {graphban_cli-0.3.0 → graphban_cli-0.5.0}/README.md +32 -11
- {graphban_cli-0.3.0 → graphban_cli-0.5.0}/pyproject.toml +1 -1
- {graphban_cli-0.3.0 → graphban_cli-0.5.0}/src/gban/cli.py +18 -11
- graphban_cli-0.5.0/src/gban/config.py +212 -0
- {graphban_cli-0.3.0 → graphban_cli-0.5.0}/src/gban/doctor.py +70 -7
- graphban_cli-0.5.0/src/gban/gitignore.py +176 -0
- {graphban_cli-0.3.0 → graphban_cli-0.5.0}/src/gban/setup.py +398 -69
- {graphban_cli-0.3.0 → graphban_cli-0.5.0}/src/gban/skills/graphban-delegation/SKILL.md +22 -4
- graphban_cli-0.5.0/src/gban/skills/watch-wave/SKILL.md +66 -0
- {graphban_cli-0.3.0 → graphban_cli-0.5.0}/src/gban/swamp.py +5 -0
- {graphban_cli-0.3.0 → graphban_cli-0.5.0}/tests/test_acts.py +4 -1
- {graphban_cli-0.3.0 → graphban_cli-0.5.0}/tests/test_doctor.py +258 -23
- {graphban_cli-0.3.0 → graphban_cli-0.5.0}/tests/test_session.py +1 -2
- {graphban_cli-0.3.0 → graphban_cli-0.5.0}/tests/test_setup.py +407 -5
- {graphban_cli-0.3.0 → graphban_cli-0.5.0}/tests/test_swamp.py +26 -1
- graphban_cli-0.3.0/src/gban/config.py +0 -102
- {graphban_cli-0.3.0 → graphban_cli-0.5.0}/LICENSE +0 -0
- {graphban_cli-0.3.0 → graphban_cli-0.5.0}/src/gban/__init__.py +0 -0
- {graphban_cli-0.3.0 → graphban_cli-0.5.0}/src/gban/client.py +0 -0
- {graphban_cli-0.3.0 → graphban_cli-0.5.0}/tests/test_login_projects.py +0 -0
- {graphban_cli-0.3.0 → graphban_cli-0.5.0}/tests/test_packaging.py +0 -0
|
@@ -57,6 +57,24 @@ dist-release/
|
|
|
57
57
|
# Project MCP config — holds a real API key
|
|
58
58
|
.mcp.json
|
|
59
59
|
|
|
60
|
+
# gban --scope project writes the same live key into Grok's project file, and
|
|
61
|
+
# gbfleet's grok adapter writes the child's seat there. Tracked, spawn refuses
|
|
62
|
+
# (fleet/tests/test_repo_keeps_its_own_config.py); unignored, it is one `git add`
|
|
63
|
+
# from a committed credential. `.grok/mcp.json` is the pre-GRPH-575 name.
|
|
64
|
+
.grok/config.toml
|
|
65
|
+
.grok/mcp.json
|
|
66
|
+
|
|
67
|
+
# gbfleet: enrolment code (`.gbfleet-instruction`), fallback seat, doctor/spawn
|
|
68
|
+
# probes, in-tree worktree pool. Salvage already excludes the seat paths from a
|
|
69
|
+
# WIP commit; git status in the parent checkout still offers them to `git add`.
|
|
70
|
+
.gbfleet-*
|
|
71
|
+
|
|
72
|
+
# Swamp vault ciphertext, and a nested copy of the adapter. docs/swamp.md: commit
|
|
73
|
+
# `.swamp.yaml`; do not commit these. The adapter is a sibling on purpose — a
|
|
74
|
+
# nested clone is one add from a tree whose licence forbids carrying Swamp source.
|
|
75
|
+
.swamp/
|
|
76
|
+
graphban-swamp/
|
|
77
|
+
|
|
60
78
|
# Cursor claim manifest — per-session runtime state (see .cursor/hooks/README.md)
|
|
61
79
|
.cursor/graphban-claim.json
|
|
62
80
|
.cursor/agentledger-claim.json
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: graphban-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.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,49 @@ 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
|
-
|
|
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, stores that key next to the login
|
|
132
|
+
session (a `gb_sk_…`, never the refresh token), installs the supervisor if it is missing,
|
|
133
|
+
drops the delegation skill into `.claude/skills/`, and then **verifies** rather than
|
|
134
|
+
asserting: it asks the new credential what it can actually see. Restart the session that will
|
|
135
|
+
call `delegate`/`spawn` afterwards — MCP servers are read at startup.
|
|
136
|
+
|
|
137
|
+
`gban fleet` and `gban doctor`'s local half then use that minted key. They do not feed the
|
|
138
|
+
login session to the supervisor. The order is `$GBFLEET_API_KEY` if you set it, then
|
|
139
|
+
`$GRAPHBAN_API_KEY`, then the key `gban setup` stored — and, for a machine that already ran
|
|
140
|
+
setup, the one already in the harness dest.
|
|
134
141
|
|
|
135
142
|
Three properties worth knowing, each of which is a bug this command exists to not have:
|
|
136
143
|
|
|
137
144
|
- **The credential does not expire.** `gban keys mint` produces a *wave* key, which lasts a
|
|
138
145
|
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
|
-
|
|
146
|
+
- **It writes where the harness will actually read.** There is more than one. Claude Code
|
|
147
|
+
reads `~/.claude.json`'s per-project `mcpServers` (JSON), which outranks a repository
|
|
148
|
+
`.mcp.json` — writing the repository file under a stale entry leaves the agent on the old
|
|
149
|
+
key, and surfaces as a JSON parse error because the harness is parsing a 401 body. Grok
|
|
150
|
+
reads `~/.grok/config.toml`'s `mcp_servers` (TOML, snake_case; `mcpServers` parses and
|
|
151
|
+
loads nothing). Writing only Claude's file while Grok holds a different key reports
|
|
152
|
+
success and leaves `delegate` unadvertised (GRPH-825). Grok's `gbfleet` MCP also
|
|
153
|
+
gets `--workspace ~/.grok/gbfleet-wt/<repo>`: the sibling default is a path Grok's
|
|
154
|
+
sandbox cannot write, and spawn then dies as `git worktree add` 128 (GRPH-826).
|
|
155
|
+
Claude keeps the sibling default. `--scope user` is the default because a credential
|
|
156
|
+
outside the repository cannot be committed. Re-running a working setup still
|
|
157
|
+
*repairs* a missing `gbfleet` entry and a missing `--workspace`; reuse skips a mint,
|
|
158
|
+
never a write.
|
|
144
159
|
- **`--auto` matches, and says so.** A project carries no repository link — no remote, no
|
|
145
160
|
path — so `--auto` compares your project ids and names against this directory, what is in
|
|
146
161
|
it, and its siblings. One level, never a recursive walk. Two directories answering to one
|
|
147
162
|
project, or one directory answering to two, are **refused rather than guessed**: a
|
|
148
163
|
credential minted into the wrong repository is not a mistake anybody notices quickly.
|
|
149
164
|
- **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.
|
|
165
|
+
`.mcp.json` or `.grok/config.toml` is refused rather than warned about, because a warning
|
|
166
|
+
attached to committing a credential still commits it.
|
|
167
|
+
- **It gitignores the files it and `gbfleet` write into the checkout.** `.mcp.json`,
|
|
168
|
+
`.cursor/mcp.json`, `.grok/config.toml`, `.gbfleet-*`, `.swamp/`, `graphban-swamp/`.
|
|
169
|
+
A warning that said "make sure it is gitignored" still wrote the key; `gban setup`
|
|
170
|
+
now adds the lines, and `gban doctor` FAILs if a credential file would still be
|
|
171
|
+
committed. It asks git (`check-ignore`), so an equivalent pattern already in the
|
|
172
|
+
file is left alone.
|
|
152
173
|
|
|
153
174
|
## Wiring a checkout to Swamp
|
|
154
175
|
|
|
@@ -112,28 +112,49 @@ 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
|
-
|
|
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, stores that key next to the login
|
|
117
|
+
session (a `gb_sk_…`, never the refresh token), installs the supervisor if it is missing,
|
|
118
|
+
drops the delegation skill into `.claude/skills/`, and then **verifies** rather than
|
|
119
|
+
asserting: it asks the new credential what it can actually see. Restart the session that will
|
|
120
|
+
call `delegate`/`spawn` afterwards — MCP servers are read at startup.
|
|
121
|
+
|
|
122
|
+
`gban fleet` and `gban doctor`'s local half then use that minted key. They do not feed the
|
|
123
|
+
login session to the supervisor. The order is `$GBFLEET_API_KEY` if you set it, then
|
|
124
|
+
`$GRAPHBAN_API_KEY`, then the key `gban setup` stored — and, for a machine that already ran
|
|
125
|
+
setup, the one already in the harness dest.
|
|
119
126
|
|
|
120
127
|
Three properties worth knowing, each of which is a bug this command exists to not have:
|
|
121
128
|
|
|
122
129
|
- **The credential does not expire.** `gban keys mint` produces a *wave* key, which lasts a
|
|
123
130
|
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
|
-
|
|
131
|
+
- **It writes where the harness will actually read.** There is more than one. Claude Code
|
|
132
|
+
reads `~/.claude.json`'s per-project `mcpServers` (JSON), which outranks a repository
|
|
133
|
+
`.mcp.json` — writing the repository file under a stale entry leaves the agent on the old
|
|
134
|
+
key, and surfaces as a JSON parse error because the harness is parsing a 401 body. Grok
|
|
135
|
+
reads `~/.grok/config.toml`'s `mcp_servers` (TOML, snake_case; `mcpServers` parses and
|
|
136
|
+
loads nothing). Writing only Claude's file while Grok holds a different key reports
|
|
137
|
+
success and leaves `delegate` unadvertised (GRPH-825). Grok's `gbfleet` MCP also
|
|
138
|
+
gets `--workspace ~/.grok/gbfleet-wt/<repo>`: the sibling default is a path Grok's
|
|
139
|
+
sandbox cannot write, and spawn then dies as `git worktree add` 128 (GRPH-826).
|
|
140
|
+
Claude keeps the sibling default. `--scope user` is the default because a credential
|
|
141
|
+
outside the repository cannot be committed. Re-running a working setup still
|
|
142
|
+
*repairs* a missing `gbfleet` entry and a missing `--workspace`; reuse skips a mint,
|
|
143
|
+
never a write.
|
|
129
144
|
- **`--auto` matches, and says so.** A project carries no repository link — no remote, no
|
|
130
145
|
path — so `--auto` compares your project ids and names against this directory, what is in
|
|
131
146
|
it, and its siblings. One level, never a recursive walk. Two directories answering to one
|
|
132
147
|
project, or one directory answering to two, are **refused rather than guessed**: a
|
|
133
148
|
credential minted into the wrong repository is not a mistake anybody notices quickly.
|
|
134
149
|
- **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.
|
|
150
|
+
`.mcp.json` or `.grok/config.toml` is refused rather than warned about, because a warning
|
|
151
|
+
attached to committing a credential still commits it.
|
|
152
|
+
- **It gitignores the files it and `gbfleet` write into the checkout.** `.mcp.json`,
|
|
153
|
+
`.cursor/mcp.json`, `.grok/config.toml`, `.gbfleet-*`, `.swamp/`, `graphban-swamp/`.
|
|
154
|
+
A warning that said "make sure it is gitignored" still wrote the key; `gban setup`
|
|
155
|
+
now adds the lines, and `gban doctor` FAILs if a credential file would still be
|
|
156
|
+
committed. It asks git (`check-ignore`), so an equivalent pattern already in the
|
|
157
|
+
file is left alone.
|
|
137
158
|
|
|
138
159
|
## Wiring a checkout to Swamp
|
|
139
160
|
|
|
@@ -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 "
|
|
@@ -275,7 +278,7 @@ def cmd_whoami(args) -> int:
|
|
|
275
278
|
def cmd_doctor(args) -> int:
|
|
276
279
|
url = config.resolve(args.server, config.URL_ENV, "url")
|
|
277
280
|
project = config.resolve(args.project, config.PROJECT_ENV, "project")
|
|
278
|
-
api_key =
|
|
281
|
+
api_key = doctor_mod.resolve_supervisor_key(project)
|
|
279
282
|
lines, code = doctor_mod.run(url, project, api_key)
|
|
280
283
|
_out({"lines": lines, "ok": code == 0}, doctor_mod.render(lines), args.as_json)
|
|
281
284
|
return code
|
|
@@ -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
|
|
|
@@ -406,9 +411,11 @@ def cmd_fleet(args) -> int:
|
|
|
406
411
|
# message is the one its own tests pin.
|
|
407
412
|
#
|
|
408
413
|
# The ENVIRONMENT goes through the same function `doctor` uses (GRPH-782). It used not
|
|
409
|
-
# to, so the doctor certified a local half this command could not reproduce.
|
|
414
|
+
# to, so the doctor certified a local half this command could not reproduce. The key
|
|
415
|
+
# is resolved the same way too: env, then the setup-minted agent key, never the login
|
|
416
|
+
# session — a doctor PASS that does not predict `gban fleet` is the original lie.
|
|
410
417
|
return subprocess.run(argv, env=doctor_mod.child_environment(
|
|
411
|
-
|
|
418
|
+
doctor_mod.resolve_supervisor_key(project))).returncode
|
|
412
419
|
|
|
413
420
|
|
|
414
421
|
def _project(args, act: str) -> str:
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Where `gban` keeps its two facts, and why they are two files (PRD-40 D3, D10).
|
|
2
|
+
|
|
3
|
+
`~/.graphban/` is shared with `graphban`, the operator's database-side tool, and the sharing
|
|
4
|
+
stops at the directory. `gban` reads `gban.json` (`url`, `project`) and `session.json` (a refresh
|
|
5
|
+
token) and **never opens `config.json`**, which is `graphban`'s and may hold a database link.
|
|
6
|
+
|
|
7
|
+
A third file, `supervisor.json`, holds the project-scoped **agent** keys `gban setup` minted
|
|
8
|
+
(`gb_sk_…`). It is not the login session. `gban fleet` / `gban doctor` hand that key to
|
|
9
|
+
`gbfleet`; feeding the refresh token instead is how a working session produces "session
|
|
10
|
+
expired" on the supervisor (GRPH-782).
|
|
11
|
+
|
|
12
|
+
The grill made that stricter than the draft. Reading `config.json` and ignoring the keys it did
|
|
13
|
+
not recognise would have been true and insufficient: the risk is not misreading a database
|
|
14
|
+
password, it is that password living in a file which now has a second consumer and a second
|
|
15
|
+
reason to be copied onto another machine. `graphban` runs in a container against a database;
|
|
16
|
+
`gban` runs on a laptop against HTTP; the credential that must not cross that line lives in its
|
|
17
|
+
own file, so copying a config never carries it.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import stat
|
|
24
|
+
import subprocess
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
#: The directory both tools use. Shared deliberately — a person has one Graphban.
|
|
28
|
+
HOME_ENV = "GRAPHBAN_HOME"
|
|
29
|
+
|
|
30
|
+
#: `gban`'s own settings. NOT `config.json`, which belongs to `graphban` (D10).
|
|
31
|
+
SETTINGS_FILE = "gban.json"
|
|
32
|
+
|
|
33
|
+
#: The refresh token, alone in its own file so that copying settings never carries it.
|
|
34
|
+
SESSION_FILE = "session.json"
|
|
35
|
+
|
|
36
|
+
#: Project-scoped agent keys `gban setup` minted. Next to the session, never inside it —
|
|
37
|
+
#: a refresh token and a `gb_sk_…` are different credentials, and mixing them is the
|
|
38
|
+
#: remaining hole GRPH-782 was reopened for.
|
|
39
|
+
SUPERVISOR_KEYS_FILE = "supervisor.json"
|
|
40
|
+
|
|
41
|
+
#: `graphban`'s file. Named here only so the test that asserts `gban` never opens it has
|
|
42
|
+
#: something to name, and so a reader knows the omission is deliberate.
|
|
43
|
+
NOT_OURS = "config.json"
|
|
44
|
+
|
|
45
|
+
#: What a minted agent key looks like. `gbfleet` wants this; a user refresh token is not it.
|
|
46
|
+
AGENT_KEY_PREFIX = "gb_sk_"
|
|
47
|
+
|
|
48
|
+
URL_ENV = "GRAPHBAN_URL"
|
|
49
|
+
PROJECT_ENV = "GRAPHBAN_PROJECT"
|
|
50
|
+
API_KEY_ENV = "GRAPHBAN_API_KEY"
|
|
51
|
+
|
|
52
|
+
#: Owner read/write and nothing else. A credential at rest gets the same mode the seat files
|
|
53
|
+
#: in `gbfleet` get, for the same reason.
|
|
54
|
+
PRIVATE = stat.S_IRUSR | stat.S_IWUSR
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def home() -> Path:
|
|
58
|
+
return Path(os.environ.get(HOME_ENV) or (Path.home() / ".graphban"))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _read(path: Path) -> dict:
|
|
62
|
+
try:
|
|
63
|
+
loaded = json.loads(path.read_text(encoding="utf-8"))
|
|
64
|
+
except (OSError, ValueError):
|
|
65
|
+
# A missing file and an unreadable one are the same to a caller that has a default,
|
|
66
|
+
# and neither is worth a traceback in front of somebody trying to log in.
|
|
67
|
+
return {}
|
|
68
|
+
return loaded if isinstance(loaded, dict) else {}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _write(path: Path, payload: dict) -> Path:
|
|
72
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
# Created private BEFORE anything is written to it. Writing first and chmod-ing after
|
|
74
|
+
# leaves a window where the token is world-readable, which is the whole failure.
|
|
75
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, PRIVATE)
|
|
76
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
77
|
+
json.dump(payload, fh, indent=1, sort_keys=True)
|
|
78
|
+
fh.write("\n")
|
|
79
|
+
_make_private(path)
|
|
80
|
+
return path
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _make_private(path: Path) -> None:
|
|
84
|
+
"""Owner-only, including on Windows where `chmod 0600` is a no-op (GRPH-855)."""
|
|
85
|
+
os.chmod(path, PRIVATE)
|
|
86
|
+
if os.name != "nt":
|
|
87
|
+
return
|
|
88
|
+
user = os.environ.get("USERNAME") or ""
|
|
89
|
+
if not user:
|
|
90
|
+
return
|
|
91
|
+
subprocess.run(
|
|
92
|
+
["icacls", str(path), "/inheritance:r", "/grant:r", f"{user}:(F)"],
|
|
93
|
+
capture_output=True, text=True, timeout=30,
|
|
94
|
+
)
|
|
95
|
+
listing = subprocess.run(
|
|
96
|
+
["icacls", str(path)], capture_output=True, text=True, timeout=30,
|
|
97
|
+
)
|
|
98
|
+
if listing.returncode != 0:
|
|
99
|
+
return
|
|
100
|
+
me = user.casefold()
|
|
101
|
+
prefix = str(path)
|
|
102
|
+
for line in listing.stdout.splitlines():
|
|
103
|
+
if ":(" not in line:
|
|
104
|
+
continue
|
|
105
|
+
principal = line.split(":(")[0].strip()
|
|
106
|
+
if principal.lower().startswith(prefix.lower()):
|
|
107
|
+
principal = principal[len(prefix):].strip()
|
|
108
|
+
if not principal:
|
|
109
|
+
continue
|
|
110
|
+
if principal.rsplit("\\", 1)[-1].casefold() == me:
|
|
111
|
+
continue
|
|
112
|
+
subprocess.run(
|
|
113
|
+
["icacls", str(path), "/remove", principal],
|
|
114
|
+
capture_output=True, text=True, timeout=30,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def is_private(path: Path) -> bool:
|
|
119
|
+
"""Whether `path` is owner-only. POSIX is the mode bits; Windows is the ACL.
|
|
120
|
+
|
|
121
|
+
Asserting `st_mode == 0o600` goes red on NTFS for a reason that has nothing to do
|
|
122
|
+
with whether anyone else can read the file (GRPH-855).
|
|
123
|
+
"""
|
|
124
|
+
path = Path(path)
|
|
125
|
+
if os.name != "nt":
|
|
126
|
+
try:
|
|
127
|
+
return stat.S_IMODE(path.stat().st_mode) == PRIVATE
|
|
128
|
+
except OSError:
|
|
129
|
+
return False
|
|
130
|
+
user = (os.environ.get("USERNAME") or "").casefold()
|
|
131
|
+
listing = subprocess.run(
|
|
132
|
+
["icacls", str(path)], capture_output=True, text=True, timeout=30,
|
|
133
|
+
)
|
|
134
|
+
if listing.returncode != 0:
|
|
135
|
+
return False
|
|
136
|
+
prefix = str(path)
|
|
137
|
+
saw_owner = False
|
|
138
|
+
for line in listing.stdout.splitlines():
|
|
139
|
+
if ":(" not in line:
|
|
140
|
+
continue
|
|
141
|
+
principal = line.split(":(")[0].strip()
|
|
142
|
+
if principal.lower().startswith(prefix.lower()):
|
|
143
|
+
principal = principal[len(prefix):].strip()
|
|
144
|
+
if not principal:
|
|
145
|
+
continue
|
|
146
|
+
account = principal.rsplit("\\", 1)[-1].casefold()
|
|
147
|
+
if account == user:
|
|
148
|
+
saw_owner = True
|
|
149
|
+
continue
|
|
150
|
+
return False
|
|
151
|
+
return saw_owner
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def settings() -> dict:
|
|
155
|
+
return _read(home() / SETTINGS_FILE)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def save_settings(**values: str) -> Path:
|
|
159
|
+
merged = {k: v for k, v in {**settings(), **values}.items() if v}
|
|
160
|
+
return _write(home() / SETTINGS_FILE, merged)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def session() -> dict:
|
|
164
|
+
return _read(home() / SESSION_FILE)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def save_session(refresh_token: str, *, user: str = "") -> Path:
|
|
168
|
+
return _write(home() / SESSION_FILE,
|
|
169
|
+
{"refresh_token": refresh_token, "user": user})
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def clear_session() -> bool:
|
|
173
|
+
path = home() / SESSION_FILE
|
|
174
|
+
try:
|
|
175
|
+
path.unlink()
|
|
176
|
+
return True
|
|
177
|
+
except FileNotFoundError:
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _agent_key(value: str) -> str:
|
|
182
|
+
value = (value or "").strip()
|
|
183
|
+
return value if value.startswith(AGENT_KEY_PREFIX) else ""
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def save_supervisor_key(project: str, key: str) -> Path | None:
|
|
187
|
+
"""Store the project-scoped agent key `gban setup` minted. Refuses a refresh token."""
|
|
188
|
+
key = _agent_key(key)
|
|
189
|
+
if not project or not key:
|
|
190
|
+
return None
|
|
191
|
+
blob = _read(home() / SUPERVISOR_KEYS_FILE)
|
|
192
|
+
keys = blob.get("keys") if isinstance(blob.get("keys"), dict) else {}
|
|
193
|
+
keys[project] = key
|
|
194
|
+
return _write(home() / SUPERVISOR_KEYS_FILE, {"keys": keys})
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def stored_supervisor_key(project: str) -> str:
|
|
198
|
+
"""The agent key setup stored for this project, or empty. Never reads `session.json`."""
|
|
199
|
+
if not project:
|
|
200
|
+
return ""
|
|
201
|
+
blob = _read(home() / SUPERVISOR_KEYS_FILE)
|
|
202
|
+
keys = blob.get("keys") if isinstance(blob.get("keys"), dict) else {}
|
|
203
|
+
return _agent_key(str(keys.get(project) or ""))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def resolve(flag: str | None, env: str, key: str) -> str:
|
|
207
|
+
"""D10's precedence, in one place: flag, then environment, then the settings file.
|
|
208
|
+
|
|
209
|
+
One function rather than three lookups at each call site, because a precedence that is
|
|
210
|
+
re-implemented per option is one that will eventually differ per option.
|
|
211
|
+
"""
|
|
212
|
+
return (flag or os.environ.get(env) or settings().get(key) or "").strip()
|
|
@@ -16,7 +16,7 @@ import shutil
|
|
|
16
16
|
import subprocess
|
|
17
17
|
from pathlib import Path
|
|
18
18
|
|
|
19
|
-
from gban import config
|
|
19
|
+
from gban import config, gitignore
|
|
20
20
|
from gban.client import Client, NoSession, Refused, Unreachable, authenticated
|
|
21
21
|
|
|
22
22
|
PASS, FAIL, UNKNOWN = "PASS", "FAIL", "UNKNOWN"
|
|
@@ -200,6 +200,31 @@ def install_supervisor(timeout: float = 600.0) -> tuple[bool, str]:
|
|
|
200
200
|
return True, ""
|
|
201
201
|
|
|
202
202
|
|
|
203
|
+
def _sibling_binary(name: str) -> str:
|
|
204
|
+
"""An executable beside this interpreter, trying the platform suffix that actually
|
|
205
|
+
exists. On Windows a `pip install` of both packages drops `gbfleet.exe` into
|
|
206
|
+
`Scripts\\`; on Unix it drops `gbfleet` into `bin/`. Checking the bare name on Windows
|
|
207
|
+
is the same hole the Unix sibling check used to have — the file is there and the lookup
|
|
208
|
+
says it is not.
|
|
209
|
+
|
|
210
|
+
`os.access(X_OK)` is true of almost every file on Windows, so a leftover extensionless
|
|
211
|
+
`gbfleet` (chmod 0644 even) would win over `gbfleet.exe` if we asked for the bare name
|
|
212
|
+
first (GRPH-855).
|
|
213
|
+
"""
|
|
214
|
+
import sys
|
|
215
|
+
|
|
216
|
+
parent = Path(sys.executable).parent
|
|
217
|
+
bare, exe = parent / name, parent / f"{name}.exe"
|
|
218
|
+
if os.name == "nt":
|
|
219
|
+
# X_OK is true of almost every file here, so a leftover extensionless
|
|
220
|
+
# `gbfleet` would win over `gbfleet.exe` (GRPH-855).
|
|
221
|
+
return str(exe) if exe.is_file() else ""
|
|
222
|
+
for candidate in (bare, exe):
|
|
223
|
+
if candidate.is_file() and os.access(candidate, os.X_OK):
|
|
224
|
+
return str(candidate)
|
|
225
|
+
return ""
|
|
226
|
+
|
|
227
|
+
|
|
203
228
|
def find_supervisor() -> str:
|
|
204
229
|
"""`gbfleet`, from beside this interpreter first and only then from PATH.
|
|
205
230
|
|
|
@@ -213,13 +238,10 @@ def find_supervisor() -> str:
|
|
|
213
238
|
PATH still wins for a supervisor the operator installed separately and put there on
|
|
214
239
|
purpose — a sibling is a fallback for the case PATH cannot see, not an override of it.
|
|
215
240
|
"""
|
|
216
|
-
import sys
|
|
217
|
-
|
|
218
241
|
found = shutil.which("gbfleet")
|
|
219
242
|
if found:
|
|
220
243
|
return found
|
|
221
|
-
|
|
222
|
-
return str(beside) if beside.exists() and os.access(beside, os.X_OK) else ""
|
|
244
|
+
return _sibling_binary("gbfleet")
|
|
223
245
|
|
|
224
246
|
|
|
225
247
|
#: What `gbfleet` reads. `gban` names it once, here, and both paths that launch a supervisor
|
|
@@ -227,6 +249,45 @@ def find_supervisor() -> str:
|
|
|
227
249
|
SUPERVISOR_KEY_ENV = "GBFLEET_API_KEY"
|
|
228
250
|
|
|
229
251
|
|
|
252
|
+
def resolve_supervisor_key(project: str, repo: Path | None = None) -> str:
|
|
253
|
+
"""The agent key we hand `child_environment` as `api_key`.
|
|
254
|
+
|
|
255
|
+
Order (GRPH-782 remaining hole): `$GRAPHBAN_API_KEY`, then the project-scoped key
|
|
256
|
+
`gban setup` stored next to `session.json`, then the key setup already wrote into a
|
|
257
|
+
harness dest for this repository. `$GBFLEET_API_KEY` the caller set is preserved
|
|
258
|
+
inside `child_environment`, so it is not resolved here.
|
|
259
|
+
|
|
260
|
+
Never the login session. A refresh token is a different kind of credential.
|
|
261
|
+
"""
|
|
262
|
+
env_key = (os.environ.get(config.API_KEY_ENV) or "").strip()
|
|
263
|
+
if env_key:
|
|
264
|
+
return env_key
|
|
265
|
+
stored = config.stored_supervisor_key(project)
|
|
266
|
+
if stored:
|
|
267
|
+
return stored
|
|
268
|
+
return _harness_supervisor_key(repo)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _harness_supervisor_key(repo: Path | None) -> str:
|
|
272
|
+
"""The key `gban setup` already wrote into a parent-harness dest.
|
|
273
|
+
|
|
274
|
+
Lazy-imports setup: that module imports this one, and a top-level cycle would
|
|
275
|
+
make `gban doctor` fail to import on a machine that has never run setup.
|
|
276
|
+
"""
|
|
277
|
+
from gban import setup as setup_mod
|
|
278
|
+
|
|
279
|
+
repo = (repo or Path.cwd()).resolve()
|
|
280
|
+
seen: list[str] = []
|
|
281
|
+
for scope in ("user", "project"):
|
|
282
|
+
dests, _ = setup_mod.destinations(
|
|
283
|
+
repo, scope, setup_mod.claude_home(), setup_mod.grok_home())
|
|
284
|
+
for dest in dests:
|
|
285
|
+
key = (setup_mod.dest_key(dest, repo) or "").strip()
|
|
286
|
+
if key.startswith(config.AGENT_KEY_PREFIX) and key not in seen:
|
|
287
|
+
seen.append(key)
|
|
288
|
+
return seen[0] if seen else ""
|
|
289
|
+
|
|
290
|
+
|
|
230
291
|
def child_environment(api_key: str) -> dict:
|
|
231
292
|
"""The environment a `gbfleet` child is launched with, for `doctor` AND for `fleet`.
|
|
232
293
|
|
|
@@ -251,8 +312,10 @@ def child_environment(api_key: str) -> dict:
|
|
|
251
312
|
return env
|
|
252
313
|
|
|
253
314
|
|
|
254
|
-
def run(url: str, project: str, api_key: str = ""
|
|
255
|
-
|
|
315
|
+
def run(url: str, project: str, api_key: str = "",
|
|
316
|
+
repo: Path | None = None) -> tuple[list[dict], int]:
|
|
317
|
+
lines = (ledger(url, project) + local(url, project, api_key)
|
|
318
|
+
+ gitignore.check(repo or Path.cwd()))
|
|
256
319
|
worst = max((SEVERITY[l["status"]] for l in lines), default=0)
|
|
257
320
|
# FAIL exits 1, UNKNOWN exits 0: "could not check" is not "broken", and a doctor that
|
|
258
321
|
# failed a script because a laptop lacked `gbfleet` would stop being run.
|