cortad 0.3.10 → 0.4.0
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 +14 -7
- package/lib/auto-reach.mjs +6 -5
- package/lib/cli.mjs +4 -1
- package/lib/commits.mjs +202 -0
- package/lib/contact-text.mjs +3 -4
- package/lib/env-files.mjs +39 -0
- package/lib/env-secrets.mjs +12 -4
- package/lib/files.mjs +5 -0
- package/lib/fresh.mjs +1 -1
- package/lib/intel-text.mjs +47 -0
- package/lib/keys.mjs +10 -6
- package/lib/lock.mjs +7 -6
- package/lib/mcp.mjs +38 -5
- package/lib/proc.mjs +3 -1
- package/lib/proof.mjs +8 -1
- package/lib/proxy.mjs +14 -3
- package/lib/pyhook/sitecustomize.py +1 -500
- package/lib/reach.mjs +7 -7
- package/lib/read-text.mjs +0 -1
- package/lib/replay.mjs +6 -11
- package/lib/start.mjs +13 -34
- package/lib/switches.mjs +9 -69
- package/lib/trace.cjs +2 -320
- package/lib/verbs.mjs +56 -1
- package/local.mjs +252 -520
- package/package.json +5 -15
- package/skill/SKILL.md +16 -9
- package/skill/references/results.md +5 -5
- package/lib/backing.mjs +0 -230
- package/lib/data.mjs +0 -137
- package/lib/hosted.mjs +0 -201
- package/lib/identities.mjs +0 -689
- package/lib/keeping.mjs +0 -174
- package/lib/ledger.mjs +0 -20
- package/lib/listing.mjs +0 -12
- package/lib/mint.mjs +0 -334
- package/lib/mongo.mjs +0 -74
- package/lib/pg.mjs +0 -118
- package/lib/qdrant.mjs +0 -48
- package/lib/redis.mjs +0 -130
- package/lib/service.mjs +0 -96
- package/lib/stores.mjs +0 -378
package/local.mjs
CHANGED
|
@@ -1,31 +1,33 @@
|
|
|
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
|
|
4
|
+
// npx cortad ABCD2345 -- <how you start your app> your command, run here with Cortad's hook inside it
|
|
5
|
+
// npx cortad ABCD2345 --port 3000 an app already running: its replies are seen, its model calls are not
|
|
5
6
|
//
|
|
6
7
|
// What it does: signs in with the code, uploads your source files once (never .env, never
|
|
7
|
-
// node_modules) so your code can be read,
|
|
8
|
-
// outbound connection open and does what a run asks: a request to
|
|
9
|
-
//
|
|
8
|
+
// node_modules) so your code can be read, runs your start command as you typed it with nothing
|
|
9
|
+
// added but the hook, then holds one outbound connection open and does what a run asks: a request to
|
|
10
|
+
// your app, a file read, a shell line. It guesses nothing about your environment: your command
|
|
11
|
+
// decides the settings, the databases and the services, and nothing is copied or started beside it. The shell is locked by the operating system (lib/lock.mjs) and cannot write your code;
|
|
10
12
|
// nothing here writes your files at all. It also makes Cortad known to the coding agents on this
|
|
11
13
|
// machine (lib/register.mjs): an MCP entry and a skill, so `npx cortad mcp` answers them from then
|
|
12
14
|
// on with the key this connect leaves in ~/.cortad. Nothing here touches git. Your environment
|
|
13
15
|
// never leaves this machine. Ctrl-C ends everything.
|
|
14
16
|
|
|
15
|
-
import { storesOf, withoutStoreSettings } from "./lib/stores.mjs";
|
|
16
17
|
import { notAnApp, withoutStores } from "./lib/app-port.mjs";
|
|
17
18
|
import { holdsKeys, secretEnvValues } from "./lib/keys.mjs";
|
|
19
|
+
import { parseEnv } from "./lib/env-files.mjs";
|
|
20
|
+
import { historyParts, repoFacts } from "./lib/commits.mjs";
|
|
18
21
|
import { spawn, execFile, execFileSync } from "node:child_process";
|
|
19
22
|
import { createHash } from "node:crypto";
|
|
20
23
|
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, watch, writeFileSync } from "node:fs";
|
|
21
|
-
import {
|
|
24
|
+
import { hostname, tmpdir } from "node:os";
|
|
22
25
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
23
|
-
import { createInterface } from "node:readline";
|
|
24
26
|
import { promisify } from "node:util";
|
|
25
27
|
import { COMMANDS, main as face, unknownVerb, VERBS } from "./lib/cli.mjs";
|
|
26
28
|
import { addNews, homeOf, projectOf, readToken, writeApp, writeDigest, writeToken } from "./lib/home.mjs";
|
|
27
29
|
import { changesOf, commandText, RELOADER, sourceOf } from "./lib/fresh.mjs";
|
|
28
|
-
import { chainOf, elapsedMs, holderOf, listening, listenerOn, portInError, spawnTied, stopTree
|
|
30
|
+
import { chainOf, elapsedMs, holderOf, listening, listenerOn, portInError, spawnTied, stopTree } 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
33
|
import { connectedText } from "./lib/read-text.mjs";
|
|
@@ -33,15 +35,11 @@ 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";
|
|
35
37
|
import { lockHolds, makeLock } from "./lib/lock.mjs";
|
|
36
|
-
import { AS_HEADER,
|
|
37
|
-
import { CAPTURED, makeCapture } from "./lib/replay.mjs";
|
|
38
|
+
import { AS_HEADER, CAPTURED, makeCapture } from "./lib/replay.mjs";
|
|
38
39
|
import { sampleHere } from "./lib/sample.mjs";
|
|
39
|
-
import {
|
|
40
|
-
import { listingUrl } from "./lib/listing.mjs";
|
|
41
|
-
import { hookable, installPlan, missingDependency, shownCommand, startPlan, workspaces } from "./lib/start.mjs";
|
|
40
|
+
import { hookable, shownCommand, startPlan, workspaces } from "./lib/start.mjs";
|
|
42
41
|
import { openProxy } from "./lib/proxy.mjs";
|
|
43
|
-
import { liftedLimits
|
|
44
|
-
import { makeKeeping } from "./lib/keeping.mjs";
|
|
42
|
+
import { liftedLimits } from "./lib/switches.mjs";
|
|
45
43
|
import { downLine, makeHealth } from "./lib/health.mjs";
|
|
46
44
|
import { markRun, writesDirOf, writtenPaths } from "./lib/writes.mjs";
|
|
47
45
|
import { makeHeld } from "./lib/held.mjs";
|
|
@@ -58,18 +56,21 @@ if (unknownVerb(argv[0])) {
|
|
|
58
56
|
console.error(`cortad ${argv[0]} is not a command of cortad ${VERSION}. Its commands are ${VERBS.slice(0, -1).join(", ")} and ${VERBS.at(-1)}; npx cortad@latest <command> runs the newest. A connect code is typed in capitals, as the connect screen shows it.`);
|
|
59
57
|
process.exit(1);
|
|
60
58
|
}
|
|
61
|
-
|
|
62
|
-
const
|
|
59
|
+
// Everything after `--` is the start command, as typed; `--start "cmd"` says the same for an older skill.
|
|
60
|
+
const dash = argv.indexOf("--");
|
|
61
|
+
const own = dash >= 0 ? argv.slice(0, dash) : argv;
|
|
62
|
+
const flag = (name) => { const i = own.indexOf(name); return i >= 0 ? own[i + 1] : undefined; };
|
|
63
|
+
const verbose = own.includes("--verbose");
|
|
63
64
|
// A code is eight characters from the connect screen's alphabet, which has no I, O, 0 or 1.
|
|
64
|
-
let code = (
|
|
65
|
+
let code = (own.find((a) => /^[A-HJ-NP-Za-hj-np-z2-9-]{8,9}$/.test(a) && !a.startsWith("-")) ?? "").toUpperCase().replace(/-/g, "");
|
|
65
66
|
const say = (line) => console.log(`cortad ${line}`);
|
|
66
67
|
const fail = (line) => { console.error(`cortad ${line}`); process.exit(1); };
|
|
67
68
|
|
|
68
|
-
const explain =
|
|
69
|
+
const explain = own.includes("--explain");
|
|
69
70
|
// The app is seen through a local proxy (lib/proxy.mjs) even where the hook could load into it.
|
|
70
|
-
const proxyAsked =
|
|
71
|
+
const proxyAsked = own.includes("--proxy");
|
|
71
72
|
// Started by lib/cli.mjs for a run on a later day: no code, the key the first connect left behind.
|
|
72
|
-
const viaToken =
|
|
73
|
+
const viaToken = own.includes("--token");
|
|
73
74
|
// What is happening right now, on one line that rewrites itself. npx spends its own seconds fetching
|
|
74
75
|
// this package before anything here runs, and the first thing we printed used to be after the whole
|
|
75
76
|
// upload: a minute or more of a cursor sitting still, which reads as nothing happening.
|
|
@@ -84,8 +85,9 @@ const clearStep = () => { if (process.stdout.isTTY) process.stdout.write("\r\x1b
|
|
|
84
85
|
const stepDone = (line) => { clearStep(); say(line); };
|
|
85
86
|
// `npx cortad` alone connects too: with the key this folder kept from its last connect, or, with none,
|
|
86
87
|
// by one approval in the browser (pairing below). A word that is neither a code nor a flag is refused.
|
|
87
|
-
const words =
|
|
88
|
-
|
|
88
|
+
const words = own.filter((a, i) => !a.startsWith("-") && !["--port", "--start"].includes(own[i - 1] ?? ""));
|
|
89
|
+
const USAGE = `usage: npx cortad <code from the connect screen> -- <how you start your app> | npx cortad <code> --port <port of your running app> | npx cortad --explain | npx cortad ${[...VERBS, "wait"].join(" | ")}`;
|
|
90
|
+
if (!explain && !viaToken && words.length && !/^[A-Z0-9]{8}$/.test(code)) fail(USAGE);
|
|
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
|
|
@@ -101,13 +103,39 @@ const root = process.cwd();
|
|
|
101
103
|
const MANIFEST = ["package.json", "pyproject.toml", "requirements.txt", "go.mod", "Cargo.toml", "docker-compose.yml", "docker-compose.yaml", "Gemfile", "mix.exs"];
|
|
102
104
|
// A repository whose root carries no manifest of its own is still a repository: crewai-examples
|
|
103
105
|
// keeps one project per folder under crews/, and the whole of it was refused at the door. The root
|
|
104
|
-
// is accepted when a project lives below it
|
|
105
|
-
|
|
106
|
-
|
|
106
|
+
// is accepted when a project lives below it, and whenever the person says how the app starts or
|
|
107
|
+
// where it answers: a single file with no manifest is an app too, and nothing here needs the manifest.
|
|
108
|
+
const startSaid = argv.includes("--") || argv.includes("--start") || argv.includes("--port") || argv.includes("--token");
|
|
109
|
+
if (!startSaid && !MANIFEST.some((f) => existsSync(join(root, f))) && !workspaces(root).length) {
|
|
110
|
+
fail(`no project here: this folder has no package.json or pyproject.toml, and neither does any folder in it. Run it from your repository's root, or say how your app starts: npx cortad <code> -- <your start command>`);
|
|
107
111
|
}
|
|
108
112
|
// Which project this is, as a hash of where it lives: the same folder coming back resumes the same
|
|
109
113
|
// connection, and the path itself never leaves this machine.
|
|
110
114
|
const project = projectOf(root);
|
|
115
|
+
// How this app starts, kept on this machine for the sessions a run starts by itself (--token) and
|
|
116
|
+
// for the next connect: the command typed after `--`, or the port of an app already running.
|
|
117
|
+
const startFile = join(homeOf(project), "start.json");
|
|
118
|
+
const keptStart = (() => { try { return JSON.parse(readFileSync(startFile, "utf8")); } catch { return {}; } })();
|
|
119
|
+
// Typed after --, or with --start, or saved from the last connect of this folder: a person who
|
|
120
|
+
// reconnects types the code alone, and the runner a run starts is given nothing at all.
|
|
121
|
+
const typedNow = dash >= 0 ? own.length === argv.length ? "" : argv.slice(dash + 1).join(" ").trim() : flag("--start") ?? "";
|
|
122
|
+
const typed = typedNow || (flag("--port") ? "" : keptStart.cmd ?? "");
|
|
123
|
+
const fromSaved = !typedNow && Boolean(typed);
|
|
124
|
+
// A port saved beside the command (a container's) is watched again on a reconnect with the code alone.
|
|
125
|
+
const wantedPort = Number(flag("--port")) || (!typedNow ? Number(keptStart.port) || 0 : 0);
|
|
126
|
+
const onPath = (bin) => (process.env.PATH ?? "").split(":").some((dir) => dir && existsSync(join(dir, bin)));
|
|
127
|
+
// Nothing was said about how the app starts: the one suggestion the repository makes obvious is
|
|
128
|
+
// printed, never run. A guess shown to a person is fine; a guess acted on at runtime is what put an
|
|
129
|
+
// app on a placeholder key and its library in an empty copy.
|
|
130
|
+
if (!explain && !viaToken && !typed && !wantedPort) {
|
|
131
|
+
const plan = startPlan({ root, onPath });
|
|
132
|
+
const shown = code || "<code>";
|
|
133
|
+
console.error(`cortad how does your app start? Run this again with your command after --:`);
|
|
134
|
+
console.error(`cortad npx cortad ${shown} -- ${plan?.cmd ?? "<your start command>"}${plan?.cmd ? ` (from this folder that looks like it${plan.within ? `, in ${plan.within}` : ""}; change it if not)` : ""}`);
|
|
135
|
+
console.error(`cortad or, if your app is already running: npx cortad ${shown} --port ${plan?.cmd ? "<its port>" : "3000"}`);
|
|
136
|
+
process.exit(2);
|
|
137
|
+
}
|
|
138
|
+
if (viaToken && !typed && !wantedPort) fail("this project has no saved start command. Run npx cortad <code> -- <how you start your app> in this folder once.");
|
|
111
139
|
|
|
112
140
|
// One process holds a project's app up (lib/runner.mjs), and its state is what every reader reports.
|
|
113
141
|
// `reach: "ask"`: an agent's reach is sent by this process (lib/auto-reach.mjs), signed in where it can.
|
|
@@ -193,71 +221,6 @@ function carriesKey(rel) {
|
|
|
193
221
|
try { return holdsKeys(readFileSync(join(root, rel), "utf8"), envKeys); } catch { return false; }
|
|
194
222
|
}
|
|
195
223
|
|
|
196
|
-
// Origins your environment names (FRONTEND_URL, CORS_ORIGIN, ...): an app that trusts a browser
|
|
197
|
-
// Origin is asked as that browser. Sent as origins only, never the variable's full value.
|
|
198
|
-
function envOrigins(envFiles) {
|
|
199
|
-
const out = new Set();
|
|
200
|
-
for (const file of envFiles) {
|
|
201
|
-
let text = "";
|
|
202
|
-
try { text = readFileSync(file, "utf8"); } catch { continue; }
|
|
203
|
-
for (const line of text.split("\n")) {
|
|
204
|
-
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
205
|
-
if (!m || !/ORIGIN|URL|HOST|DOMAIN|FRONTEND|CLIENT|SITE|WEB/i.test(m[1])) continue;
|
|
206
|
-
// OPENAI_BASE_URL names where a key is sent, not a page a browser opens the app from.
|
|
207
|
-
if (/(?:BASE_URL|API_BASE|ENDPOINT)$/i.test(m[1]) && !/ORIGIN|FRONTEND|CLIENT|SITE|WEB|PUBLIC|APP/i.test(m[1])) continue;
|
|
208
|
-
for (const v of m[2].replace(/^(['"])(.*)\1$/, "$2").split(",")) {
|
|
209
|
-
try { const u = new URL(v.trim()); if (/^https?:$/.test(u.protocol)) out.add(u.origin); } catch { /* not an origin */ }
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
return [...out].slice(0, 8);
|
|
214
|
-
}
|
|
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
|
-
// Switch-shaped names and the word each one reads as. The name and the word travel, never the value.
|
|
232
|
-
const SWITCH_NAME = /(?:_ENABLED|_DISABLED|_MODE|_ON|_OFF|_FLAG)$|^(?:ENABLE|USE|DISABLE|SKIP)_/;
|
|
233
|
-
const switchStates = (values) => Object.fromEntries(Object.entries(values)
|
|
234
|
-
.filter(([name]) => SWITCH_NAME.test(name))
|
|
235
|
-
.map(([name, value]) => [name, value === "" ? "unset" : /^(?:1|true|yes|on)$/i.test(value) ? "on" : /^(?:0|false|no|off)$/i.test(value) ? "off" : "set"]));
|
|
236
|
-
|
|
237
|
-
// What your app can actually reach: each provider key your env files set is asked that provider
|
|
238
|
-
// for its own model listing, from this machine. Your key stays here; what goes back is the name it
|
|
239
|
-
// is set under, the host, what the host answered, the model ids, and which switches are on.
|
|
240
|
-
const MODEL_ID = /^[\w./:@-]{1,160}$/;
|
|
241
|
-
async function inventoryOf(listings) {
|
|
242
|
-
const values = envValues(envFiles);
|
|
243
|
-
const providers = await Promise.all(Object.entries(listings)
|
|
244
|
-
.filter(([name]) => values[name])
|
|
245
|
-
.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: [] };
|
|
257
|
-
}
|
|
258
|
-
}));
|
|
259
|
-
return { providers: providers.filter(Boolean), flags: switchStates(values) };
|
|
260
|
-
}
|
|
261
224
|
let secrets = [];
|
|
262
225
|
// A run's request to an endpoint your app answers later carries the first (src/customer/endpoint.ts),
|
|
263
226
|
// with how long the run waits for this reply; a reply with no answer says in the second why none came.
|
|
@@ -268,7 +231,6 @@ const trialJars = new Map();
|
|
|
268
231
|
// Endpoints that refused a new visitor: their trials carry the person's own session.
|
|
269
232
|
const ownSession = new Set();
|
|
270
233
|
const TRIAL_JARS = 500;
|
|
271
|
-
let identities = null;
|
|
272
234
|
let capture = null;
|
|
273
235
|
// The local proxy an app is seen through when the hook cannot load into it (lib/proxy.mjs).
|
|
274
236
|
let proxy = null;
|
|
@@ -294,8 +256,7 @@ let forgetTold = null;
|
|
|
294
256
|
// load on it, not a hang.
|
|
295
257
|
let answering = 0;
|
|
296
258
|
const held = makeHeld({ rows: () => capture?.usage()?.rows ?? [], say: (line) => say(line) });
|
|
297
|
-
// Closed while
|
|
298
|
-
// run's requests wait at it rather than write into the original.
|
|
259
|
+
// Closed while the app is started again: the run's requests wait at it and are sent once it answers.
|
|
299
260
|
let gate = null;
|
|
300
261
|
const hold = () => { if (!gate) { let open; gate = new Promise((r) => { open = r; }); gate.open = () => { gate = null; open(); }; } };
|
|
301
262
|
const release = () => gate?.open();
|
|
@@ -340,13 +301,6 @@ async function call(method, path, body, { raw = false, timeoutMs = 60_000, heade
|
|
|
340
301
|
|
|
341
302
|
// ---- the box on this machine: your folder, read where it stands
|
|
342
303
|
const work = join(tmpdir(), `cortad-${process.pid}`);
|
|
343
|
-
// Where the hook reads the hosts a trial's writes may reach (lib/trace.cjs, lib/pyhook), and the
|
|
344
|
-
// yes they come from.
|
|
345
|
-
const outboundFile = join(work, "outbound.json");
|
|
346
|
-
let consent = {};
|
|
347
|
-
// The person's yes to raising their app's own limits and opening its sign-in switches for this
|
|
348
|
-
// session, from the browser. Until it arrives the app runs with its guards as they are.
|
|
349
|
-
let limitsYes = false;
|
|
350
304
|
mkdirSync(work, { recursive: true });
|
|
351
305
|
// What the app printed this session, beside the project's other records rather than in a temp folder
|
|
352
306
|
// the agent is never told of. Emptied once this process holds the project (holdProject), so a second
|
|
@@ -393,9 +347,14 @@ async function verb(job) {
|
|
|
393
347
|
if (verbose) say(`$ ${cmd.slice(0, 160)}`);
|
|
394
348
|
// No lock, no shell: a world never gets an unlocked one on this machine.
|
|
395
349
|
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
|
-
|
|
350
|
+
// A world's command (opts.app) runs as the app: with this terminal's settings, which are the
|
|
351
|
+
// app's own where the person started it from here, and with the app's reach on the network,
|
|
352
|
+
// since it runs the app's own AI against the app's model provider.
|
|
353
|
+
const asApp = b.opts?.app === true;
|
|
354
|
+
const { file, args } = lock.wrap(cmd, { network: asApp });
|
|
355
|
+
const env = asApp ? { ...process.env, ...plainEnv } : plainEnv;
|
|
397
356
|
return new Promise((done) => {
|
|
398
|
-
const child = spawn(file, args, { cwd: root, env
|
|
357
|
+
const child = spawn(file, args, { cwd: root, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
399
358
|
let out = "", err = "";
|
|
400
359
|
const cap = (s, chunk) => (s.length < 20_000_000 ? s + chunk : s);
|
|
401
360
|
child.stdout.on("data", (d) => { out = cap(out, d.toString()); });
|
|
@@ -417,6 +376,7 @@ async function verb(job) {
|
|
|
417
376
|
// Every request of ours is tagged, a knock or a revisit as much as a trial, so the hook never
|
|
418
377
|
// takes one for the person's: it would become the request trials copy and the sign-in they carry.
|
|
419
378
|
if (!turn) headers["x-cortad-turn"] = `cortad-${String(job.id).replace(/[^\w.-]/g, "").slice(0, 64)}`;
|
|
379
|
+
if (verbose) say(`$ ${String(b.method ?? "GET").toUpperCase()} ${path.split("?")[0]} as ${headers[AS_HEADER] ?? headers[AS_HEADER.toLowerCase()] ?? "nobody"}, turn ${headers["x-cortad-turn"]}`);
|
|
420
380
|
const letGo = held.hold(job.id, turn || headers["x-cortad-turn"]);
|
|
421
381
|
// An endpoint your app answers later: it takes the message and its model answers afterwards,
|
|
422
382
|
// outside the request (a queue's worker). Its own reply only says it took the message.
|
|
@@ -425,8 +385,9 @@ async function verb(job) {
|
|
|
425
385
|
const later = laterAt !== undefined && (headers[laterAt] === "later" || laterMs > 0);
|
|
426
386
|
if (laterAt !== undefined) delete headers[laterAt];
|
|
427
387
|
if (later) capture?.forget(turn || headers["x-cortad-turn"]);
|
|
428
|
-
// A request that speaks as one of your app's own callers carries the role, not the
|
|
429
|
-
//
|
|
388
|
+
// A request that speaks as one of your app's own callers carries the role, not the sign-in: the
|
|
389
|
+
// sign-in was seen on this machine, on the request the person or their agent sent, and is put in
|
|
390
|
+
// here, so it never travels. There is no other caller: Cortad makes no accounts.
|
|
430
391
|
const marker = Object.keys(headers).find((k) => k.toLowerCase() === AS_HEADER);
|
|
431
392
|
let spokenAs = null;
|
|
432
393
|
if (marker) {
|
|
@@ -434,16 +395,8 @@ async function verb(job) {
|
|
|
434
395
|
delete headers[marker];
|
|
435
396
|
if (role === CAPTURED || role.startsWith(`${CAPTURED}:`)) {
|
|
436
397
|
spokenAs = role;
|
|
437
|
-
// Speaking as one of the customers whose requests we watched: every header their own client sent.
|
|
438
398
|
const theirs = capture?.headers(role);
|
|
439
399
|
for (const [name, value] of Object.entries(theirs ?? {})) { for (const k of Object.keys(headers)) if (k.toLowerCase() === name) delete headers[k]; headers[name] = value; }
|
|
440
|
-
// No request of theirs is held (a new connect): sent from the app's own address, as Cortad's
|
|
441
|
-
// own test request is, so an app that lets visitors in by Origin lets this one in.
|
|
442
|
-
const from = !theirs && app ? ownOrigin(app.port) : null;
|
|
443
|
-
if (from && !Object.keys(headers).some((k) => k.toLowerCase() === "origin")) { headers.origin = from; headers.referer = `${from}/`; }
|
|
444
|
-
} else {
|
|
445
|
-
const held = await identities?.headerFor(role);
|
|
446
|
-
if (held) { for (const k of Object.keys(headers)) if (k.toLowerCase() === held.name) delete headers[k]; headers[held.name] = held.value; }
|
|
447
400
|
}
|
|
448
401
|
}
|
|
449
402
|
const method = String(b.method ?? "GET").toUpperCase();
|
|
@@ -580,11 +533,8 @@ async function verb(job) {
|
|
|
580
533
|
}
|
|
581
534
|
catch (e) { return { success: false, stderr: String(e.message) }; }
|
|
582
535
|
}
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
mint: (recipes, port, origin) => identities.mint({ ...b, recipes, headers: { ...(b.headers ?? {}), ...(origin ? { origin, referer: `${origin}/` } : {}) } }, port),
|
|
586
|
-
originFor: (port) => originFor(port, envOrigins(envFiles)),
|
|
587
|
-
})))) : { identities: [] };
|
|
536
|
+
// Cortad makes no accounts in your sign-in system: the request your agent sent carries the one it uses.
|
|
537
|
+
case "mint": return { identities: [] };
|
|
588
538
|
// The last of what your app printed, for a reply it failed: the traceback a developer reads.
|
|
589
539
|
// Masked like everything else, and the folder named so its paths can be read as your files.
|
|
590
540
|
case "log": {
|
|
@@ -600,7 +550,7 @@ async function verb(job) {
|
|
|
600
550
|
// The person changed what they said yes to while this command runs.
|
|
601
551
|
// Said in the terminal: an agent that read "one question for the person" here told them the run
|
|
602
552
|
// was still waiting two minutes after they had answered.
|
|
603
|
-
case "consent":
|
|
553
|
+
case "consent": say("the person's answer on the card in the browser arrived"); return { ok: true };
|
|
604
554
|
// A line for the agent reading this terminal: the read of the code landing, said the moment it does.
|
|
605
555
|
// One marked news (a run ending) is also kept for the hook, so an agent that was not listening
|
|
606
556
|
// hears it on its next prompt or edit.
|
|
@@ -612,10 +562,9 @@ async function verb(job) {
|
|
|
612
562
|
return { ok: true };
|
|
613
563
|
}
|
|
614
564
|
case "restart": return restartApp();
|
|
615
|
-
|
|
616
|
-
//
|
|
617
|
-
|
|
618
|
-
case "sample": return { report: JSON.parse(mask(JSON.stringify(await sampleHere(b, envValues(envFiles), root)))) };
|
|
565
|
+
// Your own pages, read here rather than in a world's shell, with this terminal's settings: the
|
|
566
|
+
// ones your app was started with.
|
|
567
|
+
case "sample": return { report: JSON.parse(mask(JSON.stringify(await sampleHere(b, { ...process.env }, root)))) };
|
|
619
568
|
case "lock": return { locked: Array.isArray(b.hosts) ? b.hosts.length : 0 };
|
|
620
569
|
// What your app spent on its providers, from the hook inside it. An app this command did not
|
|
621
570
|
// start has no hook, and the empty answer says the meter is absent rather than that nothing was spent.
|
|
@@ -651,157 +600,52 @@ const answers = async (port) => {
|
|
|
651
600
|
}
|
|
652
601
|
return false;
|
|
653
602
|
};
|
|
654
|
-
//
|
|
655
|
-
//
|
|
656
|
-
//
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
const
|
|
661
|
-
// Where their app lives inside this repository, and how it starts. Worked out in lib/start.mjs.
|
|
662
|
-
let appDir = root;
|
|
603
|
+
// Where the app's model settings point, for the proxy that stands in for a hook the app cannot load:
|
|
604
|
+
// this terminal's values, else the ones the repository's env files hold. Read here and handed to no
|
|
605
|
+
// process: the proxy forwards each call to the address the app's own settings name, and the app is
|
|
606
|
+
// handed only the proxy's address in their place (lib/proxy.mjs env).
|
|
607
|
+
const modelHints = () => ({ ...Object.assign({}, ...envFiles.filter((f) => !/\.(example|sample)$/.test(f)).map((f) => { try { return parseEnv(readFileSync(f, "utf8"), process.env); } catch { return {}; } })), ...process.env });
|
|
608
|
+
// Where their app runs inside this repository: where the person's command runs it.
|
|
609
|
+
const appDir = root;
|
|
663
610
|
|
|
664
|
-
// ---- a sign-in mounted in another workspace
|
|
665
|
-
// One app served its AI from apps/api and mounted sign-in in apps/dashboard: a test account can
|
|
666
|
-
// only be made where the sign-in is, and posting a sign-up at the AI's own port is a 404.
|
|
667
|
-
const toldService = new Set();
|
|
668
|
-
const signedInAt = (dir, port) => { if (!toldService.has(dir)) { toldService.add(dir); say(`signed in a test account through ${dir}`); } return port; };
|
|
669
|
-
// Everything else this command started, stopped with it: each started the way the app is, on the
|
|
670
|
-
// same copies of its data.
|
|
671
|
-
const sidecars = [];
|
|
672
|
-
function sidecar(cmd, cwd) {
|
|
673
|
-
const kid = spawnTied(cmd, { cwd, env: { ...process.env, ...(capture ? capture.env(process.env) : {}), FORCE_COLOR: "0", ...(pinned.bin ? { PATH: `${pinned.bin}:${process.env.PATH ?? ""}` } : {}), ...keeping.envNow() } });
|
|
674
|
-
sidecars.push(kid);
|
|
675
|
-
kid.stdout.on("data", logTo());
|
|
676
|
-
kid.stderr.on("data", logTo());
|
|
677
|
-
return kid;
|
|
678
|
-
}
|
|
679
|
-
// The port that workspace's sign-in answers on. One it is already serving on comes first: starting
|
|
680
|
-
// a second copy of a dashboard that is already up costs a minute and takes its port.
|
|
681
|
-
async function serviceUp(group) {
|
|
682
|
-
const named = servicePort(group.cwd);
|
|
683
|
-
if (named && named !== app?.port && (await answers(named))) return signedInAt(group.dir, named);
|
|
684
|
-
// Its own port taken by the app leaves nothing to wait on: whatever answers there is the app.
|
|
685
|
-
if (!named || named === app?.port || !group.plan?.cmd) return null;
|
|
686
|
-
sidecar(group.plan.cmd, group.plan.cwd);
|
|
687
|
-
return (await waitForPort(named, 90_000)) ? signedInAt(group.dir, named) : null;
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
async function ask(question) {
|
|
691
|
-
if (!process.stdin.isTTY) return null;
|
|
692
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
693
|
-
const line = await new Promise((r) => rl.question(question, r));
|
|
694
|
-
rl.close();
|
|
695
|
-
return line.trim() || null;
|
|
696
|
-
}
|
|
697
611
|
let child = null;
|
|
698
|
-
// An app this command did not start carries nothing that counts its model calls
|
|
699
|
-
//
|
|
700
|
-
|
|
701
|
-
const
|
|
612
|
+
// An app this command did not start carries nothing that counts its model calls: its replies are seen
|
|
613
|
+
// through the proxy, and the person is told so, as what they chose.
|
|
614
|
+
const UNSEEN = "your app was already running, so Cortad sees its replies and not the calls it makes to its model.";
|
|
615
|
+
const keepStart = (record) => { Object.assign(keptStart, record); try { mkdirSync(homeOf(project), { recursive: true, mode: 0o700 }); writeFileSync(startFile, JSON.stringify(keptStart), { mode: 0o600 }); } catch { /* kept for this session only */ } };
|
|
702
616
|
async function startApp() {
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
if (proxyAsked) {
|
|
711
|
-
await useProxy();
|
|
712
|
-
say(`your app was already running, so Cortad cannot see the calls it makes to its model until it is started with: ${Object.entries(proxy.env({ ...envExports(envFiles), ...process.env })).map(([k, v]) => `${k}=${v}`).join(" ")}`);
|
|
713
|
-
} else say(`${UNSEEN} Stop it and run this again without --port: this command starts your app itself.`);
|
|
714
|
-
return { port: wanted, cmd: null };
|
|
715
|
-
}
|
|
716
|
-
const plan = startPlan({ root, typed: flag("--start"), onPath });
|
|
717
|
-
// A package is not a broken app, and asking its owner how it starts is the wrong question.
|
|
718
|
-
const cmd = plan?.cmd ?? (plan?.noServer ? null : await ask("How do you start your app? (for example: npm run dev) "));
|
|
719
|
-
// Your code is already here and already being read: quitting now would throw away what you have
|
|
720
|
-
// paid for. This stays connected, the read finishes, and the browser says what is missing.
|
|
721
|
-
if (!cmd) {
|
|
722
|
-
return { port: null, said: "", noStart: true,
|
|
723
|
-
why: plan?.noServer
|
|
724
|
-
? "this repository has no server to run; connect the app that serves it. If it does have one, run this again with --start \"how you start it\"."
|
|
725
|
-
: "your code is being read, but I could not work out how your app starts, so nothing is running to ask. Run the command again with --start \"how you start it\", or start your app yourself and run it again with --port <the port it answers on>." };
|
|
617
|
+
// "--port 8106 -- ..." is "start it, it will answer on 8106", not "it is already on 8106".
|
|
618
|
+
if (wantedPort && !typed) {
|
|
619
|
+
if (!(await answers(wantedPort))) return { port: null, said: "", why: `nothing is answering on port ${wantedPort}, so there is nothing to ask. Start your app, then run this again.`, noStart: true };
|
|
620
|
+
await useProxy();
|
|
621
|
+
say(`${UNSEEN} To see them, start it through Cortad instead: npx cortad ${code || "<code>"} -- <how you start your app>`);
|
|
622
|
+
keepStart({ port: wantedPort });
|
|
623
|
+
return { port: wantedPort, cmd: null };
|
|
726
624
|
}
|
|
727
|
-
appDir = plan?.cwd ?? root;
|
|
728
|
-
pinned = pinnedNode();
|
|
729
|
-
if (plan?.within) say(`your app is in ${plan.within}, started there with: ${cmd}`);
|
|
730
625
|
const sourceFiles = files.map((f) => join(root, f));
|
|
731
|
-
|
|
732
|
-
const
|
|
733
|
-
const
|
|
626
|
+
// Only its own request limits, raised for this session by name: who the app lets in is never touched.
|
|
627
|
+
const lifted = liftedLimits(sourceFiles);
|
|
628
|
+
const raised = Object.keys(lifted);
|
|
734
629
|
if (raised.length) say(`higher request limits for this session: ${raised.join(", ")}`);
|
|
735
|
-
|
|
736
|
-
if (
|
|
737
|
-
|
|
738
|
-
if (proxyAsked || !hookable(appDir)) await useProxy();
|
|
739
|
-
step(`starting your app: ${cmd}`);
|
|
630
|
+
launched = { cmd: typed, lifted };
|
|
631
|
+
if (proxyAsked || !hookable(appDir, typed)) await useProxy();
|
|
632
|
+
step(`starting your app with: ${shownCommand(typed, mask)}${fromSaved ? " (saved from your last connect)" : ""}`);
|
|
740
633
|
const up = await launch(180_000);
|
|
741
|
-
if (up.port)
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
// from any other folder is another program: it is named, never tested as this app.
|
|
747
|
-
const ports = up.held ? [] : up.exited !== null && ALREADY.test(up.tail) ? [...new Set([...up.tail.matchAll(/(?::|port\s*[:=]?\s*)(\d{4,5})\b/gi)].map((m) => Number(m[1])))] : [];
|
|
748
|
-
const holders = up.held ? [up.held] : (await Promise.all(ports.map(holderOf))).filter(Boolean);
|
|
749
|
-
for (const held of holders) {
|
|
750
|
-
if (!ownFolder(held.cwd)) return { port: null, said: up.tail, why: whyNot({ ...up, held }) };
|
|
751
|
-
if (await answers(held.port)) {
|
|
752
|
-
launched = null; child = null;
|
|
753
|
-
await keeping.forget();
|
|
754
|
-
const took = await takeOver(held.port);
|
|
755
|
-
if (took) return took;
|
|
756
|
-
say(`${UNSEEN} Stop the one on port ${held.port} and run this again: this command starts your app itself.`);
|
|
757
|
-
return { port: held.port, cmd: null };
|
|
758
|
-
}
|
|
634
|
+
if (up.port) {
|
|
635
|
+
keepStart({ cmd: typed, ...(wantedPort ? { port: wantedPort } : {}) });
|
|
636
|
+
// A container never saw the proxy's settings: the one line that makes its model calls visible is said here.
|
|
637
|
+
if (proxy && IN_CONTAINER.test(typed)) say(containerLine());
|
|
638
|
+
return { port: up.port, cmd: launched.cmd, lifted: raised };
|
|
759
639
|
}
|
|
760
640
|
if (up.tail) console.error(up.tail);
|
|
761
641
|
return { port: null, said: up.tail, why: whyNot(up) };
|
|
762
642
|
}
|
|
763
|
-
// A start that failed because another process holds the port, in the words a framework uses.
|
|
764
|
-
const ALREADY = /EADDRINUSE|address already in use|port.{0,40}(?:in use|already used|is taken|unavailable)|another .{0,20}(?:dev )?server is already running|already running (?:on|at) (?:http|port)/i;
|
|
765
|
-
const rootReal = realpathSync(root);
|
|
766
|
-
const ownFolder = (dir) => { try { const at = realpathSync(dir); return at === rootReal || at.startsWith(rootReal + sep); } catch { return false; } };
|
|
767
643
|
// Why a start did not come up, in a sentence that stands without the terminal: the runner's state
|
|
768
644
|
// carries it to the verbs, and the screen gets the app's own words beside it.
|
|
769
645
|
function whyNot(up) {
|
|
770
646
|
const h = up.held;
|
|
771
|
-
if (h) return `port ${h.port} is in use by another program (pid ${h.pid}${h.command ? `, ${h.command}` : ""}), so your app cannot listen there
|
|
772
|
-
|
|
773
|
-
return `${up.exited !== null ? "your app stopped before it answered" : "your app did not answer within three minutes"}.${wrongNode} Fix it and save a file here: your app is started again by itself.`;
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
// An app the person started keeps the limits it started with, and a run needs more than a person
|
|
777
|
-
// uses in a month. With their yes, the command stops that app and starts it the way it would have,
|
|
778
|
-
// limits raised for the session. Only in a terminal, only when the code reads a limit, and only
|
|
779
|
-
// when the command knows how to start the app.
|
|
780
|
-
async function takeOver(port) {
|
|
781
|
-
const lifted = liftedLimits(envFiles, files.map((f) => join(root, f)));
|
|
782
|
-
const names = Object.keys(lifted);
|
|
783
|
-
if (!names.length || !process.stdin.isTTY) return null;
|
|
784
|
-
const plan = startPlan({ root, typed: flag("--start"), onPath });
|
|
785
|
-
if (!plan?.cmd) return null;
|
|
786
|
-
const pid = await listenerOn(port);
|
|
787
|
-
if (!pid) return null;
|
|
788
|
-
const answer = await ask(`your app is running with its own request limits (${names.join(", ")}). Restart it with them raised for this session? [Y/n] `);
|
|
789
|
-
if (answer && !/^y(?:es)?$/i.test(answer)) return null;
|
|
790
|
-
const top = await supervisorOf(pid);
|
|
791
|
-
step(`stopping your app (pid ${top}) to start it with higher limits`);
|
|
792
|
-
await stopTree(top);
|
|
793
|
-
for (let i = 0; i < 50 && (await answers(port)); i++) await new Promise((r) => setTimeout(r, 200));
|
|
794
|
-
if (await answers(port)) { say("your app did not stop, so it is used as it is"); return null; }
|
|
795
|
-
appDir = plan.cwd ?? root;
|
|
796
|
-
pinned = pinnedNode();
|
|
797
|
-
if (plan.within) say(`your app is in ${plan.within}, started there with: ${plan.cmd}`);
|
|
798
|
-
say(`higher request limits for this session: ${names.join(", ")}`);
|
|
799
|
-
launched = { cmd: plan.cmd, lifted, byTerminal: true };
|
|
800
|
-
step(`starting your app: ${plan.cmd}`);
|
|
801
|
-
const up = await launch(180_000);
|
|
802
|
-
if (up.port) return { port: up.port, cmd: launched.cmd, lifted: names };
|
|
803
|
-
if (up.tail) console.error(up.tail);
|
|
804
|
-
return { port: null, said: up.tail, why: "your app did not come back after the restart." };
|
|
647
|
+
if (h) return `port ${h.port} is in use by another program (pid ${h.pid}${h.command ? `, ${h.command}` : ""}), so your app cannot listen there. Stop that program and save a file here, and your app is started again by itself; or run this command with --port ${h.port} to use the one running as it is.`;
|
|
648
|
+
return `${up.exited !== null ? "your app stopped before it answered" : "your app did not answer within three minutes"}. What it printed is above, and it is your app's own output. Fix it and save a file here: your app is started again by itself with the same command.`;
|
|
805
649
|
}
|
|
806
650
|
|
|
807
651
|
// What the running app was started from, noted in app.json for the verbs, which run in another
|
|
@@ -821,41 +665,15 @@ async function noteAttached(port) {
|
|
|
821
665
|
|
|
822
666
|
// Your app, started the way you start it, and watched until one of its own ports answers.
|
|
823
667
|
let launched = null;
|
|
824
|
-
// The Node this project pins, when the shell that ran this command has another. Strapi refuses
|
|
825
|
-
// Node 26 outright, and a version manager that never switched in this shell is the usual reason.
|
|
826
|
-
// ponytail: reads .nvmrc and .node-version only; engines ranges when a project pins no other way.
|
|
827
|
-
const shellNode = Number(process.versions.node.split(".")[0]);
|
|
828
|
-
function pinnedNode() {
|
|
829
|
-
let want = "";
|
|
830
|
-
for (const f of [".nvmrc", ".node-version"].flatMap((n) => [join(appDir, n), join(root, n)])) { try { want = readFileSync(f, "utf8").trim(); } catch { /* not pinned here */ } if (want) break; }
|
|
831
|
-
const major = Number(/^v?(\d+)/.exec(want)?.[1]);
|
|
832
|
-
if (!major || major === shellNode) return { major: null, bin: null };
|
|
833
|
-
const under = (dir, tail = "bin") => { try { return readdirSync(dir).filter((v) => v.replace(/^v/, "").startsWith(`${major}.`)).sort().reverse().map((v) => join(dir, v, tail)); } catch { return []; } };
|
|
834
|
-
const bins = [`/opt/homebrew/opt/node@${major}/bin`, `/usr/local/opt/node@${major}/bin`, ...under(join(homedir(), ".nvm/versions/node")), ...under(join(homedir(), ".local/share/fnm/node-versions"), "installation/bin"), ...under(join(homedir(), ".volta/tools/image/node"))];
|
|
835
|
-
return { major, bin: bins.find((d) => existsSync(join(d, "node"))) ?? null };
|
|
836
|
-
}
|
|
837
|
-
let pinned = { major: null, bin: null };
|
|
838
668
|
|
|
839
|
-
//
|
|
840
|
-
//
|
|
841
|
-
//
|
|
842
|
-
//
|
|
669
|
+
// Every way the app is started comes through here. An app the hook could not load into (another
|
|
670
|
+
// runtime, a container) is started again with its model settings pointed at the proxy, so its calls
|
|
671
|
+
// are still seen. Nothing else is fixed for the app: a dependency it misses, a service it reaches for
|
|
672
|
+
// and finds down, a port it cannot bind, are its own to report, and they are reported in its own words.
|
|
843
673
|
async function launch(waitMs) {
|
|
844
|
-
|
|
845
|
-
if (up.port && !proxy && !(await hookedAt(up.port)))
|
|
846
|
-
|
|
847
|
-
if (up.port || up.exited === null) return up;
|
|
848
|
-
if (await keeping.backUp(app?.port).finally(release)) {
|
|
849
|
-
up = await start(waitMs);
|
|
850
|
-
if (up.port || up.exited === null) return up;
|
|
851
|
-
}
|
|
852
|
-
const ok = await installOnce(up.tail);
|
|
853
|
-
// An install that failed is the reason their app cannot start, and it goes where the app's own
|
|
854
|
-
// output goes: the terminal, and the screen that is waiting for the app.
|
|
855
|
-
const said = (tail) => (installSaid ? `${installSaid}\n${tail}` : tail);
|
|
856
|
-
if (!ok) return installSaid ? { ...up, tail: said(up.tail) } : up;
|
|
857
|
-
const back = await start(waitMs);
|
|
858
|
-
return back.port ? back : { ...back, tail: said(back.tail) };
|
|
674
|
+
const up = await start(waitMs);
|
|
675
|
+
if (up.port && !proxy && !(await hookedAt(up.port))) return startProxied(up, waitMs);
|
|
676
|
+
return up;
|
|
859
677
|
}
|
|
860
678
|
// Whether the hook loaded into the process answering on `port`. A machine that cannot name that
|
|
861
679
|
// process leaves the app as it is.
|
|
@@ -866,122 +684,53 @@ async function useProxy() {
|
|
|
866
684
|
const noted = () => { if (became?.state === "up") become("up", { ...became.fields, ...upAt(became.fields.proxied) }); };
|
|
867
685
|
// A first model call seen through it is said to the run, for an app this command did not start.
|
|
868
686
|
proxy = await openProxy({
|
|
687
|
+
// The same model port on every connect of this folder: a container's settings name it once.
|
|
688
|
+
modelPort: Number(keptStart.modelPort) || 0,
|
|
869
689
|
file: capture.file, rulesFile: capture.rulesFile, target: () => ({ host: appHost, port: app?.port }),
|
|
870
690
|
onSeen: () => { forgetTold?.(); noted(); },
|
|
871
691
|
onUnseen: (door) => { unseen = door; noted(); say(unseenText(door)); },
|
|
872
692
|
});
|
|
693
|
+
keepStart({ modelPort: proxy.modelPort });
|
|
873
694
|
say("your app's model calls are seen through a local proxy on this machine, so the line of code that made each call is not known. During a run, its calls to other services go out as they would for a real customer: a conversation that books, charges or emails does it for real");
|
|
874
695
|
}
|
|
875
696
|
// An app run in a container: the proxy's settings never reach it, and 127.0.0.1 inside it is not this machine.
|
|
697
|
+
const containerLine = () => {
|
|
698
|
+
// Only the settings the app's own environment names, else the OpenAI one: every SDK's would be a list nobody reads.
|
|
699
|
+
const hints = modelHints();
|
|
700
|
+
const handed = proxy.env(hints);
|
|
701
|
+
const named = Object.keys(handed).filter((name) => name in hints);
|
|
702
|
+
const settings = (named.length ? named : ["OPENAI_BASE_URL"]).map((name) => `${name}=${handed[name].replace("127.0.0.1", "host.docker.internal")}`);
|
|
703
|
+
return `your app runs in a container, where Cortad's hook cannot load: its replies are seen through the proxy, its model calls are not. To see them, give the container ${settings.length === 1 ? "this setting" : "these settings"} and start it again with this command (the same port on every connect of this folder): ${settings.join(", ")}`;
|
|
704
|
+
};
|
|
876
705
|
const IN_CONTAINER = /^\s*(?:sudo\s+)?(?:docker|podman)(?:-compose)?\b/;
|
|
877
706
|
// The app answered, and the hook is not in it: it is started again with its model settings on the
|
|
878
707
|
// proxy. One in a container would come back the same, so it is left up, seen at the front alone.
|
|
879
708
|
async function startProxied(up, waitMs) {
|
|
880
709
|
await useProxy();
|
|
881
|
-
if (IN_CONTAINER.test(launched?.cmd ?? ""))
|
|
710
|
+
if (IN_CONTAINER.test(launched?.cmd ?? "")) {
|
|
711
|
+
say(containerLine());
|
|
712
|
+
return up;
|
|
713
|
+
}
|
|
882
714
|
await stopTree(child.pid);
|
|
883
715
|
for (let i = 0; i < 75 && (await listenerOn(up.port)); i++) await new Promise((r) => setTimeout(r, 200));
|
|
884
716
|
return start(waitMs);
|
|
885
717
|
}
|
|
886
|
-
let installed = false;
|
|
887
|
-
let installSaid = "";
|
|
888
|
-
async function installOnce(said) {
|
|
889
|
-
if (installed) return false;
|
|
890
|
-
const name = missingDependency(said);
|
|
891
|
-
const cmd = name && installPlan(appDir, onPath, root);
|
|
892
|
-
if (!cmd) return false;
|
|
893
|
-
installed = true;
|
|
894
|
-
stepDone(`your app needs ${name}, which is not installed here`);
|
|
895
|
-
const started = Date.now();
|
|
896
|
-
const secondsSoFar = () => Math.round((Date.now() - started) / 1000);
|
|
897
|
-
// A line that rewrites itself in a terminal, and every tenth second where there is none.
|
|
898
|
-
const tick = () => { const n = secondsSoFar(); const line = `installing your app's dependencies, ${n} seconds so far`; if (process.stdout.isTTY) step(line); else if (n % 10 === 0) say(line); };
|
|
899
|
-
tick();
|
|
900
|
-
const ticking = setInterval(tick, 1000);
|
|
901
|
-
const done = await new Promise((r) => {
|
|
902
|
-
const child = spawn("/bin/sh", ["-c", cmd], { cwd: appDir, env: { ...process.env, CI: "1" }, stdio: ["ignore", "pipe", "pipe"] });
|
|
903
|
-
let tail = "";
|
|
904
|
-
const keep = (log) => (d) => { tail = (tail + d.toString()).slice(-4000); log(d); if (verbose) process.stdout.write(d.toString()); };
|
|
905
|
-
child.stdout.on("data", keep(logTo()));
|
|
906
|
-
child.stderr.on("data", keep(logTo()));
|
|
907
|
-
const timer = setTimeout(() => child.kill("SIGKILL"), 15 * 60_000);
|
|
908
|
-
child.on("close", (code) => { clearTimeout(timer); r({ ok: code === 0, tail }); });
|
|
909
|
-
child.on("error", (e) => { clearTimeout(timer); r({ ok: false, tail: String(e.message) }); });
|
|
910
|
-
});
|
|
911
|
-
clearInterval(ticking);
|
|
912
|
-
if (!done.ok) { installSaid = `installing your app's dependencies failed after ${secondsSoFar()} seconds:\n${done.tail.split("\n").filter(Boolean).slice(-12).join("\n")}`; lastSaid = done.tail; stepDone(`installing your app's dependencies failed after ${secondsSoFar()} seconds`); return false; }
|
|
913
|
-
stepDone(`installed your app's dependencies in ${secondsSoFar()} seconds`);
|
|
914
|
-
// A Python project that had no interpreter of its own has one now, and it is the one to start with.
|
|
915
|
-
const again = startPlan({ root, typed: flag("--start"), onPath });
|
|
916
|
-
if (again?.cmd && again.cmd !== launched.cmd) { launched = { ...launched, cmd: again.cmd }; say(`starting your app: ${again.cmd}`); }
|
|
917
|
-
return true;
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
// The port their start command names, moved when something else already holds it: two apps on one
|
|
921
|
-
// laptop both said --port 8000, and the second attached to the first's server as its own.
|
|
922
|
-
// Ports this program tried and lost: two connects on one machine can pick the same free port in
|
|
923
|
-
// the same second, and the one that binds second sees "address already in use".
|
|
924
|
-
const lost = new Set();
|
|
925
|
-
async function freePortAbove(port) {
|
|
926
|
-
for (let next = port + 1; next < port + 30; next++) if (!lost.has(next) && !(await listenerOn(next))) return next;
|
|
927
|
-
return null;
|
|
928
|
-
}
|
|
929
|
-
const PORT_FLAG = /(--port[= ]|-p |\bPORT=)(\d{4,5})\b/;
|
|
930
|
-
// The port a start names: its command's flag, or PORT as this command sets it or an env file does.
|
|
931
|
-
const namedPort = (cmd, lifted) => Number(PORT_FLAG.exec(cmd)?.[2] ?? lifted.PORT ?? envExports(envFiles).PORT ?? 0) || 0;
|
|
932
|
-
async function freed(cmd, lifted) {
|
|
933
|
-
const m = PORT_FLAG.exec(cmd);
|
|
934
|
-
// The app's own env file names its port too: yunqiao's PORT=8105 sat in .env, and a stale copy
|
|
935
|
-
// on that port was reported as "your app stopped" instead of moved past.
|
|
936
|
-
const named = m ? Number(m[2]) : Number(lifted.PORT) || Number(envExports(envFiles).PORT) || 0;
|
|
937
|
-
if (!named || (!lost.has(named) && !(await listenerOn(named)))) return { cmd, lifted };
|
|
938
|
-
const port = await freePortAbove(named);
|
|
939
|
-
if (!port) return { cmd, lifted };
|
|
940
|
-
say(`port ${named} is taken on this machine, so your app starts on ${port}`);
|
|
941
|
-
// The same port their env file names, moved the same way.
|
|
942
|
-
return m ? { cmd: cmd.replace(m[0], `${m[1]}${port}`), lifted } : { cmd, lifted: { ...lifted, PORT: String(port) } };
|
|
943
|
-
}
|
|
944
718
|
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
// was copied, started, or left where trials write into it is told in the terminal here and to the
|
|
948
|
-
// run with the app (announce), so status says it beside Run and the report keeps it.
|
|
949
|
-
const keeping = makeKeeping({
|
|
950
|
-
root, work, ledgerFile: join(homeOf(project), "made.json"), onPath, say, hold, typed: Boolean(flag("--start")),
|
|
951
|
-
launch: (plan) => sidecar(plan.cmd, plan.cwd),
|
|
952
|
-
connections: () => capture?.connections() ?? [],
|
|
953
|
-
copies: () => capture?.copies() ?? [],
|
|
954
|
-
settings: () => {
|
|
955
|
-
const from = {};
|
|
956
|
-
for (const file of envFiles.filter((f) => !/\.(example|sample)$/.test(f))) {
|
|
957
|
-
let text = "";
|
|
958
|
-
try { text = readFileSync(file, "utf8"); } catch { continue; }
|
|
959
|
-
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
|
-
}
|
|
961
|
-
return { values: { ...envExports(envFiles), ...process.env }, from, appDir, sources: files.map((f) => join(root, f)) };
|
|
962
|
-
},
|
|
963
|
-
});
|
|
964
|
-
|
|
965
|
-
async function start(waitMs, tries = 3) {
|
|
966
|
-
const { cmd, lifted } = await freed(launched.cmd, launched.lifted ?? {});
|
|
719
|
+
async function start(waitMs) {
|
|
720
|
+
const { cmd, lifted } = launched;
|
|
967
721
|
// The record of what the app writes opens with the app: the first run starts itself on the
|
|
968
722
|
// server's side, with no verb here to mark it, and a restart mid-run keeps the record open.
|
|
969
723
|
try { markRun(writesDirOf(homeOf(projectOf(root))), "session", { keep: true }); } catch { /* the app's writes go unrecorded */ }
|
|
970
724
|
toolsAtStart = toolsNow();
|
|
971
725
|
// Taken before the app reads a file: a save after this moment is a change it has not loaded.
|
|
972
726
|
const note = { own: true, at: Date.now(), reloads: RELOADER.test(commandText(cmd, appDir)), answered: false };
|
|
973
|
-
|
|
974
|
-
// one left the app on the old settings until the command was started again (ulaim, 2026-10-01).
|
|
975
|
-
note.files = { ...sourceOf(root, files), ...sourceOf(root, envFiles.filter((f) => !/\.(example|sample)$/.test(f)).map((f) => relative(root, f))) };
|
|
976
|
-
storesAt ??= storesNamed();
|
|
727
|
+
note.files = sourceOf(root, files);
|
|
977
728
|
loaded = note;
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
//
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
const base = flag("--start") ? { ...withoutStoreSettings(envExports(envFiles)), ...process.env, ...lifted } : { ...envExports(envFiles), ...process.env, ...lifted, ...copies };
|
|
984
|
-
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 ?? ""}` } : {}) } });
|
|
729
|
+
// The app gets this terminal's environment, as a start by hand would, its own request limits raised
|
|
730
|
+
// for the session, and the hook. No env file is read into it: its own loader reads its own files, in
|
|
731
|
+
// its own order, which is the only way a test file's placeholder key never outranks the real one.
|
|
732
|
+
const base = { ...process.env, ...lifted };
|
|
733
|
+
child = spawnTied(cmd, { cwd: appDir, env: { ...base, ...(proxy ? proxy.env(modelHints()) : capture.env(base)), FORCE_COLOR: "0" } });
|
|
985
734
|
const mine = child;
|
|
986
735
|
become("starting", { app: mine.pid ?? null });
|
|
987
736
|
let seen = "";
|
|
@@ -1008,13 +757,10 @@ async function start(waitMs, tries = 3) {
|
|
|
1008
757
|
if (taken || /app crashed - waiting for file changes|waiting for (?:file )?changes before restart|Failed running/i.test(seen)) {
|
|
1009
758
|
await stopTree(mine.pid);
|
|
1010
759
|
if (!taken) return { port: null, exited: 1, tail: tail() };
|
|
1011
|
-
//
|
|
1012
|
-
//
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
const port = portInError(seen) || named;
|
|
1016
|
-
if (named && port === named && tries > 1) { lost.add(named); return start(waitMs, tries - 1); }
|
|
1017
|
-
return { port: null, exited: 1, tail: tail(), held: port ? await holderOf(port) : null, fixed: Boolean(port) && port !== named };
|
|
760
|
+
// The port your command names is held by another program: it is named, and your command is
|
|
761
|
+
// never rewritten to another port.
|
|
762
|
+
const port = portInError(seen);
|
|
763
|
+
return { port: null, exited: 1, tail: tail(), held: port ? await holderOf(port) : null };
|
|
1018
764
|
}
|
|
1019
765
|
// The port is what the app's own process group listens on. Never a guess: a developer's
|
|
1020
766
|
// machine has other things on 3000 and 8080, and one of them answered for the app once.
|
|
@@ -1022,7 +768,7 @@ async function start(waitMs, tries = 3) {
|
|
|
1022
768
|
// The socket can open before the server answers; the log line is the tiebreak among several.
|
|
1023
769
|
const plain = withoutStores(seen.replace(/\x1b\[[0-9;]*m/g, ""));
|
|
1024
770
|
const said = [...plain.matchAll(/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\]):(\d{2,5})\b|\bport\s*[:=]?\s*(\d{4,5})\b/gi)].map((m) => Number(m[1] || m[2]));
|
|
1025
|
-
for (const port of [...new Set([...said.reverse().filter((p) => ports.includes(p)), ...ports])]) {
|
|
771
|
+
for (const port of [...new Set([...(wantedPort ? [wantedPort] : []), ...said.reverse().filter((p) => ports.includes(p)), ...ports])]) {
|
|
1026
772
|
if (await answers(port)) {
|
|
1027
773
|
note.answered = true;
|
|
1028
774
|
if (loaded === note) noteApp();
|
|
@@ -1036,7 +782,6 @@ async function start(waitMs, tries = 3) {
|
|
|
1036
782
|
return { port: null, exited: null, tail: seen.replace(/\x1b\[[0-9;]*m/g, "").split("\n").filter(Boolean).slice(-12).join("\n") };
|
|
1037
783
|
}
|
|
1038
784
|
|
|
1039
|
-
const COPIED = "starting your app again so it runs against the copies made for this session";
|
|
1040
785
|
// A restart waits this long for the run's requests already at your app, then goes ahead: one it
|
|
1041
786
|
// cuts is sent again once your app answers.
|
|
1042
787
|
const DRAIN_MS = 20_000;
|
|
@@ -1085,23 +830,11 @@ const savedSinceStart = () => Boolean(loaded?.files) && changesOf({
|
|
|
1085
830
|
}).changed.length > 0;
|
|
1086
831
|
// Every save that changes what your app runs starts it again, when it does not reload by itself: an
|
|
1087
832
|
// agent that saved a fix and asked the app eight seconds later was answered by the old code. Looked
|
|
1088
|
-
// at again after each restart, for a save made while it was under way
|
|
1089
|
-
//
|
|
1090
|
-
// writes into, so its copies and holds are made again for the new settings, and said again.
|
|
1091
|
-
const storesNamed = () => { try { return JSON.stringify(storesOf({ ...envExports(envFiles), ...process.env }).map((s) => [s.engine, s.host, s.port, s.db, s.names])); } catch { return ""; } };
|
|
1092
|
-
let storesAt = null;
|
|
1093
|
-
// Looked at before the first wait too: the app now starts while the code is uploaded, and a save made
|
|
1094
|
-
// before this watch began was otherwise never seen.
|
|
833
|
+
// at again after each restart, for a save made while it was under way, and before the first wait too:
|
|
834
|
+
// the app starts while the code is uploaded, and a save made before this watch began was otherwise never seen.
|
|
1095
835
|
async function restartOnSave() {
|
|
1096
836
|
while (!closing) {
|
|
1097
|
-
while (!closing && launched && child && !appGone && !loaded?.reloads && savedSinceStart())
|
|
1098
|
-
const named = storesNamed();
|
|
1099
|
-
const moved = storesAt !== null && named !== storesAt;
|
|
1100
|
-
storesAt = named;
|
|
1101
|
-
if (moved) await keeping.forget().catch(() => {});
|
|
1102
|
-
await restartApp(moved ? "starting your app again on the settings you changed, with its data copied again for them" : undefined);
|
|
1103
|
-
if (moved) await announce().catch(() => null);
|
|
1104
|
-
}
|
|
837
|
+
while (!closing && launched && child && !appGone && !loaded?.reloads && savedSinceStart()) await restartApp();
|
|
1105
838
|
await sourceChanged();
|
|
1106
839
|
}
|
|
1107
840
|
}
|
|
@@ -1119,13 +852,14 @@ async function restartNow() {
|
|
|
1119
852
|
return { error };
|
|
1120
853
|
}
|
|
1121
854
|
become("up", { ...upAt(up.port), app: child?.pid ?? null });
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
}
|
|
855
|
+
// Said to the server every time, on the same port too: what its endpoints' test messages met before
|
|
856
|
+
// the restart is not what they meet now, and a key fixed in the env file left Run held on the old
|
|
857
|
+
// refusal. On another port the world follows the app, never the other way round.
|
|
858
|
+
const moved = up.port !== port;
|
|
859
|
+
if (moved) app = up;
|
|
860
|
+
const told = await announce().catch(() => null);
|
|
861
|
+
if (moved && !told?.ok) return { error: `Your app came back on port ${up.port}, not ${port}. Run the command again to reconnect.` };
|
|
862
|
+
if (moved) say(`your app came back on port ${up.port}; the run follows it there`);
|
|
1129
863
|
return { restarted: true, port: up.port };
|
|
1130
864
|
}
|
|
1131
865
|
const sentence = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
@@ -1141,10 +875,6 @@ async function close(code = 0) {
|
|
|
1141
875
|
try {
|
|
1142
876
|
if (child?.pid) await stopTree(child.pid);
|
|
1143
877
|
proxy?.close();
|
|
1144
|
-
// A copy lives on its store, and a store can be a service of the repository started beside the
|
|
1145
|
-
// app: the copies go first, while it still answers.
|
|
1146
|
-
await keeping.close();
|
|
1147
|
-
for (const kid of sidecars) if (kid.pid) await stopTree(kid.pid);
|
|
1148
878
|
// A sign-in the app printed before its request reached this command is masked now. Only by the
|
|
1149
879
|
// process that writes the file: another may still be appending to it.
|
|
1150
880
|
if (readRunner(project)?.pid === process.pid) {
|
|
@@ -1175,24 +905,22 @@ if (explain) {
|
|
|
1175
905
|
const going = new Set([...files, ...kept]);
|
|
1176
906
|
const left = listed ? [...listed].filter((f) => !going.has(f) && !ENV_FILE.test(basename(f))).sort() : [];
|
|
1177
907
|
const bytes = files.reduce((n, f) => { try { return n + statSync(join(root, f)).size; } catch { return n; } }, 0);
|
|
1178
|
-
const plan = startPlan({ root, typed: flag("--start"), onPath });
|
|
1179
908
|
const rel = (f) => relative(root, f) || ".";
|
|
1180
909
|
console.log([
|
|
1181
910
|
`cortad --explain (nothing is sent or started by this)`,
|
|
1182
911
|
``,
|
|
1183
|
-
`talks to ${origin.origin}, localhost
|
|
912
|
+
`talks to ${origin.origin}, and localhost on your app's port only`,
|
|
1184
913
|
`would send ${files.length} source files, ${Math.round(bytes / 1024)} KB, once${listed ? " (what git would commit)" : ""}`,
|
|
1185
914
|
...(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
915
|
...(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
|
|
1188
|
-
`env files
|
|
1189
|
-
`would start ${
|
|
1190
|
-
`would raise ${
|
|
916
|
+
`not sent anything git ignores, env files (${envFiles.length} here: ${envFiles.slice(0, 6).map(rel).join(", ") || "none"}), key files, node_modules`,
|
|
917
|
+
`env files read here only, to hide their values in what leaves and to know where your app's model settings point; nothing in them is handed to your app or sent`,
|
|
918
|
+
`would start ${wantedPort ? `nothing: uses your app on port ${wantedPort}, replies seen and model calls not` : typed ? `${typed} (your command, as typed, in ${rel(appDir)})` : "nothing: run again with your start command after --"}`,
|
|
919
|
+
`would raise ${wantedPort || !typed ? "nothing" : `${Object.keys(liftedLimits(files.map((f) => join(root, f)))).join(", ") || "no request limits found"} (your app's own limits, for this session only; who it lets in is never touched)`}`,
|
|
1191
920
|
`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
921
|
`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
922
|
`your files never written by this program; your own coding agent edits them`,
|
|
1194
|
-
`your
|
|
1195
|
-
`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`,
|
|
923
|
+
`your data nothing is copied, started or pointed elsewhere: your app uses the databases and services your command gives it, and test conversations write where it writes, after your yes on the card`,
|
|
1196
924
|
`test shell confined by the OS: your project and toolchains only, writes to temp and build folders, localhost only`,
|
|
1197
925
|
`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
926
|
``,
|
|
@@ -1202,7 +930,6 @@ if (explain) {
|
|
|
1202
930
|
}
|
|
1203
931
|
if (kept.length) say(`left on this machine because it holds a key: ${kept.join(", ")}`);
|
|
1204
932
|
const keepSecret = (v) => { if (v && v.length >= 12 && !secrets.includes(v)) secrets.push(v); };
|
|
1205
|
-
identities = makeIdentities({ root, work, envFiles, sourceFiles: () => files, say, keepSecret, appDir: () => appDir });
|
|
1206
933
|
// A real request that reached their model proves its door (lib/proof.mjs). What it proved goes up,
|
|
1207
934
|
// masked like everything else, and nothing goes up that the mask could not read; the sign-in each
|
|
1208
935
|
// request carried stays here.
|
|
@@ -1252,8 +979,8 @@ const treeDigest = (() => {
|
|
|
1252
979
|
}
|
|
1253
980
|
return h.digest("hex");
|
|
1254
981
|
})();
|
|
1255
|
-
// The commit this tree is. Your .git is never uploaded
|
|
1256
|
-
//
|
|
982
|
+
// The commit this tree is. Your .git is never uploaded; the one sha the report needs is read here
|
|
983
|
+
// and sent as forty characters, and the commits themselves follow the code (sendHistory). A folder
|
|
1257
984
|
// that is not a checkout sends nothing, and the report says so rather than inventing one.
|
|
1258
985
|
const head = (() => {
|
|
1259
986
|
try {
|
|
@@ -1273,25 +1000,51 @@ const PART = 1_500_000;
|
|
|
1273
1000
|
let resumed = false;
|
|
1274
1001
|
let moved = false;
|
|
1275
1002
|
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
|
-
|
|
1003
|
+
// The whole upload, part by part. A part that did not land is the upload's to send again, from its
|
|
1004
|
+
// first part, once, a few seconds later: a part numbered 0 begins the archive afresh on the server, so
|
|
1005
|
+
// a second pass never lands on what the first left. A refusal of this machine's key is not retried.
|
|
1006
|
+
async function sendTree() {
|
|
1007
|
+
for (let off = 0; off < bytes.length; off += PART) {
|
|
1008
|
+
const last = off + PART >= bytes.length;
|
|
1009
|
+
step(parts > 1 ? `connecting, ${Math.floor(off / PART) + 1} of ${parts}` : "connecting");
|
|
1010
|
+
// The last part asks for this machine's key when none is stored yet: minted once the repository
|
|
1011
|
+
// row exists, kept in ~/.cortad for the runs a coding agent asks for on later days. A connect by a
|
|
1012
|
+
// code asks too, since the code may be another account's: the key kept from the account before
|
|
1013
|
+
// signed the coding agent's tools into that one, its runs and its counts.
|
|
1014
|
+
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),
|
|
1015
|
+
{ raw: true, timeoutMs: 120_000, ...(last && !viaToken && (!stored || code) ? { headers: { "x-cortad-machine": hostname().slice(0, 80) } } : {}) }).catch((err) => ({ ok: false, status: 0, err }));
|
|
1016
|
+
if (!put.ok) return put;
|
|
1017
|
+
if (last) {
|
|
1018
|
+
resumed = put.data?.resumed === true;
|
|
1019
|
+
moved = put.data?.moved === true;
|
|
1020
|
+
if (typeof put.data?.machineKey === "string" && put.data.machineKey) writeToken(project, put.data.machineKey);
|
|
1021
|
+
writeDigest(project, treeDigest);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
return { ok: true };
|
|
1025
|
+
}
|
|
1026
|
+
let sent = await sendTree();
|
|
1027
|
+
if (!sent.ok && sent.status !== 401 && sent.status !== 404) {
|
|
1028
|
+
await new Promise((r) => setTimeout(r, 2000 + Math.random() * 3000));
|
|
1029
|
+
sent = await sendTree();
|
|
1030
|
+
}
|
|
1031
|
+
if (!sent.ok) sent.err ? unreachable(sent.err) : fail(sent.data?.error ?? `upload failed (${sent.status})`);
|
|
1032
|
+
// Their commit history follows the code: the commits newer than the ones Cortad holds, the whole
|
|
1033
|
+
// history the first time, each diff cut to the files above and to lines that carry no key
|
|
1034
|
+
// (lib/commits.mjs). It goes on after the connect is reported and never stops it.
|
|
1035
|
+
async function sendHistory() {
|
|
1036
|
+
const last = await call("GET", `/local/${box}/commits/last`);
|
|
1037
|
+
if (!last.ok) return;
|
|
1038
|
+
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); };
|
|
1039
|
+
const parts = historyParts(root, typeof last.data?.sha === "string" ? last.data.sha : null, { keep: keeps, holdsKey: (line) => holdsKeys(line, envKeys) });
|
|
1040
|
+
for (const [i, commits] of parts.entries()) {
|
|
1041
|
+
const put = await call("PUT", `/local/${box}/commits`, { commits, ...(i === 0 ? { repo: repoFacts(root) } : {}) }, { timeoutMs: 120_000 });
|
|
1042
|
+
if (!put.ok) throw new Error(put.data?.error ?? `history upload failed (${put.status})`);
|
|
1291
1043
|
}
|
|
1292
1044
|
}
|
|
1293
1045
|
// Unchanged code has already been read: coming back says so instead of claiming a second read.
|
|
1294
1046
|
stepDone(moved ? "connected: your change is in, and what Cortad read before still stands" : resumed ? "connected: nothing changed since last time" : "connected");
|
|
1047
|
+
if (head) void sendHistory().catch((err) => { if (verbose) say(`commit history not sent: ${err?.message ?? err}`); });
|
|
1295
1048
|
// The coding agents on this machine learn about Cortad now, once: an MCP entry and a skill in each
|
|
1296
1049
|
// one's own home folder. A run started by an agent later comes back through lib/cli.mjs.
|
|
1297
1050
|
// CORTAD_NO_REGISTER=1 leaves the coding agents' global skill and MCP entries alone: a stack under
|
|
@@ -1323,43 +1076,15 @@ function sourceChanged() {
|
|
|
1323
1076
|
} catch { setTimeout(done, 15_000); }
|
|
1324
1077
|
});
|
|
1325
1078
|
}
|
|
1326
|
-
// `
|
|
1327
|
-
//
|
|
1328
|
-
//
|
|
1329
|
-
// `
|
|
1330
|
-
// says yes in the browser. The answer carries that yes (setup.consent), and the hosts it names are
|
|
1331
|
-
// written where the hook reads which outbound writes of a trial may leave.
|
|
1079
|
+
// `proves`: each request seen reaching the model is posted as its door's proof, so the run waits for
|
|
1080
|
+
// those; false for an app seen through the proxy alone. `reached`: the servers the app has connected
|
|
1081
|
+
// to so far, by address, so the card can say where test conversations write before the person's yes.
|
|
1082
|
+
// `watching` and `metered` say the same as `proves` to a server from before this version.
|
|
1332
1083
|
const announce = async () => {
|
|
1333
|
-
// Through the proxy, a message is seen arriving at its front, and its model calls once the app was started with its settings on the proxy.
|
|
1334
1084
|
const hooked = Boolean(proxy ? launched || proxy.seen() : launched && capture?.watching(app.port));
|
|
1335
|
-
|
|
1336
|
-
const told = await call("POST", `/local/${box}/app`, { port: doorPort(), cmd: shownCommand(app.cmd, mask), origins: envOrigins(envFiles), lifted: app.lifted ?? [], data: (await keeping.lines(started)).map(mask), blocked: await keeping.blocked(started), watching: hooked, metered: hooked, proves: hooked });
|
|
1337
|
-
if (told.ok) await allowOutbound(told.data?.consent);
|
|
1338
|
-
return told;
|
|
1085
|
+
return call("POST", `/local/${box}/app`, { port: doorPort(), cmd: shownCommand(app.cmd, mask), lifted: app.lifted ?? [], watching: hooked, metered: hooked, proves: hooked, reached: reachedNow() });
|
|
1339
1086
|
};
|
|
1340
|
-
|
|
1341
|
-
// What the person said yes to, as the hook reads it: the hosts a trial's writes may reach.
|
|
1342
|
-
async function allowOutbound(given) {
|
|
1343
|
-
if (given && typeof given === "object") {
|
|
1344
|
-
const list = (v, re) => (Array.isArray(v) ? v.filter((x) => typeof x === "string" && re.test(x)).slice(0, 50) : []);
|
|
1345
|
-
consent = { hosts: list(given.hosts, /^[a-z0-9.-]{1,253}$/i).map((h) => h.toLowerCase()), stores: list(given.stores, /^[a-f0-9]{16}$/) };
|
|
1346
|
-
await liftWith(given.limits === true);
|
|
1347
|
-
}
|
|
1348
|
-
try { writeFileSync(outboundFile, JSON.stringify({ pass: await keeping.outbound(consent) }), { mode: 0o600 }); } catch { /* every write of a trial stays held */ }
|
|
1349
|
-
}
|
|
1350
|
-
|
|
1351
|
-
// A yes given or taken back in the browser restarts an app this command started with its guards
|
|
1352
|
-
// raised or restored; an app raised by the terminal's own yes keeps that.
|
|
1353
|
-
async function liftWith(yes) {
|
|
1354
|
-
if (yes === limitsYes) return;
|
|
1355
|
-
limitsYes = yes;
|
|
1356
|
-
if (!launched || !child || launched.byTerminal) return;
|
|
1357
|
-
const lifted = sessionLifts(yes, envFiles, files.map((f) => join(root, f)));
|
|
1358
|
-
if (JSON.stringify(lifted) === JSON.stringify(launched.lifted ?? {})) return;
|
|
1359
|
-
launched = { ...launched, lifted };
|
|
1360
|
-
if (app) app.lifted = Object.keys(lifted);
|
|
1361
|
-
void restartApp(yes ? `starting your app again with its own limits raised for this session: ${Object.keys(lifted).join(", ")}` : "starting your app again with its own limits as they are");
|
|
1362
|
-
}
|
|
1087
|
+
const reachedNow = () => (capture?.connections() ?? []).map((c) => `${c.host}:${c.port}`).slice(0, 40);
|
|
1363
1088
|
|
|
1364
1089
|
// Your app's life beside this connection. It is started; if it stops, or never comes up, this stays
|
|
1365
1090
|
// and starts it again the moment you save a fix, and the browser is told each time it answers, so a
|
|
@@ -1384,11 +1109,10 @@ async function appLife(firstStart) {
|
|
|
1384
1109
|
machineOf = () => (app ? {
|
|
1385
1110
|
cmd: shownCommand(app.cmd, mask) ?? "", dir: relative(root, appDir) || ".", port: app.port, started: Boolean(launched),
|
|
1386
1111
|
hooked: Boolean(launched && !proxy && capture?.watching(app.port)), proxy: proxy?.port ?? null,
|
|
1387
|
-
changed: [...
|
|
1388
|
-
copies: Object.keys(keeping.envNow()).length ? "made" : "none",
|
|
1112
|
+
changed: [...(app.lifted ?? [])],
|
|
1389
1113
|
} : null);
|
|
1390
1114
|
become("up", { ...upAt(), app: launched ? child?.pid ?? null : null });
|
|
1391
|
-
say(`your app is answering on port ${app.port}${app.cmd ?
|
|
1115
|
+
say(`your app is answering on port ${app.port}${app.cmd ? `, started with: ${shownCommand(app.cmd, mask)}` : ""}`);
|
|
1392
1116
|
if (launched) say(`your app's output for this session is kept in ${bootLog}, with the values from your env files masked`);
|
|
1393
1117
|
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`);
|
|
1394
1118
|
for (const line of connectedText(machineOf())) say(line);
|
|
@@ -1396,16 +1120,7 @@ async function appLife(firstStart) {
|
|
|
1396
1120
|
let downSince = 0;
|
|
1397
1121
|
let toldDown = false;
|
|
1398
1122
|
let appTold = true;
|
|
1399
|
-
|
|
1400
|
-
// store copied only now is one the app was not started on.
|
|
1401
|
-
if (launched) {
|
|
1402
|
-
try {
|
|
1403
|
-
const got = await keeping.services(app.port).catch(() => ({}));
|
|
1404
|
-
if (got.copied && child) await restartApp(COPIED);
|
|
1405
|
-
if (got.changed) appTold = false;
|
|
1406
|
-
} finally { release(); }
|
|
1407
|
-
}
|
|
1408
|
-
// Only once the app runs on the copies made for this session.
|
|
1123
|
+
let reachedTold = reachedNow().join(",");
|
|
1409
1124
|
void reachLoop();
|
|
1410
1125
|
forgetTold = () => { appTold = false; };
|
|
1411
1126
|
void restartOnSave().catch((e) => say(`could not watch your files for a save: ${e?.message ?? e}`));
|
|
@@ -1423,9 +1138,9 @@ async function appLife(firstStart) {
|
|
|
1423
1138
|
const now = upAt();
|
|
1424
1139
|
if (JSON.stringify(now.receipts) !== JSON.stringify(became.fields.receipts)) { const { receipts: _was, ...fields } = became.fields; become("up", { ...fields, ...now }); }
|
|
1425
1140
|
}
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
if (
|
|
1141
|
+
// A server the app reached for the first time is said on the card, where the person's yes is asked.
|
|
1142
|
+
const seen = reachedNow().join(",");
|
|
1143
|
+
if (seen !== reachedTold) { reachedTold = seen; appTold = false; }
|
|
1429
1144
|
if (!up || !appTold) { up = true; appTold = Boolean((await announce().catch(() => null))?.ok); }
|
|
1430
1145
|
continue;
|
|
1431
1146
|
}
|
|
@@ -1447,7 +1162,7 @@ async function appLife(firstStart) {
|
|
|
1447
1162
|
// load. An app it only attached to is not its to watch, so nothing is said either way.
|
|
1448
1163
|
if (!toldDown) {
|
|
1449
1164
|
// Started again below, except an app given by --port, which is picked up when it answers again.
|
|
1450
|
-
if (!launched &&
|
|
1165
|
+
if (!launched && wantedPort) failedWith(`your app stopped answering on port ${app.port}; it is picked up again when it answers`, lastSaid);
|
|
1451
1166
|
else become("starting");
|
|
1452
1167
|
toldDown = Boolean((await call("POST", `/local/${box}/stopped`, { said: mask(lastSaid || "your app stopped answering").slice(-2000), ...(launched ? { exited: Boolean(appGone || crashed) } : {}) }).catch(() => null))?.ok);
|
|
1453
1168
|
}
|
|
@@ -1457,7 +1172,7 @@ async function appLife(firstStart) {
|
|
|
1457
1172
|
// terminal, or one left behind by a terminal that was killed) is gone now, and this command
|
|
1458
1173
|
// knows how it starts. Only an app given by --port is left to you, and picked up when it returns.
|
|
1459
1174
|
if (launched && !appGone && !crashed && downFor < 25_000) continue;
|
|
1460
|
-
if (!launched &&
|
|
1175
|
+
if (!launched && wantedPort) continue;
|
|
1461
1176
|
await restartOnce(async () => {
|
|
1462
1177
|
if (child?.pid) await stopTree(child.pid);
|
|
1463
1178
|
say(downLine(state, { port: app.port, downMs: downFor, crashed }));
|
|
@@ -1485,14 +1200,6 @@ async function appLife(firstStart) {
|
|
|
1485
1200
|
}
|
|
1486
1201
|
void appLife(firstStart).catch((e) => quit(String(e?.message ?? e)));
|
|
1487
1202
|
|
|
1488
|
-
// The page Cortad's test requests come from: one the env names on the app's port, else one on this
|
|
1489
|
-
// machine (a local frontend in front of this API). A hosted address such as a Supabase URL is no page
|
|
1490
|
-
// of the app's, so an app whose env names none is asked with no Origin, as a server would ask it.
|
|
1491
|
-
const LOCAL_ORIGIN = /^https?:\/\/(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::\d+)?$/;
|
|
1492
|
-
function ownOrigin(port) {
|
|
1493
|
-
const local = envOrigins(envFiles).filter((o) => LOCAL_ORIGIN.test(o));
|
|
1494
|
-
return local.find((o) => new URL(o).port === String(port)) ?? local[0] ?? null;
|
|
1495
|
-
}
|
|
1496
1203
|
// Counted with the run's own requests, so a restart drains them, and sent only through the open gate.
|
|
1497
1204
|
async function countedFetch(url, init) {
|
|
1498
1205
|
while (gate) await gate;
|
|
@@ -1503,9 +1210,9 @@ async function countedFetch(url, init) {
|
|
|
1503
1210
|
return new Response(body.byteLength ? body : null, { status: res.status, statusText: res.statusText, headers: res.headers });
|
|
1504
1211
|
} finally { answering -= 1; }
|
|
1505
1212
|
}
|
|
1506
|
-
//
|
|
1507
|
-
//
|
|
1508
|
-
//
|
|
1213
|
+
// The agent's `reach`: a request Cortad builds from what it read, sent from here to the app on its
|
|
1214
|
+
// port, only when the agent asks for it (lib/auto-reach.mjs). Nothing is sent to the app on Cortad's
|
|
1215
|
+
// own account: the one request that proves an endpoint is the one the person or their agent sends.
|
|
1509
1216
|
const autoReach = makeAutoReach({
|
|
1510
1217
|
status: async () => {
|
|
1511
1218
|
const token = readToken(project);
|
|
@@ -1513,11 +1220,10 @@ const autoReach = makeAutoReach({
|
|
|
1513
1220
|
return res?.ok ? res.data : null;
|
|
1514
1221
|
},
|
|
1515
1222
|
runner: () => (became?.state === "up" && !restarting ? { state: "up", ...became.fields } : null),
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
signIn: async (m) => { const role = typeof m.as === "string" ? m.as : identities?.roles()[0]; return role ? identities.headerFor(role) : null; },
|
|
1223
|
+
waiting: async () => (gate ? "Your app is being started again." : null),
|
|
1224
|
+
origin: () => null,
|
|
1225
|
+
// Sent as the person's own request was, when one was seen: the sign-in it carried, kept here.
|
|
1226
|
+
signIn: async () => { const theirs = capture?.headers(CAPTURED); const name = theirs && Object.keys(theirs).find((k) => /^(?:authorization|cookie)$/i.test(k)); return name ? { name: name.toLowerCase(), value: theirs[name] } : null; },
|
|
1521
1227
|
stampOf: (m) => { try { return m.file ? String(statSync(join(root, m.file)).mtimeMs) : ""; } catch { return ""; } },
|
|
1522
1228
|
asked: () => existsSync(join(homeOf(project), ASK)),
|
|
1523
1229
|
report: async (answers) => {
|
|
@@ -1531,15 +1237,14 @@ const autoReach = makeAutoReach({
|
|
|
1531
1237
|
say,
|
|
1532
1238
|
fetchImpl: countedFetch,
|
|
1533
1239
|
});
|
|
1534
|
-
// The connection the server last heard
|
|
1240
|
+
// The connection the server last heard what the agent's requests met; a reattach is a new one.
|
|
1535
1241
|
let reachedBox = "";
|
|
1536
1242
|
async function reachLoop() {
|
|
1537
|
-
for (
|
|
1243
|
+
for (; !closing; await new Promise((r) => setTimeout(r, 1000))) {
|
|
1538
1244
|
const ask = takeAsk(homeOf(project));
|
|
1539
|
-
if (!ask
|
|
1245
|
+
if (!ask) continue;
|
|
1540
1246
|
const out = await autoReach(ask).catch(() => null);
|
|
1541
|
-
|
|
1542
|
-
dueAt = Date.now() + (out?.wait ?? 30_000);
|
|
1247
|
+
try { answerAsk(homeOf(project), ask.id, out?.text ?? "Nothing was sent.\nnext: status"); } catch { /* the agent's wait runs out */ }
|
|
1543
1248
|
}
|
|
1544
1249
|
}
|
|
1545
1250
|
|
|
@@ -1567,6 +1272,32 @@ process.on("SIGUSR2", () => {
|
|
|
1567
1272
|
void restartApp();
|
|
1568
1273
|
});
|
|
1569
1274
|
|
|
1275
|
+
// A job's result is work already done on this machine: sent until the server has it, through a dropped
|
|
1276
|
+
// network, for as long as the server can still be waiting on it (the bus's five-minute call). Sent once
|
|
1277
|
+
// and dropped on a failure, a reply the app gave during a thirty-second outage was lost and the run
|
|
1278
|
+
// waited out its turn. Any answer below 500 ends it: 404 is a server that no longer holds the job.
|
|
1279
|
+
async function deliver(id, out) {
|
|
1280
|
+
for (let wait = 1000, until = Date.now() + 300_000; Date.now() < until && !closing; wait = Math.min(wait * 2, 15_000)) {
|
|
1281
|
+
const res = await call("POST", `/local/${box}/jobs/${id}`, out).catch(() => null);
|
|
1282
|
+
if (res && res.status < 500) return;
|
|
1283
|
+
await new Promise((r) => setTimeout(r, wait / 2 + Math.random() * (wait / 2)));
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// Asked again through a server that is still starting, a network that dropped or a rate limit every
|
|
1288
|
+
// terminal meets at once after a deploy, for as long as a restart can take: only the server saying it
|
|
1289
|
+
// does not know this terminal (404) ends the session. Ended on the first slow answer, a deploy that
|
|
1290
|
+
// restarted the server ended every session it held.
|
|
1291
|
+
async function reattach() {
|
|
1292
|
+
for (let wait = 2000, until = Date.now() + 300_000; ; wait = Math.min(wait * 2, 30_000)) {
|
|
1293
|
+
const timeoutMs = Math.max(5_000, Math.min(120_000, until - Date.now()));
|
|
1294
|
+
const back = await call("POST", "/local/reattach", { box, name: basename(root), project }, { timeoutMs }).catch(() => null);
|
|
1295
|
+
if (back && (back.ok || back.status === 404)) return back;
|
|
1296
|
+
if (Date.now() >= until || closing) return back;
|
|
1297
|
+
await new Promise((r) => setTimeout(r, wait / 2 + Math.random() * (wait / 2)));
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1570
1301
|
let quiet = 0;
|
|
1571
1302
|
// Each poll names the jobs the last one brought. A poll's answer can die on the way (the network
|
|
1572
1303
|
// drops, the lid closes): the server sends again whatever is not named, and a job seen twice is
|
|
@@ -1581,7 +1312,8 @@ for (;;) {
|
|
|
1581
1312
|
if (res.status === 404) {
|
|
1582
1313
|
// The server restarted and forgot this terminal. It is the same terminal: it proves so with the
|
|
1583
1314
|
// key it was given, its box is opened again, and the app it is holding is announced again.
|
|
1584
|
-
const back = await
|
|
1315
|
+
const back = await reattach();
|
|
1316
|
+
if (closing) break;
|
|
1585
1317
|
if (!back?.ok) { say("this session ended on the server. Run the command again for a new one."); await close(1); }
|
|
1586
1318
|
if (app?.port) await announce().catch(() => {});
|
|
1587
1319
|
forgetTold?.();
|
|
@@ -1596,6 +1328,6 @@ for (;;) {
|
|
|
1596
1328
|
if (seenJobs.has(job.id)) continue;
|
|
1597
1329
|
seenJobs.add(job.id);
|
|
1598
1330
|
if (seenJobs.size > 2000) seenJobs.delete(seenJobs.values().next().value);
|
|
1599
|
-
verb(job).catch((e) => ({ error: String(e.message) })).then((out) =>
|
|
1331
|
+
verb(job).catch((e) => ({ error: String(e.message) })).then((out) => deliver(job.id, out));
|
|
1600
1332
|
}
|
|
1601
1333
|
}
|