outerloop-science 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 (59) hide show
  1. outerloop/__init__.py +18 -0
  2. outerloop/__main__.py +3 -0
  3. outerloop/appauth.py +230 -0
  4. outerloop/appmanifest.py +203 -0
  5. outerloop/attempt.py +3784 -0
  6. outerloop/brief.py +528 -0
  7. outerloop/cli.py +621 -0
  8. outerloop/climbboard.py +1395 -0
  9. outerloop/compute.py +654 -0
  10. outerloop/contract.py +492 -0
  11. outerloop/contract_cli.py +63 -0
  12. outerloop/disk.py +164 -0
  13. outerloop/dispatch.py +631 -0
  14. outerloop/evalcache.py +147 -0
  15. outerloop/followup.py +2172 -0
  16. outerloop/github.py +1531 -0
  17. outerloop/harness.py +1435 -0
  18. outerloop/housekeeping.py +151 -0
  19. outerloop/image.py +368 -0
  20. outerloop/init.py +744 -0
  21. outerloop/intake.py +126 -0
  22. outerloop/launchlog.py +239 -0
  23. outerloop/limits.py +80 -0
  24. outerloop/maintain.py +353 -0
  25. outerloop/maintain_agent_cli.py +81 -0
  26. outerloop/maintain_post_cli.py +140 -0
  27. outerloop/markers.py +48 -0
  28. outerloop/measure.py +529 -0
  29. outerloop/orchestrator.py +2011 -0
  30. outerloop/panel.py +188 -0
  31. outerloop/paths.py +40 -0
  32. outerloop/posting.py +160 -0
  33. outerloop/progress.py +170 -0
  34. outerloop/py.typed +0 -0
  35. outerloop/review.py +615 -0
  36. outerloop/review_agent.py +263 -0
  37. outerloop/review_agent_cli.py +209 -0
  38. outerloop/review_post_cli.py +162 -0
  39. outerloop/review_summarize_cli.py +165 -0
  40. outerloop/role_runner.py +229 -0
  41. outerloop/roles.py +274 -0
  42. outerloop/rolespec.py +91 -0
  43. outerloop/runstate.py +385 -0
  44. outerloop/steward.py +845 -0
  45. outerloop/style.py +12 -0
  46. outerloop/syscall.py +1192 -0
  47. outerloop/syscall_cli.py +762 -0
  48. outerloop/tick.py +3422 -0
  49. outerloop/verifier.py +403 -0
  50. outerloop/verify_agent.py +151 -0
  51. outerloop/verify_agent_cli.py +95 -0
  52. outerloop/verify_post_cli.py +116 -0
  53. outerloop/watcher.py +203 -0
  54. outerloop_science-0.1.0.dist-info/METADATA +152 -0
  55. outerloop_science-0.1.0.dist-info/RECORD +59 -0
  56. outerloop_science-0.1.0.dist-info/WHEEL +4 -0
  57. outerloop_science-0.1.0.dist-info/entry_points.txt +2 -0
  58. outerloop_science-0.1.0.dist-info/licenses/LICENSE +202 -0
  59. outerloop_science-0.1.0.dist-info/licenses/NOTICE +5 -0
