genesis-cli 0.1.0__tar.gz → 0.2.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.
- {genesis_cli-0.1.0 → genesis_cli-0.2.0}/PKG-INFO +4 -3
- {genesis_cli-0.1.0 → genesis_cli-0.2.0}/pyproject.toml +4 -2
- {genesis_cli-0.1.0 → genesis_cli-0.2.0}/src/genesis_cli/adopt.py +8 -1
- {genesis_cli-0.1.0 → genesis_cli-0.2.0}/src/genesis_cli/init.py +7 -0
- {genesis_cli-0.1.0 → genesis_cli-0.2.0}/src/genesis_cli/main.py +11 -0
- genesis_cli-0.2.0/src/genesis_cli/runner/__init__.py +11 -0
- genesis_cli-0.2.0/src/genesis_cli/runner/claude_driver.py +185 -0
- genesis_cli-0.2.0/src/genesis_cli/runner/client.py +102 -0
- genesis_cli-0.2.0/src/genesis_cli/runner/config.py +62 -0
- genesis_cli-0.2.0/src/genesis_cli/runner/loop.py +262 -0
- genesis_cli-0.2.0/src/genesis_cli/runner/runner_cmd.py +53 -0
- genesis_cli-0.2.0/src/genesis_cli/runner/workspace.py +90 -0
- {genesis_cli-0.1.0 → genesis_cli-0.2.0}/tests/test_adopt.py +17 -0
- genesis_cli-0.2.0/tests/test_runner_config.py +55 -0
- genesis_cli-0.2.0/tests/test_runner_driver.py +135 -0
- genesis_cli-0.2.0/tests/test_runner_workspace.py +103 -0
- {genesis_cli-0.1.0 → genesis_cli-0.2.0}/.gitignore +0 -0
- {genesis_cli-0.1.0 → genesis_cli-0.2.0}/LICENSE +0 -0
- {genesis_cli-0.1.0 → genesis_cli-0.2.0}/README.md +0 -0
- {genesis_cli-0.1.0 → genesis_cli-0.2.0}/action/README.md +0 -0
- {genesis_cli-0.1.0 → genesis_cli-0.2.0}/action/action.yml +0 -0
- {genesis_cli-0.1.0 → genesis_cli-0.2.0}/src/genesis_cli/__init__.py +0 -0
- {genesis_cli-0.1.0 → genesis_cli-0.2.0}/src/genesis_cli/agents_md.py +0 -0
- {genesis_cli-0.1.0 → genesis_cli-0.2.0}/src/genesis_cli/baseline.py +0 -0
- {genesis_cli-0.1.0 → genesis_cli-0.2.0}/src/genesis_cli/gates.py +0 -0
- {genesis_cli-0.1.0 → genesis_cli-0.2.0}/src/genesis_cli/report.py +0 -0
- {genesis_cli-0.1.0 → genesis_cli-0.2.0}/src/genesis_cli/review.py +0 -0
- {genesis_cli-0.1.0 → genesis_cli-0.2.0}/src/genesis_cli/sarif.py +0 -0
- {genesis_cli-0.1.0 → genesis_cli-0.2.0}/tests/test_baseline.py +0 -0
- {genesis_cli-0.1.0 → genesis_cli-0.2.0}/tests/test_gates_cmd.py +0 -0
- {genesis_cli-0.1.0 → genesis_cli-0.2.0}/tests/test_init.py +0 -0
- {genesis_cli-0.1.0 → genesis_cli-0.2.0}/tests/test_review.py +0 -0
- {genesis_cli-0.1.0 → genesis_cli-0.2.0}/tests/test_sarif.py +0 -0
- {genesis_cli-0.1.0 → genesis_cli-0.2.0}/tests/test_schemas.py +0 -0
- {genesis_cli-0.1.0 → genesis_cli-0.2.0}/tests/test_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: genesis-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Genesis Agent Workbench CLI — make any repo agent-ready and keep AI-generated changes governed.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Inceptium-ai/genesis-framework
|
|
6
6
|
Project-URL: Repository, https://github.com/Inceptium-ai/genesis-framework
|
|
@@ -20,7 +20,8 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
20
20
|
Classifier: Topic :: Software Development :: Quality Assurance
|
|
21
21
|
Classifier: Topic :: Software Development :: Testing
|
|
22
22
|
Requires-Python: >=3.12
|
|
23
|
-
Requires-Dist: genesis-gates>=0.
|
|
23
|
+
Requires-Dist: genesis-gates>=0.2.0
|
|
24
|
+
Requires-Dist: httpx>=0.27
|
|
24
25
|
Requires-Dist: pyyaml>=6.0
|
|
25
26
|
Description-Content-Type: text/markdown
|
|
26
27
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "genesis-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
description = "Genesis Agent Workbench CLI — make any repo agent-ready and keep AI-generated changes governed."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "Apache-2.0"
|
|
@@ -30,8 +30,10 @@ dependencies = [
|
|
|
30
30
|
# Must stay a REAL version requirement: `[tool.uv.sources]` below is a
|
|
31
31
|
# dev-only workspace override and is not written into published metadata,
|
|
32
32
|
# so this is what `pip install genesis-cli` resolves against on PyPI.
|
|
33
|
-
"genesis-gates>=0.
|
|
33
|
+
"genesis-gates>=0.2.0",
|
|
34
34
|
"pyyaml>=6.0",
|
|
35
|
+
# `genesis runner` talks to a Genesis server over HTTP.
|
|
36
|
+
"httpx>=0.27",
|
|
35
37
|
]
|
|
36
38
|
|
|
37
39
|
[project.urls]
|
|
@@ -17,6 +17,7 @@ import genesis_gates.providers # noqa: F401
|
|
|
17
17
|
from genesis_gates import GateContext, GateRunner, get_gate, load_gate_config
|
|
18
18
|
from genesis_gates.analysis import (
|
|
19
19
|
analyze,
|
|
20
|
+
declared_env_vars,
|
|
20
21
|
reconcile,
|
|
21
22
|
repo_input,
|
|
22
23
|
score_readiness,
|
|
@@ -56,7 +57,13 @@ def run_audit(repo: Path) -> dict:
|
|
|
56
57
|
repo = repo.resolve()
|
|
57
58
|
gate_results = run_gates(repo).get("gate_results", [])
|
|
58
59
|
completeness = analyze(repo_input(repo))
|
|
59
|
-
|
|
60
|
+
# A brownfield audit has no stored dependency map, so the repo's own
|
|
61
|
+
# `.env.example`-family files ARE its declared config contract. Passing
|
|
62
|
+
# them makes env_hygiene measure "are env vars declared?"; with an empty
|
|
63
|
+
# map, every compose `${VAR}` without a default counted as an undeclared
|
|
64
|
+
# gap even when the repo documents it properly, which pinned the category
|
|
65
|
+
# near zero for any repo with an undefaulted compose reference.
|
|
66
|
+
reconciliation = reconcile(repo, stored_deps=declared_env_vars(repo))
|
|
60
67
|
facts = structure_facts(repo)
|
|
61
68
|
readiness = score_readiness(
|
|
62
69
|
gate_results=gate_results, facts=facts,
|
|
@@ -104,6 +104,13 @@ kind: GateConfig
|
|
|
104
104
|
# dependency-scan:
|
|
105
105
|
# provider: trivy
|
|
106
106
|
# fail_on_severity: critical
|
|
107
|
+
# # Trees this repo does not author and is not accountable for: vendored
|
|
108
|
+
# # dependencies, generated output, packaged sample apps that are scanned
|
|
109
|
+
# # in their own right. Repo-relative, anchored at the root ("vendor" does
|
|
110
|
+
# # not match "packages/x/vendor"). This is SCOPE, not suppression — a
|
|
111
|
+
# # skipped directory is invisible to the gate, so justify each entry in a
|
|
112
|
+
# # comment and keep your own source, infra and CI in scope.
|
|
113
|
+
# skip_dirs: [vendor, third_party/generated]
|
|
107
114
|
# unit-tests:
|
|
108
115
|
# provider: command
|
|
109
116
|
# command: "npm test"
|
|
@@ -9,6 +9,12 @@ Commands:
|
|
|
9
9
|
genesis init [path] Scaffold a new/bare repo so agents start agent-ready:
|
|
10
10
|
AGENTS.md, .gitignore, .env.example, audit workflow,
|
|
11
11
|
gate config. Idempotent — never overwrites.
|
|
12
|
+
genesis runner … Local runner for the Genesis platform: `connect` pairs
|
|
13
|
+
this machine with a server (runner token), `start`
|
|
14
|
+
executes your builds' coding-agent tasks here through
|
|
15
|
+
your own `claude` CLI (your subscription), `status`
|
|
16
|
+
shows the connection. The server keeps orchestration,
|
|
17
|
+
validation, gates, evidence, and billing.
|
|
12
18
|
genesis --version Print the installed genesis-cli version.
|
|
13
19
|
"""
|
|
14
20
|
|
|
@@ -220,6 +226,11 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
220
226
|
help="directory to scaffold (default: .; created if missing)")
|
|
221
227
|
init.set_defaults(func=_cmd_init)
|
|
222
228
|
|
|
229
|
+
# Local runner (deferred imports: the subcommands import httpx lazily).
|
|
230
|
+
from .runner.runner_cmd import add_runner_parser
|
|
231
|
+
|
|
232
|
+
add_runner_parser(sub)
|
|
233
|
+
|
|
223
234
|
args = parser.parse_args(argv)
|
|
224
235
|
return args.func(args)
|
|
225
236
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""`genesis runner` — execute your Genesis coding-agent tasks locally.
|
|
2
|
+
|
|
3
|
+
Connect this machine to a Genesis server with a runner token and your builds'
|
|
4
|
+
agent tasks run HERE, through your own local `claude` CLI (your subscription)
|
|
5
|
+
— while the server keeps all orchestration, validation, repair policy,
|
|
6
|
+
commits, gates, evidence, and billing.
|
|
7
|
+
|
|
8
|
+
Public package rule: this code never imports genesis_server or
|
|
9
|
+
genesis_executor; the wire protocol (claim / workspace / events / result)
|
|
10
|
+
is its only contract with the server.
|
|
11
|
+
"""
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Headless `claude` driver — the public runner-side reimplementation of the
|
|
2
|
+
platform's stream-json adapter (same argv, same parsing, no shared code: this
|
|
3
|
+
package never imports the commercial packages).
|
|
4
|
+
|
|
5
|
+
Runs `claude -p <prompt> --output-format stream-json` as an argv subprocess
|
|
6
|
+
(never a shell) with cwd = the task workspace. The child env is a pass-through
|
|
7
|
+
of the login environment with ANTHROPIC_API_KEY explicitly UNSET so the CLI
|
|
8
|
+
uses the user's subscription auth and never silently bills an API key.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
# stream-json emits one JSON object per line; a single line (a large tool
|
|
23
|
+
# result or a long assistant turn) routinely exceeds asyncio's default 64 KB
|
|
24
|
+
# StreamReader line cap, which would raise LimitOverrunError mid-task. We read
|
|
25
|
+
# raw chunks and split on newlines ourselves (no per-line cap), and raise the
|
|
26
|
+
# subprocess buffer limit so the transport isn't paused every 64 KB.
|
|
27
|
+
_STREAM_LIMIT = 64 * 1024 * 1024 # 64 MB StreamReader buffer
|
|
28
|
+
_READ_CHUNK = 256 * 1024 # 256 KB per read
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def _iter_json_lines(stream: asyncio.StreamReader):
|
|
32
|
+
"""Yield complete decoded lines via chunked reads — an oversized line can
|
|
33
|
+
never raise asyncio.LimitOverrunError."""
|
|
34
|
+
buf = bytearray()
|
|
35
|
+
while True:
|
|
36
|
+
chunk = await stream.read(_READ_CHUNK)
|
|
37
|
+
if not chunk:
|
|
38
|
+
break
|
|
39
|
+
buf.extend(chunk)
|
|
40
|
+
while True:
|
|
41
|
+
nl = buf.find(b"\n")
|
|
42
|
+
if nl == -1:
|
|
43
|
+
break
|
|
44
|
+
line = bytes(buf[:nl])
|
|
45
|
+
del buf[: nl + 1]
|
|
46
|
+
yield line
|
|
47
|
+
if buf:
|
|
48
|
+
yield bytes(buf)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _assistant_text(evt: dict) -> str:
|
|
52
|
+
msg = evt.get("message") or {}
|
|
53
|
+
parts = msg.get("content") or []
|
|
54
|
+
if isinstance(parts, str):
|
|
55
|
+
return parts
|
|
56
|
+
return "".join(
|
|
57
|
+
p.get("text", "")
|
|
58
|
+
for p in parts
|
|
59
|
+
if isinstance(p, dict) and p.get("type") == "text"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def check_cli() -> tuple[bool, str]:
|
|
64
|
+
"""Preflight: is a working `claude` CLI on PATH?"""
|
|
65
|
+
if not shutil.which("claude"):
|
|
66
|
+
return False, (
|
|
67
|
+
"The `claude` CLI is not on PATH. Install Claude Code "
|
|
68
|
+
"(https://claude.com/claude-code) and sign in with your "
|
|
69
|
+
"subscription, then run `genesis runner start` again."
|
|
70
|
+
)
|
|
71
|
+
try:
|
|
72
|
+
proc = subprocess.run(["claude", "--version"], capture_output=True,
|
|
73
|
+
text=True, timeout=30)
|
|
74
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
75
|
+
return False, f"`claude --version` failed to run: {exc}"
|
|
76
|
+
if proc.returncode != 0:
|
|
77
|
+
return False, (f"`claude --version` exited {proc.returncode}: "
|
|
78
|
+
f"{(proc.stderr or proc.stdout)[:200]}")
|
|
79
|
+
return True, (proc.stdout or "").strip()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ClaudeDriver:
|
|
83
|
+
"""One task = one run(). kill() aborts the live subprocess (server-side
|
|
84
|
+
cancel or a second Ctrl-C)."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, model: str | None = None):
|
|
87
|
+
self.model = model
|
|
88
|
+
self._proc: asyncio.subprocess.Process | None = None
|
|
89
|
+
self.killed = False
|
|
90
|
+
|
|
91
|
+
def kill(self) -> None:
|
|
92
|
+
self.killed = True
|
|
93
|
+
if self._proc is not None and self._proc.returncode is None:
|
|
94
|
+
self._proc.kill()
|
|
95
|
+
|
|
96
|
+
def _child_env(self) -> dict:
|
|
97
|
+
env = dict(os.environ)
|
|
98
|
+
# Force subscription auth: never let an API key take over accidentally.
|
|
99
|
+
env.pop("ANTHROPIC_API_KEY", None)
|
|
100
|
+
return env
|
|
101
|
+
|
|
102
|
+
async def run(self, prompt: str, *, workdir: Path,
|
|
103
|
+
resume_session: str | None = None,
|
|
104
|
+
on_text: Callable[[str], None] | None = None) -> dict:
|
|
105
|
+
"""Run one task; returns {status, session_id, usage, error} parsed
|
|
106
|
+
from the CLI's final result message."""
|
|
107
|
+
started = time.monotonic()
|
|
108
|
+
argv = [
|
|
109
|
+
"claude", "-p", prompt,
|
|
110
|
+
"--output-format", "stream-json",
|
|
111
|
+
"--verbose",
|
|
112
|
+
"--add-dir", str(workdir),
|
|
113
|
+
"--permission-mode", "acceptEdits",
|
|
114
|
+
]
|
|
115
|
+
if self.model:
|
|
116
|
+
argv += ["--model", self.model]
|
|
117
|
+
if resume_session:
|
|
118
|
+
argv += ["--resume", resume_session]
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
proc = await asyncio.create_subprocess_exec(
|
|
122
|
+
*argv,
|
|
123
|
+
cwd=str(workdir),
|
|
124
|
+
env=self._child_env(),
|
|
125
|
+
stdout=asyncio.subprocess.PIPE,
|
|
126
|
+
stderr=asyncio.subprocess.PIPE,
|
|
127
|
+
limit=_STREAM_LIMIT,
|
|
128
|
+
)
|
|
129
|
+
except FileNotFoundError:
|
|
130
|
+
return {"status": "failed", "session_id": None,
|
|
131
|
+
"usage": {}, "error": "claude CLI not found"}
|
|
132
|
+
self._proc = proc
|
|
133
|
+
|
|
134
|
+
usage: dict = {"input_tokens": 0, "output_tokens": 0, "cost_usd": None,
|
|
135
|
+
"estimated": False, "duration_s": None}
|
|
136
|
+
session_id: str | None = None
|
|
137
|
+
status = "failed"
|
|
138
|
+
error: str | None = None
|
|
139
|
+
|
|
140
|
+
assert proc.stdout is not None
|
|
141
|
+
try:
|
|
142
|
+
async for raw in _iter_json_lines(proc.stdout):
|
|
143
|
+
line = raw.decode("utf-8", "replace").strip()
|
|
144
|
+
if not line:
|
|
145
|
+
continue
|
|
146
|
+
try:
|
|
147
|
+
evt = json.loads(line)
|
|
148
|
+
except json.JSONDecodeError:
|
|
149
|
+
continue
|
|
150
|
+
etype = evt.get("type")
|
|
151
|
+
if evt.get("session_id"):
|
|
152
|
+
session_id = evt["session_id"]
|
|
153
|
+
if etype == "assistant":
|
|
154
|
+
text = _assistant_text(evt)
|
|
155
|
+
if text and on_text is not None:
|
|
156
|
+
on_text(text)
|
|
157
|
+
elif etype == "result":
|
|
158
|
+
if evt.get("total_cost_usd") is not None:
|
|
159
|
+
usage["cost_usd"] = float(evt["total_cost_usd"])
|
|
160
|
+
u = evt.get("usage") or {}
|
|
161
|
+
usage["input_tokens"] = int(u.get("input_tokens", 0))
|
|
162
|
+
usage["output_tokens"] = int(u.get("output_tokens", 0))
|
|
163
|
+
status = "completed" if evt.get("subtype") == "success" else "failed"
|
|
164
|
+
if evt.get("is_error"):
|
|
165
|
+
error = str(evt.get("result", "cli error"))[:500]
|
|
166
|
+
await proc.wait()
|
|
167
|
+
except asyncio.CancelledError:
|
|
168
|
+
proc.kill()
|
|
169
|
+
raise
|
|
170
|
+
finally:
|
|
171
|
+
self._proc = None
|
|
172
|
+
|
|
173
|
+
if self.killed:
|
|
174
|
+
status = "interrupted"
|
|
175
|
+
error = error or "task aborted on the runner"
|
|
176
|
+
elif proc.returncode not in (0, None) and status != "completed":
|
|
177
|
+
status = "failed"
|
|
178
|
+
if error is None:
|
|
179
|
+
err = (await proc.stderr.read()).decode("utf-8", "replace") \
|
|
180
|
+
if proc.stderr else ""
|
|
181
|
+
error = err[:500] or f"claude exited {proc.returncode}"
|
|
182
|
+
|
|
183
|
+
usage["duration_s"] = time.monotonic() - started
|
|
184
|
+
return {"status": status, "session_id": session_id,
|
|
185
|
+
"usage": usage, "error": error}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""HTTP client for the Genesis runner gateway.
|
|
2
|
+
|
|
3
|
+
One rule above all: the token is a credential — it goes into the
|
|
4
|
+
Authorization header and NOWHERE else (never a log line, never an error
|
|
5
|
+
message, never a URL).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
# The server long-polls a claim for ~25s; read must comfortably outlast that.
|
|
15
|
+
_CLAIM_TIMEOUT = httpx.Timeout(10.0, read=60.0)
|
|
16
|
+
_IO_TIMEOUT = httpx.Timeout(10.0, read=300.0)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RunnerAuthError(RuntimeError):
|
|
20
|
+
"""The server rejected the runner token (bad, rotated, or revoked)."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RunnerClient:
|
|
24
|
+
def __init__(self, server: str, token: str):
|
|
25
|
+
self._client = httpx.AsyncClient(
|
|
26
|
+
base_url=server.rstrip("/"),
|
|
27
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
28
|
+
timeout=_IO_TIMEOUT,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
async def aclose(self) -> None:
|
|
32
|
+
await self._client.aclose()
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def _check_auth(resp: httpx.Response) -> None:
|
|
36
|
+
if resp.status_code == 401:
|
|
37
|
+
raise RunnerAuthError(
|
|
38
|
+
"The server rejected this runner's token — it may have been "
|
|
39
|
+
"rotated or revoked. Run `genesis runner connect` again."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
async def probe(self) -> bool:
|
|
43
|
+
"""Side-effect-free auth check: a nonexistent work item reads 404 with
|
|
44
|
+
a valid token, 401 with a bad one (and never claims real work)."""
|
|
45
|
+
resp = await self._client.get("/api/runner/work/probe/workspace")
|
|
46
|
+
if resp.status_code == 401:
|
|
47
|
+
return False
|
|
48
|
+
return resp.status_code == 404
|
|
49
|
+
|
|
50
|
+
async def claim(self) -> dict | None:
|
|
51
|
+
"""One long-poll. None = no work this window (poll again)."""
|
|
52
|
+
resp = await self._client.post("/api/runner/claim", timeout=_CLAIM_TIMEOUT)
|
|
53
|
+
self._check_auth(resp)
|
|
54
|
+
if resp.status_code == 204:
|
|
55
|
+
return None
|
|
56
|
+
resp.raise_for_status()
|
|
57
|
+
data = resp.json()
|
|
58
|
+
return data if isinstance(data, dict) and data.get("item_id") else None
|
|
59
|
+
|
|
60
|
+
async def download_workspace(self, item_id: str, dest: Path) -> Path:
|
|
61
|
+
"""Stream the task workspace archive to dest (tar.gz)."""
|
|
62
|
+
async with self._client.stream(
|
|
63
|
+
"GET", f"/api/runner/work/{item_id}/workspace", timeout=_IO_TIMEOUT
|
|
64
|
+
) as resp:
|
|
65
|
+
self._check_auth(resp)
|
|
66
|
+
resp.raise_for_status()
|
|
67
|
+
with dest.open("wb") as f:
|
|
68
|
+
async for chunk in resp.aiter_bytes():
|
|
69
|
+
f.write(chunk)
|
|
70
|
+
return dest
|
|
71
|
+
|
|
72
|
+
async def post_events(self, item_id: str, events: list[dict]) -> bool:
|
|
73
|
+
"""Forward an event batch; the reply carries the cancel signal."""
|
|
74
|
+
resp = await self._client.post(
|
|
75
|
+
f"/api/runner/work/{item_id}/events", json={"events": events}
|
|
76
|
+
)
|
|
77
|
+
self._check_auth(resp)
|
|
78
|
+
resp.raise_for_status()
|
|
79
|
+
try:
|
|
80
|
+
return bool(resp.json().get("cancelled"))
|
|
81
|
+
except ValueError:
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
async def post_result(self, item_id: str, result: dict,
|
|
85
|
+
tar_path: Path | None = None) -> None:
|
|
86
|
+
"""Upload the task outcome (+ the changed-files archive when any)."""
|
|
87
|
+
import json
|
|
88
|
+
|
|
89
|
+
data = {"result": json.dumps(result)}
|
|
90
|
+
if tar_path is not None:
|
|
91
|
+
with tar_path.open("rb") as f:
|
|
92
|
+
resp = await self._client.post(
|
|
93
|
+
f"/api/runner/work/{item_id}/result", data=data,
|
|
94
|
+
files={"files": ("files.tar.gz", f, "application/gzip")},
|
|
95
|
+
timeout=_IO_TIMEOUT,
|
|
96
|
+
)
|
|
97
|
+
else:
|
|
98
|
+
resp = await self._client.post(
|
|
99
|
+
f"/api/runner/work/{item_id}/result", data=data, timeout=_IO_TIMEOUT
|
|
100
|
+
)
|
|
101
|
+
self._check_auth(resp)
|
|
102
|
+
resp.raise_for_status()
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Runner configuration: ~/.genesis/runner.toml, mode 0600.
|
|
2
|
+
|
|
3
|
+
The file holds the server URL, the runner token (a credential — hence the
|
|
4
|
+
restrictive mode and the minimal writer that never round-trips through a
|
|
5
|
+
general serializer), and this machine's runner name.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import tomllib
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
_FIELDS = ("server", "token", "name")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def config_dir() -> Path:
|
|
18
|
+
return Path(os.environ.get("GENESIS_RUNNER_HOME", str(Path.home() / ".genesis")))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def config_path() -> Path:
|
|
22
|
+
return config_dir() / "runner.toml"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def work_root() -> Path:
|
|
26
|
+
"""Where claimed task workspaces are materialized (and cleaned up)."""
|
|
27
|
+
root = config_dir() / "runner-work"
|
|
28
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
return root
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_config() -> dict:
|
|
33
|
+
path = config_path()
|
|
34
|
+
if not path.exists():
|
|
35
|
+
return {}
|
|
36
|
+
try:
|
|
37
|
+
data = tomllib.loads(path.read_text())
|
|
38
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
39
|
+
return {}
|
|
40
|
+
return {k: v for k, v in data.items() if k in _FIELDS and isinstance(v, str)}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _toml_str(value: str) -> str:
|
|
44
|
+
# Minimal TOML basic-string escaping: backslash and double quote.
|
|
45
|
+
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def save_config(server: str, token: str, name: str) -> Path:
|
|
49
|
+
path = config_path()
|
|
50
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
body = (
|
|
52
|
+
"# genesis runner credentials — created by `genesis runner connect`\n"
|
|
53
|
+
f"server = {_toml_str(server)}\n"
|
|
54
|
+
f"token = {_toml_str(token)}\n"
|
|
55
|
+
f"name = {_toml_str(name)}\n"
|
|
56
|
+
)
|
|
57
|
+
# Create 0600 BEFORE writing the secret (never a world-readable window).
|
|
58
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
59
|
+
with os.fdopen(fd, "w") as f:
|
|
60
|
+
f.write(body)
|
|
61
|
+
os.chmod(path, 0o600)
|
|
62
|
+
return path
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""The runner loop: connect, start, status.
|
|
2
|
+
|
|
3
|
+
start() is the long-lived process: claim → materialize the workspace →
|
|
4
|
+
drive the local `claude` CLI (events batched upstream ~1/s; the batch reply
|
|
5
|
+
carries the server's cancel signal) → diff → upload the result → clean up.
|
|
6
|
+
|
|
7
|
+
Ctrl-C is graceful: the first stops claiming and lets the current task
|
|
8
|
+
finish; the second aborts the task (the server records it interrupted).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import getpass
|
|
15
|
+
import platform
|
|
16
|
+
import re
|
|
17
|
+
import shutil
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from .claude_driver import ClaudeDriver, check_cli
|
|
23
|
+
from .client import RunnerAuthError, RunnerClient
|
|
24
|
+
from .config import config_path, load_config, save_config, work_root
|
|
25
|
+
from .workspace import build_result_tar, diff, manifest, safe_extract
|
|
26
|
+
|
|
27
|
+
_EVENT_FLUSH_S = 1.0
|
|
28
|
+
_CLAIM_BACKOFF_S = 5.0
|
|
29
|
+
_SAFE_NAME = re.compile(r"[^A-Za-z0-9._-]+")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _say(msg: str) -> None:
|
|
33
|
+
print(msg, flush=True)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ── connect ───────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
def connect(server: str, name: str | None = None) -> int:
|
|
39
|
+
server = server.strip().rstrip("/")
|
|
40
|
+
if not server.startswith(("http://", "https://")):
|
|
41
|
+
print("error: --server must be an http(s) URL", file=sys.stderr)
|
|
42
|
+
return 2
|
|
43
|
+
name = (name or platform.node() or "my-machine").strip()[:80]
|
|
44
|
+
_say(f"Connecting this machine to {server} as runner {name!r}.")
|
|
45
|
+
_say("In Genesis: Settings → Runners → New runner, then paste the grt_ "
|
|
46
|
+
"token below (it is shown there exactly once).")
|
|
47
|
+
try:
|
|
48
|
+
token = getpass.getpass("Runner token: ").strip()
|
|
49
|
+
except (EOFError, KeyboardInterrupt):
|
|
50
|
+
print("\naborted", file=sys.stderr)
|
|
51
|
+
return 2
|
|
52
|
+
if not token:
|
|
53
|
+
print("error: no token provided", file=sys.stderr)
|
|
54
|
+
return 2
|
|
55
|
+
|
|
56
|
+
async def _probe() -> bool:
|
|
57
|
+
client = RunnerClient(server, token)
|
|
58
|
+
try:
|
|
59
|
+
return await client.probe()
|
|
60
|
+
finally:
|
|
61
|
+
await client.aclose()
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
ok = asyncio.run(_probe())
|
|
65
|
+
except httpx.HTTPError as exc:
|
|
66
|
+
print(f"error: could not reach {server}: {exc}", file=sys.stderr)
|
|
67
|
+
return 1
|
|
68
|
+
if not ok:
|
|
69
|
+
print("error: the server rejected that token — mint a fresh one in "
|
|
70
|
+
"Settings → Runners and try again.", file=sys.stderr)
|
|
71
|
+
return 1
|
|
72
|
+
path = save_config(server, token, name)
|
|
73
|
+
_say(f"Connected. Credentials saved to {path} (mode 0600).")
|
|
74
|
+
_say("Start picking up work with: genesis runner start")
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ── status ────────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
def status() -> int:
|
|
81
|
+
cfg = load_config()
|
|
82
|
+
if not cfg.get("server") or not cfg.get("token"):
|
|
83
|
+
_say("Not connected. Run: genesis runner connect --server <URL>")
|
|
84
|
+
return 1
|
|
85
|
+
_say(f"Server : {cfg['server']}")
|
|
86
|
+
_say(f"Name : {cfg.get('name', '?')}")
|
|
87
|
+
_say(f"Config : {config_path()}")
|
|
88
|
+
ok, detail = check_cli()
|
|
89
|
+
_say(f"claude : {detail if ok else 'MISSING — ' + detail}")
|
|
90
|
+
|
|
91
|
+
async def _probe() -> bool:
|
|
92
|
+
client = RunnerClient(cfg["server"], cfg["token"])
|
|
93
|
+
try:
|
|
94
|
+
return await client.probe()
|
|
95
|
+
finally:
|
|
96
|
+
await client.aclose()
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
auth_ok = asyncio.run(_probe())
|
|
100
|
+
except httpx.HTTPError as exc:
|
|
101
|
+
_say(f"Auth : unreachable ({exc})")
|
|
102
|
+
return 1
|
|
103
|
+
_say(f"Auth : {'ok' if auth_ok else 'REJECTED — reconnect with a fresh token'}")
|
|
104
|
+
return 0 if (ok and auth_ok) else 1
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ── start ─────────────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
class _Stop:
|
|
110
|
+
"""Two-stage Ctrl-C: 1 = stop claiming, finish the task; 2 = abort it."""
|
|
111
|
+
|
|
112
|
+
def __init__(self) -> None:
|
|
113
|
+
self.stopping = False
|
|
114
|
+
self.driver: ClaudeDriver | None = None
|
|
115
|
+
|
|
116
|
+
def interrupt(self) -> None:
|
|
117
|
+
if not self.stopping:
|
|
118
|
+
self.stopping = True
|
|
119
|
+
_say("\nStopping — finishing the current task (Ctrl-C again to abort it).")
|
|
120
|
+
elif self.driver is not None:
|
|
121
|
+
_say("\nAborting the current task.")
|
|
122
|
+
self.driver.kill()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def start() -> int:
|
|
126
|
+
cfg = load_config()
|
|
127
|
+
if not cfg.get("server") or not cfg.get("token"):
|
|
128
|
+
print("error: not connected — run `genesis runner connect --server <URL>` "
|
|
129
|
+
"first.", file=sys.stderr)
|
|
130
|
+
return 2
|
|
131
|
+
ok, detail = check_cli()
|
|
132
|
+
if not ok:
|
|
133
|
+
print(f"error: {detail}", file=sys.stderr)
|
|
134
|
+
return 2
|
|
135
|
+
_say(f"claude CLI: {detail}")
|
|
136
|
+
try:
|
|
137
|
+
return asyncio.run(_start(cfg))
|
|
138
|
+
except KeyboardInterrupt:
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def _start(cfg: dict) -> int:
|
|
143
|
+
import signal
|
|
144
|
+
|
|
145
|
+
client = RunnerClient(cfg["server"], cfg["token"])
|
|
146
|
+
stop = _Stop()
|
|
147
|
+
loop = asyncio.get_running_loop()
|
|
148
|
+
try:
|
|
149
|
+
loop.add_signal_handler(signal.SIGINT, stop.interrupt)
|
|
150
|
+
except (NotImplementedError, RuntimeError):
|
|
151
|
+
pass # non-Unix: KeyboardInterrupt still lands in start()
|
|
152
|
+
|
|
153
|
+
_say(f"Runner {cfg.get('name', '?')!r} polling {cfg['server']} for work. Ctrl-C to stop.")
|
|
154
|
+
try:
|
|
155
|
+
while not stop.stopping:
|
|
156
|
+
try:
|
|
157
|
+
item = await client.claim()
|
|
158
|
+
except RunnerAuthError as exc:
|
|
159
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
160
|
+
return 1
|
|
161
|
+
except httpx.HTTPError as exc:
|
|
162
|
+
_say(f"server unreachable ({exc.__class__.__name__}); retrying in "
|
|
163
|
+
f"{_CLAIM_BACKOFF_S:.0f}s…")
|
|
164
|
+
await asyncio.sleep(_CLAIM_BACKOFF_S)
|
|
165
|
+
continue
|
|
166
|
+
if item is None:
|
|
167
|
+
continue
|
|
168
|
+
await _run_item(client, item, stop)
|
|
169
|
+
finally:
|
|
170
|
+
await client.aclose()
|
|
171
|
+
_say("Runner stopped.")
|
|
172
|
+
return 0
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
async def _run_item(client: RunnerClient, item: dict, stop: _Stop) -> None:
|
|
176
|
+
item_id = str(item.get("item_id"))
|
|
177
|
+
task = item.get("task") or {}
|
|
178
|
+
task_id = str(task.get("task_id") or "task")
|
|
179
|
+
project = _SAFE_NAME.sub("-", str(task.get("project_name") or "project")) or "project"
|
|
180
|
+
workdir = work_root() / project / item_id
|
|
181
|
+
workdir.mkdir(parents=True, exist_ok=True)
|
|
182
|
+
tar_in = workdir.parent / f"{item_id}.in.tar.gz"
|
|
183
|
+
tar_out = None
|
|
184
|
+
_say(f"▶ {task_id} (item {item_id[:12]}…) — downloading workspace")
|
|
185
|
+
|
|
186
|
+
events: list[dict] = []
|
|
187
|
+
|
|
188
|
+
def on_text(text: str) -> None:
|
|
189
|
+
events.append({"type": "agent_message", "data": {"text": text}})
|
|
190
|
+
|
|
191
|
+
driver = ClaudeDriver()
|
|
192
|
+
stop.driver = driver
|
|
193
|
+
|
|
194
|
+
async def flusher() -> None:
|
|
195
|
+
while True:
|
|
196
|
+
await asyncio.sleep(_EVENT_FLUSH_S)
|
|
197
|
+
# An empty batch still flushes: it doubles as the cancel poll.
|
|
198
|
+
batch, events[:] = list(events), []
|
|
199
|
+
try:
|
|
200
|
+
cancelled = await client.post_events(item_id, batch)
|
|
201
|
+
except (httpx.HTTPError, RunnerAuthError):
|
|
202
|
+
continue # transient — the final result upload is what matters
|
|
203
|
+
if cancelled:
|
|
204
|
+
_say(f"✖ {task_id} cancelled by the server — stopping claude")
|
|
205
|
+
driver.kill()
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
await client.download_workspace(item_id, tar_in)
|
|
210
|
+
safe_extract(tar_in, workdir)
|
|
211
|
+
before = manifest(workdir)
|
|
212
|
+
|
|
213
|
+
flush_task = asyncio.create_task(flusher())
|
|
214
|
+
try:
|
|
215
|
+
result = await driver.run(
|
|
216
|
+
str(task.get("prompt") or ""),
|
|
217
|
+
workdir=workdir,
|
|
218
|
+
resume_session=task.get("resume_session") or None,
|
|
219
|
+
on_text=on_text,
|
|
220
|
+
)
|
|
221
|
+
finally:
|
|
222
|
+
flush_task.cancel()
|
|
223
|
+
# Final event flush (best-effort).
|
|
224
|
+
if events:
|
|
225
|
+
try:
|
|
226
|
+
await client.post_events(item_id, list(events))
|
|
227
|
+
except (httpx.HTTPError, RunnerAuthError):
|
|
228
|
+
pass
|
|
229
|
+
events.clear()
|
|
230
|
+
|
|
231
|
+
after = manifest(workdir)
|
|
232
|
+
changed, deleted = diff(before, after)
|
|
233
|
+
payload = {
|
|
234
|
+
"status": result.get("status", "failed"),
|
|
235
|
+
"session_id": result.get("session_id"),
|
|
236
|
+
"error": result.get("error"),
|
|
237
|
+
"usage": result.get("usage") or {},
|
|
238
|
+
"deleted": deleted,
|
|
239
|
+
}
|
|
240
|
+
if changed:
|
|
241
|
+
tar_out = build_result_tar(workdir, changed)
|
|
242
|
+
await client.post_result(item_id, payload, tar_out)
|
|
243
|
+
_say(f"✓ {task_id}: {payload['status']} "
|
|
244
|
+
f"({len(changed)} changed, {len(deleted)} deleted)")
|
|
245
|
+
except RunnerAuthError:
|
|
246
|
+
raise
|
|
247
|
+
except Exception as exc: # noqa: BLE001 — report, never crash the loop
|
|
248
|
+
_say(f"✗ {task_id}: {exc.__class__.__name__}: {exc}")
|
|
249
|
+
try:
|
|
250
|
+
await client.post_result(item_id, {
|
|
251
|
+
"status": "failed", "session_id": None,
|
|
252
|
+
"error": f"runner error: {exc}"[:500],
|
|
253
|
+
"usage": {}, "deleted": [],
|
|
254
|
+
})
|
|
255
|
+
except (httpx.HTTPError, RunnerAuthError):
|
|
256
|
+
pass
|
|
257
|
+
finally:
|
|
258
|
+
stop.driver = None
|
|
259
|
+
tar_in.unlink(missing_ok=True)
|
|
260
|
+
if tar_out is not None:
|
|
261
|
+
tar_out.unlink(missing_ok=True)
|
|
262
|
+
shutil.rmtree(workdir, ignore_errors=True)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""argparse wiring for `genesis runner …` (heavy imports deferred to run time
|
|
2
|
+
so `genesis adopt` never pays for httpx)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _cmd_connect(args: argparse.Namespace) -> int:
|
|
10
|
+
from .loop import connect
|
|
11
|
+
|
|
12
|
+
return connect(args.server, args.name)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _cmd_start(args: argparse.Namespace) -> int:
|
|
16
|
+
from .loop import start
|
|
17
|
+
|
|
18
|
+
return start()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _cmd_status(args: argparse.Namespace) -> int:
|
|
22
|
+
from .loop import status
|
|
23
|
+
|
|
24
|
+
return status()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def add_runner_parser(sub: argparse._SubParsersAction) -> None:
|
|
28
|
+
runner = sub.add_parser(
|
|
29
|
+
"runner",
|
|
30
|
+
help="execute your Genesis builds' coding-agent tasks on THIS machine "
|
|
31
|
+
"with your local claude CLI (subscription) — the server keeps "
|
|
32
|
+
"orchestration, validation, gates, and billing",
|
|
33
|
+
)
|
|
34
|
+
rsub = runner.add_subparsers(dest="runner_command", required=True)
|
|
35
|
+
|
|
36
|
+
connect = rsub.add_parser(
|
|
37
|
+
"connect", help="pair this machine with a Genesis server "
|
|
38
|
+
"(paste a runner token from Settings → Runners)")
|
|
39
|
+
connect.add_argument("--server", required=True, metavar="URL",
|
|
40
|
+
help="the Genesis server origin, e.g. "
|
|
41
|
+
"https://genesis.example.com")
|
|
42
|
+
connect.add_argument("--name", default=None,
|
|
43
|
+
help="a name for this machine (default: hostname)")
|
|
44
|
+
connect.set_defaults(func=_cmd_connect)
|
|
45
|
+
|
|
46
|
+
start = rsub.add_parser(
|
|
47
|
+
"start", help="poll for work and run tasks locally (Ctrl-C to stop; "
|
|
48
|
+
"twice to abort the current task)")
|
|
49
|
+
start.set_defaults(func=_cmd_start)
|
|
50
|
+
|
|
51
|
+
status = rsub.add_parser("status", help="show connection, claude CLI and "
|
|
52
|
+
"auth status")
|
|
53
|
+
status.set_defaults(func=_cmd_status)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Local task workspaces: safe extraction, change manifests, result archives.
|
|
2
|
+
|
|
3
|
+
The server's archive is data from a machine seam — extraction validates every
|
|
4
|
+
member (relative, no '..', regular files only) and uses the tarfile "data"
|
|
5
|
+
filter. The change manifest (sha256 per file) brackets the agent run; the
|
|
6
|
+
diff decides what goes back up. The same exclusion set applies in every
|
|
7
|
+
direction: VCS internals, dependency trees, caches, env files.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import tarfile
|
|
14
|
+
import tempfile
|
|
15
|
+
from pathlib import Path, PurePosixPath
|
|
16
|
+
|
|
17
|
+
_EXCLUDE_DIRS = {".git", "node_modules", ".genesis-work", "__pycache__",
|
|
18
|
+
".venv", "dist", "build"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ExtractError(ValueError):
|
|
22
|
+
"""An archive member tried to escape or smuggle a non-file."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _excluded(rel: str) -> bool:
|
|
26
|
+
parts = PurePosixPath(rel).parts
|
|
27
|
+
return any(p in _EXCLUDE_DIRS or p.startswith(".env") for p in parts)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def safe_extract(tar_path: Path, dest: Path) -> list[str]:
|
|
31
|
+
"""Extract the workspace archive; every member is validated first.
|
|
32
|
+
Returns the extracted member names."""
|
|
33
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
names: list[str] = []
|
|
35
|
+
with tarfile.open(tar_path, mode="r:gz") as tar:
|
|
36
|
+
members = tar.getmembers()
|
|
37
|
+
for m in members:
|
|
38
|
+
p = PurePosixPath(m.name)
|
|
39
|
+
if p.is_absolute() or ".." in p.parts or not p.parts:
|
|
40
|
+
raise ExtractError(f"unsafe archive member path: {m.name!r}")
|
|
41
|
+
if not m.isreg():
|
|
42
|
+
raise ExtractError(f"archive member {m.name!r} is not a regular file")
|
|
43
|
+
tar.extractall(path=dest, members=members, filter="data")
|
|
44
|
+
names = [m.name for m in members]
|
|
45
|
+
return names
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def manifest(dest: Path) -> dict[str, str]:
|
|
49
|
+
"""sha256 per file under dest, excluded trees skipped — the before/after
|
|
50
|
+
bracket around a task run."""
|
|
51
|
+
out: dict[str, str] = {}
|
|
52
|
+
for path in sorted(dest.rglob("*")):
|
|
53
|
+
if not path.is_file() or path.is_symlink():
|
|
54
|
+
continue
|
|
55
|
+
rel = path.relative_to(dest).as_posix()
|
|
56
|
+
if _excluded(rel):
|
|
57
|
+
continue
|
|
58
|
+
h = hashlib.sha256()
|
|
59
|
+
try:
|
|
60
|
+
with path.open("rb") as f:
|
|
61
|
+
while chunk := f.read(1024 * 1024):
|
|
62
|
+
h.update(chunk)
|
|
63
|
+
except OSError:
|
|
64
|
+
continue
|
|
65
|
+
out[rel] = h.hexdigest()
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def diff(before: dict[str, str], after: dict[str, str]) -> tuple[list[str], list[str]]:
|
|
70
|
+
"""(changed, deleted): new or modified paths, and paths that vanished."""
|
|
71
|
+
changed = [p for p, digest in after.items() if before.get(p) != digest]
|
|
72
|
+
deleted = [p for p in before if p not in after]
|
|
73
|
+
return sorted(changed), sorted(deleted)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def build_result_tar(dest: Path, changed: list[str]) -> Path:
|
|
77
|
+
"""tar.gz of the changed files (relative to dest) in a temp file the
|
|
78
|
+
caller uploads then deletes. Excluded paths never ride along."""
|
|
79
|
+
fd = tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False)
|
|
80
|
+
tar_path = Path(fd.name)
|
|
81
|
+
with tarfile.open(fileobj=fd, mode="w:gz") as tar:
|
|
82
|
+
for rel in changed:
|
|
83
|
+
if _excluded(rel):
|
|
84
|
+
continue
|
|
85
|
+
full = dest / rel
|
|
86
|
+
if not full.is_file() or full.is_symlink():
|
|
87
|
+
continue
|
|
88
|
+
tar.add(str(full), arcname=rel, recursive=False)
|
|
89
|
+
fd.close()
|
|
90
|
+
return tar_path
|
|
@@ -50,6 +50,23 @@ def test_run_audit_shapes_and_findings(tmp_path):
|
|
|
50
50
|
assert abs(sum(c["points"] for c in r["categories"]) - r["score"]) < 0.2
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
def test_documented_env_var_is_not_an_env_hygiene_gap(tmp_path):
|
|
54
|
+
"""env_hygiene must measure declaration, not what this machine holds.
|
|
55
|
+
|
|
56
|
+
The seeded repo references ${API_SECRET_KEY} with no default; documenting
|
|
57
|
+
it in `.env.example` is exactly the fix the report asks for, so the gap has
|
|
58
|
+
to clear — previously it did not, which pinned the category at 0/10 in any
|
|
59
|
+
checkout whose compose files reference undefaulted vars.
|
|
60
|
+
"""
|
|
61
|
+
repo = _seed_repo(tmp_path)
|
|
62
|
+
(repo / ".env.example").write_text("API_SECRET_KEY=\n")
|
|
63
|
+
report = run_audit(repo)
|
|
64
|
+
assert report["env"]["missing_required"] == []
|
|
65
|
+
env_cat = next(c for c in report["readiness"]["categories"]
|
|
66
|
+
if c["key"] == "env_hygiene")
|
|
67
|
+
assert env_cat["points"] > 0
|
|
68
|
+
|
|
69
|
+
|
|
53
70
|
def test_renderers_and_agents_md(tmp_path):
|
|
54
71
|
report = run_audit(_seed_repo(tmp_path))
|
|
55
72
|
term = render_terminal(report)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Runner config: round-trip, restrictive mode, resilience to junk."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import stat
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from genesis_cli.runner.config import (
|
|
9
|
+
config_path,
|
|
10
|
+
load_config,
|
|
11
|
+
save_config,
|
|
12
|
+
work_root,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture(autouse=True)
|
|
17
|
+
def _home(tmp_path, monkeypatch):
|
|
18
|
+
monkeypatch.setenv("GENESIS_RUNNER_HOME", str(tmp_path / ".genesis"))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_round_trip_and_mode():
|
|
22
|
+
path = save_config("https://genesis.example.com/", "grt_secret-token", "my laptop")
|
|
23
|
+
assert path == config_path()
|
|
24
|
+
mode = stat.S_IMODE(path.stat().st_mode)
|
|
25
|
+
assert mode == 0o600
|
|
26
|
+
|
|
27
|
+
cfg = load_config()
|
|
28
|
+
assert cfg == {"server": "https://genesis.example.com/",
|
|
29
|
+
"token": "grt_secret-token", "name": "my laptop"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_overwrite_keeps_mode_0600():
|
|
33
|
+
save_config("https://a", "grt_1", "m")
|
|
34
|
+
save_config("https://b", "grt_2", "m")
|
|
35
|
+
assert stat.S_IMODE(config_path().stat().st_mode) == 0o600
|
|
36
|
+
assert load_config()["token"] == "grt_2"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_values_with_quotes_and_backslashes_survive():
|
|
40
|
+
save_config("https://a", 'grt_we"ird\\tok', 'na"me')
|
|
41
|
+
cfg = load_config()
|
|
42
|
+
assert cfg["token"] == 'grt_we"ird\\tok' and cfg["name"] == 'na"me'
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_missing_or_bad_file_reads_empty():
|
|
46
|
+
assert load_config() == {}
|
|
47
|
+
config_path().parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
config_path().write_text("not = valid = toml [")
|
|
49
|
+
assert load_config() == {}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_work_root_is_created_under_runner_home(tmp_path):
|
|
53
|
+
root = work_root()
|
|
54
|
+
assert root.is_dir()
|
|
55
|
+
assert root == tmp_path / ".genesis" / "runner-work"
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""ClaudeDriver: stream-json parsing against a fake `claude` on PATH —
|
|
2
|
+
final-result extraction, assistant-text callback, oversized lines, env
|
|
3
|
+
hygiene (ANTHROPIC_API_KEY never reaches the child)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import stat
|
|
11
|
+
import textwrap
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
from genesis_cli.runner.claude_driver import ClaudeDriver, check_cli
|
|
16
|
+
|
|
17
|
+
BIG = 300_000 # > asyncio's default 64 KB line cap
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture
|
|
21
|
+
def fake_claude(tmp_path: Path, monkeypatch):
|
|
22
|
+
def install(body: str) -> None:
|
|
23
|
+
bin_dir = tmp_path / "bin"
|
|
24
|
+
bin_dir.mkdir(exist_ok=True)
|
|
25
|
+
script = bin_dir / "claude"
|
|
26
|
+
script.write_text("#!/usr/bin/env python3\n" + textwrap.dedent(body))
|
|
27
|
+
script.chmod(script.stat().st_mode | stat.S_IEXEC)
|
|
28
|
+
monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{os.environ['PATH']}")
|
|
29
|
+
|
|
30
|
+
return install
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
STREAMING_BODY = f"""
|
|
34
|
+
import json, os, sys
|
|
35
|
+
args = sys.argv[1:]
|
|
36
|
+
if args == ["--version"]:
|
|
37
|
+
print("1.2.3 (fake)")
|
|
38
|
+
sys.exit(0)
|
|
39
|
+
# Record what the driver passed and what env the child saw.
|
|
40
|
+
open("argv.json", "w").write(json.dumps(args))
|
|
41
|
+
open("env.json", "w").write(json.dumps(
|
|
42
|
+
{{"has_api_key": "ANTHROPIC_API_KEY" in os.environ}}))
|
|
43
|
+
print(json.dumps({{"type": "system", "session_id": "sess-fake"}}))
|
|
44
|
+
print(json.dumps({{"type": "assistant", "session_id": "sess-fake",
|
|
45
|
+
"message": {{"content": [{{"type": "text", "text": "hello "}},
|
|
46
|
+
{{"type": "text", "text": "world"}}]}}}}))
|
|
47
|
+
# An oversized single line must not kill the stream.
|
|
48
|
+
print(json.dumps({{"type": "tool_result", "data": "x" * {BIG}}}))
|
|
49
|
+
print(json.dumps({{"type": "result", "subtype": "success", "is_error": False,
|
|
50
|
+
"session_id": "sess-fake", "total_cost_usd": None,
|
|
51
|
+
"usage": {{"input_tokens": 123, "output_tokens": 45}}}}))
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def test_driver_parses_stream_and_scrubs_env(tmp_path, fake_claude, monkeypatch):
|
|
56
|
+
fake_claude(STREAMING_BODY)
|
|
57
|
+
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-should-not-leak")
|
|
58
|
+
ok, detail = check_cli()
|
|
59
|
+
assert ok and "1.2.3" in detail
|
|
60
|
+
|
|
61
|
+
ws = tmp_path / "ws"
|
|
62
|
+
ws.mkdir()
|
|
63
|
+
texts: list[str] = []
|
|
64
|
+
result = await ClaudeDriver().run(
|
|
65
|
+
"build it", workdir=ws, resume_session="old-sess",
|
|
66
|
+
on_text=texts.append)
|
|
67
|
+
|
|
68
|
+
assert result["status"] == "completed"
|
|
69
|
+
assert result["session_id"] == "sess-fake"
|
|
70
|
+
assert result["usage"]["input_tokens"] == 123
|
|
71
|
+
assert result["usage"]["output_tokens"] == 45
|
|
72
|
+
assert result["usage"]["cost_usd"] is None # subscription: no dollar cost
|
|
73
|
+
assert result["usage"]["duration_s"] is not None
|
|
74
|
+
assert result["error"] is None
|
|
75
|
+
assert texts == ["hello world"]
|
|
76
|
+
|
|
77
|
+
argv = json.loads((ws / "argv.json").read_text())
|
|
78
|
+
assert argv[:2] == ["-p", "build it"]
|
|
79
|
+
for flag in ("--output-format", "--verbose", "--add-dir",
|
|
80
|
+
"--permission-mode", "--resume"):
|
|
81
|
+
assert flag in argv
|
|
82
|
+
assert argv[argv.index("--output-format") + 1] == "stream-json"
|
|
83
|
+
assert argv[argv.index("--permission-mode") + 1] == "acceptEdits"
|
|
84
|
+
assert argv[argv.index("--resume") + 1] == "old-sess"
|
|
85
|
+
assert argv[argv.index("--add-dir") + 1] == str(ws)
|
|
86
|
+
# The subscription rule: the child never sees an API key.
|
|
87
|
+
assert json.loads((ws / "env.json").read_text()) == {"has_api_key": False}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
FAILING_BODY = """
|
|
91
|
+
import json, sys
|
|
92
|
+
if sys.argv[1:] == ["--version"]:
|
|
93
|
+
print("1.2.3 (fake)"); sys.exit(0)
|
|
94
|
+
print(json.dumps({"type": "result", "subtype": "error", "is_error": True,
|
|
95
|
+
"session_id": "s2", "result": "budget exhausted",
|
|
96
|
+
"usage": {"input_tokens": 1, "output_tokens": 0}}))
|
|
97
|
+
sys.exit(1)
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def test_driver_reports_cli_error(tmp_path, fake_claude):
|
|
102
|
+
fake_claude(FAILING_BODY)
|
|
103
|
+
ws = tmp_path / "ws"
|
|
104
|
+
ws.mkdir()
|
|
105
|
+
result = await ClaudeDriver().run("x", workdir=ws)
|
|
106
|
+
assert result["status"] == "failed"
|
|
107
|
+
assert "budget exhausted" in (result["error"] or "")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
HANGING_BODY = """
|
|
111
|
+
import sys, time
|
|
112
|
+
if sys.argv[1:] == ["--version"]:
|
|
113
|
+
print("1.2.3 (fake)"); sys.exit(0)
|
|
114
|
+
sys.stdout.write('{"type": "system", "session_id": "s3"}\\n')
|
|
115
|
+
sys.stdout.flush()
|
|
116
|
+
time.sleep(60)
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def test_kill_marks_interrupted(tmp_path, fake_claude):
|
|
121
|
+
fake_claude(HANGING_BODY)
|
|
122
|
+
ws = tmp_path / "ws"
|
|
123
|
+
ws.mkdir()
|
|
124
|
+
driver = ClaudeDriver()
|
|
125
|
+
task = asyncio.create_task(driver.run("x", workdir=ws))
|
|
126
|
+
await asyncio.sleep(0.5)
|
|
127
|
+
driver.kill()
|
|
128
|
+
result = await asyncio.wait_for(task, timeout=10)
|
|
129
|
+
assert result["status"] == "interrupted"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def test_missing_cli_guidance(monkeypatch, tmp_path):
|
|
133
|
+
monkeypatch.setenv("PATH", str(tmp_path / "empty-bin"))
|
|
134
|
+
ok, detail = check_cli()
|
|
135
|
+
assert ok is False and "PATH" in detail
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Runner workspace handling: safe extraction, manifest diffs, result tars."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import tarfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
from genesis_cli.runner.workspace import (
|
|
11
|
+
ExtractError,
|
|
12
|
+
build_result_tar,
|
|
13
|
+
diff,
|
|
14
|
+
manifest,
|
|
15
|
+
safe_extract,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _make_tar(tmp_path: Path, entries: dict[str, bytes], *, symlink: str | None = None) -> Path:
|
|
20
|
+
path = tmp_path / "in.tar.gz"
|
|
21
|
+
with tarfile.open(path, mode="w:gz") as tar:
|
|
22
|
+
for name, data in entries.items():
|
|
23
|
+
info = tarfile.TarInfo(name)
|
|
24
|
+
info.size = len(data)
|
|
25
|
+
tar.addfile(info, io.BytesIO(data))
|
|
26
|
+
if symlink:
|
|
27
|
+
info = tarfile.TarInfo(symlink)
|
|
28
|
+
info.type = tarfile.SYMTYPE
|
|
29
|
+
info.linkname = "/etc/passwd"
|
|
30
|
+
tar.addfile(info)
|
|
31
|
+
return path
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_safe_extract_happy_path(tmp_path):
|
|
35
|
+
tar = _make_tar(tmp_path, {"index.html": b"<h1>x</h1>", "src/app.js": b"1"})
|
|
36
|
+
dest = tmp_path / "ws"
|
|
37
|
+
names = safe_extract(tar, dest)
|
|
38
|
+
assert set(names) == {"index.html", "src/app.js"}
|
|
39
|
+
assert (dest / "src" / "app.js").read_bytes() == b"1"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.mark.parametrize("name", ["../escape.txt", "/abs.txt", "a/../../up.txt"])
|
|
43
|
+
def test_safe_extract_rejects_traversal(tmp_path, name):
|
|
44
|
+
tar = _make_tar(tmp_path, {name: b"evil", "ok.txt": b"fine"})
|
|
45
|
+
dest = tmp_path / "ws"
|
|
46
|
+
with pytest.raises(ExtractError):
|
|
47
|
+
safe_extract(tar, dest)
|
|
48
|
+
# Fail closed: nothing extracted from a bad archive.
|
|
49
|
+
assert not (dest / "ok.txt").exists()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_safe_extract_rejects_symlinks(tmp_path):
|
|
53
|
+
tar = _make_tar(tmp_path, {"ok.txt": b"fine"}, symlink="sneaky")
|
|
54
|
+
with pytest.raises(ExtractError):
|
|
55
|
+
safe_extract(tar, tmp_path / "ws")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_manifest_diff_detects_changes_and_deletions(tmp_path):
|
|
59
|
+
ws = tmp_path / "ws"
|
|
60
|
+
(ws / "sub").mkdir(parents=True)
|
|
61
|
+
(ws / "keep.txt").write_text("same")
|
|
62
|
+
(ws / "edit.txt").write_text("v1")
|
|
63
|
+
(ws / "sub" / "gone.txt").write_text("bye")
|
|
64
|
+
before = manifest(ws)
|
|
65
|
+
|
|
66
|
+
(ws / "edit.txt").write_text("v2")
|
|
67
|
+
(ws / "new.txt").write_text("hello")
|
|
68
|
+
(ws / "sub" / "gone.txt").unlink()
|
|
69
|
+
after = manifest(ws)
|
|
70
|
+
|
|
71
|
+
changed, deleted = diff(before, after)
|
|
72
|
+
assert changed == ["edit.txt", "new.txt"]
|
|
73
|
+
assert deleted == ["sub/gone.txt"]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_manifest_skips_excluded_trees(tmp_path):
|
|
77
|
+
ws = tmp_path / "ws"
|
|
78
|
+
for d in (".git", "node_modules", ".genesis-work", "__pycache__", ".venv",
|
|
79
|
+
"dist", "build"):
|
|
80
|
+
(ws / d).mkdir(parents=True)
|
|
81
|
+
(ws / d / "f.txt").write_text("x")
|
|
82
|
+
(ws / ".env").write_text("SECRET=1")
|
|
83
|
+
(ws / ".env.local").write_text("SECRET=2")
|
|
84
|
+
(ws / "app.py").write_text("print(1)")
|
|
85
|
+
m = manifest(ws)
|
|
86
|
+
assert set(m) == {"app.py"}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_result_tar_excludes_protected_paths(tmp_path):
|
|
90
|
+
ws = tmp_path / "ws"
|
|
91
|
+
ws.mkdir()
|
|
92
|
+
(ws / "good.txt").write_text("ok")
|
|
93
|
+
(ws / ".env").write_text("SECRET=1")
|
|
94
|
+
(ws / "node_modules").mkdir()
|
|
95
|
+
(ws / "node_modules" / "x.js").write_text("x")
|
|
96
|
+
# Even if the changed list names protected paths, they never ride along.
|
|
97
|
+
tar_path = build_result_tar(ws, ["good.txt", ".env", "node_modules/x.js",
|
|
98
|
+
"missing.txt"])
|
|
99
|
+
try:
|
|
100
|
+
with tarfile.open(tar_path, mode="r:gz") as tar:
|
|
101
|
+
assert [m.name for m in tar.getmembers()] == ["good.txt"]
|
|
102
|
+
finally:
|
|
103
|
+
tar_path.unlink()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|