cortad 0.3.11 → 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/local.mjs CHANGED
@@ -1,33 +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 [--port 3000] [--start "npm run dev"] [--data .env.staging] [--proxy] [--verbose]
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, starts your app the way you start it, then holds one
8
- // outbound connection open and does what a run asks: a request to your app, a file read, a shell
9
- // line. The shell is locked by the operating system (lib/lock.mjs) and cannot write your code;
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 { storeSettings, 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";
18
- import { appEnv, devEnv, fileValues, keyTries, localEnv, parseEnv } from "./lib/env-files.mjs";
19
+ import { parseEnv } from "./lib/env-files.mjs";
19
20
  import { historyParts, repoFacts } from "./lib/commits.mjs";
20
21
  import { spawn, execFile, execFileSync } from "node:child_process";
21
22
  import { createHash } from "node:crypto";
22
23
  import { appendFileSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, watch, writeFileSync } from "node:fs";
23
- import { homedir, hostname, tmpdir } from "node:os";
24
+ import { hostname, tmpdir } from "node:os";
24
25
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
25
- import { createInterface } from "node:readline";
26
26
  import { promisify } from "node:util";
27
27
  import { COMMANDS, main as face, unknownVerb, VERBS } from "./lib/cli.mjs";
28
28
  import { addNews, homeOf, projectOf, readToken, writeApp, writeDigest, writeToken } from "./lib/home.mjs";
29
29
  import { changesOf, commandText, RELOADER, sourceOf } from "./lib/fresh.mjs";
30
- import { chainOf, elapsedMs, holderOf, listening, listenerOn, portInError, spawnTied, stopTree, supervisorOf } from "./lib/proc.mjs";
30
+ import { chainOf, elapsedMs, holderOf, listening, listenerOn, portInError, spawnTied, stopTree } from "./lib/proc.mjs";
31
31
  import { claimRunner, readRunner, releaseRunner, replaceRunner, startedAtOf, writeRunner } from "./lib/runner.mjs";
32
32
  import { registerAll } from "./lib/register.mjs";
33
33
  import { connectedText } from "./lib/read-text.mjs";
@@ -35,15 +35,11 @@ import { VERSION } from "./lib/spec.mjs";
35
35
  import { maskedLines, maskWith, secretValues } from "./lib/env-secrets.mjs";
36
36
  import { finished, unseenText } from "./lib/text.mjs";
37
37
  import { lockHolds, makeLock } from "./lib/lock.mjs";
38
- import { AS_HEADER, makeIdentities } from "./lib/mint.mjs";
39
- import { CAPTURED, makeCapture } from "./lib/replay.mjs";
38
+ import { AS_HEADER, CAPTURED, makeCapture } from "./lib/replay.mjs";
40
39
  import { sampleHere } from "./lib/sample.mjs";
41
- import { mintAcross, originFor, servicePort, waitForPort } from "./lib/service.mjs";
42
- import { listingUrl } from "./lib/listing.mjs";
43
- import { hookable, installPlan, missingDependency, shownCommand, startPlan, workspaces } from "./lib/start.mjs";
40
+ import { hookable, shownCommand, startPlan, workspaces } from "./lib/start.mjs";
44
41
  import { openProxy } from "./lib/proxy.mjs";
45
- import { liftedLimits, sessionLifts } from "./lib/switches.mjs";
46
- import { makeKeeping } from "./lib/keeping.mjs";
42
+ import { liftedLimits } from "./lib/switches.mjs";
47
43
  import { downLine, makeHealth } from "./lib/health.mjs";
48
44
  import { markRun, writesDirOf, writtenPaths } from "./lib/writes.mjs";
49
45
  import { makeHeld } from "./lib/held.mjs";
@@ -60,18 +56,21 @@ if (unknownVerb(argv[0])) {
60
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.`);
61
57
  process.exit(1);
62
58
  }
63
- const flag = (name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; };
64
- const verbose = argv.includes("--verbose");
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");
65
64
  // A code is eight characters from the connect screen's alphabet, which has no I, O, 0 or 1.
66
- let code = (argv.find((a) => /^[A-HJ-NP-Za-hj-np-z2-9-]{8,9}$/.test(a) && !a.startsWith("-")) ?? "").toUpperCase().replace(/-/g, "");
65
+ let code = (own.find((a) => /^[A-HJ-NP-Za-hj-np-z2-9-]{8,9}$/.test(a) && !a.startsWith("-")) ?? "").toUpperCase().replace(/-/g, "");
67
66
  const say = (line) => console.log(`cortad ${line}`);
68
67
  const fail = (line) => { console.error(`cortad ${line}`); process.exit(1); };
69
68
 
70
- const explain = argv.includes("--explain");
69
+ const explain = own.includes("--explain");
71
70
  // The app is seen through a local proxy (lib/proxy.mjs) even where the hook could load into it.
72
- const proxyAsked = argv.includes("--proxy");
71
+ const proxyAsked = own.includes("--proxy");
73
72
  // Started by lib/cli.mjs for a run on a later day: no code, the key the first connect left behind.
74
- const viaToken = argv.includes("--token");
73
+ const viaToken = own.includes("--token");
75
74
  // What is happening right now, on one line that rewrites itself. npx spends its own seconds fetching
76
75
  // this package before anything here runs, and the first thing we printed used to be after the whole
77
76
  // upload: a minute or more of a cursor sitting still, which reads as nothing happening.
@@ -86,8 +85,9 @@ const clearStep = () => { if (process.stdout.isTTY) process.stdout.write("\r\x1b
86
85
  const stepDone = (line) => { clearStep(); say(line); };
87
86
  // `npx cortad` alone connects too: with the key this folder kept from its last connect, or, with none,
88
87
  // by one approval in the browser (pairing below). A word that is neither a code nor a flag is refused.
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(" | ")}`);
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);
91
91
  // Where Brainsless is. The host is not on the command line: a code cannot point at an impostor.
92
92
  const origin = new URL(process.env.CORTAD_ORIGIN || "https://cortad.com");
93
93
  // Ours, and only ours. brainsless.com is the same service under its earlier name and stays trusted
@@ -103,30 +103,39 @@ const root = process.cwd();
103
103
  const MANIFEST = ["package.json", "pyproject.toml", "requirements.txt", "go.mod", "Cargo.toml", "docker-compose.yml", "docker-compose.yaml", "Gemfile", "mix.exs"];
104
104
  // A repository whose root carries no manifest of its own is still a repository: crewai-examples
105
105
  // keeps one project per folder under crews/, and the whole of it was refused at the door. The root
106
- // is accepted when a project lives below it.
107
- if (!MANIFEST.some((f) => existsSync(join(root, f))) && !workspaces(root).length) {
108
- 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: ${root}`);
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>`);
109
111
  }
110
112
  // Which project this is, as a hash of where it lives: the same folder coming back resumes the same
111
113
  // connection, and the path itself never leaves this machine.
112
114
  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 */ }
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);
129
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.");
130
139
 