outerloop/init.py ADDED
@@ -0,0 +1,744 @@
1
+ """`outerloop init` — the guided setup.
2
+
3
+ Collects placement (Slurm or local), the target repo, bot auth, and the
4
+ author's model key, then writes `~/.config/outerloop/.env` (plus the credential
5
+ files), so a new adopter never hand-edits config or reasons about which
6
+ `OUTERLOOP_*` keys to set. Flags fill
7
+ answers non-interactively; anything left out is prompted for (a secret via
8
+ getpass, never echoed). Auth is the adopter's own GitHub App by default —
9
+ `--github-app`, one click via the manifest flow in `appmanifest.py` — with a PAT
10
+ as the fallback; either way `resolve_bot_auth` reads the result exactly as
11
+ `outerloop start` does. An existing `.env` is never overwritten without asking.
12
+
13
+ The config location is `cli.ENV_FILE`, the same file `start` reads — one source
14
+ of truth, so a rename of the config dir moves both together.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import getpass
21
+ import json
22
+ import logging
23
+ import os
24
+ import shutil
25
+ import sys
26
+ import time
27
+ import urllib.error
28
+ import urllib.request
29
+ from dataclasses import dataclass
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ from outerloop.cli import ENV_FILE
34
+ from outerloop.image import ensure_image
35
+ from outerloop.paths import write_private
36
+
37
+ log = logging.getLogger(__name__)
38
+
39
+ CONFIG_DIR = ENV_FILE.parent
40
+ DEFAULT_PAT_FILE = CONFIG_DIR / "bot_pat"
41
+ API = "https://api.github.com"
42
+ # The climbing author's harnesses (attempt.py's --author-backend choices).
43
+ AUTHOR_BACKENDS = ("claude", "codex")
44
+
45
+
46
+ @dataclass
47
+ class InitAnswers:
48
+ compute: str # "slurm" | "local"
49
+ target: str # "owner/repo"
50
+ root: str = "" # Slurm state root on the shared filesystem
51
+ account: str = "" # Slurm account (optional; unset -> the default association)
52
+ partition: str = "" # Slurm partition (optional; unset -> Slurm default)
53
+ author_backend: str = "" # optional: the climbing author's harness
54
+ author_model: str = "" # optional
55
+ author_key_file: str = "" # the author's model key file, when known
56
+ image: str = "" # the agent image (OUTERLOOP_IMAGE)
57
+ uncontained: bool = False # --no-image: write OUTERLOOP_IMAGE= so no image is picked up
58
+ author_bin: str = "" # the author harness binary (claude/codex), absolute, when found
59
+
60
+
61
+ def render_env(
62
+ a: InitAnswers, pat_file: str = "", *, app_file: str = "", bot_login: str = ""
63
+ ) -> str:
64
+ """The `.env` body for these answers — only the keys that have a value, so
65
+ the file stays minimal and every line means something. Ordered placement →
66
+ target → auth → author to read top-to-bottom like the setup itself. Auth is
67
+ an App file (`--github-app`) or a PAT file, never both."""
68
+ lines = [f"OUTERLOOP_COMPUTE={a.compute}"]
69
+ if a.compute == "slurm":
70
+ lines.append(f"OUTERLOOP_ROOT={a.root}")
71
+ if a.account: # optional: unset bills the caller's default association
72
+ lines.append(f"OUTERLOOP_ACCOUNT={a.account}")
73
+ if a.partition: # optional: unset lets Slurm pick its default partition
74
+ lines.append(f"OUTERLOOP_PARTITION={a.partition}")
75
+ # the checkout stays put until the operator upgrades; `release` follows
76
+ # the release tags, `main` every merge (docs/install.md)
77
+ lines.append("OUTERLOOP_AUTO_UPDATE=off")
78
+ if a.image:
79
+ lines.append(f"OUTERLOOP_IMAGE={a.image}")
80
+ elif a.uncontained:
81
+ # the empty value is the off-switch: without it the tick would still
82
+ # pick up an image already at the default path
83
+ lines.append("OUTERLOOP_IMAGE=")
84
+ lines.append(f"OUTERLOOP_TARGET={a.target}")
85
+ if app_file:
86
+ lines.append(f"OUTERLOOP_GITHUB_APP_FILE={app_file}")
87
+ elif pat_file:
88
+ lines.append(f"OUTERLOOP_PAT_FILE={pat_file}")
89
+ if bot_login:
90
+ # every own-comment filter and own-PR scan keys on this login; without
91
+ # it the kernel assumes a default that is not this adopter's identity
92
+ lines.append(f"OUTERLOOP_BOT_LOGIN={bot_login}")
93
+ if a.author_backend:
94
+ lines.append(f"OUTERLOOP_AUTHOR_BACKEND={a.author_backend}")
95
+ if a.author_model:
96
+ lines.append(f"OUTERLOOP_AUTHOR_MODEL={a.author_model}")
97
+ if a.author_key_file:
98
+ lines.append(f"{author_key_env(a.author_backend)}={a.author_key_file}")
99
+ if a.author_bin:
100
+ lines.append(f"{author_bin_env(a.author_backend)}={a.author_bin}")
101
+ return "\n".join(lines) + "\n"
102
+
103
+
104
+ def author_bin_env(backend: str) -> str:
105
+ """The `.env` key naming `backend`'s harness binary (`OUTERLOOP_CLAUDE_BIN`,
106
+ `OUTERLOOP_CODEX_BIN`), the same names `attempt` reads."""
107
+ return f"OUTERLOOP_{(backend or AUTHOR_BACKENDS[0]).upper()}_BIN"
108
+
109
+
110
+ HARNESS_INSTALL = {
111
+ "claude": "npm install -g @anthropic-ai/claude-code "
112
+ "(or: curl -fsSL https://claude.ai/install.sh | bash)",
113
+ "codex": "npm install -g @openai/codex",
114
+ }
115
+
116
+
117
+ def locate_harness(backend: str) -> str:
118
+ """The absolute path of `backend`'s CLI on this machine: PATH first, then
119
+ ~/.local/bin (where the native installers put it and where a Slurm job,
120
+ with no login PATH, would not find it); "" when absent. Recorded in .env so
121
+ every job spawns the same binary the operator installed."""
122
+ name = backend or AUTHOR_BACKENDS[0]
123
+ found = shutil.which(name)
124
+ if found:
125
+ return str(Path(found).resolve())
126
+ local = Path.home() / ".local" / "bin" / name
127
+ return str(local) if local.is_file() and os.access(local, os.X_OK) else ""
128
+
129
+
130
+ def _harness_hint(answers: InitAnswers) -> None:
131
+ if not answers.author_bin:
132
+ name = answers.author_backend or AUTHOR_BACKENDS[0]
133
+ print(
134
+ f" no `{name}` binary found on PATH or in ~/.local/bin — the first climb would end\n"
135
+ f" with spawn-error. Install it: {HARNESS_INSTALL.get(name, 'see its docs')}\n"
136
+ " then run `outerloop init --force` so its path is recorded."
137
+ )
138
+
139
+
140
+ def _token_login(token: str) -> str:
141
+ """The login a PAT acts as (GET /user), or "" when GitHub does not say."""
142
+ req = urllib.request.Request(
143
+ f"{API}/user",
144
+ headers={"Authorization": f"token {token}", "Accept": "application/vnd.github+json"},
145
+ )
146
+ try:
147
+ with urllib.request.urlopen(req, timeout=15) as resp:
148
+ return str(json.loads(resp.read()).get("login") or "")
149
+ except (urllib.error.URLError, OSError, ValueError):
150
+ return ""
151
+
152
+
153
+ def _auth_is_fatal(problem: str) -> bool:
154
+ """A credential the loop cannot open pull requests with fails setup; not
155
+ being able to ask GitHub right now (network, 5xx, rate limit) does not."""
156
+ return bool(problem) and not problem.startswith(
157
+ ("could not reach GitHub", "could not read the App", "could not check the App")
158
+ )
159
+
160
+
161
+ def author_key_env(backend: str) -> str:
162
+ """The `.env` key naming `backend`'s model-key file: one rule for every
163
+ backend (`OUTERLOOP_CLAUDE_KEY_FILE`, `OUTERLOOP_CODEX_KEY_FILE`)."""
164
+ return f"OUTERLOOP_{(backend or AUTHOR_BACKENDS[0]).upper()}_KEY_FILE"
165
+
166
+
167
+ def author_key_path(backend: str, *, config_dir: Path = CONFIG_DIR) -> Path:
168
+ """Where `backend`'s model key lives by default: `<config dir>/<backend>_key`,
169
+ the same rule `attempt.resolve_author_key_file` reads."""
170
+ return config_dir / f"{backend or AUTHOR_BACKENDS[0]}_key"
171
+
172
+
173
+ def write_author_key(backend: str, key: str, *, config_dir: Path = CONFIG_DIR) -> Path:
174
+ """Write a pasted model key to its default file, owner-only (0600), no
175
+ trailing newline. Returns the path."""
176
+ path = author_key_path(backend, config_dir=config_dir)
177
+ write_private(path, key)
178
+ return path
179
+
180
+
181
+ def write_config(
182
+ a: InitAnswers, token: str, pat_file: str, *, config_dir: Path = CONFIG_DIR
183
+ ) -> tuple[Path, Path | None]:
184
+ """Write the PAT file (only when a token is pasted) and the `.env`, both
185
+ owner-only (0600) — `start`/`tick_deploy` refuse a group/world-readable
186
+ `.env`, and a token file must never be wider. Returns (env_path, pat_path)."""
187
+ config_dir.mkdir(parents=True, exist_ok=True)
188
+ written_pat: Path | None = None
189
+ if token:
190
+ pat_path = config_dir / DEFAULT_PAT_FILE.name
191
+ # printf-style: no trailing newline — the deploy `cat`s this file into
192
+ # the git credential, and a trailing newline rides along and breaks auth.
193
+ write_private(pat_path, token)
194
+ written_pat = pat_path
195
+ pat_file = str(pat_path)
196
+ env_path = config_dir / ENV_FILE.name
197
+ write_private(env_path, render_env(a, pat_file))
198
+ return env_path, written_pat
199
+
200
+
201
+ def _check_repo_access(token: str, target: str) -> str:
202
+ """Can this token write the target repo? "" on success, else a short reason.
203
+ Never raises — a check failure is a warning, not a reason to abandon config."""
204
+ req = urllib.request.Request(
205
+ f"{API}/repos/{target}",
206
+ headers={"Authorization": f"token {token}", "Accept": "application/vnd.github+json"},
207
+ )
208
+ try:
209
+ with urllib.request.urlopen(req, timeout=15) as resp:
210
+ body = json.loads(resp.read())
211
+ perms = body.get("permissions") or {}
212
+ if not perms.get("push"):
213
+ return f"reaches {target} but lacks write access (it opens PRs)"
214
+ return ""
215
+ except urllib.error.HTTPError as exc:
216
+ if exc.code == 404:
217
+ return f"{target} not found, or the token cannot see it"
218
+ if exc.code == 401:
219
+ return "the token is invalid or expired (GitHub returned 401)"
220
+ if exc.code == 403:
221
+ return f"the token is not allowed to access {target} (GitHub returned 403)"
222
+ if exc.code == 429 or exc.code >= 500:
223
+ # GitHub's problem, not the credential's: never fatal
224
+ return f"could not reach GitHub: it returned {exc.code} for {target}"
225
+ return f"GitHub returned {exc.code} for {target}"
226
+ except urllib.error.URLError as exc:
227
+ return f"could not reach GitHub: {exc.reason}"
228
+
229
+
230
+ def _check_app_access(provider: Any, target: str) -> str:
231
+ """Can this App write the target repo? "" on success, else a short reason.
232
+
233
+ An installation token does not populate a repository's `permissions`
234
+ object, so the PAT check above reads all-false for an App that can push.
235
+ The App is asked the two questions that decide it: is the target among
236
+ the repositories its installation covers, and does the installation carry
237
+ write on contents and pull requests. Never raises."""
238
+ from outerloop.appauth import build_app_jwt
239
+
240
+ headers = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"}
241
+ try:
242
+ # Asked as the App itself: GitHub answers with the installation that
243
+ # covers this repository, permissions included, or 404. A public
244
+ # repository would answer a plain read for any token, and the
245
+ # installation's repository list paginates, so neither is asked.
246
+ jwt = build_app_jwt(provider.app_id, time.time(), provider._sign)
247
+ req = urllib.request.Request(
248
+ f"{API}/repos/{target}/installation",
249
+ headers={**headers, "Authorization": f"Bearer {jwt}"},
250
+ )
251
+ try:
252
+ with urllib.request.urlopen(req, timeout=15) as resp:
253
+ installation = json.loads(resp.read())
254
+ except urllib.error.HTTPError as exc:
255
+ if exc.code == 404:
256
+ return f"the App is not installed on {target}; install it there"
257
+ raise
258
+ if int(installation.get("id", 0)) != int(provider.installation_id):
259
+ return (
260
+ f"the App is installed on {target} under installation "
261
+ f"{installation.get('id')}, but the App file records {provider.installation_id}"
262
+ )
263
+ perms = installation.get("permissions") or {}
264
+ missing = [k for k in ("contents", "issues", "pull_requests") if perms.get(k) != "write"]
265
+ if missing:
266
+ return (
267
+ f"the App lacks write on {', '.join(missing)} "
268
+ "(it pushes branches, opens PRs, and files and closes issues)"
269
+ )
270
+ return ""
271
+ except urllib.error.HTTPError as exc:
272
+ if exc.code == 429 or exc.code >= 500:
273
+ return f"could not reach GitHub: it returned {exc.code} while checking the App"
274
+ return f"GitHub returned {exc.code} while checking the App"
275
+ except urllib.error.URLError as exc:
276
+ return f"could not reach GitHub: {exc.reason}"
277
+ except Exception as exc: # a check failure is a warning, never fails setup
278
+ return f"could not check the App: {exc}"
279
+
280
+
281
+ def validate_pat(pat_file: str, target: str) -> str:
282
+ """Best-effort: can the PAT in `pat_file` write the target repo?"""
283
+ try:
284
+ token = Path(pat_file).expanduser().read_text().strip()
285
+ except OSError as exc:
286
+ return f"could not read {pat_file}: {exc}"
287
+ if not token:
288
+ return f"{pat_file} is empty"
289
+ return _check_repo_access(token, target)
290
+
291
+
292
+ def _ask(prompt: str, default: str = "", *, required: bool = False) -> str:
293
+ """One interactive prompt with an optional default; re-asks while a required
294
+ answer is blank."""
295
+ suffix = f" [{default}]" if default else ""
296
+ while True:
297
+ got = input(f"{prompt}{suffix}: ").strip() or default
298
+ if got or not required:
299
+ return got
300
+ print(" (required)")
301
+
302
+
303
+ def _collect(args: argparse.Namespace, interactive: bool) -> tuple[InitAnswers, str]:
304
+ """Merge flags with prompts (when interactive) into answers + a PAT-file
305
+ path. `args.pat_file` names an existing file; otherwise, interactively, a
306
+ pasted token is returned separately to be written 0600."""
307
+ compute = args.compute or (_ask("Compute: slurm or local", "slurm") if interactive else "slurm")
308
+ compute = compute.lower()
309
+ target = args.target or (_ask("Target repo (owner/repo)", required=True) if interactive else "")
310
+ root = account = partition = ""
311
+ if compute == "slurm":
312
+ root = args.root or (
313
+ _ask("Slurm state root (shared filesystem)", required=True) if interactive else ""
314
+ )
315
+ account = args.account or (
316
+ _ask("Slurm account (blank = your default association)") if interactive else ""
317
+ )
318
+ partition = args.partition or (
319
+ _ask("Slurm partition (blank = Slurm default; a,b for a list)") if interactive else ""
320
+ )
321
+ # Author config is part of the full setup, not the focused --github-app run
322
+ # (that one is about auth). When asked, offer the fixed set, not a blank.
323
+ ask_author = interactive and not args.github_app
324
+ backend = args.author_backend or (
325
+ _ask("Author backend (claude or codex)", "claude") if ask_author else ""
326
+ )
327
+ model = args.author_model or (
328
+ _ask("Author model (blank = the backend's default)") if ask_author else ""
329
+ )
330
+ # An explicit --image is recorded here (absolute: jobs read it from their
331
+ # own directory); the published one is fetched in main, after every check
332
+ # that could still end the run.
333
+ image = str(Path(args.image).expanduser().absolute()) if args.image else ""
334
+ answers = InitAnswers(
335
+ compute=compute,
336
+ target=target,
337
+ root=root,
338
+ account=account,
339
+ partition=partition,
340
+ author_backend=backend,
341
+ author_model=model,
342
+ image=image,
343
+ author_bin=locate_harness(backend),
344
+ uncontained=bool(args.no_image) and not image,
345
+ )
346
+ return answers, (args.pat_file or "")
347
+
348
+
349
+ ORG_PROMPT = (
350
+ "Create the App under which account? (the repository's owner is the only one "
351
+ "the App can then be installed on; Enter to accept)"
352
+ )
353
+
354
+
355
+ def _owner_type(owner: str) -> str:
356
+ """ "User" or "Organization" for a GitHub account, or "" when the lookup
357
+ fails. GitHub creates Apps at different pages for the two, and a wrong
358
+ guess sends the adopter to a page that cannot create the App."""
359
+ req = urllib.request.Request(
360
+ f"{API}/users/{owner}", headers={"Accept": "application/vnd.github+json"}
361
+ )
362
+ try:
363
+ with urllib.request.urlopen(req, timeout=15) as resp:
364
+ return str(json.loads(resp.read()).get("type", ""))
365
+ except Exception as exc:
366
+ log.warning("could not look up the account type of %s: %s", owner, exc)
367
+ return ""
368
+
369
+
370
+ def _app_owner_mismatch_note(conversion: dict[str, Any], expected_owner: str, target: str) -> str:
371
+ """When the App landed under a different account than intended — GitHub's
372
+ fallback when someone who is not an org owner tries to create an org-owned
373
+ App — explain the path that still works, since a personal App does not
374
+ install on an org repo by default. Empty when the App landed as intended."""
375
+ actual = str((conversion.get("owner") or {}).get("login", "")).strip()
376
+ if not actual or actual.casefold() == expected_owner.casefold():
377
+ return ""
378
+ org = target.split("/")[0]
379
+ return (
380
+ f" note: the App was created under '{actual}', not '{expected_owner}'. Creating an\n"
381
+ f" App owned by an organization needs org-owner rights, so GitHub made a personal\n"
382
+ f" one — which will not install on {target} by default. To use it there anyway:\n"
383
+ f" make the App public (its Settings > 'Make public'), request its installation on\n"
384
+ f" {target}, and have an owner of {org} approve it; then rerun\n"
385
+ f" `outerloop init --force --github-app`. For lab ownership, you can later transfer\n"
386
+ f" the App to {org} from its Advanced settings — the key keeps working."
387
+ )
388
+
389
+
390
+ def _app_failure(answers: InitAnswers, slug: str, problem: str) -> int:
391
+ """The App cannot write the target: say so, keep the credentials, and point
392
+ at the fix and the re-check. Never `start` — the check just failed."""
393
+ print(f" auth check: FAILED — {problem}", file=sys.stderr)
394
+ print(
395
+ f"outerloop init: the App '{slug}' cannot write {answers.target}. On\n"
396
+ f" https://github.com/apps/{slug}/installations/new (or the App's page under\n"
397
+ " Settings > Installations > Configure) install it on that repository and accept\n"
398
+ " contents, issues and pull-request write access, then run\n"
399
+ " `outerloop init --force --github-app` to re-check with these credentials.\n"
400
+ " If the App is under your personal account and the repository belongs to an\n"
401
+ " organization, make the App public first and have an org owner approve the install.",
402
+ file=sys.stderr,
403
+ )
404
+ return 1
405
+
406
+
407
+ def _github_app_recheck(answers: InitAnswers, app_json: Path) -> int:
408
+ """Re-run Step 3 for an App this machine already created: confirm it can
409
+ write the target, then point the .env at it."""
410
+ from outerloop import appmanifest
411
+ from outerloop.appauth import app_provider_from_file
412
+
413
+ slug = app_json.name[len("github_app.") : -len(".json")]
414
+ print(f"Re-checking the existing App '{slug}' ({app_json}) against {answers.target}.")
415
+ try:
416
+ data = json.loads(app_json.read_text())
417
+ if not int(data.get("installation_id") or 0):
418
+ # the earlier run ended before the App was installed: look again
419
+ iid = appmanifest.capture_installation_id(
420
+ int(data["app_id"]), Path(str(data["private_key"])), answers.target.split("/")[0]
421
+ )
422
+ if iid:
423
+ appmanifest.set_installation_id(app_json, iid)
424
+ print(f" installation id {iid} recorded")
425
+ problem = _check_app_access(app_provider_from_file(app_json), answers.target)
426
+ except Exception as exc:
427
+ problem = f"could not read the App credentials: {exc}"
428
+ env_path = CONFIG_DIR / ENV_FILE.name
429
+ write_private(env_path, render_env(answers, app_file=str(app_json), bot_login=f"{slug}[bot]"))
430
+ if _auth_is_fatal(problem):
431
+ return _app_failure(answers, slug, problem)
432
+ print(f" auth check: {'ok' if not problem else 'WARNING — ' + problem}")
433
+ print(f"wrote {env_path}")
434
+ _author_key_hint(answers)
435
+ _harness_hint(answers)
436
+ print("next: outerloop start")
437
+ return 0
438
+
439
+
440
+ def _github_app_setup(
441
+ answers: InitAnswers, app_name: str, org: str, *, interactive: bool = True
442
+ ) -> int:
443
+ """The `--github-app` path: create the adopter's own App via the manifest
444
+ flow, write its creds, help install it, then point the .env at the App file.
445
+ Interactive by nature (a browser click + install), so no `--yes` variant."""
446
+ from outerloop import appmanifest
447
+
448
+ existing = sorted(CONFIG_DIR.glob("github_app.*.json"))
449
+ if len(existing) == 1:
450
+ # a re-run after fixing the installation: re-check the App this machine
451
+ # already has rather than creating a second one
452
+ reuse = True
453
+ if interactive:
454
+ answer = _ask(f"Reuse the App credentials at {existing[0]}? (Y/n)", "y")
455
+ reuse = not answer.lower().startswith("n")
456
+ if reuse:
457
+ return _github_app_recheck(answers, existing[0])
458
+ owner = org or answers.target.split("/")[0]
459
+ name = app_name or f"outerloop-{owner}"[:34] # GitHub caps App names at 34
460
+ code = appmanifest.request_manifest_code(
461
+ name, "https://github.com/outerloop-science/outerloop", org
462
+ )
463
+ if not code:
464
+ print("outerloop init: no code entered — nothing created", file=sys.stderr)
465
+ return 1
466
+ try:
467
+ conversion = appmanifest.convert_manifest(code)
468
+ except ValueError as exc:
469
+ print(f"outerloop init: {exc}", file=sys.stderr)
470
+ return 1
471
+ pem_path, app_json = appmanifest.save_app_creds(conversion, CONFIG_DIR)
472
+ repo = answers.target.split("/", 1)[-1]
473
+ print(f" created App '{conversion['slug']}'; credentials in {app_json} and {pem_path} (0600)")
474
+ mismatch = _app_owner_mismatch_note(conversion, owner, answers.target)
475
+ if mismatch:
476
+ print(mismatch, file=sys.stderr)
477
+ print()
478
+ print(f"Step 2 of 3: install the App on {answers.target}.")
479
+ print(f" Open {appmanifest.install_url(conversion)}")
480
+ print(f" choose 'Only select repositories', pick '{repo}', and click 'Install'.")
481
+ input("Press Enter here once GitHub shows the App as installed… ")
482
+ print()
483
+ print(f"Step 3 of 3: checking that the App can write {answers.target}.")
484
+ iid = appmanifest.capture_installation_id(int(conversion["id"]), pem_path, owner)
485
+ for _ in range(2):
486
+ if iid:
487
+ break
488
+ # not installed yet: a rerun of init would stop at the existing config,
489
+ # so ask again here rather than sending the adopter around the loop
490
+ print(
491
+ f" no installation found yet. Install the App at {appmanifest.install_url(conversion)}"
492
+ )
493
+ input("Press Enter once GitHub shows the App as installed… ")
494
+ iid = appmanifest.capture_installation_id(int(conversion["id"]), pem_path, owner)
495
+ if iid:
496
+ appmanifest.set_installation_id(app_json, iid)
497
+ print(f" installation id {iid} recorded")
498
+ # Self-verify end to end: mint a real installation token and check it can
499
+ # write the target — this is what confirms the whole flow actually worked.
500
+ from outerloop.appauth import app_provider_from_file
501
+
502
+ try:
503
+ problem = _check_app_access(app_provider_from_file(app_json), answers.target)
504
+ except Exception as exc: # a check failure is a warning, never fails setup
505
+ problem = f"could not read the App credentials: {exc}"
506
+ if _auth_is_fatal(problem):
507
+ write_private(
508
+ CONFIG_DIR / ENV_FILE.name,
509
+ render_env(answers, app_file=str(app_json), bot_login=f"{conversion['slug']}[bot]"),
510
+ )
511
+ return _app_failure(answers, str(conversion["slug"]), problem)
512
+ print(f" auth check: {'ok' if not problem else 'WARNING — ' + problem}")
513
+ else:
514
+ # the credentials are kept; nothing can run until the App is installed
515
+ write_private(
516
+ CONFIG_DIR / ENV_FILE.name,
517
+ render_env(answers, app_file=str(app_json), bot_login=f"{conversion['slug']}[bot]"),
518
+ )
519
+ return _app_failure(
520
+ answers, str(conversion["slug"]), f"the App is not installed on {answers.target}"
521
+ )
522
+ env_path = CONFIG_DIR / ENV_FILE.name
523
+ write_private(
524
+ env_path,
525
+ render_env(answers, app_file=str(app_json), bot_login=f"{conversion['slug']}[bot]"),
526
+ )
527
+ print(f"wrote {env_path}")
528
+ _author_key_hint(answers)
529
+ _harness_hint(answers)
530
+ print("next: outerloop start")
531
+ return 0
532
+
533
+
534
+ def main(argv: list[str] | None = None) -> int:
535
+ parser = argparse.ArgumentParser(
536
+ prog="outerloop init",
537
+ description="Guided setup: asks for anything not given as a flag, checks the "
538
+ "credentials, and writes ~/.config/outerloop/.env.",
539
+ )
540
+ parser.add_argument("--compute", choices=["slurm", "local"], help="where the loop runs")
541
+ parser.add_argument("--target", help="the repo the agents work on, owner/repo")
542
+ parser.add_argument("--root", help="Slurm state root on the shared filesystem")
543
+ parser.add_argument(
544
+ "--account", help="Slurm account (optional; unset bills your default association)"
545
+ )
546
+ parser.add_argument(
547
+ "--partition", help="Slurm partition (optional; blank = default; a,b = list)"
548
+ )
549
+ parser.add_argument(
550
+ "--pat-file", dest="pat_file", help="path to an existing file holding a PAT"
551
+ )
552
+ parser.add_argument(
553
+ "--github-app",
554
+ dest="github_app",
555
+ action="store_true",
556
+ help="create your own GitHub App in a browser (one click) instead of a PAT",
557
+ )
558
+ parser.add_argument("--app-name", dest="app_name", help="name for the created GitHub App")
559
+ parser.add_argument("--org", help="create the App under this org (default: your account)")
560
+ parser.add_argument(
561
+ "--author-backend",
562
+ dest="author_backend",
563
+ help=f"climbing author's backend ({' or '.join(AUTHOR_BACKENDS)}; default claude)",
564
+ )
565
+ parser.add_argument("--author-model", dest="author_model", help="climbing author's model")
566
+ image_flags = parser.add_mutually_exclusive_group() # one or the other, never both
567
+ image_flags.add_argument(
568
+ "--image",
569
+ help="path to an Apptainer image to run sessions and evals in (default: the "
570
+ "published one, downloaded on Linux when apptainer is installed)",
571
+ )
572
+ image_flags.add_argument(
573
+ "--no-image",
574
+ dest="no_image",
575
+ action="store_true",
576
+ help="no image at all: runs stay uncontained even if one is already on disk",
577
+ )
578
+ parser.add_argument(
579
+ "--author-key-file",
580
+ dest="author_key_file",
581
+ help="path to an existing file holding the author's model API key",
582
+ )
583
+ parser.add_argument(
584
+ "--yes", "-y", action="store_true", help="non-interactive: use flags, do not prompt"
585
+ )
586
+ parser.add_argument(
587
+ "--force", action="store_true", help="overwrite an existing config without asking"
588
+ )
589
+ args = parser.parse_args(sys.argv[2:] if argv is None else argv)
590
+ interactive = not args.yes
591
+
592
+ answers, pat_file = _collect(args, interactive)
593
+ if not answers.target:
594
+ print("outerloop init: a target repo is required (--target owner/repo)", file=sys.stderr)
595
+ return 2
596
+ if answers.compute == "slurm" and not answers.root:
597
+ # the account and the partition are optional: Slurm's defaults apply
598
+ print("outerloop init: Slurm needs --root", file=sys.stderr)
599
+ return 2
600
+ if answers.author_backend and answers.author_backend not in AUTHOR_BACKENDS:
601
+ print(
602
+ f"outerloop init: author backend must be one of {', '.join(AUTHOR_BACKENDS)}",
603
+ file=sys.stderr,
604
+ )
605
+ return 2
606
+ if answers.image and not Path(answers.image).is_file():
607
+ # a typo here would surface as an uncontained loop later
608
+ print(f"outerloop init: --image {answers.image} is not a file", file=sys.stderr)
609
+ return 2
610
+
611
+ # Never clobber a working setup silently: a re-run of init on a configured
612
+ # machine must ask (or be told --force). Checked before any App is created.
613
+ env_path = CONFIG_DIR / ENV_FILE.name
614
+ if env_path.exists() and not args.force:
615
+ if not interactive:
616
+ print(f"outerloop init: {env_path} exists; pass --force to overwrite", file=sys.stderr)
617
+ return 1
618
+ if not _ask(f"{env_path} exists — overwrite it? (y/N)", "n").lower().startswith("y"):
619
+ print(
620
+ "outerloop init: kept the existing config (--force skips this check)",
621
+ file=sys.stderr,
622
+ )
623
+ return 1
624
+
625
+ # The author's model key: an existing file by flag, or (on the full
626
+ # interactive run, never the focused --github-app one) a hidden paste written
627
+ # to <config>/<backend>_key. Collected after the overwrite check so a
628
+ # declined run never asks for a secret.
629
+ if args.author_key_file:
630
+ key_file = Path(args.author_key_file).expanduser()
631
+ if not key_file.is_file() or not os.access(key_file, os.R_OK):
632
+ print(
633
+ f"outerloop init: --author-key-file {key_file} is not a readable file",
634
+ file=sys.stderr,
635
+ )
636
+ return 2
637
+ # absolute: climb jobs read the key from their own flight directory
638
+ answers.author_key_file = str(key_file.absolute())
639
+ if not answers.author_key_file and interactive and not args.github_app:
640
+ pasted = getpass.getpass(
641
+ f"API key for the {answers.author_backend or AUTHOR_BACKENDS[0]} author "
642
+ "(hidden; blank to set later): "
643
+ ).strip()
644
+ if pasted:
645
+ key_path = write_author_key(answers.author_backend, pasted, config_dir=CONFIG_DIR)
646
+ answers.author_key_file = str(key_path)
647
+ print(f"wrote {key_path} (0600)")
648
+
649
+ # The image, only now: every check that could still end the run has passed
650
+ # and the overwrite question is answered, so a download is never wasted.
651
+ # An existing image is used, else the published one is fetched on a machine
652
+ # that can run it (asked first when interactive); --no-image opts out.
653
+ if not answers.image and not args.no_image:
654
+ # the container probe is for local mode: on Slurm the image runs on
655
+ # compute nodes, which the login node cannot speak for
656
+ answers.image = ensure_image(interactive=interactive, probe=(answers.compute == "local"))
657
+
658
+ # The App is the recommended credential (scoped, revocable, no plaintext
659
+ # token); the PAT is the fallback. Offer it first when interactive.
660
+ if not args.github_app and not pat_file and interactive:
661
+ choice = _ask(
662
+ "Auth — [app] create your own GitHub App (recommended) or [pat] paste a token",
663
+ "app",
664
+ ).lower()
665
+ if choice.startswith("a"):
666
+ args.github_app = True
667
+ if args.github_app:
668
+ org = args.org or ""
669
+ if not org and interactive:
670
+ # a private App installs only on the account that created it, so
671
+ # the target's owner is the only default that can work
672
+ owner = answers.target.split("/")[0]
673
+ org = _ask(ORG_PROMPT, owner).strip()
674
+ if org:
675
+ kind = _owner_type(org)
676
+ if not kind and interactive:
677
+ answer = _ask(f"Is '{org}' an organization or a user account? [org/user]", "org")
678
+ kind = "User" if answer.strip().lower().startswith("u") else "Organization"
679
+ if kind == "User":
680
+ org = "" # a user account: GitHub's personal App page, not an org page
681
+ return _github_app_setup(answers, args.app_name or "", org, interactive=interactive)
682
+
683
+ token = ""
684
+ if not pat_file and interactive:
685
+ # re-collect only the token here so _collect stays pure of getpass in tests
686
+ token = getpass.getpass(
687
+ "Paste a GitHub PAT with write access to the target (hidden; blank to skip): "
688
+ ).strip()
689
+
690
+ env_path, pat_path = write_config(answers, token, pat_file, config_dir=CONFIG_DIR)
691
+ print(f"wrote {env_path}")
692
+ if pat_path:
693
+ print(f"wrote {pat_path} (0600)")
694
+ effective_pat = str(pat_path) if pat_path else pat_file
695
+ if effective_pat:
696
+ problem = validate_pat(effective_pat, answers.target)
697
+ if _auth_is_fatal(problem):
698
+ # the loop opens pull requests with this token; sending the adopter
699
+ # to `start` would fail later and further from the cause
700
+ print(f" auth check: FAILED — {problem}", file=sys.stderr)
701
+ print(
702
+ f"outerloop init: fix the token in {effective_pat} (write access to "
703
+ f"{answers.target}), then run `outerloop init --force`",
704
+ file=sys.stderr,
705
+ )
706
+ return 1
707
+ print(f" auth check: {'ok' if not problem else 'WARNING — ' + problem}")
708
+ if not problem:
709
+ # record the login the kernel will post as: every own-comment and
710
+ # own-PR filter keys on it, and a default that is not this token's
711
+ # identity would hide the adopter's own PRs from the kernel
712
+ try:
713
+ login = _token_login(Path(effective_pat).expanduser().read_text().strip())
714
+ except OSError:
715
+ login = ""
716
+ if login:
717
+ write_private(env_path, render_env(answers, effective_pat, bot_login=login))
718
+ print(f" posting as {login} (OUTERLOOP_BOT_LOGIN)")
719
+ else:
720
+ print(
721
+ " the token's login could not be read — set OUTERLOOP_BOT_LOGIN in "
722
+ f"{env_path} before `outerloop start` (the tick skips a target without it)"
723
+ )
724
+ else:
725
+ print(
726
+ " OUTERLOOP_BOT_LOGIN not recorded (the check did not pass) — rerun "
727
+ "`outerloop init --force` with network access, or set it in "
728
+ f"{env_path} (the tick skips a target without it)"
729
+ )
730
+ else:
731
+ print(" no PAT set — add OUTERLOOP_PAT_FILE before the agents can open PRs")
732
+ _author_key_hint(answers)
733
+ _harness_hint(answers)
734
+ print("next: outerloop start")
735
+ return 0
736
+
737
+
738
+ def _author_key_hint(answers: InitAnswers) -> None:
739
+ if not answers.author_key_file:
740
+ print(
741
+ " no author key set — put it in "
742
+ f"{author_key_path(answers.author_backend, config_dir=CONFIG_DIR)} "
743
+ "before the first climb"
744
+ )