outerloop-science 0.1.0.dev0__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 (52) hide show
  1. outerloop/__init__.py +18 -0
  2. outerloop/__main__.py +3 -0
  3. outerloop/appauth.py +213 -0
  4. outerloop/appmanifest.py +198 -0
  5. outerloop/attempt.py +3481 -0
  6. outerloop/brief.py +515 -0
  7. outerloop/cli.py +439 -0
  8. outerloop/climbboard.py +1145 -0
  9. outerloop/compute.py +482 -0
  10. outerloop/contract.py +483 -0
  11. outerloop/contract_cli.py +63 -0
  12. outerloop/disk.py +164 -0
  13. outerloop/dispatch.py +586 -0
  14. outerloop/followup.py +2143 -0
  15. outerloop/github.py +1486 -0
  16. outerloop/harness.py +1449 -0
  17. outerloop/housekeeping.py +167 -0
  18. outerloop/init.py +313 -0
  19. outerloop/intake.py +129 -0
  20. outerloop/limits.py +80 -0
  21. outerloop/markers.py +48 -0
  22. outerloop/measure.py +523 -0
  23. outerloop/orchestrator.py +1901 -0
  24. outerloop/panel.py +188 -0
  25. outerloop/paths.py +27 -0
  26. outerloop/posting.py +160 -0
  27. outerloop/progress.py +170 -0
  28. outerloop/py.typed +0 -0
  29. outerloop/review.py +611 -0
  30. outerloop/review_agent.py +263 -0
  31. outerloop/review_agent_cli.py +209 -0
  32. outerloop/review_post_cli.py +162 -0
  33. outerloop/review_summarize_cli.py +163 -0
  34. outerloop/role_runner.py +229 -0
  35. outerloop/roles.py +247 -0
  36. outerloop/rolespec.py +89 -0
  37. outerloop/runstate.py +385 -0
  38. outerloop/steward.py +852 -0
  39. outerloop/style.py +12 -0
  40. outerloop/syscall.py +977 -0
  41. outerloop/syscall_cli.py +531 -0
  42. outerloop/tick.py +3166 -0
  43. outerloop/verifier.py +403 -0
  44. outerloop/verify_agent.py +149 -0
  45. outerloop/verify_agent_cli.py +95 -0
  46. outerloop/verify_post_cli.py +116 -0
  47. outerloop_science-0.1.0.dev0.dist-info/METADATA +145 -0
  48. outerloop_science-0.1.0.dev0.dist-info/RECORD +52 -0
  49. outerloop_science-0.1.0.dev0.dist-info/WHEEL +4 -0
  50. outerloop_science-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  51. outerloop_science-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
  52. outerloop_science-0.1.0.dev0.dist-info/licenses/NOTICE +5 -0
