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/README.md +3 -0
- package/lib/cli.mjs +4 -1
- package/lib/commits.mjs +202 -0
- package/lib/contact-text.mjs +13 -33
- 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 +33 -26
- package/lib/keys.mjs +10 -6
- package/lib/lock.mjs +7 -6
- package/lib/mcp.mjs +37 -4
- package/lib/mint.mjs +27 -11
- package/lib/mongo.mjs +64 -3
- package/lib/proof.mjs +8 -1
- package/lib/read-text.mjs +15 -15
- package/lib/stores.mjs +118 -63
- package/lib/text.mjs +17 -16
- package/lib/verbs.mjs +57 -2
- package/local.mjs +170 -86
- package/package.json +6 -1
- package/skill/SKILL.md +28 -11
- package/skill/references/results.md +17 -17
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 {
|
|
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 =
|
|
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);
|
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
//
|
|
653
|
-
//
|
|
654
|
-
|
|
655
|
-
|
|
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(
|
|
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 ??
|
|
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(
|
|
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:
|
|
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
|
|
981
|
-
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 };
|
|
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(
|
|
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
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
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
|
|
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
|
|
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(`
|
|
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
|
|
1254
|
-
//
|
|
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
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
if (
|
|
1288
|
-
|
|
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(
|
|
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
|
|
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
|
-
//
|
|
1515
|
-
//
|
|
1516
|
-
|
|
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
|
|
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) =>
|
|
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.
|
|
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",
|