outerloop-science 0.1.0.dev0__py3-none-any.whl → 0.1.0.dev2__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.
outerloop/init.py CHANGED
@@ -1,8 +1,9 @@
1
1
  """`outerloop init` — the guided setup.
2
2
 
3
- Collects placement (Slurm or local), the target repo, and bot auth, then writes
4
- `~/.config/outerloop/.env` (plus the credential files), so a new adopter never
5
- hand-edits config or reasons about which `OUTERLOOP_*` keys to set. Flags fill
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
6
7
  answers non-interactively; anything left out is prompted for (a secret via
7
8
  getpass, never echoed). Auth is the adopter's own GitHub App by default —
8
9
  `--github-app`, one click via the manifest flow in `appmanifest.py` — with a PAT
@@ -18,13 +19,19 @@ from __future__ import annotations
18
19
  import argparse
19
20
  import getpass
20
21
  import json
22
+ import os
23
+ import shutil
21
24
  import sys
25
+ import time
22
26
  import urllib.error
23
27
  import urllib.request
24
28
  from dataclasses import dataclass
25
29
  from pathlib import Path
30
+ from typing import Any
26
31
 
27
32
  from outerloop.cli import ENV_FILE
33
+ from outerloop.image import ensure_image
34
+ from outerloop.paths import write_private
28
35
 
29
36
  CONFIG_DIR = ENV_FILE.parent
30
37
  DEFAULT_PAT_FILE = CONFIG_DIR / "bot_pat"
@@ -38,13 +45,19 @@ class InitAnswers:
38
45
  compute: str # "slurm" | "local"
39
46
  target: str # "owner/repo"
40
47
  root: str = "" # Slurm state root on the shared filesystem
41
- account: str = "" # Slurm account (required for Slurm)
48
+ account: str = "" # Slurm account (optional; unset -> the default association)
42
49
  partition: str = "" # Slurm partition (optional; unset -> Slurm default)
43
50
  author_backend: str = "" # optional: the climbing author's harness
44
51
  author_model: str = "" # optional
52
+ author_key_file: str = "" # the author's model key file, when known
53
+ image: str = "" # the agent image (OUTERLOOP_IMAGE)
54
+ uncontained: bool = False # --no-image: write OUTERLOOP_IMAGE= so no image is picked up
55
+ author_bin: str = "" # the author harness binary (claude/codex), absolute, when found
45
56
 
46
57
 
47
- def render_env(a: InitAnswers, pat_file: str = "", *, app_file: str = "") -> str:
58
+ def render_env(
59
+ a: InitAnswers, pat_file: str = "", *, app_file: str = "", bot_login: str = ""
60
+ ) -> str:
48
61
  """The `.env` body for these answers — only the keys that have a value, so
49
62
  the file stays minimal and every line means something. Ordered placement →
50
63
  target → auth → author to read top-to-bottom like the setup itself. Auth is
@@ -52,21 +65,113 @@ def render_env(a: InitAnswers, pat_file: str = "", *, app_file: str = "") -> str
52
65
  lines = [f"OUTERLOOP_COMPUTE={a.compute}"]
53
66
  if a.compute == "slurm":
54
67
  lines.append(f"OUTERLOOP_ROOT={a.root}")
55
- lines.append(f"OUTERLOOP_ACCOUNT={a.account}")
68
+ if a.account: # optional: unset bills the caller's default association
69
+ lines.append(f"OUTERLOOP_ACCOUNT={a.account}")
56
70
  if a.partition: # optional: unset lets Slurm pick its default partition
57
71
  lines.append(f"OUTERLOOP_PARTITION={a.partition}")
72
+ if a.image:
73
+ lines.append(f"OUTERLOOP_IMAGE={a.image}")
74
+ elif a.uncontained:
75
+ # the empty value is the off-switch: without it the tick would still
76
+ # pick up an image already at the default path
77
+ lines.append("OUTERLOOP_IMAGE=")
58
78
  lines.append(f"OUTERLOOP_TARGET={a.target}")
59
79
  if app_file:
60
80
  lines.append(f"OUTERLOOP_GITHUB_APP_FILE={app_file}")
61
81
  elif pat_file:
62
82
  lines.append(f"OUTERLOOP_PAT_FILE={pat_file}")
83
+ if bot_login:
84
+ # every own-comment filter and own-PR scan keys on this login; without
85
+ # it the kernel assumes a default that is not this adopter's identity
86
+ lines.append(f"OUTERLOOP_BOT_LOGIN={bot_login}")
63
87
  if a.author_backend:
