cortad 0.3.10 → 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.
package/local.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // Cortad on your own machine. Run from your repository's root with the code the connect screen
3
3
  // showed:
4
- // npx cortad ABCD2345 [--port 3000] [--start "npm run dev"] [--proxy] [--verbose]
4
+ // npx cortad ABCD2345 [--port 3000] [--start "npm run dev"] [--data .env.staging] [--proxy] [--verbose]
5
5
  //
6
6
  // What it does: signs in with the code, uploads your source files once (never .env, never
7
7
  // node_modules) so your code can be read, starts your app the way you start it, then holds one
@@ -12,9 +12,11 @@
12
12
  // on with the key this connect leaves in ~/.cortad. Nothing here touches git. Your environment
13
13
  // never leaves this machine. Ctrl-C ends everything.
14
14
 
15
- import { storesOf, withoutStoreSettings } from "./lib/stores.mjs";
15
+ import { storeSettings, storesOf, withoutStoreSettings } from "./lib/stores.mjs";
16
16
  import { notAnApp, withoutStores } from "./lib/app-port.mjs";
17
17
  import { holdsKeys, secretEnvValues } from "./lib/keys.mjs";
18
+ import { appEnv, devEnv, fileValues, keyTries, localEnv, parseEnv } from "./lib/env-files.mjs";
19
+ import { historyParts, repoFacts } from "./lib/commits.mjs";
18
20
  import { spawn, execFile, execFileSync } from "node:child_process";
19
21
  import { createHash } from "node:crypto";
20
22
  import { appendFileSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, watch, writeFileSync } from "node:fs";
