cortad 0.1.5 → 0.1.8
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/lib/door.mjs +5 -2
- package/lib/identities.mjs +15 -3
- package/lib/mint.mjs +18 -2
- package/lib/pyhook/sitecustomize.py +211 -16
- package/lib/replay.mjs +72 -6
- package/lib/sample.mjs +218 -0
- package/lib/start.mjs +69 -14
- package/lib/trace.cjs +184 -12
- package/local.mjs +235 -21
- package/package.json +2 -1
package/local.mjs
CHANGED
|
@@ -22,7 +22,9 @@ import { openDoor } from "./lib/door.mjs";
|
|
|
22
22
|
import { lockHolds, makeLock } from "./lib/lock.mjs";
|
|
23
23
|
import { AS_HEADER, makeIdentities } from "./lib/mint.mjs";
|
|
24
24
|
import { CAPTURED, makeCapture } from "./lib/replay.mjs";
|
|
25
|
-
import {
|
|
25
|
+
import { sampleHere } from "./lib/sample.mjs";
|
|
26
|
+
import { listingUrl } from "./lib/listing.mjs";
|
|
27
|
+
import { installPlan, missingDependency, startPlan, workspaces } from "./lib/start.mjs";
|
|
26
28
|
|
|
27
29
|
const argv = process.argv.slice(2);
|
|
28
30
|
const flag = (name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; };
|
|
@@ -52,7 +54,12 @@ const api = `${origin.origin}/api`;
|
|
|
52
54
|
|
|
53
55
|
const root = process.cwd();
|
|
54
56
|
const MANIFEST = ["package.json", "pyproject.toml", "requirements.txt", "go.mod", "Cargo.toml", "docker-compose.yml", "docker-compose.yaml", "Gemfile", "mix.exs"];
|
|
55
|
-
|
|
57
|
+
// A repository whose root carries no manifest of its own is still a repository: crewai-examples
|
|
58
|
+
// keeps one project per folder under crews/, and the whole of it was refused at the door. The root
|
|
59
|
+
// is accepted when a project lives below it.
|
|
60
|
+
if (!MANIFEST.some((f) => existsSync(join(root, f))) && !workspaces(root).length) {
|
|
61
|
+
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}`);
|
|
62
|
+
}
|
|
56
63
|
|
|
57
64
|
// ---- what leaves the machine: the source git would commit, and nothing git is told to ignore
|
|
58
65
|
const SKIP_DIR = /^(node_modules|\.git|dist|build|out|coverage|vendor|venv|\.venv|env|target|tmp|\.next|\.nuxt|\.turbo|\.cache|__pycache__|\.terraform|\.wrangler|\.svelte-kit|\.output|\.parcel-cache|\.idea|\.vscode|secrets?|\.secrets?)$/i;
|
|
@@ -136,6 +143,8 @@ function envOrigins(envFiles) {
|
|
|
136
143
|
for (const line of text.split("\n")) {
|
|
137
144
|
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
138
145
|
if (!m || !/ORIGIN|URL|HOST|DOMAIN|FRONTEND|CLIENT|SITE|WEB/i.test(m[1])) continue;
|
|
146
|
+
// OPENAI_BASE_URL names where a key is sent, not a page a browser opens the app from.
|
|
147
|
+
if (/(?:BASE_URL|API_BASE|ENDPOINT)$/i.test(m[1]) && !/ORIGIN|FRONTEND|CLIENT|SITE|WEB|PUBLIC|APP/i.test(m[1])) continue;
|
|
139
148
|
for (const v of m[2].replace(/^(['"])(.*)\1$/, "$2").split(",")) {
|
|
140
149
|
try { const u = new URL(v.trim()); if (/^https?:$/.test(u.protocol)) out.add(u.origin); } catch { /* not an origin */ }
|
|
141
150
|
}
|
|
@@ -175,6 +184,53 @@ function liftedLimits(envFiles, sources = []) {
|
|
|
175
184
|
}
|
|
176
185
|
return out;
|
|
177
186
|
}
|
|
187
|
+
|
|
188
|
+
// Every name your env files set, with its value, read here and used only here. An example file is
|
|
189
|
+
// read first so a real one's value wins: a placeholder key asked for a model listing comes back
|
|
190
|
+
// refused, and that would read as your own key being refused.
|
|
191
|
+
function envValues(envFiles) {
|
|
192
|
+
const example = /\.(example|sample)$/;
|
|
193
|
+
const out = {};
|
|
194
|
+
for (const file of [...envFiles.filter((f) => example.test(f)), ...envFiles.filter((f) => !example.test(f))]) {
|
|
195
|
+
let text = "";
|
|
196
|
+
try { text = readFileSync(file, "utf8"); } catch { continue; }
|
|
197
|
+
for (const line of text.split("\n")) {
|
|
198
|
+
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
199
|
+
if (m) out[m[1]] = m[2].trim().replace(/^(['"])(.*)\1$/, "$2");
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
// Switch-shaped names and the word each one reads as. The name and the word travel, never the value.
|
|
205
|
+
const SWITCH_NAME = /(?:_ENABLED|_DISABLED|_MODE|_ON|_OFF|_FLAG)$|^(?:ENABLE|USE|DISABLE|SKIP)_/;
|
|
206
|
+
const switchStates = (values) => Object.fromEntries(Object.entries(values)
|
|
207
|
+
.filter(([name]) => SWITCH_NAME.test(name))
|
|
208
|
+
.map(([name, value]) => [name, value === "" ? "unset" : /^(?:1|true|yes|on)$/i.test(value) ? "on" : /^(?:0|false|no|off)$/i.test(value) ? "off" : "set"]));
|
|
209
|
+
|
|
210
|
+
// What your app can actually reach: each provider key your env files set is asked that provider
|
|
211
|
+
// for its own model listing, from this machine. Your key stays here; what goes back is the name it
|
|
212
|
+
// is set under, the host, what the host answered, the model ids, and which switches are on.
|
|
213
|
+
const MODEL_ID = /^[\w./:@-]{1,160}$/;
|
|
214
|
+
async function inventoryOf(listings) {
|
|
215
|
+
const values = envValues(envFiles);
|
|
216
|
+
const providers = await Promise.all(Object.entries(listings)
|
|
217
|
+
.filter(([name]) => values[name])
|
|
218
|
+
.map(async ([name, canonical]) => {
|
|
219
|
+
const url = listingUrl(name, canonical, values);
|
|
220
|
+
let host = "";
|
|
221
|
+
try { host = new URL(url).host; } catch { return null; }
|
|
222
|
+
try {
|
|
223
|
+
const res = await fetch(url, { headers: { authorization: `Bearer ${values[name]}` }, signal: AbortSignal.timeout(20_000) });
|
|
224
|
+
const body = res.ok ? await res.json().catch(() => null) : null;
|
|
225
|
+
const models = (Array.isArray(body?.data) ? body.data : [])
|
|
226
|
+
.map((m) => String(m?.id ?? "")).filter((id) => MODEL_ID.test(id)).slice(0, 1500);
|
|
227
|
+
return { env: name, host, status: res.status, models };
|
|
228
|
+
} catch {
|
|
229
|
+
return { env: name, host, status: 0, models: [] };
|
|
230
|
+
}
|
|
231
|
+
}));
|
|
232
|
+
return { providers: providers.filter(Boolean), flags: switchStates(values) };
|
|
233
|
+
}
|
|
178
234
|
let secrets = [];
|
|
179
235
|
let identities = null;
|
|
180
236
|
let capture = null;
|
|
@@ -208,7 +264,8 @@ const work = join(tmpdir(), `cortad-${process.pid}`);
|
|
|
208
264
|
mkdirSync(work, { recursive: true });
|
|
209
265
|
const bootLog = join(work, "boot.log");
|
|
210
266
|
writeFileSync(bootLog, "");
|
|
211
|
-
|
|
267
|
+
// Opened once the server says whose session this is.
|
|
268
|
+
let door = null;
|
|
212
269
|
// Files nobody may read through this program: keys, and git's own internals.
|
|
213
270
|
const SECRET_PATH = /(?:^|\/)(?:\.git|\.ssh|\.gnupg|\.aws|\.npmrc|\.netrc|id_(?:rsa|ed25519|ecdsa)[^/]*|[^/]*\.(?:pem|key|p12|pfx|jks|keystore))(?:\/|$)/;
|
|
214
271
|
// The engine's paths, as this machine has them. Its scratch files live in this program's own
|
|
@@ -277,13 +334,39 @@ async function verb(job) {
|
|
|
277
334
|
// working its plan, and by you. For those seconds nothing is listening. A turn that meets a
|
|
278
335
|
// closed door is held until your app answers again and sent then, once, instead of being
|
|
279
336
|
// counted against your app as a failure it never had.
|
|
337
|
+
// A door behind a guest session answers the first request with a redirect that mints the
|
|
338
|
+
// session and sends the browser back. A browser reaches it by loading a page first; the same
|
|
339
|
+
// request pushed down that chain arrives at a page route as a POST and reads as "wrong
|
|
340
|
+
// method", which is what your chat looked like from the outside. So the chain is walked the
|
|
341
|
+
// way a browser walks it, one GET with redirects followed, and the request is sent again with
|
|
342
|
+
// the session your app just handed out. The cookie is kept here and never leaves this machine.
|
|
343
|
+
const url = `http://127.0.0.1:${port}${path}`;
|
|
344
|
+
const yours = (to) => { try { const u = new URL(to, url); return u.hostname === "127.0.0.1" && u.port === String(port) ? u.href : null; } catch { return null; } };
|
|
345
|
+
let jar = "";
|
|
346
|
+
const sent = (at, over = {}) => fetch(at, {
|
|
347
|
+
...init, ...over,
|
|
348
|
+
headers: { ...headers, ...(over.headers ?? {}), ...(jar ? { cookie: [headers.cookie, jar].filter(Boolean).join("; ") } : {}) },
|
|
349
|
+
signal: AbortSignal.timeout(170_000),
|
|
350
|
+
});
|
|
351
|
+
const keep = (res) => { const set = res.headers.getSetCookie?.() ?? []; if (set.length) jar = [jar, ...set.map((c) => c.split(";")[0])].filter(Boolean).join("; "); };
|
|
352
|
+
const sentBack = (res) => (res.status >= 300 && res.status < 400 ? yours(res.headers.get("location") ?? "") : null);
|
|
353
|
+
const warm = async () => {
|
|
354
|
+
let at = url;
|
|
355
|
+
for (let hop = 0; hop < 4 && at; hop++) {
|
|
356
|
+
const res = await sent(at, { method: "GET", body: undefined });
|
|
357
|
+
keep(res);
|
|
358
|
+
at = sentBack(res);
|
|
359
|
+
}
|
|
360
|
+
};
|
|
280
361
|
const ask = async () => {
|
|
281
|
-
|
|
362
|
+
let res = await sent(url);
|
|
363
|
+
keep(res);
|
|
364
|
+
if (method !== "GET" && sentBack(res)) { await warm(); if (jar) res = await sent(url); }
|
|
282
365
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
283
366
|
const LIMIT = 262_144;
|
|
284
367
|
// A session cookie your app sets is its business: it is dropped here, and every other header masked.
|
|
285
|
-
const
|
|
286
|
-
return { status: res.status, headers, body: mask(buf.subarray(0, LIMIT).toString("utf8")), truncated: buf.length > LIMIT };
|
|
368
|
+
const said = Object.fromEntries([...res.headers].filter(([k]) => !/^set-cookie2?$/i.test(k)).map(([k, v]) => [k, mask(v)]));
|
|
369
|
+
return { status: res.status, headers: said, body: mask(buf.subarray(0, LIMIT).toString("utf8")), truncated: buf.length > LIMIT };
|
|
287
370
|
};
|
|
288
371
|
try { return await ask(); }
|
|
289
372
|
catch (e) {
|
|
@@ -316,8 +399,14 @@ async function verb(job) {
|
|
|
316
399
|
case "restore": return door.restore(String(b.checkpoint ?? ""));
|
|
317
400
|
case "keep": return door.keep(typeof b.checkpoint === "string" && b.checkpoint ? b.checkpoint : undefined);
|
|
318
401
|
case "restart": return restartApp();
|
|
402
|
+
case "inventory": return inventoryOf(b.probe && typeof b.probe === "object" ? b.probe : {});
|
|
403
|
+
// Your own pages, read here rather than in a world's shell: that shell is sealed away from
|
|
404
|
+
// every env file and every host but this one, so inside it no store of yours has an address.
|
|
405
|
+
case "sample": return { report: JSON.parse(mask(JSON.stringify(await sampleHere(b, envValues(envFiles), root)))) };
|
|
319
406
|
case "lock": return { locked: Array.isArray(b.hosts) ? b.hosts.length : 0 };
|
|
320
|
-
|
|
407
|
+
// What your app spent on its providers, from the hook inside it. An app this command did not
|
|
408
|
+
// start has no hook, and the empty answer says the meter is absent rather than that nothing was spent.
|
|
409
|
+
case "usage": return capture?.usage() ?? {};
|
|
321
410
|
// A world is ended from this terminal, never from the cloud.
|
|
322
411
|
case "destroy": return { ok: true };
|
|
323
412
|
default: return { ok: true };
|
|
@@ -345,13 +434,23 @@ let child = null;
|
|
|
345
434
|
async function startApp() {
|
|
346
435
|
const wanted = Number(flag("--port"));
|
|
347
436
|
if (wanted) {
|
|
348
|
-
if (!(await answers(wanted)))
|
|
437
|
+
if (!(await answers(wanted))) return { port: null, said: "", why: `nothing is answering on port ${wanted}, so there is nothing to ask.`, noStart: true };
|
|
438
|
+
const took = await takeOver(wanted);
|
|
439
|
+
if (took) return took;
|
|
349
440
|
say("your app was already running, so its request limits stay as they are; if it answers 429, stop it and run this without --port and they are raised for the session");
|
|
350
441
|
return { port: wanted, cmd: null };
|
|
351
442
|
}
|
|
352
443
|
const plan = startPlan({ root, typed: flag("--start"), onPath });
|
|
353
|
-
|
|
354
|
-
|
|
444
|
+
// A package is not a broken app, and asking its owner how it starts is the wrong question.
|
|
445
|
+
const cmd = plan?.cmd ?? (plan?.noServer ? null : await ask("How do you start your app? (for example: npm run dev) "));
|
|
446
|
+
// Your code is already here and already being read: quitting now would throw away what you have
|
|
447
|
+
// paid for. This stays connected, the read finishes, and the browser says what is missing.
|
|
448
|
+
if (!cmd) {
|
|
449
|
+
return { port: null, said: "", noStart: true,
|
|
450
|
+
why: plan?.noServer
|
|
451
|
+
? "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\"."
|
|
452
|
+
: "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>." };
|
|
453
|
+
}
|
|
355
454
|
appDir = plan?.cwd ?? root;
|
|
356
455
|
pinned = pinnedNode();
|
|
357
456
|
if (plan?.within) say(`your app is in ${plan.within}, started there with: ${cmd}`);
|
|
@@ -360,13 +459,19 @@ async function startApp() {
|
|
|
360
459
|
launched = { cmd, lifted };
|
|
361
460
|
step(`starting your app: ${cmd}`);
|
|
362
461
|
const up = await launch(180_000);
|
|
363
|
-
if (up.port) return { port: up.port, cmd, lifted: Object.keys(lifted) };
|
|
462
|
+
if (up.port) return { port: up.port, cmd: launched.cmd, lifted: Object.keys(lifted) };
|
|
364
463
|
// Already running: a second start dies on the port the first one holds. The one that is running
|
|
365
464
|
// is the app, so it is used as it stands rather than treated as a failure.
|
|
366
465
|
if (up.exited !== null && /EADDRINUSE|address already in use|port.{0,40}(?:in use|already used|is taken|unavailable)/i.test(up.tail)) {
|
|
367
466
|
const ports = [...up.tail.matchAll(/(?::|port\s*[:=]?\s*)(\d{4,5})\b/gi)].map((m) => Number(m[1]));
|
|
368
467
|
for (const port of new Set(ports)) {
|
|
369
|
-
if (await answers(port)) {
|
|
468
|
+
if (await answers(port)) {
|
|
469
|
+
launched = null; child = null;
|
|
470
|
+
const took = await takeOver(port);
|
|
471
|
+
if (took) return took;
|
|
472
|
+
say(`your app is already running on port ${port}, so that one is used; its request limits stay as they are`);
|
|
473
|
+
return { port, cmd: null };
|
|
474
|
+
}
|
|
370
475
|
}
|
|
371
476
|
}
|
|
372
477
|
if (up.tail) console.error(up.tail);
|
|
@@ -374,6 +479,55 @@ async function startApp() {
|
|
|
374
479
|
return { port: null, said: up.tail, why: `${up.exited !== null ? "your app stopped before it answered" : "your app did not answer within three minutes"}; what it said is above.${wrongNode}` };
|
|
375
480
|
}
|
|
376
481
|
|
|
482
|
+
// An app the person started keeps the limits it started with, and a run needs more than a person
|
|
483
|
+
// uses in a month. With their yes, the command stops that app and starts it the way it would have,
|
|
484
|
+
// limits raised for the session. Only in a terminal, only when the code reads a limit, and only
|
|
485
|
+
// when the command knows how to start the app.
|
|
486
|
+
async function takeOver(port) {
|
|
487
|
+
const lifted = liftedLimits(envFiles, files.map((f) => join(root, f)));
|
|
488
|
+
const names = Object.keys(lifted);
|
|
489
|
+
if (!names.length || !process.stdin.isTTY) return null;
|
|
490
|
+
const plan = startPlan({ root, typed: flag("--start"), onPath });
|
|
491
|
+
if (!plan?.cmd) return null;
|
|
492
|
+
const pid = await listenerOn(port);
|
|
493
|
+
if (!pid) return null;
|
|
494
|
+
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] `);
|
|
495
|
+
if (answer && !/^y(?:es)?$/i.test(answer)) return null;
|
|
496
|
+
const top = await supervisorOf(pid);
|
|
497
|
+
step(`stopping your app (pid ${top}) to start it with higher limits`);
|
|
498
|
+
await stopApp(top);
|
|
499
|
+
for (let i = 0; i < 50 && (await answers(port)); i++) await new Promise((r) => setTimeout(r, 200));
|
|
500
|
+
if (await answers(port)) { say("your app did not stop, so it is used as it is"); return null; }
|
|
501
|
+
appDir = plan.cwd ?? root;
|
|
502
|
+
pinned = pinnedNode();
|
|
503
|
+
if (plan.within) say(`your app is in ${plan.within}, started there with: ${plan.cmd}`);
|
|
504
|
+
say(`higher request limits for this session: ${names.join(", ")}`);
|
|
505
|
+
launched = { cmd: plan.cmd, lifted };
|
|
506
|
+
step(`starting your app: ${plan.cmd}`);
|
|
507
|
+
const up = await launch(180_000);
|
|
508
|
+
if (up.port) return { port: up.port, cmd: launched.cmd, lifted: names };
|
|
509
|
+
if (up.tail) console.error(up.tail);
|
|
510
|
+
return { port: null, said: up.tail, why: "your app did not come back after the restart." };
|
|
511
|
+
}
|
|
512
|
+
// The process listening on a port.
|
|
513
|
+
async function listenerOn(port) {
|
|
514
|
+
try { return Number((await exec("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"])).stdout.trim().split("\n")[0]) || null; } catch { return null; }
|
|
515
|
+
}
|
|
516
|
+
// The top of the chain that runs the app: nodemon, npm, the sh -c under it. Climbs from the listener
|
|
517
|
+
// while the parent is a runner, never into the person's own shell or terminal.
|
|
518
|
+
const RUNNER = /^(?:\S*\/)?(?:node|npm|npx|pnpm|yarn|bun|deno|python[\d.]*|uvicorn|gunicorn|flask|tsx|ts-node|nodemon|concurrently|pm2)(?:\s|$)|^(?:\/bin\/)?sh -c\b/;
|
|
519
|
+
async function supervisorOf(pid) {
|
|
520
|
+
const rows = (await exec("ps", ["-axo", "pid=,ppid=,args="])).stdout.trim().split("\n").map((l) => l.trim());
|
|
521
|
+
const table = new Map(rows.map((l) => { const m = /^(\d+)\s+(\d+)\s+(.*)$/.exec(l); return m ? [Number(m[1]), { ppid: Number(m[2]), args: m[3] }] : [0, null]; }));
|
|
522
|
+
let top = pid;
|
|
523
|
+
for (let i = 0; i < 8; i++) {
|
|
524
|
+
const parent = table.get(table.get(top)?.ppid ?? 0);
|
|
525
|
+
if (!parent || !RUNNER.test(parent.args)) break;
|
|
526
|
+
top = table.get(top).ppid;
|
|
527
|
+
}
|
|
528
|
+
return top;
|
|
529
|
+
}
|
|
530
|
+
|
|
377
531
|
// Your app, started the way you start it, and watched until one of its own ports answers.
|
|
378
532
|
let launched = null;
|
|
379
533
|
// The Node this project pins, when the shell that ran this command has another. Strapi refuses
|
|
@@ -391,7 +545,56 @@ function pinnedNode() {
|
|
|
391
545
|
}
|
|
392
546
|
let pinned = { major: null, bin: null };
|
|
393
547
|
|
|
548
|
+
// Their app could not start because its dependencies are not on this machine. That is an install
|
|
549
|
+
// nobody ran, not a broken app: it is installed once, with the manager the project locked, and the
|
|
550
|
+
// app is started again. Every way the app is started comes through here, so it is fixed in one place.
|
|
394
551
|
async function launch(waitMs) {
|
|
552
|
+
const up = await start(waitMs);
|
|
553
|
+
// Only an app that stopped: one still running has not failed to start, whatever it printed.
|
|
554
|
+
if (up.port || up.exited === null) return up;
|
|
555
|
+
const ok = await installOnce(up.tail);
|
|
556
|
+
// An install that failed is the reason their app cannot start, and it goes where the app's own
|
|
557
|
+
// output goes: the terminal, and the screen that is waiting for the app.
|
|
558
|
+
const said = (tail) => (installSaid ? `${installSaid}\n${tail}` : tail);
|
|
559
|
+
if (!ok) return installSaid ? { ...up, tail: said(up.tail) } : up;
|
|
560
|
+
const back = await start(waitMs);
|
|
561
|
+
return back.port ? back : { ...back, tail: said(back.tail) };
|
|
562
|
+
}
|
|
563
|
+
let installed = false;
|
|
564
|
+
let installSaid = "";
|
|
565
|
+
async function installOnce(said) {
|
|
566
|
+
if (installed) return false;
|
|
567
|
+
const name = missingDependency(said);
|
|
568
|
+
const cmd = name && installPlan(appDir, onPath);
|
|
569
|
+
if (!cmd) return false;
|
|
570
|
+
installed = true;
|
|
571
|
+
stepDone(`your app needs ${name}, which is not installed here`);
|
|
572
|
+
const started = Date.now();
|
|
573
|
+
const secondsSoFar = () => Math.round((Date.now() - started) / 1000);
|
|
574
|
+
// A line that rewrites itself in a terminal, and every tenth second where there is none.
|
|
575
|
+
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); };
|
|
576
|
+
tick();
|
|
577
|
+
const ticking = setInterval(tick, 1000);
|
|
578
|
+
const done = await new Promise((r) => {
|
|
579
|
+
const child = spawn("/bin/sh", ["-c", cmd], { cwd: appDir, env: { ...process.env, CI: "1" }, stdio: ["ignore", "pipe", "pipe"] });
|
|
580
|
+
let tail = "";
|
|
581
|
+
const keep = (d) => { tail = (tail + d.toString()).slice(-4000); appendFileSync(bootLog, d.toString()); if (verbose) process.stdout.write(d.toString()); };
|
|
582
|
+
child.stdout.on("data", keep);
|
|
583
|
+
child.stderr.on("data", keep);
|
|
584
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), 15 * 60_000);
|
|
585
|
+
child.on("close", (code) => { clearTimeout(timer); r({ ok: code === 0, tail }); });
|
|
586
|
+
child.on("error", (e) => { clearTimeout(timer); r({ ok: false, tail: String(e.message) }); });
|
|
587
|
+
});
|
|
588
|
+
clearInterval(ticking);
|
|
589
|
+
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; }
|
|
590
|
+
stepDone(`installed your app's dependencies in ${secondsSoFar()} seconds`);
|
|
591
|
+
// A Python project that had no interpreter of its own has one now, and it is the one to start with.
|
|
592
|
+
const again = startPlan({ root, typed: flag("--start"), onPath });
|
|
593
|
+
if (again?.cmd && again.cmd !== launched.cmd) { launched = { ...launched, cmd: again.cmd }; say(`starting your app: ${again.cmd}`); }
|
|
594
|
+
return true;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async function start(waitMs) {
|
|
395
598
|
const { cmd, lifted } = launched;
|
|
396
599
|
child = spawn("/bin/sh", ["-c", cmd], { cwd: appDir, env: { ...process.env, ...lifted, ...(capture ? capture.env(process.env) : {}), FORCE_COLOR: "0", ...(pinned.bin ? { PATH: `${pinned.bin}:${process.env.PATH ?? ""}` } : {}) }, stdio: ["ignore", "pipe", "pipe"], detached: true });
|
|
397
600
|
const mine = child;
|
|
@@ -487,13 +690,13 @@ if (explain) {
|
|
|
487
690
|
console.log([
|
|
488
691
|
`cortad --explain (nothing is sent or started by this)`,
|
|
489
692
|
``,
|
|
490
|
-
`talks to ${origin.origin}
|
|
693
|
+
`talks to ${origin.origin}, localhost (your app's port only), and your own model providers, to ask each which models your key can use`,
|
|
491
694
|
`would send ${files.length} source files, ${Math.round(bytes / 1024)} KB, once${listed ? " (what git would commit)" : ""}`,
|
|
492
695
|
`not sent anything git ignores, env files (${envFiles.length} here: ${envFiles.slice(0, 6).map(rel).join(", ") || "none"}), key files, data files, node_modules, .git`,
|
|
493
|
-
`env files read here only: to hide
|
|
494
|
-
`would start ${flag("--port") ? `nothing: uses your app on port ${flag("--port")}` : plan ? `${plan.cmd} (in ${rel(plan.cwd)})` : "asks you how your app starts"}`,
|
|
696
|
+
`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`,
|
|
697
|
+
`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"}`,
|
|
495
698
|
`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"} (for this session only)`}`,
|
|
496
|
-
`loads into app lib/trace.cjs (Node) or lib/pyhook/sitecustomize.py (Python): records the one request during which your app calls a model`,
|
|
699
|
+
`loads into app lib/trace.cjs (Node, Bun) or lib/pyhook/sitecustomize.py (Python): records the one request during which your app calls a model`,
|
|
497
700
|
`agent edits in your files, each with an undo kept in ~/.cortad/checkpoints; git is never touched`,
|
|
498
701
|
`agent shell confined by the OS: your project and toolchains only, writes to temp and build folders, localhost only`,
|
|
499
702
|
``,
|
|
@@ -520,6 +723,8 @@ const attach = await call("POST", "/local/attach", { code, name: basename(root),
|
|
|
520
723
|
if (!attach.ok) fail(attach.data?.error ?? `could not sign in (${attach.status})`);
|
|
521
724
|
box = attach.data.box;
|
|
522
725
|
key = attach.data.key;
|
|
726
|
+
// A server that names no journal gets one for this session alone: never another account's edits.
|
|
727
|
+
door = openDoor(root, { owner: typeof attach.data.journal === "string" && attach.data.journal ? attach.data.journal : box });
|
|
523
728
|
|
|
524
729
|
const list = join(work, "files.txt");
|
|
525
730
|
writeFileSync(list, files.join("\n") + "\n");
|
|
@@ -535,6 +740,15 @@ const treeDigest = (() => {
|
|
|
535
740
|
}
|
|
536
741
|
return h.digest("hex");
|
|
537
742
|
})();
|
|
743
|
+
// The commit this tree is. Your .git is never uploaded -- nothing of your history leaves this
|
|
744
|
+
// machine -- so the one sha the report needs is read here and sent as forty characters. A folder
|
|
745
|
+
// that is not a checkout sends nothing, and the report says so rather than inventing one.
|
|
746
|
+
const head = (() => {
|
|
747
|
+
try {
|
|
748
|
+
const sha = execFileSync("git", ["-C", root, "rev-parse", "HEAD"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
|
|
749
|
+
return /^[a-f0-9]{40}$/.test(sha) ? sha : "";
|
|
750
|
+
} catch { return ""; }
|
|
751
|
+
})();
|
|
538
752
|
const archive = join(work, "tree.tgz");
|
|
539
753
|
step("connecting");
|
|
540
754
|
await exec("tar", ["-czf", archive, "-C", root, "-T", list]);
|
|
@@ -545,7 +759,7 @@ const parts = Math.max(1, Math.ceil(bytes.length / PART));
|
|
|
545
759
|
for (let off = 0; off < bytes.length; off += PART) {
|
|
546
760
|
const last = off + PART >= bytes.length;
|
|
547
761
|
step(parts > 1 ? `connecting, ${Math.floor(off / PART) + 1} of ${parts}` : "connecting");
|
|
548
|
-
const put = await call("PUT", `/local/${box}/tree?last=${last ? 1 : 0}${last ? `&digest=${treeDigest}` : ""}`, bytes.subarray(off, off + PART), { raw: true, timeoutMs: 120_000 });
|
|
762
|
+
const put = await call("PUT", `/local/${box}/tree?last=${last ? 1 : 0}${last ? `&digest=${treeDigest}${head ? `&head=${head}` : ""}` : ""}`, bytes.subarray(off, off + PART), { raw: true, timeoutMs: 120_000 });
|
|
549
763
|
if (!put.ok) fail(put.data?.error ?? `upload failed (${put.status})`);
|
|
550
764
|
if (last) resumed = put.data?.resumed === true;
|
|
551
765
|
}
|
|
@@ -575,7 +789,7 @@ function sourceChanged() {
|
|
|
575
789
|
}
|
|
576
790
|
// `watching` says whether a message sent to this app can be seen arriving: only an app this command
|
|
577
791
|
// started carries the hook, and only a runtime the hook exists for.
|
|
578
|
-
const announce = () => call("POST", `/local/${box}/app`, { port: app.port, cmd: app.cmd, origins: envOrigins(envFiles), lifted: app.lifted ?? [], watching: Boolean(launched && capture?.
|
|
792
|
+
const announce = () => call("POST", `/local/${box}/app`, { port: app.port, cmd: app.cmd, origins: envOrigins(envFiles), lifted: app.lifted ?? [], watching: Boolean(launched && capture?.watching(app.port)), metered: Boolean(launched && capture?.watching(app.port)) });
|
|
579
793
|
|
|
580
794
|
// Your app's life beside this connection. It is started; if it stops, or never comes up, this stays
|
|
581
795
|
// and starts it again the moment you save a fix, and the browser is told each time it answers, so a
|
|
@@ -584,8 +798,8 @@ async function appLife() {
|
|
|
584
798
|
for (let first = true; ; first = false) {
|
|
585
799
|
const got = await startApp();
|
|
586
800
|
if (got.port) { app = got; break; }
|
|
587
|
-
await call("POST", `/local/${box}/stopped`, { said: mask(got.said
|
|
588
|
-
say(`${got.why} Fix it and save: it is started again by itself.`);
|
|
801
|
+
await call("POST", `/local/${box}/stopped`, { said: mask(got.said || got.why).slice(-2000) }).catch(() => {});
|
|
802
|
+
say(got.noStart ? got.why : `${got.why} Fix it and save: it is started again by itself.`);
|
|
589
803
|
if (first) say("leave this open. Ctrl-C disconnects.");
|
|
590
804
|
await sourceChanged();
|
|
591
805
|
say("saw your change, starting your app again");
|
|
@@ -654,7 +868,7 @@ async function close(code = 0) {
|
|
|
654
868
|
closing = true;
|
|
655
869
|
await call("DELETE", `/local/${box}`).catch(() => {});
|
|
656
870
|
if (child?.pid) await stopApp(child.pid);
|
|
657
|
-
const pending = door
|
|
871
|
+
const pending = door?.pending() ?? 0;
|
|
658
872
|
if (pending) say(`${pending} agent edit${pending === 1 ? " is" : "s are"} waiting for you to keep or undo. Run the command again to review ${pending === 1 ? "it" : "them"} in the browser.`);
|
|
659
873
|
await exec("rm", ["-rf", work]).catch(() => {});
|
|
660
874
|
process.exit(code);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cortad",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Connects the AI app on your machine to Cortad for test conversations. No dependencies.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"cortad": "local.mjs"
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"lib/mint.mjs",
|
|
15
15
|
"lib/pyhook/sitecustomize.py",
|
|
16
16
|
"lib/replay.mjs",
|
|
17
|
+
"lib/sample.mjs",
|
|
17
18
|
"lib/start.mjs",
|
|
18
19
|
"lib/trace.cjs",
|
|
19
20
|
"local.mjs"
|