64
88
  lines.append(f"OUTERLOOP_AUTHOR_BACKEND={a.author_backend}")
65
89
  if a.author_model:
66
90
  lines.append(f"OUTERLOOP_AUTHOR_MODEL={a.author_model}")
91
+ if a.author_key_file:
92
+ lines.append(f"{author_key_env(a.author_backend)}={a.author_key_file}")
93
+ if a.author_bin:
94
+ lines.append(f"{author_bin_env(a.author_backend)}={a.author_bin}")
67
95
  return "\n".join(lines) + "\n"
68
96
 
69
97
 
98
+ def author_bin_env(backend: str) -> str:
99
+ """The `.env` key naming `backend`'s harness binary (`OUTERLOOP_CLAUDE_BIN`,
100
+ `OUTERLOOP_CODEX_BIN`), the same names `attempt` reads."""
101
+ return f"OUTERLOOP_{(backend or AUTHOR_BACKENDS[0]).upper()}_BIN"
102
+
103
+
104
+ HARNESS_INSTALL = {
105
+ "claude": "npm install -g @anthropic-ai/claude-code "
106
+ "(or: curl -fsSL https://claude.ai/install.sh | bash)",
107
+ "codex": "npm install -g @openai/codex",
108
+ }
109
+
110
+
111
+ def locate_harness(backend: str) -> str:
112
+ """The absolute path of `backend`'s CLI on this machine: PATH first, then
113
+ ~/.local/bin (where the native installers put it and where a Slurm job,
114
+ with no login PATH, would not find it); "" when absent. Recorded in .env so
115
+ every job spawns the same binary the operator installed."""
116
+ name = backend or AUTHOR_BACKENDS[0]
117
+ found = shutil.which(name)
118
+ if found:
119
+ return str(Path(found).resolve())
120
+ local = Path.home() / ".local" / "bin" / name
121
+ return str(local) if local.is_file() and os.access(local, os.X_OK) else ""
122
+
123
+
124
+ def _harness_hint(answers: InitAnswers) -> None:
125
+ if not answers.author_bin:
126
+ name = answers.author_backend or AUTHOR_BACKENDS[0]
127
+ print(
128
+ f" no `{name}` binary found on PATH or in ~/.local/bin — the first climb would end\n"
129
+ f" with spawn-error. Install it: {HARNESS_INSTALL.get(name, 'see its docs')}\n"
130
+ " then run `outerloop init --force` so its path is recorded."
131
+ )
132
+
133
+
134
+ def _token_login(token: str) -> str:
135
+ """The login a PAT acts as (GET /user), or "" when GitHub does not say."""
136
+ req = urllib.request.Request(
137
+ f"{API}/user",
138
+ headers={"Authorization": f"token {token}", "Accept": "application/vnd.github+json"},
139
+ )
140
+ try:
141
+ with urllib.request.urlopen(req, timeout=15) as resp:
142
+ return str(json.loads(resp.read()).get("login") or "")
143
+ except (urllib.error.URLError, OSError, ValueError):
144
+ return ""
145
+
146
+
147
+ def _auth_is_fatal(problem: str) -> bool:
148
+ """A credential the loop cannot open pull requests with fails setup; not
149
+ being able to ask GitHub right now (network, 5xx, rate limit) does not."""
150
+ return bool(problem) and not problem.startswith(
151
+ ("could not reach GitHub", "could not read the App", "could not check the App")
152
+ )
153
+
154
+
155
+ def author_key_env(backend: str) -> str:
156
+ """The `.env` key naming `backend`'s model-key file: one rule for every
157
+ backend (`OUTERLOOP_CLAUDE_KEY_FILE`, `OUTERLOOP_CODEX_KEY_FILE`)."""
158
+ return f"OUTERLOOP_{(backend or AUTHOR_BACKENDS[0]).upper()}_KEY_FILE"
159
+
160
+
161
+ def author_key_path(backend: str, *, config_dir: Path = CONFIG_DIR) -> Path:
162
+ """Where `backend`'s model key lives by default: `<config dir>/<backend>_key`,
163
+ the same rule `attempt.resolve_author_key_file` reads."""
164
+ return config_dir / f"{backend or AUTHOR_BACKENDS[0]}_key"
165
+
166
+
167
+ def write_author_key(backend: str, key: str, *, config_dir: Path = CONFIG_DIR) -> Path:
168
+ """Write a pasted model key to its default file, owner-only (0600), no
169
+ trailing newline. Returns the path."""
170
+ path = author_key_path(backend, config_dir=config_dir)
171
+ write_private(path, key)
172
+ return path
173
+
174
+
70
175
  def write_config(
71
176
  a: InitAnswers, token: str, pat_file: str, *, config_dir: Path = CONFIG_DIR
72
177
  ) -> tuple[Path, Path | None]:
