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 +53 -0
- package/lib/door.mjs +171 -0
- package/lib/identities.mjs +397 -0
- package/lib/lock.mjs +76 -0
- package/lib/mint.mjs +133 -0
- package/lib/pyhook/sitecustomize.py +301 -0
- package/lib/replay.mjs +49 -0
- package/lib/start.mjs +85 -0
- package/lib/trace.cjs +103 -0
- package/local.mjs +620 -0
- package/package.json +29 -0
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
|
+
}
|