cortad 0.3.9 → 0.3.11
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 +3 -0
- package/lib/cli.mjs +4 -1
- package/lib/commits.mjs +202 -0
- package/lib/contact-text.mjs +13 -33
- package/lib/container.mjs +60 -0
- package/lib/data.mjs +15 -9
- package/lib/elastic.mjs +88 -0
- package/lib/env-files.mjs +124 -0
- package/lib/env-secrets.mjs +12 -4
- package/lib/intel-text.mjs +47 -0
- package/lib/keeping.mjs +33 -26
- package/lib/keys.mjs +10 -6
- package/lib/lock.mjs +7 -6
- package/lib/mcp.mjs +37 -4
- package/lib/mint.mjs +27 -11
- package/lib/mongo.mjs +64 -3
- package/lib/proof.mjs +8 -1
- package/lib/read-text.mjs +15 -15
- package/lib/stores.mjs +118 -63
- package/lib/text.mjs +17 -16
- package/lib/verbs.mjs +57 -2
- package/local.mjs +170 -86
- package/package.json +6 -1
- package/skill/SKILL.md +28 -11
- package/skill/references/results.md +17 -17
package/lib/mongo.mjs
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
// A MongoDB database copied for a session on the server it lives on, with MongoDB's own tools: a
|
|
2
|
-
// dump of it restored under a name of ours, and dropped after.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// dump of it restored under a name of ours, and dropped after. Or, for an app that names its
|
|
3
|
+
// databases in its code, the whole server copied into a container of ours (serveMongo). The address
|
|
4
|
+
// carries the password, so it reaches mongodump and mongorestore through a config file only its user
|
|
5
|
+
// can read, and mongosh through its environment: never a command line another process can read.
|
|
5
6
|
import { spawn } from "node:child_process";
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
6
8
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
7
9
|
import { tmpdir } from "node:os";
|
|
8
10
|
import { join } from "node:path";
|
|
11
|
+
import { docker, dockerUp, freePort, runCopy, servedBy, until } from "./container.mjs";
|
|
9
12
|
|
|
10
13
|
// A database bigger than this is not copied: said instead, so the person decides.
|
|
11
14
|
export const MONGO_CAP = 1024 ** 3;
|
|
@@ -72,3 +75,61 @@ export async function cloneMongo(conn, source, clone) {
|
|
|
72
75
|
export async function dropMongo(conn, clone, source) {
|
|
73
76
|
return (await shell(withDb(conn.uri, clone, source), `c.getDB(${JSON.stringify(clone)}).dropDatabase()`)).code === 0;
|
|
74
77
|
}
|
|
78
|
+
|
|
79
|
+
// The address with only this host in it, for a reader that talks to the one server it names: a
|
|
80
|
+
// replica set's member list holds names only its own network resolves.
|
|
81
|
+
function directTo(uri, hostPort) {
|
|
82
|
+
const [, head, hosts, rest = "", query = "", tail = ""] = /^([^:]+:\/\/)([^/?#]*)(\/[^?#]*)?(\?[^#]*)?(#.*)?$/.exec(uri);
|
|
83
|
+
const cut = hosts.lastIndexOf("@");
|
|
84
|
+
const params = new URLSearchParams(query.slice(1));
|
|
85
|
+
params.delete("replicaSet");
|
|
86
|
+
params.set("directConnection", "true");
|
|
87
|
+
return `${head}${cut >= 0 ? hosts.slice(0, cut + 1) : ""}${hostPort}${rest || "/"}?${params}${tail}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// A replica set that asks for a password needs a key its members share, one member or not: made in
|
|
91
|
+
// the container before mongod starts, readable by mongod alone.
|
|
92
|
+
const KEYED = 'umask 077; head -c 756 /dev/urandom | base64 > /tmp/cortad.key; chown mongodb /tmp/cortad.key; exec docker-entrypoint.sh mongod "$@"';
|
|
93
|
+
|
|
94
|
+
// The whole server copied into a container of ours (lib/container.mjs) with MongoDB's own tools,
|
|
95
|
+
// which the container has whatever this machine lacks: every database, its indexes, and its users
|
|
96
|
+
// and roles, so the app signs in there as it does on the original. A server the app signs in to is
|
|
97
|
+
// copied into one that asks for a password too: the dump is restored by a user of ours, made through
|
|
98
|
+
// MongoDB's localhost exception and dropped after. Answers { port } or { why }.
|
|
99
|
+
export async function serveMongo(conn, name) {
|
|
100
|
+
if (!(await dockerUp())) return { why: "docker" };
|
|
101
|
+
const from = await servedBy(conn.port);
|
|
102
|
+
let image = from?.image;
|
|
103
|
+
if (!image) {
|
|
104
|
+
const v = await shell(conn.uri, "print(c.getDB('admin').version())");
|
|
105
|
+
if (v.code !== 0) return { why: refusal(v) };
|
|
106
|
+
image = `mongo:${v.out.trim()}`;
|
|
107
|
+
}
|
|
108
|
+
const port = await freePort();
|
|
109
|
+
const replSet = new URLSearchParams(/\?([^#]*)/.exec(conn.uri)?.[1] ?? "").get("replicaSet");
|
|
110
|
+
const restorer = conn.user ? { user: `cortad_${randomBytes(4).toString("hex")}`, pwd: randomBytes(24).toString("hex") } : null;
|
|
111
|
+
const mongod = ["--port", String(port), "--bind_ip_all", ...(replSet ? ["--replSet", replSet] : []), ...(restorer ? ["--auth"] : [])];
|
|
112
|
+
const started = restorer && replSet
|
|
113
|
+
? { entrypoint: "bash", args: ["-c", KEYED, "bash", ...mongod, "--keyFile", "/tmp/cortad.key"] }
|
|
114
|
+
: { args: mongod };
|
|
115
|
+
if (!(await runCopy({ name, image, port, network: from?.net, ...started }))) return { why: "container" };
|
|
116
|
+
const inside = (script, env = {}) => docker(["exec", ...Object.keys(env).flatMap((k) => ["-e", k]), name, "mongosh", "--quiet", "--port", String(port), "--eval", script], { env });
|
|
117
|
+
if (!(await until(async () => (await inside("db.runCommand({ ping: 1 }).ok")).out.trim() === "1", 90_000))) return { why: "other" };
|
|
118
|
+
if (replSet) {
|
|
119
|
+
await inside(`rs.initiate({ _id: ${JSON.stringify(replSet)}, members: [{ _id: 0, host: "127.0.0.1:${port}" }] })`);
|
|
120
|
+
if (!(await until(async () => (await inside("db.hello().isWritablePrimary")).out.trim() === "true", 60_000))) return { why: "other" };
|
|
121
|
+
}
|
|
122
|
+
if (restorer && (await inside(`print(db.getSiblingDB("admin").createUser({ user: ${JSON.stringify(restorer.user)}, pwd: process.env.CORTAD_PWD, roles: ["root"] }).ok)`, { CORTAD_PWD: restorer.pwd })).out.trim() !== "1") return { why: "other" };
|
|
123
|
+
const source = directTo(conn.uri, from?.ip ? `${from.ip}:${from.inner}` : `host.docker.internal:${conn.port}`);
|
|
124
|
+
const target = `mongodb://${restorer ? `${restorer.user}:${restorer.pwd}@` : ""}127.0.0.1:${port}/?directConnection=true${restorer ? "&authSource=admin" : ""}`;
|
|
125
|
+
const size = await docker(["exec", "-e", "CORTAD_SOURCE", name, "mongosh", "--nodb", "--quiet", "--eval", "print(new Mongo(process.env.CORTAD_SOURCE).getDB('admin').adminCommand({ listDatabases: 1 }).totalSize)"], { env: { CORTAD_SOURCE: source } });
|
|
126
|
+
if (!size.ok) return { why: refusal({ code: 1, err: size.err + size.out }) };
|
|
127
|
+
if (Number(size.out.trim()) > MONGO_CAP) return { why: "big" };
|
|
128
|
+
// Each address goes into a file only the container's own user reads, never onto a command line.
|
|
129
|
+
const copy = await docker(["exec", "-e", "CORTAD_FROM", "-e", "CORTAD_TO", name, "bash", "-c",
|
|
130
|
+
"set -o pipefail; umask 077; printf '%s\\n' \"$CORTAD_FROM\" > /tmp/from.yaml && printf '%s\\n' \"$CORTAD_TO\" > /tmp/to.yaml && mongodump --config=/tmp/from.yaml --archive --quiet | mongorestore --config=/tmp/to.yaml --archive --quiet --nsExclude='config.*'; s=$?; rm -f /tmp/from.yaml /tmp/to.yaml; exit $s"],
|
|
131
|
+
{ env: { CORTAD_FROM: `uri: ${JSON.stringify(source)}`, CORTAD_TO: `uri: ${JSON.stringify(target)}` }, timeout: 900_000 });
|
|
132
|
+
if (!copy.ok) return { why: refusal({ code: 1, err: copy.err }) };
|
|
133
|
+
if (restorer) await docker(["exec", "-e", "CORTAD_TARGET", name, "mongosh", "--nodb", "--quiet", "--eval", `new Mongo(process.env.CORTAD_TARGET).getDB("admin").dropUser(${JSON.stringify(restorer.user)})`], { env: { CORTAD_TARGET: target } });
|
|
134
|
+
return { port };
|
|
135
|
+
}
|
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/read-text.mjs
CHANGED
|
@@ -87,30 +87,30 @@ const listed = (names) => (names.length > 1 ? `${names.slice(0, -1).join(", ")}
|
|
|
87
87
|
|
|
88
88
|
// What the connect command does on this machine, from the facts it keeps in the runner's record:
|
|
89
89
|
// what it started and stops, the database copies, the settings its app runs with, what it loads,
|
|
90
|
-
// what a run holds and puts back, and what it keeps
|
|
91
|
-
//
|
|
90
|
+
// what a run holds and puts back, and what it keeps outside this repository. The first line is the
|
|
91
|
+
// one the terminal says at connect (connectedText); the rest is read when someone asks.
|
|
92
92
|
export function machineText(f) {
|
|
93
93
|
if (!f) return ["No connect command is running for this repository now, so nothing Cortad started is running here."];
|
|
94
94
|
const lines = [
|
|
95
95
|
f.started
|
|
96
|
-
? `
|
|
97
|
-
: `
|
|
98
|
-
f.copies === "made"
|
|
99
|
-
? "For the person: Where Cortad could copy a database, your app runs on the copy, made from your own at every connect and deleted when this command ends, so records created in a copy are gone at the next connect; keep records every run needs in your own database. Each Data line in status says what happens to one store."
|
|
100
|
-
: "For the person: No copy of a database was made for this session, so what test conversations create stays where your app keeps its data; each Data line in status names one store.",
|
|
96
|
+
? `Cortad started your app in ${f.dir === "." ? "this folder" : f.dir} and stops it when this command stops. Your code and settings files are never edited.`
|
|
97
|
+
: `Cortad uses your app already running on port ${f.port} and leaves it running. Your code and settings files are never edited.`,
|
|
101
98
|
];
|
|
99
|
+
if (f.copies === "made") lines.push("Test conversations save into a copy of your database, made fresh at each connect and deleted when this command stops. Your own data is not changed.");
|
|
102
100
|
// Env values reach only a process the command started itself.
|
|
103
|
-
if (f.started) lines.push(
|
|
104
|
-
lines.push(f.hooked ? "
|
|
105
|
-
: f.proxy ? `A local proxy on port ${f.proxy} sees each request sent to it and the model calls your app makes through it
|
|
106
|
-
: "
|
|
101
|
+
if (f.started) lines.push(`Your env values stay on this machine${f.changed?.length ? `, with ${listed(f.changed)} set for this session only` : ""}. None is sent to Cortad.`);
|
|
102
|
+
lines.push(f.hooked ? "Cortad sees each request that reaches your model and the model calls it makes. Request headers stay on this machine and are deleted when the command stops."
|
|
103
|
+
: f.proxy ? `A local proxy on port ${f.proxy} sees each request sent to it and the model calls your app makes through it. Request headers stay on this machine and are deleted when the command stops.`
|
|
104
|
+
: "Cortad did not start your app, so it does not see the calls your app makes to its model.");
|
|
107
105
|
// Only the hook holds a write or records a file: through the proxy, or with nothing loaded, both go as the app does them.
|
|
108
|
-
lines.push(f.hooked ? "During a run,
|
|
109
|
-
: "During a run,
|
|
110
|
-
lines.push(f.hooked ? "Files your app writes in its own folder during a run are put back when
|
|
111
|
-
lines.push("Kept
|
|
106
|
+
lines.push(f.hooked ? "During a run, anything your app would send to an outside service, such as a payment or an email, is answered inside your app and never sent, unless it carries a test key, goes to a provider's sandbox, or goes to a host you allowed on the card. A call that only reads goes out."
|
|
107
|
+
: "During a run, what your app sends to an outside service, such as a payment or an email, is sent for real.");
|
|
108
|
+
lines.push(f.hooked ? "Files your app writes in its own folder during a run are put back when the run ends. Its log files are left as they are." : "Files your app writes in its own folder stay as it writes them.");
|
|
109
|
+
lines.push("Kept in ~/.cortad on this machine between connects: this repository's key, the key the test accounts' passwords are made from, what to put back after a run, and your app's output from the last session with your env values masked.");
|
|
112
110
|
return lines;
|
|
113
111
|
}
|
|
112
|
+
// What the terminal says once the app answers: the one line the person needs.
|
|
113
|
+
export const connectedText = (f) => machineText(f).slice(0, 1);
|
|
114
114
|
|
|
115
115
|
export function showText(d, show, page = 1) {
|
|
116
116
|
if (!d.read || (d.read.complete === false && show !== "reviews")) return "Cortad is still reading this repository.";
|
package/lib/stores.mjs
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
// Your app's database servers, kept out of the run's way. A run creates people, conversations and
|
|
2
|
-
// records by the hundred; an app pointed at Postgres, Redis, MongoDB or a Qdrant
|
|
3
|
-
// every one of them. So each store the app's settings name on this machine is copied
|
|
4
|
-
// server
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
2
|
+
// records by the hundred; an app pointed at Postgres, Redis, MongoDB, Elasticsearch or a Qdrant
|
|
3
|
+
// collection kept every one of them. So each store the app's settings name on this machine is copied
|
|
4
|
+
// on its own server under a name of ours, or, where the app names its databases in its code, as a
|
|
5
|
+
// whole server in a container of ours (lib/container.mjs); the app is started pointed at the copy
|
|
6
|
+
// through the same settings, and the copy is deleted when this command ends. The original is only
|
|
7
|
+
// ever read. A store no copy is made of holds Run until the person says yes to writing into it. A
|
|
8
|
+
// store off this machine is copied where it can be (lib/hosted.mjs) and otherwise holds Run.
|
|
8
9
|
import { createHash } from "node:crypto";
|
|
10
|
+
import { copyName, removeCopy } from "./container.mjs";
|
|
11
|
+
import { serveElastic } from "./elastic.mjs";
|
|
9
12
|
import { hostedKeeper } from "./hosted.mjs";
|
|
10
13
|
import { ledger } from "./ledger.mjs";
|
|
11
|
-
import { cloneMongo, dropMongo, withDb } from "./mongo.mjs";
|
|
14
|
+
import { cloneMongo, dropMongo, serveMongo, withDb } from "./mongo.mjs";
|
|
12
15
|
import { clonePg, dropPg } from "./pg.mjs";
|
|
13
16
|
import { cloneQdrant, dropQdrant } from "./qdrant.mjs";
|
|
14
17
|
import { cloneRedis, dropRedis } from "./redis.mjs";
|
|
@@ -29,12 +32,14 @@ const COPY = {
|
|
|
29
32
|
mongo: { copy: (s, clone) => cloneMongo(s.conn, s.db, clone), drop: (s, clone) => dropMongo(s.conn, clone, s.db) },
|
|
30
33
|
qdrant: { copy: (s, clone) => cloneQdrant(s.conn, s.db, clone), drop: (s, clone) => dropQdrant(s.conn, clone) },
|
|
31
34
|
};
|
|
35
|
+
// Stores whose app names its databases in its code are copied as a whole server instead.
|
|
36
|
+
const SERVER = { mongo: (s, name) => serveMongo(s.conn, name), elasticsearch: (s, name) => serveElastic(s.conn, name) };
|
|
32
37
|
const TOOLS = { postgres: "Postgres's own psql and pg_dump are", mongo: "MongoDB's own mongodump, mongorestore and mongosh are" };
|
|
33
38
|
|
|
34
39
|
const SCHEME = /^(postgres(?:ql)?|mysql|mariadb|mongodb|rediss?|valkeys?|sqlserver|mssql)((?:\+\w+)*):\/\/([^/?#]*)(\/[^?#]*)?([?#].*)?$/i;
|
|
35
40
|
const SCHEME_ENGINE = { postgres: "postgres", postgresql: "postgres", mysql: "mysql", mariadb: "mysql", mongodb: "mongo", redis: "redis", rediss: "redis", valkey: "redis", valkeys: "redis", sqlserver: "sqlserver", mssql: "sqlserver" };
|
|
36
41
|
// A search or vector store is named by what its setting is called, since it speaks plain HTTP.
|
|
37
|
-
const BY_NAME = [["qdrant", /QDRANT/], ["chroma", /CHROMA/], ["milvus", /MILVUS|ZILLIZ/], ["weaviate", /WEAVIATE/], ["elasticsearch", /ELASTIC|OPENSEARCH/], ["meilisearch", /MEILI/], ["typesense", /TYPESENSE/], ["neo4j", /NEO4J/], ["supabase", /SUPABASE/]];
|
|
42
|
+
const BY_NAME = [["qdrant", /QDRANT/], ["chroma", /CHROMA/], ["milvus", /MILVUS|ZILLIZ/], ["weaviate", /WEAVIATE/], ["elasticsearch", /ELASTIC|OPENSEARCH|^ES_/], ["meilisearch", /MEILI/], ["typesense", /TYPESENSE/], ["neo4j", /NEO4J/], ["supabase", /SUPABASE/]];
|
|
38
43
|
const WORD_ENGINE = [["postgres", /^(?:PG|POSTGRES|PSQL)|POSTGRES/], ["mysql", /MYSQL|MARIA/], ["mongo", /MONGO/], ["redis", /REDIS|VALKEY/]];
|
|
39
44
|
const SECRETISH = /(?:^|_)(?:KEY|TOKEN|SECRET|PASSWORD|PASS|PWD)$/;
|
|
40
45
|
// Every engine's word in a setting's name, so a setting that carries one is that engine's own.
|
|
@@ -43,6 +48,7 @@ const ENGINE_WORD = new RegExp([...Object.keys(ENGINE), ...[...BY_NAME, ...WORD_
|
|
|
43
48
|
// their own command is handed the rest: which databases it uses is that command's to say.
|
|
44
49
|
const STORE_SETTING = new RegExp(`${ENGINE_WORD.source}|(?:^|_)(?:DB|DATABASE)(?:_|$)`, "i");
|
|
45
50
|
export const withoutStoreSettings = (values) => Object.fromEntries(Object.entries(values).filter(([name]) => !STORE_SETTING.test(name)));
|
|
51
|
+
export const storeSettings = (values) => Object.fromEntries(Object.entries(values).filter(([name]) => STORE_SETTING.test(name)));
|
|
46
52
|
// Stores that hold no collections: a bare COLLECTION setting beside one of them is still Qdrant's.
|
|
47
53
|
const NO_COLLECTIONS = new Set(["redis", "mysql", "sqlserver", "neo4j", "meilisearch", "pinecone"]);
|
|
48
54
|
const LOOPBACK = /^(?:localhost|127(?:\.\d+){3}|::1|\[::1\]|0\.0\.0\.0|host\.docker\.internal)?$/i;
|
|
@@ -85,18 +91,38 @@ function fromUrl(name, value) {
|
|
|
85
91
|
engine, host, port, db,
|
|
86
92
|
conn: { host, port, user: a.user, password: a.password, sslmode: query.get("sslmode") ?? "", tlsOn: /^(?:rediss|valkeys)$/i.test(m[1]), uri: value },
|
|
87
93
|
point: engine === "mongo" ? (to) => ({ [name]: withDb(value, to, db) }) : (to) => ({ [name]: `${m[1]}${m[2]}://${m[3]}/${encodeURIComponent(String(to))}${m[5] ?? ""}` }),
|
|
94
|
+
// The same address on another server: what a copy of the whole server is reached through.
|
|
95
|
+
move: (to, toPort) => ({ [name]: `${m[1]}${m[2]}://${m[3].slice(0, m[3].lastIndexOf("@") + 1)}${to}:${toPort}${m[4] ?? ""}${m[5] ?? ""}` }),
|
|
88
96
|
};
|
|
89
97
|
}
|
|
90
98
|
|
|
91
|
-
// A search or vector store's address: QDRANT_URL=http://localhost:6333, MILVUS_URI=localhost:19530
|
|
99
|
+
// A search or vector store's address: QDRANT_URL=http://localhost:6333, MILVUS_URI=localhost:19530,
|
|
100
|
+
// ELASTICSEARCH_URL=http://elastic:pw@localhost:9200.
|
|
92
101
|
function fromNamed(name, value) {
|
|
93
102
|
const engine = BY_NAME.find(([, re]) => re.test(name))?.[0];
|
|
94
103
|
if (!engine || SECRETISH.test(name)) return null;
|
|
95
|
-
const m = /^(?:(https?|grpc|bolt|neo4j):\/\/)?([\w.-]+|\[[:\w]+\]):?(\d+)?(
|
|
96
|
-
if (!m || (!m[1] && !m[
|
|
97
|
-
const host = m[
|
|
98
|
-
const port = m[
|
|
99
|
-
|
|
104
|
+
const m = /^(?:(https?|grpc|bolt|neo4j):\/\/)?(?:([^@/\s]*)@)?([\w.-]+|\[[:\w]+\]):?(\d+)?([/?#].*)?$/i.exec(value);
|
|
105
|
+
if (!m || (!m[1] && !m[4])) return null;
|
|
106
|
+
const host = m[3].replace(/^\[|\]$/g, "");
|
|
107
|
+
const port = m[4] ? Number(m[4]) : m[1] === "https" ? 443 : PORT_OF[engine] ?? 80;
|
|
108
|
+
const [user, ...pass] = m[2] ? m[2].split(":") : [];
|
|
109
|
+
return {
|
|
110
|
+
engine, host, port, db: null,
|
|
111
|
+
conn: { base: `${m[1] === "https" ? "https" : "http"}://${host.includes(":") ? `[${host}]` : host}:${port}`, port, ...(user ? { user: dec(user), password: dec(pass.join(":")) } : {}) },
|
|
112
|
+
// A copy of the whole server speaks plain HTTP on this machine.
|
|
113
|
+
move: (to, toPort) => ({ [name]: `${m[1] ? "http://" : ""}${m[2] ? `${m[2]}@` : ""}${to}:${toPort}${m[5] ?? ""}` }),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// A search server's sign-in sits in settings beside its address: ELASTIC_PASSWORD, ES_API_KEY.
|
|
118
|
+
const BY_NAME_ELASTIC = BY_NAME.find(([e]) => e === "elasticsearch")[1];
|
|
119
|
+
function withLogin(store, values) {
|
|
120
|
+
if (store.conn.user) return store;
|
|
121
|
+
const own = (re) => values[Object.keys(values).find((n) => BY_NAME_ELASTIC.test(n) && re.test(n) && values[n])];
|
|
122
|
+
const apiKey = own(/API_?KEY$/);
|
|
123
|
+
const password = own(/(?:PASS|PASSWORD)$/);
|
|
124
|
+
if (apiKey) return { ...store, conn: { ...store.conn, apiKey } };
|
|
125
|
+
return password ? { ...store, conn: { ...store.conn, user: own(/USER(?:NAME)?$/) ?? "elastic", password } } : store;
|
|
100
126
|
}
|
|
101
127
|
|
|
102
128
|
// A vector store holds collections, and the app's settings name the ones it uses: QDRANT_COLLECTION,
|
|
@@ -116,6 +142,9 @@ function withCollections(store, values, others) {
|
|
|
116
142
|
return named.map((n) => ({ ...store, conn, db: String(values[n]).trim(), names: [...store.names, n], point: (to) => ({ [n]: String(to) }) }));
|
|
117
143
|
}
|
|
118
144
|
|
|
145
|
+
// MongoDB's tools take one address: the parts' own, written as one.
|
|
146
|
+
const mongoUri = (host, port, user, password) => `mongodb://${user ? `${encodeURIComponent(user)}:${encodeURIComponent(password ?? "")}@` : ""}${host || "127.0.0.1"}:${port}/`;
|
|
147
|
+
|
|
119
148
|
// Settings that name one store in parts, grouped by what they share before the part:
|
|
120
149
|
// POSTGRES_HOST, POSTGRES_PORT, POSTGRES_DB; DB_HOST, DB_NAME, DB_CONNECTION=pgsql; PGHOST, PGDATABASE.
|
|
121
150
|
const HOST_PART = /^(.+?)_?(?:HOST(?:NAME)?|SERVER)$/;
|
|
@@ -148,10 +177,11 @@ function fromParts(values) {
|
|
|
148
177
|
const db = parts.db ? (engine === "redis" ? Number(values[parts.db] || 0) : values[parts.db]) : engine === "redis" ? 0 : "";
|
|
149
178
|
out.push({
|
|
150
179
|
engine, host, port, db,
|
|
151
|
-
conn: { host, port, user: values[parts.user] ?? "", password: values[parts.password] ?? "", sslmode: p === "PG" ? values.PGSSLMODE ?? "" : "" },
|
|
180
|
+
conn: { host, port, user: values[parts.user] ?? "", password: values[parts.password] ?? "", sslmode: p === "PG" ? values.PGSSLMODE ?? "" : "", ...(engine === "mongo" ? { uri: mongoUri(host, port, values[parts.user], values[parts.password]) } : {}) },
|
|
152
181
|
names: [hostName ?? parts.user ?? parts.port, parts.db].filter(Boolean),
|
|
153
182
|
// Only a setting that holds the database can point the app at another one.
|
|
154
183
|
point: parts.db ? (to) => ({ [parts.db]: String(to) }) : null,
|
|
184
|
+
move: !hostName ? null : parts.port ? (to, toPort) => ({ [hostName]: to, [parts.port]: String(toPort) }) : inline ? (to, toPort) => ({ [hostName]: `${to}:${toPort}` }) : null,
|
|
155
185
|
});
|
|
156
186
|
}
|
|
157
187
|
return out;
|
|
@@ -173,11 +203,12 @@ export function storesOf(values) {
|
|
|
173
203
|
}
|
|
174
204
|
found.push(...fromParts(values));
|
|
175
205
|
const engines = found.map((s) => s.engine);
|
|
176
|
-
const each = found.flatMap((s) => (s.engine === "qdrant" ? withCollections(s, values, engines) : [s]));
|
|
206
|
+
const each = found.flatMap((s) => (s.engine === "qdrant" ? withCollections(s, values, engines) : s.engine === "elasticsearch" ? [withLogin(s, values)] : [s]));
|
|
177
207
|
const byKey = new Map();
|
|
178
208
|
const merge = (held, s) => {
|
|
179
209
|
held.names = [...new Set([...held.names, ...s.names])];
|
|
180
210
|
if (s.point) held.points.push(s.point);
|
|
211
|
+
if (s.move) held.moves.push(s.move);
|
|
181
212
|
held.conn ??= s.conn;
|
|
182
213
|
};
|
|
183
214
|
const whole = each.filter((s) => s.db !== "" && s.db !== null);
|
|
@@ -185,7 +216,7 @@ export function storesOf(values) {
|
|
|
185
216
|
const key = `${at(s)}|${s.db ?? ""}`;
|
|
186
217
|
const held = byKey.get(key) ?? (s.db === "" || s.db === null ? [...byKey.values()].find((h) => at(h) === at(s)) : undefined);
|
|
187
218
|
if (held) { merge(held, s); continue; }
|
|
188
|
-
byKey.set(key, { ...s, key, points: s.point ? [s.point] : [] });
|
|
219
|
+
byKey.set(key, { ...s, key, points: s.point ? [s.point] : [], moves: s.move ? [s.move] : [] });
|
|
189
220
|
}
|
|
190
221
|
return [...byKey.values()];
|
|
191
222
|
}
|
|
@@ -194,37 +225,39 @@ export function storesOf(values) {
|
|
|
194
225
|
const listed = (names) => (names.length === 1 ? names[0] : names.length <= 3 ? `${names.slice(0, -1).join(", ")} and ${names.at(-1)}` : `${names.slice(0, 2).join(", ")} and ${names.length - 2} more settings`);
|
|
195
226
|
const unit = (s) => (s.engine === "qdrant" ? "collection" : "database");
|
|
196
227
|
const whatOf = (s) => (s.engine === "redis" ? `Redis database ${s.db}` : s.db ? `${ENGINE[s.engine]} ${unit(s)} ${s.db}` : `${ENGINE[s.engine]} server`);
|
|
197
|
-
const copied = Object.keys(COPY).map((e) => ENGINE[e]);
|
|
228
|
+
const copied = [...new Set([...Object.keys(COPY), ...Object.keys(SERVER)])].map((e) => ENGINE[e]);
|
|
198
229
|
const copiedList = `${copied.slice(0, -1).join(", ")} and ${copied.at(-1)}`;
|
|
199
230
|
|
|
200
|
-
// Why no copy was made, in a sentence of theirs, and
|
|
201
|
-
// never quoted: they name users and hosts.
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
231
|
+
// Why no copy was made of a store on this machine, in a sentence of theirs, and the one thing to do.
|
|
232
|
+
// The store's own words are never quoted: they name users and hosts.
|
|
233
|
+
const HERE = {
|
|
234
|
+
started: () => ["your app was already running when this command started", "Stop your app"],
|
|
235
|
+
engine: (s) => [`we copy ${copiedList} for a run, not ${ENGINE[s.engine]}`, `Point ${s.names[0]} at a scratch ${s.db ? unit(s) : "server"}`],
|
|
236
|
+
fixed: (s) => [`no setting names its ${s.engine === "redis" ? "database number" : `${unit(s)} name`} to point the app at a copy`, `Put the ${s.engine === "redis" ? "database number" : `${unit(s)} name`} in a setting`],
|
|
237
|
+
auth: (s) => ["the password its settings carry was refused", `Check ${s.names[0]}`],
|
|
238
|
+
rights: () => ["the user it signs in as may not create databases", "Give that user the right to create databases"],
|
|
239
|
+
down: () => ["nothing answered at the address its settings give", "Start it"],
|
|
240
|
+
big: () => ["it is over the size a run copies", "Point it at a smaller scratch copy"],
|
|
241
|
+
missing: (s) => [`${TOOLS[s.engine] ?? "its own tools are"} not on this machine's PATH`, "Install them"],
|
|
242
|
+
version: () => ["this machine's copy of the store's own tools is older than the server", "Update them"],
|
|
243
|
+
oldServer: () => ["the server is older than Redis 6.2, which a copy needs", "Upgrade it"],
|
|
244
|
+
full: () => ["it has no empty database left to copy into", "Empty one of its databases"],
|
|
245
|
+
cluster: () => ["it runs as a cluster, which has one database only", "Point it at a scratch server"],
|
|
246
|
+
docker: () => ["a copy of it is made in Docker, which is not running on this machine", "Start Docker"],
|
|
247
|
+
login: (s) => ["your app signs in to it in a way a copy of it cannot be given", `Point ${s.names[0]} at a scratch server, or sign in with a user and password`],
|
|
248
|
+
container: () => ["Docker could not start a copy of it", "Check that Docker can pull and run its image"],
|
|
249
|
+
other: (s) => ["the copy did not go through", `Point ${s.names[0]} at a scratch ${s.db ? unit(s) : "server"}`],
|
|
213
250
|
};
|
|
214
|
-
const
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
if (why === "engine") return `${lead}: we copy ${copiedList} for a run, not ${ENGINE[s.engine]}. Point ${s.names[0]} at a scratch ${s.db ? unit(s) : "server"} before a run if that one holds real data.`;
|
|
218
|
-
if (why === "fixed" && s.engine === "qdrant") return `${lead}: its settings do not say which collection the app keeps there, so the app cannot be pointed at a copy. Point ${s.names[0]} at a scratch server before a run if that one holds real data.`;
|
|
219
|
-
if (why === "fixed") return `${lead}: the ${s.engine === "redis" ? "database number" : `${unit(s)} name`} is not among its settings, so the app cannot be pointed at a copy. Put it in one before a run if that one holds real data.`;
|
|
220
|
-
const [reason, fix] = (REASONS[why] ?? REASONS.other).map((w) => (typeof w === "function" ? w(s) : w));
|
|
221
|
-
return `${lead}: ${reason}, so no copy was made. ${fix}.`;
|
|
251
|
+
const heldHere = (s, why) => {
|
|
252
|
+
const [reason, fix] = (HERE[why] ?? HERE.other)(s);
|
|
253
|
+
return `${listed(s.names)} ${s.names.length === 1 ? "points" : "point"} at the ${whatOf(s)} on this machine, and ${reason}, so test conversations would save into it. ${fix}, then run the connect command again.`;
|
|
222
254
|
};
|
|
223
255
|
const kept = (s, clone, how) => (s.engine === "redis"
|
|
224
256
|
? `Your app runs against a copy of its Redis data in database ${clone} of the same server, through ${listed(s.names)}; database ${s.db} is not touched, and database ${clone} is emptied when this command ends and copied again at the next connect.`
|
|
225
257
|
: how === "empty"
|
|
226
258
|
? `Your app makes its ${ENGINE[s.engine]} ${unit(s)} ${s.db} under the name ${clone} on the same server, through ${listed(s.names)}; the server holds no ${s.db} yet, and ${clone} is deleted when this command ends and made again empty at the next connect.`
|
|
227
259
|
: `Your app runs against a copy of its ${ENGINE[s.engine]} ${unit(s)} ${s.db}, made on the same server, through ${listed(s.names)}; the original is not touched, and the copy is deleted when this command ends and made again from the original at the next connect.`);
|
|
260
|
+
const serverKept = (s) => `Your app runs against a copy of its whole ${ENGINE[s.engine]} server, made in a Docker container of ours, through ${listed(s.names)}; the original is not touched, and the container is deleted when this command ends and made again from the original at the next connect.`;
|
|
228
261
|
|
|
229
262
|
// What holds Run for a store off this machine no copy could be made of, and the one thing to do.
|
|
230
263
|
// The first reason is the most useful: a Neon store with no key says the key.
|
|
@@ -232,7 +265,7 @@ const HELD = {
|
|
|
232
265
|
"neon-key": (s) => ["there is no NEON_API_KEY in its settings to make a branch with", `Set NEON_API_KEY, or point ${s.names[0]} at a database on this machine`],
|
|
233
266
|
neon: (s) => ["Neon did not make a branch of it", `Check NEON_API_KEY, or point ${s.names[0]} at a database on this machine`],
|
|
234
267
|
supabase: (s) => ["a local Supabase needs supabase/config.toml, Docker and the supabase command", `Add them, or point ${s.names[0]} at a local Supabase`],
|
|
235
|
-
engine: (s) => [`
|
|
268
|
+
engine: (s) => [`no copy of a hosted ${ENGINE[s.engine]} is made`, `Point ${s.names[0]} at a ${ENGINE[s.engine]} on this machine`],
|
|
236
269
|
parts: (s) => ["its settings name it in parts, which cannot be pointed at a copy", `Put its whole address in one setting, or point ${s.names[0]} at a database on this machine`],
|
|
237
270
|
"no-migrations": (s) => ["no migrations were found to build a copy on this machine from", `Point ${s.names[0]} at a database on this machine`],
|
|
238
271
|
"no-local": (s) => ["no Postgres on this machine let us make a database to build a copy in", "Start Postgres on this machine"],
|
|
@@ -247,12 +280,12 @@ const HELD = {
|
|
|
247
280
|
// card told the owner, on the card, that "the owner can say yes to it on the card in the browser".
|
|
248
281
|
const heldWhy = (s, why, tool) => {
|
|
249
282
|
const [reason, fix] = (HELD[why] ?? HELD.other)(s, tool);
|
|
250
|
-
return `${listed(s.names)} ${s.names.length === 1 ? "points" : "point"} at ${s.engine === "redis" || s.db ? "the" : "a"} ${whatOf(s)}
|
|
283
|
+
return `${listed(s.names)} ${s.names.length === 1 ? "points" : "point"} at ${s.engine === "redis" || s.db ? "the" : "a"} ${whatOf(s)} that is not on this machine, and ${reason}, so test conversations would save into it. ${fix}, then run the connect command again.`;
|
|
251
284
|
};
|
|
252
285
|
// A Qdrant server the app writes into is kept by the hook in the app instead of by a copy made here:
|
|
253
286
|
// the first write a run makes into a collection copies that collection on the same server and the
|
|
254
287
|
// app's later calls go to the copy (lib/trace.cjs, lib/pyhook/sitecustomize.py). So a hosted Qdrant
|
|
255
|
-
// holds nothing, and a local one whose collection no setting names
|
|
288
|
+
// holds nothing, and neither does a local one whose collection no setting names.
|
|
256
289
|
export const GUARDED_ENGINES = new Set(["qdrant"]);
|
|
257
290
|
const guardedKept = (s) => `Your app's ${ENGINE[s.engine]} collections at ${listed(s.names)} are not written into by a run: the first time a run writes into one, that collection is copied on the same server and the app's calls go to the copy, which is deleted when this command ends.`;
|
|
258
291
|
const hostedKept = (s, got) => (got.how === "neon"
|
|
@@ -271,13 +304,17 @@ export const bypassedOf = (connections, stores, blocked) => stores
|
|
|
271
304
|
// A copy that throws is one that did not go through, said as such; it never stops the app starting.
|
|
272
305
|
const drop = (s, clone, marker) => COPY[s.engine].drop(s, clone, marker).catch(() => false);
|
|
273
306
|
const copy = (s, clone, marker) => COPY[s.engine].copy(s, clone, marker).catch(() => ({ why: "other" }));
|
|
307
|
+
const serve = (s, name) => SERVER[s.engine](s, name).catch(() => ({ why: "other" }));
|
|
308
|
+
const undo = (s, row) => (row.kind === "server" ? removeCopy(row.made) : drop(s, row.made, row.marker));
|
|
274
309
|
const cloneName = (db) => `${String(db).slice(0, 40)}_cortad_${process.pid}`;
|
|
310
|
+
// Refusals a copy of the whole server would meet the same way.
|
|
311
|
+
const SERVER_TOO = new Set(["auth", "big", "down"]);
|
|
275
312
|
|
|
276
313
|
// One session's copies. keep() copies the stores given that answer and says what it did; a store
|
|
277
314
|
// nothing answers at yet is left out, unsaid, since trials cannot write into it: once it is started
|
|
278
315
|
// (lib/backing.mjs), keep() is asked again for it. drop() deletes every copy, and copies a session
|
|
279
|
-
// that died left behind are deleted by the next one, through the ledger. A store
|
|
280
|
-
//
|
|
316
|
+
// that died left behind are deleted by the next one, through the ledger. A store with no copy is
|
|
317
|
+
// `blocked`, and `pass` names the hosts of the copies made off this machine.
|
|
281
318
|
// `values`, `dirs`, `fetchFn` and `local` are lib/hosted.mjs's.
|
|
282
319
|
export function storeKeeper({ ledgerFile, values = {}, dirs = [], fetchFn, local }) {
|
|
283
320
|
const book = ledger(ledgerFile);
|
|
@@ -290,9 +327,9 @@ export function storeKeeper({ ledgerFile, values = {}, dirs = [], fetchFn, local
|
|
|
290
327
|
const s = stores.find((x) => x.key === row.key);
|
|
291
328
|
if (s && (await drop(s, row.made, row.marker))) book.remove(row);
|
|
292
329
|
}
|
|
330
|
+
for (const row of book.orphans("server")) if (await removeCopy(row.made)) book.remove(row);
|
|
293
331
|
await hosted.orphans();
|
|
294
332
|
const env = {};
|
|
295
|
-
const risks = [];
|
|
296
333
|
const copies = [];
|
|
297
334
|
const blocked = [];
|
|
298
335
|
const pass = [];
|
|
@@ -315,30 +352,48 @@ export function storeKeeper({ ledgerFile, values = {}, dirs = [], fetchFn, local
|
|
|
315
352
|
}
|
|
316
353
|
if (await down(s)) continue;
|
|
317
354
|
done.add(s.key);
|
|
318
|
-
if (!started) {
|
|
319
|
-
|
|
320
|
-
if (!
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
355
|
+
if (!started) { blocked.push({ id: storeId(s), names: s.names, why: heldHere(s, "started") }); continue; }
|
|
356
|
+
let why = !COPY[s.engine] ? "engine" : !s.points.length || (s.engine !== "redis" && !s.db) ? "fixed" : null;
|
|
357
|
+
if (!why) {
|
|
358
|
+
const marker = `${process.pid}-${Date.now()}`;
|
|
359
|
+
// Recorded before the copy starts, so a session killed mid-copy leaves a row the next one
|
|
360
|
+
// undoes. A Redis copy's database is found by its marker then.
|
|
361
|
+
const row = { kind: "copy", key: s.key, made: s.engine === "redis" ? null : cloneName(s.db), marker };
|
|
362
|
+
book.add(row);
|
|
363
|
+
const got = await copy(s, cloneName(s.db), marker);
|
|
364
|
+
if (!got.why) {
|
|
365
|
+
const clone = got.index ?? cloneName(s.db);
|
|
366
|
+
made.push({ s, row });
|
|
367
|
+
for (const point of s.points) Object.assign(env, point(clone));
|
|
368
|
+
copies.push(kept(s, clone, got.how));
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
328
371
|
if (await drop(s, row.made, marker)) book.remove(row);
|
|
329
372
|
// A database the server does not hold is one trials cannot write into.
|
|
330
|
-
if (got.why
|
|
331
|
-
|
|
373
|
+
if (got.why === "gone") continue;
|
|
374
|
+
why = got.why;
|
|
375
|
+
}
|
|
376
|
+
// The app names its databases in its code, or this machine lacks the store's own tools: the
|
|
377
|
+
// whole server is copied into a container of ours and the app's address moved to it.
|
|
378
|
+
if (SERVER[s.engine] && s.moves.length && !SERVER_TOO.has(why)) {
|
|
379
|
+
const row = { kind: "server", key: s.key, made: copyName(s.engine) };
|
|
380
|
+
book.add(row);
|
|
381
|
+
const got = await serve(s, row.made);
|
|
382
|
+
if (got.port) {
|
|
383
|
+
made.push({ s, row });
|
|
384
|
+
for (const move of s.moves) Object.assign(env, move("127.0.0.1", got.port));
|
|
385
|
+
copies.push(serverKept(s));
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
if (await removeCopy(row.made)) book.remove(row);
|
|
389
|
+
why = got.why;
|
|
332
390
|
}
|
|
333
|
-
|
|
334
|
-
made.push({ s, row });
|
|
335
|
-
for (const point of s.points) Object.assign(env, point(clone));
|
|
336
|
-
copies.push(kept(s, clone, got.how));
|
|
391
|
+
blocked.push({ id: storeId(s), names: s.names, why: heldHere(s, why) });
|
|
337
392
|
}
|
|
338
|
-
return { env,
|
|
393
|
+
return { env, copies, blocked, pass };
|
|
339
394
|
},
|
|
340
395
|
drop: async () => {
|
|
341
|
-
for (const { s, row } of made.splice(0)) if (await
|
|
396
|
+
for (const { s, row } of made.splice(0)) if (await undo(s, row)) book.remove(row);
|
|
342
397
|
await hosted.drop();
|
|
343
398
|
},
|
|
344
399
|
// The collections the hook in the app copied for a run's writes (lib/trace.cjs copyOf), deleted
|