cortad 0.3.9 → 0.3.11

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.
@@ -0,0 +1,124 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { basename, dirname, join, sep } from "node:path";
3
+ import { listingUrl } from "./listing.mjs";
4
+
5
+ // An env file read the way dotenv and python-dotenv read one: comments and `export` dropped, a quoted
6
+ // value kept whole across lines, an unquoted one cut at " #", ${NAME} filled from the names above it
7
+ // and then from `outer`. A bare $ is kept as written, as dotenv keeps it: "pa$$word" and a bcrypt hash
8
+ // are values, not references. A quote that never closes is the line as written. A line-by-line read
9
+ // handed an app the first line of a quoted PEM key and an unquoted value with its comment still on it.
10
+ export function parseEnv(text, outer = {}) {
11
+ const out = {};
12
+ const fill = (value) => value.replace(/(\\?)\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (whole, escaped, name) => (escaped ? whole.slice(1) : out[name] ?? outer[name] ?? ""));
13
+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
14
+ for (let i = 0; i < lines.length; i++) {
15
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(lines[i]);
16
+ if (!m) continue;
17
+ const rest = m[2];
18
+ const quote = rest[0];
19
+ if (quote === '"' || quote === "'") {
20
+ let body = rest.slice(1);
21
+ let j = i;
22
+ while (!closes(body, quote) && j + 1 < lines.length) body += `\n${lines[++j]}`;
23
+ const end = closes(body, quote);
24
+ if (end) {
25
+ i = j;
26
+ const raw = body.slice(0, end - 1);
27
+ out[m[1]] = quote === "'" ? raw : fill(raw).replace(/\\n/g, "\n").replace(/\\(["\\$])/g, "$1");
28
+ continue;
29
+ }
30
+ }
31
+ out[m[1]] = fill(rest.replace(/\s+#.*$/, "").trim());
32
+ }
33
+ return out;
34
+ }
35
+ // One past the closing quote, or 0 when the value has none yet. A double quote escaped by a backslash
36
+ // does not close it.
37
+ function closes(body, quote) {
38
+ for (let i = 0; i < body.length; i++) {
39
+ if (quote === '"' && body[i] === "\\") { i++; continue; }
40
+ if (body[i] === quote) return i + 1;
41
+ }
42
+ return 0;
43
+ }
44
+
45
+ const read = (file, outer) => { try { return parseEnv(readFileSync(file, "utf8"), outer); } catch { return {}; } };
46
+ const PLACEHOLDERS = /\.(?:example|sample)$/;
47
+
48
+ // The folders from the app's own up to the repository root, nearest first.
49
+ const upFrom = (appDir, root) => {
50
+ const dirs = [];
51
+ for (let d = appDir; ; d = dirname(d)) {
52
+ dirs.push(d);
53
+ if (d === root || !d.startsWith(root + sep) || dirname(d) === d) return dirs;
54
+ }
55
+ };
56
+
57
+ // What a start by hand gives the app from its env files: the `.env` nearest the app, as `source .env`
58
+ // does for an app with no loader of its own. A name another env file there sets to another value is
59
+ // left to the app's own loader, which reads its files by its own mode and order: put in the process,
60
+ // it outranks that loader (the process wins in dotenv, python-dotenv, pydantic-settings, Next and
61
+ // Vite). Nitham's app reads .env.test then .env.local; the test file's key, handed over this way,
62
+ // was refused on every call it made under Cortad and on none started by hand.
63
+ export function appEnv(envFiles, appDir, root, outer = {}) {
64
+ const base = upFrom(appDir, root).map((d) => join(d, ".env")).find((f) => envFiles.includes(f));
65
+ if (!base) return {};
66
+ const values = read(base, outer);
67
+ const beside = envFiles.filter((f) => f !== base && !PLACEHOLDERS.test(f) && (dirname(f) === dirname(base) || dirname(f) === appDir));
68
+ for (const file of beside) {
69
+ for (const [name, value] of Object.entries(read(file, outer))) if (name in values && values[name] !== value) delete values[name];
70
+ }
71
+ return values;
72
+ }
73
+
74
+ // The real env files in the app's folders, lowest first, the way dotenv-flow, Next and Vite rank them
75
+ // in development: .env, then the development file, then .env.local, and the file of the mode the shell
76
+ // names above all. A test, staging or stage file of another mode goes under them all and still counts:
77
+ // Nitham's app reads .env.test in its local mode, and a store only it names is a store the app writes
78
+ // into. Placeholders are left out.
79
+ const RANK = { ".env": 1, ".env.development": 2, ".env.dev": 2, ".env.local": 3, ".env.development.local": 4 };
80
+ const MODE_FILE = /^\.env\.(test|staging|stage)$/;
81
+ export function rankedFiles(envFiles, appDir, root, mode = "") {
82
+ const dirs = upFrom(appDir, root).reverse();
83
+ const want = mode.toLowerCase() === "staging" ? "stage" : mode.toLowerCase();
84
+ const rank = (f) => {
85
+ const name = basename(f);
86
+ const m = MODE_FILE.exec(name);
87
+ if (m) return (m[1] === "staging" ? "stage" : m[1]) === want ? 5 : 0;
88
+ return RANK[name] ?? -1;
89
+ };
90
+ return envFiles.filter((f) => dirs.includes(dirname(f)) && !PLACEHOLDERS.test(f) && rank(f) >= 0)
91
+ .sort((a, b) => dirs.indexOf(dirname(a)) - dirs.indexOf(dirname(b)) || rank(a) - rank(b));
92
+ }
93
+
94
+ // The values the app's settings could settle on, ranked as above: for reading which stores and ports
95
+ // the app names, and for a shell that cannot read the files itself. `outer` names the mode and fills ${NAME}.
96
+ export function localEnv(envFiles, appDir, root, outer = {}) {
97
+ const mode = outer.ENV ?? outer.APP_ENV ?? outer.NODE_ENV ?? outer.ENVIRONMENT ?? "";
98
+ return Object.assign({}, ...rankedFiles(envFiles, appDir, root, mode).map((f) => read(f, outer)));
99
+ }
100
+
101
+ // Each real env file in the app's folders, read whole: a key is only ever sent with the address the
102
+ // same file gives it, or one the app's development files give every key.
103
+ export function fileValues(envFiles, appDir, root, outer = {}) {
104
+ const dirs = upFrom(appDir, root);
105
+ return envFiles.filter((f) => dirs.includes(dirname(f)) && !PLACEHOLDERS.test(f)).map((f) => read(f, outer));
106
+ }
107
+
108
+ // The values a development start reads whatever its mode: .env, the development files and .env.local,
109
+ // never a test, staging or stage file.
110
+ export function devEnv(envFiles, appDir, root, outer = {}) {
111
+ return Object.assign({}, ...rankedFiles(envFiles, appDir, root).filter((f) => !MODE_FILE.test(basename(f))).map((f) => read(f, outer)));
112
+ }
113
+
114
+ // The key and address pairs a provider's model listing is asked with: the shell's key, else each
115
+ // distinct key a file holds. A key goes only to the address the shell or its own file names, else the
116
+ // one the development files name, else the provider's own; where only a test or staging file names
117
+ // an address, which the app may not use, the key is not sent at all.
118
+ export function keyTries(name, canonical, { shell, files, dev }) {
119
+ const prefix = name.replace(/_API_KEY$/, "");
120
+ const named = (v) => Boolean(v[`${prefix}_BASE_URL`] || v[`${prefix}_API_BASE`]);
121
+ const addressFor = (own) => (named(shell) || named(own) || named(dev) || !files.some(named) ? listingUrl(name, canonical, { ...dev, ...own, ...shell }) : null);
122
+ return [...new Map((shell[name] ? [shell] : files.filter((v) => v[name]))
123
+ .map((v) => ({ key: v[name], url: addressFor(v) })).filter((t) => t.url).map((t) => [`${t.url} ${t.key}`, t])).values()];
124
+ }
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { StringDecoder } from "node:string_decoder";
3
+ import { parseEnv } from "./env-files.mjs";
3
4
 
4
5
  // A model's or provider's name is how the app is configured, not a credential: status names it and
5
6
  // prices the run by it. A value under such a name that looks like a key stays masked all the same.
@@ -7,18 +8,25 @@ const NAMES_A_MODEL = /(?:^|_)(?:MODEL|MODELS|MODEL_NAME|MODEL_ID|PROVIDER)$/i;
7
8
  const KEY_LIKE = /[A-Za-z0-9]{24,}/;
8
9
 
9
10
  // Values from your env files, read here and only here, so nothing a command prints can carry one.
11
+ // Each value as the line holds it and as the app's own loader reads it (lib/env-files.mjs parseEnv),
12
+ // and each line of one that spans lines: the app is handed the parsed value, so a value that differs
13
+ // from its line (a trailing comment, an escaped quote, a key across lines) must be masked as parsed.
10
14
  export function secretValues(envFiles) {
11
15
  const values = new Set();
16
+ const add = (name, v) => {
17
+ if (NAMES_A_MODEL.test(name) && !KEY_LIKE.test(v)) return;
18
+ for (const one of v.includes("\n") ? [v, ...v.split("\n").map((l) => l.trim()).filter((l) => l.length >= 12)] : [v]) {
19
+ if (one.length >= 8 && !/^(true|false|localhost|development|production|\d+)$/i.test(one)) values.add(one);
20
+ }
21
+ };
12
22
  for (const file of envFiles) {
13
23
  let text = "";
14
24
  try { text = readFileSync(file, "utf8"); } catch { continue; }
15
25
  for (const line of text.split("\n")) {
16
26
  const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
17
- if (!m) continue;
18
- const v = m[2].trim().replace(/^(['"])(.*)\1$/, "$2");
19
- if (NAMES_A_MODEL.test(m[1]) && !KEY_LIKE.test(v)) continue;
20
- if (v.length >= 8 && !/^(true|false|localhost|development|production|\d+)$/i.test(v)) values.add(v);
27
+ if (m) add(m[1], m[2].trim().replace(/^(['"])(.*)\1$/, "$2"));
21
28
  }
29
+ for (const [name, v] of Object.entries(parseEnv(text))) add(name, v);
22
30
  }
23
31
  return [...values].sort((a, b) => b.length - a.length);
24
32
  }
@@ -0,0 +1,47 @@
1
+ // What the agent reads back from the Intelligence Layer's tools, as plain lines: numbers, ids and
2
+ // the cause, never a user's words.
3
+
4
+ const n = (v) => (typeof v === "number" ? v.toLocaleString("en-US") : String(v ?? 0));
5
+ const when = (iso) => (iso ? new Date(iso).toISOString().replace("T", " ").slice(0, 16) + "Z" : "");
6
+
7
+ export function incidentLine(i) {
8
+ const where = i.segment ? (i.segment.kind === "country" ? ` from ${i.segment.value}` : ` on release ${i.segment.value}`) : "";
9
+ const cause = i.cause ? `; the change was ${i.cause.kind} ${i.cause.value ?? ""}`.trimEnd() + (i.cause.at ? ` at ${when(i.cause.at)}` : "") : "";
10
+ return `Since ${when(i.since)}, ${i.kind === "reaction" ? `people${where} reacted "${i.key}" after` : `replies${where} broke "${i.key}" in`} ${n(i.now.broke)} of ${n(i.now.of)}; before, ${n(i.before.broke)} of ${n(i.before.of)}${cause}.`;
11
+ }
12
+
13
+ export function intelligenceText(d) {
14
+ const lines = [];
15
+ lines.push(d.wrong?.length ? `Wrong now: ${d.wrong.length}.` : "Nothing is wrong right now.");
16
+ for (const i of d.wrong ?? []) lines.push(`- ${incidentLine(i)}`);
17
+ const t = d.conversations ?? {};
18
+ lines.push(`Last ${d.days} days: ${n(t.n)} conversations, ${n(t.read)} read, ${n(t.heldEvery)} kept every rule, ${n(t.got)} got what they came for, ${n(t.frustrated)} frustrated, ${n(t.wantsHuman)} asked for a person.`);
19
+ if (d.visits?.n) {
20
+ const r = d.visits.reactions ?? {};
21
+ lines.push(`Visits: ${n(d.visits.n)}; after answers people ${Object.entries(r).filter(([k]) => k !== "waited").map(([k, v]) => `${k} ${n(v)}`).join(", ") || "reacted to nothing yet"} (of ${n(r.waited)} answers seen).`);
22
+ }
23
+ if (d.checks?.length) lines.push(`Checks broken most: ${d.checks.filter((c) => c.broke).sort((a, b) => b.broke - a.broke).slice(0, 5).map((c) => `${c.id} ${n(c.broke)} of ${n(c.held + c.broke)}`).join("; ") || "none"}.`);
24
+ lines.push(`The page: ${d.url}`);
25
+ return lines.join("\n");
26
+ }
27
+
28
+ export function incidentsText(d) {
29
+ if (!d.open?.length) return `${d.said ?? "Nothing is wrong right now."}\n${d.url}`;
30
+ return [...d.open.map((i) => `${incidentLine(i)} Conversations: ${(i.conversations ?? []).join(", ")}`), d.said, d.url].filter(Boolean).join("\n");
31
+ }
32
+
33
+ const ruleWords = (r) => r.kind === "path" ? `a page ${r.match} ${r.value}` : r.kind === "click" ? `a click on ${r.text ? `"${r.text}"` : r.selector}` : r.kind === "event" ? `the page's event "${r.name}"` : `the backend's outcome "${r.name}"`;
34
+
35
+ export function outcomesText(d) {
36
+ if (d.proposed !== undefined) return `${n(d.proposed)} proposed. ${d.awaiting}`;
37
+ if (!d.outcomes?.length) return `No outcome is defined yet. Propose what the code treats as a good ending (a signup route, a checkout, a "done" button) with outcomes {proposals: [{name, rules, why}]}; ${d.shape}`;
38
+ return [...d.outcomes.map((o) => `${o.approved ? "Counted" : "Proposed, awaiting the person"}: ${o.name}: ${o.rules.map(ruleWords).join(", or ")}${o.why ? ` (${o.why})` : ""}`), d.url].join("\n");
39
+ }
40
+
41
+ export function queryText(d) {
42
+ if (d.error) return `${d.error}${d.sql ? `\n${d.sql}` : ""}`;
43
+ const rows = d.rows ?? [];
44
+ if (rows.length === 0) return `No rows.\n${d.sql}`;
45
+ const keys = Object.keys(rows[0]);
46
+ return [keys.join("\t"), ...rows.slice(0, 200).map((r) => keys.map((k) => String(r[k] ?? "")).join("\t")), rows.length > 200 ? `${rows.length - 200} more rows` : "", d.scannedBytes != null ? `Scanned ${n(d.scannedBytes)} bytes.` : ""].filter(Boolean).join("\n");
47
+ }
package/lib/keeping.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // What one session keeps out of the run's way and brings up for it: the app's database files and
2
2
  // servers, copied before it starts (lib/data.mjs, lib/stores.mjs), and the stores and services it
3
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.
4
+ // and to the run: the copies made through lines(), and each store with none through blocked().
5
5
  import { createHash } from "node:crypto";
6
6
  import { localAddresses, makeBacking, tcpOpen } from "./backing.mjs";
7
7
  import { keepData } from "./data.mjs";
@@ -14,7 +14,7 @@ const addressed = (s) => Boolean(s.host) && !s.host.startsWith("/") && Boolean(s
14
14
  // One nothing answers at is left for later: trials cannot write into it.
15
15
  const isDown = async (s) => addressed(s) && !(await tcpOpen(s.host, s.port));
16
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 }));
17
+ const localStores = (k) => k.named.filter((s) => addressed(s) && onThisMachine(s.host)).map((s) => ({ port: s.port, names: s.names, store: true }));
18
18
  // The API keeps eight stores that hold Run. Past that, the rest travel as one row, and a yes to that
19
19
  // row is a yes to each of them; a row more changes its id, so an old yes no longer covers it.
20
20
  const BLOCKED_MAX = 8;
@@ -37,48 +37,51 @@ function blockedRows(blocked) {
37
37
  // `typed`: the person gave the start command. It is run as it is, with the settings and the stores
38
38
  // that command gives the app: a legal research API typed ENV=staging and was started on copies of
39
39
  // the test databases its default settings file names, where its library is empty.
40
+ // `chosen`: the store settings of the file the person chose for the run (--data). Those stores are
41
+ // the ones copied, and the app is handed all of those settings, so it reaches the server they name.
40
42
  const AS_TYPED = "Your app is started with your own command as it is, on the databases that command gives it. Nothing is copied: a run writes into them once you say yes to each on the card.";
41
- export function makeKeeping({ root, work, ledgerFile, onPath, say, settings, launch, connections, copies = () => [], hold = () => {}, typed = false }) {
43
+ export function makeKeeping({ root, work, ledgerFile, onPath, say, settings, launch, connections, copies = () => [], hold = () => {}, typed = false, chosen = {} }) {
42
44
  let pending = null;
43
45
  let now = null;
44
46
  let backing = null;
45
47
  let backedUp = false;
46
48
  let seen = 0;
47
49
  const tell = (lines) => { for (const line of lines) say(lower(line)); };
48
- const held = (blocked) => tell(blocked.map((b) => `Run waits for your yes on the card in the browser: ${lower(b.why)}`));
50
+ const held = (blocked) => tell(blocked.map((b) => `One question for the person on the card in the browser: ${lower(b.why)}`));
49
51
 
50
52
  async function keepAll(started) {
51
- if (typed) {
52
- const none = async () => ({ env: {}, risks: [], copies: [], blocked: [], pass: [] });
53
- // The app's own command may still reach a Qdrant: the copies the hook makes there are dropped too.
54
- const keeper = storeKeeper({ ledgerFile, values: {}, dirs: [] });
55
- now = { env: {}, risks: [], notes: [AS_TYPED], blocked: [], pass: [], stores: [], keeper: { keep: none, drop: async () => {}, dropCopies: (c, s) => keeper.dropCopies(c, s) }, values: {}, started };
56
- tell(now.notes);
57
- return now;
58
- }
59
53
  const { values, from, appDir, sources } = settings();
60
- const disk = keepData({ values, from, appDir, root, work, sources, started });
54
+ // Which stores a typed command gives the app is its own: only those chosen for the run are copied,
55
+ // and each other store it reaches holds Run once the hook sees it (watch). A store it reaches for
56
+ // and finds nothing at is still brought up: a session that ended stopped the ones it had started,
57
+ // and the app started next with its own command met "connection refused".
58
+ const disk = typed ? { env: {}, said: Object.keys(chosen).length ? [] : [AS_TYPED], held: [], values: chosen } : keepData({ values, from, appDir, root, work, sources, started });
61
59
  const stores = storesOf(disk.values);
62
60
  const keeper = storeKeeper({ ledgerFile, values: disk.values, dirs: [...new Set([appDir, root])] });
63
61
  const got = await keeper.keep(stores, { started, down: isDown });
64
- backing ??= makeBacking({ root, appDir, values: disk.values, onPath, ledgerFile, launch, adopt: started });
65
- 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 };
66
- tell([...now.risks, ...now.notes]);
62
+ backing ??= makeBacking({ root, appDir, values: typed ? values : disk.values, onPath, ledgerFile, launch, adopt: started });
63
+ now = { env: { ...chosen, ...disk.env, ...got.env }, notes: [...disk.said, ...got.copies], blocked: [...disk.held, ...got.blocked], pass: got.pass, stores, named: stores, keeper, values: disk.values, started };
64
+ tell(now.notes);
67
65
  held(now.blocked);
68
66
  return now;
69
67
  }
70
68
  // A read that failed is tried again on the next ask, never handed back as the same failure.
71
69
  const once = (started) => (pending ??= keepAll(started).catch((e) => { pending = null; throw e; }));
70
+ // The store ids the person's yes covers: a yes to the row the rest travel as is a yes to each.
71
+ const yesOf = (stores) => {
72
+ const yes = new Set(stores);
73
+ const rest = now && now.blocked.length > BLOCKED_MAX ? overflow(now.blocked) : [];
74
+ if (rest.length && yes.has(overflowId(rest))) for (const b of rest) yes.add(b.id);
75
+ return yes;
76
+ };
72
77
 
73
78
  // Brings up what is wanted, copies each store that now answers, and says both.
74
79
  async function bringUp(wants) {
75
- if (typed) return { changed: false, came: false, copied: false };
76
80
  const k = await once(true);
77
81
  hold();
78
82
  const up = await backing.up(wants);
79
83
  const got = await k.keeper.keep(k.stores, { started: k.started, down: isDown });
80
84
  Object.assign(k.env, got.env);
81
- k.risks.unshift(...got.risks);
82
85
  // Said once: every look brings the same lines back, and status printed one hold four times.
83
86
  k.notes.push(...[...up.lines, ...got.copies].filter((l) => !k.notes.includes(l)));
84
87
  // A service a setting names that answers nothing holds Run until it does; one that answers now
@@ -89,7 +92,7 @@ export function makeKeeping({ root, work, ledgerFile, onPath, say, settings, lau
89
92
  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 });
90
93
  k.blocked.push(...got.blocked.filter((b) => !k.blocked.some((x) => x.id === b.id)));
91
94
  k.pass.push(...got.pass);
92
- const said = [...got.risks, ...up.lines, ...got.copies];
95
+ const said = [...up.lines, ...got.copies];
93
96
  tell(said);
94
97
  held(got.blocked);
95
98
  return { changed: said.length > 0 || got.blocked.length > 0, came: up.ports.length > 0, copied: Object.keys(got.env).length > 0 };
@@ -103,20 +106,24 @@ export function makeKeeping({ root, work, ledgerFile, onPath, say, settings, lau
103
106
  env: async (started) => (await once(started)).env,
104
107
  // The copies so far, for a service of the repository started beside the app.
105
108
  envNow: () => now?.env ?? {},
106
- // What trials write into for real first; the API keeps eight sentences of 400 characters.
107
- lines: async (started) => { const k = await once(started); return [...k.risks, ...k.notes].map((l) => l.slice(0, 400)).slice(0, 8); },
108
- // The stores off this machine no copy could be made of: each holds Run until the person says yes.
109
+ // The copies trials write into; the API keeps eight sentences of 400 characters.
110
+ lines: async (started) => (await once(started)).notes.map((l) => l.slice(0, 400)).slice(0, 8),
111
+ // The stores no copy could be made of: each holds Run until the person says yes.
109
112
  blocked: async (started) => blockedRows((await once(started)).blocked),
110
113
  // The hosts a trial's writes may reach: the copies made off this machine, and the hosts and
111
114
  // stores the person said yes to (setup.consent). Before the copies are made, or after the app
112
115
  // was attached to instead, only the hosts.
113
116
  outbound: ({ hosts = [], stores = [] } = {}) => {
114
117
  if (!now) return [...new Set(hosts)];
115
- const yes = new Set(stores);
116
- const rest = now.blocked.length > BLOCKED_MAX ? overflow(now.blocked) : [];
117
- if (rest.length && yes.has(overflowId(rest))) for (const b of rest) yes.add(b.id);
118
+ const yes = yesOf(stores);
118
119
  return [...new Set([...hosts, ...now.pass, ...now.stores.filter((s) => yes.has(storeId(s)) && !onThisMachine(s.host)).map((s) => String(s.host).toLowerCase())])];
119
120
  },
121
+ // The stores with no copy the person has not said yes to writing into. A service that is down
122
+ // holds Run too, but nothing can be written into it.
123
+ holding: ({ stores = [] } = {}) => {
124
+ const yes = yesOf(stores);
125
+ return now?.blocked.filter((b) => !b.down && !yes.has(b.id)) ?? [];
126
+ },
120
127
  // The app stopped before it answered: each store its settings name and each port it reached that
121
128
  // is down is brought up, once. True when the app is worth starting again.
122
129
  async backUp(appPort) {
@@ -151,7 +158,7 @@ export function makeKeeping({ root, work, ledgerFile, onPath, say, settings, lau
151
158
  const bypassed = [...bypassedOf(live, k.stores, k.blocked), ...unnamedHeld(live, k.stores, k.blocked)];
152
159
  k.blocked.push(...bypassed);
153
160
  held(bypassed);
154
- const got = typed ? { changed: false, copied: false } : await bringUp(reached(appPort));
161
+ const got = await bringUp(reached(appPort));
155
162
  return { changed: bypassed.length > 0 || got.changed, copied: got.copied };
156
163
  },
157
164
  // An app attached to instead of started keeps its own connections: its copies are of no use.
package/lib/keys.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { parseEnv } from "./env-files.mjs";
1
2
  // A file that carries a key stays on this machine, whatever git thinks of it: a catalogue that keeps
2
3
  // an AES key per video is tracked like code and is no more shareable than a .env.
3
4
  // The header alone is code that looks for keys; a key has its body after it.
@@ -21,18 +22,21 @@ export function holdsKeys(text, values = []) {
21
22
  const SECRET_NAME = /KEY|SECRET|TOKEN|PASSW|PRIVATE|CREDENTIAL/i;
22
23
  const EXAMPLE = /\.(example|sample|template|dist)$/i;
23
24
  export function secretEnvValues(envFiles, read) {
24
- const values = [];
25
+ const values = new Set();
26
+ // A value of few distinct characters is a placeholder or a published test key, not a secret.
27
+ const add = (v) => { if (v.length >= 12 && !/\s/.test(v) && new Set(v).size >= 8) values.add(v); };
25
28
  for (const file of envFiles) {
26
29
  if (EXAMPLE.test(file)) continue;
27
30
  let text = "";
28
31
  try { text = read(file); } catch { continue; }
29
32
  for (const line of text.split("\n")) {
30
33
  const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
31
- if (!m || !SECRET_NAME.test(m[1])) continue;
32
- const v = m[2].trim().replace(/^(['"])(.*)\1$/, "$2");
33
- // A value of few distinct characters is a placeholder or a published test key, not a secret.
34
- if (v.length >= 12 && !/\s/.test(v) && new Set(v).size >= 8) values.push(v);
34
+ if (m && SECRET_NAME.test(m[1])) add(m[2].trim().replace(/^(['"])(.*)\1$/, "$2"));
35
+ }
36
+ // As the app's loader reads it too, each line of a value that spans lines on its own.
37
+ for (const [name, v] of Object.entries(parseEnv(text))) {
38
+ if (SECRET_NAME.test(name)) for (const one of v.split("\n")) add(one.trim());
35
39
  }
36
40
  }
37
- return values;
41
+ return [...values];
38
42
  }
package/lib/lock.mjs CHANGED
@@ -3,8 +3,9 @@
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 read what other programs left in /tmp or reach the network beyond this machine. With
7
- // neither tool present there is no shell.
6
+ // Mac cannot read what other programs left in /tmp or reach the network beyond this machine, unless
7
+ // it runs as the app (a world's command): then it keeps the app's reach, since it runs the app's own
8
+ // AI, which calls the app's model provider. With neither tool present there is no shell.
8
9
  import { execFile, execFileSync } from "node:child_process";
9
10
  import { existsSync, realpathSync } from "node:fs";
10
11
  import { homedir, tmpdir } from "node:os";
@@ -58,7 +59,7 @@ export async function makeLock({ root, work }) {
58
59
  // Later rules win: home is shut, the project and the toolchains are opened again inside it, and
59
60
  // what the project ignores and your keys are shut last.
60
61
  const hide = ignored.hidden.map(({ path, dir }) => `(${dir ? "subpath" : "literal"} ${q(real(path) ?? path)})`);
61
- const profile = [
62
+ const rules = ({ network }) => [
62
63
  "(version 1)(allow default)",
63
64
  `(deny file-read-data (subpath ${q(real(home) ?? home)}))`,
64
65
  // /tmp holds what other programs on this machine logged; a shell line may write there, never read it.
@@ -67,17 +68,17 @@ export async function makeLock({ root, work }) {
67
68
  '(allow file-read-data (regex #"^/private/tmp/sh-thd"))',
68
69
  `(allow file-read-data (subpath ${q(project)}) ${[work, ...tools].map((p) => `(subpath ${q(p)})`).join(" ")})`,
69
70
  ...(hide.length ? [`(deny file-read-data file-write* ${hide.join(" ")})`] : []),
70
- "(deny network*)",
71
71
  // This machine only. Unix sockets stay shut: through one, a request rides out to the internet
72
72
  // by name on a system network helper even with every direct connection refused.
73
- '(allow network-outbound (remote ip "localhost:*"))(allow network-inbound (local ip "localhost:*"))',
73
+ ...(network ? [] : ['(deny network*)(allow network-outbound (remote ip "localhost:*"))(allow network-inbound (local ip "localhost:*"))']),
74
74
  "(deny file-write*)",
75
75
  `(allow file-write* ${writable.map((p) => `(subpath ${q(p)})`).join(" ")} (literal "/dev/null") (literal "/dev/stdout") (literal "/dev/stderr") (literal "/dev/tty") (subpath "/dev/fd"))`,
76
76
  // The same operation the project is opened with: a rule naming file-read-data outranks one naming
77
77
  // file-read*, so a wildcard deny here let every .env be read again.
78
78
  `(deny file-read* file-read-data ${SECRET_DIRS.map((d) => `(subpath ${q(join(home, d))})`).join(" ")} ${SECRET_FILES.map((f) => `(literal ${q(join(home, f))})`).join(" ")} (regex #"\\.env$") (regex #"/\\.env\\.(local|staging|stage|development|dev|test|production|prod)$"))`,
79
79
  ].join("");
80
- return { name: "seatbelt", wrap: (cmd) => ({ file: "/usr/bin/sandbox-exec", args: ["-p", profile, "/bin/sh", "-c", cmd] }) };
80
+ const locked = rules({ network: false }), asApp = rules({ network: true });
81
+ return { name: "seatbelt", wrap: (cmd, { network = false } = {}) => ({ file: "/usr/bin/sandbox-exec", args: ["-p", network ? asApp : locked, "/bin/sh", "-c", cmd] }) };
81
82
  }
82
83
  const bwrap = process.platform === "linux" ? ["/usr/bin/bwrap", "/bin/bwrap"].find(existsSync) : null;
83
84
  if (bwrap) {
package/lib/mcp.mjs CHANGED
@@ -10,8 +10,8 @@ export const PROTOCOL = "2025-06-18";
10
10
  // Codex keeps only the first 512 characters self-contained and Claude Code shares about 4 KB across
11
11
  // every server, so the first paragraph stands alone and the whole stays under 1,500 characters.
12
12
  export const instructions = (spec = "cortad") => [
13
- "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.",
14
- `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 or verify ends, with what it found. After a run, call findings and tell the person what it found and what you would change, worst first; the person then says which fix to make, and nothing is edited before their go. On their go, one fix at a time: verify replays the failing conversations and says whether the failure is gone, and a further round plays only when verify is called again. findings has each failure whole and findings numbers every rate. field_connect gives the steps to connect production. dispute records a check that reads this app wrong.`,
13
+ "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 says whether the app is up, what Cortad's audit of the AI code found, and what a run would play and cost. A run starts when the person presses Run in the browser or asks you.",
14
+ `Tell the person what status found in three or four short, plain lines, leaving out file paths, endpoints, status codes and setting names unless they ask. Before you stop, start \`npx -y ${spec} wait\` as a background command: it returns when the next run or verify ends, with what it found. After a run, call findings and tell the person what their users ran into and what you would change, worst first, three problems at most; the person then says which fix to make, and nothing is edited before their go. On their go, one fix at a time: verify replays the failing conversations and says whether the failure is gone, and a further round plays only when verify is called again. findings has each failure whole and findings numbers every rate. connect gives the steps to connect the live app. intelligence says what is happening to it and incidents what went wrong and why; outcomes, subscribe and query are the layer's other tools. dispute records a check that reads this app wrong.`,
15
15
  ].join("\n\n");
16
16
  export const INSTRUCTIONS = instructions();
17
17
 
@@ -19,11 +19,11 @@ const str = (description, extra = {}) => ({ type: "string", description, ...extr
19
19
  const none = { type: "object", properties: {}, additionalProperties: false };
20
20
  const readOnly = { readOnlyHint: true };
21
21
  // What a note to the Cortad team may be about: a tool, the connect command, or the skill text.
22
- const ABOUT = ["status", "reach", "run", "run_status", "findings", "verify", "dispute", "field_connect", "field", "connect", "skill"];
22
+ const ABOUT = ["status", "reach", "run", "run_status", "findings", "verify", "dispute", "field_connect", "field", "world", "world_status", "test", "connect", "intelligence", "incidents", "outcomes", "subscribe", "query", "skill"];
23
23
 
24
24
  export const TOOLS = [
25
25
  { name: "status", title: "Cortad status", annotations: readOnly,
26
- 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
+ description: "What Cortad found in this repository: whether the app is up and on which port, its audit of the prompts and of the code around the 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.",
27
27
  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 } },
28
28
  { name: "reach", title: "Send Cortad's test requests again",
29
29
  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.",
@@ -58,6 +58,39 @@ export const TOOLS = [
58
58
  { name: "field", title: "Production numbers", annotations: readOnly,
59
59
  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.",
60
60
  inputSchema: { type: "object", properties: { days: { type: "integer", minimum: 1, maximum: 180, description: "Window in days. Default 30." } }, additionalProperties: false } },
61
+ { name: "world", title: "Test the AI in a world",
62
+ description: "For an AI in this app that is not a chat, such as a worker that reads data and writes a verdict: Cortad builds a copy of the product's data on this machine from the app's own seed, plants problems whose answers are known, runs the app's own detection on each, and scores what it raised against the answer key, repeated for a flip rate. Answers within a second with the world's id; world_status follows it. Say what was built or what to test, and the report keeps it.",
63
+ inputSchema: { type: "object", properties: { ask: str("What was built or what to test, in a sentence.", { maxLength: 2000 }) }, additionalProperties: false } },
64
+ { name: "world_status", title: "Where a world stands", annotations: readOnly,
65
+ description: "Where a world stands: planted problems found, missed, false alarms where nothing was planted, and flips between repeats, with what the app raised for each.",
66
+ inputSchema: { type: "object", properties: { jobId: str("The world's id, from world.") }, required: ["jobId"], additionalProperties: false } },
67
+ { name: "test", title: "Test what you built",
68
+ description: "The one call after a change: say what you built or want tested, in a sentence, and Cortad decides what kind of test that is. A world, where this app has one and the sentence names something it can plant; a run of the app's chat surfaces otherwise. Answers within a second with which kind started and the call that follows it.",
69
+ inputSchema: { type: "object", properties: { ask: str("What was built or what to test, in a sentence.", { maxLength: 2000 }) }, additionalProperties: false } },
70
+ { name: "connect", title: "Connect the live app", inputSchema: none,
71
+ description: "The steps to connect the live app, backend and page: a one-time code writes both keys into the env file, the SDK lines for the framework, the page tag, and where each conversation then appears. The same as field_connect." },
72
+ { name: "intelligence", title: "The live app in numbers", annotations: readOnly,
73
+ description: "What is happening to the live app: what is wrong now (since when, in how many of how many, the change before it), the window's conversations and checks, the visits and what people did after answers. No user's words.",
74
+ inputSchema: { type: "object", properties: { days: { type: "integer", minimum: 1, maximum: 90, description: "Window in days. Default 7." } }, additionalProperties: false } },
75
+ { name: "incidents", title: "What went wrong", annotations: readOnly, inputSchema: none,
76
+ description: "Each open incident: the check or reaction, since when, now against before, the cause the data points at (a release, a changed prompt or model, tool errors, page errors), and the conversations to open on the page. Fix the cause named first, then verify." },
77
+ { name: "outcomes", title: "What counts as a good ending",
78
+ description: "What the app counts as an outcome and how often it is reached; or, with proposals, new ones read from the code (a signup route, a checkout, a done button), which the person approves on the page before they count.",
79
+ inputSchema: { type: "object", properties: { proposals: { type: "array", maxItems: 10, items: { type: "object", properties: { name: str("A short name, as the person would say it.", { maxLength: 64 }), why: str("What in the code makes this an outcome.", { maxLength: 500 }), rules: { type: "array", minItems: 1, maxItems: 10, items: { type: "object", properties: { kind: { type: "string", enum: ["path", "click", "event", "backend"] }, match: { type: "string", enum: ["exact", "starts", "contains"] }, value: { type: "string" }, text: { type: "string" }, selector: { type: "string" }, name: { type: "string" } }, required: ["kind"], additionalProperties: false } } }, required: ["name", "rules", "why"], additionalProperties: false } } }, additionalProperties: false } },
80
+ { name: "subscribe", title: "Hear of incidents",
81
+ description: "Registers a public https address that receives each incident as signed JSON (x-cortad-event, x-cortad-signature: sha256=HMAC of the body). Answers with the secret once; keep it where the app keeps its secrets.",
82
+ inputSchema: { type: "object", properties: { url: str("A public https address that takes a POST.", { maxLength: 500 }) }, required: ["url"], additionalProperties: false } },
83
+ { name: "query", title: "Ask the warehouse", annotations: readOnly,
84
+ description: "A structured question over the live app's events, readings or reactions: a table, a time range of at most 90 days, filters, a grouping by columns or by hour or day, measures (count, count_distinct, sum, avg, min, max, share), a limit. Compiled by Cortad, pinned to this app; no SQL is taken. Rows come back as a table.",
85
+ inputSchema: { type: "object", properties: {
86
+ table: { type: "string", enum: ["events", "readings", "reactions"] },
87
+ from: str("Start, ISO 8601."), to: str("End, ISO 8601, at most 90 days after from."),
88
+ filters: { type: "array", maxItems: 20, items: { type: "object", properties: { column: { type: "string" }, op: { type: "string", enum: ["eq", "ne", "in", "gt", "gte", "lt", "lte", "null", "not_null"] }, value: {} }, required: ["column", "op"], additionalProperties: false } },
89
+ group: { type: "array", maxItems: 4, items: { type: "string" }, description: "Columns, or hour or day." },
90
+ measures: { type: "array", minItems: 1, maxItems: 8, items: { type: "object", properties: { fn: { type: "string", enum: ["count", "count_distinct", "sum", "avg", "min", "max", "share"] }, column: { type: "string" }, equals: {}, as: { type: "string" } }, required: ["fn"], additionalProperties: false } },
91
+ order: { type: "object", properties: { by: { type: "string" }, dir: { type: "string", enum: ["asc", "desc"] } }, required: ["by"], additionalProperties: false },
92
+ limit: { type: "integer", minimum: 1, maximum: 1000 },
93
+ }, required: ["table", "from", "to", "measures"], additionalProperties: false } },
61
94
  ];
62
95
  const NAMES = new Set(TOOLS.map((t) => t.name));
63
96
 
package/lib/mint.mjs CHANGED
@@ -109,7 +109,7 @@ async function send(url, { method = "POST", headers = {}, body } = {}) {
109
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
110
  return { ok: res.ok, status: res.status, json: await res.json().catch(() => null) };
111
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);
112
+ const saidBy = (r) => String(r.json?.errors?.[0]?.long_message ?? r.json?.msg ?? r.json?.error_description ?? r.json?.message ?? r.json?.error?.message ?? `status ${r.status}`).slice(0, 120);
113
113
  const refusedBy = (who, r) => ({ status: "refused", note: `${who} refused the test account: ${saidBy(r)}` });
114
114
  // An account the service already held may have been given a role since this machine made it.
115
115
  const ELEVATED_ACCOUNT = { status: "refused", note: "the test account carries an admin role; only an ordinary account is signed in" };
@@ -176,13 +176,31 @@ export function supabaseCookie(origin, session) {
176
176
  return value.match(/.{1,3180}/g).map((part, i) => `${name}.${i}=${part}`).join("; ");
177
177
  }
178
178
 
179
+ // The Auth emulator their app verifies against while FIREBASE_AUTH_EMULATOR_HOST is set: it signs up any
180
+ // address under any key. The account is this machine's own test account, made once and signed in after.
181
+ export async function emulatorToken(host, account) {
182
+ // An emulator runs beside the app on this machine, and the test account's password goes to it in
183
+ // plain http: never to another host.
184
+ if (!/^(?:localhost|127\.0\.0\.1|\[::1\]):\d{2,5}$/.test(host)) return { status: "refused", note: "FIREBASE_AUTH_EMULATOR_HOST does not name an emulator on this machine" };
185
+ const at = (verb) => `http://${host}/identitytoolkit.googleapis.com/v1/accounts:${verb}?key=cortad-test`;
186
+ const body = { email: account.email, password: account.password, returnSecureToken: true };
187
+ let got = await send(at("signUp"), { body });
188
+ if (!got.ok && /EMAIL_EXISTS/.test(saidBy(got))) got = await send(at("signInWithPassword"), { body });
189
+ if (!got.ok || typeof got.json?.idToken !== "string") return refusedBy("your Firebase Auth emulator", got);
190
+ return { idToken: got.json.idToken, ttl: Number(got.json.expiresIn) || 3600 };
191
+ }
192
+
179
193
  // Each hosted service's sign-in, as the value the role's header carries and how long it holds.
180
194
  const SIGN_IN = new Map([
181
- ["firebase", async ({ env, all }) => {
182
- const account = serviceAccountOf(env);
195
+ ["firebase", async ({ env, all, account }) => {
196
+ if (env.FIREBASE_AUTH_EMULATOR_HOST) {
197
+ const got = await emulatorToken(env.FIREBASE_AUTH_EMULATOR_HOST, account);
198
+ return got.idToken ? { value: `Bearer ${got.idToken}`, ttl: got.ttl, door: "your Firebase Auth emulator", said: "made a test account in your Firebase Auth emulator for the run to sign in with" } : got;
199
+ }
200
+ const admin = serviceAccountOf(env);
183
201
  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);
202
+ if (!admin || !apiKey) return { status: "absent", note: !admin ? "no Firebase admin credential in your environment" : "no Firebase web API key in your environment" };
203
+ const got = await firebaseIdToken(admin, apiKey);
186
204
  if (!got.idToken) return { status: "refused", note: `your Firebase project refused the sign-in: ${got.error}` };
187
205
  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
206
  }],
@@ -251,13 +269,11 @@ export function makeIdentities({ root, work, envFiles, sourceFiles = () => [], s
251
269
  rmSync(out, { force: true });
252
270
  // ponytail: the program is Python, as it is in the sandbox. A machine without python3 mints
253
271
  // nothing and says so; port the program to this file if that turns out to be common.
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.
272
+ // A caller a hosted service verifies is signed in at that service, below, a Firebase one at the
273
+ // Auth emulator when the app's settings name one: the program reads one env file and never saw an
274
+ // emulator named anywhere else, so a Python app verified by the emulator was knocked unsigned.
258
275
  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);
276
+ const signsHere = (r) => SIGN_IN.has(r?.kind);
261
277
  const refused = new Map();
262
278
  // A pair the environment names for the app's own account is the one credential the raise is
263
279
  // allowed to reuse: the elevation and existing-account guards do not apply to it.