cortad 0.3.11 → 0.4.0

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.
@@ -11,9 +11,6 @@ _FILE = os.environ.get("CORTAD_TRACE_FILE")
11
11
  _RULES = os.environ.get("CORTAD_RULES_FILE")
12
12
  _WRITES = os.environ.get("CORTAD_WRITES_DIR")
13
13
  _APP_ROOT = os.environ.get("CORTAD_APP_ROOT")
14
- _OUTBOUND = os.environ.get("CORTAD_OUTBOUND_FILE")
15
- # The port this hook answers held writes on: its own server, never a store the app reached.
16
- _held_ports = []
17
14
 
18
15
 
19
16
  # What the app writes into its own folder while a run is on: each file copied once before its first
@@ -1272,388 +1269,6 @@ def _install():
1272
1269
  if req and is_model_call(url):
1273
1270
  req["heard"] = True
1274
1271
 
1275
- # Outbound writes a trial makes are held in the app, never sent: a trial that books, charges,
1276
- # emails or deletes would otherwise do it for real. Held: a call made inside a request the run
1277
- # tagged, with a writing method, to a host that is not a model, a retrieval, this machine, a copy
1278
- # of ours or a host the person said yes to. A call carrying a provider's test key goes to that
1279
- # provider's own sandbox as sent. The app is answered with a success shaped like what it sent,
1280
- # and the run is told by a dep row with `held` and the clipped call (lib/trace.cjs does the same).
1281
- import base64
1282
- from urllib.parse import parse_qsl
1283
-
1284
- passed = {"at": -1, "hosts": []}
1285
- # When the run last sent a request: a write made outside every request (a queue's worker, a
1286
- # thread) is held while a run is live, since it may be carrying out a trial's ask.
1287
- # ponytail: while a run is live, a person's own queued write is held too; per-ask pinning of
1288
- # background writes is the upgrade when that matters.
1289
- run_seen = {"at": None}
1290
- run_live_s = 600
1291
- this_machine = re.compile(r"^(?:localhost|127(?:\.\d+){3}|::1|0\.0\.0\.0)$", re.I)
1292
- # ponytail: a sign-in's token refresh is a POST that changes nothing, told apart by its path only.
1293
- token_path = re.compile(r"/(?:oauth2?/)?token(?:[/?]|$)", re.I)
1294
- # A provider's own sandbox, named so in the host: api.sandbox.paypal.com, sandbox-api.polar.sh.
1295
- sandbox_host = re.compile(r"(?:^|[.-])(?:sandbox|sbox)[.-]", re.I)
1296
- test_setting = re.compile(r"(?:^|_)TEST(?:_|$)", re.I)
1297
- # An API where every call is a POST names what a call does in its last segment, as
1298
- # resource.verb_words (customers.get, billing.preview_attach) or as a gRPC or Connect method
1299
- # (acme.v1.UserService/GetUser). A POST so named that starts with a reading word, and holds no word
1300
- # that makes the read a write (get_or_create, check_in, GetLease), goes out.
1301
- # ponytail: decided on the name alone, so a POST named as a read whose body writes goes out: a
1302
- # check that also records usage, or a file created under a name such as allow.list.
1303
- dot_call = re.compile(r"[A-Za-z][\w-]*\.[a-z][a-z_]*", re.A)
1304
- dot_read = {"get", "list", "check", "search", "query", "find", "lookup", "retrieve", "describe", "count", "preview"}
1305
- # A preview commits nothing whatever it previews; any other reading word reads only while no word
1306
- # after it writes: credits.check_deduct and usage.count_increment went out as reads.
1307
- write_word = set("create|update|delete|remove|set|add|put|insert|upsert|write|save|store|record|track|log|send|deduct|increment|decrement|consume|use|spend|charge|pay|refund|cancel|reserve|lock|unlock|commit|apply|attach|detach|start|stop|run|trigger|reset|rotate|revoke|grant|issue|mint|transfer|move|merge|sync|import|export|upload|publish|enqueue|push|pop|ack|touch|mark|close|approve|reject|submit|confirm|redeem|claim".split("|"))
1308
- dot_not = {"or", "and", "in", "out", "next"} | write_word
1309
- rpc_call = re.compile(r"(?:[A-Z][a-z0-9]*)+")
1310
- rpc_read = {"Get", "List", "Search", "Query", "Find", "Lookup", "Describe"}
1311
- rpc_not = {"or", "and", "next", "lease"} | write_word
1312
- service_name = re.compile(r"\.[A-Z]\w*\Z", re.A)
1313
-
1314
- def reads_by_name(method, path):
1315
- if str(method or "").upper() != "POST":
1316
- return False
1317
- parts = [p for p in (path or "").split("/") if p]
1318
- last = parts[-1] if parts else ""
1319
- before = parts[-2] if len(parts) > 1 else ""
1320
- if dot_call.fullmatch(last):
1321
- words = last.split(".")[1].split("_")
1322
- return words[0] in dot_read and (words[0] == "preview" or not any(w in dot_not for w in words[1:]))
1323
- if service_name.search(before) and rpc_call.fullmatch(last):
1324
- words = re.findall(r"[A-Z][a-z0-9]*", last)
1325
- return words[0] in rpc_read and not any(w.lower() in rpc_not for w in words[1:])
1326
- return False
1327
-
1328
- def pass_now():
1329
- try:
1330
- at = os.stat(_OUTBOUND).st_mtime_ns
1331
- if at != passed["at"]:
1332
- passed["at"] = at
1333
- with open(_OUTBOUND, encoding="utf-8") as f:
1334
- got = json.load(f).get("pass")
1335
- passed["hosts"] = [h for h in got if isinstance(h, str) and h] if isinstance(got, list) else []
1336
- except Exception:
1337
- pass
1338
- return passed["hosts"]
1339
-
1340
- # A provider's test-mode key, named so in the key itself whatever comes before it (Stripe's
1341
- # sk_test_, am_sk_test_, pdl_sdbx_), or a value of a setting the app keeps as a test one.
1342
- def test_key(auth):
1343
- # `requests` takes a header value as bytes, and str() of one is "b'...'", which no key matches.
1344
- a = auth.decode("utf-8", "replace") if isinstance(auth, (bytes, bytearray)) else str(auth or "")
1345
- m = re.match(r"^Basic\s+(\S+)$", a, re.I)
1346
- if m:
1347
- try:
1348
- a = base64.b64decode(m.group(1)).decode("utf-8", "replace")
1349
- except Exception:
1350
- pass
1351
- # In the prefix of the key itself, set off by underscores: a Basic user named test-admin and a
1352
- # live key holding "-Test-" are not test keys.
1353
- return bool(a) and (any(re.fullmatch(r"(?:[a-z0-9]{2,8}_){0,3}(?:test|sandbox|sbox|sdbx)_[\w-]{4,}", piece, re.I | re.A) for piece in re.split(r"[\s:]+", a)) or any(v and len(v) >= 8 and v in a for k, v in os.environ.items() if test_setting.search(k)))
1354
-
1355
- # The headers a key travels in.
1356
- key_headers = ("authorization", "x-api-key", "api-key", "apikey", "x-auth-token")
1357
-
1358
- def keys_in(headers):
1359
- return [headers.get(n) for n in key_headers]
1360
-
1361
- # `keys_of()`: the values of the headers a key travels in, read only for a call that is otherwise
1362
- # held. Once a tagged write is being decided, a failure to decide holds it.
1363
- def held_here(url, method, keys_of):
1364
- req = ctx.get()
1365
- if str(method or "").upper() not in ("POST", "PUT", "PATCH", "DELETE"):
1366
- return False
1367
- if req:
1368
- if not req.get("turn"):
1369
- return False
1370
- elif run_seen["at"] is None or time.monotonic() - run_seen["at"] > run_live_s:
1371
- return False
1372
- # A read of a Qdrant collection, however its server is named, and any call to a copy this
1373
- # hook made (copy_of below), go out.
1374
- q = qdrant_call(url, method)
1375
- if q and (not q["write"] or (q["collection"] and our_copy.search(q["collection"]))):
1376
- return False
1377
- try:
1378
- parts = urlsplit(str(url))
1379
- host = (parts.hostname or "").lower()
1380
- if this_machine.match(host) or sandbox_host.search(host) or is_model_call(url) or is_retrieval(url) or token_path.search(parts.path or "") or reads_by_name(method, parts.path) or any(test_key(k) for k in keys_of()):
1381
- return False
1382
- return not any(host == p or host.endswith("." + p) for p in pass_now())
1383
- except Exception:
1384
- return True
1385
-
1386
- # A write to a Qdrant collection made inside a run goes to a copy of that collection on the same
1387
- # server, made at the first such write: the collection is read page by page and written under
1388
- # `<name>_cortad_<pid>`, and from then on every call a run makes to that collection, reads too,
1389
- # goes to the copy, so the app finds what it wrote. The original is never written. A copy that
1390
- # cannot be made (over the size a run copies, a server that refused) leaves the write held like
1391
- # any other. Each copy is reported as a `copy` row and the command deletes them when it ends.
1392
- # The person's own calls, outside a run, go where they were sent (lib/trace.cjs does the same).
1393
- import threading
1394
- our_copy = re.compile(r"_cortad_\d+$")
1395
- copies = {}
1396
- copies_lock = threading.Lock()
1397
- qdrant_cap = 200000
1398
- # What one process copies at most: collections, and points across them; past it a write is held.
1399
- copies_max = 8
1400
- points_max = 300000
1401
- copied = {"collections": 0, "points": 0}
1402
- copy_said = set()
1403
-
1404
- def copy_say(host, collection, why):
1405
- if (host, collection) in copy_said:
1406
- return
1407
- copy_said.add((host, collection))
1408
- try:
1409
- sys.stderr.write("cortad: a request Cortad sent made this app write into the Qdrant collection %s at %s. Cortad could not copy that collection on its server (%s), so the write was kept on this machine and answered with a stand-in success.\n" % (collection, host, why))
1410
- except Exception:
1411
- pass
1412
-
1413
- def in_run():
1414
- req = ctx.get()
1415
- if req:
1416
- return bool(req.get("turn"))
1417
- return run_seen["at"] is not None and time.monotonic() - run_seen["at"] <= run_live_s
1418
-
1419
- # None to leave the call alone; hold for a write of the server itself; else the collection's
1420
- # copy when there is one, or none yet for a write, which makes it.
1421
- def guarded(url, method):
1422
- call = qdrant_call(url, method)
1423
- if not call:
1424
- return None
1425
- if not call["collection"]:
1426
- return {"hold": True} if call["write"] and in_run() else None
1427
- if our_copy.search(call["collection"]) or not in_run():
1428
- return None
1429
- key = "%s|%s" % (call["parts"].netloc, call["collection"])
1430
- copy = copies.get(key)
1431
- if copy:
1432
- return {"key": key, "call": call, "copy": copy}
1433
- if not call["write"]:
1434
- return None
1435
- if call["kind"] == "schema":
1436
- return {"hold": True}
1437
- if copied["collections"] >= copies_max or copied["points"] >= points_max:
1438
- copy_say(call["parts"].netloc, call["collection"], "this run already copied %d collections, %d points" % (copied["collections"], copied["points"]))
1439
- return {"hold": True}
1440
- return {"key": key, "call": call, "copy": None}
1441
-
1442
- # Qdrant's own API over urllib, which this file leaves unpatched: never through the app's clients.
1443
- def qdrant_http(base, key, method, path, body=None):
1444
- import urllib.error
1445
- import urllib.request
1446
- data = json.dumps(body).encode("utf-8") if body is not None else None
1447
- headers = {"content-type": "application/json"}
1448
- if key:
1449
- headers["api-key"] = key
1450
- req = urllib.request.Request(base + path, data=data, method=method, headers=headers)
1451
- try:
1452
- with urllib.request.urlopen(req, timeout=30) as r:
1453
- return json.loads(r.read().decode("utf-8", "replace") or "null"), r.status
1454
- except urllib.error.HTTPError as e:
1455
- return None, e.code
1456
-
1457
- # The copy made: "empty" when the server holds no such collection yet (the app makes it, under
1458
- # the copy's name), "points" once every point is across, None when none could be made.
1459
- def clone_qdrant(base, key, source, clone):
1460
- at = lambda name: "/collections/" + quote(name, safe="")
1461
- info, status = qdrant_http(base, key, "GET", at(source))
1462
- if status == 404:
1463
- return ("empty", 0)
1464
- result = (info or {}).get("result") if status == 200 else None
1465
- if not result:
1466
- return None
1467
- if (result.get("points_count") or 0) > qdrant_cap:
1468
- return None
1469
- params = (result.get("config") or {}).get("params") or {}
1470
- made = {"vectors": params.get("vectors")}
1471
- if params.get("sparse_vectors"):
1472
- made["sparse_vectors"] = params["sparse_vectors"]
1473
- if "on_disk_payload" in params:
1474
- made["on_disk_payload"] = params["on_disk_payload"]
1475
- if qdrant_http(base, key, "PUT", at(clone), made)[1] != 200:
1476
- return None
1477
- try:
1478
- for field, schema in (result.get("payload_schema") or {}).items():
1479
- qdrant_http(base, key, "PUT", at(clone) + "/index?wait=true", {"field_name": field, "field_schema": schema.get("params") or schema.get("data_type")})
1480
- offset = None
1481
- while True:
1482
- page, status = qdrant_http(base, key, "POST", at(source) + "/points/scroll", {"limit": 256, "with_payload": True, "with_vector": True, **({"offset": offset} if offset is not None else {})})
1483
- if status != 200:
1484
- raise RuntimeError(status)
1485
- got = (page or {}).get("result") or {}
1486
- points = got.get("points") or []
1487
- if points and qdrant_http(base, key, "PUT", at(clone) + "/points?wait=true", {"points": [{"id": p.get("id"), "vector": p.get("vector"), "payload": p.get("payload")} for p in points]})[1] != 200:
1488
- raise RuntimeError("upsert")
1489
- offset = got.get("next_page_offset")
1490
- if offset is None:
1491
- return ("points", result.get("points_count") or 0)
1492
- except Exception:
1493
- qdrant_http(base, key, "DELETE", at(clone))
1494
- return None
1495
-
1496
- # The copy's name, made now when none exists; None when none could be made. The key the app
1497
- # sent is the key the copy is made with, and never leaves this process.
1498
- def copy_of(g, header_of):
1499
- with copies_lock:
1500
- have = copies.get(g["key"])
1501
- if have:
1502
- return have
1503
- parts = g["call"]["parts"]
1504
- base = "%s://%s" % (parts.scheme, parts.netloc)
1505
- auth = str(header_of("authorization") or "")
1506
- bearer = re.match(r"^Bearer\s+(\S+)", auth, re.I)
1507
- key = str(header_of("api-key") or "") or (bearer.group(1) if bearer else "")
1508
- clone = "%s_cortad_%d" % (g["call"]["collection"][:40], os.getpid())
1509
- made = clone_qdrant(base, key, g["call"]["collection"], clone)
1510
- if not made:
1511
- copy_say(parts.netloc, g["call"]["collection"], "the copy did not go through")
1512
- return None
1513
- copied["collections"] += 1
1514
- copied["points"] += made[1]
1515
- copies[g["key"]] = clone
1516
- write({"copy": {"host": parts.hostname or "", "base": base, "collection": g["call"]["collection"], "clone": clone}})
1517
- return clone
1518
-
1519
- def to_copy(url, clone, call):
1520
- parts = call["parts"]
1521
- return urlunsplit((parts.scheme, parts.netloc, "/collections/" + quote(clone, safe="") + call["rest"], parts.query, parts.fragment))
1522
-
1523
- # The copy a call goes to, waited for off the event loop: None to hold the call instead.
1524
- async def copy_for(g, header_of):
1525
- import asyncio
1526
- if g.get("hold"):
1527
- return None
1528
- return g["copy"] or await asyncio.get_running_loop().run_in_executor(None, copy_of, g, header_of)
1529
-
1530
- # A GraphQL query reads; a mutation writes. A `query` in JSON that is not GraphQL (SQL over HTTP,
1531
- # where a SELECT can write through a function) is held.
1532
- # ClickHouse, Trino and Presto take every query as a POST of its text, a SELECT too, and a SELECT
1533
- # there cannot write: one statement that starts as a read and holds no word that writes, sent to
1534
- # what its address or its headers show is one of them, goes out (lib/trace.cjs does the same).
1535
- sql_quoted = re.compile(r"""'(?:[^'\\]|\\.|'')*'|"(?:[^"\\]|\\.|"")*"|`[^`]*`|--[^\n]*|/\*[\s\S]*?\*/""")
1536
- sql_shows = re.compile(r"^(?:show|describe|desc|exists)\b", re.I | re.A)
1537
- sql_selects = re.compile(r"^(?:select|with|explain)\b", re.I | re.A)
1538
- sql_writes = re.compile(r"(?<![.\w])(?:insert|update|delete|merge|into|create|alter|drop|truncate|refresh|call|execute)(?![.\w])", re.I | re.A)
1539
- sql_hands_on = re.compile(r"\b(?:table|query|jdbc|odbc|executable)\s*\(", re.I | re.A)
1540
- sql_open = re.compile(r"""[;'"`]|/\*|\$\$""")
1541
- analytics_name = re.compile(r"clickhouse|trino|presto", re.I)
1542
- analytics_headers = ("x-clickhouse-user", "x-clickhouse-key", "x-clickhouse-database", "x-clickhouse-format", "x-trino-user", "x-presto-user")
1543
-
1544
- def statement_of(sql):
1545
- bare = sql_quoted.sub(lambda m: "''" if m.group(0)[0] == "'" else "x" if m.group(0)[0] in '"`' else " ", str(sql or "")).strip()
1546
- return re.sub(r";\s*$", "", bare)
1547
-
1548
- # A quote or a comment left open, a second statement or a dollar-quoted body is not read as a read.
1549
- def selects(bare):
1550
- if sql_open.search(bare.replace("''", "")):
1551
- return False
1552
- 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))
1553
-
1554
- def analytics(parts, header_of):
1555
- try:
1556
- port = parts.port
1557
- except ValueError:
1558
- port = None
1559
- return port in (8123, 8443) or bool(analytics_name.search(parts.hostname or "")) or bool(analytics_name.search(str(header_of("user-agent") or ""))) \
1560
- or any(header_of(name) for name in analytics_headers) or parts.path == "/v1/statement"
1561
-
1562
- def reads_only(raw, url=None, header_of=None):
1563
- sent = str(raw or "").strip()
1564
- b = parsed(sent) if sent else None
1565
- if b is not None:
1566
- q = b.get("query") if isinstance(b, dict) else None
1567
- return isinstance(q, str) and bool(re.match(r"^\s*(?:\{|query\b|fragment\b)", q)) and not re.search(r"\bmutation\b", q)
1568
- try:
1569
- parts = urlsplit(str(url)) if url else None
1570
- if parts is None or not analytics(parts, header_of or (lambda name: None)):
1571
- return False
1572
- # ClickHouse joins a `query` in the address to the body: both are read as the one statement.
1573
- asked = "%s %s" % (dict(parse_qsl(parts.query)).get("query", ""), sent)
1574
- # ClickHouse reads a backslash before a quote as part of the string and Trino as its end,
1575
- # so where a string stops is not known here: held.
1576
- return not re.search(r"""\\['"`]""", asked) and selects(statement_of(asked))
1577
- except Exception:
1578
- return False
1579
-
1580
- held_count = itertools.count(1)
1581
-
1582
- def fields_of(raw, kind):
1583
- try:
1584
- b = dict(parse_qsl(raw)) if "form" in (kind or "") else json.loads(raw)
1585
- except Exception:
1586
- return {}
1587
- return b if isinstance(b, dict) else {}
1588
-
1589
- def held_reply(raw, kind):
1590
- return json.dumps({**fields_of(raw, kind), "id": "cortad-held-%d" % next(held_count), "status": "ok"}).encode("utf-8")
1591
-
1592
- # Said once per method and host in the app's own output, where the error that follows is read:
1593
- # a stand-in an SDK cannot parse otherwise reads as a bug in the app. It holds no word that marks
1594
- # an error, so it is never quoted back as the app's own.
1595
- held_said = set()
1596
-
1597
- def held_say(method, host):
1598
- if (method, host) in held_said:
1599
- return
1600
- held_said.add((method, host))
1601
- try:
1602
- 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))
1603
- except Exception:
1604
- pass
1605
-
1606
- # Named by method and host only: a webhook's path is its credential.
1607
- def held_row(url, method, raw, kind, req):
1608
- try:
1609
- parts = urlsplit(str(url))
1610
- held_say(str(method).upper(), (parts.hostname or "")[:253])
1611
- write({"dep": {"at": int(time.time() * 1000), "host": (parts.hostname or "")[:253], "status": 200, "held": True, **inside_of(req),
1612
- "called": [{"name": ("%s %s" % (str(method).upper(), parts.hostname))[:80], "arguments": args_text(fields_of(raw, kind))}]}})
1613
- except Exception:
1614
- pass
1615
-
1616
- # aiohttp builds its own response from a connection, so a held call is sent to a server of this
1617
- # hook's own on this machine, which answers it.
1618
- held_server = {"port": None}
1619
- held_waiting = {}
1620
- held_sent = itertools.count(1)
1621
-
1622
- def held_port():
1623
- if held_server["port"] is None:
1624
- try:
1625
- import threading
1626
- from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
1627
-
1628
- class Answer(BaseHTTPRequestHandler):
1629
- def answer(self):
1630
- size = int(self.headers.get("content-length") or 0)
1631
- raw = self.rfile.read(min(size, limit)).decode("utf-8", "replace") if size else ""
1632
- kind = self.headers.get("content-type", "")
1633
- waiting = held_waiting.pop(self.headers.get("x-cortad-held", ""), None)
1634
- if waiting:
1635
- held_row(waiting[0], waiting[1], raw, kind, waiting[2])
1636
- out = held_reply(raw, kind)
1637
- self.send_response(200)
1638
- self.send_header("content-type", "application/json")
1639
- self.send_header("content-length", str(len(out)))
1640
- self.end_headers()
1641
- self.wfile.write(out)
1642
-
1643
- do_POST = do_PUT = do_PATCH = do_DELETE = answer
1644
-
1645
- def log_message(self, *a):
1646
- pass
1647
-
1648
- server = ThreadingHTTPServer(("127.0.0.1", 0), Answer)
1649
- server.daemon_threads = True
1650
- _held_ports.append(server.server_address[1])
1651
- threading.Thread(target=server.serve_forever, daemon=True).start()
1652
- held_server["port"] = server.server_address[1]
1653
- except Exception:
1654
- return None
1655
- return held_server["port"]
1656
-
1657
1272
  def said_of(req, name):