131
140
  // One process holds a project's app up (lib/runner.mjs), and its state is what every reader reports.
132
141
  // `reach: "ask"`: an agent's reach is sent by this process (lib/auto-reach.mjs), signed in where it can.
@@ -212,67 +221,6 @@ function carriesKey(rel) {
212
221
  try { return holdsKeys(readFileSync(join(root, rel), "utf8"), envKeys); } catch { return false; }
213
222
  }
214
223
 
215
- // Origins your environment names (FRONTEND_URL, CORS_ORIGIN, ...): an app that trusts a browser
216
- // Origin is asked as that browser. Sent as origins only, never the variable's full value.
217
- function envOrigins(envFiles) {
218
- const out = new Set();
219
- for (const file of envFiles) {
220
- let text = "";
221
- try { text = readFileSync(file, "utf8"); } catch { continue; }
222
- for (const line of text.split("\n")) {
223
- const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
224
- if (!m || !/ORIGIN|URL|HOST|DOMAIN|FRONTEND|CLIENT|SITE|WEB/i.test(m[1])) continue;
225
- // OPENAI_BASE_URL names where a key is sent, not a page a browser opens the app from.
226
- if (/(?:BASE_URL|API_BASE|ENDPOINT)$/i.test(m[1]) && !/ORIGIN|FRONTEND|CLIENT|SITE|WEB|PUBLIC|APP/i.test(m[1])) continue;
227
- for (const v of m[2].replace(/^(['"])(.*)\1$/, "$2").split(",")) {
228
- try { const u = new URL(v.trim()); if (/^https?:$/.test(u.protocol)) out.add(u.origin); } catch { /* not an origin */ }
229
- }
230
- }
231
- }
232
- return [...out].slice(0, 8);
233
- }
234
- // Switch-shaped names and the word each one reads as. The name and the word travel, never the value.
235
- const SWITCH_NAME = /(?:_ENABLED|_DISABLED|_MODE|_ON|_OFF|_FLAG)$|^(?:ENABLE|USE|DISABLE|SKIP)_/;
236
- const switchStates = (values) => Object.fromEntries(Object.entries(values)
237
- .filter(([name]) => SWITCH_NAME.test(name))
238
- .map(([name, value]) => [name, value === "" ? "unset" : /^(?:1|true|yes|on)$/i.test(value) ? "on" : /^(?:0|false|no|off)$/i.test(value) ? "off" : "set"]));
239
-
240
- // What your app can actually reach: each provider key your env files set is asked that provider
241
- // for its own model listing, from this machine. Your key stays here; what goes back is the name it
242
- // is set under, the host, what the host answered, the model ids, and which switches are on.
243
- const MODEL_ID = /^[\w./:@-]{1,160}$/;
244
- async function inventoryOf(listings) {
245
- const values = settingsNow();
246
- const providers = await Promise.all(Object.entries(listings)
247
- .filter(([name]) => values[name])
248
- .map(async ([name, canonical]) => {
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
- }
271
- }
272
- return first;
273
- }));
274
- return { providers: providers.filter(Boolean), flags: switchStates(values) };
275
- }
276
224
  let secrets = [];
277
225
  // A run's request to an endpoint your app answers later carries the first (src/customer/endpoint.ts),
278
226
  // with how long the run waits for this reply; a reply with no answer says in the second why none came.
@@ -283,7 +231,6 @@ const trialJars = new Map();
283
231
  // Endpoints that refused a new visitor: their trials carry the person's own session.
284
232
  const ownSession = new Set();
285
233
  const TRIAL_JARS = 500;
286
- let identities = null;
287
234
  let capture = null;
288
235
  // The local proxy an app is seen through when the hook cannot load into it (lib/proxy.mjs).
289
236
  let proxy = null;
@@ -309,17 +256,10 @@ let forgetTold = null;
309
256
  // load on it, not a hang.
310
257
  let answering = 0;
311
258
  const held = makeHeld({ rows: () => capture?.usage()?.rows ?? [], say: (line) => say(line) });
312
- // Closed while a store the app reaches is brought up and copied, until the app runs on the copy: the
313
- // 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.
314
260
  let gate = null;
315
261
  const hold = () => { if (!gate) { let open; gate = new Promise((r) => { open = r; }); gate.open = () => { gate = null; open(); }; } };
316
262
  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"));
323
263
  const mask = (text) => maskWith(String(text ?? ""), secrets);
324
264
 
325
265
  // ---- the wire
@@ -361,13 +301,6 @@ async function call(method, path, body, { raw = false, timeoutMs = 60_000, heade
361
301
 
362
302
  // ---- the box on this machine: your folder, read where it stands
363
303
  const work = join(tmpdir(), `cortad-${process.pid}`);
364
- // Where the hook reads the hosts a trial's writes may reach (lib/trace.cjs, lib/pyhook), and the
365
- // yes they come from.
366
- const outboundFile = join(work, "outbound.json");
367
- let consent = {};
368
- // The person's yes to raising their app's own limits and opening its sign-in switches for this
369
- // session, from the browser. Until it arrives the app runs with its guards as they are.
370
- let limitsYes = false;
371
304
  mkdirSync(work, { recursive: true });
372
305
  // What the app printed this session, beside the project's other records rather than in a temp folder
373
306
  // the agent is never told of. Emptied once this process holds the project (holdProject), so a second
@@ -414,13 +347,12 @@ async function verb(job) {
414
347
  if (verbose) say(`$ ${cmd.slice(0, 160)}`);
415
348
  // No lock, no shell: a world never gets an unlocked one on this machine.
416
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" };
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.
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.
420
353
  const asApp = b.opts?.app === true;
421
- if (asApp && writesHeld()) return { success: false, exitCode: 126, stdout: "", stderr: WRITES_HELD };
422
354
  const { file, args } = lock.wrap(cmd, { network: asApp });
423
- const env = asApp ? { ...localEnv(envFiles, appDir, root, process.env), ...keeping.envNow(), ...plainEnv } : plainEnv;
355
+ const env = asApp ? { ...process.env, ...plainEnv } : plainEnv;
424
356
  return new Promise((done) => {
425
357
  const child = spawn(file, args, { cwd: root, env, stdio: ["ignore", "pipe", "pipe"] });
426
358
  let out = "", err = "";
@@ -436,7 +368,6 @@ async function verb(job) {
436
368
  // Only the app's own port: this machine's other services are not the world.
437
369
  const port = Number(b.port) || doorPort();
438
370
  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 };
440
371
  const path = typeof b.path === "string" && b.path.startsWith("/") ? b.path : "/";
441
372
  const headers = {};
442
373
  for (const [k, v] of Object.entries(b.headers ?? {})) if (!/^(host|content-length|connection)$/i.test(k)) headers[k] = String(v);
@@ -445,6 +376,7 @@ async function verb(job) {
445
376
  // Every request of ours is tagged, a knock or a revisit as much as a trial, so the hook never
446
377
  // takes one for the person's: it would become the request trials copy and the sign-in they carry.
447
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"]}`);
448
380
  const letGo = held.hold(job.id, turn || headers["x-cortad-turn"]);
449
381
  // An endpoint your app answers later: it takes the message and its model answers afterwards,
450
382
  // outside the request (a queue's worker). Its own reply only says it took the message.
@@ -453,8 +385,9 @@ async function verb(job) {
453
385
  const later = laterAt !== undefined && (headers[laterAt] === "later" || laterMs > 0);
454
386
  if (laterAt !== undefined) delete headers[laterAt];
455
387
  if (later) capture?.forget(turn || headers["x-cortad-turn"]);
456
- // A request that speaks as one of your app's own callers carries the role, not the token:
457
- // the token was issued on this machine and is put in here, so it never travels.
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.
458
391
  const marker = Object.keys(headers).find((k) => k.toLowerCase() === AS_HEADER);
459
392
  let spokenAs = null;
460
393
  if (marker) {
@@ -462,16 +395,8 @@ async function verb(job) {
462
395
  delete headers[marker];
463
396
  if (role === CAPTURED || role.startsWith(`${CAPTURED}:`)) {
464
397
  spokenAs = role;
465
- // Speaking as one of the customers whose requests we watched: every header their own client sent.
466
398
  const theirs = capture?.headers(role);
467
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; }
468
- // No request of theirs is held (a new connect): sent from the app's own address, as Cortad's
469
- // own test request is, so an app that lets visitors in by Origin lets this one in.
470
- const from = !theirs && app ? ownOrigin(app.port) : null;
471
- if (from && !Object.keys(headers).some((k) => k.toLowerCase() === "origin")) { headers.origin = from; headers.referer = `${from}/`; }
472
- } else {
473
- const held = await identities?.headerFor(role);
474
- if (held) { for (const k of Object.keys(headers)) if (k.toLowerCase() === held.name) delete headers[k]; headers[held.name] = held.value; }
475
400
  }
476
401
  }
477
402
  const method = String(b.method ?? "GET").toUpperCase();
@@ -608,11 +533,8 @@ async function verb(job) {
608
533
  }
609
534
  catch (e) { return { success: false, stderr: String(e.message) }; }
610
535
  }
611
- case "mint": return identities ? JSON.parse(mask(JSON.stringify(await mintAcross({
612
- recipes: b.recipes, root, appDir, onPath, appPort: app?.port, portFor: serviceUp,
613
- mint: (recipes, port, origin) => identities.mint({ ...b, recipes, headers: { ...(b.headers ?? {}), ...(origin ? { origin, referer: `${origin}/` } : {}) } }, port),
614
- originFor: (port) => originFor(port, envOrigins(envFiles)),
615
- })))) : { 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: [] };
616
538
  // The last of what your app printed, for a reply it failed: the traceback a developer reads.
617
539
  // Masked like everything else, and the folder named so its paths can be read as your files.
618
540
  case "log": {
@@ -628,7 +550,7 @@ async function verb(job) {
628
550
  // The person changed what they said yes to while this command runs.
629
551
  // Said in the terminal: an agent that read "one question for the person" here told them the run
630
552
  // 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 };
553
+ case "consent": say("the person's answer on the card in the browser arrived"); return { ok: true };
632
554
  // A line for the agent reading this terminal: the read of the code landing, said the moment it does.
633
555
  // One marked news (a run ending) is also kept for the hook, so an agent that was not listening
634
556
  // hears it on its next prompt or edit.
@@ -640,10 +562,9 @@ async function verb(job) {
640
562
  return { ok: true };
641
563
  }
642
564
  case "restart": return restartApp();
643
- case "inventory": return inventoryOf(b.probe && typeof b.probe === "object" ? b.probe : {});
644
- // Your own pages, read here rather than in a world's shell: that shell is sealed away from
645
- // every env file and every host but this one, so inside it no store of yours has an address.
646
- case "sample": return { report: JSON.parse(mask(JSON.stringify(await sampleHere(b, settingsNow(), 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)))) };
647
568
  case "lock": return { locked: Array.isArray(b.hosts) ? b.hosts.length : 0 };
648
569
  // What your app spent on its providers, from the hook inside it. An app this command did not
649
570
  // start has no hook, and the empty answer says the meter is absent rather than that nothing was spent.
@@ -679,155 +600,52 @@ const answers = async (port) => {
679
600
  }
680
601
  return false;
681
602
  };
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 });
686
- const onPath = (bin) => (process.env.PATH ?? "").split(":").some((dir) => dir && existsSync(join(dir, bin)));
687
- // Where their app lives inside this repository, and how it starts. Worked out in lib/start.mjs.
688
- 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;
689
610
 
690
- // ---- a sign-in mounted in another workspace
691
- // One app served its AI from apps/api and mounted sign-in in apps/dashboard: a test account can
692
- // only be made where the sign-in is, and posting a sign-up at the AI's own port is a 404.
693
- const toldService = new Set();
694
- const signedInAt = (dir, port) => { if (!toldService.has(dir)) { toldService.add(dir); say(`signed in a test account through ${dir}`); } return port; };
695
- // Everything else this command started, stopped with it: each started the way the app is, on the
696
- // same copies of its data.
697
- const sidecars = [];
698
- function sidecar(cmd, cwd) {
699
- 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() } });
700
- sidecars.push(kid);
701
- kid.stdout.on("data", logTo());
702
- kid.stderr.on("data", logTo());
703
- return kid;
704
- }
705
- // The port that workspace's sign-in answers on. One it is already serving on comes first: starting
706
- // a second copy of a dashboard that is already up costs a minute and takes its port.
707
- async function serviceUp(group) {
708
- const named = servicePort(group.cwd);
709
- if (named && named !== app?.port && (await answers(named))) return signedInAt(group.dir, named);
710
- // Its own port taken by the app leaves nothing to wait on: whatever answers there is the app.
711
- if (!named || named === app?.port || !group.plan?.cmd) return null;
712
- sidecar(group.plan.cmd, group.plan.cwd);
713
- return (await waitForPort(named, 90_000)) ? signedInAt(group.dir, named) : null;
714
- }
715
-
716
- async function ask(question) {
717
- if (!process.stdin.isTTY) return null;
718
- const rl = createInterface({ input: process.stdin, output: process.stdout });
719
- const line = await new Promise((r) => rl.question(question, r));
720
- rl.close();
721
- return line.trim() || null;
722
- }
723
611
  let child = null;
724
- // An app this command did not start carries nothing that counts its model calls, and no run plays
725
- // against it (the API holds Run): databuddy's free run played 39 conversations into a route that
726
- // answers from code.
727
- const UNSEEN = "your app was already running, so Cortad cannot see the calls it makes to its model, and no run can start.";
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 */ } };
728
616
  async function startApp() {
729
- const wanted = Number(flag("--port"));
730
- // "--port 8106 --start ..." is "start it, it will answer on 8106", not "it is already on 8106":
731
- // read as the latter it said nothing was answering and never ran the command it was given.
732
- if (wanted && !flag("--start")) {
733
- if (!(await answers(wanted))) return { port: null, said: "", why: `nothing is answering on port ${wanted}, so there is nothing to ask.`, noStart: true };
734
- const took = await takeOver(wanted);
735
- if (took) return took;
736
- if (proxyAsked) {
737
- await useProxy();
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(" ")}`);
739
- } else say(`${UNSEEN} Stop it and run this again without --port: this command starts your app itself.`);
740
- return { port: wanted, cmd: null };
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 };
741
624
  }
742
- const plan = startPlan({ root, typed: flag("--start"), onPath });
743
- // A package is not a broken app, and asking its owner how it starts is the wrong question.
744
- const cmd = plan?.cmd ?? (plan?.noServer ? null : await ask("How do you start your app? (for example: npm run dev) "));
745
- // Your code is already here and already being read: quitting now would throw away what you have
746
- // paid for. This stays connected, the read finishes, and the browser says what is missing.
747
- if (!cmd) {
748
- return { port: null, said: "", noStart: true,
749
- why: plan?.noServer
750
- ? "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\"."
751
- : "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>." };
752
- }
753
- appDir = plan?.cwd ?? root;
754
- pinned = pinnedNode();
755
- if (plan?.within) say(`your app is in ${plan.within}, started there with: ${cmd}`);
756
625
  const sourceFiles = files.map((f) => join(root, f));
757
- const lifted = sessionLifts(limitsYes, envFiles, sourceFiles);
758
- const raised = Object.keys(lifted).filter((n) => /^\d+$/.test(lifted[n]));
759
- const opened = Object.keys(lifted).filter((n) => !raised.includes(n));
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);
760
629
  if (raised.length) say(`higher request limits for this session: ${raised.join(", ")}`);
761
- // Said out loud, because it changes who their app lets in for as long as this command runs.
762
- if (opened.length) say(`for this session your app runs with ${opened.map((n) => `${n}=${lifted[n]}`).join(", ")}`);
763
- launched = { cmd, lifted };
764
- if (proxyAsked || !hookable(appDir)) await useProxy();
765
- 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)" : ""}`);
766
633
  const up = await launch(180_000);
767
- if (up.port) return { port: up.port, cmd: launched.cmd, lifted: Object.keys(lifted) };
768
- // Already running: a second start dies on the port the first one holds. Held by a process running
769
- // in this repository, started from the terminal the person was already working in, it is the app
770
- // and is used as it stands. "Another next dev server is already running" is the same fact in a
771
- // framework's own words, and the port it holds is not always the one asked for. A holder running
772
- // from any other folder is another program: it is named, never tested as this app.
773
- 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])))] : [];
774
- const holders = up.held ? [up.held] : (await Promise.all(ports.map(holderOf))).filter(Boolean);
775
- for (const held of holders) {
776
- if (!ownFolder(held.cwd)) return { port: null, said: up.tail, why: whyNot({ ...up, held }) };
777
- if (await answers(held.port)) {
778
- launched = null; child = null;
779
- await keeping.forget();
780
- const took = await takeOver(held.port);
781
- if (took) return took;
782
- say(`${UNSEEN} Stop the one on port ${held.port} and run this again: this command starts your app itself.`);
783
- return { port: held.port, cmd: null };
784
- }
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 };
785
639
  }
