cortad 0.3.0-rc.10 → 0.3.0-rc.12

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.
@@ -26,9 +26,18 @@ const SOURCE = /^(?:env:[A-Z_][A-Z0-9_]*(?:\|[A-Z_][A-Z0-9_]*)*|gen:(?:username|
26
26
  // before the program saw it. Still no quote, angle bracket, `$`, backtick or semicolon.
27
27
  const WHERE = /^[\w./:@()[\] -]{1,300}$/;
28
28
  const BASE = /^http:\/\/(?:127\.0\.0\.1|\[::1\]|localhost):\d{2,5}$/;
29
- const KINDS = new Set(["header", "login", "jwt", "upstream", "firebase", "authjs", "supabase"]);
29
+ const KINDS = new Set(["header", "login", "jwt", "upstream", "firebase", "authjs", "supabase", "basic"]);
30
30
  const STATUSES = new Set(["minted", "sealed", "absent", "refused", "unreachable", "upstream"]);
31
31
 
32
+ // A credential the app's own environment names for its own account: a user-side name and a
33
+ // password-side name that share a prefix. The prefix is the name with its trailing token removed,
34
+ // so USER_USERNAME pairs with USER_PASSWORD and TUDUDI_USER_EMAIL with TUDUDI_USER_PASSWORD.
35
+ const USER_TOKEN = /_(?:USERNAME|USER|EMAIL|LOGIN)$/;
36
+ const SECRET_TOKEN = /_(?:PASSWORD|PASS|SECRET)$/;
37
+ const CRED_USER_FIELD = /^(?:e-?mail|user(?:_?name)?|login|account|identifier)$/i;
38
+ const CRED_PASS_FIELD = /^(?:password|passcode|pass|pwd)$/i;
39
+ const SIGN_IN_ROUTE = /(?:^|\/)(?:login|signin|sign-in|sign_in|session|token|authenticate)(?:\/|$)/i;
40
+
32
41
  const slug = (role) => role.replace(/[^a-z0-9-]/g, "-");
33
42
  export const identityHeaderFile = (role) => (ROLE.test(role) ? HEADER_FILE.replace("%s.%s", `${slug(role)}.header`) : null);
34
43
  // The curl argument a probe inside the box adds to speak as a role: the header line is read there,
@@ -450,6 +459,18 @@ def sealed(recipe, env):
450
459
  return {"status": "sealed", "header": recipe["header"], "line": "%s: %s" % (recipe["header"], bearer(recipe["header"], v)), "note": " or ".join(recipe["env"])}
451
460
 
452
461
 
462
+ # HTTP Basic from the username and password the app's own environment names: env[0] is the user,
463
+ # env[1] the password. The line carries the scheme verbatim, so it is not run through bearer().
464
+ def basic(recipe, env):
465
+ names = recipe.get("env", [])
466
+ user = env.get(names[0]) if len(names) > 0 else None
467
+ pw = env.get(names[1]) if len(names) > 1 else None
468
+ if not user or not pw:
469
+ return {"status": "absent", "note": " and ".join(names)}
470
+ raw = base64.b64encode(("%s:%s" % (user, pw)).encode("utf-8")).decode("ascii")
471
+ return {"status": "minted", "header": recipe["header"], "line": "%s: Basic %s" % (recipe["header"], raw), "expires": int(time.time()) + TTL, "note": " and ".join(names)}
472
+
473
+
453
474
  def keep(path, line):
454
475
  fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
455
476
  with os.fdopen(fd, "w", encoding="utf-8") as f:
@@ -509,10 +530,15 @@ def main():
509
530
  got = login(recipe, role, role_value, env)
510
531
  elif recipe["kind"] == "firebase":
511
532
  got = firebase(recipe, role, env)
533
+ elif recipe["kind"] == "basic":
534
+ got = basic(recipe, env)
512
535
  else:
513
536
  got = {"status": "upstream", "note": " or ".join(recipe["env"]) or recipe["role"]}
514
537
  except Exception as err:
515
538
  got = {"status": "unreachable", "note": type(err).__name__}
539
+ # A sign-in the environment named its own credential for records which names it used.
540
+ if recipe.get("envCred") and got.get("status") == "minted" and not got.get("note"):
541
+ got["note"] = " and ".join(recipe.get("env", []))
516
542
  line = got.pop("line", None)
517
543
  if line:
518
544
  keep(FILE % (slug(role), "header" if got["status"] == "minted" else "sealed"), line)
@@ -551,12 +577,96 @@ export function parseIdentityReport(text) {
551
577
  return { identities };
552
578
  }
553
579
 
580
+ // The credential pairs an environment names, by shared prefix. Names only, never values.
581
+ export function credentialPairs(names) {
582
+ const list = (Array.isArray(names) ? names : []).filter((n) => typeof n === "string" && ENV_NAME.test(n));
583
+ const users = list.filter((n) => USER_TOKEN.test(n)).map((n) => ({ name: n, prefix: n.replace(USER_TOKEN, "") }));
584
+ const secrets = list.filter((n) => SECRET_TOKEN.test(n)).map((n) => ({ name: n, prefix: n.replace(SECRET_TOKEN, "") }));
585
+ const pairs = [];
586
+ const seen = new Set();
587
+ for (const u of users) {
588
+ const s = secrets.find((x) => x.prefix === u.prefix);
589
+ if (s && !seen.has(u.prefix)) { seen.add(u.prefix); pairs.push({ user: u.name, secret: s.name, prefix: u.prefix }); }
590
+ }
591
+ return pairs;
592
+ }
593
+
594
+ const routeRole = (route) => {
595
+ const seg = route.split("/").filter((s) => s && !/^(?:api|v\d+|auth)$/i.test(s)).pop() ?? "user";
596
+ const r = seg.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 20);
597
+ return /^[a-z]/.test(r) ? r : "user";
598
+ };
599
+ const uniqueRole = (base, used) => {
600
+ const stem = /^[a-z]/.test(base) ? base : "account";
601
+ let role = stem;
602
+ for (let n = 2; used.has(role); n += 1) role = `${stem}-${n}`;
603
+ used.add(role);
604
+ return role;
605
+ };
606
+
607
+ // When the app's own environment names a credential pair, the box can sign in with it. Each login
608
+ // recipe whose sign-in form takes a username or email and a password is filled from the pair
609
+ // instead of a generated account; each discovered sign-in route with no recipe becomes one; and an
610
+ // HTTP Basic identity is minted for a door that answers 401 with a Basic challenge. Values are
611
+ // opened by the mint program on the machine, never here: this only rewrites which env name a field
612
+ // reads. A register step (one of several, not a sign-in) keeps its fresh account.
613
+ export function envCredentialRecipes(recipes, names, loginRoutes = []) {
614
+ const list = Array.isArray(recipes) ? recipes : [];
615
+ const pairs = credentialPairs(names);
616
+ if (!pairs.length) return list;
617
+ const pair = pairs[0];
618
+ const userSrc = `env:${pair.user}`;
619
+ const passSrc = `env:${pair.secret}`;
620
+ const used = new Set(list.map((r) => r?.role).filter(Boolean));
621
+ const covered = new Set();
622
+ const out = list.map((r) => {
623
+ if (r?.kind !== "login" || !Array.isArray(r.steps)) return r;
624
+ let touched = false;
625
+ const steps = r.steps.map((s) => {
626
+ if (r.steps.length !== 1 && !SIGN_IN_ROUTE.test(s.route)) return s;
627
+ const body = { ...(s.body ?? {}) };
628
+ let hit = false;
629
+ for (const [f, v] of Object.entries(body)) {
630
+ if (typeof v !== "string" || !v.startsWith("gen:")) continue;
631
+ if (CRED_USER_FIELD.test(f)) { body[f] = userSrc; hit = true; }
632
+ else if (CRED_PASS_FIELD.test(f)) { body[f] = passSrc; hit = true; }
633
+ }
634
+ if (!hit) return s;
635
+ covered.add(s.route);
636
+ touched = true;
637
+ return { ...s, body };
638
+ });
639
+ return touched ? { ...r, steps, env: [...new Set([...(r.env ?? []), pair.user, pair.secret])], envCred: true } : r;
640
+ });
641
+ const known = new Set(out.flatMap((r) => (r?.steps ?? []).map((s) => s.route)));
642
+ const userField = /_EMAIL$/.test(pair.user) ? "email" : "username";
643
+ for (const route of (Array.isArray(loginRoutes) ? loginRoutes : []).slice(0, 6)) {
644
+ if (typeof route !== "string" || !route.startsWith("/") || known.has(route) || covered.has(route)) continue;
645
+ out.push({
646
+ kind: "login", role: uniqueRole(routeRole(route), used), header: "authorization", env: [pair.user, pair.secret],
647
+ steps: [{ route, body: { [userField]: userSrc, password: passSrc } }], opens: [], envCred: true,
648
+ where: `your environment (${pair.user} and ${pair.secret})`,
649
+ });
650
+ }
651
+ // A Basic identity for each pair as well: a door that wants a sign-in form ignores it, and a door
652
+ // behind HTTP Basic (which declares no route to fill) is reached by nothing else.
653
+ for (const p of pairs) {
654
+ out.push({
655
+ kind: "basic", role: uniqueRole(slug(p.prefix.toLowerCase()) || "account", used), header: "authorization",
656
+ env: [p.user, p.secret], opens: [], envCred: true, where: `your environment (${p.user} and ${p.secret})`,
657
+ });
658
+ }
659
+ return out;
660
+ }
661
+
554
662
  // What each row means to the person whose app this is.
555
663
  export function identitySentence(row) {
556
664
  switch (row.status) {
557
665
  case "minted": return row.kind === "firebase"
558
666
  ? `signed in as ${row.role} through the Firebase emulator standing in for your project; the token stays inside the sandbox`
559
- : `signed in as ${row.role} through your own door ${row.door}; the token stays inside the sandbox`;
667
+ : row.kind === "basic"
668
+ ? `signed in as ${row.role} with the username and password your environment sets (${row.note ?? "two variables"}); the header stays inside the sandbox`
669
+ : `signed in as ${row.role} through your own door ${row.door}; the token stays inside the sandbox`;
560
670
  case "sealed": return `${row.role} is the sealed ${row.note} value itself; it never leaves the sandbox, so only the compile's own probe can speak as it`;
561
671
  case "absent": return `could not sign in as ${row.role}: ${row.note} is not in the environment you sealed`;
562
672
  case "refused": return `your door refused the ${row.role} sign-in (${row.note}), so cases behind it are counted and not driven`;
package/lib/mint.mjs CHANGED
@@ -10,7 +10,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync }
10
10
  import { homedir } from "node:os";
11
11
  import { basename, dirname, join } from "node:path";
12
12
  import { promisify } from "node:util";
13
- import { identityScript, parseIdentityReport } from "./identities.mjs";
13
+ import { envCredentialRecipes, identityScript, parseIdentityReport } from "./identities.mjs";
14
14
 
15
15
  const exec = promisify(execFile);
16
16
  export const AS_HEADER = "x-cortad-as";
@@ -128,7 +128,13 @@ export function makeIdentities({ root, work, envFiles, sourceFiles = () => [], s
128
128
  }
129
129
 
130
130
  async function mint(body, port) {
131
- const recipes = Array.isArray(body?.recipes) ? body.recipes.slice(0, 12) : [];
131
+ let recipes = Array.isArray(body?.recipes) ? body.recipes.slice(0, 12) : [];
132
+ // On a refused first pass the raise asks again with this flag: sign in with the credential the
133
+ // app's own environment names, filling its sign-in form or a Basic header from that pair.
134
+ if (body?.envCreds) {
135
+ const names = Object.keys(Object.assign({}, ...[...envs().all].reverse()));
136
+ recipes = envCredentialRecipes(recipes, names, body.loginRoutes).slice(0, 12);
137
+ }
132
138
  if (!recipes.length || !port) return { identities: [] };
133
139
  mkdirSync(dir, { recursive: true, mode: 0o700 });
134
140
  const out = join(dir, "bl-identities.json");
@@ -141,7 +147,9 @@ export function makeIdentities({ root, work, envFiles, sourceFiles = () => [], s
141
147
  const merged = Object.assign({}, ...[...envs().all].reverse());
142
148
  const emulated = Boolean(merged.FIREBASE_AUTH_EMULATOR_HOST);
143
149
  const refused = new Map();
144
- for (const r of recipes) { const why = refusal(r, merged, (value) => inSource(root, sourceFiles(), value)); if (why && typeof r?.role === "string") refused.set(r.role, why); }
150
+ // A pair the environment names for the app's own account is the one credential the raise is
151
+ // allowed to reuse: the elevation and existing-account guards do not apply to it.
152
+ for (const r of recipes) { const why = r?.envCred ? null : refusal(r, merged, (value) => inSource(root, sourceFiles(), value)); if (why && typeof r?.role === "string") refused.set(r.role, why); }
145
153
  const forProgram = recipes.filter((r) => !refused.has(r?.role) && (r?.kind !== "firebase" || emulated));
146
154
  // The program signs in once per role value ("staff" as sales, support, marketing), each named
147
155
  // role:value; an account made for "staff" alone was never the one it asked for.
package/lib/start.mjs CHANGED
@@ -38,22 +38,61 @@ export function lockRoot(dir, root) {
38
38
  // The manager this repository was installed with, named by its lockfile.
39
39
  const manager = (dir) => (existsSync(join(dir, "pnpm-lock.yaml")) ? "pnpm" : existsSync(join(dir, "yarn.lock")) ? "yarn" : existsSync(join(dir, "bun.lockb")) || existsSync(join(dir, "bun.lock")) ? "bun" : "npm");
40
40
 
41
+ // A script that only bundles or serves a browser build: the AI is never behind it.
42
+ const BUNDLER = /\b(?:vite|next|nuxt|astro|svelte-kit|vinxi|webpack(?:-dev-server)?|react-scripts|parcel|rsbuild|storybook)\b/;
43
+ // A script that runs a server file, or runs everything at once.
44
+ const RUNS_SERVER = /\b(?:node|nodemon|tsx|ts-node(?:-dev)?|bun run|deno run|concurrently|npm-run-all|run-p|turbo run)\b|\.(?:sh|mjs|cjs|js|ts)\b/;
45
+ const SERVER_SCRIPT = /^(?:dev:(?:full|all|server|api|backend)|server|start:server|backend(?::dev)?|api(?::dev)?|start:api|serve:api|start)$/;
46
+ // The script that starts the AI, not the page: flowviz's dev runs vite and its server runs node
47
+ // server.js with openai behind express; tududi's dev is the frontend and backend:dev the API.
48
+ function serverScript(scripts, deps) {
49
+ const first = ["dev", "develop", "start:dev", "serve", "start"].find((s) => scripts?.[s]);
50
+ if (!first) return null;
51
+ const command = String(scripts[first]);
52
+ const frontendOnly = BUNDLER.test(command) && !RUNS_SERVER.test(command.replace(BUNDLER, ""));
53
+ if (!frontendOnly || !deps.some((d) => SERVER.test(d) || MODEL_SDK.test(d))) return first;
54
+ const better = Object.keys(scripts).filter((s) => SERVER_SCRIPT.test(s) && s !== first && RUNS_SERVER.test(String(scripts[s])) && !(BUNDLER.test(String(scripts[s])) && !/concurrently|npm-run-all|run-p|\.sh\b/.test(String(scripts[s]))));
55
+ const rank = (s) => (/full|all/.test(s) ? 0 : /server|backend|api/.test(s) ? 1 : 2);
56
+ return better.sort((a, b) => rank(a) - rank(b))[0] ?? first;
57
+ }
58
+
41
59
  function nodeStart(dir, onPath) {
42
60
  const pkg = json(join(dir, "package.json"));
43
61
  if (!pkg) return null;
44
- const script = ["dev", "develop", "start:dev", "serve", "start"].find((s) => pkg.scripts?.[s]);
62
+ const deps = Object.keys({ ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) });
63
+ const script = serverScript(pkg.scripts, deps);
45
64
  if (!script) return null;
46
65
  const locked = manager(dir);
47
- const deps = Object.keys({ ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) });
48
66
  // A lockfile names the manager the repository was installed with, not one this machine has.
49
67
  return { cmd: `${onPath(locked) ? locked : "npm"} run ${script}`, serves: deps.some((d) => SERVER.test(d)) || SERVES_JS.test(pkg.scripts[script]) };
50
68
  }
51
69
 
70
+ // A package that serves through its own console script: pyproject [project.scripts] names it, and
71
+ // its command module defines a serve or run command (typer, click, argparse). RepoWiki starts with
72
+ // `repowiki serve --port N`; no module-level app object exists to hand uvicorn.
73
+ function consoleServe(dir, venv) {
74
+ if (!venv) return null;
75
+ const toml = read(join(dir, "pyproject.toml"));
76
+ const block = /\[project\.scripts\]([^[]*)/.exec(toml)?.[1] ?? "";
77
+ for (const m of block.matchAll(/^\s*"?([\w.-]+)"?\s*=\s*"([\w.]+):(\w+)"/gm)) {
78
+ const [, name, module] = m;
79
+ const file = ["src/", ""].map((base) => join(dir, base, `${module.split(".").join("/")}.py`)).find(existsSync);
80
+ const text = file ? read(file) : "";
81
+ const sub = /(?:def serve\(|@\w+\.command\(\s*(?:name\s*=\s*)?["']serve["']|add_parser\(\s*["']serve["']|["']serve["']\s*:)/.test(text) ? "serve"
82
+ : /(?:def run\(|add_parser\(\s*["']run["']|@\w+\.command\(\s*(?:name\s*=\s*)?["']run["'])/.test(text) ? "run" : null;
83
+ const bin = join(dirname(venv), name);
84
+ if (sub && existsSync(bin)) return { cmd: `${JSON.stringify(bin)} ${sub}${/--port/.test(text) ? " --port 8000" : ""}`, serves: true };
85
+ }
86
+ return null;
87
+ }
88
+
52
89
  function pythonStart(dir, onPath) {
53
90
  if (!["requirements.txt", "pyproject.toml", "manage.py", "Pipfile", "uv.lock"].some((f) => existsSync(join(dir, f)))) return null;
54
91
  const venv = [".venv/bin/python", "venv/bin/python", "env/bin/python"].map((p) => join(dir, p)).find(existsSync);
55
92
  const py = venv ? JSON.stringify(venv) : existsSync(join(dir, "uv.lock")) && onPath("uv") ? "uv run python" : existsSync(join(dir, "poetry.lock")) && onPath("poetry") ? "poetry run python" : "python3";
56
93
  if (existsSync(join(dir, "manage.py"))) return { cmd: `${py} manage.py runserver`, serves: true };
94
+ const served = consoleServe(dir, venv);
95
+ if (served) return served;
57
96
  const named = [...new Set([...ENTRIES, ...entriesIn(dir)])].map((entry) => [entry, read(join(dir, entry))]).filter(([, text]) => text);
58
97
  const manifest = `${read(join(dir, "requirements.txt"))}\n${read(join(dir, "pyproject.toml"))}\n${read(join(dir, "Pipfile"))}`;
59
98
  // The file that serves before the file that merely runs: src/run_agent.py runs a conversation in
@@ -115,7 +154,11 @@ export function startPlan({ root, typed, onPath }) {
115
154
  if (typed) return { cmd: typed, cwd: root };
116
155
  const pkg = json(join(root, "package.json"));
117
156
  const mono = Boolean(pkg?.workspaces) || existsSync(join(root, "pnpm-workspace.yaml")) || existsSync(join(root, "turbo.json")) || existsSync(join(root, "lerna.json"));
118
- const own = mono ? null : startOf(root, onPath);
157
+ // A root with both manifests: the side that names a model SDK is the app; the other is its page.
158
+ const rootPkg = json(join(root, "package.json"));
159
+ const rootPy = `${read(join(root, "requirements.txt"))}\n${read(join(root, "pyproject.toml"))}`;
160
+ const pythonFirst = Boolean(rootPkg) && PY_MODEL.test(rootPy) && !Object.keys({ ...(rootPkg.dependencies ?? {}), ...(rootPkg.devDependencies ?? {}) }).some((d) => MODEL_SDK.test(d));
161
+ const own = mono ? null : pythonFirst ? (pythonStart(root, onPath) ?? nodeStart(root, onPath)) : startOf(root, onPath);
119
162
  if (own) return { cmd: own.cmd, cwd: root };
120
163
  const ranked = workspaces(root).map((dir) => ({ dir, start: startOf(dir, onPath), weight: weight(dir, relative(root, dir)) })).filter((w) => w.start).sort((a, b) => b.weight - a.weight);
121
164
  const best = ranked[0];
package/lib/text.mjs CHANGED
@@ -50,8 +50,15 @@ function coverageLine(d) {
50
50
 
51
51
  function scoreLine(d) {
52
52
  const floor = has(d.decided) && d.decided < 22;
53
- return `${floor ? "Provisional score" : "Score"} ${num(d.score)} of 100${interval(d.ci)}${floor ? `, from ${plural(d.decided, "decided reading")}: under the 22-reading floor, so not a verdict yet` : ""}.`;
53
+ // The number never stands without its denominator: "93 of 100" read as broad assurance to four
54
+ // agents whose runs had measured a quarter of the checks.
55
+ const on = has(d.questionsAsked) && has(d.questionsOf) && d.questionsOf > 0 ? ` on the ${num(d.questionsAsked)} of ${plural(d.questionsOf, "check")} measured` : "";
56
+ return `${floor ? "Provisional score" : "Score"} ${num(d.score)} of 100${on}${interval(d.ci)}${floor ? `, from ${plural(d.decided, "decided reading")}: under the 22-reading floor, so not a verdict yet` : ""}.`;
54
57
  }
58
+ // A row the run held back names a door and why, or, when no door could take it, only the reason.
59
+ const heldBackLine = (h) => h.why === "not testable here"
60
+ ? `Held back: ${plural(h.trials, "trial")} cannot be played here: ${clip(h.surface, 200)}.`
61
+ : `Held back: ${plural(h.trials, "trial")} on ${h.surface}${h.why ? `, ${clip(h.why, 200)}` : ""}.`;
55
62
  const haltLine = (h) => `${h.side === "ours" ? "We lost your app" : "Your app stopped answering"} after ${plural(h.after, "turn")}${h.why ? `: ${clip(h.why, 200)}` : ""}. The score covers the turns before that.`;
56
63
  const LAYER_WORDS = {
57
64
  retrieval: "what the store returned behind the reply, not how the reply was written",
@@ -69,11 +76,12 @@ function runLines(d, prefix = "") {
69
76
  // Coverage before the number: a 97 beside 59 of 80 checks that never fired read as production-ready
70
77
  // to every agent that got it. What was measured leads, then the rate on what was measured.
71
78
  if (done && has(d.questionsAsked) && has(d.questionsOf) && d.questionsOf > 0) lines.push(coverageLine(d));
79
+ if (done && d.blocked) lines.push(d.blocked);
72
80
  if (done && typeof d.score === "number") lines.push(scoreLine(d));
73
81
  if (done && d.halted) lines.push(haltLine(d.halted));
74
82
  if (done && has(d.readingsHeld) && has(d.readings) && d.readings > 0) lines.push(`The question readings held in ${num(d.readingsHeld)} of ${num(d.readings)}, ${Math.round((100 * d.readingsHeld) / d.readings)}%; the score is the case checks' pass rate on the checks measured.`);
75
83
  if (done && has(d.findings)) lines.push(`${plural(d.findings, "finding")}.`);
76
- for (const h of (d.heldBack ?? []).slice(0, 6)) lines.push(`Held back: ${plural(h.trials, "trial")} on ${h.surface}${h.why ? `, ${clip(h.why, 200)}` : ""}.`);
84
+ for (const h of (d.heldBack ?? []).filter((h) => h.trials > 0).slice(0, 6)) lines.push(heldBackLine(h));
77
85
  const reads = readingsLine(d);
78
86
  if (reads) lines.push(reads);
79
87
  if (d.stopped) lines.push(stoppedLine(d));
@@ -182,7 +190,9 @@ export function linesOf(d) {
182
190
  export const findingsText = (d, page = 1) => pageOf(findingsPages(d), page, (n) => `findings with page ${n}`);
183
191
 
184
192
  function findingsHead(d) {
193
+ if (!d.runId) return ["No run has finished on this repository yet."];
185
194
  const lines = [`Run ${d.runId}: ${plural(d.findings?.length ?? 0, "finding")}.`];
195
+ if (d.blocked) lines.push(d.blocked);
186
196
  if (typeof d.score === "number") lines.push(scoreLine(d));
187
197
  if (d.halted) lines.push(haltLine(d.halted));
188
198
  const reads = readingsLine(d);
@@ -242,7 +252,10 @@ function findingBlock(f, placed) {
242
252
  const by = [f.decidedBy?.code && `code in ${plural(f.decidedBy.code, "reading")}`, f.decidedBy?.model && `a model in ${plural(f.decidedBy.model, "reading")}`].filter(Boolean);
243
253
  if (by.length) lines.push(` Decided by ${by.join(", by ")}.`);
244
254
  for (const q of (f.quotes ?? []).slice(0, 3)) {
245
- const about = [typeof q.p === "number" && `confidence ${q.p.toFixed(2)}`, q.trialId && `trial ${q.trialId}`].filter(Boolean).join(", ");
255
+ const about = [typeof q.p === "number" && `confidence ${q.p.toFixed(2)}`, q.trialId && `trial ${q.trialId}`, q.door && `at ${q.door}`].filter(Boolean).join(", ");
256
+ // The request beside the reply: an agent handed "HTTP 502 happened" did the rest of the
257
+ // investigation itself.
258
+ if (q.asked) lines.push(` Asked: "${clip(q.asked, 300)}"`);
246
259
  lines.push(` Reply ${q.reply}: "${clip(q.quote, 400)}"${about ? ` (${about})` : ""}`);
247
260
  }
248
261
  for (const line of (f.log ?? []).slice(0, 8)) lines.push(` Log: ${clip(line, 300)}`);
package/local.mjs CHANGED
@@ -370,8 +370,8 @@ async function verb(job) {
370
370
  // method", which is what your chat looked like from the outside. So the chain is walked the
371
371
  // way a browser walks it, one GET with redirects followed, and the request is sent again with
372
372
  // the session your app just handed out. The cookie is kept here and never leaves this machine.
373
- const url = `http://127.0.0.1:${port}${path}`;
374
- 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; } };
373
+ const url = hostUrl(port, path);
374
+ const yours = (to) => { try { const u = new URL(to, url); return ["127.0.0.1", "::1", "[::1]", "localhost"].includes(u.hostname) && u.port === String(port) ? u.href : null; } catch { return null; } };
375
375
  let jar = "";
376
376
  const sent = (at, over = {}) => fetch(at, {
377
377
  ...init, ...over,
@@ -448,10 +448,26 @@ async function verb(job) {
448
448
  const exec = promisify(execFile);
449
449
 
450
450
  // ---- start or attach to the app
451
+ // The address the app answers on: 127.0.0.1, or [::1] when it bound IPv6 localhost alone, which a
452
+ // Vite dev server does and which a probe of 127.0.0.1 waited three minutes on.
453
+ let appHost = "127.0.0.1";
454
+ const hostUrl = (port, path = "/") => `http://${appHost === "::1" ? "[::1]" : appHost}:${port}${path}`;
451
455
  const answers = async (port) => {
452
- try { await fetch(`http://127.0.0.1:${port}/`, { method: "GET", signal: AbortSignal.timeout(2500), redirect: "manual" }); return true; }
453
- catch { return false; }
456
+ for (const host of [appHost, appHost === "::1" ? "127.0.0.1" : "::1"]) {
457
+ try {
458
+ await fetch(`http://${host === "::1" ? "[::1]" : host}:${port}/`, { method: "GET", signal: AbortSignal.timeout(2500), redirect: "manual" });
459
+ appHost = host;
460
+ return true;
461
+ } catch { /* the other family next */ }
462
+ }
463
+ return false;
454
464
  };
465
+ // The values an env file beside the app declares, exported into its process the way `source .env`
466
+ // would: an app with no dotenv loader of its own crashed every route without them. Example files
467
+ // hold placeholders and are left out; the shell's own environment and the lifted limits win.
468
+ function envExports(envFiles) {
469
+ return envValues(envFiles.filter((f) => !/\.(example|sample)$/.test(f)));
470
+ }
455
471
  const onPath = (bin) => (process.env.PATH ?? "").split(":").some((dir) => dir && existsSync(join(dir, bin)));
456
472
  // Where their app lives inside this repository, and how it starts. Worked out in lib/start.mjs.
457
473
  let appDir = root;
@@ -516,7 +532,9 @@ async function ask(question) {
516
532
  let child = null;
517
533
  async function startApp() {
518
534
  const wanted = Number(flag("--port"));
519
- if (wanted) {
535
+ // "--port 8106 --start ..." is "start it, it will answer on 8106", not "it is already on 8106":
536
+ // read as the latter it said nothing was answering and never ran the command it was given.
537
+ if (wanted && !flag("--start")) {
520
538
  if (!(await answers(wanted))) return { port: null, said: "", why: `nothing is answering on port ${wanted}, so there is nothing to ask.`, noStart: true };
521
539
  const took = await takeOver(wanted);
522
540
  if (took) return took;
@@ -707,7 +725,7 @@ async function freed(cmd, lifted) {
707
725
 
708
726
  async function start(waitMs) {
709
727
  const { cmd, lifted } = await freed(launched.cmd, launched.lifted ?? {});
710
- 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 });
728
+ child = spawn("/bin/sh", ["-c", cmd], { cwd: appDir, env: { ...envExports(envFiles), ...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 });
711
729
  const mine = child;
712
730
  let seen = "";
713
731
  const onData = (d) => { const s = d.toString(); appendFileSync(bootLog, s); seen = (seen + s).slice(-20_000); lastSaid = seen; lastOutputAt = Date.now(); if (verbose) process.stdout.write(s); };
@@ -719,7 +737,10 @@ async function start(waitMs) {
719
737
  const started = Date.now();
720
738
  while (Date.now() - started < waitMs) {
721
739
  const tail = () => seen.replace(/\x1b\[[0-9;]*m/g, "").split("\n").filter(Boolean).slice(-25).join("\n");
722
- if (exited !== null) return { port: null, exited, tail: tail() };
740
+ // The port was taken: the app says so and exits at once, so this is read before the exit code,
741
+ // or a bed whose .env named a port another round still held printed "your app stopped" three times.
742
+ const taken = /EADDRINUSE|address already in use/i.test(seen);
743
+ if (exited !== null && !taken) return { port: null, exited, tail: tail() };
723
744
  // nodemon and its kind outlive the app they watch: the app is gone, the process is not, and
724
745
  // the wait ran its whole three minutes on one app with the reason sitting in the output.
725
746
  // Also: the port is taken, under a watcher that does not exit when its app cannot listen. The app
@@ -888,6 +909,7 @@ await exec("tar", ["-czf", archive, "-C", root, "-T", list]);
888
909
  const bytes = readFileSync(archive);
889
910
  const PART = 1_500_000;
890
911
  let resumed = false;
912
+ let moved = false;
891
913
  const parts = Math.max(1, Math.ceil(bytes.length / PART));
892
914
  for (let off = 0; off < bytes.length; off += PART) {
893
915
  const last = off + PART >= bytes.length;
@@ -899,12 +921,13 @@ for (let off = 0; off < bytes.length; off += PART) {
899
921
  if (!put.ok) fail(put.data?.error ?? `upload failed (${put.status})`);
900
922
  if (last) {
901
923
  resumed = put.data?.resumed === true;
924
+ moved = put.data?.moved === true;
902
925
  if (typeof put.data?.machineKey === "string" && put.data.machineKey) writeToken(project, put.data.machineKey);
903
926
  writeDigest(project, treeDigest);
904
927
  }
905
928
  }
906
929
  // Unchanged code has already been read: coming back says so instead of claiming a second read.
907
- stepDone(resumed ? "connected · nothing changed since last time" : "connected");
930
+ stepDone(moved ? "connected · your change is in; the read from before stands" : resumed ? "connected · nothing changed since last time" : "connected");
908
931
  // The coding agents on this machine learn about Cortad now, once: an MCP entry and a skill in each
909
932
  // one's own home folder. A run started by an agent later comes back through lib/cli.mjs.
910
933
  if (!viaToken) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortad",
3
- "version": "0.3.0-rc.10",
3
+ "version": "0.3.0-rc.12",
4
4
  "description": "Connects the AI app on your machine to Cortad for test conversations, and gives your coding agent the MCP and skill to run them. No dependencies.",
5
5
  "bin": {
6
6
  "cortad": "local.mjs"