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/README.md +3 -0
- package/lib/cli.mjs +4 -1
- package/lib/commits.mjs +202 -0
- package/lib/container.mjs +60 -0
- package/lib/data.mjs +15 -9
- package/lib/elastic.mjs +88 -0
- package/lib/env-files.mjs +124 -0
- package/lib/env-secrets.mjs +12 -4
- package/lib/intel-text.mjs +47 -0
- package/lib/keeping.mjs +32 -25
- package/lib/keys.mjs +10 -6
- package/lib/lock.mjs +7 -6
- package/lib/mcp.mjs +35 -2
- package/lib/mint.mjs +27 -11
- package/lib/mongo.mjs +64 -3
- package/lib/proof.mjs +8 -1
- package/lib/stores.mjs +116 -61
- package/lib/verbs.mjs +56 -1
- package/local.mjs +163 -81
- package/package.json +6 -1
- package/skill/SKILL.md +5 -0
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 =
|
|
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
|
-
|
|
247
|
-
let
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
//
|
|
655
|
-
//
|
|
656
|
-
|
|
657
|
-
|
|
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(
|
|
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 ??
|
|
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(
|
|
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:
|
|
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
|
|
983
|
-
const base = flag("--start") ? { ...withoutStoreSettings(envExports(
|
|
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(
|
|
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
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
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
|
|
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
|
|
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
|
|
1256
|
-
//
|
|
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
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
if (
|
|
1290
|
-
|
|
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
|
-
//
|
|
1517
|
-
//
|
|
1518
|
-
|
|
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
|
|
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) =>
|
|
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.
|
|
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.
|