cortad 0.3.0-rc.11 → 0.3.0-rc.12
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 +112 -2
- package/lib/mint.mjs +11 -3
- package/lib/text.mjs +12 -2
- package/local.mjs +10 -3
- package/package.json +1 -1
package/lib/identities.mjs
CHANGED
|
@@ -26,9 +26,18 @@ const SOURCE = /^(?:env:[A-Z_][A-Z0-9_]*(?:\|[A-Z_][A-Z0-9_]*)*|gen:(?:username|
|
|
|
26
26
|
// before the program saw it. Still no quote, angle bracket, `$`, backtick or semicolon.
|
|
27
27
|
const WHERE = /^[\w./:@()[\] -]{1,300}$/;
|
|
28
28
|
const BASE = /^http:\/\/(?:127\.0\.0\.1|\[::1\]|localhost):\d{2,5}$/;
|
|
29
|
-
const KINDS = new Set(["header", "login", "jwt", "upstream", "firebase", "authjs", "supabase"]);
|
|
29
|
+
const KINDS = new Set(["header", "login", "jwt", "upstream", "firebase", "authjs", "supabase", "basic"]);
|
|
30
30
|
const STATUSES = new Set(["minted", "sealed", "absent", "refused", "unreachable", "upstream"]);
|
|
31
31
|
|
|
32
|
+
// A credential the app's own environment names for its own account: a user-side name and a
|
|
33
|
+
// password-side name that share a prefix. The prefix is the name with its trailing token removed,
|
|
34
|
+
// so USER_USERNAME pairs with USER_PASSWORD and TUDUDI_USER_EMAIL with TUDUDI_USER_PASSWORD.
|
|
35
|
+
const USER_TOKEN = /_(?:USERNAME|USER|EMAIL|LOGIN)$/;
|
|
36
|
+
const SECRET_TOKEN = /_(?:PASSWORD|PASS|SECRET)$/;
|
|
37
|
+
const CRED_USER_FIELD = /^(?:e-?mail|user(?:_?name)?|login|account|identifier)$/i;
|
|
38
|
+
const CRED_PASS_FIELD = /^(?:password|passcode|pass|pwd)$/i;
|
|
39
|
+
const SIGN_IN_ROUTE = /(?:^|\/)(?:login|signin|sign-in|sign_in|session|token|authenticate)(?:\/|$)/i;
|
|
40
|
+
|
|
32
41
|
const slug = (role) => role.replace(/[^a-z0-9-]/g, "-");
|
|
33
42
|
export const identityHeaderFile = (role) => (ROLE.test(role) ? HEADER_FILE.replace("%s.%s", `${slug(role)}.header`) : null);
|
|
34
43
|
// The curl argument a probe inside the box adds to speak as a role: the header line is read there,
|
|
@@ -450,6 +459,18 @@ def sealed(recipe, env):
|
|
|
450
459
|
return {"status": "sealed", "header": recipe["header"], "line": "%s: %s" % (recipe["header"], bearer(recipe["header"], v)), "note": " or ".join(recipe["env"])}
|
|
451
460
|
|
|
452
461
|
|
|
462
|
+
# HTTP Basic from the username and password the app's own environment names: env[0] is the user,
|
|
463
|
+
# env[1] the password. The line carries the scheme verbatim, so it is not run through bearer().
|
|
464
|
+
def basic(recipe, env):
|
|
465
|
+
names = recipe.get("env", [])
|
|
466
|
+
user = env.get(names[0]) if len(names) > 0 else None
|
|
467
|
+
pw = env.get(names[1]) if len(names) > 1 else None
|
|
468
|
+
if not user or not pw:
|
|
469
|
+
return {"status": "absent", "note": " and ".join(names)}
|
|
470
|
+
raw = base64.b64encode(("%s:%s" % (user, pw)).encode("utf-8")).decode("ascii")
|
|
471
|
+
return {"status": "minted", "header": recipe["header"], "line": "%s: Basic %s" % (recipe["header"], raw), "expires": int(time.time()) + TTL, "note": " and ".join(names)}
|
|
472
|
+
|
|
473
|
+
|
|
453
474
|
def keep(path, line):
|
|
454
475
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
455
476
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
@@ -509,10 +530,15 @@ def main():
|
|
|
509
530
|
got = login(recipe, role, role_value, env)
|
|
510
531
|
elif recipe["kind"] == "firebase":
|
|
511
532
|
got = firebase(recipe, role, env)
|
|
533
|
+
elif recipe["kind"] == "basic":
|
|
534
|
+
got = basic(recipe, env)
|
|
512
535
|
else:
|
|
513
536
|
got = {"status": "upstream", "note": " or ".join(recipe["env"]) or recipe["role"]}
|
|
514
537
|
except Exception as err:
|
|
515
538
|
got = {"status": "unreachable", "note": type(err).__name__}
|
|
539
|
+
# A sign-in the environment named its own credential for records which names it used.
|
|
540
|
+
if recipe.get("envCred") and got.get("status") == "minted" and not got.get("note"):
|
|
541
|
+
got["note"] = " and ".join(recipe.get("env", []))
|
|
516
542
|
line = got.pop("line", None)
|
|
517
543
|
if line:
|
|
518
544
|
keep(FILE % (slug(role), "header" if got["status"] == "minted" else "sealed"), line)
|
|
@@ -551,12 +577,96 @@ export function parseIdentityReport(text) {
|
|
|
551
577
|
return { identities };
|
|
552
578
|
}
|
|
553
579
|
|
|
580
|
+
// The credential pairs an environment names, by shared prefix. Names only, never values.
|
|
581
|
+
export function credentialPairs(names) {
|
|
582
|
+
const list = (Array.isArray(names) ? names : []).filter((n) => typeof n === "string" && ENV_NAME.test(n));
|
|
583
|
+
const users = list.filter((n) => USER_TOKEN.test(n)).map((n) => ({ name: n, prefix: n.replace(USER_TOKEN, "") }));
|
|
584
|
+
const secrets = list.filter((n) => SECRET_TOKEN.test(n)).map((n) => ({ name: n, prefix: n.replace(SECRET_TOKEN, "") }));
|
|
585
|
+
const pairs = [];
|
|
586
|
+
const seen = new Set();
|
|
587
|
+
for (const u of users) {
|
|
588
|
+
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 }); }
|
|
590
|
+
}
|
|
591
|
+
return pairs;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const routeRole = (route) => {
|
|
595
|
+
const seg = route.split("/").filter((s) => s && !/^(?:api|v\d+|auth)$/i.test(s)).pop() ?? "user";
|
|
596
|
+
const r = seg.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 20);
|
|
597
|
+
return /^[a-z]/.test(r) ? r : "user";
|
|
598
|
+
};
|
|
599
|
+
const uniqueRole = (base, used) => {
|
|
600
|
+
const stem = /^[a-z]/.test(base) ? base : "account";
|
|
601
|
+
let role = stem;
|
|
602
|
+
for (let n = 2; used.has(role); n += 1) role = `${stem}-${n}`;
|
|
603
|
+
used.add(role);
|
|
604
|
+
return role;
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
// When the app's own environment names a credential pair, the box can sign in with it. Each login
|
|
608
|
+
// recipe whose sign-in form takes a username or email and a password is filled from the pair
|
|
609
|
+
// instead of a generated account; each discovered sign-in route with no recipe becomes one; and an
|
|
610
|
+
// HTTP Basic identity is minted for a door that answers 401 with a Basic challenge. Values are
|
|
611
|
+
// opened by the mint program on the machine, never here: this only rewrites which env name a field
|
|
612
|
+
// reads. A register step (one of several, not a sign-in) keeps its fresh account.
|
|
613
|
+
export function envCredentialRecipes(recipes, names, loginRoutes = []) {
|
|
614
|
+
const list = Array.isArray(recipes) ? recipes : [];
|
|
615
|
+
const pairs = credentialPairs(names);
|
|
616
|
+
if (!pairs.length) return list;
|
|
617
|
+
const pair = pairs[0];
|
|
618
|
+
const userSrc = `env:${pair.user}`;
|
|
619
|
+
const passSrc = `env:${pair.secret}`;
|
|
620
|
+
const used = new Set(list.map((r) => r?.role).filter(Boolean));
|
|
621
|
+
const covered = new Set();
|
|
622
|
+
const out = list.map((r) => {
|
|
623
|
+
if (r?.kind !== "login" || !Array.isArray(r.steps)) return r;
|
|
624
|
+
let touched = false;
|
|
625
|
+
const steps = r.steps.map((s) => {
|
|
626
|
+
if (r.steps.length !== 1 && !SIGN_IN_ROUTE.test(s.route)) return s;
|
|
627
|
+
const body = { ...(s.body ?? {}) };
|
|
628
|
+
let hit = false;
|
|
629
|
+
for (const [f, v] of Object.entries(body)) {
|
|
630
|
+
if (typeof v !== "string" || !v.startsWith("gen:")) continue;
|
|
631
|
+
if (CRED_USER_FIELD.test(f)) { body[f] = userSrc; hit = true; }
|
|
632
|
+
else if (CRED_PASS_FIELD.test(f)) { body[f] = passSrc; hit = true; }
|
|
633
|
+
}
|
|
634
|
+
if (!hit) return s;
|
|
635
|
+
covered.add(s.route);
|
|
636
|
+
touched = true;
|
|
637
|
+
return { ...s, body };
|
|
638
|
+
});
|
|
639
|
+
return touched ? { ...r, steps, env: [...new Set([...(r.env ?? []), pair.user, pair.secret])], envCred: true } : r;
|
|
640
|
+
});
|
|
641
|
+
const known = new Set(out.flatMap((r) => (r?.steps ?? []).map((s) => s.route)));
|
|
642
|
+
const userField = /_EMAIL$/.test(pair.user) ? "email" : "username";
|
|
643
|
+
for (const route of (Array.isArray(loginRoutes) ? loginRoutes : []).slice(0, 6)) {
|
|
644
|
+
if (typeof route !== "string" || !route.startsWith("/") || known.has(route) || covered.has(route)) continue;
|
|
645
|
+
out.push({
|
|
646
|
+
kind: "login", role: uniqueRole(routeRole(route), used), header: "authorization", env: [pair.user, pair.secret],
|
|
647
|
+
steps: [{ route, body: { [userField]: userSrc, password: passSrc } }], opens: [], envCred: true,
|
|
648
|
+
where: `your environment (${pair.user} and ${pair.secret})`,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
// A Basic identity for each pair as well: a door that wants a sign-in form ignores it, and a door
|
|
652
|
+
// behind HTTP Basic (which declares no route to fill) is reached by nothing else.
|
|
653
|
+
for (const p of pairs) {
|
|
654
|
+
out.push({
|
|
655
|
+
kind: "basic", role: uniqueRole(slug(p.prefix.toLowerCase()) || "account", used), header: "authorization",
|
|
656
|
+
env: [p.user, p.secret], opens: [], envCred: true, where: `your environment (${p.user} and ${p.secret})`,
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
return out;
|
|
660
|
+
}
|
|
661
|
+
|
|
554
662
|
// What each row means to the person whose app this is.
|
|
555
663
|
export function identitySentence(row) {
|
|
556
664
|
switch (row.status) {
|
|
557
665
|
case "minted": return row.kind === "firebase"
|
|
558
666
|
? `signed in as ${row.role} through the Firebase emulator standing in for your project; the token stays inside the sandbox`
|
|
559
|
-
:
|
|
667
|
+
: row.kind === "basic"
|
|
668
|
+
? `signed in as ${row.role} with the username and password your environment sets (${row.note ?? "two variables"}); the header stays inside the sandbox`
|
|
669
|
+
: `signed in as ${row.role} through your own door ${row.door}; the token stays inside the sandbox`;
|
|
560
670
|
case "sealed": return `${row.role} is the sealed ${row.note} value itself; it never leaves the sandbox, so only the compile's own probe can speak as it`;
|
|
561
671
|
case "absent": return `could not sign in as ${row.role}: ${row.note} is not in the environment you sealed`;
|
|
562
672
|
case "refused": return `your door refused the ${row.role} sign-in (${row.note}), so cases behind it are counted and not driven`;
|
package/lib/mint.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync }
|
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
11
|
import { basename, dirname, join } from "node:path";
|
|
12
12
|
import { promisify } from "node:util";
|
|
13
|
-
import { identityScript, parseIdentityReport } from "./identities.mjs";
|
|
13
|
+
import { envCredentialRecipes, identityScript, parseIdentityReport } from "./identities.mjs";
|
|
14
14
|
|
|
15
15
|
const exec = promisify(execFile);
|
|
16
16
|
export const AS_HEADER = "x-cortad-as";
|
|
@@ -128,7 +128,13 @@ export function makeIdentities({ root, work, envFiles, sourceFiles = () => [], s
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
async function mint(body, port) {
|
|
131
|
-
|
|
131
|
+
let recipes = Array.isArray(body?.recipes) ? body.recipes.slice(0, 12) : [];
|
|
132
|
+
// On a refused first pass the raise asks again with this flag: sign in with the credential the
|
|
133
|
+
// app's own environment names, filling its sign-in form or a Basic header from that pair.
|
|
134
|
+
if (body?.envCreds) {
|
|
135
|
+
const names = Object.keys(Object.assign({}, ...[...envs().all].reverse()));
|
|
136
|
+
recipes = envCredentialRecipes(recipes, names, body.loginRoutes).slice(0, 12);
|
|
137
|
+
}
|
|
132
138
|
if (!recipes.length || !port) return { identities: [] };
|
|
133
139
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
134
140
|
const out = join(dir, "bl-identities.json");
|
|
@@ -141,7 +147,9 @@ export function makeIdentities({ root, work, envFiles, sourceFiles = () => [], s
|
|
|
141
147
|
const merged = Object.assign({}, ...[...envs().all].reverse());
|
|
142
148
|
const emulated = Boolean(merged.FIREBASE_AUTH_EMULATOR_HOST);
|
|
143
149
|
const refused = new Map();
|
|
144
|
-
|
|
150
|
+
// A pair the environment names for the app's own account is the one credential the raise is
|
|
151
|
+
// allowed to reuse: the elevation and existing-account guards do not apply to it.
|
|
152
|
+
for (const r of recipes) { const why = r?.envCred ? null : refusal(r, merged, (value) => inSource(root, sourceFiles(), value)); if (why && typeof r?.role === "string") refused.set(r.role, why); }
|
|
145
153
|
const forProgram = recipes.filter((r) => !refused.has(r?.role) && (r?.kind !== "firebase" || emulated));
|
|
146
154
|
// The program signs in once per role value ("staff" as sales, support, marketing), each named
|
|
147
155
|
// role:value; an account made for "staff" alone was never the one it asked for.
|
package/lib/text.mjs
CHANGED
|
@@ -50,8 +50,15 @@ function coverageLine(d) {
|
|
|
50
50
|
|
|
51
51
|
function scoreLine(d) {
|
|
52
52
|
const floor = has(d.decided) && d.decided < 22;
|
|
53
|
-
|
|
53
|
+
// The number never stands without its denominator: "93 of 100" read as broad assurance to four
|
|
54
|
+
// agents whose runs had measured a quarter of the checks.
|
|
55
|
+
const on = has(d.questionsAsked) && has(d.questionsOf) && d.questionsOf > 0 ? ` on the ${num(d.questionsAsked)} of ${plural(d.questionsOf, "check")} measured` : "";
|
|
56
|
+
return `${floor ? "Provisional score" : "Score"} ${num(d.score)} of 100${on}${interval(d.ci)}${floor ? `, from ${plural(d.decided, "decided reading")}: under the 22-reading floor, so not a verdict yet` : ""}.`;
|
|
54
57
|
}
|
|
58
|
+
// A row the run held back names a door and why, or, when no door could take it, only the reason.
|
|
59
|
+
const heldBackLine = (h) => h.why === "not testable here"
|
|
60
|
+
? `Held back: ${plural(h.trials, "trial")} cannot be played here: ${clip(h.surface, 200)}.`
|
|
61
|
+
: `Held back: ${plural(h.trials, "trial")} on ${h.surface}${h.why ? `, ${clip(h.why, 200)}` : ""}.`;
|
|
55
62
|
const haltLine = (h) => `${h.side === "ours" ? "We lost your app" : "Your app stopped answering"} after ${plural(h.after, "turn")}${h.why ? `: ${clip(h.why, 200)}` : ""}. The score covers the turns before that.`;
|
|
56
63
|
const LAYER_WORDS = {
|
|
57
64
|
retrieval: "what the store returned behind the reply, not how the reply was written",
|
|
@@ -69,11 +76,12 @@ function runLines(d, prefix = "") {
|
|
|
69
76
|
// Coverage before the number: a 97 beside 59 of 80 checks that never fired read as production-ready
|
|
70
77
|
// to every agent that got it. What was measured leads, then the rate on what was measured.
|
|
71
78
|
if (done && has(d.questionsAsked) && has(d.questionsOf) && d.questionsOf > 0) lines.push(coverageLine(d));
|
|
79
|
+
if (done && d.blocked) lines.push(d.blocked);
|
|
72
80
|
if (done && typeof d.score === "number") lines.push(scoreLine(d));
|
|
73
81
|
if (done && d.halted) lines.push(haltLine(d.halted));
|
|
74
82
|
if (done && has(d.readingsHeld) && has(d.readings) && d.readings > 0) lines.push(`The question readings held in ${num(d.readingsHeld)} of ${num(d.readings)}, ${Math.round((100 * d.readingsHeld) / d.readings)}%; the score is the case checks' pass rate on the checks measured.`);
|
|
75
83
|
if (done && has(d.findings)) lines.push(`${plural(d.findings, "finding")}.`);
|
|
76
|
-
for (const h of (d.heldBack ?? []).slice(0, 6)) lines.push(
|
|
84
|
+
for (const h of (d.heldBack ?? []).filter((h) => h.trials > 0).slice(0, 6)) lines.push(heldBackLine(h));
|
|
77
85
|
const reads = readingsLine(d);
|
|
78
86
|
if (reads) lines.push(reads);
|
|
79
87
|
if (d.stopped) lines.push(stoppedLine(d));
|
|
@@ -182,7 +190,9 @@ export function linesOf(d) {
|
|
|
182
190
|
export const findingsText = (d, page = 1) => pageOf(findingsPages(d), page, (n) => `findings with page ${n}`);
|
|
183
191
|
|
|
184
192
|
function findingsHead(d) {
|
|
193
|
+
if (!d.runId) return ["No run has finished on this repository yet."];
|
|
185
194
|
const lines = [`Run ${d.runId}: ${plural(d.findings?.length ?? 0, "finding")}.`];
|
|
195
|
+
if (d.blocked) lines.push(d.blocked);
|
|
186
196
|
if (typeof d.score === "number") lines.push(scoreLine(d));
|
|
187
197
|
if (d.halted) lines.push(haltLine(d.halted));
|
|
188
198
|
const reads = readingsLine(d);
|
package/local.mjs
CHANGED
|
@@ -532,7 +532,9 @@ async function ask(question) {
|
|
|
532
532
|
let child = null;
|
|
533
533
|
async function startApp() {
|
|
534
534
|
const wanted = Number(flag("--port"));
|
|
535
|
-
|
|
535
|
+
// "--port 8106 --start ..." is "start it, it will answer on 8106", not "it is already on 8106":
|
|
536
|
+
// read as the latter it said nothing was answering and never ran the command it was given.
|
|
537
|
+
if (wanted && !flag("--start")) {
|
|
536
538
|
if (!(await answers(wanted))) return { port: null, said: "", why: `nothing is answering on port ${wanted}, so there is nothing to ask.`, noStart: true };
|
|
537
539
|
const took = await takeOver(wanted);
|
|
538
540
|
if (took) return took;
|
|
@@ -735,7 +737,10 @@ async function start(waitMs) {
|
|
|
735
737
|
const started = Date.now();
|
|
736
738
|
while (Date.now() - started < waitMs) {
|
|
737
739
|
const tail = () => seen.replace(/\x1b\[[0-9;]*m/g, "").split("\n").filter(Boolean).slice(-25).join("\n");
|
|
738
|
-
|
|
740
|
+
// The port was taken: the app says so and exits at once, so this is read before the exit code,
|
|
741
|
+
// or a bed whose .env named a port another round still held printed "your app stopped" three times.
|
|
742
|
+
const taken = /EADDRINUSE|address already in use/i.test(seen);
|
|
743
|
+
if (exited !== null && !taken) return { port: null, exited, tail: tail() };
|
|
739
744
|
// nodemon and its kind outlive the app they watch: the app is gone, the process is not, and
|
|
740
745
|
// the wait ran its whole three minutes on one app with the reason sitting in the output.
|
|
741
746
|
// Also: the port is taken, under a watcher that does not exit when its app cannot listen. The app
|
|
@@ -904,6 +909,7 @@ await exec("tar", ["-czf", archive, "-C", root, "-T", list]);
|
|
|
904
909
|
const bytes = readFileSync(archive);
|
|
905
910
|
const PART = 1_500_000;
|
|
906
911
|
let resumed = false;
|
|
912
|
+
let moved = false;
|
|
907
913
|
const parts = Math.max(1, Math.ceil(bytes.length / PART));
|
|
908
914
|
for (let off = 0; off < bytes.length; off += PART) {
|
|
909
915
|
const last = off + PART >= bytes.length;
|
|
@@ -915,12 +921,13 @@ for (let off = 0; off < bytes.length; off += PART) {
|
|
|
915
921
|
if (!put.ok) fail(put.data?.error ?? `upload failed (${put.status})`);
|
|
916
922
|
if (last) {
|
|
917
923
|
resumed = put.data?.resumed === true;
|
|
924
|
+
moved = put.data?.moved === true;
|
|
918
925
|
if (typeof put.data?.machineKey === "string" && put.data.machineKey) writeToken(project, put.data.machineKey);
|
|
919
926
|
writeDigest(project, treeDigest);
|
|
920
927
|
}
|
|
921
928
|
}
|
|
922
929
|
// Unchanged code has already been read: coming back says so instead of claiming a second read.
|
|
923
|
-
stepDone(resumed ? "connected · nothing changed since last time" : "connected");
|
|
930
|
+
stepDone(moved ? "connected · your change is in; the read from before stands" : resumed ? "connected · nothing changed since last time" : "connected");
|
|
924
931
|
// The coding agents on this machine learn about Cortad now, once: an MCP entry and a skill in each
|
|
925
932
|
// one's own home folder. A run started by an agent later comes back through lib/cli.mjs.
|
|
926
933
|
if (!viaToken) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cortad",
|
|
3
|
-
"version": "0.3.0-rc.
|
|
3
|
+
"version": "0.3.0-rc.12",
|
|
4
4
|
"description": "Connects the AI app on your machine to Cortad for test conversations, and gives your coding agent the MCP and skill to run them. No dependencies.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"cortad": "local.mjs"
|