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.
Files changed (35) hide show
  1. {genesis_cli-0.1.0 → genesis_cli-0.2.0}/PKG-INFO +4 -3
  2. {genesis_cli-0.1.0 → genesis_cli-0.2.0}/pyproject.toml +4 -2
  3. {genesis_cli-0.1.0 → genesis_cli-0.2.0}/src/genesis_cli/adopt.py +8 -1
  4. {genesis_cli-0.1.0 → genesis_cli-0.2.0}/src/genesis_cli/init.py +7 -0
  5. {genesis_cli-0.1.0 → genesis_cli-0.2.0}/src/genesis_cli/main.py +11 -0
  6. genesis_cli-0.2.0/src/genesis_cli/runner/__init__.py +11 -0
  7. genesis_cli-0.2.0/src/genesis_cli/runner/claude_driver.py +185 -0
  8. genesis_cli-0.2.0/src/genesis_cli/runner/client.py +102 -0
  9. genesis_cli-0.2.0/src/genesis_cli/runner/config.py +62 -0
  10. genesis_cli-0.2.0/src/genesis_cli/runner/loop.py +262 -0
  11. genesis_cli-0.2.0/src/genesis_cli/runner/runner_cmd.py +53 -0
  12. genesis_cli-0.2.0/src/genesis_cli/runner/workspace.py +90 -0
  13. {genesis_cli-0.1.0 → genesis_cli-0.2.0}/tests/test_adopt.py +17 -0
  14. genesis_cli-0.2.0/tests/test_runner_config.py +55 -0
  15. genesis_cli-0.2.0/tests/test_runner_driver.py +135 -0
  16. genesis_cli-0.2.0/tests/test_runner_workspace.py +103 -0
  17. {genesis_cli-0.1.0 → genesis_cli-0.2.0}/.gitignore +0 -0
  18. {genesis_cli-0.1.0 → genesis_cli-0.2.0}/LICENSE +0 -0
  19. {genesis_cli-0.1.0 → genesis_cli-0.2.0}/README.md +0 -0
  20. {genesis_cli-0.1.0 → genesis_cli-0.2.0}/action/README.md +0 -0
  21. {genesis_cli-0.1.0 → genesis_cli-0.2.0}/action/action.yml +0 -0
  22. {genesis_cli-0.1.0 → genesis_cli-0.2.0}/src/genesis_cli/__init__.py +0 -0
  23. {genesis_cli-0.1.0 → genesis_cli-0.2.0}/src/genesis_cli/agents_md.py +0 -0
  24. {genesis_cli-0.1.0 → genesis_cli-0.2.0}/src/genesis_cli/baseline.py +0 -0
  25. {genesis_cli-0.1.0 → genesis_cli-0.2.0}/src/genesis_cli/gates.py +0 -0
  26. {genesis_cli-0.1.0 → genesis_cli-0.2.0}/src/genesis_cli/report.py +0 -0
  27. {genesis_cli-0.1.0 → genesis_cli-0.2.0}/src/genesis_cli/review.py +0 -0
  28. {genesis_cli-0.1.0 → genesis_cli-0.2.0}/src/genesis_cli/sarif.py +0 -0
  29. {genesis_cli-0.1.0 → genesis_cli-0.2.0}/tests/test_baseline.py +0 -0
  30. {genesis_cli-0.1.0 → genesis_cli-0.2.0}/tests/test_gates_cmd.py +0 -0
  31. {genesis_cli-0.1.0 → genesis_cli-0.2.0}/tests/test_init.py +0 -0
  32. {genesis_cli-0.1.0 → genesis_cli-0.2.0}/tests/test_review.py +0 -0
  33. {genesis_cli-0.1.0 → genesis_cli-0.2.0}/tests/test_sarif.py +0 -0
  34. {genesis_cli-0.1.0 → genesis_cli-0.2.0}/tests/test_schemas.py +0 -0
  35. {genesis_cli-0.1.0 → genesis_cli-0.2.0}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: genesis-cli
3
- Version: 0.1.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.1.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.1.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.1.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
- reconciliation = reconcile(repo, stored_deps={})
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