outerloop/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """Autonomous research agent that co-develops the lab's benchmark-bearing repos."""
2
+
3
+ import os
4
+
5
+ __version__ = "0.1.0.dev0"
6
+
7
+
8
+ def _bridge_legacy_env() -> None:
9
+ """Accept the new `OUTERLOOP_*` names at the process boundary: copy each into
10
+ its `AUTORESEARCH_*` twin when the twin is unset, so the kernel's internals —
11
+ which still read `AUTORESEARCH_*` during the rename — see one name. The chain
12
+ scripts do the same in shell. Removed with the final internal flip."""
13
+ for key, value in list(os.environ.items()):
14
+ if key.startswith("OUTERLOOP_"):
15
+ os.environ.setdefault("AUTORESEARCH_" + key[len("OUTERLOOP_") :], 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,213 @@
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` / `AUTORESEARCH_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 base64
21
+ import json
22
+ import time
23
+ import urllib.error
24
+ import urllib.parse
25
+ import urllib.request
26
+ from collections.abc import Callable
27
+ from datetime import datetime
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ from outerloop.github import AUTH_SAFE_OPENER, FileTokenProvider, TokenProvider
32
+
33
+ # RS256-sign the JWT signing input, returning the raw signature bytes.
34
+ Signer = Callable[[bytes], bytes]
35
+ # Perform the token-exchange POST and return the parsed JSON body.
36
+ Transport = Callable[[urllib.request.Request], Any]
37
+
38
+ API = "https://api.github.com"
39
+ # GitHub caps App JWT lifetime at 10 minutes; stay comfortably under it.
40
+ _JWT_TTL_S = 9 * 60
41
+ # Backdate `iat` to tolerate clock skew between us and GitHub.
42
+ _JWT_BACKDATE_S = 60
43
+ # Re-mint the installation token this long before it actually expires, so a
44
+ # token is never handed out on the edge of expiry.
45
+ _REFRESH_MARGIN_S = 5 * 60
46
+
47
+ # Every installation token minted by this process, whichever provider
48
+ # instance minted it. `redact` appends these to its snapshotted secrets
49
+ # tuple at write time, so a token minted after a call site captured its
50
+ # tuple still never reaches a report, record, or log.
51
+ _ISSUED_TOKENS: list[str] = []
52
+
53
+
54
+ def issued_tokens() -> tuple[str, ...]:
55
+ """All installation tokens minted this process, for write-time redaction."""
56
+ return tuple(_ISSUED_TOKENS)
57
+
58
+
59
+ def _b64url(data: bytes) -> str:
60
+ """URL-safe base64 without padding — the JWT wire encoding."""
61
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
62
+
63
+
64
+ def build_app_jwt(app_id: int, now: float, sign: Signer) -> str:
65
+ """A GitHub App JWT: header.payload.signature, RS256 over the first two."""
66
+ header = _b64url(json.dumps({"alg": "RS256", "typ": "JWT"}).encode())
67
+ payload = _b64url(
68
+ json.dumps(
69
+ {
70
+ "iat": int(now) - _JWT_BACKDATE_S,
71
+ "exp": int(now) + _JWT_TTL_S,
72
+ "iss": str(app_id),
73
+ }
74
+ ).encode()
75
+ )
76
+ signing_input = f"{header}.{payload}"
77
+ signature = _b64url(sign(signing_input.encode("ascii")))
78
+ return f"{signing_input}.{signature}"
79
+
80
+
81
+ def _parse_expiry(expires_at: str) -> float:
82
+ """`2026-09-01T12:00:00Z` (or with an offset) -> unix seconds."""
83
+ text = expires_at.strip()
84
+ if text.endswith("Z"):
85
+ text = text[:-1] + "+00:00"
86
+ return datetime.fromisoformat(text).timestamp()
87
+
88
+
89
+ def _default_transport(request: urllib.request.Request) -> Any:
90
+ # the shared opener: a cross-host redirect must not forward the App JWT
91
+ try:
92
+ with AUTH_SAFE_OPENER.open(request, timeout=30) as response:
93
+ payload = response.read()
94
+ except urllib.error.HTTPError as exc:
95
+ body = exc.read().decode(errors="replace")[:500]
96
+ raise ValueError(f"installation-token exchange failed ({exc.code}): {body}") from None
97
+ except urllib.error.URLError as exc:
98
+ raise ValueError(f"installation-token exchange failed: {exc.reason}") from None
99
+ return json.loads(payload) if payload else None
100
+
101
+
102
+ class AppInstallationTokenProvider:
103
+ """A `TokenProvider` minting cached, short-lived installation tokens."""
104
+
105
+ def __init__(
106
+ self,
107
+ app_id: int,
108
+ installation_id: int,
109
+ sign: Signer,
110
+ *,
111
+ transport: Transport | None = None,
112
+ now: Callable[[], float] = time.time,
113
+ ) -> None:
114
+ self.app_id = app_id
115
+ self.installation_id = installation_id
116
+ self._sign = sign
117
+ self._transport = transport or _default_transport
118
+ self._now = now
119
+ self._token = ""
120
+ self._expiry = 0.0
121
+ # every token ever minted this process — the PAT was one immortal
122
+ # string, but these rotate ~hourly, so a redaction set snapshotted at
123
+ # construction goes stale; redaction must pull issued() at write time
124
+ self._issued: list[str] = []
125
+
126
+ def token(self) -> str:
127
+ now = self._now()
128
+ if self._token and now < self._expiry - _REFRESH_MARGIN_S:
129
+ return self._token
130
+ jwt = build_app_jwt(self.app_id, now, self._sign)
131
+ request = urllib.request.Request(
132
+ f"{API}/app/installations/{self.installation_id}/access_tokens",
133
+ method="POST",
134
+ headers={
135
+ "Authorization": f"Bearer {jwt}",
136
+ "Accept": "application/vnd.github+json",
137
+ "X-GitHub-Api-Version": "2022-11-28",
138
+ },
139
+ )
140
+ body = self._transport(request)
141
+ if not isinstance(body, dict) or not body.get("token"):
142
+ raise ValueError("installation-token response missing a token")
143
+ self._token = str(body["token"])
144
+ self._issued.append(self._token)
145
+ _ISSUED_TOKENS.append(self._token)
146
+ expires_at = body.get("expires_at")
147
+ # a missing/garbled expiry is treated as immediate — safe: we simply
148
+ # re-mint on every call rather than trust an unknown lifetime
149
+ try:
150
+ self._expiry = _parse_expiry(str(expires_at)) if expires_at else now
151
+ except ValueError:
152
+ self._expiry = now
153
+ return self._token
154
+
155
+ def issued(self) -> tuple[str, ...]:
156
+ """Every token this provider has minted, for redaction sets built at
157
+ write time — a set snapshotted at construction misses refreshes."""
158
+ return tuple(self._issued)
159
+
160
+
161
+ def signer_from_private_key(pem_path: Path) -> Signer:
162
+ """The production RS256 signer, built from the App private-key PEM. Lazily
163
+ imports `cryptography` (the `app-auth` extra) so the module and its tests
164
+ do not depend on it; the key file must be owner-only (chmod 600), the same
165
+ custody the PAT file has."""
166
+ if not pem_path.is_file():
167
+ raise ValueError(f"{pem_path} is not a readable private-key file")
168
+ if pem_path.stat().st_mode & 0o077:
169
+ raise PermissionError(f"{pem_path} is group/world accessible; chmod 600 it")
170
+ try:
171
+ from cryptography.hazmat.primitives import hashes, serialization
172
+ from cryptography.hazmat.primitives.asymmetric import padding, rsa
173
+ except ImportError as exc: # pragma: no cover - exercised only without the extra
174
+ raise ImportError(
175
+ "GitHub App auth needs the 'app-auth' extra (cryptography); "
176
+ "install it before selecting the App token provider"
177
+ ) from exc
178
+
179
+ key = serialization.load_pem_private_key(pem_path.read_bytes(), password=None)
180
+ if not isinstance(key, rsa.RSAPrivateKey):
181
+ raise ValueError(f"{pem_path} is not an RSA key; GitHub App keys are RSA")
182
+
183
+ def sign(message: bytes) -> bytes:
184
+ return key.sign(message, padding.PKCS1v15(), hashes.SHA256())
185
+
186
+ return sign
187
+
188
+
189
+ def app_provider_from_file(app_file: Path) -> AppInstallationTokenProvider:
190
+ """Build the provider from the App config file: JSON with `app_id`,
191
+ `installation_id` (integers), and `private_key` (path to the PEM). The
192
+ ids are not secrets; the file exists so the whole App identity travels
193
+ as one path on the same rails the PAT path already rides."""
194
+ try:
195
+ config = json.loads(app_file.read_text())
196
+ except (OSError, ValueError) as exc:
197
+ raise ValueError(f"cannot read App config {app_file}: {exc}") from None
198
+ missing = [k for k in ("app_id", "installation_id", "private_key") if not config.get(k)]
199
+ if missing:
200
+ raise ValueError(f"App config {app_file} is missing {', '.join(missing)}")
201
+ return AppInstallationTokenProvider(
202
+ int(config["app_id"]),
203
+ int(config["installation_id"]),
204
+ signer_from_private_key(Path(str(config["private_key"])).expanduser()),
205
+ )
206
+
207
+
208
+ def resolve_bot_auth(pat_file: str | Path, app_file: str | Path = "") -> TokenProvider:
209
+ """The one seam every role CLI constructs bot auth through: the App
210
+ provider when an App config file is given, the PAT file otherwise."""
211
+ if str(app_file).strip():
212
+ return app_provider_from_file(Path(str(app_file)).expanduser())
213
+ return FileTokenProvider(Path(str(pat_file)).expanduser())
@@ -0,0 +1,198 @@
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
+ `AUTORESEARCH_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.request
29
+ from collections.abc import Callable
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ from outerloop.appauth import API, build_app_jwt, signer_from_private_key
34
+
35
+ # The hosted helper page: it auto-POSTs the manifest to GitHub, then displays the
36
+ # returned code. It is also the manifest's redirect_url, so GitHub sends the code
37
+ # straight back to it. Its own subdomain (the apex is the landing page), served
38
+ # from web/ in this repo so the page and the CLI stay versioned together.
39
+ # `OUTERLOOP_SETUP_URL` overrides it — a staging copy, or a self-hoster's own.
40
+ SETUP_URL = os.environ.get("OUTERLOOP_SETUP_URL") or "https://setup.outerloop.science"
41
+
42
+ # The App's fine-grained permissions — exactly what the fleet exercises: contents
43
+ # to push commits, pull_requests to open/label PRs, issues for the courtesy note
44
+ # on the requesting issue. Nothing else (least privilege; matches the live bot).
45
+ DEFAULT_PERMISSIONS = {
46
+ "contents": "write",
47
+ "issues": "write",
48
+ "metadata": "read",
49
+ "pull_requests": "write",
50
+ }
51
+
52
+ # The POST/GET transport returns parsed JSON; injected so the pure logic tests
53
+ # without a network.
54
+ Transport = Callable[[urllib.request.Request], Any]
55
+
56
+
57
+ def build_manifest(name: str, url: str, redirect_url: str = SETUP_URL) -> dict:
58
+ """The manifest GitHub creates the App from: our permissions, no webhook,
59
+ installable only on the creating account (`public=false`). `redirect_url` is
60
+ where GitHub returns the code — the hosted helper page by default."""
61
+ return {
62
+ "name": name,
63
+ "url": url,
64
+ "redirect_url": redirect_url,
65
+ "public": False,
66
+ "default_permissions": dict(DEFAULT_PERMISSIONS),
67
+ "hook_attributes": {"active": False, "url": url},
68
+ }
69
+
70
+
71
+ def build_setup_url(manifest: dict, state: str, org: str = "") -> str:
72
+ """The one URL the adopter opens: the hosted helper page with the manifest,
73
+ state, and org packed into the URL *fragment* (base64) — a fragment stays in
74
+ the browser and is never sent to any server, so the manifest isn't logged."""
75
+ payload = json.dumps({"manifest": manifest, "state": state, "org": org})
76
+ encoded = base64.urlsafe_b64encode(payload.encode()).decode()
77
+ return f"{SETUP_URL}#{encoded}"
78
+
79
+
80
+ def request_manifest_code(
81
+ name: str,
82
+ homepage_url: str,
83
+ org: str = "",
84
+ *,
85
+ print_fn: Callable[[str], None] = print,
86
+ input_fn: Callable[[str], str] = input,
87
+ ) -> str:
88
+ """Print the setup URL and read back the code the hosted page shows. Works
89
+ anywhere a browser can reach the internet — no localhost, no callback."""
90
+ state = secrets.token_urlsafe(16)
91
+ manifest = build_manifest(name, homepage_url)
92
+ url = build_setup_url(manifest, state, org)
93
+ print_fn("Create your GitHub App — open this URL in a browser (any machine):")
94
+ print_fn(f"\n {url}\n")
95
+ print_fn("Click 'Create GitHub App', then copy the code the page shows.")
96
+ return input_fn("Paste the code here: ").strip()
97
+
98
+
99
+ def _default_transport(request: urllib.request.Request) -> Any:
100
+ with urllib.request.urlopen(request, timeout=30) as resp:
101
+ payload = resp.read()
102
+ return json.loads(payload) if payload else None
103
+
104
+
105
+ def convert_manifest(code: str, *, transport: Transport | None = None) -> dict:
106
+ """Exchange the one-time manifest `code` for the App's credentials (id, pem,
107
+ slug, ...). GitHub invalidates the code on first use and after ~1h."""
108
+ transport = transport or _default_transport
109
+ request = urllib.request.Request(
110
+ f"{API}/app-manifests/{code}/conversions",
111
+ method="POST",
112
+ headers={
113
+ "Accept": "application/vnd.github+json",
114
+ "X-GitHub-Api-Version": "2022-11-28",
115
+ },
116
+ )
117
+ try:
118
+ body = transport(request)
119
+ except urllib.error.HTTPError as exc:
120
+ detail = exc.read().decode(errors="replace")[:200]
121
+ raise ValueError(f"manifest conversion failed ({exc.code}): {detail}") from None
122
+ if not isinstance(body, dict):
123
+ raise ValueError("manifest conversion returned no object")
124
+ missing = [k for k in ("id", "pem", "slug") if not body.get(k)]
125
+ if missing:
126
+ raise ValueError(f"manifest conversion missing {', '.join(missing)}")
127
+ return body
128
+
129
+
130
+ def save_app_creds(
131
+ conversion: dict, config_dir: Path, installation_id: int = 0
132
+ ) -> tuple[Path, Path]:
133
+ """Write the PEM (0600) and `github_app.<slug>.json` pointing at it, both
134
+ owner-only. `installation_id` stays 0 until the App is installed and
135
+ `capture_installation_id` fills it. Returns (pem_path, app_json_path)."""
136
+ config_dir.mkdir(parents=True, exist_ok=True)
137
+ slug = str(conversion["slug"])
138
+ pem_path = config_dir / f"{slug}-app.pem"
139
+ pem_path.write_text(str(conversion["pem"]))
140
+ pem_path.chmod(0o600)
141
+ app_json = config_dir / f"github_app.{slug}.json"
142
+ app_json.write_text(
143
+ json.dumps(
144
+ {
145
+ "app_id": int(conversion["id"]),
146
+ "installation_id": int(installation_id),
147
+ "private_key": str(pem_path),
148
+ },
149
+ indent=2,
150
+ )
151
+ + "\n"
152
+ )
153
+ app_json.chmod(0o600)
154
+ return pem_path, app_json
155
+
156
+
157
+ def install_url(conversion: dict) -> str:
158
+ """Where the adopter installs the freshly created App on their repos."""
159
+ return f"https://github.com/apps/{conversion['slug']}/installations/new"
160
+
161
+
162
+ def capture_installation_id(
163
+ app_id: int,
164
+ pem_path: Path,
165
+ owner: str = "",
166
+ *,
167
+ transport: Transport | None = None,
168
+ now: Callable[[], float] = time.time,
169
+ ) -> int:
170
+ """After the App is installed, its installation id for `owner` (or the sole
171
+ installation when `owner` is blank). App JWT → GET /app/installations. 0 when
172
+ nothing is installed yet — the caller retries after the adopter installs."""
173
+ transport = transport or _default_transport
174
+ sign = signer_from_private_key(pem_path)
175
+ jwt = build_app_jwt(app_id, now(), sign)
176
+ request = urllib.request.Request(
177
+ f"{API}/app/installations",
178
+ headers={
179
+ "Authorization": f"Bearer {jwt}",
180
+ "Accept": "application/vnd.github+json",
181
+ "X-GitHub-Api-Version": "2022-11-28",
182
+ },
183
+ )
184
+ installs = transport(request) or []
185
+ if owner:
186
+ for inst in installs:
187
+ if str((inst.get("account") or {}).get("login", "")).lower() == owner.lower():
188
+ return int(inst["id"])
189
+ return 0
190
+ return int(installs[0]["id"]) if installs else 0
191
+
192
+
193
+ def set_installation_id(app_json: Path, installation_id: int) -> None:
194
+ """Fill the installation id into an already-written github_app.<slug>.json."""
195
+ data = json.loads(app_json.read_text())
196
+ data["installation_id"] = int(installation_id)
197
+ app_json.write_text(json.dumps(data, indent=2) + "\n")
198
+ app_json.chmod(0o600)