786
640
  if (up.tail) console.error(up.tail);
787
641
  return { port: null, said: up.tail, why: whyNot(up) };
788
642
  }
789
- // A start that failed because another process holds the port, in the words a framework uses.
790
- 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;
791
- const rootReal = realpathSync(root);
792
- const ownFolder = (dir) => { try { const at = realpathSync(dir); return at === rootReal || at.startsWith(rootReal + sep); } catch { return false; } };
793
643
  // Why a start did not come up, in a sentence that stands without the terminal: the runner's state
794
644
  // carries it to the verbs, and the screen gets the app's own words beside it.
795
645
  function whyNot(up) {
796
646
  const h = up.held;
797
- 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${up.fixed ? "; your app sets that port in its own code, so it cannot be moved" : ""}. Stop that program, then save a file here: your app is started again by itself.`;
798
- const wrongNode = pinned.major && !pinned.bin ? ` This project pins Node ${pinned.major} and this shell runs Node ${shellNode}: switch to ${pinned.major}.` : "";
799
- 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.`;
800
- }
801
-
802
- // An app the person started keeps the limits it started with, and a run needs more than a person
803
- // uses in a month. With their yes, the command stops that app and starts it the way it would have,
804
- // limits raised for the session. Only in a terminal, only when the code reads a limit, and only
805
- // when the command knows how to start the app.
806
- async function takeOver(port) {
807
- const lifted = liftedLimits(envFiles, files.map((f) => join(root, f)));
808
- const names = Object.keys(lifted);
809
- if (!names.length || !process.stdin.isTTY) return null;
810
- const plan = startPlan({ root, typed: flag("--start"), onPath });
811
- if (!plan?.cmd) return null;
812
- const pid = await listenerOn(port);
813
- if (!pid) return null;
814
- 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] `);
815
- if (answer && !/^y(?:es)?$/i.test(answer)) return null;
816
- const top = await supervisorOf(pid);
817
- step(`stopping your app (pid ${top}) to start it with higher limits`);
818
- await stopTree(top);
819
- for (let i = 0; i < 50 && (await answers(port)); i++) await new Promise((r) => setTimeout(r, 200));
820
- if (await answers(port)) { say("your app did not stop, so it is used as it is"); return null; }
821
- appDir = plan.cwd ?? root;
822
- pinned = pinnedNode();
823
- if (plan.within) say(`your app is in ${plan.within}, started there with: ${plan.cmd}`);
824
- say(`higher request limits for this session: ${names.join(", ")}`);
825
- launched = { cmd: plan.cmd, lifted, byTerminal: true };
826
- step(`starting your app: ${plan.cmd}`);
827
- const up = await launch(180_000);
828
- if (up.port) return { port: up.port, cmd: launched.cmd, lifted: names };
829
- if (up.tail) console.error(up.tail);
830
- 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.`;
831
649
  }