1658
1273
  return [v for k, v in req.get("reply_headers") or [] if k == name]
1659
1274
 
@@ -2181,8 +1796,6 @@ def _install():
2181
1796
  req = {"id": "%d.%d" % (os.getpid(), next(serial)), "at": int(time.time() * 1000), "method": method, "path": path, "headers": headers,
2182
1797
  "chunks": [], "size": 0, "noted": False, "kept": False, "sent": [], "reply": [], "reply_size": 0, "writes": 0,
2183
1798
  "turn": turn if isinstance(turn, str) and turn_ok.match(turn) else None}
2184
- if req["turn"]:
2185
- run_seen["at"] = time.monotonic()
2186
1799
  if tied:
2187
1800
  tied.opened(req)
2188
1801
  return req
@@ -2642,37 +2255,11 @@ def _install():
2642
2255
  dep(request.url, response.status_code, req=req, caller=caller)
2643
2256
  return response
2644
2257
 
2645
- def held_answer(request, sent):
2646
- kind = request.headers.get("content-type", "")
2647
- held_row(request.url, request.method, sent, kind, ctx.get())
2648
- return module.Response(200, headers={"content-type": "application/json"}, content=held_reply(sent, kind), request=request)
2649
-
2650
- # A held call that cannot be answered fails in the app; it is never sent instead.
2651
- def held_httpx(request, sent):
2652
- if not held_here(request.url, request.method, lambda: keys_in(request.headers)) or reads_only(sent, request.url, request.headers.get):
2653
- return None
2654
- return held_answer(request, sent)
2655
-
2656
- # A call to a Qdrant collection goes to the run's copy of it, `clone`; with none, it is held.
2657
- def routed_httpx(request, g, clone, sent):
2658
- if clone is None:
2659
- return held_answer(request, sent)
2660
- request.url = module.URL(to_copy(request.url, clone, g["call"]))
2661
- return None
2662
-
2663
2258
  for cls in (module.Client, module.AsyncClient):
