graphban-cli 0.4.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.4.0 → graphban_cli-0.5.0}/.gitignore +18 -0
- {graphban_cli-0.4.0 → graphban_cli-0.5.0}/PKG-INFO +15 -3
- {graphban_cli-0.4.0 → graphban_cli-0.5.0}/README.md +14 -2
- {graphban_cli-0.4.0 → graphban_cli-0.5.0}/pyproject.toml +1 -1
- {graphban_cli-0.4.0 → graphban_cli-0.5.0}/src/gban/cli.py +5 -3
- graphban_cli-0.5.0/src/gban/config.py +212 -0
- {graphban_cli-0.4.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.4.0 → graphban_cli-0.5.0}/src/gban/setup.py +90 -21
- {graphban_cli-0.4.0 → graphban_cli-0.5.0}/src/gban/skills/graphban-delegation/SKILL.md +7 -1
- graphban_cli-0.5.0/src/gban/skills/watch-wave/SKILL.md +66 -0
- {graphban_cli-0.4.0 → graphban_cli-0.5.0}/src/gban/swamp.py +5 -0
- {graphban_cli-0.4.0 → graphban_cli-0.5.0}/tests/test_acts.py +4 -1
- {graphban_cli-0.4.0 → graphban_cli-0.5.0}/tests/test_doctor.py +258 -23
- {graphban_cli-0.4.0 → graphban_cli-0.5.0}/tests/test_session.py +1 -2
- {graphban_cli-0.4.0 → graphban_cli-0.5.0}/tests/test_setup.py +169 -1
- {graphban_cli-0.4.0 → graphban_cli-0.5.0}/tests/test_swamp.py +26 -1
- graphban_cli-0.4.0/src/gban/config.py +0 -102
- {graphban_cli-0.4.0 → graphban_cli-0.5.0}/LICENSE +0 -0
- {graphban_cli-0.4.0 → graphban_cli-0.5.0}/src/gban/__init__.py +0 -0
- {graphban_cli-0.4.0 → graphban_cli-0.5.0}/src/gban/client.py +0 -0
- {graphban_cli-0.4.0 → graphban_cli-0.5.0}/tests/test_login_projects.py +0 -0
- {graphban_cli-0.4.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
|
|
@@ -128,11 +128,17 @@ mint a credential for it while you are standing in another repository, and a key
|
|
|
128
128
|
project is not a mistake anybody notices quickly.
|
|
129
129
|
|
|
130
130
|
`setup` mints a project-scoped credential, writes the `graphban` and `gbfleet` MCP entries
|
|
131
|
-
into every parent harness that would actually read them,
|
|
132
|
-
|
|
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
|
|
133
134
|
asserting: it asks the new credential what it can actually see. Restart the session that will
|
|
134
135
|
call `delegate`/`spawn` afterwards — MCP servers are read at startup.
|
|
135
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.
|
|
141
|
+
|
|
136
142
|
Three properties worth knowing, each of which is a bug this command exists to not have:
|
|
137
143
|
|
|
138
144
|
- **The credential does not expire.** `gban keys mint` produces a *wave* key, which lasts a
|
|
@@ -158,6 +164,12 @@ Three properties worth knowing, each of which is a bug this command exists to no
|
|
|
158
164
|
- **It refuses to write a key into a file git tracks.** `--scope project` on a tracked
|
|
159
165
|
`.mcp.json` or `.grok/config.toml` is refused rather than warned about, because a warning
|
|
160
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.
|
|
161
173
|
|
|
162
174
|
## Wiring a checkout to Swamp
|
|
163
175
|
|
|
@@ -113,11 +113,17 @@ mint a credential for it while you are standing in another repository, and a key
|
|
|
113
113
|
project is not a mistake anybody notices quickly.
|
|
114
114
|
|
|
115
115
|
`setup` mints a project-scoped credential, writes the `graphban` and `gbfleet` MCP entries
|
|
116
|
-
into every parent harness that would actually read them,
|
|
117
|
-
|
|
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
|
|
118
119
|
asserting: it asks the new credential what it can actually see. Restart the session that will
|
|
119
120
|
call `delegate`/`spawn` afterwards — MCP servers are read at startup.
|
|
120
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.
|
|
126
|
+
|
|
121
127
|
Three properties worth knowing, each of which is a bug this command exists to not have:
|
|
122
128
|
|
|
123
129
|
- **The credential does not expire.** `gban keys mint` produces a *wave* key, which lasts a
|
|
@@ -143,6 +149,12 @@ Three properties worth knowing, each of which is a bug this command exists to no
|
|
|
143
149
|
- **It refuses to write a key into a file git tracks.** `--scope project` on a tracked
|
|
144
150
|
`.mcp.json` or `.grok/config.toml` is refused rather than warned about, because a warning
|
|
145
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.
|
|
146
158
|
|
|
147
159
|
## Wiring a checkout to Swamp
|
|
148
160
|
|
|
@@ -278,7 +278,7 @@ def cmd_whoami(args) -> int:
|
|
|
278
278
|
def cmd_doctor(args) -> int:
|
|
279
279
|
url = config.resolve(args.server, config.URL_ENV, "url")
|
|
280
280
|
project = config.resolve(args.project, config.PROJECT_ENV, "project")
|
|
281
|
-
api_key =
|
|
281
|
+
api_key = doctor_mod.resolve_supervisor_key(project)
|
|
282
282
|
lines, code = doctor_mod.run(url, project, api_key)
|
|
283
283
|
_out({"lines": lines, "ok": code == 0}, doctor_mod.render(lines), args.as_json)
|
|
284
284
|
return code
|
|
@@ -411,9 +411,11 @@ def cmd_fleet(args) -> int:
|
|
|
411
411
|
# message is the one its own tests pin.
|
|
412
412
|
#
|
|
413
413
|
# The ENVIRONMENT goes through the same function `doctor` uses (GRPH-782). It used not
|
|
414
|
-
# 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.
|
|
415
417
|
return subprocess.run(argv, env=doctor_mod.child_environment(
|
|
416
|
-
|
|
418
|
+
doctor_mod.resolve_supervisor_key(project))).returncode
|
|
417
419
|
|
|
418
420
|
|
|
419
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.
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Keep gban/gbfleet credentials out of `git add`.
|
|
2
|
+
|
|
3
|
+
The files this tool writes into a checkout — `.mcp.json`, Grok's project config, a
|
|
4
|
+
Cursor seat, a gbfleet enrolment instruction, a Swamp vault — are live keys. A warning
|
|
5
|
+
attached to writing them still writes them, and `git add .` does not read warnings.
|
|
6
|
+
So setup *writes the ignore lines*, and doctor *fails* if a file that exists would
|
|
7
|
+
still be committed. Asking git (`check-ignore`) rather than grepping `.gitignore`
|
|
8
|
+
is the load-bearing bit: an equivalent pattern (`.cursor/`, a user exclude file)
|
|
9
|
+
already does the job, and a string match would either duplicate it or miss it.
|
|
10
|
+
|
|
11
|
+
A path git already tracks is a different refusal. gitignore does not untrack, and
|
|
12
|
+
pretending it does is how a key that is already in the index keeps shipping.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import subprocess
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
# Same three states as `gban.doctor`, inlined so this module cannot import doctor
|
|
20
|
+
# (doctor calls `check`, and a cycle here would make `gban doctor` fail to import).
|
|
21
|
+
PASS, FAIL, UNKNOWN = "PASS", "FAIL", "UNKNOWN"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _line(side: str, status: str, name: str, detail: str = "") -> dict:
|
|
25
|
+
return {"side": side, "status": status, "name": name, "detail": detail, "report": ""}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
#: What we write. Concrete files gban and gbfleet actually emit, plus the two swamp
|
|
29
|
+
#: paths `docs/swamp.md` names. `.gbfleet-*` covers instruction, seat, probes and an
|
|
30
|
+
#: in-tree worktree pool without enumerating each one as it appears.
|
|
31
|
+
PATTERNS = (
|
|
32
|
+
".mcp.json",
|
|
33
|
+
".cursor/mcp.json",
|
|
34
|
+
".grok/config.toml",
|
|
35
|
+
".grok/mcp.json",
|
|
36
|
+
".gbfleet-*",
|
|
37
|
+
".swamp/",
|
|
38
|
+
"graphban-swamp/",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
#: What we ASK git about. A glob is not a path; these are the files that would be
|
|
42
|
+
#: committed. Directory patterns keep the trailing slash: `check-ignore .swamp`
|
|
43
|
+
#: does not honour `.swamp/`, but `check-ignore .swamp/` does. `.gbfleet-instruction`
|
|
44
|
+
#: is the representative of `.gbfleet-*` because it carries a live enrolment code.
|
|
45
|
+
PROBES = (
|
|
46
|
+
".mcp.json",
|
|
47
|
+
".cursor/mcp.json",
|
|
48
|
+
".grok/config.toml",
|
|
49
|
+
".grok/mcp.json",
|
|
50
|
+
".gbfleet-instruction",
|
|
51
|
+
".swamp/",
|
|
52
|
+
"graphban-swamp/",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
HEADER = "# Graphban — live keys and seats. Maintained by `gban setup`."
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def is_repo(path: Path) -> bool:
|
|
59
|
+
"""`.git` is a directory in a clone and a file in a worktree."""
|
|
60
|
+
return (path / ".git").exists()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _git(repo: Path, *args: str) -> subprocess.CompletedProcess:
|
|
64
|
+
return subprocess.run(["git", "-C", str(repo), *args],
|
|
65
|
+
capture_output=True, text=True, timeout=10)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def usable(repo: Path) -> bool:
|
|
69
|
+
"""A `.git` marker is not a repository git will answer. Tests (and `--auto`)
|
|
70
|
+
plant an empty `.git` directory as the marker; writing a gitignore that git
|
|
71
|
+
then cannot honour would FAIL every one of them."""
|
|
72
|
+
if not is_repo(repo):
|
|
73
|
+
return False
|
|
74
|
+
try:
|
|
75
|
+
done = _git(repo, "rev-parse", "--is-inside-work-tree")
|
|
76
|
+
except (OSError, subprocess.SubprocessError):
|
|
77
|
+
return False
|
|
78
|
+
return done.returncode == 0 and done.stdout.strip() == "true"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def tracked(repo: Path, rel: str) -> bool:
|
|
82
|
+
try:
|
|
83
|
+
done = _git(repo, "ls-files", "--error-unmatch", "--", rel)
|
|
84
|
+
except (OSError, subprocess.SubprocessError):
|
|
85
|
+
return False
|
|
86
|
+
return done.returncode == 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def ignored(repo: Path, rel: str) -> bool:
|
|
90
|
+
"""Would `git add` skip this path? Tracked files are not ignored, even when a
|
|
91
|
+
pattern matches — that is git's rule, and it is why a tracked credential is a
|
|
92
|
+
FAIL rather than a write-to-gitignore."""
|
|
93
|
+
try:
|
|
94
|
+
done = _git(repo, "check-ignore", "-q", "--", rel)
|
|
95
|
+
except (OSError, subprocess.SubprocessError):
|
|
96
|
+
return False
|
|
97
|
+
return done.returncode == 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _probe_for(pattern: str) -> str:
|
|
101
|
+
if pattern == ".gbfleet-*":
|
|
102
|
+
return ".gbfleet-instruction"
|
|
103
|
+
return pattern
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def missing(repo: Path) -> list[str]:
|
|
107
|
+
"""Patterns that would not actually ignore their probe path."""
|
|
108
|
+
return [p for p in PATTERNS if not ignored(repo, _probe_for(p))]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def leaking(repo: Path) -> list[str]:
|
|
112
|
+
"""Probes that exist on disk or are tracked, and would still be committed."""
|
|
113
|
+
out = []
|
|
114
|
+
for rel in PROBES:
|
|
115
|
+
disk = rel.rstrip("/")
|
|
116
|
+
if tracked(repo, disk) or (repo / disk).exists():
|
|
117
|
+
if not ignored(repo, rel):
|
|
118
|
+
out.append(disk)
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def ensure(repo: Path) -> list[dict]:
|
|
123
|
+
"""Append any missing patterns to `.gitignore`. Idempotent.
|
|
124
|
+
|
|
125
|
+
Not a git repository: nothing to write (the caller already reports that). A
|
|
126
|
+
pattern git already honours — however it is spelled — is left alone.
|
|
127
|
+
"""
|
|
128
|
+
if not usable(repo):
|
|
129
|
+
return []
|
|
130
|
+
needed = missing(repo)
|
|
131
|
+
if not needed:
|
|
132
|
+
return [_line("config", PASS, "gitignore",
|
|
133
|
+
"credential paths are gitignored")]
|
|
134
|
+
path = repo / ".gitignore"
|
|
135
|
+
try:
|
|
136
|
+
existing = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
137
|
+
block = "\n".join([HEADER, *needed]) + "\n"
|
|
138
|
+
text = block if not existing else existing.rstrip("\n") + "\n\n" + block
|
|
139
|
+
path.write_text(text, encoding="utf-8")
|
|
140
|
+
except OSError as exc:
|
|
141
|
+
return [_line("config", FAIL, "gitignore",
|
|
142
|
+
f"could not write {path}: {exc}. Refusing to leave a key "
|
|
143
|
+
"unignored — add the lines by hand, or use --scope user")]
|
|
144
|
+
still = missing(repo)
|
|
145
|
+
if still:
|
|
146
|
+
return [_line("config", FAIL, "gitignore",
|
|
147
|
+
f"wrote {path} but git still would not ignore "
|
|
148
|
+
f"{', '.join(still)}")]
|
|
149
|
+
return [_line("config", PASS, "gitignore",
|
|
150
|
+
f"added to {path}: {', '.join(needed)}")]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def check(repo: Path) -> list[dict]:
|
|
154
|
+
"""Doctor: fail if a credential file would be committed, not if the patterns
|
|
155
|
+
are merely absent. Absence of the files is not a clean result either — that
|
|
156
|
+
prints UNKNOWN, because 'nobody has written a key here yet' is not 'safe'.
|
|
157
|
+
"""
|
|
158
|
+
if not usable(repo):
|
|
159
|
+
return []
|
|
160
|
+
live = leaking(repo)
|
|
161
|
+
if live:
|
|
162
|
+
tracked_ones = [p for p in live if tracked(repo, p)]
|
|
163
|
+
if tracked_ones:
|
|
164
|
+
return [_line("local", FAIL, "gitignore",
|
|
165
|
+
f"git tracks {', '.join(tracked_ones)} — gitignore will "
|
|
166
|
+
"not stop a commit. `git rm --cached` them, or use "
|
|
167
|
+
"--scope user")]
|
|
168
|
+
return [_line("local", FAIL, "gitignore",
|
|
169
|
+
f"{', '.join(live)} is not gitignored and would be committed. "
|
|
170
|
+
"`gban setup` adds the lines")]
|
|
171
|
+
uncovered = [p for p in PROBES if not ignored(repo, p)]
|
|
172
|
+
if uncovered:
|
|
173
|
+
return [_line("local", UNKNOWN, "gitignore",
|
|
174
|
+
"credential paths are not gitignored yet; `gban setup` adds "
|
|
175
|
+
"the lines so a later spawn cannot commit them")]
|
|
176
|
+
return [_line("local", PASS, "gitignore", "credential paths are gitignored")]
|