832
650
 
833
651
  // What the running app was started from, noted in app.json for the verbs, which run in another
@@ -847,41 +665,15 @@ async function noteAttached(port) {
847
665
 
848
666
  // Your app, started the way you start it, and watched until one of its own ports answers.
849
667
  let launched = null;
850
- // The Node this project pins, when the shell that ran this command has another. Strapi refuses
851
- // Node 26 outright, and a version manager that never switched in this shell is the usual reason.
852
- // ponytail: reads .nvmrc and .node-version only; engines ranges when a project pins no other way.
853
- const shellNode = Number(process.versions.node.split(".")[0]);
854
- function pinnedNode() {
855
- let want = "";
856
- 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; }
857
- const major = Number(/^v?(\d+)/.exec(want)?.[1]);
858
- if (!major || major === shellNode) return { major: null, bin: null };
859
- 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 []; } };
860
- 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"))];
861
- return { major, bin: bins.find((d) => existsSync(join(d, "node"))) ?? null };
862
- }
863
- let pinned = { major: null, bin: null };
864
668
 
865
- // Their app could not start because its dependencies are not on this machine. That is an install
866
- // nobody ran, not a broken app: it is installed once, with the manager the project locked, and the
867
- // app is started again. So is a store or service it reaches that nothing runs yet (backUp). Every
868
- // way the app is started comes through here, so it is fixed in one place.
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.
869
673
  async function launch(waitMs) {
870
- let up = await start(waitMs);
871
- if (up.port && !proxy && !(await hookedAt(up.port))) up = await startProxied(up, waitMs);
872
- // Only an app that stopped: one still running has not failed to start, whatever it printed.
873
- if (up.port || up.exited === null) return up;
874
- if (await keeping.backUp(app?.port).finally(release)) {
875
- up = await start(waitMs);
876
- if (up.port || up.exited === null) return up;
877
- }
878
- const ok = await installOnce(up.tail);
879
- // An install that failed is the reason their app cannot start, and it goes where the app's own
880
- // output goes: the terminal, and the screen that is waiting for the app.
881
- const said = (tail) => (installSaid ? `${installSaid}\n${tail}` : tail);
882
- if (!ok) return installSaid ? { ...up, tail: said(up.tail) } : up;
883
- const back = await start(waitMs);
884
- 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;
885
677
  }