2664
2259
  send = cls.send
2665
2260
  if cls is module.Client:
2666
2261
  def sync_send(self, request, *a, _send=send, **k):
2667
2262
  sent = sent_of(request)
2668
- g = guarded(request.url, request.method)
2669
- if g is not None:
2670
- answered = routed_httpx(request, g, None if g.get("hold") else (g["copy"] or copy_of(g, request.headers.get)), sent)
2671
- if answered is not None:
2672
- return answered
2673
- held = held_httpx(request, sent)
2674
- if held is not None:
2675
- return held
2676
2263
  caller = callers_now(request.url) if is_model_call(request.url) or is_retrieval(request.url) else None
2677
2264
  t0 = time.time()
2678
2265
  req = noted(request.url, sent)
@@ -2693,14 +2280,6 @@ def _install():
2693
2280
  else:
2694
2281
  async def async_send(self, request, *a, _send=send, **k):
2695
2282
  sent = sent_of(request)
2696
- g = guarded(request.url, request.method)
2697
- if g is not None:
2698
- answered = routed_httpx(request, g, await copy_for(g, request.headers.get), sent)
2699
- if answered is not None:
2700
- return answered
2701
- held = held_httpx(request, sent)
2702
- if held is not None:
2703
- return held
2704
2283
  caller = callers_now(request.url) if is_model_call(request.url) or is_retrieval(request.url) else None
