autop-runner 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 (89) hide show
  1. autop_runner/__init__.py +19 -0
  2. autop_runner/cli.py +429 -0
  3. autop_runner/client.py +240 -0
  4. autop_runner/config.py +267 -0
  5. autop_runner/credentials/__init__.py +18 -0
  6. autop_runner/credentials/broker.py +208 -0
  7. autop_runner/credentials/env.py +82 -0
  8. autop_runner/credentials/gh_wrapper.py +136 -0
  9. autop_runner/credentials/git_helper.py +51 -0
  10. autop_runner/credstore.py +69 -0
  11. autop_runner/executor/__init__.py +15 -0
  12. autop_runner/executor/address_review.py +739 -0
  13. autop_runner/executor/credential_check.py +165 -0
  14. autop_runner/executor/diagnostics.py +131 -0
  15. autop_runner/executor/epic.py +283 -0
  16. autop_runner/executor/external.py +559 -0
  17. autop_runner/executor/fixes.py +270 -0
  18. autop_runner/executor/flowkit.py +136 -0
  19. autop_runner/executor/gitops.py +551 -0
  20. autop_runner/executor/implement.py +896 -0
  21. autop_runner/executor/implement_credential_recovery.py +315 -0
  22. autop_runner/executor/implement_pre_review.py +379 -0
  23. autop_runner/executor/implement_publish.py +241 -0
  24. autop_runner/executor/implement_recovery.py +721 -0
  25. autop_runner/executor/implement_reuse.py +530 -0
  26. autop_runner/executor/implementation_hold.py +131 -0
  27. autop_runner/executor/issue_comment.py +137 -0
  28. autop_runner/executor/issue_mention.py +397 -0
  29. autop_runner/executor/outcome.py +430 -0
  30. autop_runner/executor/phase_watchdog.py +435 -0
  31. autop_runner/executor/pre_review.py +685 -0
  32. autop_runner/executor/pre_review_outcome.py +271 -0
  33. autop_runner/executor/prepare.py +245 -0
  34. autop_runner/executor/prompts.py +292 -0
  35. autop_runner/executor/publication.py +630 -0
  36. autop_runner/executor/publication_push.py +413 -0
  37. autop_runner/executor/reap.py +435 -0
  38. autop_runner/executor/reap_inspect.py +208 -0
  39. autop_runner/executor/registry.py +60 -0
  40. autop_runner/executor/remote_proof.py +319 -0
  41. autop_runner/executor/retro.py +225 -0
  42. autop_runner/executor/retro_codex.py +397 -0
  43. autop_runner/executor/review.py +626 -0
  44. autop_runner/executor/review_completion.py +399 -0
  45. autop_runner/executor/review_coverage.py +430 -0
  46. autop_runner/executor/review_diff.py +624 -0
  47. autop_runner/executor/review_preflight.py +303 -0
  48. autop_runner/executor/review_runtime.py +218 -0
  49. autop_runner/executor/review_task.py +110 -0
  50. autop_runner/executor/review_worktree.py +642 -0
  51. autop_runner/executor/routing.py +430 -0
  52. autop_runner/executor/rubric.py +425 -0
  53. autop_runner/executor/runtime.py +725 -0
  54. autop_runner/executor/runtime_prep.py +309 -0
  55. autop_runner/executor/settings.py +151 -0
  56. autop_runner/executor/spec_inputs.py +172 -0
  57. autop_runner/executor/spec_staging.py +123 -0
  58. autop_runner/executor/suggestion.py +209 -0
  59. autop_runner/executor/telemetry.py +490 -0
  60. autop_runner/executor/transport.py +318 -0
  61. autop_runner/gitenv.py +48 -0
  62. autop_runner/hooks/pre_pr_review_guard.sh +848 -0
  63. autop_runner/hooks/publish_guard.sh +108 -0
  64. autop_runner/issues.py +489 -0
  65. autop_runner/protocol.py +63 -0
  66. autop_runner/redact.py +66 -0
  67. autop_runner/setup/__init__.py +12 -0
  68. autop_runner/setup/agentenv.py +106 -0
  69. autop_runner/setup/container.py +191 -0
  70. autop_runner/setup/layout.py +125 -0
  71. autop_runner/setup/preflight.py +135 -0
  72. autop_runner/setup/prompts.py +94 -0
  73. autop_runner/setup/service.py +108 -0
  74. autop_runner/setup/templates/autop-runner.service.tmpl +40 -0
  75. autop_runner/setup/templates/compose.yml.tmpl +43 -0
  76. autop_runner/setup/templates/dev.autop.runner.plist.tmpl +74 -0
  77. autop_runner/setup/wizard.py +668 -0
  78. autop_runner/skills.py +133 -0
  79. autop_runner/tools/__init__.py +0 -0
  80. autop_runner/tools/dependency_guard.py +142 -0
  81. autop_runner/tools/tool_output.py +235 -0
  82. autop_runner/tools/verification_guard.py +170 -0
  83. autop_runner/worker.py +440 -0
  84. autop_runner-0.1.0.dist-info/METADATA +35 -0
  85. autop_runner-0.1.0.dist-info/RECORD +89 -0
  86. autop_runner-0.1.0.dist-info/WHEEL +5 -0
  87. autop_runner-0.1.0.dist-info/entry_points.txt +2 -0
  88. autop_runner-0.1.0.dist-info/licenses/LICENSE +22 -0
  89. autop_runner-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,19 @@