886
678
  // Whether the hook loaded into the process answering on `port`. A machine that cannot name that
887
679
  // process leaves the app as it is.
@@ -892,122 +684,53 @@ async function useProxy() {
892
684
  const noted = () => { if (became?.state === "up") become("up", { ...became.fields, ...upAt(became.fields.proxied) }); };
893
685
  // A first model call seen through it is said to the run, for an app this command did not start.
894
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,
895
689
  file: capture.file, rulesFile: capture.rulesFile, target: () => ({ host: appHost, port: app?.port }),
896
690
  onSeen: () => { forgetTold?.(); noted(); },
897
691
  onUnseen: (door) => { unseen = door; noted(); say(unseenText(door)); },
898
692
  });
693
+ keepStart({ modelPort: proxy.modelPort });
899
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");
900
695
  }
901
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
+ };
902
705
  const IN_CONTAINER = /^\s*(?:sudo\s+)?(?:docker|podman)(?:-compose)?\b/;
903
706
  // The app answered, and the hook is not in it: it is started again with its model settings on the
904
707
  // proxy. One in a container would come back the same, so it is left up, seen at the front alone.
905
708
  async function startProxied(up, waitMs) {
906
709
  await useProxy();
907
- if (IN_CONTAINER.test(launched?.cmd ?? "")) return up;
710
+ if (IN_CONTAINER.test(launched?.cmd ?? "")) {
711
+ say(containerLine());
712
+ return up;
713
+ }
908
714
  await stopTree(child.pid);
909
715
  for (let i = 0; i < 75 && (await listenerOn(up.port)); i++) await new Promise((r) => setTimeout(r, 200));
910
716
  return start(waitMs);
911
717
  }
912
- let installed = false;
913
- let installSaid = "";
914
- async function installOnce(said) {
915
- if (installed) return false;
916
- const name = missingDependency(said);
917
- const cmd = name && installPlan(appDir, onPath, root);
918
- if (!cmd) return false;
919
- installed = true;
920
- stepDone(`your app needs ${name}, which is not installed here`);
921
- const started = Date.now();
922
- const secondsSoFar = () => Math.round((Date.now() - started) / 1000);
923
- // A line that rewrites itself in a terminal, and every tenth second where there is none.
924
- 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); };
925
- tick();
926
- const ticking = setInterval(tick, 1000);
927
- const done = await new Promise((r) => {
928
- const child = spawn("/bin/sh", ["-c", cmd], { cwd: appDir, env: { ...process.env, CI: "1" }, stdio: ["ignore", "pipe", "pipe"] });
929
- let tail = "";
930
- const keep = (log) => (d) => { tail = (tail + d.toString()).slice(-4000); log(d); if (verbose) process.stdout.write(d.toString()); };
931
- child.stdout.on("data", keep(logTo()));
932
- child.stderr.on("data", keep(logTo()));
933
- const timer = setTimeout(() => child.kill("SIGKILL"), 15 * 60_000);
934
- child.on("close", (code) => { clearTimeout(timer); r({ ok: code === 0, tail }); });
935
- child.on("error", (e) => { clearTimeout(timer); r({ ok: false, tail: String(e.message) }); });
936
- });
937
- clearInterval(ticking);
938
- 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; }
939
- stepDone(`installed your app's dependencies in ${secondsSoFar()} seconds`);
940
- // A Python project that had no interpreter of its own has one now, and it is the one to start with.
941
- const again = startPlan({ root, typed: flag("--start"), onPath });
942
- if (again?.cmd && again.cmd !== launched.cmd) { launched = { ...launched, cmd: again.cmd }; say(`starting your app: ${again.cmd}`); }
943
- return true;
944
- }
945
718
 
