cortad 0.3.4 → 0.3.6

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/proof.mjs CHANGED
@@ -440,10 +440,17 @@ function carriedFrom(later, earlier, ask) {
440
440
 
441
441
  const cookiesIn = (ex) => Object.fromEntries(str(headerOf(ex.headers, "cookie")).split(";").map((c) => c.trim().split("=")).filter(([k, v]) => k && v !== undefined).map(([k, ...v]) => [k, v.join("=")]));
442
442
 
443
+ // A sign-in is never the conversation. A cookie or a header a trial is sent without is one that only
444
+ // names the conversation: Databuddy's sign-in cookie, refreshed on every reply, was taken for one, and
445
+ // each trial sent without it was refused; an Authorization header that stayed the same would be too.
446
+ const READS_AS_CREDENTIAL = /auth|token|jwt|csrf|xsrf|secret|key|sid$|sessionid|connect\.sid|phpsessid|jsessionid|__session|__host-|__secure-|^sb-|clerk|supabase|laravel_session/i;
447
+ const READS_AS_CONVERSATION = /conv|thread|chat|dialog|session[-_]?id/i;
448
+ const namesConversation = (name) => READS_AS_CONVERSATION.test(name) && !READS_AS_CREDENTIAL.test(name);
449
+
443
450
  // What stayed the same from the earlier exchange to the later one and is not the ask: a field the app
444
451
  // handed back, a path segment, a field that reads as an id, a cookie the app set, a header, another
445
452
  // cookie. The first found carries the session.
446
- function carrierOf(earlier, later, ask, door) {
453
+ export function carrierOf(earlier, later, ask, door) {
447
454
  const same = (a, b) => a !== undefined && a !== "" && a === b;
448
455
  const before = new Map(leaves(bodyOf(earlier) ?? {}).map((l) => [l.at.join("."), l.value]));
449
456
  const own = leaves(bodyOf(later) ?? {}).filter((l) => !inList(l.at) && fieldOf(l.at) !== ask?.field);
@@ -466,13 +473,15 @@ function carrierOf(earlier, later, ask, door) {
466
473
  if (idField) return { carrier: "body", key: idField.at.join("."), minted: "client" };
467
474
  const jar = cookiesIn(later);
468
475
  const jarBefore = cookiesIn(earlier);
469
- // A cookie the app set on the earlier reply, sent back: the earlier request could not carry it.
470
- const set = Object.keys(jar).find((k) => earlier.cookies.includes(k));
476
+ // A cookie the app set on the earlier reply, sent back, which the earlier request could not carry.
477
+ // One that request already carried was only refreshed: a session the person signed in with, unless
478
+ // its name says it is the conversation.
479
+ const set = Object.keys(jar).find((k) => earlier.cookies.includes(k) && (jarBefore[k] === undefined || namesConversation(k)));
471
480
  if (set) return { carrier: "cookie", key: set, minted: "app" };
472
- const cookies = Object.keys(jar).filter((k) => same(jar[k], jarBefore[k]));
473
- const header = Object.keys(later.headers).find((k) => !HOP.test(k) && !PLAIN_HEADER.test(k) && same(later.headers[k], headerOf(earlier.headers, k.toLowerCase())));
481
+ const header = Object.keys(later.headers).find((k) => !HOP.test(k) && !PLAIN_HEADER.test(k) && namesConversation(k) && same(later.headers[k], headerOf(earlier.headers, k.toLowerCase())));
474
482
  if (header) return { carrier: "header", key: header.toLowerCase(), minted: "client" };
475
- if (cookies.length) return { carrier: "cookie", key: cookies[0], minted: "client" };
483
+ const cookie = Object.keys(jar).find((k) => namesConversation(k) && same(jar[k], jarBefore[k]));
484
+ if (cookie) return { carrier: "cookie", key: cookie, minted: "client" };
476
485
  return { carrier: "none", key: null };
477
486
  }
478
487
 
@@ -1353,11 +1353,55 @@ def _install():
1353
1353
  except Exception:
1354
1354
  return True
1355
1355
 
1356
- # A GraphQL query reads; a mutation writes. A `query` that is not GraphQL (SQL over HTTP) is held.
1357
- def reads_only(raw):
1358
- b = parsed(raw) if raw else None
1359
- q = b.get("query") if isinstance(b, dict) else None
1360
- return isinstance(q, str) and bool(re.match(r"^\s*(?:\{|query\b|fragment\b)", q)) and not re.search(r"\bmutation\b", q)
1356
+ # A GraphQL query reads; a mutation writes. A `query` in JSON that is not GraphQL (SQL over HTTP,
1357
+ # where a SELECT can write through a function) is held.
1358
+ # ClickHouse, Trino and Presto take every query as a POST of its text, a SELECT too, and a SELECT
1359
+ # there cannot write: one statement that starts as a read and holds no word that writes, sent to
1360
+ # what its address or its headers show is one of them, goes out (lib/trace.cjs does the same).
1361
+ sql_quoted = re.compile(r"""'(?:[^'\\]|\\.|'')*'|"(?:[^"\\]|\\.|"")*"|`[^`]*`|--[^\n]*|/\*[\s\S]*?\*/""")
1362
+ sql_shows = re.compile(r"^(?:show|describe|desc|exists)\b", re.I | re.A)
1363
+ sql_selects = re.compile(r"^(?:select|with|explain)\b", re.I | re.A)
1364
+ sql_writes = re.compile(r"(?<![.\w])(?:insert|update|delete|merge|into|create|alter|drop|truncate|refresh|call|execute)(?![.\w])", re.I | re.A)
1365
+ sql_hands_on = re.compile(r"\b(?:table|query|jdbc|odbc|executable)\s*\(", re.I | re.A)
1366
+ sql_open = re.compile(r"""[;'"`]|/\*|\$\$""")
1367
+ analytics_name = re.compile(r"clickhouse|trino|presto", re.I)
1368
+ analytics_headers = ("x-clickhouse-user", "x-clickhouse-key", "x-clickhouse-database", "x-clickhouse-format", "x-trino-user", "x-presto-user")
1369
+
1370
+ def statement_of(sql):
1371
+ bare = sql_quoted.sub(lambda m: "''" if m.group(0)[0] == "'" else "x" if m.group(0)[0] in '"`' else " ", str(sql or "")).strip()
1372
+ return re.sub(r";\s*$", "", bare)
1373
+
1374
+ # A quote or a comment left open, a second statement or a dollar-quoted body is not read as a read.
1375
+ def selects(bare):
1376
+ if sql_open.search(bare.replace("''", "")):
1377
+ return False
1378
+ return bool(sql_shows.match(bare)) or bool(sql_selects.match(bare) and not sql_writes.search(bare) and not sql_hands_on.search(bare))
1379
+
1380
+ def analytics(parts, header_of):
1381
+ try:
1382
+ port = parts.port
1383
+ except ValueError:
1384
+ port = None
1385
+ return port in (8123, 8443) or bool(analytics_name.search(parts.hostname or "")) or bool(analytics_name.search(str(header_of("user-agent") or ""))) \
1386
+ or any(header_of(name) for name in analytics_headers) or parts.path == "/v1/statement"
1387
+
1388
+ def reads_only(raw, url=None, header_of=None):
1389
+ sent = str(raw or "").strip()
1390
+ b = parsed(sent) if sent else None
1391
+ if b is not None:
1392
+ q = b.get("query") if isinstance(b, dict) else None
1393
+ return isinstance(q, str) and bool(re.match(r"^\s*(?:\{|query\b|fragment\b)", q)) and not re.search(r"\bmutation\b", q)
1394
+ try:
1395
+ parts = urlsplit(str(url)) if url else None
1396
+ if parts is None or not analytics(parts, header_of or (lambda name: None)):
1397
+ return False
1398
+ # ClickHouse joins a `query` in the address to the body: both are read as the one statement.
1399
+ asked = "%s %s" % (dict(parse_qsl(parts.query)).get("query", ""), sent)
1400
+ # ClickHouse reads a backslash before a quote as part of the string and Trino as its end,
1401
+ # so where a string stops is not known here: held.
1402
+ return not re.search(r"""\\['"`]""", asked) and selects(statement_of(asked))
1403
+ except Exception:
1404
+ return False
1361
1405
 
1362
1406
  held_count = itertools.count(1)
1363
1407
 
@@ -1381,7 +1425,7 @@ def _install():
1381
1425
  return
1382
1426
  held_said.add((method, host))
1383
1427
  try:
1384
- sys.stderr.write("cortad: a request Cortad sent made this app send %s to %s. Cortad kept that call on this machine and answered it with a stand-in success, since it could change something real. A failure right after this line comes from that stand-in, not from your code. With that service's test key the call goes out as sent.\n" % (method, host))
1428
+ sys.stderr.write("cortad: a request Cortad sent made this app send %s to %s. Cortad kept that call on this machine and answered it with a stand-in success, since it could change something real. A failure right after this line may come from that stand-in rather than from your code. With that service's test key the call goes out as sent.\n" % (method, host))
1385
1429
  except Exception:
1386
1430
  pass
1387
1431
 
@@ -2426,7 +2470,7 @@ def _install():
2426
2470
 
2427
2471
  # A held call that cannot be answered fails in the app; it is never sent instead.
2428
2472
  def held_httpx(request, sent):
2429
- if not held_here(request.url, request.method, lambda: keys_in(request.headers)) or reads_only(sent):
2473
+ if not held_here(request.url, request.method, lambda: keys_in(request.headers)) or reads_only(sent, request.url, request.headers.get):
2430
2474
  return None
2431
2475
  kind = request.headers.get("content-type", "")
2432
2476
  held_row(request.url, request.method, sent, kind, ctx.get())
@@ -2489,7 +2533,7 @@ def _install():
2489
2533
  if not held_here(request.url, request.method, lambda: keys_in(request.headers)):
2490
2534
  return None
2491
2535
  raw = text(request.body, reply_max)
2492
- if reads_only(raw):
2536
+ if reads_only(raw, request.url, request.headers.get):
2493
2537
  return None
2494
2538
  import datetime
2495
2539
  models, structures = sys.modules["requests.models"], sys.modules["requests.structures"]
@@ -2540,6 +2584,13 @@ def _install():
2540
2584
  basic = k.get("auth") or getattr(self, "_default_auth", None)
2541
2585
  return [given.get(n) or defaults.get(n) for n in key_headers] + [basic.encode() if basic else None]
2542
2586
 
2587
+ def header_of(self, k, name):
2588
+ for headers in (k.get("headers"), getattr(self, "_default_headers", None)):
2589
+ for n, v in dict(headers or {}).items():
2590
+ if str(n).lower() == name:
2591
+ return v
2592
+ return None
2593
+
2543
2594
  # A path relative to the session's base address is decided on the whole address.
2544
2595
  def whole(self, str_or_url):
2545
2596
  try:
@@ -2550,7 +2601,7 @@ def _install():
2550
2601
  async def requested(self, method, str_or_url, *a, **k):
2551
2602
  body = k.get("json") if k.get("json") is not None else k.get("data")
2552
2603
  url = whole(self, str_or_url)
2553
- if held_here(url, method, lambda: keys_of(self, k)) and not reads_only(text(body, reply_max)):
2604
+ if held_here(url, method, lambda: keys_of(self, k)) and not reads_only(text(body, reply_max), url, lambda name: header_of(self, k, name)):
2554
2605
  port = held_port()
2555
2606
  if port is None:
2556
2607
  raise module.ClientConnectionError("this write was kept from leaving and could not be answered")
package/lib/replay.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  // reads what the hook wrote, keeps the sign-in each request carried on this machine, and tells the
3
3
  // cloud what each door is proven to do (lib/proof.mjs): the route, the method, the body, the header
4
4
  // names and what the requests did inside the app. Header values never leave.
5
- import { copyFileSync, existsSync, mkdtempSync, readFileSync, statSync } from "node:fs";
5
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs";
6
6
  import { SourceMap } from "node:module";
7
7
  import { tmpdir } from "node:os";
8
8
  import { dirname, isAbsolute, join, relative } from "node:path";
@@ -15,15 +15,17 @@ const HOOK = join(HERE, "trace.cjs");
15
15
  const PYHOOK = join(HERE, "pyhook");
16
16
  export const CAPTURED = "captured";
17
17
  // Never replayed: they describe one connection, not the caller.
18
+ const SECRET_HEADER = /authorization|cookie|token|secret|session|csrf|api-?key/i;
18
19
  const HOP = /^(?:host|content-length|connection|keep-alive|transfer-encoding|upgrade|expect|te|trailer|accept-encoding|x-cortad-as|x-cortad-turn)$/i;
19
20
 
20
21
  // `files` lists the repository's source, relative to `root`, for placing a problem at its line;
21
22
  // `appFolder` is the folder the app runs in, relative to `root`. `onProof` gets one payload per door,
22
23
  // again whenever what it proves changes. `printed(from, to)` is what the app printed between two times.
23
- export function makeCapture({ work, keepSecret, onProof = () => {}, writes = null, root = null, files = () => [], appFolder = () => "", printed = () => "" }) {
24
+ export function makeCapture({ work, keepSecret, onProof = () => {}, writes = null, root = null, files = () => [], appFolder = () => "", printed = () => "", signIns = null }) {
24
25
  const file = join(work, "trace.jsonl");
25
26
  let read = 0;
26
- const accounts = makeAccounts();
27
+ const accounts = makeAccounts(signIns);
28
+ for (const headers of accounts.kept()) for (const [k, v] of Object.entries(headers)) if (SECRET_HEADER.test(k)) keepSecret(String(v).replace(/^Bearer\s+/i, ""));
27
29
  let alive = false;
28
30
  // The ports a hooked process listens on. Loading is not listening: turbo's own Node launcher loads
29
31
  // the hook, and one app was told we would see its messages while its API on Bun carried none.
@@ -118,7 +120,7 @@ export function makeCapture({ work, keepSecret, onProof = () => {}, writes = nul
118
120
  if (typeof row.ex !== "string" || typeof row.method !== "string" || typeof row.path !== "string" || !row.path.startsWith("/")) continue;
119
121
  for (const [door, r] of taken) if (r.ex === row.ex) taken.delete(door);
120
122
  const headers = Object.fromEntries(Object.entries(row.headers ?? {}).filter(([k, v]) => !HOP.test(k) && typeof v === "string"));
121
- for (const [k, v] of Object.entries(headers)) if (/authorization|cookie|token|secret|session|csrf|api-?key/i.test(k)) keepSecret(String(v).replace(/^Bearer\s+/i, ""));
123
+ for (const [k, v] of Object.entries(headers)) if (SECRET_HEADER.test(k)) keepSecret(String(v).replace(/^Bearer\s+/i, ""));
122
124
  // Speaking as the person means their own client's headers, never a trial's. Cortad's own test
123
125
  // request (reach:*) carries the page and sign-in this machine found for the app, so a run speaks
124
126
  // with them where no request of the person's came: without it every run request lost the Origin
@@ -451,15 +453,40 @@ function leavesOf(body) {
451
453
  try { walk(typeof body === "string" ? JSON.parse(body) : body, "", 0); } catch { /* not JSON: nothing of theirs to read */ }
452
454
  return out;
453
455
  }
454
- export function makeAccounts() {
456
+ // `file`: where the accounts are kept between connects, on this machine and readable by this user
457
+ // alone. Databuddy's agent signed in once per connect, and every reconnect asked for it again.
458
+ // A sign-in that has ended is not read back.
459
+ export function makeAccounts(file = null) {
455
460
  const kept = [];
456
461
  let bare = null;
462
+ if (file) {
463
+ try {
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 ?? []) {
470
+ if (kept.length >= ACCOUNTS_MAX || typeof a?.key !== "string" || !a.headers || typeof a.headers !== "object") continue;
471
+ const headers = Object.fromEntries(Object.entries(a.headers).filter(([k, v]) => typeof k === "string" && typeof v === "string"));
472
+ if (signInKey(headers) !== a.key || (identityOf(headers).expiresInS ?? 1) <= 0) continue;
473
+ kept.push({ key: a.key, headers, bodies: new Map(Object.entries(a.bodies ?? {}).map(([door, leaves]) => [door, new Map(Object.entries(leaves ?? {}))])) });
474
+ }
475
+ } catch { /* none kept, or not ours to read: the next signed-in request is taken */ }
476
+ }
477
+ const save = () => {
478
+ if (!file) return;
479
+ try {
480
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
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 });
482
+ } catch { /* kept for this connect only */ }
483
+ };
457
484
  const at = (role) => { const m = ACCOUNT_ROLE.exec(role); return m ? kept[Number(m[1] ?? 1) - 1] ?? kept[0] ?? null : null; };
458
485
  return {
459
486
  // `door` and `body`: the request as that customer sent it, for the values in it that are theirs.
460
487
  keep(headers, door, body) {
461
488
  const key = signInKey(headers);
462
- if (!key) { bare = headers; return; }
489
+ if (!key) { bare = headers; save(); return; }
463
490
  let account = kept.find((a) => a.key === key);
464
491
  if (account) account.headers = headers;
465
492
  else {
@@ -470,6 +497,7 @@ export function makeAccounts() {
470
497
  else return;
471
498
  }
472
499
  if (door) account.bodies.set(door, leavesOf(body));
500
+ save();
473
501
  },
474
502
  kept: () => kept.map((a) => a.headers),
475
503
  speak(role) {
package/lib/start.mjs CHANGED
@@ -239,3 +239,11 @@ export function installPlan(dir, onPath, root = dir) {
239
239
  if (existsSync(join(dir, "pyproject.toml"))) return into("-e .");
240
240
  return null;
241
241
  }
242
+
243
+ // The start command as it leaves this machine: a value typed into it (DATABASE_URL=...) stays here
244
+ // unless it is a plain word, and it is cut to the length the server keeps. A command past that
245
+ // length was refused whole, as "port is required", with a valid port beside it.
246
+ export const SHOWN_COMMAND = 200;
247
+ export const shownCommand = (cmd, mask = (text) => text) => (cmd == null ? cmd
248
+ : mask(String(cmd)).replace(/\b([A-Za-z_][A-Za-z0-9_]*)=("[^"]*"|'[^']*'|\S+)/g, (whole, name, value) => (/^[\w.-]{1,24}$/.test(value) ? whole : `${name}=***`)).slice(0, SHOWN_COMMAND));
249
+
package/lib/trace.cjs CHANGED
@@ -807,8 +807,43 @@ if (FILE) {
807
807
  return !passNow().some((p) => host === p || host.endsWith("." + p));
808
808
  } catch { return true; }
809
809
  };
810
- // A GraphQL query reads; a mutation writes. A `query` that is not GraphQL (Neon's SQL) is held.
811
- const readsOnly = (text) => { try { const q = JSON.parse(text)?.query; return typeof q === "string" && /^\s*(?:\{|query\b|fragment\b)/.test(q) && !/\bmutation\b/.test(q); } catch { return false; } };
810
+ // A GraphQL query reads; a mutation writes. A `query` in JSON that is not GraphQL (Neon's SQL, where
811
+ // a SELECT can write through a function) is held.
812
+ // ClickHouse, Trino and Presto take every query as a POST of its text, a SELECT too, and a SELECT
813
+ // there cannot write: one statement that starts as a read and holds no word that writes, sent to
814
+ // what its address or its headers show is one of them, goes out. Anything this cannot read as that
815
+ // stays held. Databuddy's agent had 45 replies come back empty while its own ClickHouse queries
816
+ // were answered here with a stand-in.
817
+ const SQL_QUOTED = /'(?:[^'\\]|\\.|'')*'|"(?:[^"\\]|\\.|"")*"|`[^`]*`|--[^\n]*|\/\*[\s\S]*?\*\//g;
818
+ // SHOW, DESCRIBE and EXISTS read whatever follows them. A SELECT, a WITH or an EXPLAIN reads
819
+ // unless it holds a word that writes inside it (a CTE that deletes, SELECT INTO, EXPLAIN ANALYZE
820
+ // of an insert) or hands its text on to another database to run.
821
+ const SQL_SHOWS = /^(?:show|describe|desc|exists)\b/i, SQL_SELECTS = /^(?:select|with|explain)\b/i;
822
+ const SQL_WRITES = /(?<![.\w])(?:insert|update|delete|merge|into|create|alter|drop|truncate|refresh|call|execute)(?![.\w])/i;
823
+ const SQL_HANDS_ON = /\b(?:table|query|jdbc|odbc|executable)\s*\(/i;
824
+ const statementOf = (sql) => String(sql || "").replace(SQL_QUOTED, (m) => (m[0] === "'" ? "''" : /^["`]/.test(m) ? "x" : " ")).trim().replace(/;\s*$/, "");
825
+ // A quote or a comment left open, a second statement or a dollar-quoted body is not read as a read.
826
+ const whole = (bare) => !/[;'"`]|\/\*|\$\$/.test(bare.replace(/''/g, ""));
827
+ const selects = (bare) => whole(bare) && (SQL_SHOWS.test(bare) || (SQL_SELECTS.test(bare) && !SQL_WRITES.test(bare) && !SQL_HANDS_ON.test(bare)));
828
+ const ANALYTICS_NAME = /clickhouse|trino|presto/i;
829
+ const ANALYTICS_HEADERS = ["x-clickhouse-user", "x-clickhouse-key", "x-clickhouse-database", "x-clickhouse-format", "x-trino-user", "x-presto-user"];
830
+ const analytics = (u, headerOf, port) => /^(?:8123|8443)$/.test(String(port || u.port)) || ANALYTICS_NAME.test(u.hostname) || ANALYTICS_NAME.test(headerOf("user-agent") || "")
831
+ || ANALYTICS_HEADERS.some((name) => headerOf(name)) || u.pathname === "/v1/statement";
832
+ const readsOnly = (text, u, headerOf = () => "", port) => {
833
+ const sent = String(text || "").trim();
834
+ try { const q = JSON.parse(sent)?.query; return typeof q === "string" && /^\s*(?:\{|query\b|fragment\b)/.test(q) && !/\bmutation\b/.test(q); } catch { /* not JSON: a statement sent as text */ }
835
+ if (!u || !analytics(u, headerOf, port)) return false;
836
+ // ClickHouse joins a `query` in the address to the body: both are read as the one statement.
837
+ const asked = `${u.searchParams.get("query") || ""} ${sent}`;
838
+ // ClickHouse reads a backslash before a quote as part of the string and Trino as its end, so
839
+ // where a string stops is not known here: held.
840
+ // ponytail: a ClickHouse read with an escaped quote in a string is held too; told apart by which
841
+ // database answered if that is ever seen.
842
+ return !/\\['"`]/.test(asked) && selects(statementOf(asked));
843
+ };
844
+ // A call passed on from this hook's own server, after its body was read: never held again.
845
+ const PASSED = Symbol("cortad.passed");
846
+ const passedOn = (args) => { const o = typeof args[0] === "string" || args[0] instanceof URL ? args[1] : args[0]; return Boolean(o && o[PASSED]); };
812
847
  let heldCount = 0;
813
848
  const fieldsOf = (text, type) => {
814
849
  try { const b = /form/i.test(type || "") ? Object.fromEntries(new URLSearchParams(text)) : JSON.parse(text); return b && typeof b === "object" && !Array.isArray(b) ? b : {}; } catch { return {}; }
@@ -821,7 +856,7 @@ if (FILE) {
821
856
  const heldSay = (method, host) => {
822
857
  if (heldSaid.has(`${method} ${host}`)) return;
823
858
  heldSaid.add(`${method} ${host}`);
824
- try { process.stderr.write(`cortad: a request Cortad sent made this app send ${method} to ${host}. Cortad kept that call on this machine and answered it with a stand-in success, since it could change something real. A failure right after this line comes from that stand-in, not from your code. With that service's test key the call goes out as sent.\n`); } catch { /* no stderr */ }
859
+ try { process.stderr.write(`cortad: a request Cortad sent made this app send ${method} to ${host}. Cortad kept that call on this machine and answered it with a stand-in success, since it could change something real. A failure right after this line may come from that stand-in rather than from your code. With that service's test key the call goes out as sent.\n`); } catch { /* no stderr */ }
825
860
  };
826
861
  // Named by method and host only: a webhook's path is its credential.
827
862
  const heldRow = (u, method, text, type, ctx) => { heldSay(method, u.hostname); row({ dep: { at: Date.now(), host: u.hostname, status: 200, held: true, ...(ctx ? { ex: ctx.id, turn: ctx.turn } : {}), called: [{ name: `${method} ${u.hostname}`.slice(0, 80), arguments: argsText(fieldsOf(text, type)) }] } }); };
@@ -841,7 +876,22 @@ if (FILE) {
841
876
  const id = String(req.headers["x-cortad-held"] || "");
842
877
  const w = heldWaiting.get(id);
843
878
  heldWaiting.delete(id);
844
- const text = Buffer.concat(parts).toString("utf8").slice(0, MAX);
879
+ const sent = Buffer.concat(parts);
880
+ const text = sent.toString("utf8").slice(0, MAX);
881
+ // A body that only reads, known now that it is here, goes on to where it was sent, as it was
882
+ // sent, and its answer comes back through this server.
883
+ if (w && parts.length < 256 && readsOnly(text, w.u, (name) => String(req.headers[name] || ""), w.port)) {
884
+ const { "x-cortad-held": _held, host: _host, connection: _connection, ...headers } = req.headers;
885
+ const given = typeof w.args[0] === "string" || w.args[0] instanceof URL;
886
+ const options = { ...((given ? w.args[1] : w.args[0]) || {}), headers, signal: undefined, [PASSED]: true };
887
+ // Sent inside the request that asked, so the call is recorded behind that reply.
888
+ const send = () => (w.secure ? https : http).request(...(given ? [w.args[0], options] : [options]));
889
+ const up = w.ctx ? als.run(w.ctx, send) : send();
890
+ up.on("response", (got) => { res.writeHead(got.statusCode || 502, got.headers); got.pipe(res); });
891
+ up.on("error", () => { if (!res.headersSent) res.writeHead(502); res.end(); });
892
+ res.on("close", () => up.destroy());
893
+ return up.end(sent);
894
+ }
845
895
  if (w) heldRow(w.u, w.method, text, req.headers["content-type"], w.ctx);
846
896
  res.writeHead(200, { "content-type": "application/json" });
847
897
  res.end(heldReply(text, req.headers["content-type"]));
@@ -867,12 +917,12 @@ if (FILE) {
867
917
  const method = String(o.method || "GET").toUpperCase();
868
918
  const u = new URL(`${secure ? "https" : "http"}://${host.includes(":") && !host.startsWith("[") ? `[${host}]` : host}${path.startsWith("/") ? path : "/" + path}`);
869
919
  const headerOf = (name) => headerIn(o.headers, name) || (name === "authorization" && o.auth ? `Basic ${Buffer.from(String(o.auth)).toString("base64")}` : "");
870
- return heldHere(u, method, headerOf) ? { u, method, o, cb: args.find((a) => typeof a === "function") } : null;
920
+ return heldHere(u, method, headerOf) ? { u, method, o, args: args.filter((a) => typeof a !== "function"), port: o.port || (given && given.port) || "", cb: args.find((a) => typeof a === "function") } : null;
871
921
  };
872
922
  const httpRequest = http.request;
873
- const sendHeld = ({ u, method, o, cb }, secure) => {
923
+ const sendHeld = ({ u, method, o, cb, args, port }, secure) => {
874
924
  const id = String(++heldSent);
875
- heldWaiting.set(id, { u, method, ctx: als.getStore() });
925
+ heldWaiting.set(id, { u, method, ctx: als.getStore(), args, port, secure });
876
926
  const headers = { ...(o.headers && typeof o.headers === "object" && !Array.isArray(o.headers) ? o.headers : {}), "x-cortad-held": id };
877
927
  // Port 1 answers nothing: a write held before the server listens fails instead of leaving.
878
928
  const req = httpRequest({ host: "127.0.0.1", port: heldPort || 1, method, path: u.pathname + u.search, headers, ...(o.timeout ? { timeout: o.timeout } : {}), ...(o.signal ? { signal: o.signal } : {}) }, cb);
@@ -883,7 +933,7 @@ if (FILE) {
883
933
  const heldFetch = async (realFetch, input, init) => {
884
934
  const req = new Request(input, init);
885
935
  const text = await req.clone().text().catch(() => "");
886
- if (readsOnly(text)) return realFetch(req);
936
+ if (readsOnly(text, new URL(req.url), (name) => req.headers.get(name) || "")) return realFetch(req);
887
937
  const u = new URL(req.url);
888
938
  heldRow(u, req.method, text, req.headers.get("content-type"), als.getStore());
889
939
  return new Response(heldReply(text, req.headers.get("content-type")), { status: 200, headers: { "content-type": "application/json" } });
@@ -945,7 +995,7 @@ if (FILE) {
945
995
  const request = mod.request;
946
996
  mod.request = function (...args) {
947
997
  let held = null;
948
- try { held = heldCall(args, mod === https); } catch { /* not a call we can read: sent as asked */ }
998
+ try { held = passedOn(args) ? null : heldCall(args, mod === https); } catch { /* not a call we can read: sent as asked */ }
949
999
  if (held) return sendHeld(held, mod === https);
950
1000
  const req = request.apply(this, args);
951
1001
  try {
package/local.mjs CHANGED
@@ -38,7 +38,7 @@ import { CAPTURED, makeCapture } from "./lib/replay.mjs";
38
38
  import { sampleHere } from "./lib/sample.mjs";
39
39
  import { mintAcross, originFor, servicePort, waitForPort } from "./lib/service.mjs";
40
40
  import { listingUrl } from "./lib/listing.mjs";
41
- import { hookable, installPlan, missingDependency, startPlan, workspaces } from "./lib/start.mjs";
41
+ import { hookable, installPlan, missingDependency, shownCommand, startPlan, workspaces } from "./lib/start.mjs";
42
42
  import { openProxy } from "./lib/proxy.mjs";
43
43
  import { liftedLimits, sessionLifts } from "./lib/switches.mjs";
44
44
  import { makeKeeping } from "./lib/keeping.mjs";
@@ -410,7 +410,12 @@ async function verb(job) {
410
410
  if (role === CAPTURED || role.startsWith(`${CAPTURED}:`)) {
411
411
  spokenAs = role;
412
412
  // 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; }
413
+ const theirs = capture?.headers(role);
414
+ 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; }
415
+ // No request of theirs is held (a new connect): sent from the app's own address, as Cortad's
416
+ // own test request is, so an app that lets visitors in by Origin lets this one in.
417
+ const from = !theirs && app ? ownOrigin(app.port) : null;
418
+ if (from && !Object.keys(headers).some((k) => k.toLowerCase() === "origin")) { headers.origin = from; headers.referer = `${from}/`; }
414
419
  } else {
415
420
  const held = await identities?.headerFor(role);
416
421
  if (held) { for (const k of Object.keys(headers)) if (k.toLowerCase() === held.name) delete headers[k]; headers[held.name] = held.value; }
@@ -663,6 +668,10 @@ async function ask(question) {
663
668
  return line.trim() || null;
664
669
  }
665
670
  let child = null;
671
+ // An app this command did not start carries nothing that counts its model calls, and no run plays
672
+ // against it (the API holds Run): databuddy's free run played 39 conversations into a route that
673
+ // answers from code.
674
+ const UNSEEN = "your app was already running, so Cortad cannot see the calls it makes to its model, and no run can start.";
666
675
  async function startApp() {
667
676
  const wanted = Number(flag("--port"));
668
677
  // "--port 8106 --start ..." is "start it, it will answer on 8106", not "it is already on 8106":
@@ -671,11 +680,10 @@ async function startApp() {
671
680
  if (!(await answers(wanted))) return { port: null, said: "", why: `nothing is answering on port ${wanted}, so there is nothing to ask.`, noStart: true };
672
681
  const took = await takeOver(wanted);
673
682
  if (took) return took;
674
- 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");
675
683
  if (proxyAsked) {
676
684
  await useProxy();
677
- say(`to have its model calls seen, start your app with: ${Object.entries(proxy.env({ ...envExports(envFiles), ...process.env })).map(([k, v]) => `${k}=${v}`).join(" ")}`);
678
- }
685
+ 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(envFiles), ...process.env })).map(([k, v]) => `${k}=${v}`).join(" ")}`);
686
+ } else say(`${UNSEEN} Stop it and run this again without --port: this command starts your app itself.`);
679
687
  return { port: wanted, cmd: null };
680
688
  }
681
689
  const plan = startPlan({ root, typed: flag("--start"), onPath });
@@ -718,7 +726,7 @@ async function startApp() {
718
726
  await keeping.forget();
719
727
  const took = await takeOver(held.port);
720
728
  if (took) return took;
721
- say(`your app is already running on port ${held.port}, so that one is used; its request limits stay as they are`);
729
+ say(`${UNSEEN} Stop the one on port ${held.port} and run this again: this command starts your app itself.`);
722
730
  return { port: held.port, cmd: null };
723
731
  }
724
732
  }
@@ -1170,7 +1178,7 @@ identities = makeIdentities({ root, work, envFiles, sourceFiles: () => files, sa
1170
1178
  // A real request that reached their model proves its door (lib/proof.mjs). What it proved goes up,
1171
1179
  // masked like everything else, and nothing goes up that the mask could not read; the sign-in each
1172
1180
  // request carried stays here.
1173
- capture = makeCapture({ work, keepSecret, writes: join(homeOf(projectOf(root)), "writes"), root, files: () => files, appFolder: () => relative(root, appDir), printed: printedBetween, onProof: (proof) => {
1181
+ capture = makeCapture({ work, keepSecret, signIns: join(homeOf(projectOf(root)), "sign-ins.json"), writes: join(homeOf(projectOf(root)), "writes"), root, files: () => files, appFolder: () => relative(root, appDir), printed: printedBetween, onProof: (proof) => {
1174
1182
  let masked;
1175
1183
  try { masked = JSON.parse(mask(JSON.stringify(proof))); } catch { return; }
1176
1184
  call("POST", `/local/${box}/proof`, masked).catch(() => {});
@@ -1288,7 +1296,7 @@ const announce = async () => {
1288
1296
  // 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.
1289
1297
  const hooked = Boolean(proxy ? launched || proxy.seen() : launched && capture?.watching(app.port));
1290
1298
  const started = Boolean(launched);
1291
- const told = await call("POST", `/local/${box}/app`, { port: doorPort(), cmd: app.cmd, origins: envOrigins(envFiles), lifted: app.lifted ?? [], data: (await keeping.lines(started)).map(mask), blocked: await keeping.blocked(started), watching: hooked, metered: hooked, proves: hooked });
1299
+ 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 });
1292
1300
  if (told.ok) await allowOutbound(told.data?.consent);
1293
1301
  return told;
1294
1302
  };
@@ -1337,7 +1345,7 @@ async function appLife(firstStart) {
1337
1345
  clearStep();
1338
1346
  if (!told.ok) return quit(told.data?.error ?? `could not register your app (${told.status})`);
1339
1347
  machineOf = () => (app ? {
1340
- cmd: app.cmd ?? "", dir: relative(root, appDir) || ".", port: app.port, started: Boolean(launched),
1348
+ cmd: shownCommand(app.cmd, mask) ?? "", dir: relative(root, appDir) || ".", port: app.port, started: Boolean(launched),
1341
1349
  hooked: Boolean(launched && !proxy && capture?.watching(app.port)), proxy: proxy?.port ?? null,
1342
1350
  changed: [...new Set([...(app.lifted ?? []), ...Object.keys(keeping.envNow())])],
1343
1351
  copies: Object.keys(keeping.envNow()).length ? "made" : "none",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortad",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
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"