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.
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";
@@ -28,7 +30,7 @@ import { changesOf, commandText, RELOADER, sourceOf } from "./lib/fresh.mjs";
28
30
  import { chainOf, elapsedMs, holderOf, listening, listenerOn, portInError, spawnTied, stopTree, supervisorOf } from "./lib/proc.mjs";
29
31
  import { claimRunner, readRunner, releaseRunner, replaceRunner, startedAtOf, writeRunner } from "./lib/runner.mjs";
30
32
  import { registerAll } from "./lib/register.mjs";
31
- import { machineText } from "./lib/read-text.mjs";
33
+ import { connectedText } from "./lib/read-text.mjs";
32
34
  import { VERSION } from "./lib/spec.mjs";
33
35
  import { maskedLines, maskWith, secretValues } from "./lib/env-secrets.mjs";
34
36
  import { finished, unseenText } from "./lib/text.mjs";
@@ -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);
@@ -598,7 +626,9 @@ async function verb(job) {
598
626
  } catch { return { text: "", root, size: 0 }; }
599
627
  }
600
628
  // The person changed what they said yes to while this command runs.
601
- case "consent": await allowOutbound(b); return { ok: true };
629
+ // Said in the terminal: an agent that read "one question for the person" here told them the run
630
+ // was still waiting two minutes after they had answered.
631
+ case "consent": await allowOutbound(b); say("the person's answer on the card in the browser arrived"); return { ok: true };
602
632
  // A line for the agent reading this terminal: the read of the code landing, said the moment it does.
603
633
  // One marked news (a run ending) is also kept for the hook, so an agent that was not listening
604
634
  // hears it on its next prompt or edit.
@@ -613,7 +643,7 @@ async function verb(job) {
613
643
  case "inventory": return inventoryOf(b.probe && typeof b.probe === "object" ? b.probe : {});
614
644
  // Your own pages, read here rather than in a world's shell: that shell is sealed away from
615
645
  // every env file and every host but this one, so inside it no store of yours has an address.
616
- 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)))) };
617
647
  case "lock": return { locked: Array.isArray(b.hosts) ? b.hosts.length : 0 };
618
648
  // What your app spent on its providers, from the hook inside it. An app this command did not
619
649
  // start has no hook, and the empty answer says the meter is absent rather than that nothing was spent.
@@ -649,12 +679,10 @@ const answers = async (port) => {
649
679
  }
650
680
  return false;
651
681
  };
652
- // The values an env file beside the app declares, exported into its process the way `source .env`
653
- // would: an app with no dotenv loader of its own crashed every route without them. Example files
654
- // hold placeholders and are left out; the shell's own environment and the lifted limits win.
655
- function envExports(envFiles) {
656
- return envValues(envFiles.filter((f) => !/\.(example|sample)$/.test(f)));
657
- }
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 });
658
686
  const onPath = (bin) => (process.env.PATH ?? "").split(":").some((dir) => dir && existsSync(join(dir, bin)));
659
687
  // Where their app lives inside this repository, and how it starts. Worked out in lib/start.mjs.
660
688
  let appDir = root;
