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.
- package/README.md +13 -9
- package/lib/auto-reach.mjs +6 -5
- package/lib/contact-text.mjs +3 -4
- package/lib/env-files.mjs +0 -85
- package/lib/files.mjs +5 -0
- package/lib/fresh.mjs +1 -1
- package/lib/mcp.mjs +3 -3
- package/lib/proc.mjs +3 -1
- package/lib/proxy.mjs +14 -3
- package/lib/pyhook/sitecustomize.py +1 -500
- package/lib/reach.mjs +7 -7
- package/lib/read-text.mjs +0 -1
- package/lib/replay.mjs +6 -11
- package/lib/start.mjs +13 -34
- package/lib/switches.mjs +9 -69
- package/lib/trace.cjs +2 -320
- package/local.mjs +167 -517
- package/package.json +2 -17
- package/skill/SKILL.md +12 -10
- package/skill/references/results.md +5 -5
- package/lib/backing.mjs +0 -230
- package/lib/container.mjs +0 -60
- package/lib/data.mjs +0 -143
- package/lib/elastic.mjs +0 -88
- package/lib/hosted.mjs +0 -201
- package/lib/identities.mjs +0 -689
- package/lib/keeping.mjs +0 -181
- package/lib/ledger.mjs +0 -20
- package/lib/listing.mjs +0 -12
- package/lib/mint.mjs +0 -350
- package/lib/mongo.mjs +0 -135
- package/lib/pg.mjs +0 -118
- package/lib/qdrant.mjs +0 -48
- package/lib/redis.mjs +0 -130
- package/lib/service.mjs +0 -96
- package/lib/stores.mjs +0 -433
|
@@ -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
|
|
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
|
|
7
|
-
// The connect command sends
|
|
8
|
-
// (sendOurs), signed in
|
|
9
|
-
// go from here to the app on this machine and nowhere else, and only their
|
|
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:
|
|
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."
|