2705
2284
  t0 = time.time()
2706
2285
  req = noted(request.url, sent)
@@ -2722,36 +2301,7 @@ def _install():
2722
2301
  def patch_requests(module):
2723
2302
  send = module.Session.send
2724
2303
 
2725
- # A held call that cannot be answered fails in the app; it is never sent instead.
2726
- def held(request):
2727
- if not held_here(request.url, request.method, lambda: keys_in(request.headers)):
2728
- return None
2729
- raw = text(request.body, reply_max)
2730
- if reads_only(raw, request.url, request.headers.get):
2731
- return None
2732
- return held_answer(request, raw)
2733
-
2734
- def held_answer(request, raw):
2735
- import datetime
2736
- models, structures = sys.modules["requests.models"], sys.modules["requests.structures"]
2737
- kind = request.headers.get("content-type", "")
2738
- held_row(request.url, request.method, raw, kind, ctx.get())
2739
- response = models.Response()
2740
- response.status_code, response.reason, response.url, response.request, response.encoding = 200, "OK", request.url, request, "utf-8"
2741
- response.headers = structures.CaseInsensitiveDict({"content-type": "application/json"})
2742
- response._content, response._content_consumed, response.elapsed = held_reply(raw, kind), True, datetime.timedelta(0)
2743
- return response
2744
-
2745
2304
  def sent(self, request, *a, **k):
2746
- g = guarded(request.url, request.method)
2747
- if g is not None:
2748
- clone = None if g.get("hold") else (g["copy"] or copy_of(g, request.headers.get))
2749
- if clone is None:
2750
- return held_answer(request, text(request.body, reply_max))
2751
- request.url = to_copy(request.url, clone, g["call"])
2752
- answered = held(request)
2753
- if answered is not None:
2754
- return answered
2755
2305
  caller = callers_now(request.url) if is_model_call(request.url) or is_retrieval(request.url) else None
2756
2306
  t0 = time.time()
2757
2307
  req = noted(request.url, request.body)
@@ -2781,57 +2331,8 @@ def _install():
2781
2331
  def patch_aiohttp(module):
2782
2332
  request = module.ClientSession._request
2783
2333
 
2784
- def keys_of(self, k):
2785
- given = {str(n).lower(): v for n, v in dict(k.get("headers") or {}).items()}
2786
- defaults = {str(n).lower(): v for n, v in dict(getattr(self, "_default_headers", None) or {}).items()}
2787
- basic = k.get("auth") or getattr(self, "_default_auth", None)
2788
- return [given.get(n) or defaults.get(n) for n in key_headers] + [basic.encode() if basic else None]
2789
-
2790
- def header_of(self, k, name):
2791
- for headers in (k.get("headers"), getattr(self, "_default_headers", None)):
2792
- for n, v in dict(headers or {}).items():
2793
- if str(n).lower() == name:
2794
- return v
2795
- return None
2796
-
2797
- # A path relative to the session's base address is decided on the whole address.
2798
- def whole(self, str_or_url):
2799
- try:
2800
- return str(self._build_url(str_or_url))
2801
- except Exception:
2802
- return str(str_or_url)
2803
-
2804
2334
  async def requested(self, method, str_or_url, *a, **k):
