cortad 0.3.3 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/identities.mjs +5 -1
- package/lib/keeping.mjs +16 -9
- package/lib/proof.mjs +15 -6
- package/lib/pyhook/cortad_tied.py +48 -4
- package/lib/pyhook/sitecustomize.py +62 -10
- package/lib/replay.mjs +28 -5
- package/lib/start.mjs +8 -0
- package/lib/stores.mjs +17 -13
- package/lib/tied.cjs +40 -5
- package/lib/trace.cjs +59 -9
- package/lib/wire.cjs +6 -3
- package/local.mjs +17 -11
- package/package.json +1 -1
package/lib/identities.mjs
CHANGED
|
@@ -34,6 +34,10 @@ const STATUSES = new Set(["minted", "sealed", "absent", "refused", "unreachable"
|
|
|
34
34
|
// so USER_USERNAME pairs with USER_PASSWORD and TUDUDI_USER_EMAIL with TUDUDI_USER_PASSWORD.
|
|
35
35
|
const USER_TOKEN = /_(?:USERNAME|USER|EMAIL|LOGIN)$/;
|
|
36
36
|
const SECRET_TOKEN = /_(?:PASSWORD|PASS|SECRET)$/;
|
|
37
|
+
// A pair that opens a store or a mail server is never the app's own account: PG_DB_USER with
|
|
38
|
+
// PG_DB_PASSWORD signs in to Postgres, and offered to a sign-in form it sent a database password
|
|
39
|
+
// to the app's login route.
|
|
40
|
+
const SERVICE_PREFIX = /(?:^|_)(?:DB|DATABASE|SQL|PG|PSQL|POSTGRES(?:QL)?|MYSQL|MARIADB|MSSQL|MONGO(?:DB)?|REDIS|VALKEY|ELASTIC(?:SEARCH)?|OPENSEARCH|ES|QDRANT|NEO4J|CLICKHOUSE|RABBIT(?:MQ)?|AMQP|KAFKA|SMTP|MAIL|EMAIL|S3|MINIO|FTP|SFTP|LDAP|PROXY)(?:_|$)/;
|
|
37
41
|
const CRED_USER_FIELD = /^(?:e-?mail|user(?:_?name)?|login|account|identifier)$/i;
|
|
38
42
|
const CRED_PASS_FIELD = /^(?:password|passcode|pass|pwd)$/i;
|
|
39
43
|
const SIGN_IN_ROUTE = /(?:^|\/)(?:login|signin|sign-in|sign_in|session|token|authenticate)(?:\/|$)/i;
|
|
@@ -586,7 +590,7 @@ export function credentialPairs(names) {
|
|
|
586
590
|
const seen = new Set();
|
|
587
591
|
for (const u of users) {
|
|
588
592
|
const s = secrets.find((x) => x.prefix === u.prefix);
|
|
589
|
-
if (s && !seen.has(u.prefix)) { seen.add(u.prefix); pairs.push({ user: u.name, secret: s.name, prefix: u.prefix }); }
|
|
593
|
+
if (s && !seen.has(u.prefix) && !SERVICE_PREFIX.test(u.prefix)) { seen.add(u.prefix); pairs.push({ user: u.name, secret: s.name, prefix: u.prefix }); }
|
|
590
594
|
}
|
|
591
595
|
return pairs;
|
|
592
596
|
}
|
package/lib/keeping.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
6
|
import { localAddresses, makeBacking, tcpOpen } from "./backing.mjs";
|
|
7
7
|
import { keepData } from "./data.mjs";
|
|
8
|
-
import { bypassedOf, isStorePort, onThisMachine, storeId, storeKeeper, storesOf,
|
|
8
|
+
import { bypassedOf, isStorePort, onThisMachine, storeId, storeKeeper, storesOf, unnamedHeld } from "./stores.mjs";
|
|
9
9
|
|
|
10
10
|
// A line that opens with a setting's name keeps its case: "nEXT_PUBLIC_SUPABASE_URL" was printed.
|
|
11
11
|
const lower = (line) => (/^[A-Z][A-Z0-9_]/.test(line) ? line : line.charAt(0).toLowerCase() + line.slice(1));
|
|
@@ -34,17 +34,26 @@ function blockedRows(blocked) {
|
|
|
34
34
|
// folder and its source files. launch(plan): a service of the repository started the way the app
|
|
35
35
|
// is. connections(): what the hook saw the app reach. hold(): the run's requests wait from here until
|
|
36
36
|
// the caller has the app on the copies made, since until then it still writes into the originals.
|
|
37
|
-
|
|
37
|
+
// `typed`: the person gave the start command. It is run as it is, with the settings and the stores
|
|
38
|
+
// that command gives the app: a legal research API typed ENV=staging and was started on copies of
|
|
39
|
+
// the test databases its default settings file names, where its library is empty.
|
|
40
|
+
const AS_TYPED = "Your app is started with your own command as it is, on the databases that command gives it. Nothing is copied: a run writes into them once you say yes to each on the card.";
|
|
41
|
+
export function makeKeeping({ root, work, ledgerFile, onPath, say, settings, launch, connections, hold = () => {}, typed = false }) {
|
|
38
42
|
let pending = null;
|
|
39
43
|
let now = null;
|
|
40
44
|
let backing = null;
|
|
41
45
|
let backedUp = false;
|
|
42
46
|
let seen = 0;
|
|
43
|
-
const told = new Set();
|
|
44
47
|
const tell = (lines) => { for (const line of lines) say(lower(line)); };
|
|
45
48
|
const held = (blocked) => tell(blocked.map((b) => `Run waits for your yes on the card in the browser: ${lower(b.why)}`));
|
|
46
49
|
|
|
47
50
|
async function keepAll(started) {
|
|
51
|
+
if (typed) {
|
|
52
|
+
const none = async () => ({ env: {}, risks: [], copies: [], blocked: [], pass: [] });
|
|
53
|
+
now = { env: {}, risks: [], notes: [AS_TYPED], blocked: [], pass: [], stores: [], keeper: { keep: none, drop: async () => {} }, values: {}, started };
|
|
54
|
+
tell(now.notes);
|
|
55
|
+
return now;
|
|
56
|
+
}
|
|
48
57
|
const { values, from, appDir, sources } = settings();
|
|
49
58
|
const disk = keepData({ values, from, appDir, root, work, sources, started });
|
|
50
59
|
const stores = storesOf(disk.values);
|
|
@@ -61,6 +70,7 @@ export function makeKeeping({ root, work, ledgerFile, onPath, say, settings, lau
|
|
|
61
70
|
|
|
62
71
|
// Brings up what is wanted, copies each store that now answers, and says both.
|
|
63
72
|
async function bringUp(wants) {
|
|
73
|
+
if (typed) return { changed: false, came: false, copied: false };
|
|
64
74
|
const k = await once(true);
|
|
65
75
|
hold();
|
|
66
76
|
const up = await backing.up(wants);
|
|
@@ -136,14 +146,11 @@ export function makeKeeping({ root, work, ledgerFile, onPath, say, settings, lau
|
|
|
136
146
|
const k = await pending;
|
|
137
147
|
const live = [];
|
|
138
148
|
for (const c of conns) if (!onThisMachine(c.host) || (await tcpOpen(c.host, c.port))) live.push(c);
|
|
139
|
-
const
|
|
140
|
-
k.risks.push(...fresh);
|
|
141
|
-
tell(fresh);
|
|
142
|
-
const bypassed = bypassedOf(live, k.stores, k.blocked);
|
|
149
|
+
const bypassed = [...bypassedOf(live, k.stores, k.blocked), ...unnamedHeld(live, k.stores, k.blocked)];
|
|
143
150
|
k.blocked.push(...bypassed);
|
|
144
151
|
held(bypassed);
|
|
145
|
-
const got = await bringUp(reached(appPort));
|
|
146
|
-
return { changed:
|
|
152
|
+
const got = typed ? { changed: false, copied: false } : await bringUp(reached(appPort));
|
|
153
|
+
return { changed: bypassed.length > 0 || got.changed, copied: got.copied };
|
|
147
154
|
},
|
|
148
155
|
// An app attached to instead of started keeps its own connections: its copies are of no use.
|
|
149
156
|
async forget() {
|
package/lib/proof.mjs
CHANGED
|
@@ -440,10 +440,17 @@ function carriedFrom(later, earlier, ask) {
|
|
|
440
440
|
|
|
441
441
|
const cookiesIn = (ex) => Object.fromEntries(str(headerOf(ex.headers, "cookie")).split(";").map((c) => c.trim().split("=")).filter(([k, v]) => k && v !== undefined).map(([k, ...v]) => [k, v.join("=")]));
|
|
442
442
|
|
|
443
|
+
// A sign-in is never the conversation. A cookie or a header a trial is sent without is one that only
|
|
444
|
+
// names the conversation: Databuddy's sign-in cookie, refreshed on every reply, was taken for one, and
|
|
445
|
+
// each trial sent without it was refused; an Authorization header that stayed the same would be too.
|
|
446
|
+
const READS_AS_CREDENTIAL = /auth|token|jwt|csrf|xsrf|secret|key|sid$|sessionid|connect\.sid|phpsessid|jsessionid|__session|__host-|__secure-|^sb-|clerk|supabase|laravel_session/i;
|
|
447
|
+
const READS_AS_CONVERSATION = /conv|thread|chat|dialog|session[-_]?id/i;
|
|
448
|
+
const namesConversation = (name) => READS_AS_CONVERSATION.test(name) && !READS_AS_CREDENTIAL.test(name);
|
|
449
|
+
|
|
443
450
|
// What stayed the same from the earlier exchange to the later one and is not the ask: a field the app
|
|
444
451
|
// handed back, a path segment, a field that reads as an id, a cookie the app set, a header, another
|
|
445
452
|
// cookie. The first found carries the session.
|
|
446
|
-
function carrierOf(earlier, later, ask, door) {
|
|
453
|
+
export function carrierOf(earlier, later, ask, door) {
|
|
447
454
|
const same = (a, b) => a !== undefined && a !== "" && a === b;
|
|
448
455
|
const before = new Map(leaves(bodyOf(earlier) ?? {}).map((l) => [l.at.join("."), l.value]));
|
|
449
456
|
const own = leaves(bodyOf(later) ?? {}).filter((l) => !inList(l.at) && fieldOf(l.at) !== ask?.field);
|
|
@@ -466,13 +473,15 @@ function carrierOf(earlier, later, ask, door) {
|
|
|
466
473
|
if (idField) return { carrier: "body", key: idField.at.join("."), minted: "client" };
|
|
467
474
|
const jar = cookiesIn(later);
|
|
468
475
|
const jarBefore = cookiesIn(earlier);
|
|
469
|
-
// A cookie the app set on the earlier reply, sent back
|
|
470
|
-
|
|
476
|
+
// A cookie the app set on the earlier reply, sent back, which the earlier request could not carry.
|
|
477
|
+
// One that request already carried was only refreshed: a session the person signed in with, unless
|
|
478
|
+
// its name says it is the conversation.
|
|
479
|
+
const set = Object.keys(jar).find((k) => earlier.cookies.includes(k) && (jarBefore[k] === undefined || namesConversation(k)));
|
|
471
480
|
if (set) return { carrier: "cookie", key: set, minted: "app" };
|
|
472
|
-
const
|
|
473
|
-
const header = Object.keys(later.headers).find((k) => !HOP.test(k) && !PLAIN_HEADER.test(k) && same(later.headers[k], headerOf(earlier.headers, k.toLowerCase())));
|
|
481
|
+
const header = Object.keys(later.headers).find((k) => !HOP.test(k) && !PLAIN_HEADER.test(k) && namesConversation(k) && same(later.headers[k], headerOf(earlier.headers, k.toLowerCase())));
|
|
474
482
|
if (header) return { carrier: "header", key: header.toLowerCase(), minted: "client" };
|
|
475
|
-
|
|
483
|
+
const cookie = Object.keys(jar).find((k) => namesConversation(k) && same(jar[k], jarBefore[k]));
|
|
484
|
+
if (cookie) return { carrier: "cookie", key: cookie, minted: "client" };
|
|
476
485
|
return { carrier: "none", key: null };
|
|
477
486
|
}
|
|
478
487
|
|
|
@@ -26,6 +26,23 @@ RECENT_KEPT, RECENT_MS = 32, 300000
|
|
|
26
26
|
# turn (a chat UI shows the message with one event, then answers it with the next).
|
|
27
27
|
BEFORE_MS = 60000
|
|
28
28
|
PROMPTS_KEPT, PROMPT_HEAD = 64, 500
|
|
29
|
+
# How much of a sentence the model wrote a fetched reply has to carry to be the answer.
|
|
30
|
+
CARRY = 60
|
|
31
|
+
# tied.cjs UNDER_WAY: a job's own word for work still going on.
|
|
32
|
+
UNDER_WAY = re.compile(r"^(?:queued|pending|waiting|scheduled|submitted|accepted|created|starting|started|running|processing|generating|working|thinking|searching|retrieving|streaming|in[_ -]?progress|not[_ -]?(?:started|ready))$", re.I)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _under_way(v, depth=0):
|
|
36
|
+
if depth > 4:
|
|
37
|
+
return False
|
|
38
|
+
if isinstance(v, list):
|
|
39
|
+
return bool(v) and _under_way(v[-1], depth + 1)
|
|
40
|
+
if not isinstance(v, dict):
|
|
41
|
+
return False
|
|
42
|
+
state = v.get("status", v.get("state"))
|
|
43
|
+
if isinstance(state, str) and UNDER_WAY.match(state):
|
|
44
|
+
return True
|
|
45
|
+
return any(isinstance(x, (list, dict)) and _under_way(x, depth + 1) for x in v.values())
|
|
29
46
|
# The ask the last call pinned in this handler's context was made for. A queue runs each handler in a
|
|
30
47
|
# context of its own, so its later calls, which carry none of the person's words, are told apart by
|
|
31
48
|
# it when several asks are open at once. Only a tiebreak: it never outranks what the rules decide.
|
|
@@ -68,10 +85,23 @@ def _address_ids(req):
|
|
|
68
85
|
return {v for v in path.split("/") + [v for _, v in parse_qsl(query)] if ID.match(v)}
|
|
69
86
|
|
|
70
87
|
|
|
88
|
+
# tied.cjs saidSentences: a JSON answer the clip cut short is read by its strings.
|
|
89
|
+
def _said_sentences(text):
|
|
90
|
+
text = text.decode("utf-8", "replace") if isinstance(text, (bytes, bytearray)) else str(text or "")
|
|
91
|
+
v = _json(text)
|
|
92
|
+
whole = [s for s in (_scalars(v, []) if v is not None else [text]) if _sentence(s)]
|
|
93
|
+
if len(whole) != 1 or not re.match(r"\s*[\[{]", whole[0]):
|
|
94
|
+
return whole
|
|
95
|
+
return [m for m in re.findall(r'"((?:[^"\\]|\\.){8,})"', whole[0]) if _sentence(m)]
|
|
96
|
+
|
|
97
|
+
|
|
71
98
|
class Tied:
|
|
72
99
|
# `body_of(req)` and `reply_of(req)` read what a request carried and what the app answered.
|
|
73
|
-
|
|
100
|
+
# `said_of(req)`: what the model calls made for an ask answered later have written, one entry a
|
|
101
|
+
# call and none before one has, None where the hook hears none (tied.cjs saidOf).
|
|
102
|
+
def __init__(self, norm, body_of, reply_of, said_of=None):
|
|
74
103
|
self.norm, self.body_of, self.reply_of = norm, body_of, reply_of
|
|
104
|
+
self.said_of = said_of or (lambda req: None)
|
|
75
105
|
self.open = {}
|
|
76
106
|
self.recent = collections.deque(maxlen=RECENT_KEPT)
|
|
77
107
|
# The heads of the prompts this process sent: one arriving at a server in the same process (an
|
|
@@ -127,7 +157,7 @@ class Tied:
|
|
|
127
157
|
now = int(time.time() * 1000)
|
|
128
158
|
for t in reversed(list(self.recent)) if mine else []:
|
|
129
159
|
# A request answered later is fetched afterwards only by a read of it, never by a new message.
|
|
130
|
-
if t["at"] <= req["at"] and now - t["at"] <= RECENT_MS and (t.get("pinned") or not pinned_only) and (not t.get("settled") or req["method"] == "GET"):
|
|
160
|
+
if t["at"] <= req["at"] and now - t.get("call_at", t["at"]) <= RECENT_MS and (t.get("pinned") or not pinned_only) and (not t.get("settled") or req["method"] == "GET"):
|
|
131
161
|
shared = mine & self._ids(t)
|
|
132
162
|
if shared:
|
|
133
163
|
return t, shared
|
|
@@ -146,7 +176,7 @@ class Tied:
|
|
|
146
176
|
def closed(self, req):
|
|
147
177
|
self.open.pop(req["id"], None)
|
|
148
178
|
asker = req.get("asker")
|
|
149
|
-
if asker and not asker.get("written") and not asker.get("calls_open"):
|
|
179
|
+
if asker and not asker.get("written") and not asker.get("calls_open") and self._carries(req, asker):
|
|
150
180
|
since = max([self.answered[i] for i in req["tie"] if self.answered.get(i, asker["at"]) < asker["at"]], default=0)
|
|
151
181
|
before = [p for p in list(self.recent) if p is not asker and not p.get("settled") and since < p["at"] < asker["at"] and asker["at"] - p["at"] <= BEFORE_MS and self._ids(p) & req["tie"]]
|
|
152
182
|
for s in before + [asker]:
|
|
@@ -161,6 +191,18 @@ class Tied:
|
|
|
161
191
|
self.recent.append(req)
|
|
162
192
|
return None
|
|
163
193
|
|
|
194
|
+
# tied.cjs carries: a page polling for an answer several calls build comes back many times
|
|
195
|
+
# between two of them, on a document with no answer in it yet.
|
|
196
|
+
def _carries(self, fetch, asker):
|
|
197
|
+
said = self.said_of(asker)
|
|
198
|
+
if said is None:
|
|
199
|
+
return True
|
|
200
|
+
raw = self.reply_of(fetch)
|
|
201
|
+
if _under_way(_json(raw)):
|
|
202
|
+
return False
|
|
203
|
+
reply = self.norm(raw)
|
|
204
|
+
return any(self.norm(w)[:CARRY] in reply for one in (said if isinstance(said, (list, tuple)) else [said]) for w in _said_sentences(one))
|
|
205
|
+
|
|
164
206
|
def _answered(self, tie, at):
|
|
165
207
|
for i in tie:
|
|
166
208
|
self.answered.pop(i, None)
|
|
@@ -230,6 +272,7 @@ class Tied:
|
|
|
230
272
|
asker["late"] = True
|
|
231
273
|
asker["pinned"] = True
|
|
232
274
|
asker["calls_open"] = asker.get("calls_open", 0) + 1
|
|
275
|
+
asker["call_at"] = int(time.time() * 1000)
|
|
233
276
|
_HELD.set(asker)
|
|
234
277
|
return asker
|
|
235
278
|
|
|
@@ -260,6 +303,7 @@ class Tied:
|
|
|
260
303
|
if req.get("late"):
|
|
261
304
|
req["pinned"] = True
|
|
262
305
|
req["calls_open"] = req.get("calls_open", 0) + 1
|
|
306
|
+
req["call_at"] = int(time.time() * 1000)
|
|
263
307
|
else:
|
|
264
308
|
req["waiting"] = req.get("waiting", 0) + 1
|
|
265
309
|
return req
|
|
@@ -285,7 +329,7 @@ class Tied:
|
|
|
285
329
|
# request tied to it came to fetch the answer. It stays where a fetch that comes later can still
|
|
286
330
|
# find it and complete the exchange on a second request, but no call is pinned to it by its words.
|
|
287
331
|
def settled(self, req):
|
|
288
|
-
if not req or not req.get("late") or not req.get("ended") or req.get("calls_open") or req.get("waiting") or req.get("written") or req.get("settled") or req.get("fetched"):
|
|
332
|
+
if not req or not req.get("late") or not req.get("ended") or req.get("calls_open") or req.get("waiting") or req.get("written") or req.get("settled") or (req.get("fetched") and self.said_of(req) is None):
|
|
289
333
|
return False
|
|
290
334
|
req["settled"] = req["answered"] = True
|
|
291
335
|
return True
|
|
@@ -1353,11 +1353,55 @@ def _install():
|
|
|
1353
1353
|
except Exception:
|
|
1354
1354
|
return True
|
|
1355
1355
|
|
|
1356
|
-
# A GraphQL query reads; a mutation writes. A `query` that is not GraphQL (SQL over HTTP
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1356
|
+
# A GraphQL query reads; a mutation writes. A `query` in JSON that is not GraphQL (SQL over HTTP,
|
|
1357
|
+
# where a SELECT can write through a function) is held.
|
|
1358
|
+
# ClickHouse, Trino and Presto take every query as a POST of its text, a SELECT too, and a SELECT
|
|
1359
|
+
# there cannot write: one statement that starts as a read and holds no word that writes, sent to
|
|
1360
|
+
# what its address or its headers show is one of them, goes out (lib/trace.cjs does the same).
|
|
1361
|
+
sql_quoted = re.compile(r"""'(?:[^'\\]|\\.|'')*'|"(?:[^"\\]|\\.|"")*"|`[^`]*`|--[^\n]*|/\*[\s\S]*?\*/""")
|
|
1362
|
+
sql_shows = re.compile(r"^(?:show|describe|desc|exists)\b", re.I | re.A)
|
|
1363
|
+
sql_selects = re.compile(r"^(?:select|with|explain)\b", re.I | re.A)
|
|
1364
|
+
sql_writes = re.compile(r"(?<![.\w])(?:insert|update|delete|merge|into|create|alter|drop|truncate|refresh|call|execute)(?![.\w])", re.I | re.A)
|
|
1365
|
+
sql_hands_on = re.compile(r"\b(?:table|query|jdbc|odbc|executable)\s*\(", re.I | re.A)
|
|
1366
|
+
sql_open = re.compile(r"""[;'"`]|/\*|\$\$""")
|
|
1367
|
+
analytics_name = re.compile(r"clickhouse|trino|presto", re.I)
|
|
1368
|
+
analytics_headers = ("x-clickhouse-user", "x-clickhouse-key", "x-clickhouse-database", "x-clickhouse-format", "x-trino-user", "x-presto-user")
|
|
1369
|
+
|
|
1370
|
+
def statement_of(sql):
|
|
1371
|
+
bare = sql_quoted.sub(lambda m: "''" if m.group(0)[0] == "'" else "x" if m.group(0)[0] in '"`' else " ", str(sql or "")).strip()
|
|
1372
|
+
return re.sub(r";\s*$", "", bare)
|
|
1373
|
+
|
|
1374
|
+
# A quote or a comment left open, a second statement or a dollar-quoted body is not read as a read.
|
|
1375
|
+
def selects(bare):
|
|
1376
|
+
if sql_open.search(bare.replace("''", "")):
|
|
1377
|
+
return False
|
|
1378
|
+
return bool(sql_shows.match(bare)) or bool(sql_selects.match(bare) and not sql_writes.search(bare) and not sql_hands_on.search(bare))
|
|
1379
|
+
|
|
1380
|
+
def analytics(parts, header_of):
|
|
1381
|
+
try:
|
|
1382
|
+
port = parts.port
|
|
1383
|
+
except ValueError:
|
|
1384
|
+
port = None
|
|
1385
|
+
return port in (8123, 8443) or bool(analytics_name.search(parts.hostname or "")) or bool(analytics_name.search(str(header_of("user-agent") or ""))) \
|
|
1386
|
+
or any(header_of(name) for name in analytics_headers) or parts.path == "/v1/statement"
|
|
1387
|
+
|
|
1388
|
+
def reads_only(raw, url=None, header_of=None):
|
|
1389
|
+
sent = str(raw or "").strip()
|
|
1390
|
+
b = parsed(sent) if sent else None
|
|
1391
|
+
if b is not None:
|
|
1392
|
+
q = b.get("query") if isinstance(b, dict) else None
|
|
1393
|
+
return isinstance(q, str) and bool(re.match(r"^\s*(?:\{|query\b|fragment\b)", q)) and not re.search(r"\bmutation\b", q)
|
|
1394
|
+
try:
|
|
1395
|
+
parts = urlsplit(str(url)) if url else None
|
|
1396
|
+
if parts is None or not analytics(parts, header_of or (lambda name: None)):
|
|
1397
|
+
return False
|
|
1398
|
+
# ClickHouse joins a `query` in the address to the body: both are read as the one statement.
|
|
1399
|
+
asked = "%s %s" % (dict(parse_qsl(parts.query)).get("query", ""), sent)
|
|
1400
|
+
# ClickHouse reads a backslash before a quote as part of the string and Trino as its end,
|
|
1401
|
+
# so where a string stops is not known here: held.
|
|
1402
|
+
return not re.search(r"""\\['"`]""", asked) and selects(statement_of(asked))
|
|
1403
|
+
except Exception:
|
|
1404
|
+
return False
|
|
1361
1405
|
|
|
1362
1406
|
held_count = itertools.count(1)
|
|
1363
1407
|
|
|
@@ -1381,7 +1425,7 @@ def _install():
|
|
|
1381
1425
|
return
|
|
1382
1426
|
held_said.add((method, host))
|
|
1383
1427
|
try:
|
|
1384
|
-
sys.stderr.write("cortad: a request Cortad sent made this app send %s to %s. Cortad kept that call on this machine and answered it with a stand-in success, since it could change something real. A failure right after this line
|
|
1428
|
+
sys.stderr.write("cortad: a request Cortad sent made this app send %s to %s. Cortad kept that call on this machine and answered it with a stand-in success, since it could change something real. A failure right after this line may come from that stand-in rather than from your code. With that service's test key the call goes out as sent.\n" % (method, host))
|
|
1385
1429
|
except Exception:
|
|
1386
1430
|
pass
|
|
1387
1431
|
|
|
@@ -1548,7 +1592,7 @@ def _install():
|
|
|
1548
1592
|
|
|
1549
1593
|
try:
|
|
1550
1594
|
from cortad_tied import Tied
|
|
1551
|
-
tied = Tied(norm, body_of, reply_of)
|
|
1595
|
+
tied = Tied(norm, body_of, reply_of, lambda req: req.get("said_all", []) if req.get("late") else None)
|
|
1552
1596
|
except Exception:
|
|
1553
1597
|
tied = None
|
|
1554
1598
|
|
|
@@ -1749,6 +1793,7 @@ def _install():
|
|
|
1749
1793
|
if req and req.get("late"):
|
|
1750
1794
|
if not row.get("embedding"):
|
|
1751
1795
|
req["said"] = (row["status"], reply_text(events)[:model_words], now)
|
|
1796
|
+
req["said_all"] = (req.get("said_all", []) + [req["said"][1]])[-12:]
|
|
1752
1797
|
settle(req)
|
|
1753
1798
|
row.update(inside_of(req))
|
|
1754
1799
|
if req and (req.get("kept") or req.get("turn")):
|
|
@@ -2425,7 +2470,7 @@ def _install():
|
|
|
2425
2470
|
|
|
2426
2471
|
# A held call that cannot be answered fails in the app; it is never sent instead.
|
|
2427
2472
|
def held_httpx(request, sent):
|
|
2428
|
-
if not held_here(request.url, request.method, lambda: keys_in(request.headers)) or reads_only(sent):
|
|
2473
|
+
if not held_here(request.url, request.method, lambda: keys_in(request.headers)) or reads_only(sent, request.url, request.headers.get):
|
|
2429
2474
|
return None
|
|
2430
2475
|
kind = request.headers.get("content-type", "")
|
|
2431
2476
|
held_row(request.url, request.method, sent, kind, ctx.get())
|
|
@@ -2488,7 +2533,7 @@ def _install():
|
|
|
2488
2533
|
if not held_here(request.url, request.method, lambda: keys_in(request.headers)):
|
|
2489
2534
|
return None
|
|
2490
2535
|
raw = text(request.body, reply_max)
|
|
2491
|
-
if reads_only(raw):
|
|
2536
|
+
if reads_only(raw, request.url, request.headers.get):
|
|
2492
2537
|
return None
|
|
2493
2538
|
import datetime
|
|
2494
2539
|
models, structures = sys.modules["requests.models"], sys.modules["requests.structures"]
|
|
@@ -2539,6 +2584,13 @@ def _install():
|
|
|
2539
2584
|
basic = k.get("auth") or getattr(self, "_default_auth", None)
|
|
2540
2585
|
return [given.get(n) or defaults.get(n) for n in key_headers] + [basic.encode() if basic else None]
|
|
2541
2586
|
|
|
2587
|
+
def header_of(self, k, name):
|
|
2588
|
+
for headers in (k.get("headers"), getattr(self, "_default_headers", None)):
|
|
2589
|
+
for n, v in dict(headers or {}).items():
|
|
2590
|
+
if str(n).lower() == name:
|
|
2591
|
+
return v
|
|
2592
|
+
return None
|
|
2593
|
+
|
|
2542
2594
|
# A path relative to the session's base address is decided on the whole address.
|
|
2543
2595
|
def whole(self, str_or_url):
|
|
2544
2596
|
try:
|
|
@@ -2549,7 +2601,7 @@ def _install():
|
|
|
2549
2601
|
async def requested(self, method, str_or_url, *a, **k):
|
|
2550
2602
|
body = k.get("json") if k.get("json") is not None else k.get("data")
|
|
2551
2603
|
url = whole(self, str_or_url)
|
|
2552
|
-
if held_here(url, method, lambda: keys_of(self, k)) and not reads_only(text(body, reply_max)):
|
|
2604
|
+
if held_here(url, method, lambda: keys_of(self, k)) and not reads_only(text(body, reply_max), url, lambda name: header_of(self, k, name)):
|
|
2553
2605
|
port = held_port()
|
|
2554
2606
|
if port is None:
|
|
2555
2607
|
raise module.ClientConnectionError("this write was kept from leaving and could not be answered")
|
package/lib/replay.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// reads what the hook wrote, keeps the sign-in each request carried on this machine, and tells the
|
|
3
3
|
// cloud what each door is proven to do (lib/proof.mjs): the route, the method, the body, the header
|
|
4
4
|
// names and what the requests did inside the app. Header values never leave.
|
|
5
|
-
import { copyFileSync, existsSync, mkdtempSync, readFileSync, statSync } from "node:fs";
|
|
5
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { SourceMap } from "node:module";
|
|
7
7
|
import { tmpdir } from "node:os";
|
|
8
8
|
import { dirname, isAbsolute, join, relative } from "node:path";
|
|
@@ -15,15 +15,17 @@ const HOOK = join(HERE, "trace.cjs");
|
|
|
15
15
|
const PYHOOK = join(HERE, "pyhook");
|
|
16
16
|
export const CAPTURED = "captured";
|
|
17
17
|
// Never replayed: they describe one connection, not the caller.
|
|
18
|
+
const SECRET_HEADER = /authorization|cookie|token|secret|session|csrf|api-?key/i;
|
|
18
19
|
const HOP = /^(?:host|content-length|connection|keep-alive|transfer-encoding|upgrade|expect|te|trailer|accept-encoding|x-cortad-as|x-cortad-turn)$/i;
|
|
19
20
|
|
|
20
21
|
// `files` lists the repository's source, relative to `root`, for placing a problem at its line;
|
|
21
22
|
// `appFolder` is the folder the app runs in, relative to `root`. `onProof` gets one payload per door,
|
|
22
23
|
// again whenever what it proves changes. `printed(from, to)` is what the app printed between two times.
|
|
23
|
-
export function makeCapture({ work, keepSecret, onProof = () => {}, writes = null, root = null, files = () => [], appFolder = () => "", printed = () => "" }) {
|
|
24
|
+
export function makeCapture({ work, keepSecret, onProof = () => {}, writes = null, root = null, files = () => [], appFolder = () => "", printed = () => "", signIns = null }) {
|
|
24
25
|
const file = join(work, "trace.jsonl");
|
|
25
26
|
let read = 0;
|
|
26
|
-
const accounts = makeAccounts();
|
|
27
|
+
const accounts = makeAccounts(signIns);
|
|
28
|
+
for (const headers of accounts.kept()) for (const [k, v] of Object.entries(headers)) if (SECRET_HEADER.test(k)) keepSecret(String(v).replace(/^Bearer\s+/i, ""));
|
|
27
29
|
let alive = false;
|
|
28
30
|
// The ports a hooked process listens on. Loading is not listening: turbo's own Node launcher loads
|
|
29
31
|
// the hook, and one app was told we would see its messages while its API on Bun carried none.
|
|
@@ -118,7 +120,7 @@ export function makeCapture({ work, keepSecret, onProof = () => {}, writes = nul
|
|
|
118
120
|
if (typeof row.ex !== "string" || typeof row.method !== "string" || typeof row.path !== "string" || !row.path.startsWith("/")) continue;
|
|
119
121
|
for (const [door, r] of taken) if (r.ex === row.ex) taken.delete(door);
|
|
120
122
|
const headers = Object.fromEntries(Object.entries(row.headers ?? {}).filter(([k, v]) => !HOP.test(k) && typeof v === "string"));
|
|
121
|
-
for (const [k, v] of Object.entries(headers)) if (
|
|
123
|
+
for (const [k, v] of Object.entries(headers)) if (SECRET_HEADER.test(k)) keepSecret(String(v).replace(/^Bearer\s+/i, ""));
|
|
122
124
|
// Speaking as the person means their own client's headers, never a trial's. Cortad's own test
|
|
123
125
|
// request (reach:*) carries the page and sign-in this machine found for the app, so a run speaks
|
|
124
126
|
// with them where no request of the person's came: without it every run request lost the Origin
|
|
@@ -451,9 +453,29 @@ function leavesOf(body) {
|
|
|
451
453
|
try { walk(typeof body === "string" ? JSON.parse(body) : body, "", 0); } catch { /* not JSON: nothing of theirs to read */ }
|
|
452
454
|
return out;
|
|
453
455
|
}
|
|
454
|
-
|
|
456
|
+
// `file`: where the accounts are kept between connects, on this machine and readable by this user
|
|
457
|
+
// alone. Databuddy's agent signed in once per connect, and every reconnect asked for it again.
|
|
458
|
+
// A sign-in that has ended is not read back.
|
|
459
|
+
export function makeAccounts(file = null) {
|
|
455
460
|
const kept = [];
|
|
456
461
|
let bare = null;
|
|
462
|
+
if (file) {
|
|
463
|
+
try {
|
|
464
|
+
for (const a of JSON.parse(readFileSync(file, "utf8"))) {
|
|
465
|
+
if (kept.length >= ACCOUNTS_MAX || typeof a?.key !== "string" || !a.headers || typeof a.headers !== "object") continue;
|
|
466
|
+
const headers = Object.fromEntries(Object.entries(a.headers).filter(([k, v]) => typeof k === "string" && typeof v === "string"));
|
|
467
|
+
if (signInKey(headers) !== a.key || (identityOf(headers).expiresInS ?? 1) <= 0) continue;
|
|
468
|
+
kept.push({ key: a.key, headers, bodies: new Map(Object.entries(a.bodies ?? {}).map(([door, leaves]) => [door, new Map(Object.entries(leaves ?? {}))])) });
|
|
469
|
+
}
|
|
470
|
+
} catch { /* none kept, or not ours to read: the next signed-in request is taken */ }
|
|
471
|
+
}
|
|
472
|
+
const save = () => {
|
|
473
|
+
if (!file) return;
|
|
474
|
+
try {
|
|
475
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
476
|
+
writeFileSync(file, JSON.stringify(kept.map((a) => ({ key: a.key, headers: a.headers, bodies: Object.fromEntries([...a.bodies].map(([door, leaves]) => [door, Object.fromEntries(leaves)])) }))), { mode: 0o600 });
|
|
477
|
+
} catch { /* kept for this connect only */ }
|
|
478
|
+
};
|
|
457
479
|
const at = (role) => { const m = ACCOUNT_ROLE.exec(role); return m ? kept[Number(m[1] ?? 1) - 1] ?? kept[0] ?? null : null; };
|
|
458
480
|
return {
|
|
459
481
|
// `door` and `body`: the request as that customer sent it, for the values in it that are theirs.
|
|
@@ -470,6 +492,7 @@ export function makeAccounts() {
|
|
|
470
492
|
else return;
|
|
471
493
|
}
|
|
472
494
|
if (door) account.bodies.set(door, leavesOf(body));
|
|
495
|
+
save();
|
|
473
496
|
},
|
|
474
497
|
kept: () => kept.map((a) => a.headers),
|
|
475
498
|
speak(role) {
|
package/lib/start.mjs
CHANGED
|
@@ -239,3 +239,11 @@ export function installPlan(dir, onPath, root = dir) {
|
|
|
239
239
|
if (existsSync(join(dir, "pyproject.toml"))) return into("-e .");
|
|
240
240
|
return null;
|
|
241
241
|
}
|
|
242
|
+
|
|
243
|
+
// The start command as it leaves this machine: a value typed into it (DATABASE_URL=...) stays here
|
|
244
|
+
// unless it is a plain word, and it is cut to the length the server keeps. A command past that
|
|
245
|
+
// length was refused whole, as "port is required", with a valid port beside it.
|
|
246
|
+
export const SHOWN_COMMAND = 200;
|
|
247
|
+
export const shownCommand = (cmd, mask = (text) => text) => (cmd == null ? cmd
|
|
248
|
+
: mask(String(cmd)).replace(/\b([A-Za-z_][A-Za-z0-9_]*)=("[^"]*"|'[^']*'|\S+)/g, (whole, name, value) => (/^[\w.-]{1,24}$/.test(value) ? whole : `${name}=***`)).slice(0, SHOWN_COMMAND));
|
|
249
|
+
|
package/lib/stores.mjs
CHANGED
|
@@ -21,7 +21,7 @@ export const PORT_OF = { postgres: 5432, mysql: 3306, mongo: 27017, redis: 6379,
|
|
|
21
21
|
const ENGINE_AT = { ...Object.fromEntries(Object.entries(PORT_OF).filter(([e]) => e !== "chroma" && e !== "weaviate").map(([e, p]) => [p, e])), 6334: "qdrant" };
|
|
22
22
|
export const isStorePort = (port) => Boolean(ENGINE_AT[port]);
|
|
23
23
|
// Stores reached through their maker's SDK, which a setting rarely names: the hook sees the host.
|
|
24
|
-
const HOSTED = [["supabase", /\.supabase\.(?:co|in|com)$/], ["mongo", /\.mongodb\.net$/], ["redis", /\.upstash\.io$|\.redis-cloud\.com$|\.redislabs\.com$/], ["postgres", /\.neon\.tech$/], ["pinecone", /\.pinecone\.io$/], ["qdrant", /\.qdrant\.io$/], ["firestore", /^firestore\.googleapis\.com$/]];
|
|
24
|
+
const HOSTED = [["supabase", /\.supabase\.(?:co|in|com)$/], ["mongo", /\.mongodb\.net$/], ["elasticsearch", /\.elastic-cloud\.com$|\.found\.io$|\.es\.amazonaws\.com$/], ["redis", /\.upstash\.io$|\.redis-cloud\.com$|\.redislabs\.com$/], ["postgres", /\.neon\.tech$/], ["pinecone", /\.pinecone\.io$/], ["qdrant", /\.qdrant\.io$/], ["firestore", /^firestore\.googleapis\.com$/]];
|
|
25
25
|
// How each store we copy is copied and dropped: on its own server, under the name given.
|
|
26
26
|
const COPY = {
|
|
27
27
|
postgres: { copy: (s, clone) => clonePg(s.conn, s.db, clone), drop: (s, clone) => dropPg(s.conn, clone) },
|
|
@@ -39,6 +39,10 @@ const WORD_ENGINE = [["postgres", /^(?:PG|POSTGRES|PSQL)|POSTGRES/], ["mysql", /
|
|
|
39
39
|
const SECRETISH = /(?:^|_)(?:KEY|TOKEN|SECRET|PASSWORD|PASS|PWD)$/;
|
|
40
40
|
// Every engine's word in a setting's name, so a setting that carries one is that engine's own.
|
|
41
41
|
const ENGINE_WORD = new RegExp([...Object.keys(ENGINE), ...[...BY_NAME, ...WORD_ENGINE].map(([, re]) => re.source)].join("|"), "i");
|
|
42
|
+
// A settings file's values without the ones that name a store. An app the person started with
|
|
43
|
+
// their own command is handed the rest: which databases it uses is that command's to say.
|
|
44
|
+
const STORE_SETTING = new RegExp(`${ENGINE_WORD.source}|(?:^|_)(?:DB|DATABASE)(?:_|$)`, "i");
|
|
45
|
+
export const withoutStoreSettings = (values) => Object.fromEntries(Object.entries(values).filter(([name]) => !STORE_SETTING.test(name)));
|
|
42
46
|
// Stores that hold no collections: a bare COLLECTION setting beside one of them is still Qdrant's.
|
|
43
47
|
const NO_COLLECTIONS = new Set(["redis", "mysql", "sqlserver", "neo4j", "meilisearch", "pinecone"]);
|
|
44
48
|
const LOOPBACK = /^(?:localhost|127(?:\.\d+){3}|::1|\[::1\]|0\.0\.0\.0|host\.docker\.internal)?$/i;
|
|
@@ -329,19 +333,19 @@ export function storeKeeper({ ledgerFile, values = {}, dirs = [], fetchFn, local
|
|
|
329
333
|
}
|
|
330
334
|
|
|
331
335
|
// The stores the app reached that no setting names, as the hook saw each connection: a store written
|
|
332
|
-
// into a config file rather than the environment,
|
|
333
|
-
//
|
|
334
|
-
|
|
335
|
-
|
|
336
|
+
// into a config file rather than the environment, one its maker's SDK finds by itself, or the ones an
|
|
337
|
+
// app started with the person's own command was given. No copy stands in for any of them, so each
|
|
338
|
+
// holds Run for the person's yes, once. `blocked`: the rows already holding it.
|
|
339
|
+
export function unnamedHeld(connections, stores, blocked) {
|
|
340
|
+
const rows = [];
|
|
336
341
|
for (const { host, port } of connections) {
|
|
337
342
|
const local = onThisMachine(host);
|
|
338
|
-
const engine = local ? ENGINE_AT[port] : HOSTED.find(([, re]) => re.test(String(host).toLowerCase()))?.[0];
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
: `Your app reaches ${ENGINE[engine]} on a server off this machine, and a copy is only made on this machine: what test conversations create there stays there.`);
|
|
343
|
+
const engine = local ? ENGINE_AT[port] : HOSTED.find(([, re]) => re.test(String(host).toLowerCase()))?.[0] ?? ENGINE_AT[port];
|
|
344
|
+
if (!engine || stores.some((s) => (local ? `${hostKey(s.host)}:${s.port}` === `localhost:${port}` : s.engine === engine && !onThisMachine(s.host)))) continue;
|
|
345
|
+
const id = createHash("sha256").update(`${engine}|${local ? "localhost" : String(host).toLowerCase()}|${port}`).digest("hex").slice(0, 16);
|
|
346
|
+
if ([...blocked, ...rows].some((b) => b.id === id)) continue;
|
|
347
|
+
// Named by its engine where a setting's name would stand: the API takes a name, never an address.
|
|
348
|
+
rows.push({ id, names: [ENGINE[engine].replace(/\W+/g, "_")], why: `Your app reaches ${/^[AEIOU]/.test(ENGINE[engine]) ? "an" : "a"} ${ENGINE[engine]} server ${local ? "on" : "off"} this machine that it was not given a copy of, so the run would write what its test conversations create into it as it is. Start your app on a copy of it, then run the connect command again.` });
|
|
345
349
|
}
|
|
346
|
-
return
|
|
350
|
+
return rows;
|
|
347
351
|
}
|
package/lib/tied.cjs
CHANGED
|
@@ -39,9 +39,32 @@ const sentence = (v) => v.length >= 8 && /\s|[^\x00-\x7F]/.test(v);
|
|
|
39
39
|
// The sentences a body or a reply carries, read as the person's words are: an "ok" or an id says nothing.
|
|
40
40
|
const sentencesIn = (text) => { const v = json(text); return (v === undefined ? [String(text || "")] : scalars(v)).filter(sentence); };
|
|
41
41
|
const PROMPTS_KEPT = 64, PROMPT_HEAD = 500;
|
|
42
|
+
// How much of a sentence the model wrote a fetched reply has to carry to be the answer.
|
|
43
|
+
const CARRY = 60;
|
|
44
|
+
// A job's own word for work still going on, as its status or state says it: at the top of a document,
|
|
45
|
+
// in an object inside it, or on the newest entry of a list in it. The backend reads the same words
|
|
46
|
+
// (customer/endpoint.ts).
|
|
47
|
+
const UNDER_WAY = /^(?:queued|pending|waiting|scheduled|submitted|accepted|created|starting|started|running|processing|generating|working|thinking|searching|retrieving|streaming|in[_ -]?progress|not[_ -]?(?:started|ready))$/i;
|
|
48
|
+
const underWay = (v, depth = 0) => {
|
|
49
|
+
if (!v || typeof v !== "object" || depth > 4) return false;
|
|
50
|
+
if (Array.isArray(v)) return underWay(v.at(-1), depth + 1);
|
|
51
|
+
const state = v.status ?? v.state;
|
|
52
|
+
if (typeof state === "string" && UNDER_WAY.test(state)) return true;
|
|
53
|
+
return Object.values(v).some((x) => x && typeof x === "object" && underWay(x, depth + 1));
|
|
54
|
+
};
|
|
55
|
+
// The sentences a model wrote, read as the person's words are; a JSON answer the clip cut short is
|
|
56
|
+
// read by its strings.
|
|
57
|
+
const saidSentences = (text) => {
|
|
58
|
+
const whole = sentencesIn(text);
|
|
59
|
+
if (whole.length !== 1 || !/^\s*[[{]/.test(whole[0])) return whole;
|
|
60
|
+
return [...whole[0].matchAll(/"((?:[^"\\]|\\.){8,})"/g)].map((m) => m[1]).filter(sentence);
|
|
61
|
+
};
|
|
42
62
|
|
|
43
63
|
// `bodyOf(req)` and `replyOf(req)` read what a request carried and what the app answered.
|
|
44
|
-
|
|
64
|
+
// `saidOf(req)`: what the model calls made for an ask answered later have written, one entry a call
|
|
65
|
+
// and none before one has, undefined where the hook hears none. The answer a person reads is not
|
|
66
|
+
// always the last call's: a second look that changes nothing leaves the first reading standing.
|
|
67
|
+
function makeTied({ norm, bodyOf, replyOf, saidOf = () => undefined }) {
|
|
45
68
|
const open = new Set();
|
|
46
69
|
const recent = [];
|
|
47
70
|
// The heads of the prompts this process sent: one arriving at a server in the same process (an
|
|
@@ -68,6 +91,17 @@ function makeTied({ norm, bodyOf, replyOf }) {
|
|
|
68
91
|
return first[1] >= 0 && first[1] !== second[1] ? first[0] : null;
|
|
69
92
|
};
|
|
70
93
|
const drop = (req) => { const i = recent.indexOf(req); if (i >= 0) recent.splice(i, 1); };
|
|
94
|
+
// Whether a fetch carries the ask's answer. A page polling for an answer several calls build comes
|
|
95
|
+
// back many times between two of them, on a document with no answer in it yet, or with a first
|
|
96
|
+
// reading in it while the document itself still says running.
|
|
97
|
+
const carries = (fetch, asker) => {
|
|
98
|
+
const said = saidOf(asker);
|
|
99
|
+
if (said === undefined) return true;
|
|
100
|
+
const raw = replyOf(fetch);
|
|
101
|
+
if (underWay(json(raw))) return false;
|
|
102
|
+
const reply = norm(raw);
|
|
103
|
+
return [].concat(said).flatMap(saidSentences).some((w) => reply.includes(norm(w).slice(0, CARRY)));
|
|
104
|
+
};
|
|
71
105
|
// Asks reached through one session are one client's turns, the newest the one being answered: a
|
|
72
106
|
// client holds a second stream open on its session (a heartbeat) that points at an older ask.
|
|
73
107
|
const newestOf = (found) => {
|
|
@@ -89,7 +123,7 @@ function makeTied({ norm, bodyOf, replyOf }) {
|
|
|
89
123
|
for (let i = mine.size ? recent.length - 1 : -1; i >= 0; i--) {
|
|
90
124
|
const t = recent[i];
|
|
91
125
|
// A request answered later is fetched afterwards only by a read of it, never by a new message.
|
|
92
|
-
if (t.at > req.at || now - t.at > RECENT_MS || (pinnedOnly && !t.pinned) || (t.settled && req.method !== "GET")) continue;
|
|
126
|
+
if (t.at > req.at || now - (t.callAt ?? t.at) > RECENT_MS || (pinnedOnly && !t.pinned) || (t.settled && req.method !== "GET")) continue;
|
|
93
127
|
const tie = [...mine].filter((v) => idsOf(t).has(v));
|
|
94
128
|
if (tie.length) return { asker: t, tie: new Set(tie) };
|
|
95
129
|
}
|
|
@@ -107,7 +141,7 @@ function makeTied({ norm, bodyOf, replyOf }) {
|
|
|
107
141
|
closed(req) {
|
|
108
142
|
open.delete(req);
|
|
109
143
|
const { asker } = req;
|
|
110
|
-
if (asker && !asker.written && !asker.callsOpen) {
|
|
144
|
+
if (asker && !asker.written && !asker.callsOpen && carries(req, asker)) {
|
|
111
145
|
const since = Math.max(0, ...[...req.tie].map((i) => answered.get(i) ?? asker.at).filter((at) => at < asker.at));
|
|
112
146
|
const before = recent.filter((p) => p !== asker && !p.settled && since < p.at && p.at < asker.at && asker.at - p.at <= BEFORE_MS && meets(idsOf(p), req.tie));
|
|
113
147
|
for (const s of [...before, asker]) drop(s);
|
|
@@ -140,7 +174,7 @@ function makeTied({ norm, bodyOf, replyOf }) {
|
|
|
140
174
|
inside(req, sent) {
|
|
141
175
|
if (req.finished && (!req.noted || req.late) && !says(req, norm(sent))) return pinned(sent, req);
|
|
142
176
|
if (!req.noted && req.finished) req.late = true;
|
|
143
|
-
if (req.late) { req.pinned = true; req.callsOpen = (req.callsOpen || 0) + 1; }
|
|
177
|
+
if (req.late) { req.pinned = true; req.callsOpen = (req.callsOpen || 0) + 1; req.callAt = Date.now(); }
|
|
144
178
|
else req.waiting = (req.waiting || 0) + 1;
|
|
145
179
|
return req;
|
|
146
180
|
},
|
|
@@ -166,7 +200,7 @@ function makeTied({ norm, bodyOf, replyOf }) {
|
|
|
166
200
|
// request tied to it came to fetch the answer. It stays where a fetch that comes later can still
|
|
167
201
|
// find it and complete the exchange on a second request, but no call is pinned to it by its words.
|
|
168
202
|
settled(req) {
|
|
169
|
-
if (!req || !req.late || !req.ended || req.callsOpen || req.waiting || req.written || req.settled || req.fetched) return false;
|
|
203
|
+
if (!req || !req.late || !req.ended || req.callsOpen || req.waiting || req.written || req.settled || (req.fetched && saidOf(req) === undefined)) return false;
|
|
170
204
|
req.settled = true;
|
|
171
205
|
return true;
|
|
172
206
|
},
|
|
@@ -210,6 +244,7 @@ function makeTied({ norm, bodyOf, replyOf }) {
|
|
|
210
244
|
if (!asker.noted && asker.finished) asker.late = true;
|
|
211
245
|
asker.pinned = true;
|
|
212
246
|
asker.callsOpen = (asker.callsOpen || 0) + 1;
|
|
247
|
+
asker.callAt = Date.now();
|
|
213
248
|
return asker;
|
|
214
249
|
}
|
|
215
250
|
}
|
package/lib/trace.cjs
CHANGED
|
@@ -807,8 +807,43 @@ if (FILE) {
|
|
|
807
807
|
return !passNow().some((p) => host === p || host.endsWith("." + p));
|
|
808
808
|
} catch { return true; }
|
|
809
809
|
};
|
|
810
|
-
// A GraphQL query reads; a mutation writes. A `query` that is not GraphQL (Neon's SQL
|
|
811
|
-
|
|
810
|
+
// A GraphQL query reads; a mutation writes. A `query` in JSON that is not GraphQL (Neon's SQL, where
|
|
811
|
+
// a SELECT can write through a function) is held.
|
|
812
|
+
// ClickHouse, Trino and Presto take every query as a POST of its text, a SELECT too, and a SELECT
|
|
813
|
+
// there cannot write: one statement that starts as a read and holds no word that writes, sent to
|
|
814
|
+
// what its address or its headers show is one of them, goes out. Anything this cannot read as that
|
|
815
|
+
// stays held. Databuddy's agent had 45 replies come back empty while its own ClickHouse queries
|
|
816
|
+
// were answered here with a stand-in.
|
|
817
|
+
const SQL_QUOTED = /'(?:[^'\\]|\\.|'')*'|"(?:[^"\\]|\\.|"")*"|`[^`]*`|--[^\n]*|\/\*[\s\S]*?\*\//g;
|
|
818
|
+
// SHOW, DESCRIBE and EXISTS read whatever follows them. A SELECT, a WITH or an EXPLAIN reads
|
|
819
|
+
// unless it holds a word that writes inside it (a CTE that deletes, SELECT INTO, EXPLAIN ANALYZE
|
|
820
|
+
// of an insert) or hands its text on to another database to run.
|
|
821
|
+
const SQL_SHOWS = /^(?:show|describe|desc|exists)\b/i, SQL_SELECTS = /^(?:select|with|explain)\b/i;
|
|
822
|
+
const SQL_WRITES = /(?<![.\w])(?:insert|update|delete|merge|into|create|alter|drop|truncate|refresh|call|execute)(?![.\w])/i;
|
|
823
|
+
const SQL_HANDS_ON = /\b(?:table|query|jdbc|odbc|executable)\s*\(/i;
|
|
824
|
+
const statementOf = (sql) => String(sql || "").replace(SQL_QUOTED, (m) => (m[0] === "'" ? "''" : /^["`]/.test(m) ? "x" : " ")).trim().replace(/;\s*$/, "");
|
|
825
|
+
// A quote or a comment left open, a second statement or a dollar-quoted body is not read as a read.
|
|
826
|
+
const whole = (bare) => !/[;'"`]|\/\*|\$\$/.test(bare.replace(/''/g, ""));
|
|
827
|
+
const selects = (bare) => whole(bare) && (SQL_SHOWS.test(bare) || (SQL_SELECTS.test(bare) && !SQL_WRITES.test(bare) && !SQL_HANDS_ON.test(bare)));
|
|
828
|
+
const ANALYTICS_NAME = /clickhouse|trino|presto/i;
|
|
829
|
+
const ANALYTICS_HEADERS = ["x-clickhouse-user", "x-clickhouse-key", "x-clickhouse-database", "x-clickhouse-format", "x-trino-user", "x-presto-user"];
|
|
830
|
+
const analytics = (u, headerOf, port) => /^(?:8123|8443)$/.test(String(port || u.port)) || ANALYTICS_NAME.test(u.hostname) || ANALYTICS_NAME.test(headerOf("user-agent") || "")
|
|
831
|
+
|| ANALYTICS_HEADERS.some((name) => headerOf(name)) || u.pathname === "/v1/statement";
|
|
832
|
+
const readsOnly = (text, u, headerOf = () => "", port) => {
|
|
833
|
+
const sent = String(text || "").trim();
|
|
834
|
+
try { const q = JSON.parse(sent)?.query; return typeof q === "string" && /^\s*(?:\{|query\b|fragment\b)/.test(q) && !/\bmutation\b/.test(q); } catch { /* not JSON: a statement sent as text */ }
|
|
835
|
+
if (!u || !analytics(u, headerOf, port)) return false;
|
|
836
|
+
// ClickHouse joins a `query` in the address to the body: both are read as the one statement.
|
|
837
|
+
const asked = `${u.searchParams.get("query") || ""} ${sent}`;
|
|
838
|
+
// ClickHouse reads a backslash before a quote as part of the string and Trino as its end, so
|
|
839
|
+
// where a string stops is not known here: held.
|
|
840
|
+
// ponytail: a ClickHouse read with an escaped quote in a string is held too; told apart by which
|
|
841
|
+
// database answered if that is ever seen.
|
|
842
|
+
return !/\\['"`]/.test(asked) && selects(statementOf(asked));
|
|
843
|
+
};
|
|
844
|
+
// A call passed on from this hook's own server, after its body was read: never held again.
|
|
845
|
+
const PASSED = Symbol("cortad.passed");
|
|
846
|
+
const passedOn = (args) => { const o = typeof args[0] === "string" || args[0] instanceof URL ? args[1] : args[0]; return Boolean(o && o[PASSED]); };
|
|
812
847
|
let heldCount = 0;
|
|
813
848
|
const fieldsOf = (text, type) => {
|
|
814
849
|
try { const b = /form/i.test(type || "") ? Object.fromEntries(new URLSearchParams(text)) : JSON.parse(text); return b && typeof b === "object" && !Array.isArray(b) ? b : {}; } catch { return {}; }
|
|
@@ -821,7 +856,7 @@ if (FILE) {
|
|
|
821
856
|
const heldSay = (method, host) => {
|
|
822
857
|
if (heldSaid.has(`${method} ${host}`)) return;
|
|
823
858
|
heldSaid.add(`${method} ${host}`);
|
|
824
|
-
try { process.stderr.write(`cortad: a request Cortad sent made this app send ${method} to ${host}. Cortad kept that call on this machine and answered it with a stand-in success, since it could change something real. A failure right after this line
|
|
859
|
+
try { process.stderr.write(`cortad: a request Cortad sent made this app send ${method} to ${host}. Cortad kept that call on this machine and answered it with a stand-in success, since it could change something real. A failure right after this line may come from that stand-in rather than from your code. With that service's test key the call goes out as sent.\n`); } catch { /* no stderr */ }
|
|
825
860
|
};
|
|
826
861
|
// Named by method and host only: a webhook's path is its credential.
|
|
827
862
|
const heldRow = (u, method, text, type, ctx) => { heldSay(method, u.hostname); row({ dep: { at: Date.now(), host: u.hostname, status: 200, held: true, ...(ctx ? { ex: ctx.id, turn: ctx.turn } : {}), called: [{ name: `${method} ${u.hostname}`.slice(0, 80), arguments: argsText(fieldsOf(text, type)) }] } }); };
|
|
@@ -841,7 +876,22 @@ if (FILE) {
|
|
|
841
876
|
const id = String(req.headers["x-cortad-held"] || "");
|
|
842
877
|
const w = heldWaiting.get(id);
|
|
843
878
|
heldWaiting.delete(id);
|
|
844
|
-
const
|
|
879
|
+
const sent = Buffer.concat(parts);
|
|
880
|
+
const text = sent.toString("utf8").slice(0, MAX);
|
|
881
|
+
// A body that only reads, known now that it is here, goes on to where it was sent, as it was
|
|
882
|
+
// sent, and its answer comes back through this server.
|
|
883
|
+
if (w && parts.length < 256 && readsOnly(text, w.u, (name) => String(req.headers[name] || ""), w.port)) {
|
|
884
|
+
const { "x-cortad-held": _held, host: _host, connection: _connection, ...headers } = req.headers;
|
|
885
|
+
const given = typeof w.args[0] === "string" || w.args[0] instanceof URL;
|
|
886
|
+
const options = { ...((given ? w.args[1] : w.args[0]) || {}), headers, signal: undefined, [PASSED]: true };
|
|
887
|
+
// Sent inside the request that asked, so the call is recorded behind that reply.
|
|
888
|
+
const send = () => (w.secure ? https : http).request(...(given ? [w.args[0], options] : [options]));
|
|
889
|
+
const up = w.ctx ? als.run(w.ctx, send) : send();
|
|
890
|
+
up.on("response", (got) => { res.writeHead(got.statusCode || 502, got.headers); got.pipe(res); });
|
|
891
|
+
up.on("error", () => { if (!res.headersSent) res.writeHead(502); res.end(); });
|
|
892
|
+
res.on("close", () => up.destroy());
|
|
893
|
+
return up.end(sent);
|
|
894
|
+
}
|
|
845
895
|
if (w) heldRow(w.u, w.method, text, req.headers["content-type"], w.ctx);
|
|
846
896
|
res.writeHead(200, { "content-type": "application/json" });
|
|
847
897
|
res.end(heldReply(text, req.headers["content-type"]));
|
|
@@ -867,12 +917,12 @@ if (FILE) {
|
|
|
867
917
|
const method = String(o.method || "GET").toUpperCase();
|
|
868
918
|
const u = new URL(`${secure ? "https" : "http"}://${host.includes(":") && !host.startsWith("[") ? `[${host}]` : host}${path.startsWith("/") ? path : "/" + path}`);
|
|
869
919
|
const headerOf = (name) => headerIn(o.headers, name) || (name === "authorization" && o.auth ? `Basic ${Buffer.from(String(o.auth)).toString("base64")}` : "");
|
|
870
|
-
return heldHere(u, method, headerOf) ? { u, method, o, cb: args.find((a) => typeof a === "function") } : null;
|
|
920
|
+
return heldHere(u, method, headerOf) ? { u, method, o, args: args.filter((a) => typeof a !== "function"), port: o.port || (given && given.port) || "", cb: args.find((a) => typeof a === "function") } : null;
|
|
871
921
|
};
|
|
872
922
|
const httpRequest = http.request;
|
|
873
|
-
const sendHeld = ({ u, method, o, cb }, secure) => {
|
|
923
|
+
const sendHeld = ({ u, method, o, cb, args, port }, secure) => {
|
|
874
924
|
const id = String(++heldSent);
|
|
875
|
-
heldWaiting.set(id, { u, method, ctx: als.getStore() });
|
|
925
|
+
heldWaiting.set(id, { u, method, ctx: als.getStore(), args, port, secure });
|
|
876
926
|
const headers = { ...(o.headers && typeof o.headers === "object" && !Array.isArray(o.headers) ? o.headers : {}), "x-cortad-held": id };
|
|
877
927
|
// Port 1 answers nothing: a write held before the server listens fails instead of leaving.
|
|
878
928
|
const req = httpRequest({ host: "127.0.0.1", port: heldPort || 1, method, path: u.pathname + u.search, headers, ...(o.timeout ? { timeout: o.timeout } : {}), ...(o.signal ? { signal: o.signal } : {}) }, cb);
|
|
@@ -883,7 +933,7 @@ if (FILE) {
|
|
|
883
933
|
const heldFetch = async (realFetch, input, init) => {
|
|
884
934
|
const req = new Request(input, init);
|
|
885
935
|
const text = await req.clone().text().catch(() => "");
|
|
886
|
-
if (readsOnly(text)) return realFetch(req);
|
|
936
|
+
if (readsOnly(text, new URL(req.url), (name) => req.headers.get(name) || "")) return realFetch(req);
|
|
887
937
|
const u = new URL(req.url);
|
|
888
938
|
heldRow(u, req.method, text, req.headers.get("content-type"), als.getStore());
|
|
889
939
|
return new Response(heldReply(text, req.headers.get("content-type")), { status: 200, headers: { "content-type": "application/json" } });
|
|
@@ -945,7 +995,7 @@ if (FILE) {
|
|
|
945
995
|
const request = mod.request;
|
|
946
996
|
mod.request = function (...args) {
|
|
947
997
|
let held = null;
|
|
948
|
-
try { held = heldCall(args, mod === https); } catch { /* not a call we can read: sent as asked */ }
|
|
998
|
+
try { held = passedOn(args) ? null : heldCall(args, mod === https); } catch { /* not a call we can read: sent as asked */ }
|
|
949
999
|
if (held) return sendHeld(held, mod === https);
|
|
950
1000
|
const req = request.apply(this, args);
|
|
951
1001
|
try {
|
package/lib/wire.cjs
CHANGED
|
@@ -761,14 +761,14 @@ function makeRecorder({ row, rulesFile, onTurn = () => {}, onEnded = () => {} })
|
|
|
761
761
|
// of the person's is kept: their newest is the sign-in and the proof a run takes, and the command
|
|
762
762
|
// keeps only the newest few per door.
|
|
763
763
|
// ponytail: the trace file grows by one row per request of the person's; a cap per route if a person's own load test fills it.
|
|
764
|
-
const EXCHANGES_PER_ROUTE = 4, CANARY_PER_ROUTE = 30, ROUTES_KEPT = 1000, SENT_MAX = 4, MODEL_WORDS = 4000;
|
|
764
|
+
const EXCHANGES_PER_ROUTE = 4, CANARY_PER_ROUTE = 30, ROUTES_KEPT = 1000, SENT_MAX = 4, MODEL_WORDS = 4000, SAID_KEPT = 12;
|
|
765
765
|
const slotOf = (turn) => (!turn ? { cls: "", max: Infinity } : /^canary:/.test(turn) ? { cls: "canary ", max: CANARY_PER_ROUTE } : { cls: "ours ", max: EXCHANGES_PER_ROUTE });
|
|
766
766
|
const perRoute = new Map();
|
|
767
767
|
// One route whatever id its path carries: a conversation in the path is a new path every trial.
|
|
768
768
|
const routeKey = (method, path) => `${method} ${path.split("?")[0].split("/").map((s) => (/^\d+$/.test(s) || (s.length >= 8 && /\d/.test(s)) ? "{id}" : s)).join("/")}`;
|
|
769
769
|
let lastExchange = 0;
|
|
770
770
|
const { makeTied, sentencesIn } = require("./tied.cjs");
|
|
771
|
-
const tied = makeTied({ norm, bodyOf, replyOf: (ctx) => (ctx.reply ? ctx.reply.text.slice(0, MAX) : "") });
|
|
771
|
+
const tied = makeTied({ norm, bodyOf, replyOf: (ctx) => (ctx.reply ? ctx.reply.text.slice(0, MAX) : ""), saidOf: (ctx) => (ctx.late ? ctx.saidAll || [] : undefined) });
|
|
772
772
|
const requestCtx = (method, path, headers, turn) => {
|
|
773
773
|
if (turn) onTurn();
|
|
774
774
|
const ctx = { id: `${process.pid}.${++lastExchange}`, at: Date.now(), method, path, headers, chunks: [], size: 0, noted: false, kept: false, sent: [], turn, sites: [], traced: 0 };
|
|
@@ -866,7 +866,10 @@ function makeRecorder({ row, rulesFile, onTurn = () => {}, onEnded = () => {} })
|
|
|
866
866
|
const told = asked && typeof asked === "object" && !EMBEDDING.test(path) ? toldOf(asked, ctx ? new Set([...heard, ...heardIn(ctx)]) : heard) : {};
|
|
867
867
|
tied.done(ctx);
|
|
868
868
|
if (ctx && ctx.late) {
|
|
869
|
-
if (!EMBEDDING.test(path))
|
|
869
|
+
if (!EMBEDDING.test(path)) {
|
|
870
|
+
ctx.said = { status, words: replyText(reply.events).slice(0, MODEL_WORDS), at: Date.now() };
|
|
871
|
+
ctx.saidAll = [...(ctx.saidAll || []), ctx.said.words].slice(-SAID_KEPT);
|
|
872
|
+
}
|
|
870
873
|
settle(ctx);
|
|
871
874
|
}
|
|
872
875
|
row({ call: { at: Date.now(), ms: Date.now() - t0, host: String(host).replace(/:443$/, ""), model: String(askedFor(path, sent) || reply.model || "").slice(0, 160), ...answeredBy(askedFor(path, sent), reply.model), status, ...(reply.tokens || { promptTokens: 0, cachedTokens: 0, completionTokens: 0 }), usage: Boolean(reply.tokens), ...(EMBEDDING.test(path) ? { embedding: true } : {}), ...(ctx ? { ex: ctx.id } : {}), ...(ctx && (ctx.kept || ctx.turn) ? { reply: replyText(reply.events).slice(0, MODEL_WORDS) } : {}), ...(turn ? { turn } : {}), ...(rules ? { rules } : {}), ...(tools ? { tools } : {}), ...(called ? { called } : {}), ...(passages ? { passages } : {}), ...(caller ? { caller } : {}), ...(reply.limit ? { limit: { cap: capOf(asked), reasoning: reply.reasoning } } : {}), ...told } });
|
package/local.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// on with the key this connect leaves in ~/.cortad. Nothing here touches git. Your environment
|
|
13
13
|
// never leaves this machine. Ctrl-C ends everything.
|
|
14
14
|
|
|
15
|
-
import { storesOf } from "./lib/stores.mjs";
|
|
15
|
+
import { storesOf, withoutStoreSettings } from "./lib/stores.mjs";
|
|
16
16
|
import { notAnApp, withoutStores } from "./lib/app-port.mjs";
|
|
17
17
|
import { holdsKeys, secretEnvValues } from "./lib/keys.mjs";
|
|
18
18
|
import { spawn, execFile, execFileSync } from "node:child_process";
|
|
@@ -38,7 +38,7 @@ import { CAPTURED, makeCapture } from "./lib/replay.mjs";
|
|
|
38
38
|
import { sampleHere } from "./lib/sample.mjs";
|
|
39
39
|
import { mintAcross, originFor, servicePort, waitForPort } from "./lib/service.mjs";
|
|
40
40
|
import { listingUrl } from "./lib/listing.mjs";
|
|
41
|
-
import { hookable, installPlan, missingDependency, startPlan, workspaces } from "./lib/start.mjs";
|
|
41
|
+
import { hookable, installPlan, missingDependency, shownCommand, startPlan, workspaces } from "./lib/start.mjs";
|
|
42
42
|
import { openProxy } from "./lib/proxy.mjs";
|
|
43
43
|
import { liftedLimits, sessionLifts } from "./lib/switches.mjs";
|
|
44
44
|
import { makeKeeping } from "./lib/keeping.mjs";
|
|
@@ -663,6 +663,10 @@ async function ask(question) {
|
|
|
663
663
|
return line.trim() || null;
|
|
664
664
|
}
|
|
665
665
|
let child = null;
|
|
666
|
+
// An app this command did not start carries nothing that counts its model calls, and no run plays
|
|
667
|
+
// against it (the API holds Run): databuddy's free run played 39 conversations into a route that
|
|
668
|
+
// answers from code.
|
|
669
|
+
const UNSEEN = "your app was already running, so Cortad cannot see the calls it makes to its model, and no run can start.";
|
|
666
670
|
async function startApp() {
|
|
667
671
|
const wanted = Number(flag("--port"));
|
|
668
672
|
// "--port 8106 --start ..." is "start it, it will answer on 8106", not "it is already on 8106":
|
|
@@ -671,11 +675,10 @@ async function startApp() {
|
|
|
671
675
|
if (!(await answers(wanted))) return { port: null, said: "", why: `nothing is answering on port ${wanted}, so there is nothing to ask.`, noStart: true };
|
|
672
676
|
const took = await takeOver(wanted);
|
|
673
677
|
if (took) return took;
|
|
674
|
-
say("your app was already running, so its request limits stay as they are; if it answers 429, stop it and run this without --port and they are raised for the session");
|
|
675
678
|
if (proxyAsked) {
|
|
676
679
|
await useProxy();
|
|
677
|
-
say(`to
|
|
678
|
-
}
|
|
680
|
+
say(`your app was already running, so Cortad cannot see the calls it makes to its model until it is started with: ${Object.entries(proxy.env({ ...envExports(envFiles), ...process.env })).map(([k, v]) => `${k}=${v}`).join(" ")}`);
|
|
681
|
+
} else say(`${UNSEEN} Stop it and run this again without --port: this command starts your app itself.`);
|
|
679
682
|
return { port: wanted, cmd: null };
|
|
680
683
|
}
|
|
681
684
|
const plan = startPlan({ root, typed: flag("--start"), onPath });
|
|
@@ -718,7 +721,7 @@ async function startApp() {
|
|
|
718
721
|
await keeping.forget();
|
|
719
722
|
const took = await takeOver(held.port);
|
|
720
723
|
if (took) return took;
|
|
721
|
-
say(
|
|
724
|
+
say(`${UNSEEN} Stop the one on port ${held.port} and run this again: this command starts your app itself.`);
|
|
722
725
|
return { port: held.port, cmd: null };
|
|
723
726
|
}
|
|
724
727
|
}
|
|
@@ -912,7 +915,7 @@ async function freed(cmd, lifted) {
|
|
|
912
915
|
// was copied, started, or left where trials write into it is told in the terminal here and to the
|
|
913
916
|
// run with the app (announce), so status says it beside Run and the report keeps it.
|
|
914
917
|
const keeping = makeKeeping({
|
|
915
|
-
root, work, ledgerFile: join(homeOf(project), "made.json"), onPath, say, hold,
|
|
918
|
+
root, work, ledgerFile: join(homeOf(project), "made.json"), onPath, say, hold, typed: Boolean(flag("--start")),
|
|
916
919
|
launch: (plan) => sidecar(plan.cmd, plan.cwd),
|
|
917
920
|
connections: () => capture?.connections() ?? [],
|
|
918
921
|
settings: () => {
|
|
@@ -941,7 +944,10 @@ async function start(waitMs, tries = 3) {
|
|
|
941
944
|
loaded = note;
|
|
942
945
|
const copies = await keeping.env(true);
|
|
943
946
|
await allowOutbound();
|
|
944
|
-
|
|
947
|
+
// A start command the person typed says which databases the app uses: the store settings of their
|
|
948
|
+
// settings files are left for the app to load, since handed over here they outrank the
|
|
949
|
+
// environment that command chose, and no copy is pointed at.
|
|
950
|
+
const base = flag("--start") ? { ...withoutStoreSettings(envExports(envFiles)), ...process.env, ...lifted } : { ...envExports(envFiles), ...process.env, ...lifted, ...copies };
|
|
945
951
|
child = spawnTied(cmd, { cwd: appDir, env: { ...base, ...(proxy ? proxy.env(base) : capture ? capture.env(process.env) : {}), FORCE_COLOR: "0", ...(pinned.bin ? { PATH: `${pinned.bin}:${process.env.PATH ?? ""}` } : {}) } });
|
|
946
952
|
const mine = child;
|
|
947
953
|
become("starting", { app: mine.pid ?? null });
|
|
@@ -1167,7 +1173,7 @@ identities = makeIdentities({ root, work, envFiles, sourceFiles: () => files, sa
|
|
|
1167
1173
|
// A real request that reached their model proves its door (lib/proof.mjs). What it proved goes up,
|
|
1168
1174
|
// masked like everything else, and nothing goes up that the mask could not read; the sign-in each
|
|
1169
1175
|
// request carried stays here.
|
|
1170
|
-
capture = makeCapture({ work, keepSecret, writes: join(homeOf(projectOf(root)), "writes"), root, files: () => files, appFolder: () => relative(root, appDir), printed: printedBetween, onProof: (proof) => {
|
|
1176
|
+
capture = makeCapture({ work, keepSecret, signIns: join(homeOf(projectOf(root)), "sign-ins.json"), writes: join(homeOf(projectOf(root)), "writes"), root, files: () => files, appFolder: () => relative(root, appDir), printed: printedBetween, onProof: (proof) => {
|
|
1171
1177
|
let masked;
|
|
1172
1178
|
try { masked = JSON.parse(mask(JSON.stringify(proof))); } catch { return; }
|
|
1173
1179
|
call("POST", `/local/${box}/proof`, masked).catch(() => {});
|
|
@@ -1285,7 +1291,7 @@ const announce = async () => {
|
|
|
1285
1291
|
// Through the proxy, a message is seen arriving at its front, and its model calls once the app was started with its settings on the proxy.
|
|
1286
1292
|
const hooked = Boolean(proxy ? launched || proxy.seen() : launched && capture?.watching(app.port));
|
|
1287
1293
|
const started = Boolean(launched);
|
|
1288
|
-
const told = await call("POST", `/local/${box}/app`, { port: doorPort(), cmd: app.cmd, origins: envOrigins(envFiles), lifted: app.lifted ?? [], data: (await keeping.lines(started)).map(mask), blocked: await keeping.blocked(started), watching: hooked, metered: hooked, proves: hooked });
|
|
1294
|
+
const told = await call("POST", `/local/${box}/app`, { port: doorPort(), cmd: shownCommand(app.cmd, mask), origins: envOrigins(envFiles), lifted: app.lifted ?? [], data: (await keeping.lines(started)).map(mask), blocked: await keeping.blocked(started), watching: hooked, metered: hooked, proves: hooked });
|
|
1289
1295
|
if (told.ok) await allowOutbound(told.data?.consent);
|
|
1290
1296
|
return told;
|
|
1291
1297
|
};
|
|
@@ -1334,7 +1340,7 @@ async function appLife(firstStart) {
|
|
|
1334
1340
|
clearStep();
|
|
1335
1341
|
if (!told.ok) return quit(told.data?.error ?? `could not register your app (${told.status})`);
|
|
1336
1342
|
machineOf = () => (app ? {
|
|
1337
|
-
cmd: app.cmd ?? "", dir: relative(root, appDir) || ".", port: app.port, started: Boolean(launched),
|
|
1343
|
+
cmd: shownCommand(app.cmd, mask) ?? "", dir: relative(root, appDir) || ".", port: app.port, started: Boolean(launched),
|
|
1338
1344
|
hooked: Boolean(launched && !proxy && capture?.watching(app.port)), proxy: proxy?.port ?? null,
|
|
1339
1345
|
changed: [...new Set([...(app.lifted ?? []), ...Object.keys(keeping.envNow())])],
|
|
1340
1346
|
copies: Object.keys(keeping.envNow()).length ? "made" : "none",
|
package/package.json
CHANGED