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,18 @@
1
+ """Outerloop: autonomous research agents that improve a benchmark on your own code."""
2
+
3
+ import os
4
+
5
+ __version__ = "0.1.0"
6
+
7
+
8
+ def _bridge_legacy_env() -> None:
9
+ """Accept the pre-rename `AUTORESEARCH_*` names at the process boundary for
10
+ one release: copy each into its `OUTERLOOP_*` twin when the twin is unset,
11
+ so the kernel reads one name. The chain scripts do the same in shell.
12
+ Removed in the release after 0.1."""
13
+ for key, value in list(os.environ.items()):
14
+ if key.startswith("AUTORESEARCH_"):
15
+ os.environ.setdefault("OUTERLOOP_" + key[len("AUTORESEARCH_") :], value)
16
+
17
+
18
+ _bridge_legacy_env()
outerloop/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from outerloop.cli import main
2
+
3
+ raise SystemExit(main())
outerloop/appauth.py ADDED
@@ -0,0 +1,230 @@
1
+ """GitHub App installation-token auth (docs/design/github-app-auth.md).
2
+
3
+ `AppInstallationTokenProvider` satisfies the `TokenProvider` protocol used
4
+ throughout `github.py`. Role CLIs construct bot auth through
5
+ `resolve_bot_auth`, which selects this provider when an App config file is
6
+ given (`--github-app-file` / `OUTERLOOP_GITHUB_APP_FILE`) and falls back
7
+ to the PAT file otherwise — the cutover flag, revertible by unsetting the
8
+ env. Each `token()` mints a short-lived JWT (RS256, signed by the App
9
+ private key), exchanges it for a ~1h installation token scoped to the
10
+ installation's repos, and caches that token until a refresh margin before
11
+ it expires.
12
+
13
+ The RS256 signer and the HTTP transport are injected so the provider — and
14
+ its tests — build and run without the `cryptography` dependency or a
15
+ network; the production signer lives behind the `app-auth` extra.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import base64
22
+ import json
23
+ import os
24
+ import time
25
+ import urllib.error
26
+ import urllib.parse
27
+ import urllib.request
28
+ from collections.abc import Callable
29
+ from datetime import datetime
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ from outerloop.github import AUTH_SAFE_OPENER, FileTokenProvider, TokenProvider
34
+ from outerloop.paths import CONFIG_DIR
35
+
36
+ # RS256-sign the JWT signing input, returning the raw signature bytes.
37
+ Signer = Callable[[bytes], bytes]
38
+ # Perform the token-exchange POST and return the parsed JSON body.
39
+ Transport = Callable[[urllib.request.Request], Any]
40
+
41
+ API = "https://api.github.com"
42
+ # GitHub caps App JWT lifetime at 10 minutes; stay comfortably under it.
43
+ _JWT_TTL_S = 9 * 60
44
+ # Backdate `iat` to tolerate clock skew between us and GitHub.
45
+ _JWT_BACKDATE_S = 60
46
+ # Re-mint the installation token this long before it actually expires, so a
47
+ # token is never handed out on the edge of expiry.
48
+ _REFRESH_MARGIN_S = 5 * 60
49
+
50
+ # Every installation token minted by this process, whichever provider
51
+ # instance minted it. `redact` appends these to its snapshotted secrets
52
+ # tuple at write time, so a token minted after a call site captured its
53
+ # tuple still never reaches a report, record, or log.
54
+ _ISSUED_TOKENS: list[str] = []
55
+
56
+
57
+ def issued_tokens() -> tuple[str, ...]:
58
+ """All installation tokens minted this process, for write-time redaction."""
59
+ return tuple(_ISSUED_TOKENS)
60
+
61
+
62
+ def _b64url(data: bytes) -> str:
63
+ """URL-safe base64 without padding — the JWT wire encoding."""
64
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
65
+
66
+
67
+ def build_app_jwt(app_id: int, now: float, sign: Signer) -> str:
68
+ """A GitHub App JWT: header.payload.signature, RS256 over the first two."""
69
+ header = _b64url(json.dumps({"alg": "RS256", "typ": "JWT"}).encode())
70
+ payload = _b64url(
71
+ json.dumps(
72
+ {
73
+ "iat": int(now) - _JWT_BACKDATE_S,
74
+ "exp": int(now) + _JWT_TTL_S,
75
+ "iss": str(app_id),
76
+ }
77
+ ).encode()
78
+ )
79
+ signing_input = f"{header}.{payload}"
80
+ signature = _b64url(sign(signing_input.encode("ascii")))
81
+ return f"{signing_input}.{signature}"
82
+
83
+
84
+ def _parse_expiry(expires_at: str) -> float:
85
+ """`2026-09-01T12:00:00Z` (or with an offset) -> unix seconds."""
86
+ text = expires_at.strip()
87
+ if text.endswith("Z"):
88
+ text = text[:-1] + "+00:00"
89
+ return datetime.fromisoformat(text).timestamp()
90
+
91
+
92
+ def _default_transport(request: urllib.request.Request) -> Any:
93
+ # the shared opener: a cross-host redirect must not forward the App JWT
94
+ try:
95
+ with AUTH_SAFE_OPENER.open(request, timeout=30) as response:
96
+ payload = response.read()
97
+ except urllib.error.HTTPError as exc:
98
+ body = exc.read().decode(errors="replace")[:500]
99
+ raise ValueError(f"installation-token exchange failed ({exc.code}): {body}") from None
100
+ except urllib.error.URLError as exc:
101
+ raise ValueError(f"installation-token exchange failed: {exc.reason}") from None
102
+ return json.loads(payload) if payload else None
103
+
104
+
105
+ class AppInstallationTokenProvider:
106
+ """A `TokenProvider` minting cached, short-lived installation tokens."""
107
+
108
+ def __init__(
109
+ self,
110
+ app_id: int,
111
+ installation_id: int,
112
+ sign: Signer,
113
+ *,
114
+ transport: Transport | None = None,
115
+ now: Callable[[], float] = time.time,
116
+ ) -> None:
117
+ self.app_id = app_id
118
+ self.installation_id = installation_id
119
+ self._sign = sign
120
+ self._transport = transport or _default_transport
121
+ self._now = now
122
+ self._token = ""
123
+ self._expiry = 0.0
124
+ # every token ever minted this process — the PAT was one immortal
125
+ # string, but these rotate ~hourly, so a redaction set snapshotted at
126
+ # construction goes stale; redaction must pull issued() at write time
127
+ self._issued: list[str] = []
128
+
129
+ def token(self) -> str:
130
+ now = self._now()
131
+ if self._token and now < self._expiry - _REFRESH_MARGIN_S:
132
+ return self._token
133
+ jwt = build_app_jwt(self.app_id, now, self._sign)
134
+ request = urllib.request.Request(
135
+ f"{API}/app/installations/{self.installation_id}/access_tokens",
136
+ method="POST",
137
+ headers={
138
+ "Authorization": f"Bearer {jwt}",
139
+ "Accept": "application/vnd.github+json",
140
+ "X-GitHub-Api-Version": "2022-11-28",
141
+ },
142
+ )
143
+ body = self._transport(request)
144
+ if not isinstance(body, dict) or not body.get("token"):
145
+ raise ValueError("installation-token response missing a token")
146
+ self._token = str(body["token"])
147
+ self._issued.append(self._token)
148
+ _ISSUED_TOKENS.append(self._token)
149
+ expires_at = body.get("expires_at")
150
+ # a missing/garbled expiry is treated as immediate — safe: we simply
151
+ # re-mint on every call rather than trust an unknown lifetime
152
+ try:
153
+ self._expiry = _parse_expiry(str(expires_at)) if expires_at else now
154
+ except ValueError:
155
+ self._expiry = now
156
+ return self._token
157
+
158
+ def issued(self) -> tuple[str, ...]:
159
+ """Every token this provider has minted, for redaction sets built at
160
+ write time — a set snapshotted at construction misses refreshes."""
161
+ return tuple(self._issued)
162
+
163
+
164
+ def signer_from_private_key(pem_path: Path) -> Signer:
165
+ """The production RS256 signer, built from the App private-key PEM. Lazily
166
+ imports `cryptography` (the `app-auth` extra) so the module and its tests
167
+ do not depend on it; the key file must be owner-only (chmod 600), the same
168
+ custody the PAT file has."""
169
+ if not pem_path.is_file():
170
+ raise ValueError(f"{pem_path} is not a readable private-key file")
171
+ if pem_path.stat().st_mode & 0o077:
172
+ raise PermissionError(f"{pem_path} is group/world accessible; chmod 600 it")
173
+ try:
174
+ from cryptography.hazmat.primitives import hashes, serialization
175
+ from cryptography.hazmat.primitives.asymmetric import padding, rsa
176
+ except ImportError as exc: # pragma: no cover - exercised only without the extra
177
+ raise ImportError(
178
+ "GitHub App auth needs the cryptography package "
179
+ "(pip install cryptography), a base dependency since 0.1.0.dev2"
180
+ ) from exc
181
+
182
+ key = serialization.load_pem_private_key(pem_path.read_bytes(), password=None)
183
+ if not isinstance(key, rsa.RSAPrivateKey):
184
+ raise ValueError(f"{pem_path} is not an RSA key; GitHub App keys are RSA")
185
+
186
+ def sign(message: bytes) -> bytes:
187
+ return key.sign(message, padding.PKCS1v15(), hashes.SHA256())
188
+
189
+ return sign
190
+
191
+
192
+ def app_provider_from_file(app_file: Path) -> AppInstallationTokenProvider:
193
+ """Build the provider from the App config file: JSON with `app_id`,
194
+ `installation_id` (integers), and `private_key` (path to the PEM). The
195
+ ids are not secrets; the file exists so the whole App identity travels
196
+ as one path on the same rails the PAT path already rides."""
197
+ try:
198
+ config = json.loads(app_file.read_text())
199
+ except (OSError, ValueError) as exc:
200
+ raise ValueError(f"cannot read App config {app_file}: {exc}") from None
201
+ missing = [k for k in ("app_id", "installation_id", "private_key") if not config.get(k)]
202
+ if missing:
203
+ raise ValueError(f"App config {app_file} is missing {', '.join(missing)}")
204
+ return AppInstallationTokenProvider(
205
+ int(config["app_id"]),
206
+ int(config["installation_id"]),
207
+ signer_from_private_key(Path(str(config["private_key"])).expanduser()),
208
+ )
209
+
210
+
211
+ def resolve_bot_auth(pat_file: str | Path, app_file: str | Path = "") -> TokenProvider:
212
+ """The one seam every role CLI constructs bot auth through: the App
213
+ provider when an App config file is given, the PAT file otherwise."""
214
+ if str(app_file).strip():
215
+ return app_provider_from_file(Path(str(app_file)).expanduser())
216
+ return FileTokenProvider(Path(str(pat_file)).expanduser())
217
+
218
+
219
+ def add_credential_args(parser: argparse.ArgumentParser) -> None:
220
+ """Add the shared bot-auth options to a role parser: `--pat-file`, and
221
+ `--github-app-file`, which supplies installation tokens instead of the PAT
222
+ when set. `resolve_bot_auth` reads the pair. One owner so the defaults and
223
+ help cannot drift between attempt, followup, and steward."""
224
+ parser.add_argument("--pat-file", default=str(CONFIG_DIR / "bot_pat"))
225
+ parser.add_argument(
226
+ "--github-app-file",
227
+ default=os.environ.get("OUTERLOOP_GITHUB_APP_FILE", ""),
228
+ help="GitHub App config (JSON: app_id, installation_id, private_key); "
229
+ "when set, installation tokens replace the PAT",
230
+ )
@@ -0,0 +1,203 @@
1
+ """GitHub App Manifest flow for `outerloop init --github-app`.
2
+
3
+ Creates the adopter's OWN GitHub App in one click instead of a hand-rolled PAT.
4
+ Because the bot runs on the adopter's own compute (self-hosted), the App's
5
+ private key must live there — a shared App would mean either we run the fleet or
6
+ we hand out a master key — so each adopter owns their App. GitHub's manifest flow
7
+ makes that a click: a pre-filled create page, then a code we exchange for the key.
8
+
9
+ The flow is paste-based and hostless on the adopter's side: init prints ONE URL
10
+ to the hosted helper page (`setup.outerloop.science`), which carries the
11
+ manifest in its URL *fragment* (never sent to any server). The adopter opens it
12
+ in any browser — laptop or, for a headless cluster, anywhere — clicks Create,
13
+ and GitHub redirects back to that page with a one-time code the page displays.
14
+ The adopter pastes the code here; we exchange it for the app id + key, write
15
+ `github_app.<slug>.json` + the PEM (both 0600), help install the App, and capture
16
+ the installation id. The written files are what `resolve_bot_auth` reads via
17
+ `OUTERLOOP_GITHUB_APP_FILE`, the same path `outerloop start` uses.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import base64
23
+ import json
24
+ import os
25
+ import secrets
26
+ import time
27
+ import urllib.error
28
+ import urllib.parse
29
+ import urllib.request
30
+ from collections.abc import Callable
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ from outerloop.appauth import API, build_app_jwt, signer_from_private_key
35
+ from outerloop.paths import write_private
36
+
37
+ # The hosted helper page: it auto-POSTs the manifest to GitHub, then displays the
38
+ # returned code. It is also the manifest's redirect_url, so GitHub sends the code
39
+ # straight back to it. Its own subdomain (the apex is the landing page), served
40
+ # from web/ in this repo so the page and the CLI stay versioned together.
41
+ # `OUTERLOOP_SETUP_URL` overrides it — a staging copy, or a self-hoster's own.
42
+ SETUP_URL = os.environ.get("OUTERLOOP_SETUP_URL") or "https://setup.outerloop.science"
43
+
44
+ # The App's fine-grained permissions — exactly what the fleet exercises: contents
45
+ # to push commits, pull_requests to open/label PRs, issues for the courtesy note
46
+ # on the requesting issue. Nothing else (least privilege; matches the live bot).
47
+ DEFAULT_PERMISSIONS = {
48
+ "contents": "write",
49
+ "issues": "write",
50
+ "metadata": "read",
51
+ "pull_requests": "write",
52
+ }
53
+
54
+ # The POST/GET transport returns parsed JSON; injected so the pure logic tests
55
+ # without a network.
56
+ Transport = Callable[[urllib.request.Request], Any]
57
+
58
+
59
+ def build_manifest(name: str, url: str, redirect_url: str = SETUP_URL) -> dict:
60
+ """The manifest GitHub creates the App from: our permissions, no webhook,
61
+ installable only on the creating account (`public=false`). `redirect_url` is
62
+ where GitHub returns the code — the hosted helper page by default."""
63
+ return {
64
+ "name": name,
65
+ "url": url,
66
+ "redirect_url": redirect_url,
67
+ "public": False,
68
+ "default_permissions": dict(DEFAULT_PERMISSIONS),
69
+ "hook_attributes": {"active": False, "url": url},
70
+ }
71
+
72
+
73
+ def build_setup_url(manifest: dict, state: str, org: str = "") -> str:
74
+ """The one URL the adopter opens: the hosted helper page with the manifest,
75
+ state, and org packed into the URL *fragment* (base64) — a fragment stays in
76
+ the browser and is never sent to any server, so the manifest isn't logged."""
77
+ payload = json.dumps({"manifest": manifest, "state": state, "org": org})
78
+ encoded = base64.urlsafe_b64encode(payload.encode()).decode()
79
+ return f"{SETUP_URL}#{encoded}"
80
+
81
+
82
+ def request_manifest_code(
83
+ name: str,
84
+ homepage_url: str,
85
+ org: str = "",
86
+ *,
87
+ print_fn: Callable[[str], None] = print,
88
+ input_fn: Callable[[str], str] = input,
89
+ ) -> str:
90
+ """Print the setup URL and read back the code the hosted page shows. Works
91
+ anywhere a browser can reach the internet — no localhost, no callback."""
92
+ state = secrets.token_urlsafe(16)
93
+ manifest = build_manifest(name, homepage_url)
94
+ url = build_setup_url(manifest, state, org)
95
+ where = f"the {org} organization" if org else "your account"
96
+ print_fn("Step 1 of 3: create the GitHub App.")
97
+ print_fn(" Open this link in a browser where you are signed in to GitHub (any machine):")
98
+ print_fn(f"\n {url}\n")
99
+ print_fn(f" GitHub shows a page titled 'Create GitHub App' under {where}. Click the")
100
+ host = urllib.parse.urlsplit(SETUP_URL).netloc or SETUP_URL
101
+ print_fn(" green 'Create GitHub App' button. GitHub then sends you to")
102
+ print_fn(f" {host}, which shows a one-time code.")
103
+ return input_fn("Paste that code here: ").strip()
104
+
105
+
106
+ def _default_transport(request: urllib.request.Request) -> Any:
107
+ with urllib.request.urlopen(request, timeout=30) as resp:
108
+ payload = resp.read()
109
+ return json.loads(payload) if payload else None
110
+
111
+
112
+ def convert_manifest(code: str, *, transport: Transport | None = None) -> dict:
113
+ """Exchange the one-time manifest `code` for the App's credentials (id, pem,
114
+ slug, ...). GitHub invalidates the code on first use and after ~1h."""
115
+ transport = transport or _default_transport
116
+ request = urllib.request.Request(
117
+ f"{API}/app-manifests/{code}/conversions",
118
+ method="POST",
119
+ headers={
120
+ "Accept": "application/vnd.github+json",
121
+ "X-GitHub-Api-Version": "2022-11-28",
122
+ },
123
+ )
124
+ try:
125
+ body = transport(request)
126
+ except urllib.error.HTTPError as exc:
127
+ detail = exc.read().decode(errors="replace")[:200]
128
+ raise ValueError(f"manifest conversion failed ({exc.code}): {detail}") from None
129
+ if not isinstance(body, dict):
130
+ raise ValueError("manifest conversion returned no object")
131
+ missing = [k for k in ("id", "pem", "slug") if not body.get(k)]
132
+ if missing:
133
+ raise ValueError(f"manifest conversion missing {', '.join(missing)}")
134
+ return body
135
+
136
+
137
+ def save_app_creds(
138
+ conversion: dict, config_dir: Path, installation_id: int = 0
139
+ ) -> tuple[Path, Path]:
140
+ """Write the PEM (0600) and `github_app.<slug>.json` pointing at it, both
141
+ owner-only. `installation_id` stays 0 until the App is installed and
142
+ `capture_installation_id` fills it. Returns (pem_path, app_json_path)."""
143
+ config_dir.mkdir(parents=True, exist_ok=True)
144
+ slug = str(conversion["slug"])
145
+ pem_path = config_dir / f"{slug}-app.pem"
146
+ write_private(pem_path, str(conversion["pem"]))
147
+ app_json = config_dir / f"github_app.{slug}.json"
148
+ write_private(
149
+ app_json,
150
+ json.dumps(
151
+ {
152
+ "app_id": int(conversion["id"]),
153
+ "installation_id": int(installation_id),
154
+ "private_key": str(pem_path),
155
+ },
156
+ indent=2,
157
+ )
158
+ + "\n",
159
+ )
160
+ return pem_path, app_json
161
+
162
+
163
+ def install_url(conversion: dict) -> str:
164
+ """Where the adopter installs the freshly created App on their repos."""
165
+ return f"https://github.com/apps/{conversion['slug']}/installations/new"
166
+
167
+
168
+ def capture_installation_id(
169
+ app_id: int,
170
+ pem_path: Path,
171
+ owner: str = "",
172
+ *,
173
+ transport: Transport | None = None,
174
+ now: Callable[[], float] = time.time,
175
+ ) -> int:
176
+ """After the App is installed, its installation id for `owner` (or the sole
177
+ installation when `owner` is blank). App JWT → GET /app/installations. 0 when
178
+ nothing is installed yet — the caller retries after the adopter installs."""
179
+ transport = transport or _default_transport
180
+ sign = signer_from_private_key(pem_path)
181
+ jwt = build_app_jwt(app_id, now(), sign)
182
+ request = urllib.request.Request(
183
+ f"{API}/app/installations",
184
+ headers={
185
+ "Authorization": f"Bearer {jwt}",
186
+ "Accept": "application/vnd.github+json",
187
+ "X-GitHub-Api-Version": "2022-11-28",
188
+ },
189
+ )
190
+ installs = transport(request) or []
191
+ if owner:
192
+ for inst in installs:
193
+ if str((inst.get("account") or {}).get("login", "")).lower() == owner.lower():
194
+ return int(inst["id"])
195
+ return 0
196
+ return int(installs[0]["id"]) if installs else 0
197
+
198
+
199
+ def set_installation_id(app_json: Path, installation_id: int) -> None:
200
+ """Fill the installation id into an already-written github_app.<slug>.json."""
201
+ data = json.loads(app_json.read_text())
202
+ data["installation_id"] = int(installation_id)
203
+ write_private(app_json, json.dumps(data, indent=2) + "\n")