@@ -79,13 +184,11 @@ def write_config(
79
184
  pat_path = config_dir / DEFAULT_PAT_FILE.name
80
185
  # printf-style: no trailing newline — the deploy `cat`s this file into
81
186
  # the git credential, and a trailing newline rides along and breaks auth.
82
- pat_path.write_text(token)
83
- pat_path.chmod(0o600)
187
+ write_private(pat_path, token)
84
188
  written_pat = pat_path
85
189
  pat_file = str(pat_path)
86
190
  env_path = config_dir / ENV_FILE.name
87
- env_path.write_text(render_env(a, pat_file))
88
- env_path.chmod(0o600)
191
+ write_private(env_path, render_env(a, pat_file))
89
192
  return env_path, written_pat
90
193
 
91
194
 
@@ -106,11 +209,69 @@ def _check_repo_access(token: str, target: str) -> str:
106
209
  except urllib.error.HTTPError as exc:
107
210
  if exc.code == 404:
108
211
  return f"{target} not found, or the token cannot see it"
212
+ if exc.code == 401:
213
+ return "the token is invalid or expired (GitHub returned 401)"
214
+ if exc.code == 403:
215
+ return f"the token is not allowed to access {target} (GitHub returned 403)"
216
+ if exc.code == 429 or exc.code >= 500:
217
+ # GitHub's problem, not the credential's: never fatal
218
+ return f"could not reach GitHub: it returned {exc.code} for {target}"
109
219
  return f"GitHub returned {exc.code} for {target}"
110
220
  except urllib.error.URLError as exc:
111
221
  return f"could not reach GitHub: {exc.reason}"
112
222
 
113
223
 
224
+ def _check_app_access(provider: Any, target: str) -> str:
225
+ """Can this App write the target repo? "" on success, else a short reason.
226
+
227
+ An installation token does not populate a repository's `permissions`
228
+ object, so the PAT check above reads all-false for an App that can push.
229
+ The App is asked the two questions that decide it: is the target among
230
+ the repositories its installation covers, and does the installation carry
231
+ write on contents and pull requests. Never raises."""
232
+ from outerloop.appauth import build_app_jwt
233
+
234
+ headers = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"}
235
+ try:
236
+ # Asked as the App itself: GitHub answers with the installation that
237
+ # covers this repository, permissions included, or 404. A public
238
+ # repository would answer a plain read for any token, and the
239
+ # installation's repository list paginates, so neither is asked.
240
+ jwt = build_app_jwt(provider.app_id, time.time(), provider._sign)
241
+ req = urllib.request.Request(
242
+ f"{API}/repos/{target}/installation",
243
+ headers={**headers, "Authorization": f"Bearer {jwt}"},
244
+ )
245
+ try:
246
+ with urllib.request.urlopen(req, timeout=15) as resp:
247
+ installation = json.loads(resp.read())
248
+ except urllib.error.HTTPError as exc:
249
+ if exc.code == 404:
250
+ return f"the App is not installed on {target}; install it there"
251
+ raise
252
+ if int(installation.get("id", 0)) != int(provider.installation_id):
253
+ return (
254
+ f"the App is installed on {target} under installation "
255
+ f"{installation.get('id')}, but the App file records {provider.installation_id}"
256
+ )
257
+ perms = installation.get("permissions") or {}
258
+ missing = [k for k in ("contents", "issues", "pull_requests") if perms.get(k) != "write"]
259
+ if missing:
260
+ return (
261
+ f"the App lacks write on {', '.join(missing)} "
262
+ "(it pushes branches, opens PRs, and files and closes issues)"
263
+ )
264
+ return ""
265
+ except urllib.error.HTTPError as exc:
266
+ if exc.code == 429 or exc.code >= 500:
267
+ return f"could not reach GitHub: it returned {exc.code} while checking the App"
268
+ return f"GitHub returned {exc.code} while checking the App"
269
+ except urllib.error.URLError as exc:
270
+ return f"could not reach GitHub: {exc.reason}"
271
+ except Exception as exc: # a check failure is a warning, never fails setup
272
+ return f"could not check the App: {exc}"
273
+
274
+
114
275
  def validate_pat(pat_file: str, target: str) -> str:
115
276
  """Best-effort: can the PAT in `pat_file` write the target repo?"""
116
277
  try:
@@ -145,7 +306,9 @@ def _collect(args: argparse.Namespace, interactive: bool) -> tuple[InitAnswers,
145
306
  root = args.root or (
146
307
  _ask("Slurm state root (shared filesystem)", required=True) if interactive else ""
147
308
  )
148
- account = args.account or (_ask("Slurm account", required=True) if interactive else "")
309
+ account = args.account or (
310
+ _ask("Slurm account (blank = your default association)") if interactive else ""
311
+ )
149
312
  partition = args.partition or (
150
313
  _ask("Slurm partition (blank = Slurm default; a,b for a list)") if interactive else ""
151
314
  )
@@ -158,6 +321,10 @@ def _collect(args: argparse.Namespace, interactive: bool) -> tuple[InitAnswers,
158
321
  model = args.author_model or (
159
322
  _ask("Author model (blank = the backend's default)") if ask_author else ""
160
323
  )
324
+ # An explicit --image is recorded here (absolute: jobs read it from their
325
+ # own directory); the published one is fetched in main, after every check
326
+ # that could still end the run.
327
+ image = str(Path(args.image).expanduser().absolute()) if args.image else ""
161
328
  answers = InitAnswers(
162
329
  compute=compute,
163
330
  target=target,
@@ -166,16 +333,99 @@ def _collect(args: argparse.Namespace, interactive: bool) -> tuple[InitAnswers,
166
333
  partition=partition,
167
334
  author_backend=backend,
168
335
  author_model=model,
336
+ image=image,
337
+ author_bin=locate_harness(backend),
338
+ uncontained=bool(args.no_image) and not image,
169
339
  )
170
340
  return answers, (args.pat_file or "")
171
341
 
172
342
 
173
- def _github_app_setup(answers: InitAnswers, app_name: str, org: str) -> int:
343
+ ORG_PROMPT = (
344
+ "Create the App under which account? (the repository's owner is the only one "
345
+ "the App can then be installed on; Enter to accept)"
346
+ )
347
+
348
+
349
+ def _owner_type(owner: str) -> str:
350
+ """ "User" or "Organization" for a GitHub account, or "" when the lookup
351
+ fails. GitHub creates Apps at different pages for the two, and a wrong
352
+ guess sends the adopter to a page that cannot create the App."""
353
+ req = urllib.request.Request(
354
+ f"{API}/users/{owner}", headers={"Accept": "application/vnd.github+json"}
355
+ )
356
+ try:
357
+ with urllib.request.urlopen(req, timeout=15) as resp:
358
+ return str(json.loads(resp.read()).get("type", ""))
359
+ except Exception:
360
+ return ""
361
+
362
+
363
+ def _app_failure(answers: InitAnswers, slug: str, problem: str) -> int:
364
+ """The App cannot write the target: say so, keep the credentials, and point
365
+ at the fix and the re-check. Never `start` — the check just failed."""
366
+ print(f" auth check: FAILED — {problem}", file=sys.stderr)
367
+ print(
368
+ f"outerloop init: the App '{slug}' cannot write {answers.target}. On\n"
369
+ f" https://github.com/apps/{slug}/installations/new (or the App's page under\n"
370
+ " Settings > Installations > Configure) install it on that repository and accept\n"
371
+ " contents, issues and pull-request write access, then run\n"
372
+ " `outerloop init --force --github-app` to re-check with these credentials.",
373
+ file=sys.stderr,
374
+ )
375
+ return 1
376
+
377
+
378
+ def _github_app_recheck(answers: InitAnswers, app_json: Path) -> int:
379
+ """Re-run Step 3 for an App this machine already created: confirm it can
380
+ write the target, then point the .env at it."""
381
+ from outerloop import appmanifest
382
+ from outerloop.appauth import app_provider_from_file
383
+
384
+ slug = app_json.name[len("github_app.") : -len(".json")]
385
+ print(f"Re-checking the existing App '{slug}' ({app_json}) against {answers.target}.")
386
+ try:
387
+ data = json.loads(app_json.read_text())
388
+ if not int(data.get("installation_id") or 0):
389
+ # the earlier run ended before the App was installed: look again
390
+ iid = appmanifest.capture_installation_id(
391
+ int(data["app_id"]), Path(str(data["private_key"])), answers.target.split("/")[0]
392
+ )
393
+ if iid:
394
+ appmanifest.set_installation_id(app_json, iid)
395
+ print(f" installation id {iid} recorded")
396
+ problem = _check_app_access(app_provider_from_file(app_json), answers.target)
397
+ except Exception as exc:
398
+ problem = f"could not read the App credentials: {exc}"
399
+ env_path = CONFIG_DIR / ENV_FILE.name
400
+ write_private(env_path, render_env(answers, app_file=str(app_json), bot_login=f"{slug}[bot]"))
401
+ if _auth_is_fatal(problem):
402
+ return _app_failure(answers, slug, problem)
403
+ print(f" auth check: {'ok' if not problem else 'WARNING — ' + problem}")
404
+ print(f"wrote {env_path}")
405
+ _author_key_hint(answers)
406
+ _harness_hint(answers)
407
+ print("next: outerloop start")
408
+ return 0
409
+
410
+
411
+ def _github_app_setup(
412
+ answers: InitAnswers, app_name: str, org: str, *, interactive: bool = True
413
+ ) -> int:
174
414
  """The `--github-app` path: create the adopter's own App via the manifest
175
415
  flow, write its creds, help install it, then point the .env at the App file.
176
416
  Interactive by nature (a browser click + install), so no `--yes` variant."""
177
417
  from outerloop import appmanifest
178
418
 
419
+ existing = sorted(CONFIG_DIR.glob("github_app.*.json"))
420
+ if len(existing) == 1:
421
+ # a re-run after fixing the installation: re-check the App this machine
422
+ # already has rather than creating a second one
423
+ reuse = True
424
+ if interactive:
425
+ answer = _ask(f"Reuse the App credentials at {existing[0]}? (Y/n)", "y")
426
+ reuse = not answer.lower().startswith("n")
427
+ if reuse:
428
+ return _github_app_recheck(answers, existing[0])
179
429
  owner = org or answers.target.split("/")[0]
180
430
  name = app_name or f"outerloop-{owner}"[:34] # GitHub caps App names at 34
181
431
  code = appmanifest.request_manifest_code(
@@ -190,10 +440,26 @@ def _github_app_setup(answers: InitAnswers, app_name: str, org: str) -> int:
190
440
  print(f"outerloop init: {exc}", file=sys.stderr)
191
441
  return 1
192
442
  pem_path, app_json = appmanifest.save_app_creds(conversion, CONFIG_DIR)
193
- print(f"created App '{conversion['slug']}' — wrote {app_json} and {pem_path} (0600)")
194
- print(f"install it on {answers.target}: {appmanifest.install_url(conversion)}")
195
- input("press Enter once you've installed the App… ")
443
+ repo = answers.target.split("/", 1)[-1]
444
+ print(f" created App '{conversion['slug']}'; credentials in {app_json} and {pem_path} (0600)")
445
+ print()
446
+ print(f"Step 2 of 3: install the App on {answers.target}.")
447
+ print(f" Open {appmanifest.install_url(conversion)}")
448
+ print(f" choose 'Only select repositories', pick '{repo}', and click 'Install'.")
449
+ input("Press Enter here once GitHub shows the App as installed… ")
450
+ print()
451
+ print(f"Step 3 of 3: checking that the App can write {answers.target}.")
196
452
  iid = appmanifest.capture_installation_id(int(conversion["id"]), pem_path, owner)
453
+ for _ in range(2):
454
+ if iid:
455
+ break
456
+ # not installed yet: a rerun of init would stop at the existing config,
457
+ # so ask again here rather than sending the adopter around the loop
458
+ print(
459
+ f" no installation found yet. Install the App at {appmanifest.install_url(conversion)}"
460
+ )
461
+ input("Press Enter once GitHub shows the App as installed… ")
462
+ iid = appmanifest.capture_installation_id(int(conversion["id"]), pem_path, owner)
197
463
  if iid:
198
464
  appmanifest.set_installation_id(app_json, iid)
199
465
  print(f" installation id {iid} recorded")
@@ -202,31 +468,49 @@ def _github_app_setup(answers: InitAnswers, app_name: str, org: str) -> int:
202
468
  from outerloop.appauth import app_provider_from_file
203
469
 
204
470
  try:
205
- token = app_provider_from_file(app_json).token()
206
- problem = _check_repo_access(token, answers.target)
207
- print(f" auth check: {'ok' if not problem else 'WARNING — ' + problem}")
471
+ problem = _check_app_access(app_provider_from_file(app_json), answers.target)
208
472
  except Exception as exc: # a check failure is a warning, never fails setup
209
- print(f" auth check: WARNING — could not mint a token: {exc}")
473
+ problem = f"could not read the App credentials: {exc}"
474
+ if _auth_is_fatal(problem):
475
+ write_private(
476
+ CONFIG_DIR / ENV_FILE.name,
477
+ render_env(answers, app_file=str(app_json), bot_login=f"{conversion['slug']}[bot]"),
478
+ )
479
+ return _app_failure(answers, str(conversion["slug"]), problem)
480
+ print(f" auth check: {'ok' if not problem else 'WARNING — ' + problem}")
210
481
  else:
211
- print(
212
- f" no installation found yet — install the App, then set installation_id in {app_json}"
482
+ # the credentials are kept; nothing can run until the App is installed
483
+ write_private(
484
+ CONFIG_DIR / ENV_FILE.name,
485
+ render_env(answers, app_file=str(app_json), bot_login=f"{conversion['slug']}[bot]"),
486
+ )
487
+ return _app_failure(
488
+ answers, str(conversion["slug"]), f"the App is not installed on {answers.target}"
213
489
  )
214
490
  env_path = CONFIG_DIR / ENV_FILE.name
215
- env_path.write_text(render_env(answers, app_file=str(app_json)))
216
- env_path.chmod(0o600)
491
+ write_private(
492
+ env_path,
493
+ render_env(answers, app_file=str(app_json), bot_login=f"{conversion['slug']}[bot]"),
494
+ )
217
495
  print(f"wrote {env_path}")
496
+ _author_key_hint(answers)
497
+ _harness_hint(answers)
218
498
  print("next: outerloop start")
219
499
  return 0
220
500
 
221
501
 
222
502
  def main(argv: list[str] | None = None) -> int:
223
503
  parser = argparse.ArgumentParser(
224
- prog="outerloop init", description="guided setup for outerloop"
504
+ prog="outerloop init",
505
+ description="Guided setup: asks for anything not given as a flag, checks the "
506
+ "credentials, and writes ~/.config/outerloop/.env.",
225
507
  )
226
508
  parser.add_argument("--compute", choices=["slurm", "local"], help="where the loop runs")
227
509
  parser.add_argument("--target", help="the repo the agents work on, owner/repo")
228
510
  parser.add_argument("--root", help="Slurm state root on the shared filesystem")
229
- parser.add_argument("--account", help="Slurm account")
511
+ parser.add_argument(
512
+ "--account", help="Slurm account (optional; unset bills your default association)"
513
+ )
230
514
  parser.add_argument(
231
515
  "--partition", help="Slurm partition (optional; blank = default; a,b = list)"
232
516
  )
@@ -241,8 +525,29 @@ def main(argv: list[str] | None = None) -> int:
241
525
  )
242
526
  parser.add_argument("--app-name", dest="app_name", help="name for the created GitHub App")
243
527
  parser.add_argument("--org", help="create the App under this org (default: your account)")
244
- parser.add_argument("--author-backend", dest="author_backend", help="climbing author's backend")
528
+ parser.add_argument(
529
+ "--author-backend",
530
+ dest="author_backend",
531
+ help=f"climbing author's backend ({' or '.join(AUTHOR_BACKENDS)}; default claude)",
532
+ )
245
533
  parser.add_argument("--author-model", dest="author_model", help="climbing author's model")
534
+ image_flags = parser.add_mutually_exclusive_group() # one or the other, never both
535
+ image_flags.add_argument(
536
+ "--image",
537
+ help="path to an Apptainer image to run sessions and evals in (default: the "
538
+ "published one, downloaded on Linux when apptainer is installed)",
539
+ )
540
+ image_flags.add_argument(
541
+ "--no-image",
542
+ dest="no_image",
543
+ action="store_true",
544
+ help="no image at all: runs stay uncontained even if one is already on disk",
545
+ )
546
+ parser.add_argument(
547
+ "--author-key-file",
548
+ dest="author_key_file",
549
+ help="path to an existing file holding the author's model API key",
550
+ )
246
551
  parser.add_argument(
247
552
  "--yes", "-y", action="store_true", help="non-interactive: use flags, do not prompt"
248
553
  )
@@ -256,8 +561,9 @@ def main(argv: list[str] | None = None) -> int:
256
561
  if not answers.target:
257
562
  print("outerloop init: a target repo is required (--target owner/repo)", file=sys.stderr)
258
563
  return 2
259
- if answers.compute == "slurm" and not (answers.root and answers.account):
260
- print("outerloop init: Slurm needs --root and --account", file=sys.stderr)
564
+ if answers.compute == "slurm" and not answers.root:
565
+ # the account and the partition are optional: Slurm's defaults apply
566
+ print("outerloop init: Slurm needs --root", file=sys.stderr)
261
567
  return 2
262
568
  if answers.author_backend and answers.author_backend not in AUTHOR_BACKENDS:
263
569
  print(
@@ -265,6 +571,10 @@ def main(argv: list[str] | None = None) -> int:
265
571
  file=sys.stderr,
266
572
  )
267
573
  return 2
574
+ if answers.image and not Path(answers.image).is_file():
575
+ # a typo here would surface as an uncontained loop later
576
+ print(f"outerloop init: --image {answers.image} is not a file", file=sys.stderr)
577
+ return 2
268
578
 
269
579
  # Never clobber a working setup silently: a re-run of init on a configured
270
580
  # machine must ask (or be told --force). Checked before any App is created.
@@ -280,6 +590,39 @@ def main(argv: list[str] | None = None) -> int:
280
590
  )
281
591
  return 1
282
592
 
593
+ # The author's model key: an existing file by flag, or (on the full
594
+ # interactive run, never the focused --github-app one) a hidden paste written
595
+ # to <config>/<backend>_key. Collected after the overwrite check so a
596
+ # declined run never asks for a secret.
597
+ if args.author_key_file:
598
+ key_file = Path(args.author_key_file).expanduser()
599
+ if not key_file.is_file() or not os.access(key_file, os.R_OK):
600
+ print(
601
+ f"outerloop init: --author-key-file {key_file} is not a readable file",
602
+ file=sys.stderr,
603
+ )
604
+ return 2
605
+ # absolute: climb jobs read the key from their own flight directory
606
+ answers.author_key_file = str(key_file.absolute())
607
+ if not answers.author_key_file and interactive and not args.github_app:
608
+ pasted = getpass.getpass(
609
+ f"API key for the {answers.author_backend or AUTHOR_BACKENDS[0]} author "
610
+ "(hidden; blank to set later): "
611
+ ).strip()
612
+ if pasted:
613
+ key_path = write_author_key(answers.author_backend, pasted, config_dir=CONFIG_DIR)
614
+ answers.author_key_file = str(key_path)
615
+ print(f"wrote {key_path} (0600)")
616
+
617
+ # The image, only now: every check that could still end the run has passed
618
+ # and the overwrite question is answered, so a download is never wasted.
619
+ # An existing image is used, else the published one is fetched on a machine
620
+ # that can run it (asked first when interactive); --no-image opts out.
621
+ if not answers.image and not args.no_image:
622
+ # the container probe is for local mode: on Slurm the image runs on
623
+ # compute nodes, which the login node cannot speak for
624
+ answers.image = ensure_image(interactive=interactive, probe=(answers.compute == "local"))
625
+
283
626
  # The App is the recommended credential (scoped, revocable, no plaintext
284
627
  # token); the PAT is the fallback. Offer it first when interactive.
285
628
  if not args.github_app and not pat_file and interactive:
@@ -290,7 +633,20 @@ def main(argv: list[str] | None = None) -> int:
290
633
  if choice.startswith("a"):
291
634
  args.github_app = True
292
635
  if args.github_app:
293
- return _github_app_setup(answers, args.app_name or "", args.org or "")
636
+ org = args.org or ""
637
+ if not org and interactive:
638
+ # a private App installs only on the account that created it, so
639
+ # the target's owner is the only default that can work
640
+ owner = answers.target.split("/")[0]
641
+ org = _ask(ORG_PROMPT, owner).strip()
642
+ if org:
643
+ kind = _owner_type(org)
644
+ if not kind and interactive:
645
+ answer = _ask(f"Is '{org}' an organization or a user account? [org/user]", "org")
646
+ kind = "User" if answer.strip().lower().startswith("u") else "Organization"
647
+ if kind == "User":
648
+ org = "" # a user account: GitHub's personal App page, not an org page
649
+ return _github_app_setup(answers, args.app_name or "", org, interactive=interactive)
294
650
 
295
651
  token = ""
296
652
  if not pat_file and interactive:
@@ -306,8 +662,40 @@ def main(argv: list[str] | None = None) -> int:
306
662
  effective_pat = str(pat_path) if pat_path else pat_file
307
663
  if effective_pat:
308
664
  problem = validate_pat(effective_pat, answers.target)
665
+ if _auth_is_fatal(problem):
666
+ # the loop opens pull requests with this token; sending the adopter
667
+ # to `start` would fail later and further from the cause
668
+ print(f" auth check: FAILED — {problem}", file=sys.stderr)
669
+ print(
670
+ f"outerloop init: fix the token in {effective_pat} (write access to "
671
+ f"{answers.target}), then run `outerloop init --force`",
672
+ file=sys.stderr,
673
+ )
674
+ return 1
309
675
  print(f" auth check: {'ok' if not problem else 'WARNING — ' + problem}")
676
+ if not problem:
677
+ # record the login the kernel will post as: every own-comment and
678
+ # own-PR filter keys on it, and a default that is not this token's
679
+ # identity would hide the adopter's own PRs from the kernel
680
+ try:
681
+ login = _token_login(Path(effective_pat).expanduser().read_text().strip())
682
+ except OSError:
683
+ login = ""
684
+ if login:
685
+ write_private(env_path, render_env(answers, effective_pat, bot_login=login))
686
+ print(f" posting as {login} (OUTERLOOP_BOT_LOGIN)")
310
687
  else:
311
688
  print(" no PAT set — add OUTERLOOP_PAT_FILE before the agents can open PRs")
689
+ _author_key_hint(answers)
690
+ _harness_hint(answers)
312
691
  print("next: outerloop start")
313
692
  return 0
693
+
694
+
695
+ def _author_key_hint(answers: InitAnswers) -> None:
696
+ if not answers.author_key_file:
697
+ print(
698
+ " no author key set — put it in "
699
+ f"{author_key_path(answers.author_backend, config_dir=CONFIG_DIR)} "
700
+ "before the first climb"
701
+ )
outerloop/limits.py CHANGED
@@ -26,7 +26,7 @@ from typing import Any
26
26
  # work — session budgets sized for solver tweaks starve construction work.
27
27
  # floor = session floor + overhead + self-deadline margin: even at the
28
28
  # floors, a session must fit inside its job with the ending's runway.
29
- # Public: the tick's AUTORESEARCH_MAX_JOB_MINUTES knob floors here too.
29
+ # Public: the tick's OUTERLOOP_MAX_JOB_MINUTES knob floors here too.
30
30
  ATTEMPT_JOB_MINUTES_FLOOR = 40
31
31
 
32
32
  # Public: the tick shrinks a capped job's session with the same floor the
outerloop/measure.py CHANGED
@@ -278,7 +278,7 @@ class DispatchedMeasurer:
278
278
  if not self.gpu_partition:
279
279
  raise ValueError(
280
280
  f"measure {m.name} needs {m.gpus} GPU(s) but no GPU lane is configured "
281
- "(set AUTORESEARCH_GPU_PARTITION)"
281
+ "(set OUTERLOOP_GPU_PARTITION)"
282
282
  )
283
283
  return self.gpu_account or self.account, self.gpu_partition
284
284
 
@@ -494,7 +494,7 @@ class DispatchSettings:
494
494
  if not self.gpu_partition:
495
495
  raise ValueError(
496
496
  f"benchmark needs {gpus} GPU(s) but no GPU lane is configured "
497
- "(set AUTORESEARCH_GPU_PARTITION)"
497
+ "(set OUTERLOOP_GPU_PARTITION)"
498
498
  )
499
499
  return self.gpu_account or self.account, self.gpu_partition
500
500
 
outerloop/orchestrator.py CHANGED
@@ -361,7 +361,7 @@ class SubprocessEvaluator:
361
361
  )
362
362
  except OSError as exc:
363
363
  raise EvalError(f"could not create check home: {exc}") from exc
364
- cache_dir = Path(tempfile.mkdtemp(prefix="autoresearch-check-cache-"))
364
+ cache_dir = Path(tempfile.mkdtemp(prefix="outerloop-check-cache-"))
365
365
  try:
366
366
  self._run(workspace, command, eval_home, cache_dir)
367
367
  finally: