cortad 0.3.3 → 0.3.5

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.
@@ -34,6 +34,10 @@ const STATUSES = new Set(["minted", "sealed", "absent", "refused", "unreachable"
34
34
  // so USER_USERNAME pairs with USER_PASSWORD and TUDUDI_USER_EMAIL with TUDUDI_USER_PASSWORD.
35
35
  const USER_TOKEN = /_(?:USERNAME|USER|EMAIL|LOGIN)$/;
36
36
  const SECRET_TOKEN = /_(?:PASSWORD|PASS|SECRET)$/;
37
+ // A pair that opens a store or a mail server is never the app's own account: PG_DB_USER with
38
+ // PG_DB_PASSWORD signs in to Postgres, and offered to a sign-in form it sent a database password
39
+ // to the app's login route.
40
+ const SERVICE_PREFIX = /(?:^|_)(?:DB|DATABASE|SQL|PG|PSQL|POSTGRES(?:QL)?|MYSQL|MARIADB|MSSQL|MONGO(?:DB)?|REDIS|VALKEY|ELASTIC(?:SEARCH)?|OPENSEARCH|ES|QDRANT|NEO4J|CLICKHOUSE|RABBIT(?:MQ)?|AMQP|KAFKA|SMTP|MAIL|EMAIL|S3|MINIO|FTP|SFTP|LDAP|PROXY)(?:_|$)/;
37
41
  const CRED_USER_FIELD = /^(?:e-?mail|user(?:_?name)?|login|account|identifier)$/i;
38
42
  const CRED_PASS_FIELD = /^(?:password|passcode|pass|pwd)$/i;
39
43
  const SIGN_IN_ROUTE = /(?:^|\/)(?:login|signin|sign-in|sign_in|session|token|authenticate)(?:\/|$)/i;
@@ -586,7 +590,7 @@ export function credentialPairs(names) {
586
590
  const seen = new Set();
587
591
  for (const u of users) {
588
592
  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 }); }
593
+ if (s && !seen.has(u.prefix) && !SERVICE_PREFIX.test(u.prefix)) { seen.add(u.prefix); pairs.push({ user: u.name, secret: s.name, prefix: u.prefix }); }
590
594
  }
591
595
  return pairs;
592
596
  }
package/lib/keeping.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  import { createHash } from "node:crypto";
6
6
  import { localAddresses, makeBacking, tcpOpen } from "./backing.mjs";
7
7
  import { keepData } from "./data.mjs";
8
- import { bypassedOf, isStorePort, onThisMachine, storeId, storeKeeper, storesOf, unnamedLines } from "./stores.mjs";
8
+ import { bypassedOf, isStorePort, onThisMachine, storeId, storeKeeper, storesOf, unnamedHeld } from "./stores.mjs";
9
9
 
10
10
  // A line that opens with a setting's name keeps its case: "nEXT_PUBLIC_SUPABASE_URL" was printed.
11
11
  const lower = (line) => (/^[A-Z][A-Z0-9_]/.test(line) ? line : line.charAt(0).toLowerCase() + line.slice(1));
@@ -34,17 +34,26 @@ function blockedRows(blocked) {
34
34
  // folder and its source files. launch(plan): a service of the repository started the way the app
35
35
  // is. connections(): what the hook saw the app reach. hold(): the run's requests wait from here until
36
36
  // the caller has the app on the copies made, since until then it still writes into the originals.
37
- export function makeKeeping({ root, work, ledgerFile, onPath, say, settings, launch, connections, hold = () => {} }) {
37
+ // `typed`: the person gave the start command. It is run as it is, with the settings and the stores
38
+ // that command gives the app: a legal research API typed ENV=staging and was started on copies of
39
+ // the test databases its default settings file names, where its library is empty.
40
+ const AS_TYPED = "Your app is started with your own command as it is, on the databases that command gives it. Nothing is copied: a run writes into them once you say yes to each on the card.";
41
+ export function makeKeeping({ root, work, ledgerFile, onPath, say, settings, launch, connections, hold = () => {}, typed = false }) {
38
42
  let pending = null;
39
43
  let now = null;
40
44
  let backing = null;
41
45
  let backedUp = false;
42
46
  let seen = 0;
43
- const told = new Set();
44
47
  const tell = (lines) => { for (const line of lines) say(lower(line)); };
45
48
  const held = (blocked) => tell(blocked.map((b) => `Run waits for your yes on the card in the browser: ${lower(b.why)}`));
46
49
 
47
50
  async function keepAll(started) {
51
+ if (typed) {
52
+ const none = async () => ({ env: {}, risks: [], copies: [], blocked: [], pass: [] });
53
+ now = { env: {}, risks: [], notes: [AS_TYPED], blocked: [], pass: [], stores: [], keeper: { keep: none, drop: async () => {} }, values: {}, started };
54
+ tell(now.notes);
55
+ return now;
56
+ }
48
57
  const { values, from, appDir, sources } = settings();
49
58
  const disk = keepData({ values, from, appDir, root, work, sources, started });
50
59
  const stores = storesOf(disk.values);
@@ -61,6 +70,7 @@ export function makeKeeping({ root, work, ledgerFile, onPath, say, settings, lau
61
70
 
62
71
  // Brings up what is wanted, copies each store that now answers, and says both.
63
72
  async function bringUp(wants) {
73
+ if (typed) return { changed: false, came: false, copied: false };
64
74
  const k = await once(true);
65
75
  hold();
66
76
  const up = await backing.up(wants);
@@ -136,14 +146,11 @@ export function makeKeeping({ root, work, ledgerFile, onPath, say, settings, lau
136
146
  const k = await pending;
137
147
  const live = [];
138
148
  for (const c of conns) if (!onThisMachine(c.host) || (await tcpOpen(c.host, c.port))) live.push(c);
139
- const fresh = unnamedLines(live, k.stores, told);
140
- k.risks.push(...fresh);
141
- tell(fresh);
142
- const bypassed = bypassedOf(live, k.stores, k.blocked);
149
+ const bypassed = [...bypassedOf(live, k.stores, k.blocked), ...unnamedHeld(live, k.stores, k.blocked)];
143
150
  k.blocked.push(...bypassed);
144
151
  held(bypassed);
145
- const got = await bringUp(reached(appPort));
146
- return { changed: fresh.length > 0 || bypassed.length > 0 || got.changed, copied: got.copied };
152
+ const got = typed ? { changed: false, copied: false } : await bringUp(reached(appPort));
153
+ return { changed: bypassed.length > 0 || got.changed, copied: got.copied };
147
154
  },
148
155
  // An app attached to instead of started keeps its own connections: its copies are of no use.
149
156
  async forget() {
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
 
@@ -26,6 +26,23 @@ RECENT_KEPT, RECENT_MS = 32, 300000
26
26
  # turn (a chat UI shows the message with one event, then answers it with the next).
27
27
  BEFORE_MS = 60000
28
28
  PROMPTS_KEPT, PROMPT_HEAD = 64, 500
29
+ # How much of a sentence the model wrote a fetched reply has to carry to be the answer.
30
+ CARRY = 60
31
+ # tied.cjs UNDER_WAY: a job's own word for work still going on.
32
+ UNDER_WAY = re.compile(r"^(?:queued|pending|waiting|scheduled|submitted|accepted|created|starting|started|running|processing|generating|working|thinking|searching|retrieving|streaming|in[_ -]?progress|not[_ -]?(?:started|ready))$", re.I)
33
+
34
+
35
+ def _under_way(v, depth=0):
36
+ if depth > 4:
37
+ return False
38
+ if isinstance(v, list):
39
+ return bool(v) and _under_way(v[-1], depth + 1)
40
+ if not isinstance(v, dict):
41
+ return False
42
+ state = v.get("status", v.get("state"))
43
+ if isinstance(state, str) and UNDER_WAY.match(state):
44
+ return True
45
+ return any(isinstance(x, (list, dict)) and _under_way(x, depth + 1) for x in v.values())
29
46
  # The ask the last call pinned in this handler's context was made for. A queue runs each handler in a
30
47
  # context of its own, so its later calls, which carry none of the person's words, are told apart by
31
48
  # it when several asks are open at once. Only a tiebreak: it never outranks what the rules decide.
@@ -68,10 +85,23 @@ def _address_ids(req):
68
85
  return {v for v in path.split("/") + [v for _, v in parse_qsl(query)] if ID.match(v)}
69
86
 
70
87
 
88
+ # tied.cjs saidSentences: a JSON answer the clip cut short is read by its strings.
89
+ def _said_sentences(text):
90
+ text = text.decode("utf-8", "replace") if isinstance(text, (bytes, bytearray)) else str(text or "")
91
+ v = _json(text)
92
+ whole = [s for s in (_scalars(v, []) if v is not None else [text]) if _sentence(s)]
93
+ if len(whole) != 1 or not re.match(r"\s*[\[{]", whole[0]):
94
+ return whole
95
+ return [m for m in re.findall(r'"((?:[^"\\]|\\.){8,})"', whole[0]) if _sentence(m)]
96
+
97
+
71
98
  class Tied:
72
99
  # `body_of(req)` and `reply_of(req)` read what a request carried and what the app answered.
73
- def __init__(self, norm, body_of, reply_of):
100
+ # `said_of(req)`: what the model calls made for an ask answered later have written, one entry a
101
+ # call and none before one has, None where the hook hears none (tied.cjs saidOf).
102
+ def __init__(self, norm, body_of, reply_of, said_of=None):
74
103
  self.norm, self.body_of, self.reply_of = norm, body_of, reply_of
104
+ self.said_of = said_of or (lambda req: None)
75
105
  self.open = {}
76
106
  self.recent = collections.deque(maxlen=RECENT_KEPT)
77
107
  # The heads of the prompts this process sent: one arriving at a server in the same process (an
@@ -127,7 +157,7 @@ class Tied:
127
157
  now = int(time.time() * 1000)
128
158
  for t in reversed(list(self.recent)) if mine else []:
129
159
  # A request answered later is fetched afterwards only by a read of it, never by a new message.
130
- if t["at"] <= req["at"] and now - t["at"] <= RECENT_MS and (t.get("pinned") or not pinned_only) and (not t.get("settled") or req["method"] == "GET"):
160
+ if t["at"] <= req["at"] and now - t.get("call_at", t["at"]) <= RECENT_MS and (t.get("pinned") or not pinned_only) and (not t.get("settled") or req["method"] == "GET"):
131
161
  shared = mine & self._ids(t)
132
162
  if shared:
133
163
  return t, shared
@@ -146,7 +176,7 @@ class Tied:
146
176
  def closed(self, req):
147
177
  self.open.pop(req["id"], None)
148
178
  asker = req.get("asker")
149
- if asker and not asker.get("written") and not asker.get("calls_open"):
179
+ if asker and not asker.get("written") and not asker.get("calls_open") and self._carries(req, asker):
150
180
  since = max([self.answered[i] for i in req["tie"] if self.answered.get(i, asker["at"]) < asker["at"]], default=0)
151
181
  before = [p for p in list(self.recent) if p is not asker and not p.get("settled") and since < p["at"] < asker["at"] and asker["at"] - p["at"] <= BEFORE_MS and self._ids(p) & req["tie"]]
152
182
  for s in before + [asker]:
@@ -161,6 +191,18 @@ class Tied:
161
191
  self.recent.append(req)
162
192
  return None
163
193
 
194
+ # tied.cjs carries: a page polling for an answer several calls build comes back many times
195
+ # between two of them, on a document with no answer in it yet.
196
+ def _carries(self, fetch, asker):
197
+ said = self.said_of(asker)
198
+ if said is None:
199
+ return True
200
+ raw = self.reply_of(fetch)
201
+ if _under_way(_json(raw)):
202
+ return False
203
+ reply = self.norm(raw)
204
+ return any(self.norm(w)[:CARRY] in reply for one in (said if isinstance(said, (list, tuple)) else [said]) for w in _said_sentences(one))
205
+
164
206
  def _answered(self, tie, at):
165
207
  for i in tie:
166
208
  self.answered.pop(i, None)
@@ -230,6 +272,7 @@ class Tied:
230
272
  asker["late"] = True
231
273
  asker["pinned"] = True
232
274
  asker["calls_open"] = asker.get("calls_open", 0) + 1
275
+ asker["call_at"] = int(time.time() * 1000)
233
276
  _HELD.set(asker)
234
277
  return asker
235
278
 
@@ -260,6 +303,7 @@ class Tied:
260
303
  if req.get("late"):
261
304
  req["pinned"] = True
262
305
  req["calls_open"] = req.get("calls_open", 0) + 1
306
+ req["call_at"] = int(time.time() * 1000)
263
307
  else:
264
308
  req["waiting"] = req.get("waiting", 0) + 1
265
309
  return req
@@ -285,7 +329,7 @@ class Tied:
285
329
  # request tied to it came to fetch the answer. It stays where a fetch that comes later can still
286
330
  # find it and complete the exchange on a second request, but no call is pinned to it by its words.
287
331
  def settled(self, req):
288
- if not req or not req.get("late") or not req.get("ended") or req.get("calls_open") or req.get("waiting") or req.get("written") or req.get("settled") or req.get("fetched"):
332
+ if not req or not req.get("late") or not req.get("ended") or req.get("calls_open") or req.get("waiting") or req.get("written") or req.get("settled") or (req.get("fetched") and self.said_of(req) is None):
289
333
  return False
290
334
  req["settled"] = req["answered"] = True
291
335
  return True
@@ -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
 
@@ -1548,7 +1592,7 @@ def _install():
1548
1592
 
1549
1593
  try:
1550
1594
  from cortad_tied import Tied
1551
- tied = Tied(norm, body_of, reply_of)
1595
+ tied = Tied(norm, body_of, reply_of, lambda req: req.get("said_all", []) if req.get("late") else None)
1552
1596
  except Exception:
1553
1597
  tied = None
1554
1598
 
@@ -1749,6 +1793,7 @@ def _install():
1749
1793
  if req and req.get("late"):
1750
1794
  if not row.get("embedding"):
1751
1795
  req["said"] = (row["status"], reply_text(events)[:model_words], now)
1796
+ req["said_all"] = (req.get("said_all", []) + [req["said"][1]])[-12:]
1752
1797
  settle(req)
1753
1798
  row.update(inside_of(req))
1754
1799
  if req and (req.get("kept") or req.get("turn")):
@@ -2425,7 +2470,7 @@ def _install():
2425
2470
 
2426
2471
  # A held call that cannot be answered fails in the app; it is never sent instead.
2427
2472
  def held_httpx(request, sent):
2428
- 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):
2429
2474
  return None
2430
2475
  kind = request.headers.get("content-type", "")
2431
2476
  held_row(request.url, request.method, sent, kind, ctx.get())
@@ -2488,7 +2533,7 @@ def _install():
2488
2533
  if not held_here(request.url, request.method, lambda: keys_in(request.headers)):
2489
2534
  return None
2490
2535
  raw = text(request.body, reply_max)
2491
- if reads_only(raw):
2536
+ if reads_only(raw, request.url, request.headers.get):
2492
2537
  return None
2493
2538
  import datetime
2494
2539
  models, structures = sys.modules["requests.models"], sys.modules["requests.structures"]
@@ -2539,6 +2584,13 @@ def _install():
2539
2584
  basic = k.get("auth") or getattr(self, "_default_auth", None)
2540
2585
  return [given.get(n) or defaults.get(n) for n in key_headers] + [basic.encode() if basic else None]
2541
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
+
2542
2594
  # A path relative to the session's base address is decided on the whole address.
2543
2595
  def whole(self, str_or_url):
2544
2596
  try:
@@ -2549,7 +2601,7 @@ def _install():
2549
2601
  async def requested(self, method, str_or_url, *a, **k):
2550
2602
  body = k.get("json") if k.get("json") is not None else k.get("data")
2551
2603
  url = whole(self, str_or_url)
2552
- 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)):
2553
2605
  port = held_port()
2554
2606
  if port is None:
2555
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,9 +453,29 @@ 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
+ for (const a of JSON.parse(readFileSync(file, "utf8"))) {
465
+ if (kept.length >= ACCOUNTS_MAX || typeof a?.key !== "string" || !a.headers || typeof a.headers !== "object") continue;
466
+ const headers = Object.fromEntries(Object.entries(a.headers).filter(([k, v]) => typeof k === "string" && typeof v === "string"));
467
+ if (signInKey(headers) !== a.key || (identityOf(headers).expiresInS ?? 1) <= 0) continue;
468
+ kept.push({ key: a.key, headers, bodies: new Map(Object.entries(a.bodies ?? {}).map(([door, leaves]) => [door, new Map(Object.entries(leaves ?? {}))])) });
469
+ }
470
+ } catch { /* none kept, or not ours to read: the next signed-in request is taken */ }
471
+ }
472
+ const save = () => {
473
+ if (!file) return;
474
+ try {
475
+ 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 });
477
+ } catch { /* kept for this connect only */ }
478
+ };
457
479
  const at = (role) => { const m = ACCOUNT_ROLE.exec(role); return m ? kept[Number(m[1] ?? 1) - 1] ?? kept[0] ?? null : null; };
458
480
  return {
459
481
  // `door` and `body`: the request as that customer sent it, for the values in it that are theirs.
@@ -470,6 +492,7 @@ export function makeAccounts() {
470
492
  else return;
471
493
  }
472
494
  if (door) account.bodies.set(door, leavesOf(body));
495
+ save();
473
496
  },
474
497
  kept: () => kept.map((a) => a.headers),
475
498
  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/stores.mjs CHANGED
@@ -21,7 +21,7 @@ export const PORT_OF = { postgres: 5432, mysql: 3306, mongo: 27017, redis: 6379,
21
21
  const ENGINE_AT = { ...Object.fromEntries(Object.entries(PORT_OF).filter(([e]) => e !== "chroma" && e !== "weaviate").map(([e, p]) => [p, e])), 6334: "qdrant" };
22
22
  export const isStorePort = (port) => Boolean(ENGINE_AT[port]);
23
23
  // Stores reached through their maker's SDK, which a setting rarely names: the hook sees the host.
24
- const HOSTED = [["supabase", /\.supabase\.(?:co|in|com)$/], ["mongo", /\.mongodb\.net$/], ["redis", /\.upstash\.io$|\.redis-cloud\.com$|\.redislabs\.com$/], ["postgres", /\.neon\.tech$/], ["pinecone", /\.pinecone\.io$/], ["qdrant", /\.qdrant\.io$/], ["firestore", /^firestore\.googleapis\.com$/]];
24
+ const HOSTED = [["supabase", /\.supabase\.(?:co|in|com)$/], ["mongo", /\.mongodb\.net$/], ["elasticsearch", /\.elastic-cloud\.com$|\.found\.io$|\.es\.amazonaws\.com$/], ["redis", /\.upstash\.io$|\.redis-cloud\.com$|\.redislabs\.com$/], ["postgres", /\.neon\.tech$/], ["pinecone", /\.pinecone\.io$/], ["qdrant", /\.qdrant\.io$/], ["firestore", /^firestore\.googleapis\.com$/]];
25
25
  // How each store we copy is copied and dropped: on its own server, under the name given.
26
26
  const COPY = {
27
27
  postgres: { copy: (s, clone) => clonePg(s.conn, s.db, clone), drop: (s, clone) => dropPg(s.conn, clone) },
@@ -39,6 +39,10 @@ const WORD_ENGINE = [["postgres", /^(?:PG|POSTGRES|PSQL)|POSTGRES/], ["mysql", /
39
39
  const SECRETISH = /(?:^|_)(?:KEY|TOKEN|SECRET|PASSWORD|PASS|PWD)$/;
40
40
  // Every engine's word in a setting's name, so a setting that carries one is that engine's own.
41
41
  const ENGINE_WORD = new RegExp([...Object.keys(ENGINE), ...[...BY_NAME, ...WORD_ENGINE].map(([, re]) => re.source)].join("|"), "i");
42
+ // A settings file's values without the ones that name a store. An app the person started with
43
+ // their own command is handed the rest: which databases it uses is that command's to say.
44
+ const STORE_SETTING = new RegExp(`${ENGINE_WORD.source}|(?:^|_)(?:DB|DATABASE)(?:_|$)`, "i");
45
+ export const withoutStoreSettings = (values) => Object.fromEntries(Object.entries(values).filter(([name]) => !STORE_SETTING.test(name)));
42
46
  // Stores that hold no collections: a bare COLLECTION setting beside one of them is still Qdrant's.
43
47
  const NO_COLLECTIONS = new Set(["redis", "mysql", "sqlserver", "neo4j", "meilisearch", "pinecone"]);
44
48
  const LOOPBACK = /^(?:localhost|127(?:\.\d+){3}|::1|\[::1\]|0\.0\.0\.0|host\.docker\.internal)?$/i;
@@ -329,19 +333,19 @@ export function storeKeeper({ ledgerFile, values = {}, dirs = [], fetchFn, local
329
333
  }
330
334
 
331
335
  // The stores the app reached that no setting names, as the hook saw each connection: a store written
332
- // into a config file rather than the environment, or one its maker's SDK finds by itself. One
333
- // sentence per store, said once.
334
- export function unnamedLines(connections, stores, told) {
335
- const lines = [];
336
+ // into a config file rather than the environment, one its maker's SDK finds by itself, or the ones an
337
+ // app started with the person's own command was given. No copy stands in for any of them, so each
338
+ // holds Run for the person's yes, once. `blocked`: the rows already holding it.
339
+ export function unnamedHeld(connections, stores, blocked) {
340
+ const rows = [];
336
341
  for (const { host, port } of connections) {
337
342
  const local = onThisMachine(host);
338
- const engine = local ? ENGINE_AT[port] : HOSTED.find(([, re]) => re.test(String(host).toLowerCase()))?.[0];
339
- const key = local ? `localhost:${port}` : `${engine}:${String(host).toLowerCase()}`;
340
- if (!engine || told.has(key) || stores.some((s) => (local ? `${hostKey(s.host)}:${s.port}` === key : s.engine === engine && !onThisMachine(s.host)))) continue;
341
- told.add(key);
342
- lines.push(local
343
- ? `Your app connects to a ${ENGINE[engine]} server on this machine that none of its settings names, so it cannot be pointed at a copy: what test conversations create there stays there.`
344
- : `Your app reaches ${ENGINE[engine]} on a server off this machine, and a copy is only made on this machine: what test conversations create there stays there.`);
343
+ const engine = local ? ENGINE_AT[port] : HOSTED.find(([, re]) => re.test(String(host).toLowerCase()))?.[0] ?? ENGINE_AT[port];
344
+ if (!engine || stores.some((s) => (local ? `${hostKey(s.host)}:${s.port}` === `localhost:${port}` : s.engine === engine && !onThisMachine(s.host)))) continue;
345
+ const id = createHash("sha256").update(`${engine}|${local ? "localhost" : String(host).toLowerCase()}|${port}`).digest("hex").slice(0, 16);
346
+ if ([...blocked, ...rows].some((b) => b.id === id)) continue;
347
+ // Named by its engine where a setting's name would stand: the API takes a name, never an address.
348
+ rows.push({ id, names: [ENGINE[engine].replace(/\W+/g, "_")], why: `Your app reaches ${/^[AEIOU]/.test(ENGINE[engine]) ? "an" : "a"} ${ENGINE[engine]} server ${local ? "on" : "off"} this machine that it was not given a copy of, so the run would write what its test conversations create into it as it is. Start your app on a copy of it, then run the connect command again.` });
345
349
  }
346
- return lines;
350
+ return rows;
347
351
  }
package/lib/tied.cjs CHANGED
@@ -39,9 +39,32 @@ const sentence = (v) => v.length >= 8 && /\s|[^\x00-\x7F]/.test(v);
39
39
  // The sentences a body or a reply carries, read as the person's words are: an "ok" or an id says nothing.
40
40
  const sentencesIn = (text) => { const v = json(text); return (v === undefined ? [String(text || "")] : scalars(v)).filter(sentence); };
41
41
  const PROMPTS_KEPT = 64, PROMPT_HEAD = 500;
42
+ // How much of a sentence the model wrote a fetched reply has to carry to be the answer.
43
+ const CARRY = 60;
44
+ // A job's own word for work still going on, as its status or state says it: at the top of a document,
45
+ // in an object inside it, or on the newest entry of a list in it. The backend reads the same words
46
+ // (customer/endpoint.ts).
47
+ const UNDER_WAY = /^(?:queued|pending|waiting|scheduled|submitted|accepted|created|starting|started|running|processing|generating|working|thinking|searching|retrieving|streaming|in[_ -]?progress|not[_ -]?(?:started|ready))$/i;
48
+ const underWay = (v, depth = 0) => {
49
+ if (!v || typeof v !== "object" || depth > 4) return false;
50
+ if (Array.isArray(v)) return underWay(v.at(-1), depth + 1);
51
+ const state = v.status ?? v.state;
52
+ if (typeof state === "string" && UNDER_WAY.test(state)) return true;
53
+ return Object.values(v).some((x) => x && typeof x === "object" && underWay(x, depth + 1));
54
+ };
55
+ // The sentences a model wrote, read as the person's words are; a JSON answer the clip cut short is
56
+ // read by its strings.
57
+ const saidSentences = (text) => {
58
+ const whole = sentencesIn(text);
59
+ if (whole.length !== 1 || !/^\s*[[{]/.test(whole[0])) return whole;
60
+ return [...whole[0].matchAll(/"((?:[^"\\]|\\.){8,})"/g)].map((m) => m[1]).filter(sentence);
61
+ };
42
62
 
43
63
  // `bodyOf(req)` and `replyOf(req)` read what a request carried and what the app answered.
44
- function makeTied({ norm, bodyOf, replyOf }) {
64
+ // `saidOf(req)`: what the model calls made for an ask answered later have written, one entry a call
65
+ // and none before one has, undefined where the hook hears none. The answer a person reads is not
66
+ // always the last call's: a second look that changes nothing leaves the first reading standing.
67
+ function makeTied({ norm, bodyOf, replyOf, saidOf = () => undefined }) {
45
68
  const open = new Set();
46
69
  const recent = [];
47
70
  // The heads of the prompts this process sent: one arriving at a server in the same process (an
@@ -68,6 +91,17 @@ function makeTied({ norm, bodyOf, replyOf }) {
68
91
  return first[1] >= 0 && first[1] !== second[1] ? first[0] : null;
69
92
  };
70
93
  const drop = (req) => { const i = recent.indexOf(req); if (i >= 0) recent.splice(i, 1); };
94
+ // Whether a fetch carries the ask's answer. A page polling for an answer several calls build comes
95
+ // back many times between two of them, on a document with no answer in it yet, or with a first
96
+ // reading in it while the document itself still says running.
97
+ const carries = (fetch, asker) => {
98
+ const said = saidOf(asker);
99
+ if (said === undefined) return true;
100
+ const raw = replyOf(fetch);
101
+ if (underWay(json(raw))) return false;
102
+ const reply = norm(raw);
103
+ return [].concat(said).flatMap(saidSentences).some((w) => reply.includes(norm(w).slice(0, CARRY)));
104
+ };
71
105
  // Asks reached through one session are one client's turns, the newest the one being answered: a
72
106
  // client holds a second stream open on its session (a heartbeat) that points at an older ask.
73
107
  const newestOf = (found) => {
@@ -89,7 +123,7 @@ function makeTied({ norm, bodyOf, replyOf }) {
89
123
  for (let i = mine.size ? recent.length - 1 : -1; i >= 0; i--) {
90
124
  const t = recent[i];
91
125
  // A request answered later is fetched afterwards only by a read of it, never by a new message.
92
- if (t.at > req.at || now - t.at > RECENT_MS || (pinnedOnly && !t.pinned) || (t.settled && req.method !== "GET")) continue;
126
+ if (t.at > req.at || now - (t.callAt ?? t.at) > RECENT_MS || (pinnedOnly && !t.pinned) || (t.settled && req.method !== "GET")) continue;
93
127
  const tie = [...mine].filter((v) => idsOf(t).has(v));
94
128
  if (tie.length) return { asker: t, tie: new Set(tie) };
95
129
  }
@@ -107,7 +141,7 @@ function makeTied({ norm, bodyOf, replyOf }) {
107
141
  closed(req) {
108
142
  open.delete(req);
109
143
  const { asker } = req;
110
- if (asker && !asker.written && !asker.callsOpen) {
144
+ if (asker && !asker.written && !asker.callsOpen && carries(req, asker)) {
111
145
  const since = Math.max(0, ...[...req.tie].map((i) => answered.get(i) ?? asker.at).filter((at) => at < asker.at));
112
146
  const before = recent.filter((p) => p !== asker && !p.settled && since < p.at && p.at < asker.at && asker.at - p.at <= BEFORE_MS && meets(idsOf(p), req.tie));
113
147
  for (const s of [...before, asker]) drop(s);
@@ -140,7 +174,7 @@ function makeTied({ norm, bodyOf, replyOf }) {
140
174
  inside(req, sent) {
141
175
  if (req.finished && (!req.noted || req.late) && !says(req, norm(sent))) return pinned(sent, req);
142
176
  if (!req.noted && req.finished) req.late = true;
143
- if (req.late) { req.pinned = true; req.callsOpen = (req.callsOpen || 0) + 1; }
177
+ if (req.late) { req.pinned = true; req.callsOpen = (req.callsOpen || 0) + 1; req.callAt = Date.now(); }
144
178
  else req.waiting = (req.waiting || 0) + 1;
145
179
  return req;
146
180
  },
@@ -166,7 +200,7 @@ function makeTied({ norm, bodyOf, replyOf }) {
166
200
  // request tied to it came to fetch the answer. It stays where a fetch that comes later can still
167
201
  // find it and complete the exchange on a second request, but no call is pinned to it by its words.
168
202
  settled(req) {
169
- if (!req || !req.late || !req.ended || req.callsOpen || req.waiting || req.written || req.settled || req.fetched) return false;
203
+ if (!req || !req.late || !req.ended || req.callsOpen || req.waiting || req.written || req.settled || (req.fetched && saidOf(req) === undefined)) return false;
170
204
  req.settled = true;
171
205
  return true;
172
206
  },
@@ -210,6 +244,7 @@ function makeTied({ norm, bodyOf, replyOf }) {
210
244
  if (!asker.noted && asker.finished) asker.late = true;
211
245
  asker.pinned = true;
212
246
  asker.callsOpen = (asker.callsOpen || 0) + 1;
247
+ asker.callAt = Date.now();
213
248
  return asker;
214
249
  }
215
250
  }
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/lib/wire.cjs CHANGED
@@ -761,14 +761,14 @@ function makeRecorder({ row, rulesFile, onTurn = () => {}, onEnded = () => {} })
761
761
  // of the person's is kept: their newest is the sign-in and the proof a run takes, and the command
762
762
  // keeps only the newest few per door.
763
763
  // ponytail: the trace file grows by one row per request of the person's; a cap per route if a person's own load test fills it.
764
- const EXCHANGES_PER_ROUTE = 4, CANARY_PER_ROUTE = 30, ROUTES_KEPT = 1000, SENT_MAX = 4, MODEL_WORDS = 4000;
764
+ const EXCHANGES_PER_ROUTE = 4, CANARY_PER_ROUTE = 30, ROUTES_KEPT = 1000, SENT_MAX = 4, MODEL_WORDS = 4000, SAID_KEPT = 12;
765
765
  const slotOf = (turn) => (!turn ? { cls: "", max: Infinity } : /^canary:/.test(turn) ? { cls: "canary ", max: CANARY_PER_ROUTE } : { cls: "ours ", max: EXCHANGES_PER_ROUTE });
766
766
  const perRoute = new Map();
767
767
  // One route whatever id its path carries: a conversation in the path is a new path every trial.
768
768
  const routeKey = (method, path) => `${method} ${path.split("?")[0].split("/").map((s) => (/^\d+$/.test(s) || (s.length >= 8 && /\d/.test(s)) ? "{id}" : s)).join("/")}`;
769
769
  let lastExchange = 0;
770
770
  const { makeTied, sentencesIn } = require("./tied.cjs");
771
- const tied = makeTied({ norm, bodyOf, replyOf: (ctx) => (ctx.reply ? ctx.reply.text.slice(0, MAX) : "") });
771
+ const tied = makeTied({ norm, bodyOf, replyOf: (ctx) => (ctx.reply ? ctx.reply.text.slice(0, MAX) : ""), saidOf: (ctx) => (ctx.late ? ctx.saidAll || [] : undefined) });
772
772
  const requestCtx = (method, path, headers, turn) => {
773
773
  if (turn) onTurn();
774
774
  const ctx = { id: `${process.pid}.${++lastExchange}`, at: Date.now(), method, path, headers, chunks: [], size: 0, noted: false, kept: false, sent: [], turn, sites: [], traced: 0 };
@@ -866,7 +866,10 @@ function makeRecorder({ row, rulesFile, onTurn = () => {}, onEnded = () => {} })
866
866
  const told = asked && typeof asked === "object" && !EMBEDDING.test(path) ? toldOf(asked, ctx ? new Set([...heard, ...heardIn(ctx)]) : heard) : {};
867
867
  tied.done(ctx);
868
868
  if (ctx && ctx.late) {
869
- if (!EMBEDDING.test(path)) ctx.said = { status, words: replyText(reply.events).slice(0, MODEL_WORDS), at: Date.now() };
869
+ if (!EMBEDDING.test(path)) {
870
+ ctx.said = { status, words: replyText(reply.events).slice(0, MODEL_WORDS), at: Date.now() };
871
+ ctx.saidAll = [...(ctx.saidAll || []), ctx.said.words].slice(-SAID_KEPT);
872
+ }
870
873
  settle(ctx);
871
874
  }
872
875
  row({ call: { at: Date.now(), ms: Date.now() - t0, host: String(host).replace(/:443$/, ""), model: String(askedFor(path, sent) || reply.model || "").slice(0, 160), ...answeredBy(askedFor(path, sent), reply.model), status, ...(reply.tokens || { promptTokens: 0, cachedTokens: 0, completionTokens: 0 }), usage: Boolean(reply.tokens), ...(EMBEDDING.test(path) ? { embedding: true } : {}), ...(ctx ? { ex: ctx.id } : {}), ...(ctx && (ctx.kept || ctx.turn) ? { reply: replyText(reply.events).slice(0, MODEL_WORDS) } : {}), ...(turn ? { turn } : {}), ...(rules ? { rules } : {}), ...(tools ? { tools } : {}), ...(called ? { called } : {}), ...(passages ? { passages } : {}), ...(caller ? { caller } : {}), ...(reply.limit ? { limit: { cap: capOf(asked), reasoning: reply.reasoning } } : {}), ...told } });
package/local.mjs CHANGED
@@ -12,7 +12,7 @@
12
12
  // on with the key this connect leaves in ~/.cortad. Nothing here touches git. Your environment
13
13
  // never leaves this machine. Ctrl-C ends everything.
14
14
 
15
- import { storesOf } from "./lib/stores.mjs";
15
+ import { storesOf, withoutStoreSettings } from "./lib/stores.mjs";
16
16
  import { notAnApp, withoutStores } from "./lib/app-port.mjs";
17
17
  import { holdsKeys, secretEnvValues } from "./lib/keys.mjs";
18
18
  import { spawn, execFile, execFileSync } from "node:child_process";
@@ -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";
@@ -663,6 +663,10 @@ async function ask(question) {
663
663
  return line.trim() || null;
664
664
  }
665
665
  let child = null;
666
+ // An app this command did not start carries nothing that counts its model calls, and no run plays
667
+ // against it (the API holds Run): databuddy's free run played 39 conversations into a route that
668
+ // answers from code.
669
+ const UNSEEN = "your app was already running, so Cortad cannot see the calls it makes to its model, and no run can start.";
666
670
  async function startApp() {
667
671
  const wanted = Number(flag("--port"));
668
672
  // "--port 8106 --start ..." is "start it, it will answer on 8106", not "it is already on 8106":
@@ -671,11 +675,10 @@ async function startApp() {
671
675
  if (!(await answers(wanted))) return { port: null, said: "", why: `nothing is answering on port ${wanted}, so there is nothing to ask.`, noStart: true };
672
676
  const took = await takeOver(wanted);
673
677
  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
678
  if (proxyAsked) {
676
679
  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
- }
680
+ 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(" ")}`);
681
+ } else say(`${UNSEEN} Stop it and run this again without --port: this command starts your app itself.`);
679
682
  return { port: wanted, cmd: null };
680
683
  }
681
684
  const plan = startPlan({ root, typed: flag("--start"), onPath });
@@ -718,7 +721,7 @@ async function startApp() {
718
721
  await keeping.forget();
719
722
  const took = await takeOver(held.port);
720
723
  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`);
724
+ say(`${UNSEEN} Stop the one on port ${held.port} and run this again: this command starts your app itself.`);
722
725
  return { port: held.port, cmd: null };
723
726
  }
724
727
  }
@@ -912,7 +915,7 @@ async function freed(cmd, lifted) {
912
915
  // was copied, started, or left where trials write into it is told in the terminal here and to the
913
916
  // run with the app (announce), so status says it beside Run and the report keeps it.
914
917
  const keeping = makeKeeping({
915
- root, work, ledgerFile: join(homeOf(project), "made.json"), onPath, say, hold,
918
+ root, work, ledgerFile: join(homeOf(project), "made.json"), onPath, say, hold, typed: Boolean(flag("--start")),
916
919
  launch: (plan) => sidecar(plan.cmd, plan.cwd),
917
920
  connections: () => capture?.connections() ?? [],
918
921
  settings: () => {
@@ -941,7 +944,10 @@ async function start(waitMs, tries = 3) {
941
944
  loaded = note;
942
945
  const copies = await keeping.env(true);
943
946
  await allowOutbound();
944
- const base = { ...envExports(envFiles), ...process.env, ...lifted, ...copies };
947
+ // A start command the person typed says which databases the app uses: the store settings of their
948
+ // settings files are left for the app to load, since handed over here they outrank the
949
+ // environment that command chose, and no copy is pointed at.
950
+ const base = flag("--start") ? { ...withoutStoreSettings(envExports(envFiles)), ...process.env, ...lifted } : { ...envExports(envFiles), ...process.env, ...lifted, ...copies };
945
951
  child = spawnTied(cmd, { cwd: appDir, env: { ...base, ...(proxy ? proxy.env(base) : capture ? capture.env(process.env) : {}), FORCE_COLOR: "0", ...(pinned.bin ? { PATH: `${pinned.bin}:${process.env.PATH ?? ""}` } : {}) } });
946
952
  const mine = child;
947
953
  become("starting", { app: mine.pid ?? null });
@@ -1167,7 +1173,7 @@ identities = makeIdentities({ root, work, envFiles, sourceFiles: () => files, sa
1167
1173
  // A real request that reached their model proves its door (lib/proof.mjs). What it proved goes up,
1168
1174
  // masked like everything else, and nothing goes up that the mask could not read; the sign-in each
1169
1175
  // request carried stays here.
1170
- capture = makeCapture({ work, keepSecret, writes: join(homeOf(projectOf(root)), "writes"), root, files: () => files, appFolder: () => relative(root, appDir), printed: printedBetween, onProof: (proof) => {
1176
+ 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) => {
1171
1177
  let masked;
1172
1178
  try { masked = JSON.parse(mask(JSON.stringify(proof))); } catch { return; }
1173
1179
  call("POST", `/local/${box}/proof`, masked).catch(() => {});
@@ -1285,7 +1291,7 @@ const announce = async () => {
1285
1291
  // 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.
1286
1292
  const hooked = Boolean(proxy ? launched || proxy.seen() : launched && capture?.watching(app.port));
1287
1293
  const started = Boolean(launched);
1288
- 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 });
1294
+ 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 });
1289
1295
  if (told.ok) await allowOutbound(told.data?.consent);
1290
1296
  return told;
1291
1297
  };
@@ -1334,7 +1340,7 @@ async function appLife(firstStart) {
1334
1340
  clearStep();
1335
1341
  if (!told.ok) return quit(told.data?.error ?? `could not register your app (${told.status})`);
1336
1342
  machineOf = () => (app ? {
1337
- cmd: app.cmd ?? "", dir: relative(root, appDir) || ".", port: app.port, started: Boolean(launched),
1343
+ cmd: shownCommand(app.cmd, mask) ?? "", dir: relative(root, appDir) || ".", port: app.port, started: Boolean(launched),
1338
1344
  hooked: Boolean(launched && !proxy && capture?.watching(app.port)), proxy: proxy?.port ?? null,
1339
1345
  changed: [...new Set([...(app.lifted ?? []), ...Object.keys(keeping.envNow())])],
1340
1346
  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.3",
3
+ "version": "0.3.5",
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"