@@ -707,7 +735,7 @@ async function startApp() {
707
735
  if (took) return took;
708
736
  if (proxyAsked) {
709
737
  await useProxy();
710
- 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(" ")}`);
711
739
  } else say(`${UNSEEN} Stop it and run this again without --port: this command starts your app itself.`);
712
740
  return { port: wanted, cmd: null };
713
741
  }
@@ -926,12 +954,12 @@ async function freePortAbove(port) {
926
954
  }
927
955
  const PORT_FLAG = /(--port[= ]|-p |\bPORT=)(\d{4,5})\b/;
928
956
  // The port a start names: its command's flag, or PORT as this command sets it or an env file does.
929
- 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;
930
958
  async function freed(cmd, lifted) {
931
959
  const m = PORT_FLAG.exec(cmd);
932
960
  // The app's own env file names its port too: yunqiao's PORT=8105 sat in .env, and a stale copy
933
961
  // on that port was reported as "your app stopped" instead of moved past.
934
- 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;
935
963
  if (!named || (!lost.has(named) && !(await listenerOn(named)))) return { cmd, lifted };
936
964
  const port = await freePortAbove(named);
937
965
  if (!port) return { cmd, lifted };
@@ -945,7 +973,7 @@ async function freed(cmd, lifted) {
945
973
  // was copied, started, or left where trials write into it is told in the terminal here and to the
946
974
  // run with the app (announce), so status says it beside Run and the report keeps it.
947
975
  const keeping = makeKeeping({
948
- 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,
949
977
  launch: (plan) => sidecar(plan.cmd, plan.cwd),
950
978
  connections: () => capture?.connections() ?? [],
951
979
  copies: () => capture?.copies() ?? [],
@@ -956,7 +984,7 @@ const keeping = makeKeeping({
956
984
  try { text = readFileSync(file, "utf8"); } catch { continue; }
957
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; }
958
986
  }
959
- 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)) };
960
988
  },
961
989
  });
962
990
 
@@ -977,8 +1005,8 @@ async function start(waitMs, tries = 3) {
977
1005
  await allowOutbound();
978
1006
  // A start command the person typed says which databases the app uses: the store settings of their
979
1007
  // settings files are left for the app to load, since handed over here they outrank the
980
- // environment that command chose, and no copy is pointed at.
981
- 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 };
982
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 ?? ""}` } : {}) } });
983
1011
  const mine = child;
984
1012
  become("starting", { app: mine.pid ?? null });
@@ -1086,7 +1114,7 @@ const savedSinceStart = () => Boolean(loaded?.files) && changesOf({
1086
1114
  // at again after each restart, for a save made while it was under way.
1087
1115
  // The stores the settings name, as copies are made of them: a change here is a change of what the app
1088
1116
  // writes into, so its copies and holds are made again for the new settings, and said again.
1089
- 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 ""; } };
1090
1118
  let storesAt = null;
1091
1119
  // Looked at before the first wait too: the app now starts while the code is uploaded, and a save made
1092
1120
  // before this watch began was otherwise never seen.
@@ -1117,13 +1145,14 @@ async function restartNow() {
1117
1145
  return { error };
1118
1146
  }
1119
1147
  become("up", { ...upAt(up.port), app: child?.pid ?? null });
1120
- if (up.port !== port) {
1121
- // It moved anyway: the world follows the app, never the other way round.
1122
- app = up;
1123
- const told = await announce().catch(() => null);
1124
- if (!told?.ok) return { error: `Your app came back on port ${up.port}, not ${port}. Run the command again to reconnect.` };
1125
- say(`your app came back on port ${up.port}; the run follows it there`);
1126
- }
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`);
1127
1156
  return { restarted: true, port: up.port };
1128
1157
  }
1129
1158
  const sentence = (s) => s.charAt(0).toUpperCase() + s.slice(1);
@@ -1163,7 +1192,7 @@ const envFiles = [];
1163
1192
  const files = [];
1164
1193
  listed = gitListed();
1165
1194
  walk(root, 0, files, envFiles, 0);
1166
- secrets = secretValues(envFiles);
1195
+ secrets = secretValues(dataFile ? [...envFiles, dataFile] : envFiles);
1167
1196
  envKeys = secretEnvValues(envFiles, (f) => readFileSync(f, "utf8"));
1168
1197
  const kept = [];
1169
1198
  for (let i = files.length - 1; i >= 0; i -= 1) if (carriesKey(files[i])) kept.unshift(...files.splice(i, 1));
@@ -1182,14 +1211,15 @@ if (explain) {
1182
1211
  `would send ${files.length} source files, ${Math.round(bytes / 1024)} KB, once${listed ? " (what git would commit)" : ""}`,
1183
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 ? ", ..." : ""}`] : []),
1184
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 ? ", ..." : ""}`] : []),
1185
- `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`,
1186
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`,
1187
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"}`,
1188
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)`}`,
1189
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`,
1190
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`,
1191
1220
  `your files never written by this program; your own coding agent edits them`,
