cortad 0.1.0

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.
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # cortad
2
+
3
+ Runs Cortad test conversations against the app on your machine. Your env stays on your machine.
4
+
5
+ ```
6
+ npx cortad <code>
7
+ ```
8
+
9
+ Run it in your app's folder. The code comes from the connect screen at cortad.com. Nothing is installed. Ctrl-C disconnects.
10
+
11
+ ## Behavior
12
+
13
+ - Starts your app with its dev script. If it is already running, uses it.
14
+ - Finds the route your AI answers on. Sends requests to that port on `localhost`, nowhere else.
15
+ - If it cannot find the route, the screen asks you to send one message in your app. It reads the route and body from that request.
16
+ - Restarts your app when it stops and you save a fix.
17
+ - The Cortad agent edits your files in place. Every edit can be undone from the screen. Git is never touched: no commit, stage, stash or push.
18
+
19
+ ## Sent to cortad.com
20
+
21
+ - Source files, once. Not `.env*`, key files, `node_modules` or `.git`.
22
+ - Your app's replies to the test requests. Values from your env files are masked first.
23
+
24
+ ## Never sent
25
+
26
+ - Env values.
27
+ - Tokens, cookies, API keys. Signed-in requests get their token attached on your machine.
28
+
29
+ ## Never done
30
+
31
+ - Writes to `.env*`, key files, `.git`, `node_modules`.
32
+ - Requests to any port but your app's.
33
+ - Agent shell access to the network or to files outside temp and git-ignored build folders.
34
+
35
+ ## Loaded into your app
36
+
37
+ When cortad starts your app it preloads one file: `lib/trace.cjs` for Node (`NODE_OPTIONS=--require`), `lib/pyhook/sitecustomize.py` for Python (`PYTHONPATH`). It records the one request during which your app called a model, to a file in cortad's temp folder, mode 0600. It makes no network calls. Read it; it is short.
38
+
39
+ ## Flags
40
+
41
+ ```
42
+ --explain print what would be sent and started, then exit; no network
43
+ --port 3000 use an app that is already running
44
+ --start "make dev" how your app starts
45
+ --verbose print your app's output
46
+ ```
47
+
48
+ ## Files it creates
49
+
50
+ - `~/.cortad/checkpoints/` undo data for agent edits
51
+ - `$TMPDIR/cortad-<pid>/` removed on exit
52
+
53
+ macOS, Linux. Node 20+.
package/lib/door.mjs ADDED
@@ -0,0 +1,171 @@
1
+ // The one door a change to your files goes through. Nothing else in this program can write inside
2
+ // your repository: the shell it runs for a world is locked by the operating system, and every edit
3
+ // arrives here. Before a byte changes, what was there is saved outside the project, so any change
4
+ // can be put back and nothing here ever touches git: no commit, no stage, no stash, no branch.
5
+ import { execFileSync } from "node:child_process";
6
+ import { createHash } from "node:crypto";
7
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
10
+
11
+ const sha = (bytes) => createHash("sha256").update(bytes).digest("hex");
12
+ // Never written, whoever asks: environments, keys, git's own data, and dependencies.
13
+ const ENV_FILE = /^\.env(\..*)?$/;
14
+ const KEY_FILE = /^(?:id_(?:rsa|ed25519|ecdsa).*|.*\.(?:pem|key|p12|pfx|jks|keystore)|\.npmrc|\.netrc|\.pypirc)$/;
15
+ const CLOSED_DIR = new Set([".git", "node_modules", ".ssh", ".aws", ".gnupg"]);
16
+
17
+ export function openDoor(root, { store = join(homedir(), ".cortad", "checkpoints") } = {}) {
18
+ const realRoot = realpathSync(root);
19
+ const dir = join(store, sha(realRoot).slice(0, 16));
20
+ const blobs = join(dir, "blobs");
21
+ const journalFile = join(dir, "journal.json");
22
+ mkdirSync(blobs, { recursive: true });
23
+ let journal = { seq: 0, entries: [] };
24
+ try { journal = JSON.parse(readFileSync(journalFile, "utf8")); } catch { /* first session here */ }
25
+ const save = () => { const tmp = `${journalFile}.${process.pid}`; writeFileSync(tmp, JSON.stringify(journal)); renameSync(tmp, journalFile); };
26
+
27
+ // Where a path really lands, or why it is refused. Decided on the real filesystem, not on how the
28
+ // path is spelled: a link inside the repository that points out of it is a way out of it.
29
+ function target(path) {
30
+ const abs = resolve(realRoot, String(path ?? ""));
31
+ if (abs !== realRoot && !abs.startsWith(realRoot + sep)) return { ok: false, why: "outside your repository" };
32
+ const rel = relative(realRoot, abs);
33
+ if (!rel) return { ok: false, why: "not a file" };
34
+ const parts = rel.split(sep);
35
+ if (parts.some((p) => CLOSED_DIR.has(p))) return { ok: false, why: `inside ${parts.find((p) => CLOSED_DIR.has(p))}, which is never edited` };
36
+ const leaf = parts.at(-1);
37
+ if (ENV_FILE.test(leaf)) return { ok: false, why: "an environment file, which is never edited" };
38
+ if (KEY_FILE.test(leaf)) return { ok: false, why: "a key or credential file, which is never edited" };
39
+ let standing = abs;
40
+ while (!existsSync(standing)) standing = dirname(standing);
41
+ const landed = realpathSync(standing);
42
+ if (landed !== realRoot && !landed.startsWith(realRoot + sep)) return { ok: false, why: "a link that leads outside your repository" };
43
+ if (existsSync(abs) || isLink(abs)) {
44
+ const info = lstatSync(abs);
45
+ // A link or a hard-linked file cannot be put back faithfully, so it is never changed.
46
+ if (info.isSymbolicLink()) return { ok: false, why: "a symbolic link" };
47
+ if (!info.isFile()) return { ok: false, why: "not a regular file" };
48
+ if (info.nlink > 1) return { ok: false, why: "hard-linked to another file" };
49
+ }
50
+ return { ok: true, abs, rel: parts.join("/") };
51
+ }
52
+ const isLink = (p) => { try { return lstatSync(p).isSymbolicLink(); } catch { return false; } };
53
+
54
+ // Written beside the file and moved into place, so a failure leaves the original standing.
55
+ function place(abs, bytes, mode) {
56
+ mkdirSync(dirname(abs), { recursive: true });
57
+ const tmp = join(dirname(abs), `.${basename(abs)}.bl-${process.pid}-${journal.seq}`);
58
+ writeFileSync(tmp, bytes, { flag: "wx", mode });
59
+ try { renameSync(tmp, abs); } catch (err) { rmSync(tmp, { force: true }); throw err; }
60
+ }
61
+
62
+ function write(path, bytes, { checkpoint = "manual", append = false, exclusive = false } = {}) {
63
+ const t = target(path);
64
+ if (!t.ok) return { success: false, stderr: `refused: ${path} is ${t.why}` };
65
+ const existed = existsSync(t.abs);
66
+ if (exclusive && existed) return { success: false, stderr: `refused: ${t.rel} already exists` };
67
+ const before = existed ? readFileSync(t.abs) : null;
68
+ const mode = existed ? lstatSync(t.abs).mode & 0o777 : 0o644;
69
+ const next = append && before ? Buffer.concat([before, bytes]) : bytes;
70
+ // The first touch inside a checkpoint holds the true "before"; later touches only move "after".
71
+ let entry = journal.entries.find((e) => e.checkpoint === checkpoint && e.rel === t.rel);
72
+ if (!entry) {
73
+ journal.seq += 1;
74
+ entry = { seq: journal.seq, checkpoint, rel: t.rel, existed, mode, blob: existed ? `${journal.seq}.before` : null, afterHash: null, at: new Date().toISOString() };
75
+ if (before) writeFileSync(join(blobs, entry.blob), before);
76
+ journal.entries.push(entry);
77
+ save();
78
+ }
79
+ try { place(t.abs, next, mode); } catch (err) { return { success: false, stderr: `could not write ${t.rel}: ${err.code ?? err.message}` }; }
80
+ entry.afterHash = sha(next);
81
+ save();
82
+ return { success: true, stderr: "" };
83
+ }
84
+
85
+ const currentHash = (abs) => (existsSync(abs) && !isLink(abs) ? sha(readFileSync(abs)) : null);
86
+
87
+ // What is pending, as a person would ask it: which files, how much, and whether they have since
88
+ // edited one themselves (in which case putting it back would erase their work, so it is said).
89
+ function changes() {
90
+ const byPath = new Map();
91
+ for (const e of journal.entries) if (!byPath.has(e.rel)) byPath.set(e.rel, { first: e, last: e }); else byPath.get(e.rel).last = e;
92
+ const files = [...byPath.entries()].map(([rel, { first, last }]) => {
93
+ const abs = join(realRoot, rel);
94
+ const counts = lineCounts(first.blob ? join(blobs, first.blob) : null, existsSync(abs) ? abs : null);
95
+ return { path: rel, status: first.existed ? "modified" : "added", ...counts, conflict: currentHash(abs) !== last.afterHash };
96
+ });
97
+ const checkpoints = [];
98
+ for (const e of journal.entries) {
99
+ const held = checkpoints.find((c) => c.id === e.checkpoint);
100
+ if (held) { if (!held.paths.includes(e.rel)) held.paths.push(e.rel); } else checkpoints.push({ id: e.checkpoint, at: e.at, paths: [e.rel] });
101
+ }
102
+ return { files, checkpoints };
103
+ }
104
+
105
+ // Back to how things stood before a checkpoint: that change and everything after it, newest
106
+ // first. A file they edited after the change is left exactly as it is, and named.
107
+ function restore(checkpoint) {
108
+ const from = journal.entries.findIndex((e) => e.checkpoint === checkpoint);
109
+ if (from < 0) return { restored: [], skipped: [], missing: true };
110
+ const restored = [];
111
+ const skipped = [];
112
+ const blocked = new Set();
113
+ const kept = journal.entries.slice(0, from);
114
+ const stay = [];
115
+ for (const e of journal.entries.slice(from).reverse()) {
116
+ if (blocked.has(e.rel)) { stay.unshift(e); continue; }
117
+ const t = target(e.rel);
118
+ const why = !t.ok ? `it is now ${t.why}` : currentHash(t.abs) !== e.afterHash ? "you changed it after this edit, so it was left as it is" : null;
119
+ if (why) { blocked.add(e.rel); skipped.push({ path: e.rel, why }); stay.unshift(e); continue; }
120
+ if (e.existed) place(t.abs, readFileSync(join(blobs, e.blob)), e.mode); else unlinkSync(t.abs);
121
+ if (e.blob) rmSync(join(blobs, e.blob), { force: true });
122
+ if (!restored.includes(e.rel)) restored.push(e.rel);
123
+ }
124
+ journal.entries = [...kept, ...stay];
125
+ save();
126
+ return { restored, skipped };
127
+ }
128
+
129
+ // Accepted: the change stays and the way back is let go. Up to a checkpoint, or everything.
130
+ function keep(checkpoint) {
131
+ let upTo = journal.entries.length;
132
+ if (checkpoint) { const last = journal.entries.map((e) => e.checkpoint).lastIndexOf(checkpoint); if (last < 0) return { kept: 0, missing: true }; upTo = last + 1; }
133
+ const gone = journal.entries.slice(0, upTo);
134
+ for (const e of gone) if (e.blob) rmSync(join(blobs, e.blob), { force: true });
135
+ journal.entries = journal.entries.slice(upTo);
136
+ save();
137
+ return { kept: new Set(gone.map((e) => e.rel)).size };
138
+ }
139
+
140
+ // One pending file, before and after, as the unified diff an editor would show. "Before" is how
141
+ // the file stood ahead of the first change still pending, so several edits read as one.
142
+ function diff(path) {
143
+ const rel = String(path ?? "").replace(/^\/+/, "");
144
+ const first = journal.entries.find((e) => e.rel === rel);
145
+ if (!first) return { diff: "", missing: true };
146
+ const abs = join(realRoot, rel);
147
+ try {
148
+ const out = spawnDiff(first.blob ? join(blobs, first.blob) : "/dev/null", existsSync(abs) ? abs : "/dev/null", ["-u", "--label", `a/${rel}`, "--label", `b/${rel}`]);
149
+ return { diff: out.slice(0, 120_000), truncated: out.length > 120_000 };
150
+ } catch { return { diff: "", missing: true }; }
151
+ }
152
+
153
+ return { target, write, changes, restore, keep, diff, pending: () => new Set(journal.entries.map((e) => e.rel)).size };
154
+ }
155
+
156
+ // Lines added and removed, from the system's own diff. Null when it cannot say.
157
+ function lineCounts(beforeFile, afterFile) {
158
+ try {
159
+ const out = spawnDiff(beforeFile ?? "/dev/null", afterFile ?? "/dev/null");
160
+ let added = 0; let removed = 0;
161
+ for (const line of out.split("\n")) {
162
+ if (line.startsWith("+") && !line.startsWith("+++")) added += 1;
163
+ else if (line.startsWith("-") && !line.startsWith("---")) removed += 1;
164
+ }
165
+ return { added, removed };
166
+ } catch { return { added: null, removed: null }; }
167
+ }
168
+ function spawnDiff(a, b, flags = ["-U0"]) {
169
+ try { return execFileSync("diff", [...flags, a, b], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }); }
170
+ catch (err) { if (err.status === 1 && typeof err.stdout === "string") return err.stdout; throw err; }
171
+ }
@@ -0,0 +1,397 @@
1
+ // Copied from brainsless-backend src/harness/identities.mjs. Keep the two in step: the recipes the
2
+ // cloud sends are gated here again, on the machine that holds the secrets.
3
+ // Signing in as the customer's own callers, from inside the box, through their own doors.
4
+ //
5
+ // A recipe (src/setup/road-identities.ts) names env variables, routes and body fields; never a
6
+ // value. This program runs where the app's environment file is, opens the values itself, creates
7
+ // an account through their admin door or exchanges their shared password for a token, and keeps
8
+ // what their door issued in a header file only the box can read. What it prints is roles and
9
+ // statuses: a token the run needs is fetched from the box when a turn first speaks as that role,
10
+ // and a credential that IS a sealed value (an admin key) is marked sealed and never leaves. A
11
+ // caller Firebase verifies is signed up at the Auth emulator the boot raised in place of their
12
+ // project; without that emulator the role stays upstream, verified outside the box.
13
+
14
+ export const IDENTITY_PATH = "/tmp/bl-identities.py";
15
+ export const IDENTITY_OUT = "/tmp/bl-identities.json";
16
+ const HEADER_FILE = "/tmp/bl-identity-%s.%s";
17
+
18
+ const ROLE = /^[a-z][a-z0-9_-]{0,30}(?::[a-z][a-z0-9_-]{0,30})?$/;
19
+ const HEADER = /^[a-z][a-z0-9-]{0,40}$/;
20
+ const ENV_NAME = /^[A-Z_][A-Z0-9_]*$/;
21
+ const ROUTE = /^\/[\w./-]{0,200}$/;
22
+ const OPENS = /^\/[\w./{}-]{0,200}$/;
23
+ const SOURCE = /^(?:env:[A-Z_][A-Z0-9_]*(?:\|[A-Z_][A-Z0-9_]*)*|gen:(?:username|email|password|name)|role)$/;
24
+ // A Next.js route file is spelled with brackets and route groups with parentheses: `app/api/auth/
25
+ // [...all]/route.ts (better-auth)` failed the old gate and every catch-all auth recipe was dropped
26
+ // before the program saw it. Still no quote, angle bracket, `$`, backtick or semicolon.
27
+ const WHERE = /^[\w./:@()[\] -]{1,300}$/;
28
+ const BASE = /^http:\/\/(?:127\.0\.0\.1|\[::1\]|localhost):\d{2,5}$/;
29
+ const KINDS = new Set(["header", "login", "jwt", "upstream", "firebase"]);
30
+ const STATUSES = new Set(["minted", "sealed", "absent", "refused", "unreachable", "upstream"]);
31
+
32
+ const slug = (role) => role.replace(/[^a-z0-9-]/g, "-");
33
+ export const identityHeaderFile = (role) => (ROLE.test(role) ? HEADER_FILE.replace("%s.%s", `${slug(role)}.header`) : null);
34
+ // The curl argument a probe inside the box adds to speak as a role: the header line is read there,
35
+ // so the value never rides in a command this process composes.
36
+ export function identityCurlArg(as) {
37
+ if (as === "browser" || as === "bare" || !ROLE.test(as)) return "";
38
+ return `-H "$(cat ${HEADER_FILE.replace("%s.%s", `${slug(as)}.header`)} ${HEADER_FILE.replace("%s.%s", `${slug(as)}.sealed`)} 2>/dev/null)"`;
39
+ }
40
+
41
+ // Every string the recipes carry reaches the program as base64 JSON, never as source, and only
42
+ // after the charset gate: a route or a role is read from a repository.
43
+ export function identityScript(recipes, target) {
44
+ const safe = recipes.filter(safeRecipe).slice(0, 12);
45
+ const headers = Object.fromEntries(Object.entries(target.headers ?? {}).filter(([k, v]) => HEADER.test(k) && typeof v === "string" && v.length <= 300 && !/[\r\n]/.test(v)));
46
+ const plan = { envPath: /^[\w./-]+$/.test(target.envPath) ? target.envPath : "/workspace/repo/.env", base: BASE.test(target.base) ? target.base : "http://127.0.0.1:0", headers, recipes: safe };
47
+ return PROGRAM
48
+ .replace("__PLAN__", Buffer.from(JSON.stringify(plan), "utf8").toString("base64"))
49
+ .replace("__OUT__", IDENTITY_OUT)
50
+ .replace("__FILE__", HEADER_FILE);
51
+ }
52
+
53
+ function safeRecipe(r) {
54
+ const step = (s) => s && ROUTE.test(s.route)
55
+ && Object.entries(s.headers ?? {}).every(([k, v]) => HEADER.test(k) && SOURCE.test(v))
56
+ && Object.entries(s.body ?? {}).every(([k, v]) => /^[A-Za-z_][\w]{0,40}$/.test(k) && SOURCE.test(v));
57
+ return r && KINDS.has(r.kind) && ROLE.test(r.role) && HEADER.test(r.header) && WHERE.test(r.where)
58
+ && (r.env ?? []).every((n) => ENV_NAME.test(n))
59
+ && (r.roles ?? []).every((v) => /^[a-z][a-z0-9_-]{0,30}$/.test(v))
60
+ && (r.opens ?? []).every((o) => OPENS.test(o))
61
+ && (r.steps ?? []).every(step) && (r.alt ?? []).every(step)
62
+ && (r.claim === undefined || /^[a-z_][a-z0-9_]{0,30}$/i.test(r.claim));
63
+ }
64
+
65
+ const PROGRAM = String.raw`import base64, hashlib, hmac, http.cookiejar, json, os, re, secrets, time, urllib.error, urllib.request
66
+
67
+ PLAN = json.loads(base64.b64decode("__PLAN__").decode("utf-8"))
68
+ OUT = "__OUT__"
69
+ FILE = "__FILE__"
70
+ TTL = 6 * 3600
71
+ # A dev server compiles its auth route on the first request: better-chatbot's sign-in timed out at 10s
72
+ # and the caller was filed unreachable. The whole program stays inside the step's two minutes.
73
+ DEADLINE = time.time() + 100
74
+ SIGNUP = "/identitytoolkit.googleapis.com/v1/accounts:signUp?key=standin"
75
+
76
+
77
+ def read_env(path):
78
+ out = {}
79
+ try:
80
+ with open(path, encoding="utf-8", errors="replace") as f:
81
+ for line in f:
82
+ m = re.match(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$", line)
83
+ if not m:
84
+ continue
85
+ v = m.group(2).strip()
86
+ if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
87
+ v = v[1:-1]
88
+ out[m.group(1)] = v
89
+ except OSError:
90
+ pass
91
+ return out
92
+
93
+
94
+ def slug(role):
95
+ return re.sub(r"[^a-z0-9-]", "-", role)
96
+
97
+
98
+ def gen(role):
99
+ tag = secrets.token_hex(4)
100
+ return {
101
+ "username": "bl-%s-%s" % (slug(role), tag),
102
+ "email": "bl-%s-%s@example.invalid" % (slug(role), tag),
103
+ "password": secrets.token_urlsafe(18) + "A1!",
104
+ "name": "Brainsless %s" % role,
105
+ }
106
+
107
+
108
+ def source(spec, env, g, role):
109
+ if spec.startswith("env:"):
110
+ for name in spec[4:].split("|"):
111
+ if env.get(name):
112
+ return env[name]
113
+ return None
114
+ return {"gen:username": g["username"], "gen:email": g["email"], "gen:password": g["password"], "gen:name": g["name"], "role": role}.get(spec)
115
+
116
+
117
+ def names(spec):
118
+ return spec[4:].replace("|", " or ") if spec.startswith("env:") else spec
119
+
120
+
121
+ def call(path, headers, body, jar, base=None):
122
+ opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
123
+ req = urllib.request.Request((base or PLAN["base"]) + path, data=json.dumps(body).encode("utf-8"), method="POST")
124
+ for k, v in list(PLAN["headers"].items()) + list(headers.items()):
125
+ req.add_header(k, v)
126
+ req.add_header("content-type", "application/json")
127
+ try:
128
+ with opener.open(req, timeout=40) as r:
129
+ return r.status, r.read(200000).decode("utf-8", "replace")
130
+ except urllib.error.HTTPError as e:
131
+ # The door's own sentence rides the refusal: a 500 with no body told nobody what the
132
+ # sign-up died on.
133
+ try:
134
+ return e.code, e.read(4000).decode("utf-8", "replace")
135
+ except Exception:
136
+ return e.code, ""
137
+ except Exception as e:
138
+ return 0, type(e).__name__
139
+
140
+
141
+ def token_in(value, depth=0):
142
+ if isinstance(value, dict):
143
+ for k, v in value.items():
144
+ if isinstance(v, str) and v and re.fullmatch(r"(?i)(access_?token|token|jwt|session_?token|id_?token|api_?key)", k):
145
+ return v
146
+ if depth < 2:
147
+ for v in value.values():
148
+ found = token_in(v, depth + 1)
149
+ if found:
150
+ return found
151
+ return None
152
+
153
+
154
+ def bearer(header, value):
155
+ if header == "authorization" and not value.lower().startswith("bearer "):
156
+ return "Bearer " + value
157
+ return value
158
+
159
+
160
+ def minted(header, value, ttl=TTL):
161
+ return {"status": "minted", "header": header, "line": "%s: %s" % (header, bearer(header, value)), "expires": int(time.time()) + ttl}
162
+
163
+
164
+ # The account is named after the identity, not the role slot: staff:sales and sales are two callers
165
+ # at two doors, and their door reads the name back on every login.
166
+ def login(recipe, role, role_value, env):
167
+ g = gen(role)
168
+ ways = [recipe["steps"]] + ([recipe["alt"]] if recipe.get("alt") else [])
169
+ for way in ways:
170
+ last = ways[-1] is way
171
+ got = walk(way, recipe, env, g, role_value)
172
+ if got.get("status") != "refused" or last:
173
+ return got
174
+ return got
175
+
176
+
177
+ def walk(steps, recipe, env, g, role_value):
178
+ jar = http.cookiejar.CookieJar()
179
+ status, token = 0, None
180
+ for step in steps:
181
+ headers = {}
182
+ # A token the previous step minted signs this one: the key route wants the session that
183
+ # just logged in, and a cookie jar alone does not carry a bearer.
184
+ if token:
185
+ headers[recipe["header"]] = bearer(recipe["header"], token)
186
+ for h, spec in step.get("headers", {}).items():
187
+ v = source(spec, env, g, role_value)
188
+ if v is None:
189
+ return {"status": "absent", "note": names(spec)}
190
+ headers[h] = v
191
+ body = {}
192
+ unset = None
193
+ for field, spec in step["body"].items():
194
+ v = source(spec, env, g, role_value)
195
+ if v is None:
196
+ if not spec.startswith("env:"):
197
+ return {"status": "absent", "note": names(spec)}
198
+ # A switch in their code may make this one optional: ask without it, and a refusal
199
+ # is then the variable they did not seal, not their door.
200
+ unset = names(spec)
201
+ continue
202
+ body[field] = v
203
+ status, text = call(step["route"], headers, body, jar)
204
+ if status == 0:
205
+ return {"status": "unreachable", "note": text}
206
+ if status >= 400 and unset:
207
+ return {"status": "absent", "note": unset}
208
+ if status >= 400:
209
+ said = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", text or "")).strip()[:160]
210
+ return {"status": "refused", "note": "HTTP %d at %s%s" % (status, step["route"], (": " + said) if said else "")}
211
+ try:
212
+ found = token_in(json.loads(text))
213
+ except ValueError:
214
+ found = None
215
+ # A step that mints nothing keeps what the step before it minted.
216
+ token = found or token
217
+ cookies = "; ".join("%s=%s" % (c.name, c.value) for c in jar)
218
+ # A cookie door reads its cookie by name. better-auth's sign-in answers a token AND sets
219
+ # better-auth.session_token; sending the bare token as the cookie header named nothing, so its
220
+ # proxy redirected every signed-in knock to the sign-in page.
221
+ if recipe["header"] == "cookie" and cookies:
222
+ return minted("cookie", cookies)
223
+ if token:
224
+ return minted(recipe["header"], token)
225
+ if cookies:
226
+ # ponytail: the cookie alone; a CSRF token their form also wants is the next thing to carry.
227
+ return minted("cookie", cookies)
228
+ return {"status": "refused", "note": "HTTP %d with no token in the reply" % status}
229
+
230
+
231
+ def b64url(raw):
232
+ return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
233
+
234
+
235
+ def jwt(recipe, env):
236
+ secret = source("env:" + "|".join(recipe["env"]), env, None, None)
237
+ if not secret:
238
+ return {"status": "absent", "note": " or ".join(recipe["env"])}
239
+ now = int(time.time())
240
+ # ponytail: subject 1, the first account of a restored copy; read a real id off the stand-in
241
+ # database when a customer's users table does not start there.
242
+ claims = {"sub": "1", "id": 1, "iat": now, "exp": now + TTL}
243
+ claim = recipe.get("claim") or "sub"
244
+ if claim not in claims:
245
+ claims[claim] = 1
246
+ head = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode("utf-8"))
247
+ body = b64url(json.dumps(claims, separators=(",", ":")).encode("utf-8"))
248
+ sig = b64url(hmac.new(secret.encode("utf-8"), ("%s.%s" % (head, body)).encode("ascii"), hashlib.sha256).digest())
249
+ return minted("authorization", "%s.%s.%s" % (head, body, sig))
250
+
251
+
252
+ # The Auth emulator the boot raised in place of their Firebase project: it signs any address up
253
+ # under any key, and firebase-admin accepts its tokens while FIREBASE_AUTH_EMULATOR_HOST is set.
254
+ def firebase(recipe, role, env):
255
+ host = env.get("FIREBASE_AUTH_EMULATOR_HOST", "")
256
+ if not re.fullmatch(r"[\w.-]+:\d{2,5}", host):
257
+ return {"status": "upstream", "note": "Firebase"}
258
+ g = gen(role)
259
+ status, text = call(SIGNUP, {}, {"email": g["email"], "password": g["password"], "returnSecureToken": True}, http.cookiejar.CookieJar(), "http://" + host)
260
+ if status == 0:
261
+ return {"status": "unreachable", "note": text}
262
+ try:
263
+ reply = json.loads(text)
264
+ except ValueError:
265
+ reply = {}
266
+ token = reply.get("idToken") if isinstance(reply, dict) else None
267
+ if not isinstance(token, str) or not token:
268
+ return {"status": "refused", "note": "HTTP %d at the Firebase emulator" % status}
269
+ return minted(recipe["header"], token, int(reply.get("expiresIn") or 3600))
270
+
271
+
272
+ def sealed(recipe, env):
273
+ v = source("env:" + "|".join(recipe["env"]), env, None, None)
274
+ if not v:
275
+ return {"status": "absent", "note": " or ".join(recipe["env"])}
276
+ return {"status": "sealed", "header": recipe["header"], "line": "%s: %s" % (recipe["header"], bearer(recipe["header"], v)), "note": " or ".join(recipe["env"])}
277
+
278
+
279
+ def keep(path, line):
280
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
281
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
282
+ f.write(line + "\n")
283
+
284
+
285
+ # A token their door issued earlier and still honours is reused: sign-in doors are rate limited,
286
+ # and a run re-minting on every boot would spend that budget on itself.
287
+ def previous():
288
+ try:
289
+ with open(OUT, encoding="utf-8") as f:
290
+ rows = json.load(f)["identities"]
291
+ except Exception:
292
+ return {}
293
+ now = time.time()
294
+ out = {}
295
+ for r in rows:
296
+ if r.get("status") not in ("minted", "sealed"):
297
+ continue
298
+ if not os.path.exists(FILE % (slug(r["role"]), "header" if r["status"] == "minted" else "sealed")):
299
+ continue
300
+ if r.get("expires") is not None and r["expires"] < now + 60:
301
+ continue
302
+ out[r["role"]] = r
303
+ return out
304
+
305
+
306
+ def main():
307
+ env = read_env(PLAN["envPath"])
308
+ kept = previous()
309
+ rows = []
310
+ for recipe in PLAN["recipes"]:
311
+ for role_value in recipe.get("roles") or [None]:
312
+ role = "%s:%s" % (recipe["role"], role_value) if role_value else recipe["role"]
313
+ steps = recipe.get("steps") or []
314
+ base = {
315
+ "role": role, "kind": recipe["kind"], "header": recipe["header"], "where": recipe["where"],
316
+ "opens": [o.replace("{role}", role_value or "") for o in recipe.get("opens", [])],
317
+ "door": steps[-1]["route"] if steps else SIGNUP if recipe["kind"] == "firebase" else recipe["header"],
318
+ }
319
+ if role in kept:
320
+ rows.append(dict(base, **{k: v for k, v in kept[role].items() if k in ("status", "header", "expires", "note")}))
321
+ continue
322
+ if time.time() > DEADLINE:
323
+ rows.append(dict(base, status="unreachable", note="out of time"))
324
+ continue
325
+ try:
326
+ if recipe["kind"] == "header":
327
+ got = sealed(recipe, env)
328
+ elif recipe["kind"] == "jwt":
329
+ got = jwt(recipe, env)
330
+ elif recipe["kind"] == "login":
331
+ got = login(recipe, role, role_value, env)
332
+ elif recipe["kind"] == "firebase":
333
+ got = firebase(recipe, role, env)
334
+ else:
335
+ got = {"status": "upstream", "note": " or ".join(recipe["env"]) or recipe["role"]}
336
+ except Exception as err:
337
+ got = {"status": "unreachable", "note": type(err).__name__}
338
+ line = got.pop("line", None)
339
+ if line:
340
+ keep(FILE % (slug(role), "header" if got["status"] == "minted" else "sealed"), line)
341
+ rows.append(dict(base, **got))
342
+ report = {"identities": rows}
343
+ with open(OUT, "w", encoding="utf-8") as f:
344
+ json.dump(report, f)
345
+ print("BL_IDENTITIES " + json.dumps(report))
346
+
347
+
348
+ main()
349
+ `;
350
+
351
+ // The one line the program wrote, as typed rows. Anything malformed is dropped rather than read,
352
+ // and nothing here is a value: a role, a header name, a status, a door.
353
+ export function parseIdentityReport(text) {
354
+ const line = String(text ?? "").split("\n").find((l) => l.startsWith("BL_IDENTITIES {")) ?? (String(text ?? "").trim().startsWith("{") ? `BL_IDENTITIES ${String(text).trim()}` : null);
355
+ if (!line) return null;
356
+ let raw;
357
+ try {
358
+ raw = JSON.parse(line.slice("BL_IDENTITIES ".length));
359
+ } catch {
360
+ return null;
361
+ }
362
+ if (!Array.isArray(raw?.identities)) return null;
363
+ const identities = raw.identities.flatMap((r) => {
364
+ if (!r || !ROLE.test(String(r.role)) || !KINDS.has(r.kind) || !HEADER.test(String(r.header)) || !STATUSES.has(r.status)) return [];
365
+ if (typeof r.where !== "string" || !WHERE.test(r.where) || typeof r.door !== "string" || r.door.length > 200) return [];
366
+ const opens = (Array.isArray(r.opens) ? r.opens : []).filter((o) => typeof o === "string" && OPENS.test(o)).slice(0, 8);
367
+ return [{
368
+ role: r.role, kind: r.kind, header: r.header, status: r.status, opens, door: r.door, where: r.where,
369
+ ...(Number.isFinite(r.expires) ? { expires: Number(r.expires) } : {}),
370
+ ...(typeof r.note === "string" ? { note: r.note.slice(0, 200) } : {}),
371
+ }];
372
+ });
373
+ return { identities };
374
+ }
375
+
376
+ // What each row means to the person whose app this is.
377
+ export function identitySentence(row) {
378
+ switch (row.status) {
379
+ case "minted": return row.kind === "firebase"
380
+ ? `signed in as ${row.role} through the Firebase emulator standing in for your project; the token stays inside the sandbox`
381
+ : `signed in as ${row.role} through your own door ${row.door}; the token stays inside the sandbox`;
382
+ case "sealed": return `${row.role} is the sealed ${row.note} value itself; it never leaves the sandbox, so only the compile's own probe can speak as it`;
383
+ case "absent": return `could not sign in as ${row.role}: ${row.note} is not in the environment you sealed`;
384
+ case "refused": return `your door refused the ${row.role} sign-in (${row.note}), so cases behind it are counted and not driven`;
385
+ case "unreachable": return `the ${row.role} sign-in at ${row.door} did not answer (${row.note}); that is ours to look at`;
386
+ default: return `${row.role} callers are verified by ${row.note}, outside this sandbox; their doors are counted and not driven`;
387
+ }
388
+ }
389
+
390
+ // Write the program, run it detached, read the report back, and leave only the header files and
391
+ // the report the next mint reuses.
392
+ export async function mintInBox(box, recipes, target) {
393
+ if (!(await box.write(IDENTITY_PATH, identityScript(recipes, target)))) return null;
394
+ await box.run("bl-identities", `python3 ${IDENTITY_PATH}`).catch(() => null);
395
+ const out = await box.exec(`cat ${IDENTITY_OUT} 2>/dev/null; rm -f ${IDENTITY_PATH}`).catch(() => "");
396
+ return parseIdentityReport(out);
397
+ }