2805
2335
  body = k.get("json") if k.get("json") is not None else k.get("data")
2806
- url = whole(self, str_or_url)
2807
- g = guarded(url, method)
2808
- hold = False
2809
- if g is not None:
2810
- clone = await copy_for(g, lambda name: header_of(self, k, name))
2811
- if clone is None:
2812
- hold = True
2813
- else:
2814
- parts = g["call"]["parts"]
2815
- url = to_copy(url, clone, g["call"])
2816
- # A session with a base address takes the path alone.
2817
- str_or_url = url if getattr(self, "_base_url", None) is None else url[len("%s://%s" % (parts.scheme, parts.netloc)):]
2818
- if hold or (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))):
2819
- port = held_port()
2820
- if port is None:
2821
- raise module.ClientConnectionError("this write was kept from leaving and could not be answered")
2822
- parts = urlsplit(url)
2823
- n = str(next(held_sent))
2824
- held_waiting[n] = (url, method, ctx.get())
2825
- k = {**k, "headers": {**dict(k.get("headers") or {}), "x-cortad-held": n}}
2826
- target = "http://127.0.0.1:%d%s%s" % (port, parts.path or "/", "?" + parts.query if parts.query else "")
2827
- if getattr(self, "_base_url", None) is None:
2828
- return await request(self, method, target, *a, **k)
2829
- # A session with a base address refuses a whole one on older aiohttp: the held call is
2830
- # answered through a session of its own, read before that session closes.
2831
- async with module.ClientSession() as own:
2832
- answered = await request(own, method, target, *a, **k)
2833
- await answered.read()
2834
- return answered
2835
2336
  caller = callers_now(str_or_url) if is_model_call(str_or_url) or is_retrieval(str_or_url) else None