946
- // The port their start command names, moved when something else already holds it: two apps on one
947
- // laptop both said --port 8000, and the second attached to the first's server as its own.
948
- // Ports this program tried and lost: two connects on one machine can pick the same free port in
949
- // the same second, and the one that binds second sees "address already in use".
950
- const lost = new Set();
951
- async function freePortAbove(port) {
952
- for (let next = port + 1; next < port + 30; next++) if (!lost.has(next) && !(await listenerOn(next))) return next;
953
- return null;
954
- }
955
- const PORT_FLAG = /(--port[= ]|-p |\bPORT=)(\d{4,5})\b/;
956
- // The port a start names: its command's flag, or PORT as this command sets it or an env file does.
957
- const namedPort = (cmd, lifted) => Number(PORT_FLAG.exec(cmd)?.[2] ?? lifted.PORT ?? settingsNow().PORT ?? 0) || 0;
958
- async function freed(cmd, lifted) {
959
- const m = PORT_FLAG.exec(cmd);
960
- // The app's own env file names its port too: yunqiao's PORT=8105 sat in .env, and a stale copy
961
- // on that port was reported as "your app stopped" instead of moved past.
962
- const named = m ? Number(m[2]) : Number(lifted.PORT) || Number(settingsNow().PORT) || 0;
963
- if (!named || (!lost.has(named) && !(await listenerOn(named)))) return { cmd, lifted };
964
- const port = await freePortAbove(named);
965
- if (!port) return { cmd, lifted };
966
- say(`port ${named} is taken on this machine, so your app starts on ${port}`);
967
- // The same port their env file names, moved the same way.
968
- return m ? { cmd: cmd.replace(m[0], `${m[1]}${port}`), lifted } : { cmd, lifted: { ...lifted, PORT: String(port) } };
969
- }
970
-
971
- // Your app's data, read once per session: copies made before the first start, and every restart
972
- // after it runs against the same copies, so a crash mid-run does not lose what the run made. What
973
- // was copied, started, or left where trials write into it is told in the terminal here and to the
974
- // run with the app (announce), so status says it beside Run and the report keeps it.
975
- const keeping = makeKeeping({
976
- root, work, ledgerFile: join(homeOf(project), "made.json"), onPath, say, hold, typed: Boolean(flag("--start")), chosen,
977
- launch: (plan) => sidecar(plan.cmd, plan.cwd),
978
- connections: () => capture?.connections() ?? [],
979
- copies: () => capture?.copies() ?? [],
980
- settings: () => {
981
- const from = {};
982
- for (const file of envFiles.filter((f) => !/\.(example|sample)$/.test(f))) {
983
- let text = "";
984
- try { text = readFileSync(file, "utf8"); } catch { continue; }
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; }
986
- }
987
- return { values: settingsNow(), from, appDir, sources: files.map((f) => join(root, f)) };
988
- },
989
- });
990
-
991
- async function start(waitMs, tries = 3) {
992
- const { cmd, lifted } = await freed(launched.cmd, launched.lifted ?? {});
719
+ async function start(waitMs) {
720
+ const { cmd, lifted } = launched;
993
721
  // The record of what the app writes opens with the app: the first run starts itself on the
994
722
  // server's side, with no verb here to mark it, and a restart mid-run keeps the record open.
995
723
  try { markRun(writesDirOf(homeOf(projectOf(root))), "session", { keep: true }); } catch { /* the app's writes go unrecorded */ }
996
724
  toolsAtStart = toolsNow();
997
725
  // Taken before the app reads a file: a save after this moment is a change it has not loaded.
998
726
  const note = { own: true, at: Date.now(), reloads: RELOADER.test(commandText(cmd, appDir)), answered: false };
999
- // The settings files count as what it loaded: an env file is rarely tracked, and an agent's edit to
1000
- // one left the app on the old settings until the command was started again (ulaim, 2026-10-01).
1001
- note.files = { ...sourceOf(root, files), ...sourceOf(root, envFiles.filter((f) => !/\.(example|sample)$/.test(f)).map((f) => relative(root, f))) };
1002
- storesAt ??= storesNamed();
727
+ note.files = sourceOf(root, files);
1003
728
  loaded = note;
1004
- const copies = await keeping.env(true);
1005
- await allowOutbound();
1006
- // A start command the person typed says which databases the app uses: the store settings of their
1007
- // settings files are left for the app to load, since handed over here they outrank the
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 };
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 ?? ""}` } : {}) } });
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" } });
1011
734
  const mine = child;
1012
735
  become("starting", { app: mine.pid ?? null });
1013
736
  let seen = "";
@@ -1034,13 +757,10 @@ async function start(waitMs, tries = 3) {
1034
757
  if (taken || /app crashed - waiting for file changes|waiting for (?:file )?changes before restart|Failed running/i.test(seen)) {
1035
758
  await stopTree(mine.pid);
1036
759
  if (!taken) return { port: null, exited: 1, tail: tail() };
1037
- // Taken between the check and the bind: a port this start names moves to the next free one, a
1038
- // bounded number of times. A port the app sets in its own code does not follow it, and starting
1039
- // the app again only meets the same holder, so the holder is named instead.
1040
- const named = namedPort(cmd, lifted);
1041
- const port = portInError(seen) || named;
1042
- if (named && port === named && tries > 1) { lost.add(named); return start(waitMs, tries - 1); }
1043
- 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 };
1044
764
  }
1045
765
  // The port is what the app's own process group listens on. Never a guess: a developer's
1046
766
  // machine has other things on 3000 and 8080, and one of them answered for the app once.
@@ -1048,7 +768,7 @@ async function start(waitMs, tries = 3) {
1048
768
  // The socket can open before the server answers; the log line is the tiebreak among several.
1049
769
  const plain = withoutStores(seen.replace(/\x1b\[[0-9;]*m/g, ""));
1050
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]));
1051
- 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])]) {
1052
772
  if (await answers(port)) {
1053
773
  note.answered = true;
1054
774
  if (loaded === note) noteApp();
@@ -1062,7 +782,6 @@ async function start(waitMs, tries = 3) {
1062
782
  return { port: null, exited: null, tail: seen.replace(/\x1b\[[0-9;]*m/g, "").split("\n").filter(Boolean).slice(-12).join("\n") };
1063
783
  }
1064
784
 
1065
- const COPIED = "starting your app again so it runs against the copies made for this session";
1066
785
  // A restart waits this long for the run's requests already at your app, then goes ahead: one it
1067
786
  // cuts is sent again once your app answers.
1068
787
  const DRAIN_MS = 20_000;
@@ -1111,23 +830,11 @@ const savedSinceStart = () => Boolean(loaded?.files) && changesOf({
1111
830
  }).changed.length > 0;
1112
831
  // Every save that changes what your app runs starts it again, when it does not reload by itself: an
1113
832
  // agent that saved a fix and asked the app eight seconds later was answered by the old code. Looked
1114
- // at again after each restart, for a save made while it was under way.
1115
- // The stores the settings name, as copies are made of them: a change here is a change of what the app
1116
- // writes into, so its copies and holds are made again for the new settings, and said again.
1117
- const storesNamed = () => { try { return JSON.stringify(storesOf(settingsNow()).map((s) => [s.engine, s.host, s.port, s.db, s.names])); } catch { return ""; } };
1118
- let storesAt = null;
1119
- // Looked at before the first wait too: the app now starts while the code is uploaded, and a save made
1120
- // 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.
1121
835
  async function restartOnSave() {
1122
836
  while (!closing) {
1123
- while (!closing && launched && child && !appGone && !loaded?.reloads && savedSinceStart()) {
1124
- const named = storesNamed();
1125
- const moved = storesAt !== null && named !== storesAt;
1126
- storesAt = named;
1127
- if (moved) await keeping.forget().catch(() => {});
1128
- await restartApp(moved ? "starting your app again on the settings you changed, with its data copied again for them" : undefined);
1129
- if (moved) await announce().catch(() => null);
1130
- }
837
+ while (!closing && launched && child && !appGone && !loaded?.reloads && savedSinceStart()) await restartApp();
1131
838
  await sourceChanged();
1132
839
  }
1133
840
  }
@@ -1168,10 +875,6 @@ async function close(code = 0) {
1168
875
  try {
1169
876
  if (child?.pid) await stopTree(child.pid);
1170
877
  proxy?.close();
1171
- // A copy lives on its store, and a store can be a service of the repository started beside the
1172
- // app: the copies go first, while it still answers.
1173
- await keeping.close();
1174
- for (const kid of sidecars) if (kid.pid) await stopTree(kid.pid);
1175
878
  // A sign-in the app printed before its request reached this command is masked now. Only by the
1176
879
  // process that writes the file: another may still be appending to it.
1177
880
  if (readRunner(project)?.pid === process.pid) {
@@ -1192,7 +895,7 @@ const envFiles = [];
1192
895
  const files = [];
1193
896
  listed = gitListed();
1194
897
  walk(root, 0, files, envFiles, 0);
1195
- secrets = secretValues(dataFile ? [...envFiles, dataFile] : envFiles);
898
+ secrets = secretValues(envFiles);
1196
899
  envKeys = secretEnvValues(envFiles, (f) => readFileSync(f, "utf8"));
1197
900
  const kept = [];
1198
901
  for (let i = files.length - 1; i >= 0; i -= 1) if (carriesKey(files[i])) kept.unshift(...files.splice(i, 1));
@@ -1202,25 +905,22 @@ if (explain) {
1202
905
  const going = new Set([...files, ...kept]);
1203
906
  const left = listed ? [...listed].filter((f) => !going.has(f) && !ENV_FILE.test(basename(f))).sort() : [];
1204
907
  const bytes = files.reduce((n, f) => { try { return n + statSync(join(root, f)).size; } catch { return n; } }, 0);
1205
- const plan = startPlan({ root, typed: flag("--start"), onPath });
1206
908
  const rel = (f) => relative(root, f) || ".";
1207
909
  console.log([
1208
910
  `cortad --explain (nothing is sent or started by this)`,
1209
911
  ``,
1210
- `talks to ${origin.origin}, localhost (your app's port only), and your own model providers, to ask each which models your key can use`,
912
+ `talks to ${origin.origin}, and localhost on your app's port only`,
1211
913
  `would send ${files.length} source files, ${Math.round(bytes / 1024)} KB, once${listed ? " (what git would commit)" : ""}`,
1212
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 ? ", ..." : ""}`] : []),
1213
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 ? ", ..." : ""}`] : []),
1214
916
  `not sent anything git ignores, env files (${envFiles.length} here: ${envFiles.slice(0, 6).map(rel).join(", ") || "none"}), key files, node_modules`,
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`,
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"}`,
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)`}`,
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)`}`,
1218
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`,
1219
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`,
1220
922
  `your files never written by this program; your own coding agent edits them`,
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"}`,
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`,
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`,
1224
924
  `test shell confined by the OS: your project and toolchains only, writes to temp and build folders, localhost only`,
