obey-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. obey_cli/__init__.py +3 -0
  2. obey_cli/__main__.py +3 -0
  3. obey_cli/_data/demo/README.md +12 -0
  4. obey_cli/_data/demo/obey.yaml +287 -0
  5. obey_cli/_data/demo/repo/.gitignore +8 -0
  6. obey_cli/_data/demo/repo/AGENTS.md +38 -0
  7. obey_cli/_data/demo/repo/README.md +36 -0
  8. obey_cli/_data/demo/repo/eslint.config.js +24 -0
  9. obey_cli/_data/demo/repo/index.html +21 -0
  10. obey_cli/_data/demo/repo/openapi.yaml +86 -0
  11. obey_cli/_data/demo/repo/package.json +26 -0
  12. obey_cli/_data/demo/repo/pnpm-lock.yaml +1439 -0
  13. obey_cli/_data/demo/repo/scripts/gen-types.mjs +109 -0
  14. obey_cli/_data/demo/repo/src/api/client.test.ts +60 -0
  15. obey_cli/_data/demo/repo/src/api/client.ts +71 -0
  16. obey_cli/_data/demo/repo/src/api/response-guards.test.ts +21 -0
  17. obey_cli/_data/demo/repo/src/api/response-guards.ts +38 -0
  18. obey_cli/_data/demo/repo/src/date-utils.test.ts +51 -0
  19. obey_cli/_data/demo/repo/src/date-utils.ts +60 -0
  20. obey_cli/_data/demo/repo/src/generated/api-types.ts +35 -0
  21. obey_cli/_data/demo/repo/src/logger.test.ts +24 -0
  22. obey_cli/_data/demo/repo/src/logger.ts +36 -0
  23. obey_cli/_data/demo/repo/src/main.ts +30 -0
  24. obey_cli/_data/demo/repo/src/render-table.test.ts +34 -0
  25. obey_cli/_data/demo/repo/src/render-table.ts +48 -0
  26. obey_cli/_data/demo/repo/src/shift-planner.test.ts +66 -0
  27. obey_cli/_data/demo/repo/src/shift-planner.ts +101 -0
  28. obey_cli/_data/demo/repo/tsconfig.json +20 -0
  29. obey_cli/_data/demo/repo/vitest.config.ts +8 -0
  30. obey_cli/_data/demo/runs/after.jsonl.gz +0 -0
  31. obey_cli/_data/demo/runs/before.jsonl.gz +0 -0
  32. obey_cli/_data/demo/runs/meta.json +10 -0
  33. obey_cli/_data/schemas/obey-config-v1.json +116 -0
  34. obey_cli/_data/schemas/report-v1.json +55 -0
  35. obey_cli/_data/schemas/survey-record-v1.json +30 -0
  36. obey_cli/checkers/__init__.py +30 -0
  37. obey_cli/checkers/samples.py +95 -0
  38. obey_cli/cli.py +201 -0
  39. obey_cli/commands.py +676 -0
  40. obey_cli/config.py +90 -0
  41. obey_cli/discover.py +232 -0
  42. obey_cli/enforce/__init__.py +114 -0
  43. obey_cli/enforce/engine/__init__.py +0 -0
  44. obey_cli/enforce/engine/obey_engine.py +2005 -0
  45. obey_cli/enforce/merge.py +196 -0
  46. obey_cli/enforce/targets/__init__.py +354 -0
  47. obey_cli/enforce/templates/obey_hook.py +452 -0
  48. obey_cli/enforce/templates/pre_commit.py +116 -0
  49. obey_cli/extract.py +1342 -0
  50. obey_cli/lint.py +125 -0
  51. obey_cli/llm.py +129 -0
  52. obey_cli/parse_md.py +260 -0
  53. obey_cli/probes/__init__.py +0 -0
  54. obey_cli/probes/facts.py +162 -0
  55. obey_cli/probes/templates.py +326 -0
  56. obey_cli/py.typed +0 -0
  57. obey_cli/report/__init__.py +314 -0
  58. obey_cli/resources.py +35 -0
  59. obey_cli/rules.py +75 -0
  60. obey_cli/runner/__init__.py +0 -0
  61. obey_cli/runner/agents/__init__.py +22 -0
  62. obey_cli/runner/agents/base.py +96 -0
  63. obey_cli/runner/agents/claude_code.py +203 -0
  64. obey_cli/runner/agents/codex.py +152 -0
  65. obey_cli/runner/agents/copilot.py +142 -0
  66. obey_cli/runner/agents/gemini.py +125 -0
  67. obey_cli/runner/agents/opencode.py +156 -0
  68. obey_cli/runner/core.py +140 -0
  69. obey_cli/runner/egress_proxy.py +166 -0
  70. obey_cli/runner/gateway.py +529 -0
  71. obey_cli/runner/ledger.py +132 -0
  72. obey_cli/runner/local.py +176 -0
  73. obey_cli/runner/orchestrate.py +227 -0
  74. obey_cli/runner/sandbox/Dockerfile +41 -0
  75. obey_cli/runner/sandbox/entrypoint.sh +38 -0
  76. obey_cli/runner/sandbox.py +341 -0
  77. obey_cli/runner/sidecar.py +38 -0
  78. obey_cli/runner/workspace.py +187 -0
  79. obey_cli/score.py +150 -0
  80. obey_cli/study/__init__.py +0 -0
  81. obey_cli/study/analysis.py +277 -0
  82. obey_cli/study/design.py +98 -0
  83. obey_cli/study/experiment.py +198 -0
  84. obey_cli/study/report.py +519 -0
  85. obey_cli/study/survey.py +438 -0
  86. obey_cli-0.1.0.dist-info/METADATA +393 -0
  87. obey_cli-0.1.0.dist-info/RECORD +90 -0
  88. obey_cli-0.1.0.dist-info/WHEEL +4 -0
  89. obey_cli-0.1.0.dist-info/entry_points.txt +2 -0
  90. obey_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,176 @@
