cortad 0.3.1-rc.9 → 0.3.1
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 +8 -5
- package/lib/app-port.mjs +9 -0
- package/lib/auto-reach.mjs +88 -0
- package/lib/backing.mjs +230 -0
- package/lib/cli.mjs +113 -30
- package/lib/contact-text.mjs +167 -0
- package/lib/data.mjs +16 -22
- package/lib/dissection-text.mjs +232 -0
- package/lib/document.mjs +75 -0
- package/lib/env-secrets.mjs +48 -0
- package/lib/failed-text.mjs +36 -0
- package/lib/fresh.mjs +33 -12
- package/lib/health.mjs +61 -0
- package/lib/held.mjs +67 -0
- package/lib/home.mjs +55 -17
- package/lib/hosted.mjs +201 -0
- package/lib/identities.mjs +1 -1
- package/lib/keeping.mjs +162 -0
- package/lib/ledger.mjs +20 -0
- package/lib/lock.mjs +6 -1
- package/lib/mcp.mjs +33 -15
- package/lib/mint.mjs +179 -38
- package/lib/mongo.mjs +74 -0
- package/lib/numbers-text.mjs +68 -0
- package/lib/pg.mjs +118 -0
- package/lib/proc.mjs +100 -0
- package/lib/proof.mjs +1014 -0
- package/lib/proxy.mjs +184 -0
- package/lib/pyhook/cortad_sites.py +218 -0
- package/lib/pyhook/cortad_tied.py +291 -0
- package/lib/pyhook/sitecustomize.py +1175 -166
- package/lib/qdrant.mjs +47 -0
- package/lib/reach.mjs +223 -0
- package/lib/read-text.mjs +78 -24
- package/lib/redis.mjs +130 -0
- package/lib/register.mjs +36 -27
- package/lib/replay.mjs +464 -34
- package/lib/runner.mjs +85 -0
- package/lib/service.mjs +25 -0
- package/lib/spec.mjs +7 -4
- package/lib/start.mjs +33 -2
- package/lib/stick.mjs +65 -25
- package/lib/stores.mjs +347 -0
- package/lib/switches.mjs +36 -0
- package/lib/tether.mjs +9 -0
- package/lib/text.mjs +314 -179
- package/lib/tied.cjs +217 -0
- package/lib/trace.cjs +386 -514
- package/lib/verbs.mjs +142 -31
- package/lib/wire.cjs +882 -0
- package/lib/words.mjs +2 -0
- package/lib/writes.mjs +2 -2
- package/local.mjs +661 -297
- package/package.json +30 -1
- package/skill/SKILL.md +49 -42
- package/skill/references/results.md +279 -102
package/lib/keeping.mjs
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// What one session keeps out of the run's way and brings up for it: the app's database files and
|
|
2
|
+
// servers, copied before it starts (lib/data.mjs, lib/stores.mjs), and the stores and services it
|
|
3
|
+
// reaches, started when nothing answers there (lib/backing.mjs). Every sentence goes to the terminal
|
|
4
|
+
// and, through lines(), to the run: what trials write into for real first.
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { localAddresses, makeBacking, tcpOpen } from "./backing.mjs";
|
|
7
|
+
import { keepData } from "./data.mjs";
|
|
8
|
+
import { bypassedOf, isStorePort, onThisMachine, storeId, storeKeeper, storesOf, unnamedLines } from "./stores.mjs";
|
|
9
|
+
|
|
10
|
+
// A line that opens with a setting's name keeps its case: "nEXT_PUBLIC_SUPABASE_URL" was printed.
|
|
11
|
+
const lower = (line) => (/^[A-Z][A-Z0-9_]/.test(line) ? line : line.charAt(0).toLowerCase() + line.slice(1));
|
|
12
|
+
// A store at a network address; a socket path, or no host at all, is Postgres's own socket.
|
|
13
|
+
const addressed = (s) => Boolean(s.host) && !s.host.startsWith("/") && Boolean(s.port);
|
|
14
|
+
// One nothing answers at is left for later: trials cannot write into it.
|
|
15
|
+
const isDown = async (s) => addressed(s) && !(await tcpOpen(s.host, s.port));
|
|
16
|
+
// The stores the settings name on this machine, as ports to bring up; a compose image may serve them.
|
|
17
|
+
const localStores = (k) => k.stores.filter((s) => addressed(s) && onThisMachine(s.host)).map((s) => ({ port: s.port, names: s.names, store: true }));
|
|
18
|
+
// The API keeps eight stores that hold Run. Past that, the rest travel as one row, and a yes to that
|
|
19
|
+
// row is a yes to each of them; a row more changes its id, so an old yes no longer covers it.
|
|
20
|
+
const BLOCKED_MAX = 8;
|
|
21
|
+
const overflow = (blocked) => blocked.slice(BLOCKED_MAX - 1);
|
|
22
|
+
// How a service on this machine that answers nothing is named to the API: by its port, never an address.
|
|
23
|
+
const downId = (port) => createHash("sha256").update(`down:${port}`).digest("hex").slice(0, 16);
|
|
24
|
+
const overflowId = (rest) => createHash("sha256").update(rest.map((b) => b.id).join()).digest("hex").slice(0, 16);
|
|
25
|
+
function blockedRows(blocked) {
|
|
26
|
+
const rows = blocked.map(({ port: _port, ...b }) => ({ ...b, why: b.why.slice(0, 400) }));
|
|
27
|
+
if (rows.length <= BLOCKED_MAX) return rows;
|
|
28
|
+
const rest = overflow(rows);
|
|
29
|
+
const names = [...new Set(rest.flatMap((b) => b.names))].slice(0, 8);
|
|
30
|
+
return [...rows.slice(0, BLOCKED_MAX - 1), { id: overflowId(rest), names, why: `These ${rest.length} stores hold Run too. ${rest[0].why}`.slice(0, 400) }];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// settings(): the environment the app starts with, the env file each name came from, the app's
|
|
34
|
+
// folder and its source files. launch(plan): a service of the repository started the way the app
|
|
35
|
+
// is. connections(): what the hook saw the app reach. hold(): the run's requests wait from here until
|
|
36
|
+
// the caller has the app on the copies made, since until then it still writes into the originals.
|
|
37
|
+
export function makeKeeping({ root, work, ledgerFile, onPath, say, settings, launch, connections, hold = () => {} }) {
|
|
38
|
+
let pending = null;
|
|
39
|
+
let now = null;
|
|
40
|
+
let backing = null;
|
|
41
|
+
let backedUp = false;
|
|
42
|
+
let seen = 0;
|
|
43
|
+
const told = new Set();
|
|
44
|
+
const tell = (lines) => { for (const line of lines) say(lower(line)); };
|
|
45
|
+
const held = (blocked) => tell(blocked.map((b) => `Run waits for your yes on the card in the browser: ${lower(b.why)}`));
|
|
46
|
+
|
|
47
|
+
async function keepAll(started) {
|
|
48
|
+
const { values, from, appDir, sources } = settings();
|
|
49
|
+
const disk = keepData({ values, from, appDir, root, work, sources, started });
|
|
50
|
+
const stores = storesOf(disk.values);
|
|
51
|
+
const keeper = storeKeeper({ ledgerFile, values: disk.values, dirs: [...new Set([appDir, root])] });
|
|
52
|
+
const got = await keeper.keep(stores, { started, down: isDown });
|
|
53
|
+
backing ??= makeBacking({ root, appDir, values: disk.values, onPath, ledgerFile, launch, adopt: started });
|
|
54
|
+
now = { env: { ...disk.env, ...got.env }, risks: got.risks, notes: [...disk.said, ...got.copies], blocked: got.blocked, pass: got.pass, stores, keeper, values: disk.values, started };
|
|
55
|
+
tell([...now.risks, ...now.notes]);
|
|
56
|
+
held(now.blocked);
|
|
57
|
+
return now;
|
|
58
|
+
}
|
|
59
|
+
// A read that failed is tried again on the next ask, never handed back as the same failure.
|
|
60
|
+
const once = (started) => (pending ??= keepAll(started).catch((e) => { pending = null; throw e; }));
|
|
61
|
+
|
|
62
|
+
// Brings up what is wanted, copies each store that now answers, and says both.
|
|
63
|
+
async function bringUp(wants) {
|
|
64
|
+
const k = await once(true);
|
|
65
|
+
hold();
|
|
66
|
+
const up = await backing.up(wants);
|
|
67
|
+
const got = await k.keeper.keep(k.stores, { started: k.started, down: isDown });
|
|
68
|
+
Object.assign(k.env, got.env);
|
|
69
|
+
k.risks.unshift(...got.risks);
|
|
70
|
+
// Said once: every look brings the same lines back, and status printed one hold four times.
|
|
71
|
+
k.notes.push(...[...up.lines, ...got.copies].filter((l) => !k.notes.includes(l)));
|
|
72
|
+
// A service a setting names that answers nothing holds Run until it does; one that answers now
|
|
73
|
+
// lets go. ai-appointment-setter's only free run played with its knowledge base down.
|
|
74
|
+
const checked = new Set(wants.map((w) => w.port));
|
|
75
|
+
const stillDown = new Set(up.down.map((d) => d.port));
|
|
76
|
+
for (let i = k.blocked.length - 1; i >= 0; i--) if (k.blocked[i].down && checked.has(k.blocked[i].port) && !stillDown.has(k.blocked[i].port)) k.blocked.splice(i, 1);
|
|
77
|
+
for (const d of up.down) if (!k.blocked.some((b) => b.down && b.port === d.port)) k.blocked.push({ id: downId(d.port), names: d.names, why: d.why, down: true, port: d.port });
|
|
78
|
+
k.blocked.push(...got.blocked.filter((b) => !k.blocked.some((x) => x.id === b.id)));
|
|
79
|
+
k.pass.push(...got.pass);
|
|
80
|
+
const said = [...got.risks, ...up.lines, ...got.copies];
|
|
81
|
+
tell(said);
|
|
82
|
+
held(got.blocked);
|
|
83
|
+
return { changed: said.length > 0 || got.blocked.length > 0, came: up.ports.length > 0, copied: Object.keys(got.env).length > 0 };
|
|
84
|
+
}
|
|
85
|
+
// Ports on this machine the hook saw the app reach. A compose image serves only a store's port: a
|
|
86
|
+
// model server behind a compose file is gigabytes nobody asked for.
|
|
87
|
+
const reached = (appPort) => connections().filter((c) => onThisMachine(c.host) && c.port !== appPort).map((c) => ({ port: c.port, store: isStorePort(c.port) }));
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
// The settings the app starts with: the copies it runs against.
|
|
91
|
+
env: async (started) => (await once(started)).env,
|
|
92
|
+
// The copies so far, for a service of the repository started beside the app.
|
|
93
|
+
envNow: () => now?.env ?? {},
|
|
94
|
+
// What trials write into for real first; the API keeps eight sentences of 400 characters.
|
|
95
|
+
lines: async (started) => { const k = await once(started); return [...k.risks, ...k.notes].map((l) => l.slice(0, 400)).slice(0, 8); },
|
|
96
|
+
// The stores off this machine no copy could be made of: each holds Run until the person says yes.
|
|
97
|
+
blocked: async (started) => blockedRows((await once(started)).blocked),
|
|
98
|
+
// The hosts a trial's writes may reach: the copies made off this machine, and the hosts and
|
|
99
|
+
// stores the person said yes to (setup.consent). Before the copies are made, or after the app
|
|
100
|
+
// was attached to instead, only the hosts.
|
|
101
|
+
outbound: ({ hosts = [], stores = [] } = {}) => {
|
|
102
|
+
if (!now) return [...new Set(hosts)];
|
|
103
|
+
const yes = new Set(stores);
|
|
104
|
+
const rest = now.blocked.length > BLOCKED_MAX ? overflow(now.blocked) : [];
|
|
105
|
+
if (rest.length && yes.has(overflowId(rest))) for (const b of rest) yes.add(b.id);
|
|
106
|
+
return [...new Set([...hosts, ...now.pass, ...now.stores.filter((s) => yes.has(storeId(s)) && !onThisMachine(s.host)).map((s) => String(s.host).toLowerCase())])];
|
|
107
|
+
},
|
|
108
|
+
// The app stopped before it answered: each store its settings name and each port it reached that
|
|
109
|
+
// is down is brought up, once. True when the app is worth starting again.
|
|
110
|
+
async backUp(appPort) {
|
|
111
|
+
if (backedUp) return false;
|
|
112
|
+
backedUp = true;
|
|
113
|
+
const k = await once(true);
|
|
114
|
+
const got = await bringUp([...localStores(k), ...reached(appPort)]);
|
|
115
|
+
return got.came || got.copied;
|
|
116
|
+
},
|
|
117
|
+
// The app answers: each store and service its settings name on this machine that nothing
|
|
118
|
+
// answers at is brought up, since an app that connects on first use starts without them.
|
|
119
|
+
// `copied` says the app has copies it was not started on.
|
|
120
|
+
async services(appPort) {
|
|
121
|
+
const k = await once(true);
|
|
122
|
+
return bringUp([...localStores(k), ...localAddresses(k.values)].filter((a) => a.port !== appPort));
|
|
123
|
+
},
|
|
124
|
+
// What the app reached since the last look. A live store no setting names is said, what is down
|
|
125
|
+
// is brought up, and `copied` says the app has copies it was not started on.
|
|
126
|
+
async watch(appPort) {
|
|
127
|
+
const conns = connections();
|
|
128
|
+
// A service that held Run and answers now lets go without a reconnect: the person started
|
|
129
|
+
// Supabase, and the hold stood until they disconnected and connected again.
|
|
130
|
+
const heldDown = now?.blocked.filter((b) => b.down) ?? [];
|
|
131
|
+
const cameUp = [];
|
|
132
|
+
for (const b of heldDown) if (await tcpOpen("127.0.0.1", b.port)) cameUp.push({ port: b.port, names: b.names });
|
|
133
|
+
if (cameUp.length) { const got = await bringUp(cameUp); if (!pending || conns.length === seen) return { changed: true, copied: got.copied }; }
|
|
134
|
+
if (!pending || conns.length === seen) return { changed: false, copied: false };
|
|
135
|
+
seen = conns.length;
|
|
136
|
+
const k = await pending;
|
|
137
|
+
const live = [];
|
|
138
|
+
for (const c of conns) if (!onThisMachine(c.host) || (await tcpOpen(c.host, c.port))) live.push(c);
|
|
139
|
+
const fresh = unnamedLines(live, k.stores, told);
|
|
140
|
+
k.risks.push(...fresh);
|
|
141
|
+
tell(fresh);
|
|
142
|
+
const bypassed = bypassedOf(live, k.stores, k.blocked);
|
|
143
|
+
k.blocked.push(...bypassed);
|
|
144
|
+
held(bypassed);
|
|
145
|
+
const got = await bringUp(reached(appPort));
|
|
146
|
+
return { changed: fresh.length > 0 || bypassed.length > 0 || got.changed, copied: got.copied };
|
|
147
|
+
},
|
|
148
|
+
// An app attached to instead of started keeps its own connections: its copies are of no use.
|
|
149
|
+
async forget() {
|
|
150
|
+
const k = await pending;
|
|
151
|
+
pending = null;
|
|
152
|
+
now = null;
|
|
153
|
+
await k?.keeper.drop();
|
|
154
|
+
},
|
|
155
|
+
// Once the app has stopped: a copy is dropped when nothing holds it open, and a store is stopped
|
|
156
|
+
// only after its copy is gone.
|
|
157
|
+
async close() {
|
|
158
|
+
await (await pending?.catch(() => null))?.keeper.drop().catch(() => {});
|
|
159
|
+
await backing?.stop().catch(() => {});
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
package/lib/ledger.mjs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// What this command made on the person's machine and has not yet undone: a database copied, a
|
|
2
|
+
// service started. Kept in the project's folder under ~/.cortad, so a session that died (a killed
|
|
3
|
+
// terminal, a closed lid) is undone by the next one. Rows name what was made, never an address or
|
|
4
|
+
// a password.
|
|
5
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname } from "node:path";
|
|
7
|
+
|
|
8
|
+
const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e.code !== "ESRCH"; } };
|
|
9
|
+
|
|
10
|
+
export function ledger(file) {
|
|
11
|
+
const read = () => { try { const rows = JSON.parse(readFileSync(file, "utf8")); return Array.isArray(rows) ? rows : []; } catch { return []; } };
|
|
12
|
+
const write = (rows) => { try { mkdirSync(dirname(file), { recursive: true, mode: 0o700 }); writeFileSync(file, JSON.stringify(rows), { mode: 0o600 }); } catch { /* undone by hand then */ } };
|
|
13
|
+
const same = (a, b) => a.kind === b.kind && a.key === b.key && a.made === b.made;
|
|
14
|
+
return {
|
|
15
|
+
add: (row) => write([...read().filter((r) => !same(r, row)), { ...row, pid: process.pid }]),
|
|
16
|
+
remove: (row) => write(read().filter((r) => !same(r, row))),
|
|
17
|
+
// Rows of `kind` whose session has ended.
|
|
18
|
+
orphans: (kind) => read().filter((r) => r.kind === kind && !alive(r.pid)),
|
|
19
|
+
};
|
|
20
|
+
}
|
package/lib/lock.mjs
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// bubblewrap. Inside it a shell line can read your project and run your tests, and it can write
|
|
4
4
|
// only where your own .gitignore already says files are disposable (caches, build output, coverage)
|
|
5
5
|
// and in temp. It cannot write a tracked file, cannot read your keys or environment files, and on a
|
|
6
|
-
// Mac cannot reach the network beyond this machine. With
|
|
6
|
+
// Mac cannot read what other programs left in /tmp or reach the network beyond this machine. With
|
|
7
|
+
// neither tool present there is no shell.
|
|
7
8
|
import { execFile, execFileSync } from "node:child_process";
|
|
8
9
|
import { existsSync, realpathSync } from "node:fs";
|
|
9
10
|
import { homedir, tmpdir } from "node:os";
|
|
@@ -60,6 +61,10 @@ export async function makeLock({ root, work }) {
|
|
|
60
61
|
const profile = [
|
|
61
62
|
"(version 1)(allow default)",
|
|
62
63
|
`(deny file-read-data (subpath ${q(real(home) ?? home)}))`,
|
|
64
|
+
// /tmp holds what other programs on this machine logged; a shell line may write there, never read it.
|
|
65
|
+
`(deny file-read-data (subpath ${q(real("/tmp") ?? "/tmp")}))`,
|
|
66
|
+
// Except the shell's own heredoc files: /bin/sh writes each one under /tmp and reads it back.
|
|
67
|
+
'(allow file-read-data (regex #"^/private/tmp/sh-thd"))',
|
|
63
68
|
`(allow file-read-data (subpath ${q(project)}) ${[work, ...tools].map((p) => `(subpath ${q(p)})`).join(" ")})`,
|
|
64
69
|
...(hide.length ? [`(deny file-read-data file-write* ${hide.join(" ")})`] : []),
|
|
65
70
|
"(deny network*)",
|
package/lib/mcp.mjs
CHANGED
|
@@ -8,37 +8,54 @@ export const PROTOCOL = "2025-06-18";
|
|
|
8
8
|
|
|
9
9
|
// Codex keeps only the first 512 characters self-contained and Claude Code shares about 4 KB across
|
|
10
10
|
// every server, so the first paragraph stands alone and the whole stays under 1,500 characters.
|
|
11
|
-
export const
|
|
12
|
-
"Cortad tests the AI
|
|
13
|
-
|
|
11
|
+
export const instructions = (spec = "cortad") => [
|
|
12
|
+
"Cortad tests the AI in this repository: simulated users written from the app's code talk to the app running on this machine, and every reply is checked. Cortad sends each AI endpoint a test request itself. status shows the app's port, what the reviews of the prompts and of the code around the model found, which endpoints answered and which failed at what file and line, and what a run would play and cost. The first run starts by itself; later runs when the person presses Run or asks.",
|
|
13
|
+
`Tell the person what status found in a few plain lines. Before you stop, start \`npx -y ${spec} wait\` as a background command: it returns when the next run ends, with what it found. After a run, call findings and fix the worst problem, then verify it; the person asked for these fixes when they connected Cortad. findings has each failure whole and findings numbers every rate. verify replays the failing conversations and says whether the failure is gone. field_connect gives the steps to connect production. dispute records a check that reads this app wrong.`,
|
|
14
14
|
].join("\n\n");
|
|
15
|
+
export const INSTRUCTIONS = instructions();
|
|
15
16
|
|
|
16
17
|
const str = (description, extra = {}) => ({ type: "string", description, ...extra });
|
|
17
18
|
const none = { type: "object", properties: {}, additionalProperties: false };
|
|
18
19
|
const readOnly = { readOnlyHint: true };
|
|
20
|
+
// What a note to the Cortad team may be about: a tool, the connect command, or the skill text.
|
|
21
|
+
const ABOUT = ["status", "reach", "run", "run_status", "findings", "verify", "dispute", "field_connect", "field", "connect", "skill"];
|
|
19
22
|
|
|
20
23
|
export const TOOLS = [
|
|
21
24
|
{ name: "status", title: "Cortad status", annotations: readOnly,
|
|
22
|
-
description: "What Cortad
|
|
23
|
-
inputSchema: { type: "object", properties: { show: { type: "string", enum: SHOW, description: "A section
|
|
25
|
+
description: "What Cortad found in this repository: whether the app is up and on which port, the reviews of its prompts and of the code around its model, each endpoint's answer to Cortad's test request or the file and line it failed at, the plan with runs left, and the latest run. Applies at the start of a session, right after a connect, and before a commit that changes prompts, tools, models or retrieval. With show, one section in full, in pages.",
|
|
26
|
+
inputSchema: { type: "object", properties: { show: { type: "string", enum: SHOW, description: "A section to list in full: rules, standards, journeys, endpoints, trials, records or reviews; or machine, what Cortad does on this machine." }, page: { type: "integer", minimum: 1, description: "The page of that list. Default 1." } }, additionalProperties: false } },
|
|
27
|
+
{ name: "reach", title: "Send Cortad's test requests again",
|
|
28
|
+
description: "Sends Cortad's test request again to the endpoints status lists as failed or not yet answered, or to the paths named, from this machine to the app on its port. Each endpoint comes back with its HTTP status, its time and, when it is not 2xx, the start of what the app answered.",
|
|
29
|
+
inputSchema: { type: "object", properties: {
|
|
30
|
+
paths: { type: "array", items: { type: "string" }, description: "Only these endpoints, by path. Default: every endpoint status lists as failed or not yet answered." },
|
|
31
|
+
}, additionalProperties: false } },
|
|
24
32
|
{ name: "run", title: "Start a run", inputSchema: none,
|
|
25
|
-
description: "Starts a run of
|
|
33
|
+
description: "Starts a run of conversations against the app on this machine and answers within a second with the run id; after the first run it plays what the files saved since the last full run reach, plus new conversations written for the change, and its result leads with what moved. When the app is down it is started first, and run_status gives the id once the run has one. Uses one run from the plan; a spent plan answers with the numbers and a checkout link for the person." },
|
|
26
34
|
{ name: "run_status", title: "Run progress", annotations: readOnly,
|
|
27
|
-
description: "Where a run or verify stands:
|
|
35
|
+
description: "Where a run or verify stands: conversations played of the total, the findings, what the app's rules and users measured, and for a verify whether the failure is gone, shows less often, stayed or cannot be told yet. The call holds up to 45 seconds until the count moves, and ends with the next call. With no jobId it follows the run this machine started last, or the latest run.",
|
|
28
36
|
inputSchema: { type: "object", properties: { jobId: str("A run or verify id, or the word pending. Omit for the run this machine started last.") }, additionalProperties: false } },
|
|
29
37
|
{ name: "findings", title: "Findings", annotations: readOnly,
|
|
30
|
-
description: "The failures of a run, worst first and grouped by file and line: the question, the
|
|
31
|
-
inputSchema: { type: "object", properties: { jobId: str("A run id. Omit for the latest."), page: { type: "integer", minimum: 1, description: "The page to read. Default 1." } }, additionalProperties: false } },
|
|
38
|
+
description: "The failures of a run, worst first and grouped by file and line: the question, the conversations it failed in with the range, the exchange, what a verify replays and how many clean replays would show it gone, then where the conversations ended and how their simulated users fared. With show numbers, every number the run measured: each question and measurement by journey, simulated user and reply, with ranges and the exchange behind the worst group. Once a run has finished, a long list comes in pages.",
|
|
39
|
+
inputSchema: { type: "object", properties: { jobId: str("A run id. Omit for the latest."), show: { type: "string", enum: ["numbers"], description: "numbers: every number the run measured, in pages." }, page: { type: "integer", minimum: 1, description: "The page to read. Default 1." } }, additionalProperties: false } },
|
|
32
40
|
{ name: "verify", title: "Verify a fix",
|
|
33
|
-
description: "Replays
|
|
41
|
+
description: "Replays the conversations a finding failed on, word for word, after a change, and again by itself until an exact test says the failure is gone, shows less often, stayed, or cannot tell yet with how many clean replays would decide. Replies refused or failed on the replay are said first; conversations of the same question saved to test the fix are replayed once, and where they disagree with the replays beyond chance the first sentence says so; a crash:N finding replays the requests out when the app stopped. Answers within a second, run_status follows every round, and it uses verify conversations from the plan, not a run.",
|
|
34
42
|
inputSchema: { type: "object", properties: { findingId: str("The finding id from findings."), jobId: str("The run the finding came from. Omit for the latest.") }, required: ["findingId"], additionalProperties: false } },
|
|
35
43
|
{ name: "dispute", title: "Dispute a check",
|
|
36
44
|
description: "Records that a finding's check reads this app wrong, with the reason and an optional better wording. The finding and its rate stay as they are; the note goes to the owner.",
|
|
37
45
|
inputSchema: { type: "object", properties: { findingId: str("The finding id."), why: str("One or two sentences: what the check gets wrong about this app.", { maxLength: 500 }), question: str("The wording that fits this app.", { maxLength: 300 }) }, required: ["findingId", "why"], additionalProperties: false } },
|
|
46
|
+
{ name: "feedback", title: "A note to the Cortad team",
|
|
47
|
+
description: "Sends the Cortad team a note about Cortad itself: a result that read wrong, something you needed that no tool gave, a question a result left open, or what worked. Filing one is part of using Cortad, and the team reads each note. Nothing about the run or its checks changes.",
|
|
48
|
+
inputSchema: { type: "object", properties: {
|
|
49
|
+
about: { type: "string", enum: ABOUT, description: "The tool the note is about, connect for the connect command, or skill for the instructions you were handed." },
|
|
50
|
+
kind: { type: "string", enum: ["problem", "idea", "question", "praise"], description: "problem: wrong or failed; idea: something you needed; question: something a result left open; praise: what worked." },
|
|
51
|
+
needed: str("What you were trying to do or find out.", { maxLength: 500 }),
|
|
52
|
+
got: str("What came back instead, in your own words; leave out your users' text and any key.", { maxLength: 1000 }),
|
|
53
|
+
tried: str("What you did about it: another call, a guess, or asked the person.", { maxLength: 500 }),
|
|
54
|
+
}, required: ["about", "kind", "needed"], additionalProperties: false } },
|
|
38
55
|
{ name: "field_connect", title: "Connect production", inputSchema: none,
|
|
39
|
-
description: "
|
|
56
|
+
description: "The steps to connect production, to see what real users are doing as it happens and where the AI lets them down. The owner creates the key in the browser." },
|
|
40
57
|
{ name: "field", title: "Production numbers", annotations: readOnly,
|
|
41
|
-
description: "Production in numbers: conversations read, checks
|
|
58
|
+
description: "Production in numbers: conversations read, checks passed, resolved, frustrated, asks for a human, and the rules broken most. Message text stays out of the answer.",
|
|
42
59
|
inputSchema: { type: "object", properties: { days: { type: "integer", minimum: 1, maximum: 180, description: "Window in days. Default 30." } }, additionalProperties: false } },
|
|
43
60
|
];
|
|
44
61
|
const NAMES = new Set(TOOLS.map((t) => t.name));
|
|
@@ -47,7 +64,7 @@ const error = (id, code, message) => ({ jsonrpc: "2.0", id, error: { code, messa
|
|
|
47
64
|
const result = (id, value) => ({ jsonrpc: "2.0", id, result: value });
|
|
48
65
|
|
|
49
66
|
// Serves until stdin closes. `verbs` is lib/verbs.mjs's object; `version` is the package's.
|
|
50
|
-
export function serveMcp({ verbs, version, input = process.stdin, output = process.stdout, log = () => {} }) {
|
|
67
|
+
export function serveMcp({ verbs, version, spec, input = process.stdin, output = process.stdout, log = () => {} }) {
|
|
51
68
|
return new Promise((resolve) => {
|
|
52
69
|
const send = (msg) => output.write(`${JSON.stringify(msg)}\n`);
|
|
53
70
|
let buffer = "";
|
|
@@ -73,7 +90,7 @@ export function serveMcp({ verbs, version, input = process.stdin, output = proce
|
|
|
73
90
|
try {
|
|
74
91
|
switch (method) {
|
|
75
92
|
case "initialize":
|
|
76
|
-
return send(result(id, { protocolVersion: PROTOCOL, capabilities: { tools: {} }, serverInfo: { name: "cortad", version }, instructions:
|
|
93
|
+
return send(result(id, { protocolVersion: PROTOCOL, capabilities: { tools: {} }, serverInfo: { name: "cortad", version }, instructions: instructions(spec) }));
|
|
77
94
|
case "notifications/initialized":
|
|
78
95
|
case "notifications/cancelled":
|
|
79
96
|
return;
|
|
@@ -86,7 +103,8 @@ export function serveMcp({ verbs, version, input = process.stdin, output = proce
|
|
|
86
103
|
const verb = NAMES.has(name) && Object.hasOwn(verbs, name) ? verbs[name] : null;
|
|
87
104
|
if (!verb) return send(result(id, { content: [{ type: "text", text: `No tool ${String(name)}.` }], isError: true }));
|
|
88
105
|
const out = await verb(params?.arguments ?? {});
|
|
89
|
-
|
|
106
|
+
const structured = out.structured && typeof out.structured === "object" && !Array.isArray(out.structured) ? { structuredContent: out.structured } : {};
|
|
107
|
+
return send(result(id, { content: [{ type: "text", text: out.text }], ...structured, ...(out.isError ? { isError: true } : {}) }));
|
|
90
108
|
}
|
|
91
109
|
default:
|
|
92
110
|
if (!notification) send(error(id, -32601, `method not found: ${String(method)}`));
|
package/lib/mint.mjs
CHANGED
|
@@ -100,57 +100,169 @@ async function firebaseIdToken(account, apiKey) {
|
|
|
100
100
|
return res.ok && out?.idToken ? { idToken: out.idToken } : { error: String(out?.error?.message ?? `status ${res.status}`).slice(0, 120) };
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
const LOOPBACK = new Set(["127.0.0.1", "localhost", "[::1]"]);
|
|
104
|
+
// A server key goes only over TLS, or to a service on this machine (a local Supabase).
|
|
105
|
+
function serviceOrigin(value) {
|
|
106
|
+
try { const u = new URL(value); return u.protocol === "https:" || (u.protocol === "http:" && LOOPBACK.has(u.hostname)) ? u.origin : null; } catch { return null; }
|
|
107
|
+
}
|
|
108
|
+
async function send(url, { method = "POST", headers = {}, body } = {}) {
|
|
109
|
+
const res = await fetch(url, { method, headers: { "content-type": "application/json", ...headers }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(20_000) });
|
|
110
|
+
return { ok: res.ok, status: res.status, json: await res.json().catch(() => null) };
|
|
111
|
+
}
|
|
112
|
+
const saidBy = (r) => String(r.json?.errors?.[0]?.long_message ?? r.json?.msg ?? r.json?.error_description ?? r.json?.message ?? `status ${r.status}`).slice(0, 120);
|
|
113
|
+
const refusedBy = (who, r) => ({ status: "refused", note: `${who} refused the test account: ${saidBy(r)}` });
|
|
114
|
+
// An account the service already held may have been given a role since this machine made it.
|
|
115
|
+
const ELEVATED_ACCOUNT = { status: "refused", note: "the test account carries an admin role; only an ordinary account is signed in" };
|
|
116
|
+
const elevated = (...claims) => claims.some((c) => c && ELEVATED.test(JSON.stringify(c)));
|
|
117
|
+
|
|
118
|
+
// Clerk's Backend API, in their development instance only: the account, a session for it, and the
|
|
119
|
+
// session's token, which lives a minute and is minted again from the same session before it lapses.
|
|
120
|
+
export async function clerkToken(env, account, session) {
|
|
121
|
+
const key = env.CLERK_SECRET_KEY;
|
|
122
|
+
if (!key) return { status: "absent", note: "no CLERK_SECRET_KEY in your environment" };
|
|
123
|
+
if (!key.startsWith("sk_test_")) return { status: "refused", note: "your CLERK_SECRET_KEY is a production key; test accounts are made only in a development instance" };
|
|
124
|
+
const origin = serviceOrigin(env.CLERK_API_URL || "https://api.clerk.com");
|
|
125
|
+
if (!origin) return { status: "refused", note: "CLERK_API_URL is not an https address" };
|
|
126
|
+
const api = `${origin}/v1`;
|
|
127
|
+
const auth = { authorization: `Bearer ${key}` };
|
|
128
|
+
const tokenOf = async (id) => (await send(`${api}/sessions/${encodeURIComponent(id)}/tokens`, { headers: auth })).json?.jwt;
|
|
129
|
+
const again = session && await tokenOf(session);
|
|
130
|
+
if (again) return { token: again, session };
|
|
131
|
+
// Clerk sends nothing to a +clerk_test address; it exists for exactly this.
|
|
132
|
+
const email = `${account.username}+clerk_test@example.com`;
|
|
133
|
+
const found = await send(`${api}/users?email_address=${encodeURIComponent(email)}`, { method: "GET", headers: auth });
|
|
134
|
+
let user = Array.isArray(found.json) ? found.json[0] : null;
|
|
135
|
+
if (!user) {
|
|
136
|
+
// No password: a session is made through this API, and an instance that signs in only by email
|
|
137
|
+
// code or OAuth refuses a user created with one.
|
|
138
|
+
const made = await send(`${api}/users`, { headers: auth, body: { email_address: [email], skip_password_requirement: true } });
|
|
139
|
+
if (!made.ok || !made.json?.id) return refusedBy("your Clerk instance", made);
|
|
140
|
+
user = made.json;
|
|
141
|
+
}
|
|
142
|
+
if (elevated(user.public_metadata, user.private_metadata)) return ELEVATED_ACCOUNT;
|
|
143
|
+
const opened = await send(`${api}/sessions`, { headers: auth, body: { user_id: user.id } });
|
|
144
|
+
if (!opened.ok || !opened.json?.id) return refusedBy("your Clerk instance", opened);
|
|
145
|
+
const token = await tokenOf(opened.json.id);
|
|
146
|
+
return token ? { token, session: opened.json.id } : { status: "refused", note: "your Clerk instance issued no session token" };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const SUPABASE_URL = /^(?:NEXT_PUBLIC_|VITE_|EXPO_PUBLIC_|PUBLIC_)?SUPABASE_URL$/;
|
|
150
|
+
const SUPABASE_SERVICE = /^SUPABASE_(?:SERVICE_ROLE|SERVICE|SECRET)_KEY$/;
|
|
151
|
+
const SUPABASE_PUBLIC = /^(?:NEXT_PUBLIC_|VITE_|EXPO_PUBLIC_|PUBLIC_)?SUPABASE_(?:ANON|PUBLISHABLE)(?:_DEFAULT)?_KEY$/;
|
|
152
|
+
const named = (env, pattern) => Object.entries(env).find(([name, value]) => pattern.test(name) && value)?.[1] ?? null;
|
|
153
|
+
|
|
154
|
+
// Supabase Auth's admin API makes the account already confirmed, with the service key their server
|
|
155
|
+
// holds; a password sign-in then issues the session their own client would hold.
|
|
156
|
+
export async function supabaseSession(env, account) {
|
|
157
|
+
const url = named(env, SUPABASE_URL);
|
|
158
|
+
const service = named(env, SUPABASE_SERVICE);
|
|
159
|
+
if (!url || !service) return { status: "absent", note: `no ${url ? "SUPABASE_SERVICE_ROLE_KEY" : "SUPABASE_URL"} in your environment` };
|
|
160
|
+
const origin = serviceOrigin(url);
|
|
161
|
+
if (!origin) return { status: "refused", note: "SUPABASE_URL is not an https address" };
|
|
162
|
+
const made = await send(`${origin}/auth/v1/admin/users`, { headers: { apikey: service, authorization: `Bearer ${service}` }, body: { email: account.email, password: account.password, email_confirm: true } });
|
|
163
|
+
if (!made.ok && !/already|exists|registered/i.test(saidBy(made))) return refusedBy("your Supabase project", made);
|
|
164
|
+
const got = await send(`${origin}/auth/v1/token?grant_type=password`, { headers: { apikey: named(env, SUPABASE_PUBLIC) ?? service }, body: { email: account.email, password: account.password } });
|
|
165
|
+
if (!got.ok || typeof got.json?.access_token !== "string") return refusedBy("your Supabase project", got);
|
|
166
|
+
if (elevated(got.json.user?.app_metadata, got.json.user?.role)) return ELEVATED_ACCOUNT;
|
|
167
|
+
return { session: got.json, origin };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// @supabase/ssr's own cookie: the session as base64url JSON under sb-<ref>-auth-token, cut into
|
|
171
|
+
// numbered chunks past 3180 characters the way the library cuts it.
|
|
172
|
+
export function supabaseCookie(origin, session) {
|
|
173
|
+
const name = `sb-${new URL(origin).hostname.split(".")[0]}-auth-token`;
|
|
174
|
+
const value = `base64-${Buffer.from(JSON.stringify(session)).toString("base64url")}`;
|
|
175
|
+
if (value.length <= 3180) return `${name}=${value}`;
|
|
176
|
+
return value.match(/.{1,3180}/g).map((part, i) => `${name}.${i}=${part}`).join("; ");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Each hosted service's sign-in, as the value the role's header carries and how long it holds.
|
|
180
|
+
const SIGN_IN = new Map([
|
|
181
|
+
["firebase", async ({ env, all }) => {
|
|
182
|
+
const account = serviceAccountOf(env);
|
|
183
|
+
const apiKey = webApiKeyOf(all);
|
|
184
|
+
if (!account || !apiKey) return { status: "absent", note: !account ? "no Firebase admin credential in your environment" : "no Firebase web API key in your environment" };
|
|
185
|
+
const got = await firebaseIdToken(account, apiKey);
|
|
186
|
+
if (!got.idToken) return { status: "refused", note: `your Firebase project refused the sign-in: ${got.error}` };
|
|
187
|
+
return { value: `Bearer ${got.idToken}`, ttl: 3600, door: "your Firebase project", said: `signed in to your app as a test user it can tell apart (${TEST_UID}, in your own Firebase project)` };
|
|
188
|
+
}],
|
|
189
|
+
["clerk", async ({ env, account, session }) => {
|
|
190
|
+
const got = await clerkToken(env, account, session);
|
|
191
|
+
return got.token ? { value: `Bearer ${got.token}`, ttl: 60, session: got.session, door: "your Clerk development instance", said: "made a test account in your Clerk development instance for the run to sign in with" } : got;
|
|
192
|
+
}],
|
|
193
|
+
["supabase-admin", async ({ env, account, header }) => {
|
|
194
|
+
const got = await supabaseSession(env, account);
|
|
195
|
+
if (!got.session) return got;
|
|
196
|
+
const s = got.session;
|
|
197
|
+
return {
|
|
198
|
+
value: header === "cookie" ? supabaseCookie(got.origin, s) : `Bearer ${s.access_token}`, secrets: [s.access_token, s.refresh_token],
|
|
199
|
+
ttl: Number(s.expires_in) || 3600, door: "your Supabase project", said: "made a test account in your Supabase project for the run to sign in with",
|
|
200
|
+
};
|
|
201
|
+
}],
|
|
202
|
+
]);
|
|
203
|
+
// Renewed this long after a sign-in: Clerk's token lives 60 s, Supabase's and Firebase's an hour.
|
|
204
|
+
const RENEW_MS = { firebase: 50 * 60_000, clerk: 50_000, "supabase-admin": 55 * 60_000 };
|
|
205
|
+
const PER_PERSON = new Set(["clerk", "supabase-admin"]);
|
|
206
|
+
// A simulated person's own caller: the run sends `clerk:p1a2b3c4` for the person behind a trial.
|
|
207
|
+
const PERSONA = /^([a-z][a-z0-9_-]{0,30}):p[0-9a-f]{8}$/;
|
|
208
|
+
|
|
103
209
|
export function makeIdentities({ root, work, envFiles, sourceFiles = () => [], say, keepSecret, appDir = () => root }) {
|
|
104
210
|
const dir = join(work, "identities");
|
|
105
211
|
const headerFile = (role) => join(dir, `bl-identity-${slug(role)}.header`);
|
|
106
|
-
const
|
|
212
|
+
const hosted = new Map();
|
|
213
|
+
// The roles signed in on this machine so far, in the order they were: the connect command's own
|
|
214
|
+
// test requests ask as the first when an endpoint refuses a visitor.
|
|
215
|
+
const minted = new Set();
|
|
107
216
|
const envs = () => {
|
|
108
217
|
// The app's own folder first: in a monorepo the root's env file is not the backend's.
|
|
109
218
|
const homes = [appDir(), root];
|
|
110
219
|
const own = homes.flatMap((h) => [envFiles.find((f) => dirname(f) === h && basename(f) === ".env"), envFiles.find((f) => dirname(f) === h)]).find(Boolean) ?? envFiles[0];
|
|
111
220
|
return { path: own ?? join(root, ".env"), all: [process.env, ...envFiles.map(readEnv)] };
|
|
112
221
|
};
|
|
113
|
-
const
|
|
222
|
+
const merged = () => Object.assign({}, ...[...envs().all].reverse());
|
|
223
|
+
const keep = (role, header, value, secrets = []) => {
|
|
224
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
225
|
+
writeFileSync(headerFile(role), `${header}: ${value}\n`, { mode: 0o600 });
|
|
226
|
+
for (const secret of [value.replace(/^Bearer\s+/i, ""), ...secrets]) keepSecret(secret);
|
|
227
|
+
};
|
|
114
228
|
|
|
115
|
-
|
|
229
|
+
// A hosted service's sign-in, kept on this machine and renewed through headerFor. Only the
|
|
230
|
+
// bearer or the cookie is ever filled, whatever header a recipe names.
|
|
231
|
+
async function signIn(row) {
|
|
232
|
+
const header = row.header === "cookie" ? "cookie" : "authorization";
|
|
116
233
|
const { all } = envs();
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (!
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
keep(row.role, row.header, `Bearer ${got.idToken}`);
|
|
125
|
-
if (!firebaseRoles.has(row.role)) say(`signed in to your app as a test user it can tell apart (${TEST_UID}, in your own Firebase project)`);
|
|
126
|
-
firebaseRoles.set(row.role, { header: row.header, at: Date.now() });
|
|
127
|
-
return { ...row, status: "minted", door: "your Firebase project", expires: Math.floor(Date.now() / 1000) + 3600 };
|
|
234
|
+
const got = await SIGN_IN.get(row.kind)({ env: merged(), all, header, account: accountsFor(root, [row.role])[row.role], session: hosted.get(row.role)?.session })
|
|
235
|
+
.catch((e) => ({ status: "unreachable", note: String(e?.message ?? e).slice(0, 120) }));
|
|
236
|
+
if (!got.value) return { ...row, status: got.status, note: got.note };
|
|
237
|
+
keep(row.role, header, got.value, got.secrets);
|
|
238
|
+
if (!hosted.has(row.role) && !PERSONA.test(row.role)) say(got.said);
|
|
239
|
+
hosted.set(row.role, { row: { ...row, header }, at: Date.now(), session: got.session });
|
|
240
|
+
return { ...row, header, status: "minted", door: got.door, expires: Math.floor(Date.now() / 1000) + got.ttl };
|
|
128
241
|
}
|
|
129
242
|
|
|
130
243
|
async function mint(body, port) {
|
|
131
244
|
let recipes = Array.isArray(body?.recipes) ? body.recipes.slice(0, 12) : [];
|
|
132
245
|
// On a refused first pass the raise asks again with this flag: sign in with the credential the
|
|
133
246
|
// app's own environment names, filling its sign-in form or a Basic header from that pair.
|
|
134
|
-
if (body?.envCreds)
|
|
135
|
-
const names = Object.keys(Object.assign({}, ...[...envs().all].reverse()));
|
|
136
|
-
recipes = envCredentialRecipes(recipes, names, body.loginRoutes).slice(0, 12);
|
|
137
|
-
}
|
|
247
|
+
if (body?.envCreds) recipes = envCredentialRecipes(recipes, Object.keys(merged()), body.loginRoutes).slice(0, 12);
|
|
138
248
|
if (!recipes.length || !port) return { identities: [] };
|
|
139
249
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
140
250
|
const out = join(dir, "bl-identities.json");
|
|
141
251
|
rmSync(out, { force: true });
|
|
142
252
|
// ponytail: the program is Python, as it is in the sandbox. A machine without python3 mints
|
|
143
253
|
// nothing and says so; port the program to this file if that turns out to be common.
|
|
144
|
-
// A
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
const
|
|
254
|
+
// A caller a hosted service verifies is signed in at that service, below; a Firebase caller goes
|
|
255
|
+
// to the program only when an emulator stands in for their project. Handed to the program it
|
|
256
|
+
// waited its whole deadline on an emulator that was never there, and one app's sign-in arrived
|
|
257
|
+
// after the world had given up waiting for it.
|
|
258
|
+
const env = merged();
|
|
259
|
+
const emulated = Boolean(env.FIREBASE_AUTH_EMULATOR_HOST);
|
|
260
|
+
const signsHere = (r) => SIGN_IN.has(r?.kind) && !(r.kind === "firebase" && emulated);
|
|
149
261
|
const refused = new Map();
|
|
150
262
|
// A pair the environment names for the app's own account is the one credential the raise is
|
|
151
263
|
// allowed to reuse: the elevation and existing-account guards do not apply to it.
|
|
152
|
-
for (const r of recipes) { const why = r?.envCred ? null : refusal(r,
|
|
153
|
-
const forProgram = recipes.filter((r) => !refused.has(r?.role) && (r
|
|
264
|
+
for (const r of recipes) { const why = r?.envCred ? null : refusal(r, env, (value) => inSource(root, sourceFiles(), value)); if (why && typeof r?.role === "string") refused.set(r.role, why); }
|
|
265
|
+
const forProgram = recipes.filter((r) => !refused.has(r?.role) && !signsHere(r));
|
|
154
266
|
// The program signs in once per role value ("staff" as sales, support, marketing), each named
|
|
155
267
|
// role:value; an account made for "staff" alone was never the one it asked for.
|
|
156
268
|
const accounts = accountsFor(root, forProgram.flatMap((r) => (Array.isArray(r.roles) && r.roles.length ? r.roles.map((v) => `${r.role}:${v}`) : [r.role])));
|
|
@@ -166,22 +278,42 @@ export function makeIdentities({ root, work, envFiles, sourceFiles = () => [], s
|
|
|
166
278
|
if (typeof recipe?.role !== "string" || !ROLE.test(recipe.role)) continue;
|
|
167
279
|
const blank = { role: recipe.role, kind: recipe.kind, header: recipe.header, opens: [], door: recipe.header, where: recipe.where };
|
|
168
280
|
if (refused.has(recipe.role)) { rows.push({ ...blank, status: "refused", note: refused.get(recipe.role) }); continue; }
|
|
169
|
-
|
|
170
|
-
rows.push(recipe.
|
|
281
|
+
if (signsHere(recipe)) { rows.push(await signIn({ ...blank, status: "upstream" })); continue; }
|
|
282
|
+
rows.push(known.get(recipe.role) ?? { ...blank, status: ran ? "unreachable" : "absent", ...(ran ? {} : { note: "python3 is not installed on this machine" }) });
|
|
283
|
+
}
|
|
284
|
+
for (const row of rows.filter((r) => r.status === "minted")) {
|
|
285
|
+
minted.add(row.role);
|
|
286
|
+
if (existsSync(headerFile(row.role))) keepSecret(readFileSync(headerFile(row.role), "utf8").split(": ").slice(1).join(": ").trim().replace(/^Bearer\s+/i, ""));
|
|
171
287
|
}
|
|
172
|
-
for (const row of rows) if (row.status === "minted" && existsSync(headerFile(row.role))) keepSecret(readFileSync(headerFile(row.role), "utf8").split(": ").slice(1).join(": ").trim().replace(/^Bearer\s+/i, ""));
|
|
173
288
|
return { identities: rows };
|
|
174
289
|
}
|
|
175
290
|
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
291
|
+
// A hosted token renewed before it lapses, and a simulated person's own account signed in the
|
|
292
|
+
// first time that person speaks. One sign-in per role at a time: lanes asking together share it.
|
|
293
|
+
const renewing = new Map();
|
|
294
|
+
const tried = new Set();
|
|
295
|
+
const shared = new Set();
|
|
296
|
+
async function due(role) {
|
|
297
|
+
const held = hosted.get(role);
|
|
298
|
+
if (held) { if (Date.now() - held.at > RENEW_MS[held.row.kind]) await signIn(held.row); return; }
|
|
299
|
+
const owner = hosted.get(PERSONA.exec(role)?.[1]);
|
|
300
|
+
if (!owner || !PER_PERSON.has(owner.row.kind) || tried.has(role)) return;
|
|
301
|
+
tried.add(role);
|
|
302
|
+
const got = await signIn({ ...owner.row, role });
|
|
303
|
+
if (got.status !== "minted" && !shared.has(owner.row.role)) {
|
|
304
|
+
shared.add(owner.row.role);
|
|
305
|
+
say(`a simulated person's own test account could not be made (${got.note ?? got.status}), so that person signs in as the shared test account`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
const renewed = (role) => {
|
|
309
|
+
if (!renewing.has(role)) renewing.set(role, due(role).catch(() => null).finally(() => renewing.delete(role)));
|
|
310
|
+
return renewing.get(role);
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
// A token the app's door issued, or a credential that is itself one of their secrets (an admin
|
|
314
|
+
// key). In the sandbox the second never leaves the box; here it never leaves this machine, which
|
|
315
|
+
// is the same promise, because this is where the request is sent from.
|
|
316
|
+
function held(role) {
|
|
185
317
|
const file = [headerFile(role), headerFile(role).replace(/\.header$/, ".sealed")].find((f) => { try { return statSync(f).isFile(); } catch { return false; } });
|
|
186
318
|
if (!file) return null;
|
|
187
319
|
const line = readFileSync(file, "utf8").split("\n")[0] ?? "";
|
|
@@ -189,5 +321,14 @@ export function makeIdentities({ root, work, envFiles, sourceFiles = () => [], s
|
|
|
189
321
|
return at > 0 ? { name: line.slice(0, at).trim().toLowerCase(), value: line.slice(at + 2).trim() } : null;
|
|
190
322
|
}
|
|
191
323
|
|
|
192
|
-
|
|
324
|
+
// The header a role speaks with, or nothing. A person whose own account could not be made speaks
|
|
325
|
+
// as the role that person belongs to, the one account every trial used before.
|
|
326
|
+
async function headerFor(role) {
|
|
327
|
+
if (!ROLE.test(role)) return null;
|
|
328
|
+
await renewed(role);
|
|
329
|
+
const base = PERSONA.exec(role)?.[1];
|
|
330
|
+
return held(role) ?? (base ? held(base) : null);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return { mint, headerFor, roles: () => [...minted] };
|
|
193
334
|
}
|