2836
2337
  t0 = time.time()
2837
2338
  req = noted(str_or_url, body)
@@ -2974,7 +2475,7 @@ def _install_connects():
2974
2475
  if not isinstance(address, tuple) or len(address) < 2 or len(seen) >= 64:
2975
2476
  return
2976
2477
  host, port = str(address[0]).lower()[:253], int(address[1])
2977
- if (host, port) in seen or port in _held_ports:
2478
+ if (host, port) in seen:
2978
2479
  return
2979
2480
  seen.add((host, port))
2980
2481
  fd = os.open(_FILE, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
package/lib/reach.mjs CHANGED
@@ -2,12 +2,12 @@ import { isIPv4 } from "node:net";
2
2
  import { unreached } from "./contact-text.mjs";
3
3
  import { plural, upper } from "./words.mjs";
4
4
 
5
- // One request to each endpoint status lists as not reached, from this machine to the app on its port.
6
- // Each body is the one Cortad read that endpoint's client sending (the server's `request`).
7
- // The connect command sends these by itself, up to four at a time, each tagged x-cortad-turn: reach:auto:<n>
8
- // (sendOurs), signed in only as a test account this machine made. Headers are never the agent's: they
9
- // go from here to the app on this machine and nowhere else, and only their names are printed. These
10
- // are real requests with real effects.
5
+ // One request to each endpoint status lists as not reached, from this machine to the app on its port,
6
+ // when the agent asks for it. Each body is the one Cortad read that endpoint's client sending (the
7
+ // server's `request`). The connect command sends them up to four at a time, each tagged x-cortad-turn:
8
+ // reach:agent:<n> (sendOurs), signed in as the person's own request was where one was seen. Headers
9
+ // are never the agent's: they go from here to the app on this machine and nowhere else, and only their
10
+ // names are printed. These are real requests with real effects.
11
11
 
12
12
  // Under the 60 seconds Codex, Cursor CLI and Copilot CLI give an MCP call, with the status read first.
13
13
  export const REACH_MS = 55_000;
@@ -82,7 +82,7 @@ export function reachText({ rows, sent, left, unknown }, answers, how) {
82
82
  const AT_ONCE = 4;
83
83
  export async function sendOurs(rows, { runner, origin, signIn = async () => null, next, stop = () => false, onAnswer = () => {}, fetchImpl = fetch, atOnce = AT_ONCE }) {
84
84
  const app = appUrl(runner);
85
- const base = () => ({ ...(origin ? { origin, referer: `${origin}/` } : {}), [RUN_TURN]: `reach:auto:${next()}` });
85
+ const base = () => ({ ...(origin ? { origin, referer: `${origin}/` } : {}), [RUN_TURN]: `reach:agent:${next()}` });
86
86
  const answers = [];
87
87
  let at = 0, stopped = false;
88
88
  const one = async (m) => {
package/lib/read-text.mjs CHANGED
@@ -96,7 +96,6 @@ export function machineText(f) {
96
96
  ? `Cortad started your app in ${f.dir === "." ? "this folder" : f.dir} and stops it when this command stops. Your code and settings files are never edited.`
97
97
  : `Cortad uses your app already running on port ${f.port} and leaves it running. Your code and settings files are never edited.`,
98
98
  ];
99
- if (f.copies === "made") lines.push("Test conversations save into a copy of your database, made fresh at each connect and deleted when this command stops. Your own data is not changed.");
100
99
  // Env values reach only a process the command started itself.
101
100
  if (f.started) lines.push(`Your env values stay on this machine${f.changed?.length ? `, with ${listed(f.changed)} set for this session only` : ""}. None is sent to Cortad.`);
102
101
  lines.push(f.hooked ? "Cortad sees each request that reaches your model and the model calls it makes. Request headers stay on this machine and are deleted when the command stops."