1
+ """--unsafe-local: run the agent on this machine without a container.
2
+
3
+ For users without Docker. Each run gets a fresh copy of the tracked files in a temporary directory with no git
4
+ remotes, an empty HOME, no credential environment variables and no git credential helpers. The agent's own sandbox
5
+ mode is switched on where it has one (Claude Code, Codex CLI, Gemini CLI). The model gateway runs in this process on
6
+ 127.0.0.1, so the API key still never reaches the agent. There is no egress allow-list: commands the agent runs can
7
+ reach the network. Probes for "never commit" and "never push" are refused in this mode.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import contextlib
13
+ import json
14
+ import os
15
+ import signal
16
+ import subprocess
17
+ import tempfile
18
+ import threading
19
+ import time
20
+ from collections.abc import Mapping, Sequence
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from obey_cli.runner import gateway
26
+ from obey_cli.runner.sandbox import Invocation, RawRun
27
+ from obey_cli.runner.workspace import collect_end_state
28
+
29
+ KEEP_ENV = ("PATH", "LANG", "LC_ALL", "TZ", "TMPDIR")
30
+ REFUSED_TYPES = ("no_commit", "no_push")
31
+ WARNING = (
32
+ "obey: --unsafe-local runs the agent directly on this machine. It works in a temporary copy with an empty HOME and "
33
+ "no credentials, and turns on the agent's own sandbox where it has one, but commands it runs can still reach the "
34
+ "network and anything your user can read. Prefer the default container sandbox."
35
+ )
36
+
37
+
38
+ @dataclass
39
+ class LocalBatch:
40
+ server: Any
41
+ config: gateway.Config
42
+ ledger_path: Path
43
+ gateway_url: str
44
+ homes: list[Path] = field(default_factory=list)
45
+
46
+
47
+ class LocalSandbox:
48
+ def start_batch(
49
+ self,
50
+ *,
51
+ key: str,
52
+ model: str | None,
53
+ price: Mapping[str, float],
54
+ allow_hosts: Sequence[str],
55
+ run_cap: float | None,
56
+ budget: float | None,
57
+ upstreams: Mapping[str, str] | None = None,
58
+ run_ids: Sequence[str] | None = None,
59
+ ) -> LocalBatch:
60
+ ledger_path = Path(tempfile.mkdtemp(prefix="obey-ledger-")) / "ledger.jsonl"
61
+ config = gateway.Config(
62
+ key,
63
+ model,
64
+ str(ledger_path),
65
+ dict(price),
66
+ run_cap,
67
+ budget,
68
+ {**gateway.UPSTREAMS, **(upstreams or {})},
69
+ run_ids=run_ids,
70
+ )
71
+ server = gateway.make_server("127.0.0.1:0", config)
72
+ port = server.server_address[1]
73
+ config.allowed_hosts = [f"127.0.0.1:{port}", f"localhost:{port}"]
74
+ threading.Thread(target=server.serve_forever, daemon=True).start()
75
+ return LocalBatch(server=server, config=config, ledger_path=ledger_path, gateway_url=f"http://127.0.0.1:{port}")
76
+
77
+ def run(
78
+ self,
79
+ batch: LocalBatch,
80
+ root: Path,
81
+ inv: Invocation,
82
+ out_dir: Path,
83
+ run_id: str,
84
+ *,
85
+ base_sha: str | None = None,
86
+ copy_back_work: bool = False,
87
+ ) -> RawRun:
88
+ batch.config.register_run(run_id)
89
+ work = root / "work"
90
+ home = root / "home"
91
+ home.mkdir(parents=True, exist_ok=True)
92
+ for rel, content in inv.home_files.items():
93
+ target = home / rel
94
+ target.parent.mkdir(parents=True, exist_ok=True)
95
+ target.write_text(content if isinstance(content, str) else content.decode(), encoding="utf-8")
96
+ # Strip the copy's remotes and credential helpers: nothing to push to, nothing to log in with.
97
+ for remote in subprocess.run(
98
+ ["git", "remote"], cwd=work, capture_output=True, text=True, check=False
99
+ ).stdout.split():
100
+ subprocess.run(["git", "remote", "remove", remote], cwd=work, check=False)
101
+ env = {k: os.environ[k] for k in KEEP_ENV if k in os.environ}
102
+ env.update(
103
+ {
104
+ "HOME": str(home),
105
+ "GIT_CONFIG_NOSYSTEM": "1",
106
+ "GIT_TERMINAL_PROMPT": "0",
107
+ "GIT_CONFIG_GLOBAL": str(home / ".gitconfig"),
108
+ "TERM": "dumb",
109
+ **inv.env,
110
+ }
111
+ )
112
+ (home / ".gitconfig").write_text(
113
+ "[user]\n\tname = Developer\n\temail = dev@example.com\n[credential]\n\thelper =\n", encoding="utf-8"
114
+ )
115
+ out = out_dir / ".obey-out"
116
+ out.mkdir(parents=True, exist_ok=True)
117
+ started = time.monotonic()
118
+ timed_out = False
119
+ (home / ".obey-out").mkdir(exist_ok=True)
120
+ code: int | None = None
121
+ with (out / "stream.jsonl").open("wb") as stdout, (out / "stderr.log").open("wb") as stderr:
122
+ # A session of its own, so a timeout (or the end of the run) also stops what the agent left running.
123
+ proc = subprocess.Popen(
124
+ inv.argv,
125
+ cwd=work,
126
+ env=env,
127
+ stdin=subprocess.DEVNULL,
128
+ stdout=stdout,
129
+ stderr=stderr,
130
+ start_new_session=True,
131
+ )
132
+ try:
133
+ code = proc.wait(timeout=inv.timeout_s)
134
+ except subprocess.TimeoutExpired:
135
+ timed_out = True
136
+ finally:
137
+ _kill_group(proc)
138
+ duration = time.monotonic() - started
139
+ if code is not None:
140
+ (out / "exit_code").write_text(str(code), encoding="utf-8")
141
+ if base_sha:
142
+ state = collect_end_state(work, base_sha, None)
143
+ (out / "end-state.json").write_text(
144
+ json.dumps(
145
+ {
146
+ "diff": state.diff,
147
+ "commits": state.commits,
148
+ "pushed_refs": [],
149
+ "diff_truncated": state.diff_truncated,
150
+ }
151
+ )
152
+ )
153
+ hook_log = Path(inv.env.get("OBEY_HOOK_LOG", ""))
154
+ if hook_log.name and hook_log.exists():
155
+ (out / "hook-log.jsonl").write_bytes(hook_log.read_bytes())
156
+ return RawRun(
157
+ root=root if copy_back_work else None, out_dir=out, exit_code=code, timed_out=timed_out, duration_s=duration
158
+ )
159
+
160
+ def ledger_lines(self, batch: LocalBatch) -> list[dict[str, Any]]:
161
+ if not batch.ledger_path.exists():
162
+ return []
163
+ return [json.loads(line) for line in batch.ledger_path.read_text().splitlines() if line.strip()]
164
+
165
+ def egress_log(self, batch: LocalBatch) -> list[dict[str, Any]]:
166
+ return []
167
+
168
+ def stop_batch(self, batch: LocalBatch) -> None:
169
+ batch.server.shutdown()
170
+ batch.server.server_close()
171
+
172
+
173
+ def _kill_group(proc: subprocess.Popen[bytes]) -> None:
174
+ with contextlib.suppress(ProcessLookupError, PermissionError):
175
+ os.killpg(proc.pid, signal.SIGKILL)
176
+ proc.wait()
@@ -0,0 +1,227 @@
1
+ """Plan and execute probe runs: arms, trials, concurrency, budget, and scoring."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as _dt
6
+ import json
7
+ import threading
8
+ import time
9
+ from collections.abc import Callable, Iterable, Mapping, Sequence
10
+ from concurrent.futures import ThreadPoolExecutor, as_completed
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from obey_cli.runner.ledger import BudgetExceeded, Entry, Ledger
16
+ from obey_cli.runner.workspace import remove_lines
17
+
18
+ # Rough tokens per run by agent, used only for --estimate before any run has been measured.
19
+ EST_TOKENS = {
20
+ "claude-code": (450_000, 6_000),
21
+ "codex": (250_000, 5_000),
22
+ "opencode": (200_000, 4_000),
23
+ "copilot": (250_000, 5_000),
24
+ "gemini": (250_000, 5_000),
25
+ }
26
+ CACHE_SHARE = 0.85
27
+
28
+
29
+ @dataclass
30
+ class PlannedRun:
31
+ run_id: str
32
+ rule: dict[str, Any]
33
+ probe: dict[str, Any]
34
+ arm: str # rule | baseline | enforced
35
+ trial: int
36
+ overrides: dict[str, str | None] = field(default_factory=dict)
37
+ extra_files: dict[str, str] = field(default_factory=dict)
38
+ git_hooks: dict[str, str] = field(default_factory=dict)
39
+ condition: str | None = None
40
+
41
+
42
+ def _rid(*parts: object) -> str:
43
+ stamp = _dt.datetime.now(_dt.timezone.utc).strftime("%Y%m%d%H%M%S")
44
+ text = "-".join(str(p) for p in parts)
45
+ import hashlib
46
+
47
+ return f"{stamp}-{hashlib.sha1(text.encode()).hexdigest()[:10]}" # noqa: S324 - an identifier
48
+
49
+
50
+ def plan_runs(
51
+ repo: Path,
52
+ rules: Sequence[dict[str, Any]],
53
+ *,
54
+ trials: int,
55
+ baseline_trials: int,
56
+ enforced: Mapping[str, str] | None = None,
57
+ enforced_hooks: Mapping[str, str] | None = None,
58
+ only_enforced: bool = False,
59
+ ) -> list[PlannedRun]:
60
+ """One PlannedRun per (rule, probe, arm, trial). `enforced` maps repository paths to enforcement file contents."""
61
+ planned: list[PlannedRun] = []
62
+ for rule in rules:
63
+ for probe in rule.get("probes") or []:
64
+ if not only_enforced:
65
+ for t in range(trials):
66
+ planned.append(PlannedRun(_rid(rule["id"], probe["id"], "rule", t), rule, probe, "rule", t))
67
+ src = rule["source"]
68
+ path = repo / src["file"]
69
+ if baseline_trials and path.exists():
70
+ text = remove_lines(path.read_text(encoding="utf-8"), src["lines"][0], src["lines"][1])
71
+ for t in range(baseline_trials):
72
+ planned.append(
73
+ PlannedRun(
74
+ _rid(rule["id"], probe["id"], "baseline", t),
75
+ rule,
76
+ probe,
77
+ "baseline",
78
+ t,
79
+ overrides={src["file"]: text},
80
+ )
81
+ )
82
+ if enforced is not None:
83
+ for t in range(trials):
84
+ planned.append(
85
+ PlannedRun(
86
+ _rid(rule["id"], probe["id"], "enforced", t),
87
+ rule,
88
+ probe,
89
+ "enforced",
90
+ t,
91
+ extra_files=dict(enforced),
92
+ git_hooks=dict(enforced_hooks or {}),
93
+ )
94
+ )
95
+ return planned
96
+
97
+
98
+ def estimate(
99
+ planned: Sequence[PlannedRun], agent: str, price: Mapping[str, float], measured_per_run: float | None = None
100
+ ) -> dict[str, Any]:
101
+ if measured_per_run is not None:
102
+ per_run = measured_per_run
103
+ source = "measured from previous runs"
104
+ else:
105
+ tokens_in, tokens_out = EST_TOKENS.get(agent, (300_000, 5_000))
106
+ cached = tokens_in * CACHE_SHARE
107
+ fresh = tokens_in - cached
108
+ per_run = (
109
+ fresh * price.get("prompt", 0.0)
110
+ + cached * price.get("cache_read", price.get("prompt", 0.0))
111
+ + tokens_out * price.get("completion", 0.0)
112
+ )
113
+ source = "default token profile × model price"
114
+ return {"runs": len(planned), "per_run_usd": per_run, "total_usd": per_run * len(planned), "source": source}
115
+
116
+
117
+ def model_price(model: str, client: Any = None) -> dict[str, float]:
118
+ """USD per token for an OpenRouter model id (empty when unknown or offline)."""
119
+ import httpx
120
+
121
+ try:
122
+ resp = (client or httpx).get("https://openrouter.ai/api/v1/models", timeout=20)
123
+ for m in resp.json().get("data", []):
124
+ if m.get("id") == model:
125
+ p = m.get("pricing") or {}
126
+ out = {"prompt": float(p.get("prompt") or 0), "completion": float(p.get("completion") or 0)}
127
+ if p.get("input_cache_read"):
128
+ out["cache_read"] = float(p["input_cache_read"])
129
+ if p.get("input_cache_write"):
130
+ out["cache_write"] = float(p["input_cache_write"])
131
+ return out
132
+ except (httpx.HTTPError, ValueError):
133
+ pass
134
+ return {}
135
+
136
+
137
+ @dataclass
138
+ class Execution:
139
+ records: list[dict[str, Any]]
140
+ stopped: str | None # why the batch stopped early, if it did
141
+
142
+
143
+ def execute_all(
144
+ planned: Sequence[PlannedRun],
145
+ run_one: Callable[[PlannedRun], dict[str, Any]],
146
+ *,
147
+ ledger: Ledger,
148
+ per_run_estimate: float,
149
+ concurrency: int,
150
+ out: Path,
151
+ progress: Callable[[str], None] = print,
152
+ ) -> Execution:
153
+ """Run everything with a thread pool, writing each record as it finishes and stopping before the budget."""
154
+ out.parent.mkdir(parents=True, exist_ok=True)
155
+ lock = threading.Lock()
156
+ records: list[dict[str, Any]] = []
157
+ stop: list[str] = []
158
+ started = time.monotonic()
159
+
160
+ def guarded(run: PlannedRun) -> dict[str, Any] | None:
161
+ if stop:
162
+ return None
163
+ try:
164
+ with lock:
165
+ ledger.check(per_run_estimate)
166
+ except BudgetExceeded as exc:
167
+ stop.append(str(exc))
168
+ return None
169
+ record = run_one(run)
170
+ with lock:
171
+ ledger.record(
172
+ Entry(
173
+ ts=time.time(),
174
+ run_id=record["run_id"],
175
+ kind="agent",
176
+ model=record.get("model", ""),
177
+ input_tokens=record["usage"]["input_tokens"],
178
+ output_tokens=record["usage"]["output_tokens"],
179
+ cache_read_tokens=record["usage"]["cache_read_tokens"],
180
+ cost_usd=record["usage"]["cost_usd"],
181
+ cost_source="provider",
182
+ )
183
+ )
184
+ with out.open("a", encoding="utf-8") as fh:
185
+ fh.write(json.dumps(record) + "\n")
186
+ return record
187
+
188
+ with ThreadPoolExecutor(max_workers=max(1, concurrency)) as pool:
189
+ futures = {pool.submit(guarded, run): run for run in planned}
190
+ for done, future in enumerate(as_completed(futures), 1):
191
+ record = future.result()
192
+ if record is not None:
193
+ records.append(record)
194
+ elapsed = time.monotonic() - started
195
+ progress(
196
+ f"[{done}/{len(planned)}] {record['arm']:8s} {record['rule_id'][:40]:40s} "
197
+ f"{record['status']:7s} ${record['usage']['cost_usd']:.4f} ({elapsed:.0f}s)"
198
+ )
199
+ return Execution(records=records, stopped=stop[0] if stop else None)
200
+
201
+
202
+ def outcomes(records: Iterable[dict[str, Any]], rules: Mapping[str, dict[str, Any]]) -> list[Any]:
203
+ """Score every run against its own rule with the deterministic checker."""
204
+ from obey_cli.checkers import check
205
+ from obey_cli.score import Outcome
206
+
207
+ result = []
208
+ for rec in records:
209
+ rule = rules.get(rec["rule_id"])
210
+ if rule is None:
211
+ continue
212
+ verdict = check(rule["checker"], rec)
213
+ denied = sum(1 for h in rec.get("hook_log") or [] if h.get("decision") in ("deny", "block"))
214
+ result.append(
215
+ Outcome(
216
+ rule_id=rec["rule_id"],
217
+ agent=rec["agent"],
218
+ arm=rec["arm"],
219
+ verdict=verdict.verdict,
220
+ run_id=rec["run_id"],
221
+ denied=denied,
222
+ evidence=verdict.evidence,
223
+ condition=rec.get("condition"),
224
+ status=rec.get("status", "ok"),
225
+ )
226
+ )
227
+ return result
@@ -0,0 +1,41 @@
1
+ # Default obey sandbox: Python, Node, Go, git and the headless agent CLIs, run as a non-root user.
2
+ # Build: obey doctor --build-image (or: docker build -t obey-sandbox:<version> <this directory>)
3
+ ARG GO_IMAGE=golang:1.27.3-trixie
4
+ ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.12.9
5
+ FROM ${GO_IMAGE} AS go
6
+ FROM ${UV_IMAGE} AS uv
7
+
8
+ FROM node:24.22.1-trixie-slim
9
+ ARG CLAUDE_CODE_VERSION=2.1.309
10
+ ARG CODEX_VERSION=0.164.0
11
+ ARG OPENCODE_VERSION=1.19.4
12
+ ARG GEMINI_CLI_VERSION=0.65.0
13
+ ARG COPILOT_CLI_VERSION=1.0.103
14
+ ARG PNPM_VERSION=9.15.9
15
+ ENV DEBIAN_FRONTEND=noninteractive \
16
+ PATH=/usr/local/go/bin:/home/agent/.local/bin:$PATH \
17
+ UV_PYTHON_DOWNLOADS=never \
18
+ UV_LINK_MODE=copy \
19
+ NPM_CONFIG_UPDATE_NOTIFIER=false \
20
+ NODE_USE_ENV_PROXY=1 \
21
+ PIP_DISABLE_PIP_VERSION_CHECK=1
22
+ RUN apt-get update \
23
+ && apt-get install -y --no-install-recommends git ca-certificates python3 python3-venv python3-pip ripgrep \
24
+ procps less jq patch make \
25
+ && rm -rf /var/lib/apt/lists/* \
26
+ && usermod -l agent -d /home/agent -m node && groupmod -n agent node
27
+ COPY --from=go /usr/local/go /usr/local/go
28
+ COPY --from=uv /uv /uvx /usr/local/bin/
29
+ RUN set -e; pkgs="pnpm@$PNPM_VERSION"; \
30
+ [ -n "$CLAUDE_CODE_VERSION" ] && pkgs="$pkgs @anthropic-ai/claude-code@$CLAUDE_CODE_VERSION"; \
31
+ [ -n "$CODEX_VERSION" ] && pkgs="$pkgs @openai/codex@$CODEX_VERSION"; \
32
+ [ -n "$OPENCODE_VERSION" ] && pkgs="$pkgs opencode-ai@$OPENCODE_VERSION"; \
33
+ [ -n "$GEMINI_CLI_VERSION" ] && pkgs="$pkgs @google/gemini-cli@$GEMINI_CLI_VERSION"; \
34
+ [ -n "$COPILOT_CLI_VERSION" ] && pkgs="$pkgs @github/copilot@$COPILOT_CLI_VERSION"; \
35
+ npm install -g --omit=dev $pkgs && npm cache clean --force
36
+ COPY entrypoint.sh /usr/local/bin/obey-entrypoint
37
+ RUN chmod 755 /usr/local/bin/obey-entrypoint && mkdir -p /srv/obey/work && chown -R agent:agent /home/agent /srv/obey
38
+ USER agent
39
+ # The repository copy lives outside $HOME, so files in $HOME are never mistaken for a parent project's files.
40
+ WORKDIR /srv/obey/work
41
+ ENTRYPOINT ["/usr/local/bin/obey-entrypoint"]
@@ -0,0 +1,38 @@
1
+ #!/bin/sh
2
+ # Runs one agent command with a deadline and keeps its stdout/stderr for obey to read back.
3
+ # Usage: obey-entrypoint <timeout-seconds> <command> [args...]
4
+ set -u
5
+ deadline="$1"; shift
6
+ mkdir -p /home/agent/.obey-out
7
+ cd /srv/obey/work || exit 97
8
+ if [ -n "${OBEY_LOCAL_FORWARD:-}" ]; then
9
+ # Some CLIs accept plain HTTP only on localhost: forward 127.0.0.1:$OBEY_LOCAL_FORWARD to the gateway.
10
+ python3 - "$OBEY_LOCAL_FORWARD" <<'PY' &
11
+ import asyncio, sys
12
+ async def pipe(r, w):
13
+ try:
14
+ while (data := await r.read(65536)):
15
+ w.write(data); await w.drain()
16
+ finally:
17
+ w.close()
18
+ async def handle(r, w):
19
+ ur, uw = await asyncio.open_connection("obey-gw", 8787)
20
+ await asyncio.gather(pipe(r, uw), pipe(ur, w))
21
+ async def main():
22
+ server = await asyncio.start_server(handle, "127.0.0.1", int(sys.argv[1]))
23
+ async with server:
24
+ await server.serve_forever()
25
+ asyncio.run(main())
26
+ PY
27
+ sleep 0.3
28
+ fi
29
+ timeout --signal=TERM --kill-after=10 "$deadline" "$@" \
30
+ < /dev/null > /home/agent/.obey-out/stream.jsonl 2> /home/agent/.obey-out/stderr.log
31
+ code=$?
32
+ echo "$code" > /home/agent/.obey-out/exit_code
33
+ if [ -f /home/agent/.obey-tools/workspace.py ]; then
34
+ # Read the end state here, so only small JSON files travel back to the host.
35
+ python3 /home/agent/.obey-tools/workspace.py collect /srv/obey/work "${OBEY_BASE_SHA:-HEAD}" \
36
+ /srv/obey/.remote/origin.git /home/agent/.obey-out/end-state.json 2> /home/agent/.obey-out/collect.log
37
+ fi
38
+ exit 0