1225
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`,
1226
926
  ``,
@@ -1230,7 +930,6 @@ if (explain) {
1230
930
  }
1231
931
  if (kept.length) say(`left on this machine because it holds a key: ${kept.join(", ")}`);
1232
932
  const keepSecret = (v) => { if (v && v.length >= 12 && !secrets.includes(v)) secrets.push(v); };
1233
- identities = makeIdentities({ root, work, envFiles, sourceFiles: () => files, say, keepSecret, appDir: () => appDir });
1234
933
  // A real request that reached their model proves its door (lib/proof.mjs). What it proved goes up,
1235
934
  // masked like everything else, and nothing goes up that the mask could not read; the sign-in each
1236
935
  // request carried stays here.
@@ -1377,43 +1076,15 @@ function sourceChanged() {
1377
1076
  } catch { setTimeout(done, 15_000); }
1378
1077
  });
1379
1078
  }
1380
- // `watching` says whether a message sent to this app can be seen arriving: only an app this command
1381
- // started carries the hook, and only a runtime the hook exists for. `proves`: each request seen
1382
- // reaching the model is posted as its door's proof, so the run waits for those.
1383
- // `blocked`: each store off this machine no copy could be made of, which holds Run until the person
1384
- // says yes in the browser. The answer carries that yes (setup.consent), and the hosts it names are
1385
- // 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.
1386
1083
  const announce = async () => {
1387
- // 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.
1388
1084
  const hooked = Boolean(proxy ? launched || proxy.seen() : launched && capture?.watching(app.port));
1389
- const started = Boolean(launched);
1390
- 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 });
1391
- if (told.ok) await allowOutbound(told.data?.consent);
1392
- 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() });
1393
1086
  };
1394
-
1395
- // What the person said yes to, as the hook reads it: the hosts a trial's writes may reach.
1396
- async function allowOutbound(given) {
1397
- if (given && typeof given === "object") {
1398
- const list = (v, re) => (Array.isArray(v) ? v.filter((x) => typeof x === "string" && re.test(x)).slice(0, 50) : []);
1399
- consent = { hosts: list(given.hosts, /^[a-z0-9.-]{1,253}$/i).map((h) => h.toLowerCase()), stores: list(given.stores, /^[a-f0-9]{16}$/) };
1400
- await liftWith(given.limits === true);
1401
- }
1402
- try { writeFileSync(outboundFile, JSON.stringify({ pass: await keeping.outbound(consent) }), { mode: 0o600 }); } catch { /* every write of a trial stays held */ }
1403
- }
1404
-
1405
- // A yes given or taken back in the browser restarts an app this command started with its guards
1406
- // raised or restored; an app raised by the terminal's own yes keeps that.
1407
- async function liftWith(yes) {
1408
- if (yes === limitsYes) return;
1409
- limitsYes = yes;
1410
- if (!launched || !child || launched.byTerminal) return;
1411
- const lifted = sessionLifts(yes, envFiles, files.map((f) => join(root, f)));
1412
- if (JSON.stringify(lifted) === JSON.stringify(launched.lifted ?? {})) return;
1413
- launched = { ...launched, lifted };
1414
- if (app) app.lifted = Object.keys(lifted);
1415
- 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");
1416
- }
1087
+ const reachedNow = () => (capture?.connections() ?? []).map((c) => `${c.host}:${c.port}`).slice(0, 40);
1417
1088
 
1418
1089
  // Your app's life beside this connection. It is started; if it stops, or never comes up, this stays
1419
1090
  // and starts it again the moment you save a fix, and the browser is told each time it answers, so a
@@ -1438,11 +1109,10 @@ async function appLife(firstStart) {
1438
1109
  machineOf = () => (app ? {
1439
1110
  cmd: shownCommand(app.cmd, mask) ?? "", dir: relative(root, appDir) || ".", port: app.port, started: Boolean(launched),
1440
1111
  hooked: Boolean(launched && !proxy && capture?.watching(app.port)), proxy: proxy?.port ?? null,
1441
- changed: [...new Set([...(app.lifted ?? []), ...Object.keys(keeping.envNow())])],
1442
- copies: Object.keys(keeping.envNow()).length ? "made" : "none",
1112
+ changed: [...(app.lifted ?? [])],
1443
1113
  } : null);
1444
1114
  become("up", { ...upAt(), app: launched ? child?.pid ?? null : null });
1445
- say(`your app is answering on port ${app.port}${app.cmd ? ` · ${app.cmd}` : ""}`);
1115
+ say(`your app is answering on port ${app.port}${app.cmd ? `, started with: ${shownCommand(app.cmd, mask)}` : ""}`);
1446
1116
  if (launched) say(`your app's output for this session is kept in ${bootLog}, with the values from your env files masked`);
