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/README.md +13 -9
- package/lib/auto-reach.mjs +6 -5
- package/lib/contact-text.mjs +3 -4
- package/lib/env-files.mjs +0 -85
- package/lib/files.mjs +5 -0
- package/lib/fresh.mjs +1 -1
- package/lib/mcp.mjs +3 -3
- package/lib/proc.mjs +3 -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/local.mjs +167 -517
- package/package.json +2 -17
- package/skill/SKILL.md +12 -10
- package/skill/references/results.md +5 -5
- package/lib/backing.mjs +0 -230
- package/lib/container.mjs +0 -60
- package/lib/data.mjs +0 -143
- package/lib/elastic.mjs +0 -88
- package/lib/hosted.mjs +0 -201
- package/lib/identities.mjs +0 -689
- package/lib/keeping.mjs +0 -181
- package/lib/ledger.mjs +0 -20
- package/lib/listing.mjs +0 -12
- package/lib/mint.mjs +0 -350
- package/lib/mongo.mjs +0 -135
- 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 -433
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
|
|
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 { 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 {
|
|
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 {
|
|
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
|
|
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,
|
|
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 {
|
|
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
|
|
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
|
-
|
|
64
|
-
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");
|
|
65
64
|
// A code is eight characters from the connect screen's alphabet, which has no I, O, 0 or 1.
|
|
66
|
-
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, "");
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
90
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
//
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
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
|
|
418
|
-
//
|
|
419
|
-
//
|
|
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 ? { ...
|
|
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
|
|
457
|
-
//
|
|
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
|
-
|
|
612
|
-
|
|
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":
|
|
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
|
-
|
|
644
|
-
//
|
|
645
|
-
|
|
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
|
-
//
|
|
683
|
-
//
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
const
|
|
687
|
-
// Where their app
|
|
688
|
-
|
|
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
|
|
725
|
-
//
|
|
726
|
-
|
|
727
|
-
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 */ } };
|
|
728
616
|
async function startApp() {
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|
-
|
|
758
|
-
const
|
|
759
|
-
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);
|
|
760
629
|
if (raised.length) say(`higher request limits for this session: ${raised.join(", ")}`);
|
|
761
|
-
|
|
762
|
-
if (
|
|
763
|
-
|
|
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)
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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
|
|
798
|
-
|
|
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
|
-
//
|
|
866
|
-
//
|
|
867
|
-
//
|
|
868
|
-
//
|
|
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
|
-
|
|
871
|
-
if (up.port && !proxy && !(await hookedAt(up.port)))
|
|
872
|
-
|
|
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 ?? ""))
|
|
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
|
-
|
|
947
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1005
|
-
|
|
1006
|
-
//
|
|
1007
|
-
|
|
1008
|
-
|
|
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
|
-
//
|
|
1038
|
-
//
|
|
1039
|
-
|
|
1040
|
-
|
|
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
|
-
//
|
|
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(
|
|
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
|
|
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
|
|
1216
|
-
`would start ${
|
|
1217
|
-
`would raise ${
|
|
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
|
|
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
|
-
// `
|
|
1381
|
-
//
|
|
1382
|
-
//
|
|
1383
|
-
// `
|
|
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
|
-
|
|
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: [...
|
|
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 ?
|
|
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
|
-
|
|
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
|
-
|
|
1481
|
-
|
|
1482
|
-
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; }
|
|
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 &&
|
|
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 &&
|
|
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
|
-
//
|
|
1561
|
-
//
|
|
1562
|
-
//
|
|
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
|
-
|
|
1571
|
-
|
|
1572
|
-
//
|
|
1573
|
-
|
|
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
|
|
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 (
|
|
1243
|
+
for (; !closing; await new Promise((r) => setTimeout(r, 1000))) {
|
|
1593
1244
|
const ask = takeAsk(homeOf(project));
|
|
1594
|
-
if (!ask
|
|
1245
|
+
if (!ask) continue;
|
|
1595
1246
|
const out = await autoReach(ask).catch(() => null);
|
|
1596
|
-
|
|
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
|
|