@@ -84,8 +86,8 @@ const clearStep = () => { if (process.stdout.isTTY) process.stdout.write("\r\x1b
84
86
  const stepDone = (line) => { clearStep(); say(line); };
85
87
  // `npx cortad` alone connects too: with the key this folder kept from its last connect, or, with none,
86
88
  // by one approval in the browser (pairing below). A word that is neither a code nor a flag is refused.
87
- const words = argv.filter((a, i) => !a.startsWith("-") && !["--port", "--start"].includes(argv[i - 1] ?? ""));
88
- if (!explain && !viaToken && words.length && !/^[A-Z0-9]{8}$/.test(code)) fail(`usage: npx cortad <code from the connect screen> [--port N] [--start \"cmd\"] [--proxy] | npx cortad --explain | npx cortad ${[...VERBS, "wait"].join(" | ")}`);
89
+ const words = argv.filter((a, i) => !a.startsWith("-") && !["--port", "--start", "--data"].includes(argv[i - 1] ?? ""));
90
+ if (!explain && !viaToken && words.length && !/^[A-Z0-9]{8}$/.test(code)) fail(`usage: npx cortad <code from the connect screen> [--port N] [--start \"cmd\"] [--data <settings file>] [--proxy] | npx cortad --explain | npx cortad ${[...VERBS, "wait"].join(" | ")}`);
89
91
  // Where Brainsless is. The host is not on the command line: a code cannot point at an impostor.
90
92
  const origin = new URL(process.env.CORTAD_ORIGIN || "https://cortad.com");
91
93
  // Ours, and only ours. brainsless.com is the same service under its earlier name and stays trusted
@@ -108,6 +110,23 @@ if (!MANIFEST.some((f) => existsSync(join(root, f))) && !workspaces(root).length
108
110
  // Which project this is, as a hash of where it lives: the same folder coming back resumes the same
109
111
  // connection, and the path itself never leaves this machine.
110
112
  const project = projectOf(root);
113
+ // --data: the databases a run copies are the ones this settings file names, in place of the ones the
114
+ // app's own settings name. Only its store settings are taken, and they outrank every other source.
115
+ // The choice holds for the sessions a run starts by itself (--token) until the next connect makes its
116
+ // own: a run started later would otherwise copy the databases the person chose not to use.
117
+ const choiceFile = join(homeOf(project), "data-choice");
118
+ const dataArg = flag("--data") ?? (viaToken ? (() => { try { return readFileSync(choiceFile, "utf8").trim() || undefined; } catch { return undefined; } })() : undefined);
119
+ const dataFile = dataArg ? resolve(root, dataArg) : null;
120
+ let chosen = {};
121
+ if (dataFile) {
122
+ let text = null;
123
+ try { text = readFileSync(dataFile, "utf8"); } catch { fail(`--data ${dataArg}: there is no settings file there to read the databases from.`); }
124
+ chosen = storeSettings(parseEnv(text, process.env));
125
+ if (!storesOf(chosen).length) fail(`--data ${dataArg}: that file names no database. Give the settings file that holds the database settings the run should copy, under the names your app reads them by.`);
126
+ }
127
+ if (!viaToken && !explain) {
128
+ try { mkdirSync(homeOf(project), { recursive: true, mode: 0o700 }); writeFileSync(choiceFile, dataFile ?? "", { mode: 0o600 }); } catch { /* later sessions copy the app's own databases */ }
129
+ }
111
130
 
112
131
  // One process holds a project's app up (lib/runner.mjs), and its state is what every reader reports.
113
132
  // `reach: "ask"`: an agent's reach is sent by this process (lib/auto-reach.mjs), signed in where it can.
@@ -212,22 +231,6 @@ function envOrigins(envFiles) {
212
231
  }
213
232
  return [...out].slice(0, 8);
214
233
  }
215
- // Every name your env files set, with its value, read here and used only here. An example file is
216
- // read first so a real one's value wins: a placeholder key asked for a model listing comes back
217
- // refused, and that would read as your own key being refused.
218
- function envValues(envFiles) {
219
- const example = /\.(example|sample)$/;
220
- const out = {};
221
- for (const file of [...envFiles.filter((f) => example.test(f)), ...envFiles.filter((f) => !example.test(f))]) {
222
- let text = "";
223
- try { text = readFileSync(file, "utf8"); } catch { continue; }
224
- for (const line of text.split("\n")) {
225
- const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
226
- if (m) out[m[1]] = m[2].trim().replace(/^(['"])(.*)\1$/, "$2");
227
- }
228
- }
229
- return out;
230
- }
231
234
  // Switch-shaped names and the word each one reads as. The name and the word travel, never the value.
232
235
  const SWITCH_NAME = /(?:_ENABLED|_DISABLED|_MODE|_ON|_OFF|_FLAG)$|^(?:ENABLE|USE|DISABLE|SKIP)_/;
233
236
  const switchStates = (values) => Object.fromEntries(Object.entries(values)
@@ -239,22 +242,34 @@ const switchStates = (values) => Object.fromEntries(Object.entries(values)
239
242
  // is set under, the host, what the host answered, the model ids, and which switches are on.
240
243
  const MODEL_ID = /^[\w./:@-]{1,160}$/;
241
244
  async function inventoryOf(listings) {
242
- const values = envValues(envFiles);
245
+ const values = settingsNow();
243
246
  const providers = await Promise.all(Object.entries(listings)
244
247
  .filter(([name]) => values[name])
245
248
  .map(async ([name, canonical]) => {
246
- const url = listingUrl(name, canonical, values);
247
- let host = "";
248
- try { host = new URL(url).host; } catch { return null; }
249
- try {
250
- const res = await fetch(url, { headers: { authorization: `Bearer ${values[name]}` }, signal: AbortSignal.timeout(20_000) });
251
- const body = res.ok ? await res.json().catch(() => null) : null;
252
- const models = (Array.isArray(body?.data) ? body.data : [])
253
- .map((m) => String(m?.id ?? "")).filter((id) => MODEL_ID.test(id)).slice(0, 1500);
254
- return { env: name, host, status: res.status, models };
255
- } catch {
256
- return { env: name, host, status: 0, models: [] };
249
+ // The shell's key, which outranks every loader, else each key a file of theirs holds, until one
250
+ // is let in: which file the app's own loader takes it from is its business, and "refuses your
251
+ // key" was said of a test file's key while the app called its provider on the real one. A key
252
+ // goes only to the address the shell or its own file names, else the one the development files
253
+ // name, else the provider's own; never to one only a test or staging file names, which the
254
+ // app may not use, so where only such a file names one the key is not asked about at all.
255
+ const tries = keyTries(name, canonical, { shell: process.env, files: fileValues(envFiles, appDir, root, process.env), dev: devEnv(envFiles, appDir, root, process.env) });
256
+ let first = null;
257
+ for (const { key, url } of tries) {
258
+ let host = "";
259
+ try { host = new URL(url).host; } catch { continue; }
260
+ try {
261
+ const res = await fetch(url, { headers: { authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(20_000) });
262
+ const body = res.ok ? await res.json().catch(() => null) : null;
263
+ const models = (Array.isArray(body?.data) ? body.data : [])
264
+ .map((m) => String(m?.id ?? "")).filter((id) => MODEL_ID.test(id)).slice(0, 1500);
265
+ const seen = { env: name, host, status: res.status, models };
266
+ if (res.ok) return seen;
267
+ first ??= seen;
268
+ } catch {
269
+ first ??= { env: name, host, status: 0, models: [] };
270
+ }
257
271
  }
272
+ return first;
258
273
  }));
259
274
  return { providers: providers.filter(Boolean), flags: switchStates(values) };
260
275
  }
@@ -299,6 +314,12 @@ const held = makeHeld({ rows: () => capture?.usage()?.rows ?? [], say: (line) =>
299
314
  let gate = null;
300
315
  const hold = () => { if (!gate) { let open; gate = new Promise((r) => { open = r; }); gate.open = () => { gate = null; open(); }; } };
301
316
  const release = () => gate?.open();
317
+ // A store with no copy that the person has not said yes to: until they do, nothing of ours that can
318
+ // write reaches the app or runs as it, so nothing lands in that store. Reads still go: they list the
319
+ // app's routes and answer its health.
320
+ const WRITES_HELD = "A store your app writes into has no copy and waits for the person's yes on the card in the browser.";
321
+ const writesHeld = () => keeping.holding(consent).length > 0;
322
+ const reads = (method) => /^(?:GET|HEAD|OPTIONS)$/i.test(String(method ?? "GET"));
302
323
  const mask = (text) => maskWith(String(text ?? ""), secrets);
303
324
 
304
325
  // ---- the wire
@@ -393,9 +414,15 @@ async function verb(job) {
393
414
  if (verbose) say(`$ ${cmd.slice(0, 160)}`);
394
415
  // No lock, no shell: a world never gets an unlocked one on this machine.
395
416
  if (!lock) return { success: false, exitCode: 126, stdout: "", stderr: "refused: this machine has no sandbox tool (sandbox-exec or bubblewrap), so no shell line is run here" };
396
- const { file, args } = lock.wrap(cmd);
417
+ // A world's command (opts.app) runs as the app: with the values of its env files and the copies
418
+ // made of its stores, which the sandbox keeps a command from reading off the disk, and with the
419
+ // app's reach on the network, since it runs the app's own AI against the app's model provider.
420
+ const asApp = b.opts?.app === true;
421
+ if (asApp && writesHeld()) return { success: false, exitCode: 126, stdout: "", stderr: WRITES_HELD };
422
+ const { file, args } = lock.wrap(cmd, { network: asApp });
423
+ const env = asApp ? { ...localEnv(envFiles, appDir, root, process.env), ...keeping.envNow(), ...plainEnv } : plainEnv;
397
424
  return new Promise((done) => {
398
- const child = spawn(file, args, { cwd: root, env: plainEnv, stdio: ["ignore", "pipe", "pipe"] });
425
+ const child = spawn(file, args, { cwd: root, env, stdio: ["ignore", "pipe", "pipe"] });
399
426
  let out = "", err = "";
400
427
  const cap = (s, chunk) => (s.length < 20_000_000 ? s + chunk : s);
401
428
  child.stdout.on("data", (d) => { out = cap(out, d.toString()); });
@@ -409,6 +436,7 @@ async function verb(job) {
409
436
  // Only the app's own port: this machine's other services are not the world.
410
437
  const port = Number(b.port) || doorPort();
411
438
  if (!app || (port !== app.port && port !== doorPort())) return { error: `refused: port ${port} is not your app` };
439
+ if (!reads(b.method) && writesHeld()) return { error: WRITES_HELD };
412
440
  const path = typeof b.path === "string" && b.path.startsWith("/") ? b.path : "/";
413
441
  const headers = {};
414
442
  for (const [k, v] of Object.entries(b.headers ?? {})) if (!/^(host|content-length|connection)$/i.test(k)) headers[k] = String(v);
@@ -615,7 +643,7 @@ async function verb(job) {
615
643
  case "inventory": return inventoryOf(b.probe && typeof b.probe === "object" ? b.probe : {});
616
644
  // Your own pages, read here rather than in a world's shell: that shell is sealed away from
617
645
  // every env file and every host but this one, so inside it no store of yours has an address.
618
- case "sample": return { report: JSON.parse(mask(JSON.stringify(await sampleHere(b, envValues(envFiles), root)))) };
646
+ case "sample": return { report: JSON.parse(mask(JSON.stringify(await sampleHere(b, settingsNow(), root)))) };
619
647
  case "lock": return { locked: Array.isArray(b.hosts) ? b.hosts.length : 0 };
620
648
  // What your app spent on its providers, from the hook inside it. An app this command did not
621
649
  // start has no hook, and the empty answer says the meter is absent rather than that nothing was spent.
@@ -651,12 +679,10 @@ const answers = async (port) => {
651
679
  }
652
680
  return false;
653
681
  };
654
- // The values an env file beside the app declares, exported into its process the way `source .env`
655
- // would: an app with no dotenv loader of its own crashed every route without them. Example files
656
- // hold placeholders and are left out; the shell's own environment and the lifted limits win.
657
- function envExports(envFiles) {
658
- return envValues(envFiles.filter((f) => !/\.(example|sample)$/.test(f)));
659
- }
682
+ // What the app's own start takes from its env files (lib/env-files.mjs appEnv), and every value its
683
+ // settings could hold, for reading which stores and ports it names.
684
+ const envExports = () => appEnv(envFiles, appDir, root, process.env);
685
+ const settingsNow = () => ({ ...localEnv(envFiles, appDir, root, process.env), ...process.env, ...chosen });
660
686
  const onPath = (bin) => (process.env.PATH ?? "").split(":").some((dir) => dir && existsSync(join(dir, bin)));
661
687
  // Where their app lives inside this repository, and how it starts. Worked out in lib/start.mjs.
662
688
  let appDir = root;
@@ -709,7 +735,7 @@ async function startApp() {
709
735
  if (took) return took;
710
736
  if (proxyAsked) {
711
737
  await useProxy();
712
- say(`your app was already running, so Cortad cannot see the calls it makes to its model until it is started with: ${Object.entries(proxy.env({ ...envExports(envFiles), ...process.env })).map(([k, v]) => `${k}=${v}`).join(" ")}`);
738
+ say(`your app was already running, so Cortad cannot see the calls it makes to its model until it is started with: ${Object.entries(proxy.env({ ...envExports(), ...process.env })).map(([k, v]) => `${k}=${v}`).join(" ")}`);
713
739
  } else say(`${UNSEEN} Stop it and run this again without --port: this command starts your app itself.`);
714
740
  return { port: wanted, cmd: null };
715
741
  }
@@ -928,12 +954,12 @@ async function freePortAbove(port) {
928
954
  }
929
955
  const PORT_FLAG = /(--port[= ]|-p |\bPORT=)(\d{4,5})\b/;
930
956
  // The port a start names: its command's flag, or PORT as this command sets it or an env file does.
931
- const namedPort = (cmd, lifted) => Number(PORT_FLAG.exec(cmd)?.[2] ?? lifted.PORT ?? envExports(envFiles).PORT ?? 0) || 0;
957
+ const namedPort = (cmd, lifted) => Number(PORT_FLAG.exec(cmd)?.[2] ?? lifted.PORT ?? settingsNow().PORT ?? 0) || 0;
932
958
  async function freed(cmd, lifted) {
933
959
  const m = PORT_FLAG.exec(cmd);
934
960
  // The app's own env file names its port too: yunqiao's PORT=8105 sat in .env, and a stale copy
935
961
  // on that port was reported as "your app stopped" instead of moved past.
936
- const named = m ? Number(m[2]) : Number(lifted.PORT) || Number(envExports(envFiles).PORT) || 0;
962
+ const named = m ? Number(m[2]) : Number(lifted.PORT) || Number(settingsNow().PORT) || 0;
937
963
  if (!named || (!lost.has(named) && !(await listenerOn(named)))) return { cmd, lifted };
938
964
  const port = await freePortAbove(named);
939
965
  if (!port) return { cmd, lifted };
@@ -947,7 +973,7 @@ async function freed(cmd, lifted) {
947
973
  // was copied, started, or left where trials write into it is told in the terminal here and to the
948
974
  // run with the app (announce), so status says it beside Run and the report keeps it.
949
975
  const keeping = makeKeeping({
950
- root, work, ledgerFile: join(homeOf(project), "made.json"), onPath, say, hold, typed: Boolean(flag("--start")),
976
+ root, work, ledgerFile: join(homeOf(project), "made.json"), onPath, say, hold, typed: Boolean(flag("--start")), chosen,
951
977
  launch: (plan) => sidecar(plan.cmd, plan.cwd),
952
978
  connections: () => capture?.connections() ?? [],
953
979
  copies: () => capture?.copies() ?? [],
@@ -958,7 +984,7 @@ const keeping = makeKeeping({
958
984
  try { text = readFileSync(file, "utf8"); } catch { continue; }
959
985
  for (const line of text.split("\n")) { const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line); if (m && !(m[1] in process.env)) from[m[1]] = file; }
960
986
  }
961
- return { values: { ...envExports(envFiles), ...process.env }, from, appDir, sources: files.map((f) => join(root, f)) };
987
+ return { values: settingsNow(), from, appDir, sources: files.map((f) => join(root, f)) };
962
988
  },
963
989
  });
964
990
 
@@ -979,8 +1005,8 @@ async function start(waitMs, tries = 3) {
979
1005
  await allowOutbound();
980
1006
  // A start command the person typed says which databases the app uses: the store settings of their
981
1007
  // settings files are left for the app to load, since handed over here they outrank the
982
- // environment that command chose, and no copy is pointed at.
983
- const base = flag("--start") ? { ...withoutStoreSettings(envExports(envFiles)), ...process.env, ...lifted } : { ...envExports(envFiles), ...process.env, ...lifted, ...copies };
1008
+ // environment that command chose. Only the copies of databases chosen with --data are handed over.
1009
+ const base = flag("--start") ? { ...withoutStoreSettings(envExports()), ...process.env, ...lifted, ...copies } : { ...envExports(), ...process.env, ...lifted, ...copies };
984
1010
  child = spawnTied(cmd, { cwd: appDir, env: { ...base, ...(proxy ? proxy.env(base) : capture ? capture.env(process.env) : {}), FORCE_COLOR: "0", ...(pinned.bin ? { PATH: `${pinned.bin}:${process.env.PATH ?? ""}` } : {}) } });
985
1011
  const mine = child;
986
1012
  become("starting", { app: mine.pid ?? null });
@@ -1088,7 +1114,7 @@ const savedSinceStart = () => Boolean(loaded?.files) && changesOf({
1088
1114
  // at again after each restart, for a save made while it was under way.
1089
1115
  // The stores the settings name, as copies are made of them: a change here is a change of what the app
1090
1116
  // writes into, so its copies and holds are made again for the new settings, and said again.
1091
- const storesNamed = () => { try { return JSON.stringify(storesOf({ ...envExports(envFiles), ...process.env }).map((s) => [s.engine, s.host, s.port, s.db, s.names])); } catch { return ""; } };
1117
+ const storesNamed = () => { try { return JSON.stringify(storesOf(settingsNow()).map((s) => [s.engine, s.host, s.port, s.db, s.names])); } catch { return ""; } };
1092
1118
  let storesAt = null;
1093
1119
  // Looked at before the first wait too: the app now starts while the code is uploaded, and a save made
1094
1120
  // before this watch began was otherwise never seen.
@@ -1119,13 +1145,14 @@ async function restartNow() {
1119
1145
  return { error };
1120
1146
  }
1121
1147
  become("up", { ...upAt(up.port), app: child?.pid ?? null });
1122
- if (up.port !== port) {
1123
- // It moved anyway: the world follows the app, never the other way round.
1124
- app = up;
1125
- const told = await announce().catch(() => null);
1126
- if (!told?.ok) return { error: `Your app came back on port ${up.port}, not ${port}. Run the command again to reconnect.` };
1127
- say(`your app came back on port ${up.port}; the run follows it there`);
1128
- }
1148
+ // Said to the server every time, on the same port too: what its endpoints' test messages met before
1149
+ // the restart is not what they meet now, and a key fixed in the env file left Run held on the old
1150
+ // refusal. On another port the world follows the app, never the other way round.
1151
+ const moved = up.port !== port;
1152
+ if (moved) app = up;
1153
+ const told = await announce().catch(() => null);
1154
+ if (moved && !told?.ok) return { error: `Your app came back on port ${up.port}, not ${port}. Run the command again to reconnect.` };
1155
+ if (moved) say(`your app came back on port ${up.port}; the run follows it there`);
1129
1156
  return { restarted: true, port: up.port };
1130
1157
  }
1131
1158
  const sentence = (s) => s.charAt(0).toUpperCase() + s.slice(1);
@@ -1165,7 +1192,7 @@ const envFiles = [];
1165
1192
  const files = [];
1166
1193
  listed = gitListed();
1167
1194
  walk(root, 0, files, envFiles, 0);
1168
- secrets = secretValues(envFiles);
1195
+ secrets = secretValues(dataFile ? [...envFiles, dataFile] : envFiles);
1169
1196
  envKeys = secretEnvValues(envFiles, (f) => readFileSync(f, "utf8"));
1170
1197
  const kept = [];
1171
1198
  for (let i = files.length - 1; i >= 0; i -= 1) if (carriesKey(files[i])) kept.unshift(...files.splice(i, 1));
@@ -1184,14 +1211,15 @@ if (explain) {
1184
1211
  `would send ${files.length} source files, ${Math.round(bytes / 1024)} KB, once${listed ? " (what git would commit)" : ""}`,
1185
1212
  ...(kept.length ? [`kept here ${kept.length} file${kept.length === 1 ? " that holds" : "s that hold"} keys: ${kept.slice(0, 6).join(", ")}${kept.length > 6 ? ", ..." : ""}`] : []),
1186
1213
  ...(left.length ? [`also not sent ${left.length} tracked media or lock file${left.length === 1 ? "" : "s"}: ${left.slice(0, 6).join(", ")}${left.length > 6 ? ", ..." : ""}`] : []),
1187
- `not sent anything git ignores, env files (${envFiles.length} here: ${envFiles.slice(0, 6).map(rel).join(", ") || "none"}), key files, node_modules, .git`,
1214
+ `not sent anything git ignores, env files (${envFiles.length} here: ${envFiles.slice(0, 6).map(rel).join(", ") || "none"}), key files, node_modules`,
1188
1215
  `env files values read here only: to hide them in replies, to sign in a test account, and to ask your providers what your keys reach. Variable names and whether a switch is on or off go up; no value does`,
1189
1216
  `would start ${flag("--port") ? `nothing: uses your app on port ${flag("--port")}` : plan?.cmd ? `${plan.cmd} (in ${rel(plan.cwd)})` : plan?.noServer ? "nothing: this repository has no server to run" : "asks you how your app starts"}`,
1190
1217
  `would raise ${flag("--port") ? "nothing: your app's own request limits stay as they are" : `${Object.keys(liftedLimits(envFiles, files.map((f) => join(root, f)))).join(", ") || "no request limits found"} (only after your yes, for this session only)`}`,
1191
1218
  `loads into app lib/trace.cjs (Node, Bun) or lib/pyhook/sitecustomize.py (Python): records each request during which your app calls a model, what it answered and the model calls on the way; header values stay here`,
1192
1219
  `other apps a local proxy on 127.0.0.1 in front of your app's port and where its model settings point (OPENAI_BASE_URL and the others), recording the same; it passes each call on to the provider your settings name`,
1193
1220
  `your files never written by this program; your own coding agent edits them`,
1194
- `your database a database file your env or code names is copied to this program's temp folder; a Postgres, Redis or MongoDB database or a Qdrant collection on this machine is copied on its own server under a name of ours and deleted at the end; your app is started on the copies. Any other store is named, not copied`,
1221
+ `your database a database file your env or code names is copied to this program's temp folder; a Postgres, Redis or MongoDB database or a Qdrant collection on this machine is copied on its own server under a name of ours, and a MongoDB or Elasticsearch server whose databases your code names is copied whole into a Docker container of ours; all are deleted at the end, and your app is started on the copies. A store with no copy holds Run until you say yes to writing into it`,
1222
+ `choose data ${dataFile ? `the databases ${relative(root, dataFile) || dataFile} names are the ones copied` : "--data <settings file> copies the databases that file names instead of the ones your app's settings name; your app keeps the rest of its settings"}`,
1195
1223
  `your services a store your compose file runs, or a second service of this repository, is started when your app reaches for it and nothing answers there, and stopped at the end`,
1196
1224
  `test shell confined by the OS: your project and toolchains only, writes to temp and build folders, localhost only`,
1197
1225
  `for your agent an MCP entry and a skill in each coding agent's own home folder (Claude Code, Codex, Cursor), and a key in ~/.cortad for later runs`,
@@ -1252,8 +1280,8 @@ const treeDigest = (() => {
1252
1280
  }
1253
1281
  return h.digest("hex");
1254
1282
  })();
1255
- // The commit this tree is. Your .git is never uploaded -- nothing of your history leaves this
1256
- // machine -- so the one sha the report needs is read here and sent as forty characters. A folder
1283
+ // The commit this tree is. Your .git is never uploaded; the one sha the report needs is read here
1284
+ // and sent as forty characters, and the commits themselves follow the code (sendHistory). A folder
1257
1285
  // that is not a checkout sends nothing, and the report says so rather than inventing one.
1258
1286
  const head = (() => {
1259
1287
  try {
@@ -1273,25 +1301,51 @@ const PART = 1_500_000;
1273
1301
  let resumed = false;
1274
1302
  let moved = false;
1275
1303
  const parts = Math.max(1, Math.ceil(bytes.length / PART));
1276
- for (let off = 0; off < bytes.length; off += PART) {
1277
- const last = off + PART >= bytes.length;
1278
- step(parts > 1 ? `connecting, ${Math.floor(off / PART) + 1} of ${parts}` : "connecting");
1279
- // The last part asks for this machine's key when none is stored yet: minted once the repository
1280
- // row exists, kept in ~/.cortad for the runs a coding agent asks for on later days. A connect by a
1281
- // code asks too, since the code may be another account's: the key kept from the account before
1282
- // signed the coding agent's tools into that one, its runs and its counts.
1283
- const put = await call("PUT", `/local/${box}/tree?last=${last ? 1 : 0}${last ? `&digest=${treeDigest}${head ? `&head=${head}` : ""}` : ""}`, bytes.subarray(off, off + PART),
1284
- { raw: true, timeoutMs: 120_000, ...(last && !viaToken && (!stored || code) ? { headers: { "x-cortad-machine": hostname().slice(0, 80) } } : {}) }).catch(unreachable);
1285
- if (!put.ok) fail(put.data?.error ?? `upload failed (${put.status})`);
1286
- if (last) {
1287
- resumed = put.data?.resumed === true;
1288
- moved = put.data?.moved === true;
1289
- if (typeof put.data?.machineKey === "string" && put.data.machineKey) writeToken(project, put.data.machineKey);
1290
- writeDigest(project, treeDigest);
1304
+ // The whole upload, part by part. A part that did not land is the upload's to send again, from its
1305
+ // first part, once, a few seconds later: a part numbered 0 begins the archive afresh on the server, so
1306
+ // a second pass never lands on what the first left. A refusal of this machine's key is not retried.
1307
+ async function sendTree() {
1308
+ for (let off = 0; off < bytes.length; off += PART) {
1309
+ const last = off + PART >= bytes.length;
1310
+ step(parts > 1 ? `connecting, ${Math.floor(off / PART) + 1} of ${parts}` : "connecting");
1311
+ // The last part asks for this machine's key when none is stored yet: minted once the repository
1312
+ // row exists, kept in ~/.cortad for the runs a coding agent asks for on later days. A connect by a
1313
+ // code asks too, since the code may be another account's: the key kept from the account before
1314
+ // signed the coding agent's tools into that one, its runs and its counts.
1315
+ const put = await call("PUT", `/local/${box}/tree?part=${Math.floor(off / PART)}&last=${last ? 1 : 0}${last ? `&digest=${treeDigest}${head ? `&head=${head}` : ""}` : ""}`, bytes.subarray(off, off + PART),
1316
+ { raw: true, timeoutMs: 120_000, ...(last && !viaToken && (!stored || code) ? { headers: { "x-cortad-machine": hostname().slice(0, 80) } } : {}) }).catch((err) => ({ ok: false, status: 0, err }));
1317
+ if (!put.ok) return put;
1318
+ if (last) {
1319
+ resumed = put.data?.resumed === true;
1320
+ moved = put.data?.moved === true;
1321
+ if (typeof put.data?.machineKey === "string" && put.data.machineKey) writeToken(project, put.data.machineKey);
1322
+ writeDigest(project, treeDigest);
1323
+ }
1324
+ }
1325
+ return { ok: true };
1326
+ }
1327
+ let sent = await sendTree();
1328
+ if (!sent.ok && sent.status !== 401 && sent.status !== 404) {
1329
+ await new Promise((r) => setTimeout(r, 2000 + Math.random() * 3000));
1330
+ sent = await sendTree();
1331
+ }
1332
+ if (!sent.ok) sent.err ? unreachable(sent.err) : fail(sent.data?.error ?? `upload failed (${sent.status})`);
1333
+ // Their commit history follows the code: the commits newer than the ones Cortad holds, the whole
1334
+ // history the first time, each diff cut to the files above and to lines that carry no key
1335
+ // (lib/commits.mjs). It goes on after the connect is reported and never stops it.
1336
+ async function sendHistory() {
1337
+ const last = await call("GET", `/local/${box}/commits/last`);
1338
+ if (!last.ok) return;
1339
+ const keeps = (rel) => { const parts = rel.split("/"); return !parts.slice(0, -1).some((d) => SKIP_DIR.test(d)) && !SKIP_FILE.test(parts.at(-1)) && !ENV_FILE.test(parts.at(-1)) && !kept.includes(rel); };
1340
+ const parts = historyParts(root, typeof last.data?.sha === "string" ? last.data.sha : null, { keep: keeps, holdsKey: (line) => holdsKeys(line, envKeys) });
1341
+ for (const [i, commits] of parts.entries()) {
1342
+ const put = await call("PUT", `/local/${box}/commits`, { commits, ...(i === 0 ? { repo: repoFacts(root) } : {}) }, { timeoutMs: 120_000 });
1343
+ if (!put.ok) throw new Error(put.data?.error ?? `history upload failed (${put.status})`);
1291
1344
  }
1292
1345
  }
1293
1346
  // Unchanged code has already been read: coming back says so instead of claiming a second read.
1294
1347
  stepDone(moved ? "connected: your change is in, and what Cortad read before still stands" : resumed ? "connected: nothing changed since last time" : "connected");
1348
+ if (head) void sendHistory().catch((err) => { if (verbose) say(`commit history not sent: ${err?.message ?? err}`); });
1295
1349
  // The coding agents on this machine learn about Cortad now, once: an MCP entry and a skill in each
1296
1350
  // one's own home folder. A run started by an agent later comes back through lib/cli.mjs.
1297
1351
  // CORTAD_NO_REGISTER=1 leaves the coding agents' global skill and MCP entries alone: a stack under
@@ -1513,9 +1567,10 @@ const autoReach = makeAutoReach({
1513
1567
  return res?.ok ? res.data : null;
1514
1568
  },
1515
1569
  runner: () => (became?.state === "up" && !restarting ? { state: "up", ...became.fields } : null),
1516
- // One test request per endpoint is what the person's own agent sends by hand, so it does not wait on
1517
- // the yes a run's writes into an uncopied store need; it waits only while a copy is being made.
1518
- waiting: async () => (gate ? "Your app's data is being copied for this session." : null),
1570
+ // A test request writes what a run's turn writes: it waits while a copy is being made, and while a
1571
+ // store with no copy waits for the person's yes. Sent anyway, the test requests to a twin of a legal
1572
+ // research API wrote into its own MongoDB and Elasticsearch while both held Run.
1573
+ waiting: async () => (gate ? "Your app's data is being copied for this session." : writesHeld() ? WRITES_HELD : null),
1519
1574
  origin: () => (app ? ownOrigin(app.port) : null),
1520
1575
  signIn: async (m) => { const role = typeof m.as === "string" ? m.as : identities?.roles()[0]; return role ? identities.headerFor(role) : null; },
1521
1576
  stampOf: (m) => { try { return m.file ? String(statSync(join(root, m.file)).mtimeMs) : ""; } catch { return ""; } },
@@ -1567,6 +1622,32 @@ process.on("SIGUSR2", () => {
1567
1622
  void restartApp();
1568
1623
  });
1569
1624
 
1625
+ // A job's result is work already done on this machine: sent until the server has it, through a dropped
1626
+ // network, for as long as the server can still be waiting on it (the bus's five-minute call). Sent once
1627
+ // and dropped on a failure, a reply the app gave during a thirty-second outage was lost and the run
1628
+ // waited out its turn. Any answer below 500 ends it: 404 is a server that no longer holds the job.
1629
+ async function deliver(id, out) {
1630
+ for (let wait = 1000, until = Date.now() + 300_000; Date.now() < until && !closing; wait = Math.min(wait * 2, 15_000)) {
1631
+ const res = await call("POST", `/local/${box}/jobs/${id}`, out).catch(() => null);
1632
+ if (res && res.status < 500) return;
1633
+ await new Promise((r) => setTimeout(r, wait / 2 + Math.random() * (wait / 2)));
1634
+ }
1635
+ }
1636
+
1637
+ // Asked again through a server that is still starting, a network that dropped or a rate limit every
1638
+ // terminal meets at once after a deploy, for as long as a restart can take: only the server saying it
1639
+ // does not know this terminal (404) ends the session. Ended on the first slow answer, a deploy that
1640
+ // restarted the server ended every session it held.
1641
+ async function reattach() {
1642
+ for (let wait = 2000, until = Date.now() + 300_000; ; wait = Math.min(wait * 2, 30_000)) {
1643
+ const timeoutMs = Math.max(5_000, Math.min(120_000, until - Date.now()));
1644
+ const back = await call("POST", "/local/reattach", { box, name: basename(root), project }, { timeoutMs }).catch(() => null);
1645
+ if (back && (back.ok || back.status === 404)) return back;
1646
+ if (Date.now() >= until || closing) return back;
1647
+ await new Promise((r) => setTimeout(r, wait / 2 + Math.random() * (wait / 2)));
1648
+ }
1649
+ }
1650
+
1570
1651
  let quiet = 0;
1571
1652
  // Each poll names the jobs the last one brought. A poll's answer can die on the way (the network
1572
1653
  // drops, the lid closes): the server sends again whatever is not named, and a job seen twice is
@@ -1581,7 +1662,8 @@ for (;;) {
1581
1662
  if (res.status === 404) {
1582
1663
  // The server restarted and forgot this terminal. It is the same terminal: it proves so with the
1583
1664
  // key it was given, its box is opened again, and the app it is holding is announced again.
1584
- const back = await call("POST", "/local/reattach", { box, name: basename(root), project }).catch(() => null);
1665
+ const back = await reattach();
1666
+ if (closing) break;
1585
1667
  if (!back?.ok) { say("this session ended on the server. Run the command again for a new one."); await close(1); }
1586
1668
  if (app?.port) await announce().catch(() => {});
1587
1669
  forgetTold?.();
@@ -1596,6 +1678,6 @@ for (;;) {
1596
1678
  if (seenJobs.has(job.id)) continue;
1597
1679
  seenJobs.add(job.id);
1598
1680
  if (seenJobs.size > 2000) seenJobs.delete(seenJobs.values().next().value);
1599
- verb(job).catch((e) => ({ error: String(e.message) })).then((out) => call("POST", `/local/${box}/jobs/${job.id}`, out).catch(() => {}));
1681
+ verb(job).catch((e) => ({ error: String(e.message) })).then((out) => deliver(job.id, out));
1600
1682
  }
1601
1683
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortad",
3
- "version": "0.3.10",
3
+ "version": "0.3.11",
4
4
  "description": "Connects the AI app on your machine to Cortad for test conversations, and gives your coding agent the MCP and skill to run them. No dependencies.",
5
5
  "bin": {
6
6
  "cortad": "local.mjs"
@@ -13,16 +13,20 @@
13
13
  "lib/backing.mjs",
14
14
  "lib/calls.mjs",
15
15
  "lib/cli.mjs",
16
+ "lib/commits.mjs",
17
+ "lib/container.mjs",
16
18
  "lib/contact-text.mjs",
17
19
  "lib/data.mjs",
18
20
  "lib/dissection-text.mjs",
19
21
  "lib/document.mjs",
22
+ "lib/elastic.mjs",
20
23
  "lib/failed-text.mjs",
21
24
  "lib/fresh.mjs",
22
25
  "lib/health.mjs",
23
26
  "lib/held.mjs",
24
27
  "lib/home.mjs",
25
28
  "lib/hosted.mjs",
29
+ "lib/env-files.mjs",
26
30
  "lib/env-secrets.mjs",
27
31
  "lib/identities.mjs",
28
32
  "lib/keeping.mjs",
@@ -61,6 +65,7 @@
61
65
  "lib/trace.cjs",
62
66
  "lib/wire.cjs",
63
67
  "lib/verbs.mjs",
68
+ "lib/intel-text.mjs",
64
69
  "lib/writes.mjs",
65
70
  "lib/words.mjs",
66
71
  "local.mjs",
package/skill/SKILL.md CHANGED
@@ -34,6 +34,7 @@ The person owns the product. They may not be reading the code with you, so every
34
34
  - A folder connected before reconnects at once with the key it kept.
35
35
  - A folder connected for the first time prints a link and four characters, good for ten minutes. Give the person both as printed; they approve the link in their browser, signed in to Cortad, and the command goes on by itself.
36
36
  - When a result says no connect command is running, run `npx {{cortad}}` again in this folder. No code is needed from the person.
37
+ - The app runs on copies of the databases its settings name, deleted when the command ends. When the person wants a run on other data, such as their staging databases instead of the test ones, stop the command and run `npx {{cortad}} --data <settings file>`: the databases that file names are copied instead, under the names the app reads them by, and the app keeps the rest of its own settings. The choice holds for later runs until the next connect, and every setting the app needs stays in place.
37
38
 
38
39
  The connect command prints "Go back to the browser" once it is connected. Cortad then finds the AI endpoints in the app's code and sends each one a test request itself, from this machine to the app on its port.
39
40
 
@@ -96,6 +97,10 @@ After the fixes, `run` again. It plays what your changes since the last full run
96
97
 
97
98
  Once a fix is verified gone and production is not connected, the result offers production: connect it to see what real users are doing as it happens and where the AI lets them down. `field_connect` gives the steps; the owner creates the key in the browser. `field` gives the numbers; message text stays out.
98
99
 
100
+ ## After a change: the one call, and a world
101
+
102
+ `test <what you built or want tested>` is the call after a change. Cortad decides what kind of test that is: a world, where this app has one and the sentence names something it can plant, or a run of the app's chat surfaces; the answer says which started and the call that follows it. A world is for an AI that is not a chat, 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 and investigation on each, and scores what it raised and published: found, missed, false alarms where nothing was planted, flips between repeats. `world <ask>` asks for one outright, `world_status <id>` follows it, and the person sees it on the World page. A repository no world is written for yet says so: send `feedback` naming the AI to test, and the Cortad team writes one.
103
+
99
104
  ## Making it stick
100
105
 
101
106
  `npx {{cortad}} stick` makes this part of the repository: one line in AGENTS.md, CLAUDE.md, the Cursor rules and the Copilot instructions; a hook after each edit that names the changed prompt and tool files; and a hook on the person's prompt that hands you a run or verify that ended, for clients that a background command cannot wake. Run it only when the person asks for it. `npx {{cortad}} unstick` takes them out.