1447
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`);
1448
1118
  for (const line of connectedText(machineOf())) say(line);
@@ -1450,16 +1120,7 @@ async function appLife(firstStart) {
1450
1120
  let downSince = 0;
1451
1121
  let toldDown = false;
1452
1122
  let appTold = true;
1453
- // The stores and services its settings name, brought up once the app's own port is known; a
1454
- // store copied only now is one the app was not started on.
1455
- if (launched) {
1456
- try {
1457
- const got = await keeping.services(app.port).catch(() => ({}));
1458
- if (got.copied && child) await restartApp(COPIED);
1459
- if (got.changed) appTold = false;
1460
- } finally { release(); }
1461
- }
1462
- // Only once the app runs on the copies made for this session.
1123
+ let reachedTold = reachedNow().join(",");
1463
1124
  void reachLoop();
1464
1125
  forgetTold = () => { appTold = false; };
1465
1126
  void restartOnSave().catch((e) => say(`could not watch your files for a save: ${e?.message ?? e}`));
@@ -1477,9 +1138,9 @@ async function appLife(firstStart) {
1477
1138
  const now = upAt();
1478
1139
  if (JSON.stringify(now.receipts) !== JSON.stringify(became.fields.receipts)) { const { receipts: _was, ...fields } = became.fields; become("up", { ...fields, ...now }); }
1479
1140
  }
1480
- const reach = await keeping.watch(app.port).catch(() => ({}));
1481
- try { if (reach.copied && launched && child) await restartApp(COPIED); } finally { release(); }
1482
- if (reach.changed) appTold = false;
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; }
1483
1144
  if (!up || !appTold) { up = true; appTold = Boolean((await announce().catch(() => null))?.ok); }
1484
1145
  continue;
1485
1146
  }
@@ -1501,7 +1162,7 @@ async function appLife(firstStart) {
1501
1162
  // load. An app it only attached to is not its to watch, so nothing is said either way.
1502
1163
  if (!toldDown) {
1503
1164
  // Started again below, except an app given by --port, which is picked up when it answers again.
1504
- if (!launched && flag("--port")) failedWith(`your app stopped answering on port ${app.port}; it is picked up again when it answers`, lastSaid);
1165
+ if (!launched && wantedPort) failedWith(`your app stopped answering on port ${app.port}; it is picked up again when it answers`, lastSaid);
1505
1166
  else become("starting");
1506
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);
1507
1168
  }
@@ -1511,7 +1172,7 @@ async function appLife(firstStart) {
1511
1172
  // terminal, or one left behind by a terminal that was killed) is gone now, and this command
1512
1173
  // knows how it starts. Only an app given by --port is left to you, and picked up when it returns.
1513
1174
  if (launched && !appGone && !crashed && downFor < 25_000) continue;
1514
- if (!launched && flag("--port")) continue;
1175
+ if (!launched && wantedPort) continue;
1515
1176
  await restartOnce(async () => {
1516
1177
  if (child?.pid) await stopTree(child.pid);
1517
1178
  say(downLine(state, { port: app.port, downMs: downFor, crashed }));
@@ -1539,14 +1200,6 @@ async function appLife(firstStart) {
1539
1200
  }
1540
1201
  void appLife(firstStart).catch((e) => quit(String(e?.message ?? e)));
1541
1202
 
1542
- // The page Cortad's test requests come from: one the env names on the app's port, else one on this
1543
- // machine (a local frontend in front of this API). A hosted address such as a Supabase URL is no page
1544
- // of the app's, so an app whose env names none is asked with no Origin, as a server would ask it.
1545
- const LOCAL_ORIGIN = /^https?:\/\/(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::\d+)?$/;
1546
- function ownOrigin(port) {
1547
- const local = envOrigins(envFiles).filter((o) => LOCAL_ORIGIN.test(o));
1548
- return local.find((o) => new URL(o).port === String(port)) ?? local[0] ?? null;
1549
- }
1550
1203
  // Counted with the run's own requests, so a restart drains them, and sent only through the open gate.
1551
1204
  async function countedFetch(url, init) {
1552
1205
  while (gate) await gate;
@@ -1557,9 +1210,9 @@ async function countedFetch(url, init) {
1557
1210
  return new Response(body.byteLength ? body : null, { status: res.status, statusText: res.statusText, headers: res.headers });
1558
1211
  } finally { answering -= 1; }
1559
1212
  }
1560
- // Cortad's own test request to each endpoint the read lists, sent from here as soon as status lists
1561
- // them (lib/auto-reach.mjs), and the agent's reach the same way. Status is asked every 5 seconds
1562
- // while the code is read, then every minute; an ask from the agent is taken within a second.
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.
1563
1216
  const autoReach = makeAutoReach({
1564
1217
  status: async () => {
1565
1218
  const token = readToken(project);
@@ -1567,12 +1220,10 @@ const autoReach = makeAutoReach({
1567
1220
  return res?.ok ? res.data : null;
1568
1221
  },
1569
1222
  runner: () => (became?.state === "up" && !restarting ? { state: "up", ...became.fields } : null),
1570
- // A test request writes what a run's turn writes: it waits while a copy is being made, and while a
1571
- // store with no copy waits for the person's yes. Sent anyway, the test requests to a twin of a legal
1572
- // research API wrote into its own MongoDB and Elasticsearch while both held Run.
1573
- waiting: async () => (gate ? "Your app's data is being copied for this session." : writesHeld() ? WRITES_HELD : null),
1574
- origin: () => (app ? ownOrigin(app.port) : null),
1575
- 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; },
1576
1227
  stampOf: (m) => { try { return m.file ? String(statSync(join(root, m.file)).mtimeMs) : ""; } catch { return ""; } },
1577
1228
  asked: () => existsSync(join(homeOf(project), ASK)),
1578
1229
  report: async (answers) => {
@@ -1586,15 +1237,14 @@ const autoReach = makeAutoReach({
1586
1237
  say,
1587
1238
  fetchImpl: countedFetch,
1588
1239
  });
1589
- // The connection the server last heard sends its test requests itself; a reattach is a new one.
1240
+ // The connection the server last heard what the agent's requests met; a reattach is a new one.
1590
1241
  let reachedBox = "";
1591
1242
  async function reachLoop() {
1592
- for (let dueAt = 0; !closing; await new Promise((r) => setTimeout(r, 1000))) {
1243
+ for (; !closing; await new Promise((r) => setTimeout(r, 1000))) {
1593
1244
  const ask = takeAsk(homeOf(project));
1594
- if (!ask && Date.now() < dueAt) continue;
1245
+ if (!ask) continue;
1595
1246
  const out = await autoReach(ask).catch(() => null);
1596
- if (ask) { try { answerAsk(homeOf(project), ask.id, out?.text ?? "Nothing was sent.\nnext: status"); } catch { /* the agent's wait runs out */ } }
1597
- 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 */ }
1598
1248
  }
1599
1249
  }
1600
1250