cortad 0.3.10 → 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 +14 -7
- package/lib/auto-reach.mjs +6 -5
- package/lib/cli.mjs +4 -1
- package/lib/commits.mjs +202 -0
- package/lib/contact-text.mjs +3 -4
- package/lib/env-files.mjs +39 -0
- package/lib/env-secrets.mjs +12 -4
- package/lib/files.mjs +5 -0
- package/lib/fresh.mjs +1 -1
- package/lib/intel-text.mjs +47 -0
- package/lib/keys.mjs +10 -6
- package/lib/lock.mjs +7 -6
- package/lib/mcp.mjs +38 -5
- package/lib/proc.mjs +3 -1
- package/lib/proof.mjs +8 -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/lib/verbs.mjs +56 -1
- package/local.mjs +252 -520
- package/package.json +5 -15
- package/skill/SKILL.md +16 -9
- package/skill/references/results.md +5 -5
- package/lib/backing.mjs +0 -230
- package/lib/data.mjs +0 -137
- package/lib/hosted.mjs +0 -201
- package/lib/identities.mjs +0 -689
- package/lib/keeping.mjs +0 -174
- package/lib/ledger.mjs +0 -20
- package/lib/listing.mjs +0 -12
- package/lib/mint.mjs +0 -334
- package/lib/mongo.mjs +0 -74
- 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 -378
package/lib/proc.mjs
CHANGED
|
@@ -58,7 +58,9 @@ export async function listenerOn(port) {
|
|
|
58
58
|
// already in use", Go's "0.0.0.0:8080: bind: address already in use", Node's "EADDRINUSE: address
|
|
59
59
|
// already in use :::3000" or "... 127.0.0.1:3000", Rails' "... for "127.0.0.1" port 3000". 0 when
|
|
60
60
|
// it names none.
|
|
61
|
-
|
|
61
|
+
// The port in the app's own words for a taken one, however its runtime spells the address: Node's
|
|
62
|
+
// "EADDRINUSE: address already in use :::3100", uvicorn's "('127.0.0.1', 58889): [errno 48] address already in use".
|
|
63
|
+
export const portInError = (said) => Number(/[:\s(,](\d{4,5})\)?:?[^\n]{0,40}?address already in use/i.exec(said)?.[1] ?? /(?:EADDRINUSE|address already in use)[^\n]{0,60}?[:\s](\d{4,5})\b/i.exec(said)?.[1] ?? 0);
|
|
62
64
|
|
|
63
65
|
// Whoever holds a port: its pid, the program and its first argument (never the rest of its command
|
|
64
66
|
// line, which can carry a key), and the folder it runs in.
|
package/lib/proof.mjs
CHANGED
|
@@ -991,7 +991,14 @@ export function canaryOf(exchanges, proof, found = null, own = []) {
|
|
|
991
991
|
if (carried(b1)) add("shared-history", "theirs", "A new conversation carried an earlier one's words: every conversation on this endpoint shares what your app keeps, so test conversations would read each other's.");
|
|
992
992
|
const met = metIn(a1, own);
|
|
993
993
|
if (met) add("unreadable", "theirs", met);
|
|
994
|
-
else if (!ok(a1.status)
|
|
994
|
+
else if (!ok(a1.status)) add("unreadable", "theirs", `Your app answered ${a1.status} to the first message.`);
|
|
995
|
+
else if (!replyOf(a1).text.trim()) {
|
|
996
|
+
// No words because the app's own model refused it is the app's key, card or model, never our
|
|
997
|
+
// reading: a wrong key in the twin's env file held Run as "could not be read as words", ours.
|
|
998
|
+
const calls = generation(a1);
|
|
999
|
+
const refused = calls.length && calls.every((c) => REFUSED.has(Number(c.status))) ? calls.at(-1) : null;
|
|
1000
|
+
add("unreadable", refused ? "theirs" : "ours", refused ? `The first message reached your app's model, and the ${modelCall(refused)}, so no words came back.` : "The reply to the first message could not be read as words.");
|
|
1001
|
+
}
|
|
995
1002
|
const instructions = str(generation(a1).find((c) => typeof c.instructions === "string")?.instructions).trim();
|
|
996
1003
|
const pieces = piecesOf(instructions);
|
|
997
1004
|
const request = norm(a1.body);
|
package/lib/proxy.mjs
CHANGED
|
@@ -34,12 +34,23 @@ const asked = (raw) => (/^[^=&\s]+=[^&]*(?:&[^=&\s]+=[^&]*)*$/.test(raw) ? [...n
|
|
|
34
34
|
// ponytail: a worker slower than this gets a request said unseen; the wait per app if one is seen.
|
|
35
35
|
const UNSEEN_MS = 3000;
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
// On the port asked for when it is free, else on any: a container's settings name the model port once.
|
|
38
|
+
const listen = async (server, port = 0) => {
|
|
39
|
+
const at = await new Promise((resolve) => {
|
|
40
|
+
server.once("error", () => resolve(null));
|
|
41
|
+
server.listen(port, "127.0.0.1", () => resolve(server.address().port));
|
|
42
|
+
});
|
|
43
|
+
if (at !== null) return at;
|
|
44
|
+
server.removeAllListeners("error");
|
|
45
|
+
server.listen(0, "127.0.0.1");
|
|
46
|
+
await once(server, "listening");
|
|
47
|
+
return server.address().port;
|
|
48
|
+
};
|
|
38
49
|
|
|
39
50
|
// `file`: the trace file the command reads. `target()`: where the app answers now, { host, port };
|
|
40
51
|
// it moves when the app is started again on another port. `onSeen`: the first model call came through.
|
|
41
52
|
// `onUnseen(door)`: a request the app answered in words with no model call begun, said once, as "POST /chat".
|
|
42
|
-
export async function openProxy({ file, rulesFile, target, onSeen = () => {}, onUnseen = () => {} }) {
|
|
53
|
+
export async function openProxy({ file, rulesFile, target, modelPort: wantedModelPort = 0, onSeen = () => {}, onUnseen = () => {} }) {
|
|
43
54
|
const row = (value) => { try { appendFileSync(file, JSON.stringify(scrubbed(value)) + "\n", { mode: 0o600 }); } catch { /* the command is gone */ } };
|
|
44
55
|
const rec = makeRecorder({ row, rulesFile });
|
|
45
56
|
// The requests in flight through the front that a model call can be made for.
|
|
@@ -156,7 +167,7 @@ export async function openProxy({ file, rulesFile, target, onSeen = () => {}, on
|
|
|
156
167
|
const model = createServer((req, res) => { relay(req, res).catch(() => res.destroy()); });
|
|
157
168
|
|
|
158
169
|
const frontPort = await listen(front);
|
|
159
|
-
const modelPort = await listen(model);
|
|
170
|
+
const modelPort = await listen(model, wantedModelPort);
|
|
160
171
|
row({ hello: "proxy", pid: process.pid });
|
|
161
172
|
const via = (upstream) => {
|
|
162
173
|
if (!slots.includes(upstream)) slots.push(upstream);
|
|
@@ -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)
|