1192
- `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"}`,
1193
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`,
1194
1224
  `test shell confined by the OS: your project and toolchains only, writes to temp and build folders, localhost only`,
1195
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`,
@@ -1198,7 +1228,7 @@ if (explain) {
1198
1228
  ].join("\n"));
1199
1229
  process.exit(0);
1200
1230
  }
1201
- if (kept.length) say(`not uploaded, left on this machine: ${kept.join(", ")}`);
1231
+ if (kept.length) say(`left on this machine because it holds a key: ${kept.join(", ")}`);
1202
1232
  const keepSecret = (v) => { if (v && v.length >= 12 && !secrets.includes(v)) secrets.push(v); };
1203
1233
  identities = makeIdentities({ root, work, envFiles, sourceFiles: () => files, say, keepSecret, appDir: () => appDir });
1204
1234
  // A real request that reached their model proves its door (lib/proof.mjs). What it proved goes up,
@@ -1250,8 +1280,8 @@ const treeDigest = (() => {
1250
1280
  }
1251
1281
  return h.digest("hex");
1252
1282
  })();
1253
- // The commit this tree is. Your .git is never uploaded -- nothing of your history leaves this
1254
- // 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
1255
1285
  // that is not a checkout sends nothing, and the report says so rather than inventing one.
1256
1286
  const head = (() => {
1257
1287
  try {
@@ -1271,25 +1301,51 @@ const PART = 1_500_000;
1271
1301
  let resumed = false;
1272
1302
  let moved = false;
1273
1303
  const parts = Math.max(1, Math.ceil(bytes.length / PART));
1274
- for (let off = 0; off < bytes.length; off += PART) {
1275
- const last = off + PART >= bytes.length;
1276
- step(parts > 1 ? `connecting, ${Math.floor(off / PART) + 1} of ${parts}` : "connecting");
1277
- // The last part asks for this machine's key when none is stored yet: minted once the repository
1278
- // row exists, kept in ~/.cortad for the runs a coding agent asks for on later days. A connect by a
1279
- // code asks too, since the code may be another account's: the key kept from the account before
1280
- // signed the coding agent's tools into that one, its runs and its counts.
1281
- const put = await call("PUT", `/local/${box}/tree?last=${last ? 1 : 0}${last ? `&digest=${treeDigest}${head ? `&head=${head}` : ""}` : ""}`, bytes.subarray(off, off + PART),
1282
- { raw: true, timeoutMs: 120_000, ...(last && !viaToken && (!stored || code) ? { headers: { "x-cortad-machine": hostname().slice(0, 80) } } : {}) }).catch(unreachable);
1283
- if (!put.ok) fail(put.data?.error ?? `upload failed (${put.status})`);
1284
- if (last) {
1285
- resumed = put.data?.resumed === true;
1286
- moved = put.data?.moved === true;
1287
- if (typeof put.data?.machineKey === "string" && put.data.machineKey) writeToken(project, put.data.machineKey);
1288
- 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})`);
1289
1344
  }
1290
1345
  }
1291
1346
  // Unchanged code has already been read: coming back says so instead of claiming a second read.
1292
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}`); });
1293
1349
  // The coding agents on this machine learn about Cortad now, once: an MCP entry and a skill in each
1294
1350
  // one's own home folder. A run started by an agent later comes back through lib/cli.mjs.
1295
1351
  // CORTAD_NO_REGISTER=1 leaves the coding agents' global skill and MCP entries alone: a stack under
