cortad 0.3.5 → 0.3.7

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/reach.mjs CHANGED
@@ -21,7 +21,7 @@ const appUrl = (runner) => new URL(`http://${runner.host === "::1" ? "[::1]" : r
21
21
  // Why nothing can be sent to the app this record names, or null.
22
22
  export function notUp(runner) {
23
23
  if (runner?.state !== "up") {
24
- return !runner ? "No connect command is running for this repository, so no app is up on this machine."
24
+ return !runner ? "No connect command is running for this repository, so no app is up on this machine. Run npx cortad in this folder as a background command to connect it again."
25
25
  : runner.state === "failed" ? upper(runner.error) : "Your app is still starting.";
26
26
  }
27
27
  const host = runner.host ?? "127.0.0.1";
package/lib/replay.mjs CHANGED
@@ -461,7 +461,12 @@ export function makeAccounts(file = null) {
461
461
  let bare = null;
462
462
  if (file) {
463
463
  try {
464
- for (const a of JSON.parse(readFileSync(file, "utf8"))) {
464
+ const stored = JSON.parse(readFileSync(file, "utf8"));
465
+ // A visitor who is not signed in still sends what the app lets them in by (its Origin): ulaim
466
+ // refused every test request of a reconnect with 403, having been sent none of it.
467
+ const visitor = Array.isArray(stored) ? null : stored?.visitor;
468
+ if (visitor && typeof visitor === "object" && !signInKey(visitor)) bare = Object.fromEntries(Object.entries(visitor).filter(([k, v]) => typeof k === "string" && typeof v === "string"));
469
+ for (const a of Array.isArray(stored) ? stored : stored?.accounts ?? []) {
465
470
  if (kept.length >= ACCOUNTS_MAX || typeof a?.key !== "string" || !a.headers || typeof a.headers !== "object") continue;
466
471
  const headers = Object.fromEntries(Object.entries(a.headers).filter(([k, v]) => typeof k === "string" && typeof v === "string"));
467
472
  if (signInKey(headers) !== a.key || (identityOf(headers).expiresInS ?? 1) <= 0) continue;
@@ -473,7 +478,7 @@ export function makeAccounts(file = null) {
473
478
  if (!file) return;
474
479
  try {
475
480
  mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
476
- writeFileSync(file, JSON.stringify(kept.map((a) => ({ key: a.key, headers: a.headers, bodies: Object.fromEntries([...a.bodies].map(([door, leaves]) => [door, Object.fromEntries(leaves)])) }))), { mode: 0o600 });
481
+ writeFileSync(file, JSON.stringify({ visitor: bare, accounts: kept.map((a) => ({ key: a.key, headers: a.headers, bodies: Object.fromEntries([...a.bodies].map(([door, leaves]) => [door, Object.fromEntries(leaves)])) })) }), { mode: 0o600 });
477
482
  } catch { /* kept for this connect only */ }
478
483
  };
479
484
  const at = (role) => { const m = ACCOUNT_ROLE.exec(role); return m ? kept[Number(m[1] ?? 1) - 1] ?? kept[0] ?? null : null; };
@@ -481,7 +486,7 @@ export function makeAccounts(file = null) {
481
486
  // `door` and `body`: the request as that customer sent it, for the values in it that are theirs.
482
487
  keep(headers, door, body) {
483
488
  const key = signInKey(headers);
484
- if (!key) { bare = headers; return; }
489
+ if (!key) { bare = headers; save(); return; }
485
490
  let account = kept.find((a) => a.key === key);
486
491
  if (account) account.headers = headers;
487
492
  else {
package/lib/verbs.mjs CHANGED
@@ -16,7 +16,7 @@ import { fieldConnectText, fieldText, findingsText, finished, linesOf, notStarte
16
16
  // the run before them has its numbers in and its job is still ending: the server holds them until it
17
17
  // has, up to the same 45 seconds.
18
18
 
19
- const NOT_CONNECTED = "This folder is not connected to Cortad.\nFor the person: sign in at cortad.com and run the command the connect screen shows, in this folder.";
19
+ const NOT_CONNECTED = "This folder is not connected to Cortad. Run npx cortad in this folder as a background command: it prints a link for the person to approve.\nFor the person: approve the link npx cortad prints, in a browser where you are signed in to Cortad.";
20
20
  const HOLD_S = 45;
21
21
  // `wait`: how often it asks whether a run has started, how long the connect command may be gone
22
22
  // before no run can start, and how long it waits for one at all.
@@ -204,7 +204,7 @@ export function makeVerbs({ api, token, runner = () => null, fetchImpl = fetch,
204
204
  let goneAt = null;
205
205
  for (;;) {
206
206
  if (runner()) goneAt = null;
207
- else if (now() - (goneAt ??= now()) >= GONE_MS) return { text: "The connect command stopped, so no run can start from the browser.\nFor the person: run the connect command again in this folder.\nnext: status", isError: true };
207
+ else if (now() - (goneAt ??= now()) >= GONE_MS) return { text: "The connect command stopped, so no run can start from the browser. Run npx cortad in this folder again as a background command: it reconnects with the key it kept.\nnext: status", isError: true };
208
208
  if (now() - began >= IDLE_MS) return { text: `No run ended in 6 hours.\nnext: npx -y ${spec} wait` };
209
209
  const res = id ? await call("GET", `/mcp/run/${encodeURIComponent(id)}?wait=${HOLD_S}`, undefined, (HOLD_S + 13) * 1000) : await call("GET", "/mcp/status?quiet=1");
210
210
  // A key that no longer opens this repository, or a run that is gone, does not come back by asking again.
package/local.mjs CHANGED
@@ -61,7 +61,7 @@ if (unknownVerb(argv[0])) {
61
61
  const flag = (name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; };
62
62
  const verbose = argv.includes("--verbose");
63
63
  // A code is eight characters from the connect screen's alphabet, which has no I, O, 0 or 1.
64
- const code = (argv.find((a) => /^[A-HJ-NP-Za-hj-np-z2-9-]{8,9}$/.test(a) && !a.startsWith("-")) ?? "").toUpperCase().replace(/-/g, "");
64
+ let code = (argv.find((a) => /^[A-HJ-NP-Za-hj-np-z2-9-]{8,9}$/.test(a) && !a.startsWith("-")) ?? "").toUpperCase().replace(/-/g, "");
65
65
  const say = (line) => console.log(`cortad ${line}`);
66
66
  const fail = (line) => { console.error(`cortad ${line}`); process.exit(1); };
67
67
 
@@ -82,7 +82,10 @@ const step = (line) => {
82
82
  };
83
83
  const clearStep = () => { if (process.stdout.isTTY) process.stdout.write("\r\x1b[K"); };
84
84
  const stepDone = (line) => { clearStep(); say(line); };
85
- if (!explain && !viaToken && !/^[A-Z0-9]{8}$/.test(code)) fail(`usage: npx cortad <code from the connect screen> [--port N] [--start \"cmd\"] [--proxy] | npx cortad --explain | npx cortad ${[...VERBS, "wait"].join(" | ")}`);
85
+ // `npx cortad` alone connects too: with the key this folder kept from its last connect, or, with none,
86
+ // by one approval in the browser (pairing below). A word that is neither a code nor a flag is refused.
87
+ const words = argv.filter((a, i) => !a.startsWith("-") && !["--port", "--start"].includes(argv[i - 1] ?? ""));
88
+ if (!explain && !viaToken && words.length && !/^[A-Z0-9]{8}$/.test(code)) fail(`usage: npx cortad <code from the connect screen> [--port N] [--start \"cmd\"] [--proxy] | npx cortad --explain | npx cortad ${[...VERBS, "wait"].join(" | ")}`);
86
89
  // Where Brainsless is. The host is not on the command line: a code cannot point at an impostor.
87
90
  const origin = new URL(process.env.CORTAD_ORIGIN || "https://cortad.com");
88
91
  // Ours, and only ours. brainsless.com is the same service under its earlier name and stays trusted
@@ -300,6 +303,27 @@ const mask = (text) => maskWith(String(text ?? ""), secrets);
300
303
  // ---- the wire
301
304
  let box = "";
302
305
  let key = "";
306
+ // The browser approves this computer: a link to open where the person is signed in, and four
307
+ // characters the page shows too. The terminal waits, and is handed a code once it is approved.
308
+ async function paired() {
309
+ const machine = hostname().replace(/[^\w.-]/g, "-").slice(0, 80);
310
+ const opened = await call("POST", "/local/pair", { name: basename(root).replace(/[^\w.-]/g, "-").slice(0, 80), machine }).catch(unreachable);
311
+ if (!opened.ok) fail(opened.data?.error ?? `could not start connecting (${opened.status})`);
312
+ const link = `${origin.origin}/approve/${opened.data.id}`;
313
+ say(`approve this computer in your browser, where you are signed in to Cortad: ${link}`);
314
+ say(`the page shows ${opened.data.words} and ${machine}; approve only if both match`);
315
+ if (process.stdout.isTTY) {
316
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
317
+ try { spawn(opener, [link], { stdio: "ignore", detached: true }).on("error", () => {}).unref(); } catch { /* the link is printed */ }
318
+ }
319
+ for (const until = Date.now() + 10 * 60_000; Date.now() < until; await new Promise((r) => setTimeout(r, 2_000))) {
320
+ const got = await call("POST", `/local/pair/${opened.data.id}/collect`, { secret: opened.data.secret }).catch(() => null);
321
+ if (got?.ok && typeof got.data?.code === "string") { say("approved"); return got.data.code; }
322
+ if (got && got.status === 410) break;
323
+ }
324
+ fail("the link was not approved within ten minutes. Run npx cortad again for a new one.");
325
+ }
326
+
303
327
  async function call(method, path, body, { raw = false, timeoutMs = 60_000, headers = {} } = {}) {
304
328
  const res = await fetch(`${api}${path}`, {
305
329
  method,
@@ -410,7 +434,12 @@ async function verb(job) {
410
434
  if (role === CAPTURED || role.startsWith(`${CAPTURED}:`)) {
411
435
  spokenAs = role;
412
436
  // Speaking as one of the customers whose requests we watched: every header their own client sent.
413
- for (const [name, value] of Object.entries(capture?.headers(role) ?? {})) { for (const k of Object.keys(headers)) if (k.toLowerCase() === name) delete headers[k]; headers[name] = value; }
437
+ const theirs = capture?.headers(role);
438
+ 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; }
439
+ // No request of theirs is held (a new connect): sent from the app's own address, as Cortad's
440
+ // own test request is, so an app that lets visitors in by Origin lets this one in.
441
+ const from = !theirs && app ? ownOrigin(app.port) : null;
442
+ if (from && !Object.keys(headers).some((k) => k.toLowerCase() === "origin")) { headers.origin = from; headers.referer = `${from}/`; }
414
443
  } else {
415
444
  const held = await identities?.headerFor(role);
416
445
  if (held) { for (const k of Object.keys(headers)) if (k.toLowerCase() === held.name) delete headers[k]; headers[held.name] = held.value; }
@@ -1183,15 +1212,21 @@ if (!files.length) fail("no source files here to read.");
1183
1212
  try { copyFileSync(join(homeOf(project), "tools.json"), join(work, "tools.json")); } catch { /* no run has read this project's tools yet */ }
1184
1213
  // The key the last connect left for this project, if any: the token face signs in with it. A connect
1185
1214
  // from the screen always asks for a fresh one, since the code may belong to another account or site.
1186
- const stored = readToken(project);
1187
- if (viaToken && !stored) fail("this project has no stored key. Run the command from the connect screen once.");
1215
+ let stored = readToken(project);
1216
+ if (viaToken && !stored) fail("this project has no stored key. Run npx cortad in this folder once.");
1188
1217
  // A network that drops while connecting ends here in a sentence, never a stack trace: running the
1189
1218
  // command again starts a clean connection.
1190
1219
  const unreachable = (err) => fail(`could not reach ${origin.host}: ${err?.name === "TimeoutError" ? "it did not answer in time" : "the connection failed"}. Check your connection and run the command again.`);
1191
1220
  // The key goes up beside a code too: a code that has lapsed while this project's key still holds is
1192
1221
  // the same person coming back, and the server signs them in by the key instead of refusing.
1193
- const attach = await call("POST", "/local/attach", { ...(viaToken ? {} : { code }), name: basename(root), project },
1222
+ let attach = !code && !viaToken && !stored ? null : await call("POST", "/local/attach", { ...(viaToken ? {} : { code }), name: basename(root), project },
1194
1223
  stored ? { headers: { authorization: `Bearer ${stored}` } } : {}).catch(unreachable);
1224
+ // No code and no key that still signs in: the person approves this computer in their browser.
1225
+ if (!viaToken && !code && !attach?.ok) {
1226
+ if (attach && stored) { say("the key this folder kept no longer signs in, so this computer is connected again"); stored = null; }
1227
+ code = await paired();
1228
+ attach = await call("POST", "/local/attach", { code, name: basename(root), project }).catch(unreachable);
1229
+ }
1195
1230
  if (!attach.ok) fail(attach.data?.error ?? `could not sign in (${attach.status})`);
1196
1231
  box = attach.data.box;
1197
1232
  key = attach.data.key;
@@ -1239,7 +1274,7 @@ for (let off = 0; off < bytes.length; off += PART) {
1239
1274
  // The last part asks for this machine's key when none is stored yet: minted once the repository
1240
1275
  // row exists, kept in ~/.cortad for the runs a coding agent asks for on later days.
1241
1276
  const put = await call("PUT", `/local/${box}/tree?last=${last ? 1 : 0}${last ? `&digest=${treeDigest}${head ? `&head=${head}` : ""}` : ""}`, bytes.subarray(off, off + PART),
1242
- { raw: true, timeoutMs: 120_000, ...(last && !viaToken ? { headers: { "x-cortad-machine": hostname().slice(0, 80) } } : {}) }).catch(unreachable);
1277
+ { raw: true, timeoutMs: 120_000, ...(last && !viaToken && !stored ? { headers: { "x-cortad-machine": hostname().slice(0, 80) } } : {}) }).catch(unreachable);
1243
1278
  if (!put.ok) fail(put.data?.error ?? `upload failed (${put.status})`);
1244
1279
  if (last) {
1245
1280
  resumed = put.data?.resumed === true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortad",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
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"
package/skill/SKILL.md CHANGED
@@ -18,6 +18,13 @@ The tools are the `cortad` MCP tools. Where they are missing, each one is `npx {
18
18
 
19
19
  ## A session, start to finish
20
20
 
21
+ ## Connecting
22
+
23
+ `npx {{cortad}}` in the repository's folder connects it, with nothing to copy. Run it as a background command (in Claude Code: Bash with run_in_background) and leave it running: it starts the app and keeps it reachable from the browser until it is stopped.
24
+ - A folder connected before reconnects at once with the key it kept.
25
+ - A folder connected for the first time prints a link and four characters, good for ten minutes. Give the person both as printed; they approve the link in their browser, signed in to Cortad, and the command goes on by itself.
26
+ - When a result says no connect command is running, run `npx {{cortad}}` again in this folder. No code is needed from the person.
27
+
21
28
  The connect command prints "Go back to the browser" once it is connected. Cortad then finds the AI endpoints in the app's code and sends each one a test request itself, from this machine to the app on its port.
22
29
 
23
30
  1. Call `status` and tell the person, in a few plain lines: