cortad 0.1.10 → 0.1.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 +169 -5
- package/lib/service.mjs +71 -0
- package/lib/start.mjs +22 -5
- package/lib/switches.mjs +47 -0
- package/local.mjs +71 -5
- package/package.json +3 -1
package/lib/identities.mjs
CHANGED
|
@@ -26,7 +26,7 @@ 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"]);
|
|
29
|
+
const KINDS = new Set(["header", "login", "jwt", "upstream", "firebase", "authjs", "supabase"]);
|
|
30
30
|
const STATUSES = new Set(["minted", "sealed", "absent", "refused", "unreachable", "upstream"]);
|
|
31
31
|
|
|
32
32
|
const slug = (role) => role.replace(/[^a-z0-9-]/g, "-");
|
|
@@ -245,6 +245,13 @@ def b64url(raw):
|
|
|
245
245
|
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
|
246
246
|
|
|
247
247
|
|
|
248
|
+
def hs256(secret, claims):
|
|
249
|
+
head = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode("utf-8"))
|
|
250
|
+
body = b64url(json.dumps(claims, separators=(",", ":")).encode("utf-8"))
|
|
251
|
+
sig = b64url(hmac.new(secret.encode("utf-8"), ("%s.%s" % (head, body)).encode("ascii"), hashlib.sha256).digest())
|
|
252
|
+
return "%s.%s.%s" % (head, body, sig)
|
|
253
|
+
|
|
254
|
+
|
|
248
255
|
def jwt(recipe, env):
|
|
249
256
|
secret = source("env:" + "|".join(recipe["env"]), env, None, None)
|
|
250
257
|
if not secret:
|
|
@@ -257,10 +264,163 @@ def jwt(recipe, env):
|
|
|
257
264
|
claim = recipe.get("claim") or "sub"
|
|
258
265
|
if claim not in claims:
|
|
259
266
|
claims[claim] = TEST
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
267
|
+
return minted("authorization", hs256(secret, claims))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# GoTrue signs its access token HS256 with the project's JWT secret; RLS reads sub as auth.uid()
|
|
271
|
+
# and role as auth.role(). The id is a well-formed uuid that belongs to nobody, never a real row.
|
|
272
|
+
SUPA_UID = "00000000-0000-4000-8000-000000009001"
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def supabase(recipe, env):
|
|
276
|
+
secret = source("env:" + "|".join(recipe["env"]), env, None, None)
|
|
277
|
+
if not secret:
|
|
278
|
+
return {"status": "absent", "note": " or ".join(recipe["env"])}
|
|
279
|
+
now = int(time.time())
|
|
280
|
+
claims = {"sub": SUPA_UID, "aud": "authenticated", "role": "authenticated", "iat": now, "exp": now + TTL, "is_anonymous": False}
|
|
281
|
+
return minted("authorization", hs256(secret, claims))
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# Auth.js v5 (and next-auth v5) seals its session as a JWE, not a signed JWT: dir key management,
|
|
285
|
+
# A256CBC-HS512 content encryption, the key derived from the secret with HKDF over the cookie name.
|
|
286
|
+
# Verified against the @auth/core jwt.ts encode path. next-auth v4 (A256GCM) is not minted here.
|
|
287
|
+
def hkdf_sha256(secret, salt, info, length):
|
|
288
|
+
salt = salt or bytes(hashlib.sha256().digest_size)
|
|
289
|
+
prk = hmac.new(salt, secret, hashlib.sha256).digest()
|
|
290
|
+
out, block, i = b"", b"", 1
|
|
291
|
+
while len(out) < length:
|
|
292
|
+
block = hmac.new(prk, block + info + bytes([i]), hashlib.sha256).digest()
|
|
293
|
+
out += block
|
|
294
|
+
i += 1
|
|
295
|
+
return out[:length]
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
AES_SBOX = bytes.fromhex(
|
|
299
|
+
"637c777bf26b6fc53001672bfed7ab76ca82c97dfa5947f0add4a2af9ca472c0"
|
|
300
|
+
"b7fd9326363ff7cc34a5e5f171d8311504c723c31896059a071280e2eb27b275"
|
|
301
|
+
"09832c1a1b6e5aa0523bd6b329e32f8453d100ed20fcb15b6acbbe394a4c58cf"
|
|
302
|
+
"d0efaafb434d338545f9027f503c9fa851a3408f929d38f5bcb6da2110fff3d2"
|
|
303
|
+
"cd0c13ec5f974417c4a77e3d645d197360814fdc222a908846eeb814de5e0bdb"
|
|
304
|
+
"e0323a0a4906245cc2d3ac629195e479e7c8376d8dd54ea96c56f4ea657aae08"
|
|
305
|
+
"ba78252e1ca6b4c6e8dd741f4bbd8b8a703eb5664803f60e613557b986c11d9e"
|
|
306
|
+
"e1f8981169d98e949b1e87e9ce5528df8ca1890dbfe6426841992d0fb054bb16")
|
|
307
|
+
AES_RCON = (0, 1, 2, 4, 8, 16, 32, 64)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _aes_mul(a, b):
|
|
311
|
+
r = 0
|
|
312
|
+
for _ in range(8):
|
|
313
|
+
if b & 1:
|
|
314
|
+
r ^= a
|
|
315
|
+
hi = a & 0x80
|
|
316
|
+
a = (a << 1) & 0xFF
|
|
317
|
+
if hi:
|
|
318
|
+
a ^= 0x1B
|
|
319
|
+
b >>= 1
|
|
320
|
+
return r
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _aes_expand(key):
|
|
324
|
+
w = [list(key[i:i + 4]) for i in range(0, 32, 4)]
|
|
325
|
+
for i in range(8, 60):
|
|
326
|
+
t = list(w[i - 1])
|
|
327
|
+
if i % 8 == 0:
|
|
328
|
+
t = [AES_SBOX[b] for b in t[1:] + t[:1]]
|
|
329
|
+
t[0] ^= AES_RCON[i // 8]
|
|
330
|
+
elif i % 8 == 4:
|
|
331
|
+
t = [AES_SBOX[b] for b in t]
|
|
332
|
+
w.append([w[i - 8][j] ^ t[j] for j in range(4)])
|
|
333
|
+
return [b for word in w for b in word]
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _aes_shift_rows(s):
|
|
337
|
+
o = list(s)
|
|
338
|
+
for r in range(1, 4):
|
|
339
|
+
vals = [s[c * 4 + r] for c in range(4)]
|
|
340
|
+
vals = vals[r:] + vals[:r]
|
|
341
|
+
for c in range(4):
|
|
342
|
+
o[c * 4 + r] = vals[c]
|
|
343
|
+
return o
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _aes_encrypt_block(block, rk):
|
|
347
|
+
s = list(block)
|
|
348
|
+
|
|
349
|
+
def add(rnd):
|
|
350
|
+
for i in range(16):
|
|
351
|
+
s[i] ^= rk[rnd * 16 + i]
|
|
352
|
+
|
|
353
|
+
add(0)
|
|
354
|
+
for rnd in range(1, 14):
|
|
355
|
+
s = [AES_SBOX[b] for b in s]
|
|
356
|
+
s = _aes_shift_rows(s)
|
|
357
|
+
mixed = []
|
|
358
|
+
for c in range(4):
|
|
359
|
+
a, b, d, e = s[c * 4:c * 4 + 4]
|
|
360
|
+
mixed += [
|
|
361
|
+
_aes_mul(a, 2) ^ _aes_mul(b, 3) ^ d ^ e,
|
|
362
|
+
a ^ _aes_mul(b, 2) ^ _aes_mul(d, 3) ^ e,
|
|
363
|
+
a ^ b ^ _aes_mul(d, 2) ^ _aes_mul(e, 3),
|
|
364
|
+
_aes_mul(a, 3) ^ b ^ d ^ _aes_mul(e, 2),
|
|
365
|
+
]
|
|
366
|
+
s = mixed
|
|
367
|
+
add(rnd)
|
|
368
|
+
s = _aes_shift_rows([AES_SBOX[b] for b in s])
|
|
369
|
+
add(14)
|
|
370
|
+
return bytes(s)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _aes_cbc(key, iv, data):
|
|
374
|
+
rk = _aes_expand(key)
|
|
375
|
+
out, prev = b"", iv
|
|
376
|
+
for i in range(0, len(data), 16):
|
|
377
|
+
block = bytes(x ^ y for x, y in zip(data[i:i + 16], prev))
|
|
378
|
+
prev = _aes_encrypt_block(block, rk)
|
|
379
|
+
out += prev
|
|
380
|
+
return out
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _authjs_cookie(env_path):
|
|
384
|
+
root = os.path.dirname(os.path.abspath(env_path))
|
|
385
|
+
for _ in range(5):
|
|
386
|
+
nm = os.path.join(root, "node_modules")
|
|
387
|
+
if os.path.isdir(os.path.join(nm, "@auth", "core")):
|
|
388
|
+
return "authjs.session-token"
|
|
389
|
+
try:
|
|
390
|
+
with open(os.path.join(nm, "next-auth", "package.json"), encoding="utf-8") as f:
|
|
391
|
+
major = json.load(f).get("version", "").lstrip("^~").split(".")[0]
|
|
392
|
+
return "authjs.session-token" if major.isdigit() and int(major) >= 5 else None
|
|
393
|
+
except OSError:
|
|
394
|
+
pass
|
|
395
|
+
parent = os.path.dirname(root)
|
|
396
|
+
if parent == root:
|
|
397
|
+
break
|
|
398
|
+
root = parent
|
|
399
|
+
# The dependency was detected upstream but the tree is not on disk: v5 is the current default.
|
|
400
|
+
return "authjs.session-token"
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def authjs(recipe, env):
|
|
404
|
+
secret = source("env:" + "|".join(recipe["env"]), env, None, None)
|
|
405
|
+
if not secret:
|
|
406
|
+
return {"status": "absent", "note": " or ".join(recipe["env"])}
|
|
407
|
+
cookie = _authjs_cookie(PLAN["envPath"])
|
|
408
|
+
if not cookie:
|
|
409
|
+
return {"status": "upstream", "note": "Auth.js v4"}
|
|
410
|
+
key = hkdf_sha256(secret.encode("utf-8"), cookie.encode("utf-8"), ("Auth.js Generated Encryption Key (%s)" % cookie).encode("utf-8"), 64)
|
|
411
|
+
mac_key, enc_key = key[:32], key[32:]
|
|
412
|
+
now = int(time.time())
|
|
413
|
+
claims = {"sub": TEST, "name": "Cortad test", "email": "cortad@example.invalid", "iat": now, "exp": now + TTL, "jti": secrets.token_hex(16)}
|
|
414
|
+
header = b64url(json.dumps({"alg": "dir", "enc": "A256CBC-HS512"}, separators=(",", ":")).encode("utf-8"))
|
|
415
|
+
plaintext = json.dumps(claims, separators=(",", ":")).encode("utf-8")
|
|
416
|
+
pad = 16 - (len(plaintext) % 16)
|
|
417
|
+
iv = secrets.token_bytes(16)
|
|
418
|
+
ct = _aes_cbc(enc_key, iv, plaintext + bytes([pad]) * pad)
|
|
419
|
+
aad = header.encode("ascii")
|
|
420
|
+
al = (len(aad) * 8).to_bytes(8, "big")
|
|
421
|
+
tag = hmac.new(mac_key, aad + iv + ct + al, hashlib.sha512).digest()[:32]
|
|
422
|
+
jwe = "%s..%s.%s.%s" % (header, b64url(iv), b64url(ct), b64url(tag))
|
|
423
|
+
return minted("cookie", "%s=%s" % (cookie, jwe))
|
|
264
424
|
|
|
265
425
|
|
|
266
426
|
# The Auth emulator the boot raised in place of their Firebase project: it signs any address up
|
|
@@ -341,6 +501,10 @@ def main():
|
|
|
341
501
|
got = sealed(recipe, env)
|
|
342
502
|
elif recipe["kind"] == "jwt":
|
|
343
503
|
got = jwt(recipe, env)
|
|
504
|
+
elif recipe["kind"] == "supabase":
|
|
505
|
+
got = supabase(recipe, env)
|
|
506
|
+
elif recipe["kind"] == "authjs":
|
|
507
|
+
got = authjs(recipe, env)
|
|
344
508
|
elif recipe["kind"] == "login":
|
|
345
509
|
got = login(recipe, role, role_value, env)
|
|
346
510
|
elif recipe["kind"] == "firebase":
|
package/lib/service.mjs
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Some apps keep their sign-in on a different service than the one that serves the AI: databuddy
|
|
2
|
+
// serves chat from apps/api and mounts better-auth in apps/dashboard, and the command starts only
|
|
3
|
+
// apps/api. A recipe that names such a service (src/setup/road-identities.ts) is minted against
|
|
4
|
+
// that service instead, so nobody is asked to send a message by hand. Cookies set on localhost
|
|
5
|
+
// apply to every port, which is what makes the session work on the AI service after.
|
|
6
|
+
import { relative, join } from "node:path";
|
|
7
|
+
import { startPlan } from "./start.mjs";
|
|
8
|
+
|
|
9
|
+
export const serviceBase = (port) => `http://127.0.0.1:${port}`;
|
|
10
|
+
|
|
11
|
+
// The recipes to mint against a service other than the running app, grouped by that service's
|
|
12
|
+
// workspace, each with how it starts itself. A recipe whose service is the app's own dir stays with
|
|
13
|
+
// the app and is not returned here.
|
|
14
|
+
export function servicesFor({ recipes, root, appDir, onPath }) {
|
|
15
|
+
const here = relative(root, appDir) || ".";
|
|
16
|
+
const byDir = new Map();
|
|
17
|
+
for (const recipe of Array.isArray(recipes) ? recipes : []) {
|
|
18
|
+
const dir = typeof recipe?.service === "string" ? recipe.service.replace(/^\.\//, "").replace(/\/+$/, "") : "";
|
|
19
|
+
if (!dir || dir === here) continue;
|
|
20
|
+
if (!byDir.has(dir)) byDir.set(dir, []);
|
|
21
|
+
byDir.get(dir).push(recipe);
|
|
22
|
+
}
|
|
23
|
+
return [...byDir].map(([dir, group]) => ({
|
|
24
|
+
dir,
|
|
25
|
+
cwd: join(root, dir),
|
|
26
|
+
plan: startPlan({ root: join(root, dir), onPath }),
|
|
27
|
+
recipes: group,
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// One sign-in per port: recipes whose sign-in is mounted in another workspace are minted against
|
|
32
|
+
// that workspace, the rest against the app, and the rows come back as one list. A repository whose
|
|
33
|
+
// sign-in is where the app is mints once, against the app, as it always did.
|
|
34
|
+
export async function mintAcross({ recipes, root, appDir, onPath, appPort, portFor, mint, originFor = () => undefined }) {
|
|
35
|
+
const groups = servicesFor({ recipes, root, appDir, onPath });
|
|
36
|
+
if (!groups.length) return mint(recipes, appPort);
|
|
37
|
+
const elsewhere = new Set(groups.flatMap((g) => g.recipes));
|
|
38
|
+
const rows = [];
|
|
39
|
+
for (const group of groups) {
|
|
40
|
+
const port = (await portFor(group)) ?? appPort;
|
|
41
|
+
rows.push(...((await mint(group.recipes, port, originFor(port)))?.identities ?? []));
|
|
42
|
+
}
|
|
43
|
+
const rest = (Array.isArray(recipes) ? recipes : []).filter((r) => !elsewhere.has(r));
|
|
44
|
+
if (rest.length) rows.push(...((await mint(rest, appPort))?.identities ?? []));
|
|
45
|
+
return { identities: rows };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Wait for a service to answer on its port, bounded so a service that never comes up cannot hold
|
|
49
|
+
// the whole raise. Any HTTP reply means the port is up; a connection refused means keep waiting.
|
|
50
|
+
export async function waitForPort(port, deadlineMs = 90_000) {
|
|
51
|
+
const until = Date.now() + deadlineMs;
|
|
52
|
+
while (Date.now() < until) {
|
|
53
|
+
try {
|
|
54
|
+
await fetch(serviceBase(port) + "/", { method: "GET", signal: AbortSignal.timeout(2500), redirect: "manual" });
|
|
55
|
+
return true;
|
|
56
|
+
} catch (e) {
|
|
57
|
+
if (!/aborted|timeout|ECONNREFUSED|ECONNRESET|fetch failed|other side closed/i.test(String(e?.cause?.code ?? e?.message ?? e))) return false;
|
|
58
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The page a sign-in believes it is being asked from. Nearly every auth library refuses a request
|
|
65
|
+
// whose Origin it does not trust: better-auth answered databuddy's own sign-up with 403
|
|
66
|
+
// INVALID_ORIGIN because the ask went to 127.0.0.1 with no page behind it. Their own settings name
|
|
67
|
+
// the page; where they do not, it is that port on localhost, which is what their own browser sends.
|
|
68
|
+
export function originFor(port, origins = []) {
|
|
69
|
+
const named = origins.find((o) => { try { return new URL(o).port === String(port); } catch { return false; } });
|
|
70
|
+
return named ?? `http://localhost:${port}`;
|
|
71
|
+
}
|
package/lib/start.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// How their app is started, and from which folder, worked out from the repository itself so nobody
|
|
2
2
|
// is asked. A monorepo root has no app of its own: the app is the workspace that serves the AI.
|
|
3
3
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
4
|
-
import { join, relative } from "node:path";
|
|
4
|
+
import { dirname, join, relative } from "node:path";
|
|
5
5
|
|
|
6
6
|
const read = (file) => { try { return readFileSync(file, "utf8"); } catch { return ""; } };
|
|
7
7
|
const json = (file) => { try { return JSON.parse(read(file)); } catch { return null; } };
|
|
@@ -23,6 +23,18 @@ const SERVES = /uvicorn\.run|FastAPI\(|Flask\(|Starlette\(|Litestar\(|Quart\(|ap
|
|
|
23
23
|
// The same question of a package script: does this command put something on a port, or run a task
|
|
24
24
|
// and exit. `crewai run` and `python main.py` are tasks; `next dev` and `nodemon server.js` serve.
|
|
25
25
|
const SERVES_JS = /\b(?:next|nuxt|vite|nest|remix|astro|sveltekit|serve|nodemon|ts-node-dev|uvicorn|gunicorn|rails|strapi)\b/;
|
|
26
|
+
// Where this workspace's install lives: the nearest folder at or above it holding a lockfile, never
|
|
27
|
+
// past the repository the customer ran the command in.
|
|
28
|
+
const LOCKS = ["pnpm-lock.yaml", "yarn.lock", "bun.lockb", "bun.lock", "package-lock.json"];
|
|
29
|
+
export function lockRoot(dir, root) {
|
|
30
|
+
const inside = (d) => d === root || d.startsWith(root.endsWith("/") ? root : `${root}/`);
|
|
31
|
+
for (let at = dir; inside(at); at = dirname(at)) {
|
|
32
|
+
if (LOCKS.some((l) => existsSync(join(at, l)))) return at;
|
|
33
|
+
if (at === dirname(at)) break;
|
|
34
|
+
}
|
|
35
|
+
return dir;
|
|
36
|
+
}
|
|
37
|
+
|
|
26
38
|
// The manager this repository was installed with, named by its lockfile.
|
|
27
39
|
const manager = (dir) => (existsSync(join(dir, "pnpm-lock.yaml")) ? "pnpm" : existsSync(join(dir, "yarn.lock")) ? "yarn" : existsSync(join(dir, "bun.lockb")) || existsSync(join(dir, "bun.lock")) ? "bun" : "npm");
|
|
28
40
|
|
|
@@ -121,13 +133,18 @@ export const missingDependency = (said) => MISSING.exec(String(said ?? ""))?.sli
|
|
|
121
133
|
// One install, with the manager the project locked. Never a global one: a Python project without an
|
|
122
134
|
// interpreter of its own gets a virtual environment beside its code, so nothing installed here
|
|
123
135
|
// reaches the rest of the machine.
|
|
124
|
-
export function installPlan(dir, onPath) {
|
|
136
|
+
export function installPlan(dir, onPath, root = dir) {
|
|
125
137
|
if (json(join(dir, "package.json"))) {
|
|
138
|
+
// A workspace member holds no lockfile of its own: the manager, and the install, belong to the
|
|
139
|
+
// repository that owns it. novel's apps/web installed with npm here, and npm cannot read the
|
|
140
|
+
// `workspace:^` ranges its own packages are pinned with: "Unsupported URL Type".
|
|
141
|
+
const at = lockRoot(dir, root);
|
|
126
142
|
// The manager the repository locked, fetched for the job when this machine has not got it: npm
|
|
127
143
|
// refuses vercel's ai-chatbot outright over a peer range pnpm resolves without a word.
|
|
128
|
-
const locked = manager(
|
|
129
|
-
|
|
130
|
-
|
|
144
|
+
const locked = manager(at);
|
|
145
|
+
const where = at === dir ? "" : `cd ${JSON.stringify(at)} && `;
|
|
146
|
+
if (locked !== "npm") return `${where}${onPath(locked) ? `${locked} install` : `npx --yes ${locked} install`}`;
|
|
147
|
+
return `${where}${existsSync(join(at, "package-lock.json")) ? "npm ci || npm install --legacy-peer-deps" : "npm install || npm install --legacy-peer-deps"}`;
|
|
131
148
|
}
|
|
132
149
|
if (existsSync(join(dir, "uv.lock")) && onPath("uv")) return "uv sync";
|
|
133
150
|
if (existsSync(join(dir, "poetry.lock")) && onPath("poetry")) return "poetry install";
|
package/lib/switches.mjs
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Switches an app reads to decide who it lets in, set for the app this command started and for
|
|
2
|
+
// nothing else. The customer's files are never written, and every name is printed before the app
|
|
3
|
+
// starts. A lockout, an attempt counter and a password rule are never touched.
|
|
4
|
+
import { readFileSync, statSync } from "node:fs";
|
|
5
|
+
|
|
6
|
+
const GUARDED = /(AUTH|LOGIN|PASSWORD|BREAKER|LOCKOUT|ATTEMPT|FAIL|BAN|BLOCK)/;
|
|
7
|
+
const ENV_READ = /(?:process\.env(?:\.|\[\s*['"])|os\.(?:environ\.get|getenv)\(\s*['"]|os\.environ\[\s*['"]|\benv\(\s*['"]|Deno\.env\.get\(\s*['"])([A-Z][A-Z0-9_]*)/g;
|
|
8
|
+
|
|
9
|
+
// A switch their own app reads to decide whether a caller has to be signed in at all. morphic's
|
|
10
|
+
// chat answers 401 to everyone until ENABLE_GUEST_CHAT is true; open-webui, librechat and a dozen
|
|
11
|
+
// others carry the same idea under their own name. Set for the app this command started and for
|
|
12
|
+
// nothing else: their files are not touched, and the name is printed before anything starts.
|
|
13
|
+
// A lockout, an attempt counter and a password rule carry the same words and are never touched.
|
|
14
|
+
const OPENS_AUTH = /^(?:ENABLE|REQUIRE)_AUTH(?:ENTICATION)?$|^AUTH(?:ENTICATION)?_(?:ENABLED|REQUIRED)$|^(?:REQUIRE|ENABLE)_(?:LOGIN|SIGN_?IN)$/;
|
|
15
|
+
const CLOSES_AUTH = /^(?:DISABLE|SKIP|NO)_AUTH(?:ENTICATION)?$|^AUTH(?:ENTICATION)?_DISABLED$/;
|
|
16
|
+
const OPENS_GUEST = /^(?:ENABLE|ALLOW)_(?:GUEST|ANONYMOUS|PUBLIC)(?:_[A-Z0-9_]+)?$|^(?:GUEST|ANONYMOUS|PUBLIC)_(?:MODE|ACCESS|CHAT|ENABLED|LOGIN)$/;
|
|
17
|
+
const CLOSES_GUEST = /^(?:DISABLE|BLOCK)_(?:GUEST|ANONYMOUS|PUBLIC)(?:_[A-Z0-9_]+)?$|^(?:GUEST|ANONYMOUS|PUBLIC)_(?:MODE|ACCESS|CHAT)_DISABLED$/;
|
|
18
|
+
const BOOLEAN = /^(?:true|false|1|0|yes|no|on|off)$/i;
|
|
19
|
+
export function openSwitches(envFiles, sources = []) {
|
|
20
|
+
const out = {};
|
|
21
|
+
const open = (name) => {
|
|
22
|
+
if (GUARDED.test(name) && !OPENS_AUTH.test(name) && !CLOSES_AUTH.test(name)) return null;
|
|
23
|
+
if (OPENS_AUTH.test(name) || CLOSES_GUEST.test(name)) return "false";
|
|
24
|
+
if (CLOSES_AUTH.test(name) || OPENS_GUEST.test(name)) return "true";
|
|
25
|
+
return null;
|
|
26
|
+
};
|
|
27
|
+
for (const file of envFiles) {
|
|
28
|
+
let text = "";
|
|
29
|
+
try { text = readFileSync(file, "utf8"); } catch { continue; }
|
|
30
|
+
for (const line of text.split("\n")) {
|
|
31
|
+
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
32
|
+
if (!m) continue;
|
|
33
|
+
const value = m[2].trim().replace(/^(['"])(.*)\1$/, "$2");
|
|
34
|
+
const to = open(m[1]);
|
|
35
|
+
// Only a switch: a name of this shape holding a URL or a key is something else entirely.
|
|
36
|
+
if (to && (value === "" || BOOLEAN.test(value))) out[m[1]] = to;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
for (const file of sources) {
|
|
40
|
+
if (!/\.(?:[cm]?[jt]sx?|py|go|rb|php|rs)$/.test(file)) continue;
|
|
41
|
+
let text = "";
|
|
42
|
+
try { if (statSync(file).size > 512_000) continue; text = readFileSync(file, "utf8"); } catch { continue; }
|
|
43
|
+
for (const m of text.matchAll(ENV_READ)) { const to = open(m[1]); if (to && !(m[1] in out)) out[m[1]] = to; }
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
package/local.mjs
CHANGED
|
@@ -23,8 +23,10 @@ import { lockHolds, makeLock } from "./lib/lock.mjs";
|
|
|
23
23
|
import { AS_HEADER, makeIdentities } from "./lib/mint.mjs";
|
|
24
24
|
import { CAPTURED, makeCapture } from "./lib/replay.mjs";
|
|
25
25
|
import { sampleHere } from "./lib/sample.mjs";
|
|
26
|
+
import { mintAcross, originFor, waitForPort } from "./lib/service.mjs";
|
|
26
27
|
import { listingUrl } from "./lib/listing.mjs";
|
|
27
28
|
import { installPlan, missingDependency, startPlan, workspaces } from "./lib/start.mjs";
|
|
29
|
+
import { openSwitches } from "./lib/switches.mjs";
|
|
28
30
|
|
|
29
31
|
const argv = process.argv.slice(2);
|
|
30
32
|
const flag = (name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; };
|
|
@@ -395,7 +397,11 @@ async function verb(job) {
|
|
|
395
397
|
}
|
|
396
398
|
case "changes": return door.changes();
|
|
397
399
|
case "diff": return door.diff(String(b.path ?? ""));
|
|
398
|
-
case "mint": return identities ? JSON.parse(mask(JSON.stringify(await
|
|
400
|
+
case "mint": return identities ? JSON.parse(mask(JSON.stringify(await mintAcross({
|
|
401
|
+
recipes: b.recipes, root, appDir, onPath, appPort: app?.port, portFor: serviceUp,
|
|
402
|
+
mint: (recipes, port, origin) => identities.mint({ ...b, recipes, headers: { ...(b.headers ?? {}), ...(origin ? { origin, referer: `${origin}/` } : {}) } }, port),
|
|
403
|
+
originFor: (port) => originFor(port, envOrigins(envFiles)),
|
|
404
|
+
})))) : { identities: [] };
|
|
399
405
|
case "restore": return door.restore(String(b.checkpoint ?? ""));
|
|
400
406
|
case "keep": return door.keep(typeof b.checkpoint === "string" && b.checkpoint ? b.checkpoint : undefined);
|
|
401
407
|
case "restart": return restartApp();
|
|
@@ -423,6 +429,57 @@ const answers = async (port) => {
|
|
|
423
429
|
const onPath = (bin) => (process.env.PATH ?? "").split(":").some((dir) => dir && existsSync(join(dir, bin)));
|
|
424
430
|
// Where their app lives inside this repository, and how it starts. Worked out in lib/start.mjs.
|
|
425
431
|
let appDir = root;
|
|
432
|
+
|
|
433
|
+
// ---- a sign-in mounted in another workspace
|
|
434
|
+
// databuddy serves its AI from apps/api and mounts sign-in in apps/dashboard: a test account can
|
|
435
|
+
// only be made where the sign-in is, and posting a sign-up at the AI's own port is a 404.
|
|
436
|
+
const PORT_IN_SCRIPT = /(?:^|\s)(?:PORT=|-p[ =]|--port[ =])(\d{2,5})\b/;
|
|
437
|
+
// The port a framework serves on when nobody names one. sveltekit and astro before vite: both bring
|
|
438
|
+
// vite with them and neither uses its port.
|
|
439
|
+
const FRAMEWORK_PORT = [["next", 3000], ["nuxt", 3000], ["@remix-run/serve", 3000], ["@remix-run/dev", 3000], ["@sveltejs/kit", 5173], ["astro", 4321], ["vite", 5173]];
|
|
440
|
+
// No scan of the usual ports: whatever answers on 3000 or 8080 on this machine is very often not
|
|
441
|
+
// this workspace, and a test account made against somebody else's service is the worst kind of
|
|
442
|
+
// wrong. Only the port this workspace itself names, and if nothing is there, it is started.
|
|
443
|
+
|
|
444
|
+
// What that workspace serves on: its own script says so, or the framework it is written in does.
|
|
445
|
+
function servicePort(cwd) {
|
|
446
|
+
let pkg = null;
|
|
447
|
+
try { pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8")); } catch { /* not a Node workspace */ }
|
|
448
|
+
const script = ["dev", "develop", "start:dev", "serve", "start"].map((s) => pkg?.scripts?.[s]).find(Boolean) ?? "";
|
|
449
|
+
const named = Number(PORT_IN_SCRIPT.exec(script)?.[1]);
|
|
450
|
+
if (named) return named;
|
|
451
|
+
const deps = Object.keys({ ...pkg?.dependencies, ...pkg?.devDependencies });
|
|
452
|
+
const framework = FRAMEWORK_PORT.find(([dep]) => deps.includes(dep))?.[1];
|
|
453
|
+
if (framework) return framework;
|
|
454
|
+
if (existsSync(join(cwd, "manage.py"))) return 8000;
|
|
455
|
+
if (existsSync(join(cwd, "Gemfile"))) return 3000;
|
|
456
|
+
const py = ["requirements.txt", "pyproject.toml"].map((f) => { try { return readFileSync(join(cwd, f), "utf8"); } catch { return ""; } }).join("\n");
|
|
457
|
+
return /\bdjango\b/i.test(py) ? 8000 : /\bflask\b/i.test(py) ? 5000 : null;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const toldService = new Set();
|
|
461
|
+
const signedInAt = (dir, port) => { if (!toldService.has(dir)) { toldService.add(dir); say(`your sign-in lives in ${dir}, so I signed in there`); } return port; };
|
|
462
|
+
// Everything else this command started, stopped with it.
|
|
463
|
+
const sidecars = [];
|
|
464
|
+
// The port that workspace's sign-in answers on. One it is already serving on comes first: starting
|
|
465
|
+
// a second copy of a dashboard that is already up costs a minute and takes its port.
|
|
466
|
+
async function serviceUp(group) {
|
|
467
|
+
const named = servicePort(group.cwd);
|
|
468
|
+
if (named && named !== app?.port && (await answers(named))) return signedInAt(group.dir, named);
|
|
469
|
+
// Its own port taken by the app leaves nothing to wait on: whatever answers there is the app.
|
|
470
|
+
if (!named || named === app?.port || !group.plan?.cmd) return null;
|
|
471
|
+
const kid = spawn("/bin/sh", ["-c", group.plan.cmd], {
|
|
472
|
+
cwd: group.plan.cwd,
|
|
473
|
+
env: { ...process.env, ...(capture ? capture.env(process.env) : {}), FORCE_COLOR: "0", ...(pinned.bin ? { PATH: `${pinned.bin}:${process.env.PATH ?? ""}` } : {}) },
|
|
474
|
+
stdio: ["ignore", "pipe", "pipe"], detached: true,
|
|
475
|
+
});
|
|
476
|
+
sidecars.push(kid);
|
|
477
|
+
const keep = (d) => appendFileSync(bootLog, d.toString());
|
|
478
|
+
kid.stdout.on("data", keep);
|
|
479
|
+
kid.stderr.on("data", keep);
|
|
480
|
+
return (await waitForPort(named, 90_000)) ? signedInAt(group.dir, named) : null;
|
|
481
|
+
}
|
|
482
|
+
|
|
426
483
|
async function ask(question) {
|
|
427
484
|
if (!process.stdin.isTTY) return null;
|
|
428
485
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -454,15 +511,23 @@ async function startApp() {
|
|
|
454
511
|
appDir = plan?.cwd ?? root;
|
|
455
512
|
pinned = pinnedNode();
|
|
456
513
|
if (plan?.within) say(`your app is in ${plan.within}, started there with: ${cmd}`);
|
|
457
|
-
const
|
|
458
|
-
|
|
514
|
+
const sourceFiles = files.map((f) => join(root, f));
|
|
515
|
+
const lifted = { ...liftedLimits(envFiles, sourceFiles), ...openSwitches(envFiles, sourceFiles) };
|
|
516
|
+
const raised = Object.keys(liftedLimits(envFiles, sourceFiles));
|
|
517
|
+
const opened = Object.keys(openSwitches(envFiles, sourceFiles));
|
|
518
|
+
if (raised.length) say(`higher request limits for this session: ${raised.join(", ")}`);
|
|
519
|
+
// Said out loud, because it changes who their app lets in for as long as this command runs.
|
|
520
|
+
if (opened.length) say(`your app's own sign-in switch, for this session only: ${opened.map((n) => `${n}=${lifted[n]}`).join(", ")}`);
|
|
459
521
|
launched = { cmd, lifted };
|
|
460
522
|
step(`starting your app: ${cmd}`);
|
|
461
523
|
const up = await launch(180_000);
|
|
462
524
|
if (up.port) return { port: up.port, cmd: launched.cmd, lifted: Object.keys(lifted) };
|
|
463
525
|
// Already running: a second start dies on the port the first one holds. The one that is running
|
|
464
526
|
// is the app, so it is used as it stands rather than treated as a failure.
|
|
465
|
-
|
|
527
|
+
// "Another next dev server is already running" is the same fact in a framework's own words: their
|
|
528
|
+
// app is up, started from the terminal they were already working in, and the port it holds is not
|
|
529
|
+
// always the one we asked for. Their running app is the app.
|
|
530
|
+
if (up.exited !== null && /EADDRINUSE|address already in use|port.{0,40}(?:in use|already used|is taken|unavailable)|another .{0,20}(?:dev )?server is already running|already running (?:on|at) (?:http|port)/i.test(up.tail)) {
|
|
466
531
|
const ports = [...up.tail.matchAll(/(?::|port\s*[:=]?\s*)(\d{4,5})\b/gi)].map((m) => Number(m[1]));
|
|
467
532
|
for (const port of new Set(ports)) {
|
|
468
533
|
if (await answers(port)) {
|
|
@@ -565,7 +630,7 @@ let installSaid = "";
|
|
|
565
630
|
async function installOnce(said) {
|
|
566
631
|
if (installed) return false;
|
|
567
632
|
const name = missingDependency(said);
|
|
568
|
-
const cmd = name && installPlan(appDir, onPath);
|
|
633
|
+
const cmd = name && installPlan(appDir, onPath, root);
|
|
569
634
|
if (!cmd) return false;
|
|
570
635
|
installed = true;
|
|
571
636
|
stepDone(`your app needs ${name}, which is not installed here`);
|
|
@@ -871,6 +936,7 @@ async function close(code = 0) {
|
|
|
871
936
|
closing = true;
|
|
872
937
|
await call("DELETE", `/local/${box}`).catch(() => {});
|
|
873
938
|
if (child?.pid) await stopApp(child.pid);
|
|
939
|
+
for (const kid of sidecars) if (kid.pid) await stopApp(kid.pid);
|
|
874
940
|
const pending = door?.pending() ?? 0;
|
|
875
941
|
if (pending) say(`${pending} agent edit${pending === 1 ? " is" : "s are"} waiting for you to keep or undo. Run the command again to review ${pending === 1 ? "it" : "them"} in the browser.`);
|
|
876
942
|
await exec("rm", ["-rf", work]).catch(() => {});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cortad",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
4
4
|
"description": "Connects the AI app on your machine to Cortad for test conversations. No dependencies.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"cortad": "local.mjs"
|
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
"lib/pyhook/sitecustomize.py",
|
|
17
17
|
"lib/replay.mjs",
|
|
18
18
|
"lib/sample.mjs",
|
|
19
|
+
"lib/service.mjs",
|
|
19
20
|
"lib/start.mjs",
|
|
21
|
+
"lib/switches.mjs",
|
|
20
22
|
"lib/trace.cjs",
|
|
21
23
|
"local.mjs"
|
|
22
24
|
],
|