graphban-cli 0.1.0__py3-none-any.whl
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.
- gban/__init__.py +0 -0
- gban/cli.py +379 -0
- gban/client.py +154 -0
- gban/config.py +102 -0
- gban/doctor.py +196 -0
- graphban_cli-0.1.0.dist-info/METADATA +135 -0
- graphban_cli-0.1.0.dist-info/RECORD +10 -0
- graphban_cli-0.1.0.dist-info/WHEEL +4 -0
- graphban_cli-0.1.0.dist-info/entry_points.txt +2 -0
- graphban_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
gban/__init__.py
ADDED
|
File without changes
|
gban/cli.py
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""`gban` — the client for a human at a terminal (PRD-40).
|
|
2
|
+
|
|
3
|
+
The v1 verb list is short on purpose (D11). Every verb here is one HTTP call to one route
|
|
4
|
+
that already exists, named one-to-one, because a client that composes several calls into a
|
|
5
|
+
new act becomes a second place the rules live.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import getpass
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
from gban import config, doctor as doctor_mod
|
|
18
|
+
from gban.client import (EXIT_NO_SESSION, EXIT_NO_SUPERVISOR, EXIT_REFUSED, EXIT_UNREACHABLE,
|
|
19
|
+
Client, NoSession,
|
|
20
|
+
Refused, Unreachable, authenticated, login)
|
|
21
|
+
|
|
22
|
+
PROG = "gban"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _parser() -> argparse.ArgumentParser:
|
|
26
|
+
parser = argparse.ArgumentParser(
|
|
27
|
+
prog=PROG,
|
|
28
|
+
description=("A Graphban client for a human at a terminal. The web app, `gbfleet` and "
|
|
29
|
+
"`graphban` all still exist; this is for the acts that otherwise need a "
|
|
30
|
+
"browser, and for finding out why something is stuck."),
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument("--json", action="store_true", dest="as_json",
|
|
33
|
+
help="machine-readable output; the human format is never parsed")
|
|
34
|
+
parser.add_argument("--server", default=None,
|
|
35
|
+
help=f"Graphban base URL (or ${config.URL_ENV}, or {config.SETTINGS_FILE})")
|
|
36
|
+
parser.add_argument("--project", default=None,
|
|
37
|
+
help=f"project id (or ${config.PROJECT_ENV}, or {config.SETTINGS_FILE})")
|
|
38
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
39
|
+
|
|
40
|
+
login_cmd = sub.add_parser(
|
|
41
|
+
"login", help="start a session on this machine",
|
|
42
|
+
description=("Exchanges an email and password for a session. The refresh token is "
|
|
43
|
+
"written to session.json at mode 600; the access token is never stored, "
|
|
44
|
+
"because it is renewed on every invocation anyway."))
|
|
45
|
+
login_cmd.add_argument("--email", default=None)
|
|
46
|
+
|
|
47
|
+
sub.add_parser("logout", help="end the session, here and on the server")
|
|
48
|
+
sub.add_parser(
|
|
49
|
+
"doctor", help="check both halves: the ledger, and the local fleet",
|
|
50
|
+
description=("Everything that can be checked before anything is spawned. The ledger "
|
|
51
|
+
"half runs here; the local half is `gbfleet doctor`, run as a subprocess. "
|
|
52
|
+
"Neither half silences the other: what could not be checked prints "
|
|
53
|
+
"UNKNOWN with its reason, never a pass."))
|
|
54
|
+
|
|
55
|
+
fleet = sub.add_parser(
|
|
56
|
+
"fleet", help="hand off to gbfleet (the supervisor)",
|
|
57
|
+
description=("Passes everything through to `gbfleet` and returns its exit code "
|
|
58
|
+
"unchanged — 75 is stuck, 69 an unreachable model endpoint, 55 a spent "
|
|
59
|
+
"budget, and folding those into one code would destroy a taxonomy the "
|
|
60
|
+
"supervisor's own tests pin."),
|
|
61
|
+
add_help=False)
|
|
62
|
+
fleet.add_argument("rest", nargs=argparse.REMAINDER,
|
|
63
|
+
help="arguments for gbfleet; try `gban fleet --help`")
|
|
64
|
+
sub.add_parser("whoami", help="who this session belongs to, and where it points")
|
|
65
|
+
|
|
66
|
+
seats = sub.add_parser(
|
|
67
|
+
"seats", help="the seats a wave was issued, and issuing more",
|
|
68
|
+
description=("A SEAT is an enrolment code: one agent's right to register once, for "
|
|
69
|
+
"half an hour. It is not a credential — the credential is the API key "
|
|
70
|
+
"the child authenticates with, and `gban keys` is where those live."))
|
|
71
|
+
seats_do = seats.add_subparsers(dest="act")
|
|
72
|
+
issue = seats_do.add_parser("issue", help="issue seats, one role per agent")
|
|
73
|
+
issue.add_argument("roles", nargs="+", metavar="ROLE",
|
|
74
|
+
help="one entry per agent, repeats included: worker worker planner")
|
|
75
|
+
issue.add_argument("--wave", default="",
|
|
76
|
+
help="blank means the next one, computed server-side")
|
|
77
|
+
revoke = seats_do.add_parser("revoke-unused", help="throw away seats nobody redeemed")
|
|
78
|
+
revoke.add_argument("--wave", default="")
|
|
79
|
+
|
|
80
|
+
agents = sub.add_parser(
|
|
81
|
+
"agents", help="the roster, and re-tasking one",
|
|
82
|
+
description=("Who is live, what each is doing, and — the reason this verb exists — "
|
|
83
|
+
"what any of them was last refused and why."))
|
|
84
|
+
agents_do = agents.add_subparsers(dest="act")
|
|
85
|
+
role = agents_do.add_parser(
|
|
86
|
+
"role", help="re-task a live agent, as the human who owns its credential",
|
|
87
|
+
description=("The credential ceiling still decides. A role the agent's key does not "
|
|
88
|
+
"permit is refused by the server, and widening a ceiling means minting "
|
|
89
|
+
"a different credential — keeping those two acts apart is the point of "
|
|
90
|
+
"having a ceiling. Lands on the agent's next poll."))
|
|
91
|
+
role.add_argument("agent_id")
|
|
92
|
+
role.add_argument("role", metavar="ROLE")
|
|
93
|
+
role.add_argument("--reason", default="", help="recorded on the event, and told to the agent")
|
|
94
|
+
|
|
95
|
+
keys = sub.add_parser("keys", help="the credentials this project's agents authenticate with")
|
|
96
|
+
keys_do = keys.add_subparsers(dest="act")
|
|
97
|
+
mint = keys_do.add_parser(
|
|
98
|
+
"mint", help="mint a credential narrowed to a role, or to several",
|
|
99
|
+
description=("Repeat --role to mint a credential an agent can be RE-TASKED within. "
|
|
100
|
+
"One role is the default and is a real bound: it is what stops a client "
|
|
101
|
+
"config from registering a worker as a planner. But a role change cannot "
|
|
102
|
+
"climb past the credential the agent already holds, so an agent minted "
|
|
103
|
+
"for one role can never be promoted without being restarted."))
|
|
104
|
+
mint.add_argument("--role", required=True, action="append", metavar="ROLE",
|
|
105
|
+
help="repeatable; the first is the role the agent registers into")
|
|
106
|
+
mint.add_argument("--wave", default="wave-1")
|
|
107
|
+
mint.add_argument("--label", default="")
|
|
108
|
+
return parser
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _out(payload: dict, human: str, as_json: bool) -> None:
|
|
112
|
+
print(json.dumps(payload, indent=1, sort_keys=True) if as_json else human)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _server(args) -> str:
|
|
116
|
+
url = config.resolve(args.server, config.URL_ENV, "url")
|
|
117
|
+
if not url:
|
|
118
|
+
print(f"{PROG}: no server. Pass --server, set ${config.URL_ENV}, or run `gban login "
|
|
119
|
+
f"--server …` once.", file=sys.stderr)
|
|
120
|
+
raise SystemExit(EXIT_REFUSED)
|
|
121
|
+
return url
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def cmd_login(args) -> int:
|
|
125
|
+
url = _server(args)
|
|
126
|
+
# A TERMINAL, OR NOTHING. `getpass` falls back to a plain echoing read when it cannot
|
|
127
|
+
# turn echo off, and warns about it — which is the wrong trade for a password: the
|
|
128
|
+
# warning arrives after the person has already decided to type. Without a tty the
|
|
129
|
+
# password would land in the scrollback, in a transcript, or in whatever captured the
|
|
130
|
+
# session. Refusing is the only safe branch, and it is not a limitation somebody can
|
|
131
|
+
# work around by trying harder.
|
|
132
|
+
if not sys.stdin.isatty():
|
|
133
|
+
print(f"{PROG}: `{PROG} login` needs a terminal. Without one, the prompt cannot turn "
|
|
134
|
+
f"off echo and your password would be written to the scrollback.\n"
|
|
135
|
+
f" Run it in a shell, not through a pipe, a heredoc or an editor's "
|
|
136
|
+
f"command runner.", file=sys.stderr)
|
|
137
|
+
return EXIT_REFUSED
|
|
138
|
+
try:
|
|
139
|
+
email = args.email or input("email: ").strip()
|
|
140
|
+
# Prompted, never an argument: argv is world-readable in `ps`, and a password in
|
|
141
|
+
# shell history is a credential nobody remembers leaving there.
|
|
142
|
+
password = getpass.getpass("password: ")
|
|
143
|
+
except (EOFError, KeyboardInterrupt):
|
|
144
|
+
# Somebody pressed ctrl-C or the input ended. One line, not a traceback — the
|
|
145
|
+
# same rule criterion 4 applies to an expired session applies to a cancelled login.
|
|
146
|
+
print(f"\n{PROG}: cancelled", file=sys.stderr)
|
|
147
|
+
return EXIT_REFUSED
|
|
148
|
+
pair = login(url, email, password)
|
|
149
|
+
refresh = pair.get("refresh_token") or ""
|
|
150
|
+
if not refresh:
|
|
151
|
+
print(f"{PROG}: the server returned no refresh token", file=sys.stderr)
|
|
152
|
+
return EXIT_REFUSED
|
|
153
|
+
config.save_settings(url=url, project=config.resolve(args.project, config.PROJECT_ENV,
|
|
154
|
+
"project"))
|
|
155
|
+
path = config.save_session(refresh, user=email)
|
|
156
|
+
_out({"server": url, "session": str(path), "user": email},
|
|
157
|
+
f"{PROG}: signed in to {url} as {email}\n session stored at {path} (mode 600)",
|
|
158
|
+
args.as_json)
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def cmd_logout(args) -> int:
|
|
163
|
+
url = _server(args)
|
|
164
|
+
told_server, why = False, ""
|
|
165
|
+
try:
|
|
166
|
+
authenticated(url).call("POST", "/api/auth/logout")
|
|
167
|
+
told_server = True
|
|
168
|
+
except NoSession:
|
|
169
|
+
# Already dead server-side. Not an error: the person asked to be logged out and they
|
|
170
|
+
# are, which is the outcome they wanted.
|
|
171
|
+
told_server, why = True, "the session was already ended"
|
|
172
|
+
except (Unreachable, Refused) as exc:
|
|
173
|
+
why = str(exc)
|
|
174
|
+
removed = config.clear_session()
|
|
175
|
+
# The file goes EITHER WAY. Leaving it because the server could not be told would leave a
|
|
176
|
+
# live token on disk belonging to somebody who believes they logged out, which is the one
|
|
177
|
+
# outcome worth avoiding here.
|
|
178
|
+
human = f"{PROG}: signed out" + ("" if told_server else
|
|
179
|
+
f"\n WARNING: the server was not told ({why}).\n"
|
|
180
|
+
" The local session is gone; the server-side one "
|
|
181
|
+
"stays valid until it expires.\n Revoke it from the "
|
|
182
|
+
"web app, or run `gban logout` again when the network is "
|
|
183
|
+
"back.")
|
|
184
|
+
if not removed and told_server:
|
|
185
|
+
human = f"{PROG}: no local session to remove"
|
|
186
|
+
_out({"signed_out": True, "server_told": told_server, "local_removed": removed,
|
|
187
|
+
"reason": why}, human, args.as_json)
|
|
188
|
+
return 0
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def cmd_whoami(args) -> int:
|
|
192
|
+
url = _server(args)
|
|
193
|
+
me = authenticated(url).call("GET", "/api/auth/me")
|
|
194
|
+
project = config.resolve(args.project, config.PROJECT_ENV, "project")
|
|
195
|
+
_out({"server": url, "project": project, **me},
|
|
196
|
+
f"{PROG}: {me.get('email') or me.get('id')} at {url}"
|
|
197
|
+
+ (f"\n project {project}" if project else "\n no default project set"),
|
|
198
|
+
args.as_json)
|
|
199
|
+
return 0
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def cmd_doctor(args) -> int:
|
|
203
|
+
url = config.resolve(args.server, config.URL_ENV, "url")
|
|
204
|
+
project = config.resolve(args.project, config.PROJECT_ENV, "project")
|
|
205
|
+
api_key = os.environ.get(config.API_KEY_ENV, "")
|
|
206
|
+
lines, code = doctor_mod.run(url, project, api_key)
|
|
207
|
+
_out({"lines": lines, "ok": code == 0}, doctor_mod.render(lines), args.as_json)
|
|
208
|
+
return code
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def cmd_fleet(args) -> int:
|
|
212
|
+
"""A subprocess, never an import (D5).
|
|
213
|
+
|
|
214
|
+
Keeps `gban` free of the supervisor's dependencies, keeps the Apache-2.0 boundary intact,
|
|
215
|
+
and leaves `gbfleet --help` authoritative about its own commands.
|
|
216
|
+
"""
|
|
217
|
+
binary = doctor_mod.find_supervisor()
|
|
218
|
+
if not binary:
|
|
219
|
+
print(f"{PROG}: gbfleet is not installed here. Install it with:\n"
|
|
220
|
+
f" {doctor_mod.INSTALL_SUPERVISOR}\n"
|
|
221
|
+
f" …or run it from the repository's fleet/ directory.", file=sys.stderr)
|
|
222
|
+
return EXIT_NO_SUPERVISOR
|
|
223
|
+
argv = [binary, *[a for a in args.rest if a != "--"]]
|
|
224
|
+
url = config.resolve(args.server, config.URL_ENV, "url")
|
|
225
|
+
if url and "--server" not in argv:
|
|
226
|
+
argv += ["--server", url]
|
|
227
|
+
project = config.resolve(args.project, config.PROJECT_ENV, "project")
|
|
228
|
+
if project and "--project" not in argv:
|
|
229
|
+
argv += ["--project", project]
|
|
230
|
+
# Its exit code, unchanged. `gban` adds nothing and explains nothing: the supervisor's
|
|
231
|
+
# message is the one its own tests pin.
|
|
232
|
+
#
|
|
233
|
+
# The ENVIRONMENT goes through the same function `doctor` uses (GRPH-782). It used not
|
|
234
|
+
# to, so the doctor certified a local half this command could not reproduce.
|
|
235
|
+
return subprocess.run(argv, env=doctor_mod.child_environment(
|
|
236
|
+
os.environ.get(config.API_KEY_ENV, ""))).returncode
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _project(args, act: str) -> str:
|
|
240
|
+
project = config.resolve(args.project, config.PROJECT_ENV, "project")
|
|
241
|
+
if not project:
|
|
242
|
+
print(f"{PROG}: `{PROG} {act}` needs a project. Pass --project, set "
|
|
243
|
+
f"${config.PROJECT_ENV}, or run `gban login --project …` once.", file=sys.stderr)
|
|
244
|
+
raise SystemExit(EXIT_REFUSED)
|
|
245
|
+
return project
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _fleet_read(args, act: str) -> tuple[dict, str]:
|
|
249
|
+
"""The one read behind `seats`, `agents` and `keys` (D11).
|
|
250
|
+
|
|
251
|
+
`GET /api/fleet` already returns the roster, the seats and the credentials together,
|
|
252
|
+
because they are read together. Three verbs over one route is not three routes.
|
|
253
|
+
"""
|
|
254
|
+
url, project = _server(args), _project(args, act)
|
|
255
|
+
client = authenticated(url, act=act)
|
|
256
|
+
return client.call("GET", f"/api/fleet?project_id={project}"), project
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def cmd_seats(args) -> int:
|
|
260
|
+
if args.act == "issue":
|
|
261
|
+
url, project = _server(args), _project(args, "seats issue")
|
|
262
|
+
out = authenticated(url, act="seats issue").call(
|
|
263
|
+
"POST", "/api/fleet/seats",
|
|
264
|
+
{"project_id": project, "roles": args.roles, "wave": args.wave})
|
|
265
|
+
# The codes are returned ONCE, by the server, and nothing here stores them. A CLI
|
|
266
|
+
# that helpfully wrote them to a file would be inventing a second credential at rest
|
|
267
|
+
# that no route and no test knows about.
|
|
268
|
+
lines = [f"{PROG}: {len(out.get('seats', []))} seats on {out.get('wave')}",
|
|
269
|
+
" each code is shown once and is not stored anywhere:"]
|
|
270
|
+
lines += [f" {s['code']} {s['role']}" for s in out.get("seats", [])]
|
|
271
|
+
lines.append(" feed them to `gban fleet up --seats-file`, one per line.")
|
|
272
|
+
_out(out, "\n".join(lines), args.as_json)
|
|
273
|
+
return 0
|
|
274
|
+
if args.act == "revoke-unused":
|
|
275
|
+
url, project = _server(args), _project(args, "seats revoke-unused")
|
|
276
|
+
out = authenticated(url, act="seats revoke-unused").call(
|
|
277
|
+
"POST", "/api/fleet/seats/revoke-unused",
|
|
278
|
+
{"project_id": project, "wave": args.wave or None})
|
|
279
|
+
_out(out, f"{PROG}: {out.get('revoked', 0)} unused seats revoked"
|
|
280
|
+
f"\n consumed seats are untouched: they record which agent took what.",
|
|
281
|
+
args.as_json)
|
|
282
|
+
return 0
|
|
283
|
+
fleet, _ = _fleet_read(args, "seats")
|
|
284
|
+
seats = fleet.get("seats", [])
|
|
285
|
+
human = [f"{PROG}: {len(seats)} seats"] + [
|
|
286
|
+
f" {s.get('wave', ''):<10} {s.get('role', ''):<8} {s.get('state', '')}"
|
|
287
|
+
+ (f" taken by {s['consumed_by']}" if s.get("consumed_by") else "")
|
|
288
|
+
for s in seats] or [f"{PROG}: no seats"]
|
|
289
|
+
_out({"seats": seats}, "\n".join(human), args.as_json)
|
|
290
|
+
return 0
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def cmd_agents(args) -> int:
|
|
294
|
+
if args.act == "role":
|
|
295
|
+
url = _server(args)
|
|
296
|
+
# No project in the path: the agent id already names one, and the server resolves it.
|
|
297
|
+
# Asking for a project here would let a person name one the agent is not on.
|
|
298
|
+
out = authenticated(url, act="agents role").call(
|
|
299
|
+
"PUT", f"/api/fleet/agents/{args.agent_id}/role",
|
|
300
|
+
{"role": args.role, "reason": args.reason})
|
|
301
|
+
_out(out, f"{PROG}: {out.get('agent_id')} is now {out.get('active_role')}"
|
|
302
|
+
f"\n {out.get("takes_effect") or "on the agent's next poll"}", args.as_json)
|
|
303
|
+
return 0
|
|
304
|
+
fleet, _ = _fleet_read(args, "agents")
|
|
305
|
+
agents = fleet.get("agents", [])
|
|
306
|
+
human = [f"{PROG}: {len(agents)} agents"]
|
|
307
|
+
for a in agents:
|
|
308
|
+
# WHAT IT COULD BE MOVED TO, next to what it is. Without the ceiling on the row,
|
|
309
|
+
# `agents role` is a coin flip against a bound nothing shows (GRPH-780).
|
|
310
|
+
ceiling = a.get("credential_roles") or []
|
|
311
|
+
human.append(f" {a.get('key') or a.get('id'):<12} {a.get('active_role', ''):<8} "
|
|
312
|
+
f"{a.get('state', ''):<10} {a.get('credential') or ''}"
|
|
313
|
+
+ (f" [{'|'.join(ceiling)}]" if len(ceiling) > 1 else ""))
|
|
314
|
+
# THE LINE THIS VERB EXISTS FOR (criterion 8). A roster that says "idle worker" for an
|
|
315
|
+
# agent being refused every call it makes is the thing that cost an afternoon and a
|
|
316
|
+
# database query on Super-Arc.
|
|
317
|
+
refusal = a.get("last_refusal") or {}
|
|
318
|
+
if refusal.get("tool"):
|
|
319
|
+
human.append(f" last refused {refusal['tool']} x{refusal.get('count', 1)}: "
|
|
320
|
+
f"{refusal.get('reason', '')}")
|
|
321
|
+
_out({"agents": agents}, "\n".join(human) if agents else f"{PROG}: no agents", args.as_json)
|
|
322
|
+
return 0
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def cmd_keys(args) -> int:
|
|
326
|
+
if args.act == "mint":
|
|
327
|
+
url, project = _server(args), _project(args, "keys mint")
|
|
328
|
+
first, *also = args.role
|
|
329
|
+
out = authenticated(url, act="keys mint").call(
|
|
330
|
+
"POST", "/api/fleet/keys",
|
|
331
|
+
{"project_id": project, "role": first, "also": also, "wave": args.wave,
|
|
332
|
+
"label": args.label})
|
|
333
|
+
ceiling = out.get("roles") or [out.get("role")]
|
|
334
|
+
_out(out, f"{PROG}: {out.get('plaintext')}"
|
|
335
|
+
f"\n {out.get('role')} on {out.get('wave')}, shown once, expires "
|
|
336
|
+
f"{out.get('expires_at')}"
|
|
337
|
+
+ (f"\n re-taskable within {', '.join(ceiling)}" if len(ceiling) > 1
|
|
338
|
+
else "\n one role only: an agent on this key cannot be re-tasked"),
|
|
339
|
+
args.as_json)
|
|
340
|
+
return 0
|
|
341
|
+
fleet, _ = _fleet_read(args, "keys")
|
|
342
|
+
creds = fleet.get("credentials", [])
|
|
343
|
+
human = [f"{PROG}: {len(creds)} credentials"] + [
|
|
344
|
+
f" {c.get('prefix', ''):<14} {c.get('name', ''):<20} {c.get('wave') or '-':<10}"
|
|
345
|
+
+ (" REVOKED" if c.get("revoked") else "")
|
|
346
|
+
+ (" all-in-one" if c.get("posture") == "single" else "")
|
|
347
|
+
for c in creds]
|
|
348
|
+
_out({"credentials": creds}, "\n".join(human) if creds else f"{PROG}: no credentials",
|
|
349
|
+
args.as_json)
|
|
350
|
+
return 0
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
COMMANDS = {"login": cmd_login, "logout": cmd_logout, "whoami": cmd_whoami,
|
|
354
|
+
"doctor": cmd_doctor, "fleet": cmd_fleet, "seats": cmd_seats,
|
|
355
|
+
"agents": cmd_agents, "keys": cmd_keys}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def main(argv: list[str] | None = None) -> int:
|
|
359
|
+
args = _parser().parse_args(argv)
|
|
360
|
+
try:
|
|
361
|
+
return COMMANDS[args.command](args)
|
|
362
|
+
except NoSession as exc:
|
|
363
|
+
# Never a 401 traceback: the person needs one instruction, and this is it.
|
|
364
|
+
print(f"{PROG}: {exc.advice(PROG)}", file=sys.stderr)
|
|
365
|
+
return EXIT_NO_SESSION
|
|
366
|
+
except Unreachable as exc:
|
|
367
|
+
print(f"{PROG}: {exc}", file=sys.stderr)
|
|
368
|
+
return EXIT_UNREACHABLE
|
|
369
|
+
except Refused as exc:
|
|
370
|
+
# The server's own words, unedited (D8). Its `hint` is already the machine-readable
|
|
371
|
+
# next step, and re-wording it here would be a second definition of the rule.
|
|
372
|
+
print(f"{PROG}: {exc.detail}", file=sys.stderr)
|
|
373
|
+
if exc.hint:
|
|
374
|
+
print(f" {exc.hint}", file=sys.stderr)
|
|
375
|
+
return EXIT_REFUSED
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
if __name__ == "__main__": # pragma: no cover
|
|
379
|
+
raise SystemExit(main())
|
gban/client.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""The one place `gban` talks to a Graphban server (PRD-40 D3, D11).
|
|
2
|
+
|
|
3
|
+
Every verb is one endpoint, called once. The only call that is not a verb is the token
|
|
4
|
+
exchange, and it is transport rather than policy: it carries no rule, decides nothing, and
|
|
5
|
+
happens before any verb runs.
|
|
6
|
+
|
|
7
|
+
**The session model, and why it needs no machinery.** `POST /api/auth/refresh` issues a new
|
|
8
|
+
pair but does not consume the token presented — validity is keyed on `user.token_version`,
|
|
9
|
+
which moves only on logout or a password change (AL-59). So an invocation exchanges once, holds
|
|
10
|
+
the access token in memory, makes its call, and exits; two `gban` processes at once cannot
|
|
11
|
+
disturb each other; and `session.json` is written only by `gban login`, never per call. Writing
|
|
12
|
+
it every time would manufacture the race that does not otherwise exist.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import urllib.error
|
|
19
|
+
import urllib.request
|
|
20
|
+
|
|
21
|
+
from gban import config
|
|
22
|
+
|
|
23
|
+
#: Exit codes. Chosen so they cannot collide with a passed-through `gbfleet` code, which
|
|
24
|
+
#: carries meaning of its own (75 stuck, 69 unreachable endpoint, 55 budget) and is returned
|
|
25
|
+
#: unchanged by `gban fleet` (D5).
|
|
26
|
+
EXIT_REFUSED = 1
|
|
27
|
+
EXIT_UNREACHABLE = 2
|
|
28
|
+
EXIT_NO_SESSION = 3
|
|
29
|
+
EXIT_NO_SUPERVISOR = 4
|
|
30
|
+
|
|
31
|
+
TIMEOUT = 30.0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Unreachable(Exception):
|
|
35
|
+
"""No HTTP response at all: the server, the network, or the URL."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class NoSession(Exception):
|
|
39
|
+
"""No stored session, or one the server will not renew.
|
|
40
|
+
|
|
41
|
+
Expired and revoked are deliberately the SAME state here. Both come back 401, and the
|
|
42
|
+
person does the same thing either way — a distinction would be precision nobody can act on.
|
|
43
|
+
|
|
44
|
+
"You have a credential, but not one that can do this" IS a different state, and the only
|
|
45
|
+
one where the next step is not simply `gban login` (criterion 5). Somebody who exported
|
|
46
|
+
`GRAPHBAN_API_KEY` and watched `gban fleet` work has every reason to read "session expired"
|
|
47
|
+
as a bug in the tool rather than as a statement about what a key is for.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, why: str, *, act: str = "", have_key: bool = False) -> None:
|
|
51
|
+
super().__init__(why)
|
|
52
|
+
self.why = why
|
|
53
|
+
self.act = act
|
|
54
|
+
self.have_key = have_key
|
|
55
|
+
|
|
56
|
+
def advice(self, prog: str) -> str:
|
|
57
|
+
if self.have_key and self.act:
|
|
58
|
+
return (f"`{prog} {self.act}` needs a session, not an API key. It acts as YOU — "
|
|
59
|
+
f"the ledger records which human did it — and ${config.API_KEY_ENV} names "
|
|
60
|
+
f"an agent.\n Run `{prog} login`.")
|
|
61
|
+
return f"session expired, run `{prog} login`"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Refused(Exception):
|
|
65
|
+
"""The server said no, in its own words (D8).
|
|
66
|
+
|
|
67
|
+
Carries the server's `detail` and `hint` unedited. Re-wording either would put a second
|
|
68
|
+
copy of a rule in the client, and the server's phrasing is the one under test.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(self, status: int, detail: str, hint: str = "") -> None:
|
|
72
|
+
super().__init__(detail)
|
|
73
|
+
self.status = status
|
|
74
|
+
self.detail = detail
|
|
75
|
+
self.hint = hint
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Client:
|
|
79
|
+
"""A server, and the credential this invocation is using."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, url: str, *, token: str = "", api_key: str = "") -> None:
|
|
82
|
+
self.url = url.rstrip("/")
|
|
83
|
+
self.token = token
|
|
84
|
+
self.api_key = api_key
|
|
85
|
+
|
|
86
|
+
# ---- transport ---------------------------------------------------------------------
|
|
87
|
+
def _open(self, method: str, path: str, body: dict | None, headers: dict) -> tuple[int, dict]:
|
|
88
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
89
|
+
request = urllib.request.Request(self.url + path, data=data, method=method,
|
|
90
|
+
headers={"Content-Type": "application/json", **headers})
|
|
91
|
+
try:
|
|
92
|
+
with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
|
|
93
|
+
raw = response.read().decode() or "{}"
|
|
94
|
+
return response.status, (json.loads(raw) if raw.strip() else {})
|
|
95
|
+
except urllib.error.HTTPError as exc:
|
|
96
|
+
raw = exc.read().decode() or "{}"
|
|
97
|
+
try:
|
|
98
|
+
payload = json.loads(raw)
|
|
99
|
+
except ValueError:
|
|
100
|
+
payload = {"detail": raw[:400]}
|
|
101
|
+
return exc.code, payload if isinstance(payload, dict) else {"detail": str(payload)}
|
|
102
|
+
except (urllib.error.URLError, OSError, TimeoutError) as exc:
|
|
103
|
+
# No HTTP response at all. Structurally different from a refusal, and reported as
|
|
104
|
+
# such — the client never reads a message to decide which failure it is in (D8).
|
|
105
|
+
raise Unreachable(f"could not reach {self.url}: {exc}") from exc
|
|
106
|
+
|
|
107
|
+
def call(self, method: str, path: str, body: dict | None = None) -> dict:
|
|
108
|
+
headers = {}
|
|
109
|
+
if self.token:
|
|
110
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
111
|
+
elif self.api_key:
|
|
112
|
+
headers["X-API-Key"] = self.api_key
|
|
113
|
+
status, payload = self._open(method, path, body, headers)
|
|
114
|
+
if status >= 400:
|
|
115
|
+
detail = payload.get("detail") or payload.get("message") or f"HTTP {status}"
|
|
116
|
+
if isinstance(detail, dict):
|
|
117
|
+
detail, hint = (detail.get("message") or str(detail)), detail.get("hint", "")
|
|
118
|
+
else:
|
|
119
|
+
hint = payload.get("hint", "")
|
|
120
|
+
raise Refused(status, str(detail), str(hint))
|
|
121
|
+
return payload
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def login(url: str, email: str, password: str) -> dict:
|
|
125
|
+
"""Exchange credentials for a token pair. The only call that sends a password."""
|
|
126
|
+
return Client(url).call("POST", "/api/auth/login",
|
|
127
|
+
{"email": email, "password": password})
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def authenticated(url: str, *, act: str = "") -> Client:
|
|
131
|
+
"""A client for this invocation: one refresh exchange, access token held in memory.
|
|
132
|
+
|
|
133
|
+
Raises `NoSession` when there is nothing stored or the server will not renew it. The
|
|
134
|
+
caller turns that into one instruction and exit 3 — never a traceback, and never a bare
|
|
135
|
+
401 (criterion 4). `act` is the verb the person typed; it appears only in the message for
|
|
136
|
+
somebody holding an API key and nothing else.
|
|
137
|
+
"""
|
|
138
|
+
stored = config.session().get("refresh_token")
|
|
139
|
+
if not stored:
|
|
140
|
+
# `act` is what the person typed, so the message can name it rather than describe a
|
|
141
|
+
# category they would then have to work out they are in.
|
|
142
|
+
raise NoSession("no stored session", act=act,
|
|
143
|
+
have_key=bool(os.environ.get(config.API_KEY_ENV)))
|
|
144
|
+
try:
|
|
145
|
+
pair = Client(url).call("POST", "/api/auth/refresh", {"refresh_token": stored})
|
|
146
|
+
except Refused as exc:
|
|
147
|
+
raise NoSession(str(exc)) from exc
|
|
148
|
+
token = pair.get("access_token") or ""
|
|
149
|
+
if not token:
|
|
150
|
+
raise NoSession("the server returned no access token")
|
|
151
|
+
# The new refresh token is NOT written back. The presented one stays valid (the server
|
|
152
|
+
# keys on `token_version`), so rewriting the file on every call would buy nothing and
|
|
153
|
+
# create a race between concurrent invocations.
|
|
154
|
+
return Client(url, token=token)
|
gban/config.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
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
|
+
The grill made that stricter than the draft. Reading `config.json` and ignoring the keys it did
|
|
8
|
+
not recognise would have been true and insufficient: the risk is not misreading a database
|
|
9
|
+
password, it is that password living in a file which now has a second consumer and a second
|
|
10
|
+
reason to be copied onto another machine. `graphban` runs in a container against a database;
|
|
11
|
+
`gban` runs on a laptop against HTTP; the credential that must not cross that line lives in its
|
|
12
|
+
own file, so copying a config never carries it.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import stat
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
#: The directory both tools use. Shared deliberately — a person has one Graphban.
|
|
22
|
+
HOME_ENV = "GRAPHBAN_HOME"
|
|
23
|
+
|
|
24
|
+
#: `gban`'s own settings. NOT `config.json`, which belongs to `graphban` (D10).
|
|
25
|
+
SETTINGS_FILE = "gban.json"
|
|
26
|
+
|
|
27
|
+
#: The refresh token, alone in its own file so that copying settings never carries it.
|
|
28
|
+
SESSION_FILE = "session.json"
|
|
29
|
+
|
|
30
|
+
#: `graphban`'s file. Named here only so the test that asserts `gban` never opens it has
|
|
31
|
+
#: something to name, and so a reader knows the omission is deliberate.
|
|
32
|
+
NOT_OURS = "config.json"
|
|
33
|
+
|
|
34
|
+
URL_ENV = "GRAPHBAN_URL"
|
|
35
|
+
PROJECT_ENV = "GRAPHBAN_PROJECT"
|
|
36
|
+
API_KEY_ENV = "GRAPHBAN_API_KEY"
|
|
37
|
+
|
|
38
|
+
#: Owner read/write and nothing else. A credential at rest gets the same mode the seat files
|
|
39
|
+
#: in `gbfleet` get, for the same reason.
|
|
40
|
+
PRIVATE = stat.S_IRUSR | stat.S_IWUSR
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def home() -> Path:
|
|
44
|
+
return Path(os.environ.get(HOME_ENV) or (Path.home() / ".graphban"))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _read(path: Path) -> dict:
|
|
48
|
+
try:
|
|
49
|
+
loaded = json.loads(path.read_text(encoding="utf-8"))
|
|
50
|
+
except (OSError, ValueError):
|
|
51
|
+
# A missing file and an unreadable one are the same to a caller that has a default,
|
|
52
|
+
# and neither is worth a traceback in front of somebody trying to log in.
|
|
53
|
+
return {}
|
|
54
|
+
return loaded if isinstance(loaded, dict) else {}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _write(path: Path, payload: dict) -> Path:
|
|
58
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
# Created private BEFORE anything is written to it. Writing first and chmod-ing after
|
|
60
|
+
# leaves a window where the token is world-readable, which is the whole failure.
|
|
61
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, PRIVATE)
|
|
62
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
63
|
+
json.dump(payload, fh, indent=1, sort_keys=True)
|
|
64
|
+
fh.write("\n")
|
|
65
|
+
os.chmod(path, PRIVATE)
|
|
66
|
+
return path
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def settings() -> dict:
|
|
70
|
+
return _read(home() / SETTINGS_FILE)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def save_settings(**values: str) -> Path:
|
|
74
|
+
merged = {k: v for k, v in {**settings(), **values}.items() if v}
|
|
75
|
+
return _write(home() / SETTINGS_FILE, merged)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def session() -> dict:
|
|
79
|
+
return _read(home() / SESSION_FILE)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def save_session(refresh_token: str, *, user: str = "") -> Path:
|
|
83
|
+
return _write(home() / SESSION_FILE,
|
|
84
|
+
{"refresh_token": refresh_token, "user": user})
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def clear_session() -> bool:
|
|
88
|
+
path = home() / SESSION_FILE
|
|
89
|
+
try:
|
|
90
|
+
path.unlink()
|
|
91
|
+
return True
|
|
92
|
+
except FileNotFoundError:
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def resolve(flag: str | None, env: str, key: str) -> str:
|
|
97
|
+
"""D10's precedence, in one place: flag, then environment, then the settings file.
|
|
98
|
+
|
|
99
|
+
One function rather than three lookups at each call site, because a precedence that is
|
|
100
|
+
re-implemented per option is one that will eventually differ per option.
|
|
101
|
+
"""
|
|
102
|
+
return (flag or os.environ.get(env) or settings().get(key) or "").strip()
|
gban/doctor.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""`gban doctor` — one answer to "is this set up correctly", across both halves (PRD-40 D7).
|
|
2
|
+
|
|
3
|
+
Two halves fail in each other's terms. `gbfleet doctor` already checks the local one — repo,
|
|
4
|
+
workspace, adapter binary, seats file — and nothing checked the other: whether the credential
|
|
5
|
+
is valid, whether the project exists, whether an agent is quarantined or being refused every
|
|
6
|
+
call it makes. Diagnosing a stuck worker on the deployed instance needed a database query.
|
|
7
|
+
|
|
8
|
+
**Neither half may silence the other.** A half that could not be checked prints UNKNOWN with
|
|
9
|
+
its reason, never a pass and never a fail — the same three states `gbfleet doctor` uses, for
|
|
10
|
+
the same reason: "we could not tell" must never render as "nothing is wrong".
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from gban import config
|
|
20
|
+
from gban.client import Client, NoSession, Refused, Unreachable, authenticated
|
|
21
|
+
|
|
22
|
+
PASS, FAIL, UNKNOWN = "PASS", "FAIL", "UNKNOWN"
|
|
23
|
+
|
|
24
|
+
#: Worst first. The exit code is the worst finding across BOTH halves, never whichever ran
|
|
25
|
+
#: last — a local pass after a ledger failure must not report success.
|
|
26
|
+
SEVERITY = {PASS: 0, UNKNOWN: 1, FAIL: 2}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _line(side: str, status: str, name: str, detail: str = "", report: str = "") -> dict:
|
|
30
|
+
"""One finding. `detail` is this line's own reason; `report` is another tool's output,
|
|
31
|
+
kept in its own field so it can never be mistaken for one."""
|
|
32
|
+
return {"side": side, "status": status, "name": name, "detail": detail, "report": report}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def ledger(url: str, project: str) -> list[dict]:
|
|
36
|
+
"""The half nothing checked. Every line says `ledger`, because a line that does not say
|
|
37
|
+
where it came from sends a reader to the wrong machine."""
|
|
38
|
+
if not url:
|
|
39
|
+
return [_line("ledger", UNKNOWN, "server",
|
|
40
|
+
f"no server configured; pass --server or run `gban login`")]
|
|
41
|
+
try:
|
|
42
|
+
client = authenticated(url)
|
|
43
|
+
except NoSession as exc:
|
|
44
|
+
return [_line("ledger", FAIL, "session", f"{exc} — run `gban login`")]
|
|
45
|
+
except Unreachable as exc:
|
|
46
|
+
# Three distinct lines for three distinct failures, because they send a reader to
|
|
47
|
+
# three different places: the network, the credential, or the project.
|
|
48
|
+
return [_line("ledger", UNKNOWN, "server", str(exc))]
|
|
49
|
+
|
|
50
|
+
out = [_line("ledger", PASS, "server", url)]
|
|
51
|
+
try:
|
|
52
|
+
me = client.call("GET", "/api/auth/me")
|
|
53
|
+
out.append(_line("ledger", PASS, "session", me.get("email") or me.get("id", "")))
|
|
54
|
+
except Refused as exc:
|
|
55
|
+
return out + [_line("ledger", FAIL, "session", exc.detail)]
|
|
56
|
+
except Unreachable as exc:
|
|
57
|
+
return out + [_line("ledger", UNKNOWN, "session", str(exc))]
|
|
58
|
+
|
|
59
|
+
if not project:
|
|
60
|
+
out.append(_line("ledger", UNKNOWN, "project",
|
|
61
|
+
"no project configured; pass --project or set one with `gban login`"))
|
|
62
|
+
return out
|
|
63
|
+
try:
|
|
64
|
+
fleet = client.call("GET", f"/api/fleet?project_id={project}")
|
|
65
|
+
except Refused as exc:
|
|
66
|
+
out.append(_line("ledger", FAIL, "project",
|
|
67
|
+
f"{project}: {exc.detail}" + (f" — {exc.hint}" if exc.hint else "")))
|
|
68
|
+
return out
|
|
69
|
+
except Unreachable as exc:
|
|
70
|
+
out.append(_line("ledger", UNKNOWN, "project", str(exc)))
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
agents = fleet.get("agents") or []
|
|
74
|
+
live = [a for a in agents if a.get("state") != "offline"]
|
|
75
|
+
out.append(_line("ledger", PASS, "project",
|
|
76
|
+
f"{project}: {len(live)} agent(s) online of {len(agents)}"))
|
|
77
|
+
|
|
78
|
+
# The two states that are the whole reason a person runs this. Reported per agent, because
|
|
79
|
+
# "one agent is quarantined" and "which one" are different amounts of help.
|
|
80
|
+
for agent in agents:
|
|
81
|
+
if agent.get("state") == "quarantined":
|
|
82
|
+
out.append(_line("ledger", FAIL, f"agent {agent.get('id')}",
|
|
83
|
+
"quarantined — it kept calling tools it is not permitted"))
|
|
84
|
+
refusal = agent.get("last_refusal") or {}
|
|
85
|
+
if refusal.get("tool"):
|
|
86
|
+
times = f" x{refusal['count']}" if refusal.get("count", 1) > 1 else ""
|
|
87
|
+
out.append(_line("ledger", FAIL, f"agent {agent.get('id')}",
|
|
88
|
+
f"refused {refusal['tool']}{times} — {refusal.get('reason', '')}"))
|
|
89
|
+
return out
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def local(url: str, project: str, api_key: str) -> list[dict]:
|
|
93
|
+
"""`gbfleet doctor`, run as a subprocess (D5). Its output is passed through, not parsed:
|
|
94
|
+
the supervisor's own report is the one under test."""
|
|
95
|
+
binary = find_supervisor()
|
|
96
|
+
if not binary:
|
|
97
|
+
return [_line("local", UNKNOWN, "gbfleet",
|
|
98
|
+
f"not installed here — {INSTALL_SUPERVISOR} to check the local half")]
|
|
99
|
+
argv = [binary, "doctor"]
|
|
100
|
+
if url:
|
|
101
|
+
argv += ["--server", url]
|
|
102
|
+
if project:
|
|
103
|
+
argv += ["--project", project]
|
|
104
|
+
try:
|
|
105
|
+
done = subprocess.run(argv, capture_output=True, text=True, timeout=120,
|
|
106
|
+
env=child_environment(api_key))
|
|
107
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
108
|
+
return [_line("local", UNKNOWN, "gbfleet", f"could not run: {exc}")]
|
|
109
|
+
status = PASS if done.returncode == 0 else FAIL
|
|
110
|
+
body = (done.stdout or done.stderr or "").strip()
|
|
111
|
+
# The summary line says what HAPPENED; the child's report goes underneath, verbatim, in
|
|
112
|
+
# its own field. Putting the body in `detail` put the child's banner where the reason
|
|
113
|
+
# belongs — the walk read `FAIL local gbfleet gbfleet 0.1.0 doctor`, which names a
|
|
114
|
+
# version and explains nothing. Choosing some line of the body to promote instead would
|
|
115
|
+
# be parsing the supervisor's output, which is the thing D5 is careful not to do.
|
|
116
|
+
verdict = "every check passed" if status is PASS else f"exited {done.returncode}"
|
|
117
|
+
return [_line("local", status, "gbfleet",
|
|
118
|
+
f"`gbfleet doctor` {verdict}; its own report follows", report=body)]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
#: The command that actually installs the supervisor. NOT `uv pip install graphban-fleet`,
|
|
122
|
+
#: which is what this said until somebody ran it: neither package is on PyPI, so that line
|
|
123
|
+
#: 404s. A tool whose remedy does not work is worse than one that offers none — it spends the
|
|
124
|
+
#: reader's trust before spending their time.
|
|
125
|
+
INSTALL_SUPERVISOR = (
|
|
126
|
+
'uv tool install "git+https://github.com/asc-me/graphban.git#subdirectory=fleet"')
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def find_supervisor() -> str:
|
|
130
|
+
"""`gbfleet`, from beside this interpreter first and only then from PATH.
|
|
131
|
+
|
|
132
|
+
**A sibling install is invisible to `PATH` alone**, and that is not a corner case: a
|
|
133
|
+
`graphban-cli[fleet]` extra, or a plain `pip install` of both into one virtualenv, puts
|
|
134
|
+
`gbfleet` in the same `bin/` as `gban` — and `uv tool install` deliberately exposes only
|
|
135
|
+
the requested package's executables, so the supervisor lands there and on no path at all.
|
|
136
|
+
Measured: with the extra installed, `gban fleet` said "gbfleet is not installed here"
|
|
137
|
+
while `gbfleet` sat in the very environment it was running from.
|
|
138
|
+
|
|
139
|
+
PATH still wins for a supervisor the operator installed separately and put there on
|
|
140
|
+
purpose — a sibling is a fallback for the case PATH cannot see, not an override of it.
|
|
141
|
+
"""
|
|
142
|
+
import sys
|
|
143
|
+
|
|
144
|
+
found = shutil.which("gbfleet")
|
|
145
|
+
if found:
|
|
146
|
+
return found
|
|
147
|
+
beside = Path(sys.executable).parent / "gbfleet"
|
|
148
|
+
return str(beside) if beside.exists() and os.access(beside, os.X_OK) else ""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
#: What `gbfleet` reads. `gban` names it once, here, and both paths that launch a supervisor
|
|
152
|
+
#: go through this function (GRPH-782).
|
|
153
|
+
SUPERVISOR_KEY_ENV = "GBFLEET_API_KEY"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def child_environment(api_key: str) -> dict:
|
|
157
|
+
"""The environment a `gbfleet` child is launched with, for `doctor` AND for `fleet`.
|
|
158
|
+
|
|
159
|
+
**The two used to disagree, and that made the doctor a liar.** `doctor` translated
|
|
160
|
+
`$GRAPHBAN_API_KEY` into `$GBFLEET_API_KEY` for the child; `gban fleet` was a bare
|
|
161
|
+
`subprocess.run` and did not. So on a machine set up the way this tool's own premise
|
|
162
|
+
assumes — one key exported, under `gban`'s name — `gban doctor` reported the local half
|
|
163
|
+
green using a credential it manufactured, and `gban fleet up`, which `gban seats issue`
|
|
164
|
+
sends people to by name, started the supervisor with nothing. D7's promise is
|
|
165
|
+
"everything that can be checked before anything is spawned"; a local verdict that does
|
|
166
|
+
not predict what the next command does is the failure that promise exists to prevent.
|
|
167
|
+
|
|
168
|
+
In the ENVIRONMENT, never argv: `ps` shows argv to every process on the machine. And
|
|
169
|
+
never over a value the caller set themselves — somebody who exported `$GBFLEET_API_KEY`
|
|
170
|
+
deliberately, to run the supervisor on a different credential, means it.
|
|
171
|
+
"""
|
|
172
|
+
import os
|
|
173
|
+
|
|
174
|
+
env = dict(os.environ)
|
|
175
|
+
if api_key and not env.get(SUPERVISOR_KEY_ENV):
|
|
176
|
+
env[SUPERVISOR_KEY_ENV] = api_key
|
|
177
|
+
return env
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def run(url: str, project: str, api_key: str = "") -> tuple[list[dict], int]:
|
|
181
|
+
lines = ledger(url, project) + local(url, project, api_key)
|
|
182
|
+
worst = max((SEVERITY[l["status"]] for l in lines), default=0)
|
|
183
|
+
# FAIL exits 1, UNKNOWN exits 0: "could not check" is not "broken", and a doctor that
|
|
184
|
+
# failed a script because a laptop lacked `gbfleet` would stop being run.
|
|
185
|
+
return lines, (1 if worst == SEVERITY[FAIL] else 0)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def render(lines: list[dict]) -> str:
|
|
189
|
+
width = max((len(l["name"]) for l in lines), default=0)
|
|
190
|
+
out = []
|
|
191
|
+
for l in lines:
|
|
192
|
+
out.append(f"{l['status']:<7} {l['side']:<6} {l['name']:<{width}} {l['detail']}".rstrip())
|
|
193
|
+
# Indented, so a reader scanning the summary column can skip a page of somebody
|
|
194
|
+
# else's report without losing the two lines they came for.
|
|
195
|
+
out += [f" {row}".rstrip() for row in (l.get("report") or "").splitlines()]
|
|
196
|
+
return "\n".join(out)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: graphban-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: gban — a Graphban client for the human at a terminal
|
|
5
|
+
Project-URL: Homepage, https://github.com/asc-me/graphban
|
|
6
|
+
Project-URL: Repository, https://github.com/asc-me/graphban
|
|
7
|
+
Project-URL: Documentation, https://github.com/asc-me/graphban/blob/main/cli/README.md
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# graphban-cli
|
|
17
|
+
|
|
18
|
+
**`gban`** — the client for a human at a terminal.
|
|
19
|
+
|
|
20
|
+
Five surfaces existed before this and none of them was for a person at a shell prompt:
|
|
21
|
+
`graphban` talks to the database from inside the container, `gbfleet` supervises processes,
|
|
22
|
+
`gbagent` is a spawned child, the web app is a browser, and `/api/mcp` is for agents. Issuing
|
|
23
|
+
a seat, seeing why an agent is stuck, or re-tasking one meant opening a browser.
|
|
24
|
+
|
|
25
|
+
Specified by [PRD-40](https://github.com/asc-me/graphban/blob/main/docs/prd-40-gb-cli.md).
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
Not on PyPI yet, so it installs from the repository. `uv tool install` puts it on your PATH
|
|
30
|
+
in its own environment, which is what you want for a CLI:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
uv tool install "git+https://github.com/asc-me/graphban.git#subdirectory=cli"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Add `gbfleet` too if you run waves — it is a separate package, and `gban fleet` hands off to it:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
uv tool install "git+https://github.com/asc-me/graphban.git#subdirectory=fleet"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`uv tool update-shell` once, if uv says the bin directory is not on your PATH. Upgrade either
|
|
43
|
+
with `uv tool upgrade graphban-cli` (or `--all`); reinstalling from the same URL also works,
|
|
44
|
+
since the spec is a branch rather than a pin.
|
|
45
|
+
|
|
46
|
+
With pip instead, into an environment you already have:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install "graphban-cli @ git+https://github.com/asc-me/graphban.git#subdirectory=cli"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`gban` pulls **nothing**: `client.py` is `urllib.request` throughout, and the install lands
|
|
53
|
+
exactly one distribution. `gbfleet` brings httpx and its four transitive dependencies, which
|
|
54
|
+
is why they are separate packages and not one.
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
gban login --server https://cloud.agentldgr.dev
|
|
58
|
+
gban doctor # both halves: the ledger, and the local fleet
|
|
59
|
+
gban agents # the roster, and why an agent is stuck
|
|
60
|
+
gban agents role SA-A4 planner # what used to need a browser
|
|
61
|
+
gban seats issue worker worker planner # one entry per agent
|
|
62
|
+
gban keys # which key is that agent on
|
|
63
|
+
gban fleet up --seats-file seats.txt --adapter claude # hands off to gbfleet
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`seats issue` takes **one role per agent, repeats included**, because that is the server's
|
|
67
|
+
own shape: two agents on one seat share a session and cannot review each other. Each code is
|
|
68
|
+
printed once and written nowhere — a CLI that helpfully saved them would invent a second
|
|
69
|
+
credential at rest that no route and no test knows about.
|
|
70
|
+
|
|
71
|
+
`agents` prints what an agent was **last refused, and why**. That line is the reason the verb
|
|
72
|
+
exists: a roster saying "idle worker" for an agent being told no on every call it makes is
|
|
73
|
+
what made the Super-Arc diagnosis take a database query.
|
|
74
|
+
|
|
75
|
+
`agents role` re-tasks a live agent **within its credential's ceiling** and never past it. A
|
|
76
|
+
role the key does not permit is the server's refusal, printed in the server's own words;
|
|
77
|
+
widening a ceiling means minting a different credential, and keeping those two acts apart is
|
|
78
|
+
the point of having a ceiling. It lands on the agent's next poll.
|
|
79
|
+
|
|
80
|
+
## `gban login` wants a real terminal
|
|
81
|
+
|
|
82
|
+
It refuses without one, rather than prompting. `getpass` falls back to a plain **echoing**
|
|
83
|
+
read when it cannot turn echo off — it warns, but the warning arrives after the person has
|
|
84
|
+
decided to type — so a login through a pipe, a heredoc or an editor's command runner would
|
|
85
|
+
put the password in the scrollback. There is no non-interactive login yet (PRD-40 open
|
|
86
|
+
question 2: an API key cannot reach the JWT routes, so CI would need a service session).
|
|
87
|
+
|
|
88
|
+
## Why not `gb`
|
|
89
|
+
|
|
90
|
+
Because `gb` is already `git branch` on a large share of developer machines, and **an alias
|
|
91
|
+
beats a binary on `PATH`**. The deployed walk hit it on the very first command and got git's
|
|
92
|
+
usage text; nothing inside the process can detect that, because by the time `gb` would have
|
|
93
|
+
run, the alias did not.
|
|
94
|
+
|
|
95
|
+
It is not one alias but a whole namespace. oh-my-zsh's git plugin — which is where most of
|
|
96
|
+
these come from — defines sixteen `gb*` aliases and ten `grb*`, so `gb`, `gba`, `grb` and
|
|
97
|
+
`gbl` are all spoken for. `gban` is outside it, still short, and still says which product it
|
|
98
|
+
belongs to.
|
|
99
|
+
|
|
100
|
+
## Licence — Apache-2.0, deliberately not the repository's FSL-1.1
|
|
101
|
+
|
|
102
|
+
The repository is [FSL-1.1-Apache-2.0](https://github.com/asc-me/graphban/blob/main/LICENSE.md). This directory is
|
|
103
|
+
[Apache-2.0](https://github.com/asc-me/graphban/blob/main/cli/LICENSE), for the reasons PRD-22 §8 gives for `fleet/` — every one of which
|
|
104
|
+
applies here identically. `gban` is inert without a Graphban server and holds no authority of
|
|
105
|
+
its own, so FSL's Competing Use clause protects the server and protects nothing here. It is a
|
|
106
|
+
laptop-installed developer CLI, which is exactly the kind of dependency that has to clear a
|
|
107
|
+
corporate licence policy scanner.
|
|
108
|
+
|
|
109
|
+
## Why it is in this repository
|
|
110
|
+
|
|
111
|
+
**Not a second repository**, for the reason [`fleet/README.md`](https://github.com/asc-me/graphban/blob/main/fleet/README.md) gives for
|
|
112
|
+
the supervisor, with more force: the client↔server contract has no schema anywhere, and a
|
|
113
|
+
cross-repo break would present as absence reading clean — `gban` still runs, nothing errors, the
|
|
114
|
+
verb quietly stops meaning what it said. The evidence is recent and specific: `ROLES` lost
|
|
115
|
+
`reviewer` in one PR while another added a test naming it, and CI caught the pair inside
|
|
116
|
+
seventeen minutes because both lived in one repository. Split across two, that lands as a bug
|
|
117
|
+
report from somebody whose `gban agents role ... reviewer` started refusing.
|
|
118
|
+
|
|
119
|
+
**Not inside `backend/`**, because `graphban-api` pulls fastapi, sqlalchemy, pgvector, psycopg,
|
|
120
|
+
alembic, redis and cryptography, and this installs on a laptop. `tests/test_packaging.py`
|
|
121
|
+
derives its forbidden set from the backend's own dependency list rather than a denylist
|
|
122
|
+
somebody maintains.
|
|
123
|
+
|
|
124
|
+
## What it is not
|
|
125
|
+
|
|
126
|
+
It is **not a second web app**: no board, no PRD editor, no search. Every verb is either
|
|
127
|
+
something a human currently opens a browser for, or a diagnosis nothing else gives.
|
|
128
|
+
|
|
129
|
+
It is **not `graphban`**, which talks to the local database from inside the container and
|
|
130
|
+
stays exactly as it is. Mixing "against the DB in the container" and "over HTTP from a laptop"
|
|
131
|
+
into one command is an ambiguity that ends with somebody purging the wrong instance.
|
|
132
|
+
|
|
133
|
+
It **holds no state the server does not** and computes nothing the server computes. Every verb
|
|
134
|
+
is one endpoint, called once (PRD-40 D11); an ordering between two calls would be a rule, and
|
|
135
|
+
a rule in the client is a second definition of something the server already enforces.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
gban/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
gban/cli.py,sha256=MNzyjywOXNSdzixpio0tp_M_J6Z-gKlXSJGVxuMtQEI,19163
|
|
3
|
+
gban/client.py,sha256=IZc7C7V9EYY0Iq51X6j9acZa7f_nUKpChgHZLTiR_Sc,7026
|
|
4
|
+
gban/config.py,sha256=-BI1KUeSvU11aJ79faOMoTuuv2YHc0ShQ-J6zjxzjRE,3816
|
|
5
|
+
gban/doctor.py,sha256=7xiCAWlGpi3aCeHN2d_G8WDqKZsNpKytjQia6U_jMn4,9766
|
|
6
|
+
graphban_cli-0.1.0.dist-info/METADATA,sha256=bsMjyMZdpzdih6OpbAPo1O8WVwcy4oL9BJtYPJwRrto,7074
|
|
7
|
+
graphban_cli-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
8
|
+
graphban_cli-0.1.0.dist-info/entry_points.txt,sha256=nc7a--ylRW5tl7Y3zGYYj8iCfvEU4bHFXuLC5XrzR2Y,39
|
|
9
|
+
graphban_cli-0.1.0.dist-info/licenses/LICENSE,sha256=bTJXVKNN5dcSiUDYFuITA3s6oqa7HFoEOyxmodoZ8Ak,10833
|
|
10
|
+
graphban_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright 2026 Ascme Labs
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|