1
+ """Autop runner — the customer-side executor (installs the `autop` command).
2
+
3
+ It dials out to the control plane, claims leased jobs, and runs every
4
+ LLM-backed Autopilot flow locally with the customer's own clones, worktrees,
5
+ and coding-agent CLI. It never holds a GitHub App key and never reads the
6
+ provider credential the CLI uses.
7
+
8
+ Modules (see `~/Projects/ATC/specs/001-atc-platform/plan.md`):
9
+
10
+ - `cli` — `autop runner enroll | run | status | drain`
11
+ - `config` — local, non-secret runner config (schema v1)
12
+ - `protocol` — protocol version range and message constants
13
+ - `client` — register/claim/heartbeat/events/complete/fail/abandon (phase 2)
14
+ - `executor/` — extracted from the Autopilot core's `dispatcher.py` (phase 3)
15
+ - `credentials/` — git credential helper + gh wrapper fetching per-job tokens (phase 3)
16
+ - `hooks/` — Claude Code hooks ported from the core (phase 3)
17
+ """
18
+
19
+ __version__ = "0.1.0"
autop_runner/cli.py ADDED
@@ -0,0 +1,429 @@
1
+ """`autop` command line.
2
+
3
+ The customer-facing entrypoint. Runner operations live under `autop runner`;
4
+ authoring helpers are sibling groups: `autop issue` files
5
+ backlog work as the person's own `gh` login (`issues.py`), `autop skills`
6
+ installs the public Agent Skills (`skills.py`).
7
+
8
+ * `setup` — the interactive install: preflight, questions, files, enrollment,
9
+ start (D67; the one-liner's `install.sh` ends in this command).
10
+ * `status` — local readiness, no network.
11
+ * `enroll` — exchange the console's one-time token for the runner credential
12
+ (saved 0600, never printed).
13
+ * `run` — register and claim/execute until stopped; SIGTERM/SIGINT drain
14
+ (finish current work, claim nothing new, exit).
15
+ * `drain` — send that SIGTERM to the running `autop runner run`.
16
+
17
+ Execution mode (FR-026): `run` requires `AUTOP_EXECUTION`. The container
18
+ image sets `container` (the default install, deploy/Dockerfile). Running the
19
+ agents directly on the host needs the explicit `direct-host` opt-in and prints
20
+ why that is discouraged: the agent CLIs run with permission prompts skipped
21
+ (the core's `--dangerously-skip-permissions`, guarded by hooks and PATH
22
+ shims), and provider terms expect such runs to be confined.
23
+
24
+ Exit codes: 0 ok, 1 local configuration error, 4 credential rejected (re-enroll),
25
+ 5 protocol incompatible (upgrade), 2 usage.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import getpass
32
+ import logging
33
+ import os
34
+ import pathlib
35
+ import platform
36
+ import signal
37
+ import sys
38
+
39
+ from autop_runner import __version__, credstore, issues, skills
40
+ from autop_runner.client import Incompatible, PlaneClient, PlaneError, Unauthorized
41
+ from autop_runner.config import ConfigError, RunnerConfig
42
+ from autop_runner.protocol import PROTOCOL_MAX, PROTOCOL_MIN
43
+ from autop_runner.setup.wizard import Answers, run_setup
44
+ from autop_runner.worker import EXIT_INCOMPATIBLE, EXIT_UNAUTHORIZED, Worker
45
+
46
+ DEFAULT_CONFIG = pathlib.Path("~/.autop/runner.json").expanduser()
47
+ PID_FILE = pathlib.Path("~/.autop/runner.pid").expanduser()
48
+ ENV_ENROLL_TOKEN = "AUTOP_ENROLL_TOKEN"
49
+
50
+ # Customer-facing environment (secrets live only here, never in the config file).
51
+ ENV_URL = "AUTOP_URL"
52
+ ENV_RUNNER_ID = "AUTOP_RUNNER_ID"
53
+ ENV_RUNNER_TOKEN = "AUTOP_RUNNER_TOKEN"
54
+ ENV_EXECUTION = "AUTOP_EXECUTION"
55
+ EXECUTION_MODES = ("container", "direct-host")
56
+
57
+ DIRECT_HOST_WARNING = (
58
+ "WARNING: direct-host execution. Agent CLIs run on this machine with "
59
+ "permission prompts skipped, confined only by Autop's hooks and command "
60
+ "guards, with access to everything your user can read. Provider terms "
61
+ "expect permission-skipped runs to be sandboxed; the container install "
62
+ "(deploy/compose.yml) is the supported default."
63
+ )
64
+ UNSET_EXECUTION = (
65
+ f"run: {ENV_EXECUTION} is not set. Autop runs agents in a container by "
66
+ "default (see deploy/INSTALL.md in the runner package). To run agents "
67
+ f"directly on this host anyway, set {ENV_EXECUTION}=direct-host."
68
+ )
69
+
70
+
71
+ def _parser() -> argparse.ArgumentParser:
72
+ p = argparse.ArgumentParser(prog="autop", description="Autop command line.")
73
+ p.add_argument("--version", action="version", version=f"autop {__version__}")
74
+ groups = p.add_subparsers(dest="group", required=True)
75
+
76
+ runner = groups.add_parser("runner", help="operate this machine's Autop runner")
77
+ runner.add_argument(
78
+ "--config",
79
+ type=pathlib.Path,
80
+ default=DEFAULT_CONFIG,
81
+ help=f"runner config file (default {DEFAULT_CONFIG})",
82
+ )
83
+ sub = runner.add_subparsers(dest="command", required=True)
84
+ for name, help_ in (
85
+ ("status", "print local readiness without contacting Autop"),
86
+ ("enroll", "exchange a one-time enrollment token for a credential"),
87
+ ("run", "claim and execute jobs until stopped"),
88
+ ("drain", "stop claiming, finish current work, exit"),
89
+ ):
90
+ sp = sub.add_parser(name, help=help_)
91
+ # Accept --config after the subcommand too; people type it both ways.
92
+ sp.add_argument(
93
+ "--config", type=pathlib.Path, dest="config_after", default=None
94
+ )
95
+ sp.add_argument(
96
+ "--credential-file",
97
+ type=pathlib.Path,
98
+ default=credstore.DEFAULT_PATH,
99
+ help=f"runner credential (default {credstore.DEFAULT_PATH})",
100
+ )
101
+ if name == "enroll":
102
+ sp.add_argument("--url", help=f"Autop API URL (or {ENV_URL})")
103
+ sp.add_argument(
104
+ "--token-stdin",
105
+ action="store_true",
106
+ help=f"read the enrollment token from stdin (else {ENV_ENROLL_TOKEN} "
107
+ "or a hidden prompt)",
108
+ )
109
+ if name == "run":
110
+ sp.add_argument(
111
+ "--once", action="store_true", help="run a single job, then exit"
112
+ )
113
+ # `setup` stays out of that loop: it derives the config and credential paths
114
+ # from the layout it installs (container and direct host differ, D64), so
115
+ # --config / --credential-file would be two ways to say the same thing.
116
+ st = sub.add_parser(
117
+ "setup", help="install and configure this machine's runner interactively"
118
+ )
119
+ st.add_argument("--url", help=f"Autop API URL (or {ENV_URL})")
120
+ st.add_argument(
121
+ "--execution",
122
+ choices=EXECUTION_MODES,
123
+ default="container",
124
+ help="where agents run (default container)",
125
+ )
126
+ st.add_argument(
127
+ "--repos-dir", type=pathlib.Path, help="directory holding your clones"
128
+ )
129
+ st.add_argument("--image", help="runner image to pin (default the published one)")
130
+ st.add_argument(
131
+ "--build", action="store_true", help="build the image from this checkout"
132
+ )
133
+ st.add_argument(
134
+ "--upgrade", action="store_true", help="re-pin the image and restart"
135
+ )
136
+ st.add_argument(
137
+ "--yes", action="store_true", help="no questions: flags and environment only"
138
+ )
139
+ _issue_parser(groups)
140
+ _skills_parser(groups)
141
+ return p
142
+
143
+
144
+ def _issue_parser(groups) -> None:
145
+ issue = groups.add_parser(
146
+ "issue", help="file backlog work for Autop with your own gh login"
147
+ )
148
+ sub = issue.add_subparsers(dest="issue_command", required=True)
149
+ add = sub.add_parser(
150
+ "add", help="create an issue, label it, put it on the board as Todo, link it"
151
+ )
152
+ add.add_argument("--board", required=True, help="<org>/<number> or the board URL")
153
+ add.add_argument("--repo", required=True, help="owner/repo to file in")
154
+ add.add_argument("--title", required=True)
155
+ add.add_argument(
156
+ "--body-file", required=True, help="Markdown body ('-' reads stdin)"
157
+ )
158
+ add.add_argument("--priority", required=True, choices=issues.PRIORITIES)
159
+ add.add_argument(
160
+ "--spec", help="owner/<project>-autopilot@<sha>:specs/…/spec.md#user-story-N"
161
+ )
162
+ add.add_argument("--epic", action="store_true", help="label it an epic")
163
+ add.add_argument(
164
+ "--human", action="store_true", help="human-task: Autop never picks it"
165
+ )
166
+ add.add_argument("--parent", help="epic to attach to as a native sub-issue")
167
+ add.add_argument(
168
+ "--blocked-by",
169
+ action="append",
170
+ metavar="REF",
171
+ help="blocking issue (repeatable): native link plus a Blocked-by: line",
172
+ )
173
+ link = sub.add_parser("link", help="add sub-issue/blocked-by links to an issue")
174
+ link.add_argument("issue", help="owner/repo#N or URL")
175
+ link.add_argument("--parent")
176
+ link.add_argument("--blocked-by", action="append", metavar="REF")
177
+ labels = sub.add_parser("labels", help="create or refresh Autop's labels")
178
+ labels.add_argument("repos", nargs="+", metavar="owner/repo")
179
+ subs = sub.add_parser("subs", help="list an epic's native sub-issues")
180
+ subs.add_argument("epic", help="owner/repo#N or URL")
181
+
182
+
183
+ def _skills_parser(groups) -> None:
184
+ sk = groups.add_parser("skills", help="install public Autop Agent Skills")
185
+ sub = sk.add_subparsers(dest="skills_command", required=True)
186
+ listing = sub.add_parser("list", help="list installed managed skills")
187
+ listing.add_argument("--target", choices=("all", *skills.TARGETS), default="all")
188
+ listing.add_argument(
189
+ "--dest", type=pathlib.Path, help="list this directory instead"
190
+ )
191
+ inst = sub.add_parser(
192
+ "install", help="copy them into ~/.claude/skills and ~/.agents/skills"
193
+ )
194
+ inst.add_argument("--ref", help="pin a tag or commit instead of following main")
195
+ inst.add_argument("--target", choices=("all", *skills.TARGETS), default="all")
196
+ inst.add_argument(
197
+ "--dest", type=pathlib.Path, help="install into this directory instead"
198
+ )
199
+
200
+
201
+ def cmd_status(config_path: pathlib.Path) -> int:
202
+ print(f"autop {__version__}")
203
+ print(f"protocol {PROTOCOL_MIN}..{PROTOCOL_MAX}")
204
+ print(f"os {platform.system().lower()} {platform.machine()}")
205
+ print(f"{ENV_URL} {'set' if os.environ.get(ENV_URL) else 'unset'}")
206
+ print(f"execution {execution_mode() or f'unset (set {ENV_EXECUTION})'}")
207
+ print(
208
+ f"{ENV_RUNNER_TOKEN} {'set' if os.environ.get(ENV_RUNNER_TOKEN) else 'unset'}"
209
+ )
210
+ cred_path = credstore.DEFAULT_PATH
211
+ print(f"credential {cred_path}: {'present' if cred_path.exists() else 'absent'}")
212
+ try:
213
+ cfg = RunnerConfig.load(config_path)
214
+ except ConfigError as exc:
215
+ print(f"config {config_path}: {exc}")
216
+ return 1
217
+ ready_repos = cfg.ready_repos()
218
+ ready = {r.full_name for r in ready_repos}
219
+ print(
220
+ f"config {config_path}: ok (name={cfg.name}, max_jobs={cfg.max_concurrent_jobs})"
221
+ )
222
+ explicit = {r.full_name for r in cfg.repos}
223
+ for r in cfg.repos:
224
+ # Same test as registration (`ready_repos`): a checkout, not a directory.
225
+ if r.full_name in ready:
226
+ state = "ready"
227
+ elif r.workspace.is_dir():
228
+ state = "not a clone (no .git)"
229
+ else:
230
+ state = "workspace missing"
231
+ print(f" repo {r.full_name}: {state}")
232
+ for r in ready_repos:
233
+ if r.full_name not in explicit:
234
+ print(f" repo {r.full_name}: ready (discovered)")
235
+ if cfg.repos_dir is not None:
236
+ print(f" repos_dir {cfg.repos_dir}")
237
+ print(f" runtimes {', '.join(cfg.runtimes) or '(none)'}")
238
+ return 0
239
+
240
+
241
+ def _client(url: str, token: str) -> PlaneClient:
242
+ """Seam for tests (they substitute an in-process fake plane)."""
243
+ return PlaneClient(url, token)
244
+
245
+
246
+ def report(cfg: RunnerConfig) -> dict:
247
+ """What this machine offers: only ready repos, only configured runtimes.
248
+ The plane intersects it with the tenant's policy (it can only narrow)."""
249
+ return {
250
+ "name": cfg.name,
251
+ "os": platform.system().lower(),
252
+ "arch": platform.machine(),
253
+ "labels": list(cfg.labels),
254
+ "max_concurrent_jobs": cfg.max_concurrent_jobs,
255
+ "repos_ready": sorted(r.full_name for r in cfg.ready_repos()),
256
+ "runtimes_ready": list(cfg.runtimes),
257
+ }
258
+
259
+
260
+ def cmd_enroll(args, config_path: pathlib.Path) -> int:
261
+ url = args.url or os.environ.get(ENV_URL)
262
+ if not url:
263
+ print(f"enroll: pass --url or set {ENV_URL}")
264
+ return 1
265
+ try:
266
+ cfg = RunnerConfig.load(config_path)
267
+ except ConfigError as exc:
268
+ print(f"config {config_path}: {exc}")
269
+ return 1
270
+ if args.token_stdin:
271
+ token = sys.stdin.readline().strip()
272
+ else:
273
+ token = os.environ.get(ENV_ENROLL_TOKEN) or getpass.getpass(
274
+ "Enrollment token: "
275
+ )
276
+ if not token.startswith("autop_enroll_"):
277
+ print("enroll: that is not an Autop enrollment token (autop_enroll_…)")
278
+ return 1
279
+ try:
280
+ client = _client(url, token)
281
+ reg = client.register(report(cfg))
282
+ except ValueError as exc:
283
+ print(f"enroll: {exc}")
284
+ return 1
285
+ except Unauthorized:
286
+ print(
287
+ "enroll: the token was rejected (used, expired, or revoked); "
288
+ "create a new one in the console"
289
+ )
290
+ return EXIT_UNAUTHORIZED
291
+ except Incompatible as exc:
292
+ print(f"enroll: {exc}")
293
+ return EXIT_INCOMPATIBLE
294
+ except PlaneError as exc:
295
+ print(f"enroll: {exc}")
296
+ return 1
297
+ if not reg.get("enrolled"):
298
+ print("enroll: Autop did not issue a credential")
299
+ return 1
300
+ credstore.save(
301
+ args.credential_file,
302
+ credstore.Credential(
303
+ url=client.url, runner_id=reg["runner_id"], token=client.token
304
+ ),
305
+ )
306
+ client.close()
307
+ caps = reg.get("capabilities") or {}
308
+ print(
309
+ f"enrolled runner {reg['runner_id']}; credential saved to "
310
+ f"{args.credential_file}"
311
+ )
312
+ print(f" repos {', '.join(caps.get('repos') or []) or '(none)'}")
313
+ print(f" runtimes {', '.join(caps.get('runtimes') or []) or '(none)'}")
314
+ ignored = reg.get("ignored") or {}
315
+ if ignored.get("repos") or ignored.get("runtimes"):
316
+ print(f" not permitted by your Autop policy: {ignored}")
317
+ return 0
318
+
319
+
320
+ def execution_mode(env=None) -> str | None:
321
+ """`container` or `direct-host` when declared, else None (refuse)."""
322
+ mode = (os.environ if env is None else env).get(ENV_EXECUTION, "").strip()
323
+ return mode if mode in EXECUTION_MODES else None
324
+
325
+
326
+ def cmd_run(args, config_path: pathlib.Path) -> int:
327
+ logging.basicConfig(
328
+ level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s"
329
+ )
330
+ mode = execution_mode()
331
+ if mode is None:
332
+ print(UNSET_EXECUTION)
333
+ return 1
334
+ if mode == "direct-host":
335
+ print(DIRECT_HOST_WARNING, file=sys.stderr)
336
+ logging.getLogger("autop.runner").warning("execution mode: direct-host")
337
+ try:
338
+ cfg = RunnerConfig.load(config_path)
339
+ cred = credstore.load(args.credential_file)
340
+ client = _client(cred.url, cred.token)
341
+ except (ConfigError, credstore.CredentialError, ValueError) as exc:
342
+ print(f"run: {exc}")
343
+ return 1
344
+ from autop_runner.executor.registry import build_executors
345
+ from autop_runner.executor.settings import RuntimeSettings
346
+
347
+ executors = build_executors(cfg, RuntimeSettings.from_env())
348
+ worker = Worker(client, report(cfg), executors=executors)
349
+
350
+ def drain(signum, _frame):
351
+ logging.getLogger("autop.runner").info(
352
+ "signal %s: draining (finishing current work)", signum
353
+ )
354
+ worker.stop.set()
355
+
356
+ signal.signal(signal.SIGTERM, drain)
357
+ signal.signal(signal.SIGINT, drain)
358
+ pid_file = args.credential_file.parent / PID_FILE.name
359
+ pid_file.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
360
+ pid_file.write_text(str(os.getpid()))
361
+ try:
362
+ return worker.run(max_jobs=1 if args.once else None)
363
+ finally:
364
+ client.close()
365
+ if pid_file.exists() and pid_file.read_text() == str(os.getpid()):
366
+ pid_file.unlink()
367
+
368
+
369
+ def cmd_drain(args) -> int:
370
+ pid_file = args.credential_file.parent / PID_FILE.name
371
+ try:
372
+ pid = int(pid_file.read_text())
373
+ except (FileNotFoundError, ValueError):
374
+ print("drain: no running runner found")
375
+ return 1
376
+ try:
377
+ os.kill(pid, signal.SIGTERM)
378
+ except ProcessLookupError:
379
+ print("drain: the recorded runner is not running")
380
+ pid_file.unlink(missing_ok=True)
381
+ return 1
382
+ print(f"drain: asked runner (pid {pid}) to finish current work and exit")
383
+ return 0
384
+
385
+
386
+ def cmd_setup(args) -> int:
387
+ url = args.url or os.environ.get(ENV_URL)
388
+ if not url:
389
+ print(f"setup: pass --url or set {ENV_URL}")
390
+ return 1
391
+ return run_setup(
392
+ Answers(
393
+ url=url,
394
+ mode=args.execution,
395
+ # The token may come from the environment (`install.sh` exports
396
+ # nothing; an unattended install may). It is never echoed.
397
+ token=os.environ.get(ENV_ENROLL_TOKEN),
398
+ repos_dir=args.repos_dir,
399
+ image=args.image,
400
+ build=args.build,
401
+ upgrade=args.upgrade,
402
+ assume_yes=args.yes,
403
+ )
404
+ )
405
+
406
+
407
+ def main(argv: list[str] | None = None) -> int:
408
+ args = _parser().parse_args(argv)
409
+ if args.group == "issue":
410
+ return issues.main(args)
411
+ if args.group == "skills":
412
+ return skills.main(args)
413
+ if args.group == "runner":
414
+ if args.command == "setup":
415
+ return cmd_setup(args)
416
+ config_path = args.config_after or args.config
417
+ if args.command == "status":
418
+ return cmd_status(config_path)
419
+ if args.command == "enroll":
420
+ return cmd_enroll(args, config_path)
421
+ if args.command == "run":
422
+ return cmd_run(args, config_path)
423
+ if args.command == "drain":
424
+ return cmd_drain(args)
425
+ return 2
426
+
427
+
428
+ if __name__ == "__main__":
429
+ sys.exit(main())
autop_runner/client.py ADDED
@@ -0,0 +1,240 @@
1
+ """Protocol client for `/api/runner/v1` (the plane's side is `atc/api/runner_v1.py`).
2
+
3
+ Outbound HTTPS only (D2): the runner dials the plane; nothing dials the runner.
4
+ Every failure maps to a typed exception so the worker can decide without
5
+ parsing strings:
6
+
7
+ * `Unauthorized` — credential unknown/revoked: stop; re-enroll.
8
+ * `Incompatible` — disjoint protocol ranges (426): drain and exit.
9
+ * `StaleLease(reason)` — this attempt no longer owns its job: stop the work,
10
+ send nothing further for it.
11
+ * `SequenceGap(expected)` — resend events from `expected`.
12
+ * `Refused(reason)` — the plane's 422: a tenancy rule refused the request
13
+ (for a publishing token: a paused project, unverified org ownership, a
14
+ repo outside the project, an uncovered repo). `reason` is the plane's
15
+ tenant-facing sentence, non-secret by construction (`atc.db.Rejected`).
16
+ * `PlaneUnavailable` — network error or 5xx: retry with backoff. Every call is
17
+ safe to retry: claims carry a request id the plane replays, terminal reports
18
+ replay to the same answer, events are idempotent by sequence.
19
+
20
+ The credential is sent only in the Authorization header and never appears in
21
+ an exception message or `repr`.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import urllib.parse
27
+ from typing import Any
28
+
29
+ import httpx
30
+
31
+ from autop_runner import __version__
32
+ from autop_runner.protocol import PROTOCOL_MAX, PROTOCOL_MIN
33
+
34
+ BUILD_TAG = f"autop-runner/{__version__}"
35
+ _LOCAL_HOSTS = {"127.0.0.1", "localhost", "::1"}
36
+
37
+
38
+ class PlaneError(RuntimeError):
39
+ pass
40
+
41
+
42
+ class Unauthorized(PlaneError):
43
+ pass
44
+
45
+
46
+ class Incompatible(PlaneError):
47
+ def __init__(self, plane_min: int, plane_max: int):
48
+ super().__init__(
49
+ f"protocol {PROTOCOL_MIN}..{PROTOCOL_MAX} is incompatible with the "
50
+ f"plane's {plane_min}..{plane_max}; upgrade autop-runner"
51
+ )
52
+ self.plane_min, self.plane_max = plane_min, plane_max
53
+
54
+
55
+ class StaleLease(PlaneError):
56
+ def __init__(self, reason: str):
57
+ super().__init__(f"lease is {reason}")
58
+ self.reason = reason
59
+
60
+
61
+ class SequenceGap(PlaneError):
62
+ def __init__(self, expected: int):
63
+ super().__init__(f"plane expects event sequence {expected}")
64
+ self.expected = expected
65
+
66
+
67
+ class PlaneUnavailable(PlaneError):
68
+ pass
69
+
70
+
71
+ class Refused(PlaneError):
72
+ def __init__(self, reason: str):
73
+ super().__init__(f"Autop refused: {reason}")
74
+ self.reason = reason
75
+
76
+
77
+ def _refusal_reason(response: httpx.Response) -> str:
78
+ """The 422 body's `detail` (a `Rejected` message), bounded; never raw
79
+ bytes the plane did not mean to show."""
80
+ try:
81
+ detail = response.json().get("detail")
82
+ except (ValueError, AttributeError):
83
+ detail = None
84
+ return " ".join(str(detail).split())[:200] if isinstance(detail, str) else ""
85
+
86
+
87
+ def check_url(url: str) -> str:
88
+ """HTTPS required (FR-008), except loopback for local development."""
89
+ parsed = urllib.parse.urlparse(url)
90
+ if parsed.scheme == "https" and parsed.hostname:
91
+ return url.rstrip("/")
92
+ if parsed.scheme == "http" and parsed.hostname in _LOCAL_HOSTS:
93
+ return url.rstrip("/")
94
+ raise ValueError("AUTOP_URL must be https:// (http:// only for localhost)")
95
+
96
+
97
+ class PlaneClient:
98
+ def __init__(
99
+ self,
100
+ url: str,
101
+ token: str,
102
+ *,
103
+ transport: httpx.BaseTransport | None = None,
104
+ timeout: float = 20,
105
+ ):
106
+ self.url = check_url(url)
107
+ self._token = token
108
+ self._http = httpx.Client(
109
+ transport=transport,
110
+ timeout=httpx.Timeout(timeout, read=timeout + 70), # long-poll headroom
111
+ headers={"User-Agent": BUILD_TAG},
112
+ follow_redirects=False,
113
+ )
114
+
115
+ def __repr__(self) -> str:
116
+ return f"PlaneClient(url={self.url!r})"
117
+
118
+ @property
119
+ def token(self) -> str:
120
+ return self._token
121
+
122
+ def close(self) -> None:
123
+ self._http.close()
124
+
125
+ def _post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
126
+ try:
127
+ r = self._http.post(
128
+ f"{self.url}/api/runner/v1{path}",
129
+ json=body,
130
+ headers={"Authorization": f"Bearer {self._token}"},
131
+ )
132
+ except httpx.HTTPError as exc:
133
+ raise PlaneUnavailable(f"cannot reach Autop: {type(exc).__name__}") from exc
134
+ if r.status_code == 200:
135
+ return r.json()
136
+ if r.status_code == 401:
137
+ raise Unauthorized("the runner credential was rejected; re-enroll")
138
+ if r.status_code == 426:
139
+ data = r.json()
140
+ raise Incompatible(int(data["plane_min"]), int(data["plane_max"]))
141
+ if r.status_code == 409:
142
+ data = r.json()
143
+ if data.get("detail") == "stale_lease":
144
+ raise StaleLease(str(data.get("reason")))
145
+ if data.get("detail") == "sequence_gap":
146
+ raise SequenceGap(int(data["expected_sequence"]))
147
+ if r.status_code == 422:
148
+ reason = _refusal_reason(r)
149
+ if reason:
150
+ raise Refused(reason)
151
+ if r.status_code >= 500 or r.status_code == 429:
152
+ raise PlaneUnavailable(f"Autop answered {r.status_code} for {path}")
153
+ raise PlaneError(f"Autop answered {r.status_code} for {path}")
154
+
155
+ def _get_json(self, path: str) -> dict[str, Any] | None:
156
+ """GET for the installer's reads. `None` means the plane does not have
157
+ the route (a plane older than protocol 2), which is a fallback, not an
158
+ error."""
159
+ try:
160
+ r = self._http.get(
161
+ f"{self.url}/api/runner/v1{path}",
162
+ headers={"Authorization": f"Bearer {self._token}"},
163
+ )
164
+ except httpx.HTTPError as exc:
165
+ raise PlaneUnavailable(f"cannot reach Autop: {type(exc).__name__}") from exc
166
+ if r.status_code == 200:
167
+ return r.json()
168
+ if r.status_code == 404:
169
+ return None
170
+ if r.status_code == 401:
171
+ raise Unauthorized("the token was rejected (used, expired or revoked)")
172
+ if r.status_code >= 500 or r.status_code == 429:
173
+ raise PlaneUnavailable(f"Autop answered {r.status_code} for {path}")
174
+ raise PlaneError(f"Autop answered {r.status_code} for {path}")
175
+
176
+ # ── calls ────────────────────────────────────────────────────────────────
177
+
178
+ def enrollment(self) -> dict[str, Any] | None:
179
+ """The runner's configured policy behind an enrollment token, without
180
+ consuming it (D70). `None` when the plane predates protocol 2."""
181
+ return self._get_json("/enrollment")
182
+
183
+ def register(self, report: dict[str, Any]) -> dict[str, Any]:
184
+ """Register (or refresh). With an enrollment token, the response carries
185
+ the new credential once; the client switches to it immediately."""
186
+ body = {
187
+ "protocol_min": PROTOCOL_MIN,
188
+ "protocol_max": PROTOCOL_MAX,
189
+ "build_tag": BUILD_TAG,
190
+ **report,
191
+ }
192
+ data = self._post("/register", body)
193
+ credential = data.pop("credential", None)
194
+ if credential:
195
+ self._token = credential
196
+ data["enrolled"] = True
197
+ return data
198
+
199
+ def claim(self, *, wait_seconds: int, request_id: str) -> dict[str, Any]:
200
+ return self._post(
201
+ "/claim",
202
+ {
203
+ "protocol_min": PROTOCOL_MIN,
204
+ "protocol_max": PROTOCOL_MAX,
205
+ "wait_seconds": wait_seconds,
206
+ "request_id": request_id,
207
+ "build_tag": BUILD_TAG,
208
+ },
209
+ )
210
+
211
+ def _lease(self, job: dict[str, Any], path: str, **extra: Any) -> dict[str, Any]:
212
+ body = {"attempt_id": job["attempt_id"], "epoch": job["epoch"], **extra}
213
+ return self._post(f"/jobs/{job['job_id']}/{path}", body)
214
+
215
+ def heartbeat(self, job: dict[str, Any]) -> dict[str, Any]:
216
+ return self._lease(job, "heartbeat")
217
+
218
+ def events(self, job: dict[str, Any], events: list[dict[str, Any]]) -> int:
219
+ return int(self._lease(job, "events", events=events)["next_sequence"])
220
+
221
+ def publishing_token(self, job: dict[str, Any]) -> dict[str, Any]:
222
+ """A fresh Coder token for the job's repo under the current epoch (D-A)."""
223
+ return self._lease(job, "token")
224
+
225
+ def finish(
226
+ self,
227
+ job: dict[str, Any],
228
+ outcome: str,
229
+ detail: dict[str, Any],
230
+ *,
231
+ retryable: bool = False,
232
+ effect_free: bool = False,
233
+ ) -> str:
234
+ path = {"succeeded": "complete", "failed": "fail", "abandoned": "abandon"}[
235
+ outcome
236
+ ]
237
+ data = self._lease(
238
+ job, path, outcome=detail, retryable=retryable, effect_free=effect_free
239
+ )
240
+ return str(data["job_state"])