@@ -1297,7 +1353,7 @@ stepDone(moved ? "connected: your change is in, and what Cortad read before stil
1297
1353
  if (!viaToken && process.env.CORTAD_NO_REGISTER !== "1") {
1298
1354
  const got = await registerAll().catch((err) => { if (verbose) say(`could not register with your coding agents: ${err?.message ?? err}`); return { clients: [], changed: [] }; });
1299
1355
  const and = (list) => new Intl.ListFormat("en", { type: "conjunction" }).format(list);
1300
- if (got.changed.length) say(`For the person: ${and(got.clients)} can now use Cortad: its MCP server and skill were added to ${got.changed.map((f) => `~/${f}`).join(", ")}. This repository was not changed.`);
1356
+ if (got.changed.length) say(`${and(got.clients)} can now use Cortad: its tools and skill were added to ${got.changed.map((f) => `~/${f}`).join(", ")}. This repository was not changed.`);
1301
1357
  }
1302
1358
 
1303
1359
  lock = await makeLock({ root, work });
@@ -1389,7 +1445,7 @@ async function appLife(firstStart) {
1389
1445
  say(`your app is answering on port ${app.port}${app.cmd ? ` · ${app.cmd}` : ""}`);
1390
1446
  if (launched) say(`your app's output for this session is kept in ${bootLog}, with the values from your env files masked`);
1391
1447
  if (proxy) say(`send requests to port ${proxy.port}: it passes them to your app and Cortad sees each one. Requests sent straight to port ${app.port} are not seen`);
1392
- for (const line of machineText(machineOf())) say(line);
1448
+ for (const line of connectedText(machineOf())) say(line);
1393
1449
  say("leave this open. Go back to the browser; Ctrl-C disconnects.");
1394
1450
  let downSince = 0;
1395
1451
  let toldDown = false;
@@ -1511,9 +1567,10 @@ const autoReach = makeAutoReach({
1511
1567
  return res?.ok ? res.data : null;
1512
1568
  },
1513
1569
  runner: () => (became?.state === "up" && !restarting ? { state: "up", ...became.fields } : null),
1514
- // One test request per endpoint is what the person's own agent sends by hand, so it does not wait on
1515
- // the yes a run's writes into an uncopied store need; it waits only while a copy is being made.
1516
- 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),
1517
1574
  origin: () => (app ? ownOrigin(app.port) : null),
1518
1575
  signIn: async (m) => { const role = typeof m.as === "string" ? m.as : identities?.roles()[0]; return role ? identities.headerFor(role) : null; },
1519
1576
  stampOf: (m) => { try { return m.file ? String(statSync(join(root, m.file)).mtimeMs) : ""; } catch { return ""; } },
@@ -1565,6 +1622,32 @@ process.on("SIGUSR2", () => {
1565
1622
  void restartApp();
1566
1623
  });
1567
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
+
1568
1651
  let quiet = 0;
1569
1652
  // Each poll names the jobs the last one brought. A poll's answer can die on the way (the network
1570
1653
  // drops, the lid closes): the server sends again whatever is not named, and a job seen twice is
@@ -1579,7 +1662,8 @@ for (;;) {
1579
1662
  if (res.status === 404) {
1580
1663
  // The server restarted and forgot this terminal. It is the same terminal: it proves so with the
1581
1664
  // key it was given, its box is opened again, and the app it is holding is announced again.
1582
- const back = await call("POST", "/local/reattach", { box, name: basename(root), project }).catch(() => null);
1665
+ const back = await reattach();
1666
+ if (closing) break;
1583
1667
  if (!back?.ok) { say("this session ended on the server. Run the command again for a new one."); await close(1); }
1584
1668
  if (app?.port) await announce().catch(() => {});
1585
1669
  forgetTold?.();
@@ -1594,6 +1678,6 @@ for (;;) {
1594
1678
  if (seenJobs.has(job.id)) continue;
1595
1679
  seenJobs.add(job.id);
1596
1680
  if (seenJobs.size > 2000) seenJobs.delete(seenJobs.values().next().value);
1597
- 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));
1598
1682
  }
1599
1683
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortad",
3
- "version": "0.3.9",
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",