@verax-ai/body 0.2.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -3
- package/dist/approve-cli.d.ts +22 -1
- package/dist/approve-cli.js +209 -22
- package/dist/auth.d.ts +13 -1
- package/dist/auth.js +37 -3
- package/dist/body-log.d.ts +14 -0
- package/dist/body-log.js +61 -0
- package/dist/cli.d.ts +23 -1
- package/dist/cli.js +236 -77
- package/dist/config.d.ts +31 -0
- package/dist/config.js +81 -1
- package/dist/demo.d.ts +2 -0
- package/dist/demo.js +8 -2
- package/dist/desktop-parent.d.ts +19 -0
- package/dist/desktop-parent.js +44 -0
- package/dist/desktop.d.ts +143 -11
- package/dist/desktop.js +947 -104
- package/dist/doctor.d.ts +2 -0
- package/dist/doctor.js +155 -14
- package/dist/health-extras.d.ts +4 -0
- package/dist/health-extras.js +14 -0
- package/dist/init-local.d.ts +39 -0
- package/dist/init-local.js +316 -0
- package/dist/install.d.ts +745 -0
- package/dist/install.js +5738 -0
- package/dist/keys.d.ts +6 -0
- package/dist/keys.js +43 -20
- package/dist/main.js +16 -1
- package/dist/no-bypass-scan.js +3 -2
- package/dist/reconcile-cli.js +3 -1
- package/dist/server.d.ts +14 -1
- package/dist/server.js +125 -3
- package/dist/tools/audit.d.ts +5 -2
- package/dist/tools/audit.js +12 -2
- package/dist/tools/memory.js +41 -6
- package/dist/tools/message.js +38 -9
- package/dist/unlock.d.ts +5 -2
- package/dist/unlock.js +6 -1
- package/dist/verify-cli.js +135 -15
- package/dist/wiring.js +1 -1
- package/dist/witness.js +154 -24
- package/package.json +7 -3
package/dist/doctor.d.ts
CHANGED
|
@@ -5,4 +5,6 @@ export type DoctorCheck = {
|
|
|
5
5
|
detail: string;
|
|
6
6
|
};
|
|
7
7
|
export declare function runDoctor(env: NodeJS.ProcessEnv, argv: readonly string[]): DoctorCheck[];
|
|
8
|
+
/** Last 20 lines of `<stateDir>/body.log`, or a missing-file sentence. */
|
|
9
|
+
export declare function doctorBodyLog(stateDir: string): string;
|
|
8
10
|
export declare function doctorExit(checks: readonly DoctorCheck[]): number;
|
package/dist/doctor.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
2
3
|
import { join } from "node:path";
|
|
4
|
+
import { readWindowsMachineRoots, SystemToolError, windowsAgentTokenPath } from "./install.js";
|
|
5
|
+
import { readLogTail } from "./body-log.js";
|
|
3
6
|
import { indexCoverage, listPieceFiles } from "@verax-ai/proxy";
|
|
4
7
|
import { loadConfig, isLoopbackHost } from "./config.js";
|
|
5
8
|
import { parseDownstreamDocument } from "./downstream.js";
|
|
@@ -9,13 +12,18 @@ const SECRET_NAME = /^(VERAX_DEV_TOKEN|.*_(TOKEN|SECRET|KEY))$/;
|
|
|
9
12
|
export function runDoctor(env, argv) {
|
|
10
13
|
const checks = [];
|
|
11
14
|
const issuer = Boolean(env.VERAX_ISSUER?.trim());
|
|
12
|
-
const
|
|
15
|
+
const jwksUrl = Boolean(env.VERAX_JWKS_URL?.trim());
|
|
16
|
+
const jwksFileSet = Boolean(env.VERAX_JWKS_FILE?.trim());
|
|
17
|
+
const jwks = jwksUrl || jwksFileSet;
|
|
13
18
|
const audience = Boolean(env.VERAX_AUDIENCE?.trim());
|
|
19
|
+
const tripleReady = issuer && jwks && audience;
|
|
14
20
|
checks.push({
|
|
15
21
|
id: "issuer-jwks-audience",
|
|
16
|
-
level:
|
|
17
|
-
detail:
|
|
18
|
-
?
|
|
22
|
+
level: tripleReady ? "ok" : "fail",
|
|
23
|
+
detail: tripleReady
|
|
24
|
+
? jwksFileSet && !jwksUrl
|
|
25
|
+
? "VERAX_ISSUER, VERAX_JWKS_FILE, and VERAX_AUDIENCE are set"
|
|
26
|
+
: "VERAX_ISSUER, VERAX_JWKS_URL, and VERAX_AUDIENCE are set"
|
|
19
27
|
: "one of VERAX_ISSUER, VERAX_JWKS_URL, VERAX_AUDIENCE is missing",
|
|
20
28
|
});
|
|
21
29
|
const loaded = loadConfig({
|
|
@@ -43,7 +51,7 @@ export function runDoctor(env, argv) {
|
|
|
43
51
|
});
|
|
44
52
|
}
|
|
45
53
|
}
|
|
46
|
-
else if (loaded.reason.includes("non-loopback")) {
|
|
54
|
+
else if (loaded.reason.includes("non-loopback") || loaded.reason.includes("loopback bind")) {
|
|
47
55
|
checks.push({ id: "bind", level: "fail", detail: loaded.reason });
|
|
48
56
|
}
|
|
49
57
|
else {
|
|
@@ -167,24 +175,32 @@ export function runDoctor(env, argv) {
|
|
|
167
175
|
}
|
|
168
176
|
const tokenScope = scopeFromDevToken(env.VERAX_DEV_TOKEN);
|
|
169
177
|
const mintScope = env.VERAX_DEV_SCOPE?.trim() ?? "";
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
178
|
+
// VERAX_DEV_SCOPE cannot put verax:audit on the agent file or on a session
|
|
179
|
+
// that has not passed a passkey. The panel reads the ledger with that session.
|
|
180
|
+
if (tokenScope !== null) {
|
|
181
|
+
const parts = tokenScope.split(/\s+/).filter((s) => s !== "");
|
|
173
182
|
if (parts.includes("verax:audit")) {
|
|
174
183
|
checks.push({
|
|
175
184
|
id: "dev-token-audit",
|
|
176
|
-
level: "
|
|
177
|
-
detail: "development token
|
|
185
|
+
level: "warn",
|
|
186
|
+
detail: "development token carries verax:audit; that scope belongs on the passkey session, not the agent token. The panel reads /api/ledger only after a passkey sign-in",
|
|
178
187
|
});
|
|
179
188
|
}
|
|
180
189
|
else {
|
|
181
190
|
checks.push({
|
|
182
191
|
id: "dev-token-audit",
|
|
183
|
-
level: "
|
|
184
|
-
detail: "development token
|
|
192
|
+
level: "ok",
|
|
193
|
+
detail: "development token does not carry verax:audit",
|
|
185
194
|
});
|
|
186
195
|
}
|
|
187
196
|
}
|
|
197
|
+
else if (mintScope !== "") {
|
|
198
|
+
checks.push({
|
|
199
|
+
id: "dev-token-audit",
|
|
200
|
+
level: "ok",
|
|
201
|
+
detail: "VERAX_DEV_SCOPE does not mint verax:audit onto the agent token or a session without a passkey; the panel reads /api/ledger with the passkey session",
|
|
202
|
+
});
|
|
203
|
+
}
|
|
188
204
|
const downstreamPath = env.VERAX_DOWNSTREAM?.trim() ?? "";
|
|
189
205
|
if (downstreamPath !== "") {
|
|
190
206
|
try {
|
|
@@ -212,8 +228,82 @@ export function runDoctor(env, argv) {
|
|
|
212
228
|
});
|
|
213
229
|
}
|
|
214
230
|
}
|
|
231
|
+
checks.push(...jwksFileChecks(env, stateDir));
|
|
215
232
|
return checks;
|
|
216
233
|
}
|
|
234
|
+
function jwksFileChecks(env, stateDir) {
|
|
235
|
+
const file = env.VERAX_JWKS_FILE?.trim() ?? "";
|
|
236
|
+
if (file === "")
|
|
237
|
+
return [];
|
|
238
|
+
const out = [];
|
|
239
|
+
let kid = "";
|
|
240
|
+
let parses = false;
|
|
241
|
+
try {
|
|
242
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
243
|
+
const first = parsed.keys?.[0];
|
|
244
|
+
if (first && typeof first.kid === "string" && first.kid !== "") {
|
|
245
|
+
parses = true;
|
|
246
|
+
kid = first.kid;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
parses = false;
|
|
251
|
+
}
|
|
252
|
+
out.push({
|
|
253
|
+
id: "jwks-file",
|
|
254
|
+
level: parses ? "ok" : "fail",
|
|
255
|
+
detail: parses ? `VERAX_JWKS_FILE ${file} parses kid ${kid}` : `VERAX_JWKS_FILE ${file} does not parse`,
|
|
256
|
+
});
|
|
257
|
+
if (stateDir === "")
|
|
258
|
+
return out;
|
|
259
|
+
const candidates = [agentTokenOnDisk(env), join(stateDir, "local-issuer", "agent.token")].filter((file) => file !== null);
|
|
260
|
+
const tokenPath = candidates.find((file) => existsSync(file));
|
|
261
|
+
if (!tokenPath)
|
|
262
|
+
return out;
|
|
263
|
+
const exp = expFromToken(readFileSync(tokenPath, "utf8"));
|
|
264
|
+
const weekSec = 7 * 24 * 60 * 60;
|
|
265
|
+
if (exp !== null && exp <= Math.floor(Date.now() / 1000) + weekSec) {
|
|
266
|
+
out.push({
|
|
267
|
+
id: "local-agent-token",
|
|
268
|
+
level: "warn",
|
|
269
|
+
detail: `${tokenPath} expires within 7 days`,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
/** Install token: ProgramData on Windows, `~/.verax/agent.token` on Linux and macOS. */
|
|
275
|
+
function agentTokenOnDisk(env) {
|
|
276
|
+
if (process.platform === "win32") {
|
|
277
|
+
const sid = env.VERAX_USER_SID?.trim().replace(/^\*/, "") ?? "";
|
|
278
|
+
if (!/^S-1-[0-9-]+$/i.test(sid))
|
|
279
|
+
return null;
|
|
280
|
+
try {
|
|
281
|
+
return windowsAgentTokenPath(env, sid, readWindowsMachineRoots());
|
|
282
|
+
}
|
|
283
|
+
catch (err) {
|
|
284
|
+
if (err instanceof SystemToolError)
|
|
285
|
+
return null;
|
|
286
|
+
throw err;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const home = env.HOME?.trim() || env.USERPROFILE?.trim() || homedir();
|
|
290
|
+
if (home === "")
|
|
291
|
+
return null;
|
|
292
|
+
return join(home, ".verax", "agent.token");
|
|
293
|
+
}
|
|
294
|
+
function expFromToken(raw) {
|
|
295
|
+
const parts = raw.trim().split(".");
|
|
296
|
+
if (parts.length < 2)
|
|
297
|
+
return null;
|
|
298
|
+
try {
|
|
299
|
+
const json = Buffer.from(parts[1], "base64url").toString("utf8");
|
|
300
|
+
const payload = JSON.parse(json);
|
|
301
|
+
return typeof payload.exp === "number" ? payload.exp : null;
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
217
307
|
const DEFAULT_REDIRECTS = ["http://127.0.0.1:5173/", "http://127.0.0.1:4173/"];
|
|
218
308
|
function stripSlash(s) {
|
|
219
309
|
return s.replace(/\/$/, "");
|
|
@@ -260,6 +350,14 @@ function scopeFromDevToken(raw) {
|
|
|
260
350
|
return null;
|
|
261
351
|
}
|
|
262
352
|
}
|
|
353
|
+
/** Last 20 lines of `<stateDir>/body.log`, or a missing-file sentence. */
|
|
354
|
+
export function doctorBodyLog(stateDir) {
|
|
355
|
+
const file = join(stateDir, "body.log");
|
|
356
|
+
const tail = readLogTail(file, 20);
|
|
357
|
+
if (tail === null)
|
|
358
|
+
return `body.log missing: ${file}\n`;
|
|
359
|
+
return tail;
|
|
360
|
+
}
|
|
263
361
|
export function doctorExit(checks) {
|
|
264
362
|
return checks.some((c) => c.level === "fail") ? 1 : 0;
|
|
265
363
|
}
|
|
@@ -290,6 +388,8 @@ function heartbeatMaxMs(env) {
|
|
|
290
388
|
const n = Number(env.VERAX_HEARTBEAT_MAX_MS ?? "30000");
|
|
291
389
|
return Number.isFinite(n) && n > 0 ? n : 30_000;
|
|
292
390
|
}
|
|
391
|
+
/** A pulse this far ahead of the clock is still treated as now. */
|
|
392
|
+
const HEARTBEAT_FUTURE_SKEW_MS = 60_000;
|
|
293
393
|
function evidenceChecks(stateDir, env) {
|
|
294
394
|
const checks = [];
|
|
295
395
|
let pieces;
|
|
@@ -302,7 +402,24 @@ function evidenceChecks(stateDir, env) {
|
|
|
302
402
|
}
|
|
303
403
|
const srcLines = pieces.reduce((s, p) => s + countJsonl(p.decisions).lines, 0);
|
|
304
404
|
const decisionN = pieces.reduce((s, p) => s + (p.closed ? p.n : countJsonl(p.decisions).lines), 0);
|
|
305
|
-
|
|
405
|
+
let closedOff = null;
|
|
406
|
+
for (const piece of pieces) {
|
|
407
|
+
if (!piece.closed)
|
|
408
|
+
continue;
|
|
409
|
+
const lines = countJsonl(piece.decisions).lines;
|
|
410
|
+
if (lines !== piece.n) {
|
|
411
|
+
closedOff = { id: piece.id, lines, n: piece.n };
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if (closedOff) {
|
|
416
|
+
checks.push({
|
|
417
|
+
id: "ledger-index",
|
|
418
|
+
level: "fail",
|
|
419
|
+
detail: `piece ${closedOff.id} has ${closedOff.lines} decision line(s), the manifest says ${closedOff.n}`,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
else if (decisionN > 0) {
|
|
306
423
|
const known = new Set(pieces.map((p) => p.id));
|
|
307
424
|
const cov = indexCoverage(stateDir);
|
|
308
425
|
if (cov === null) {
|
|
@@ -355,6 +472,13 @@ function evidenceChecks(stateDir, env) {
|
|
|
355
472
|
detail: "ledger has rows but no readable heartbeat; the evidence service looks silent",
|
|
356
473
|
});
|
|
357
474
|
}
|
|
475
|
+
else if (!Number.isFinite(hb.atMs) || hb.atMs > Date.now() + HEARTBEAT_FUTURE_SKEW_MS) {
|
|
476
|
+
checks.push({
|
|
477
|
+
id: "heartbeat",
|
|
478
|
+
level: "fail",
|
|
479
|
+
detail: "heartbeat time is in the future",
|
|
480
|
+
});
|
|
481
|
+
}
|
|
358
482
|
else if (Date.now() - hb.atMs > heartbeatMaxMs(env)) {
|
|
359
483
|
checks.push({
|
|
360
484
|
id: "heartbeat",
|
|
@@ -387,6 +511,8 @@ function evidenceChecks(stateDir, env) {
|
|
|
387
511
|
srcEffects.lines > 0 ||
|
|
388
512
|
copy.lines > 0 ||
|
|
389
513
|
copyEffects.lines > 0 ||
|
|
514
|
+
src.corrupt ||
|
|
515
|
+
srcEffects.corrupt ||
|
|
390
516
|
copy.corrupt ||
|
|
391
517
|
copyEffects.corrupt;
|
|
392
518
|
if (!pieceAnything)
|
|
@@ -394,7 +520,15 @@ function evidenceChecks(stateDir, env) {
|
|
|
394
520
|
anything = true;
|
|
395
521
|
if (fail)
|
|
396
522
|
continue;
|
|
397
|
-
if (
|
|
523
|
+
if (src.corrupt || srcEffects.corrupt) {
|
|
524
|
+
const file = src.corrupt ? "decisions.jsonl" : "effects.jsonl";
|
|
525
|
+
fail = {
|
|
526
|
+
id: "evidence-copy",
|
|
527
|
+
level: "fail",
|
|
528
|
+
detail: `source ${file} on piece ${piece.id} is corrupt`,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
else if (copy.corrupt || copyEffects.corrupt) {
|
|
398
532
|
fail = {
|
|
399
533
|
id: "evidence-copy",
|
|
400
534
|
level: "fail",
|
|
@@ -415,6 +549,13 @@ function evidenceChecks(stateDir, env) {
|
|
|
415
549
|
detail: `evidence copy is stale on piece ${piece.id} effects: ${copyEffects.lines} effect line(s) behind source ${srcEffects.lines}`,
|
|
416
550
|
};
|
|
417
551
|
}
|
|
552
|
+
else if (copy.lines > src.lines || copyEffects.lines > srcEffects.lines) {
|
|
553
|
+
fail = {
|
|
554
|
+
id: "evidence-copy",
|
|
555
|
+
level: "fail",
|
|
556
|
+
detail: `source is shorter than its evidence copy on piece ${piece.id}`,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
418
559
|
}
|
|
419
560
|
if (anything) {
|
|
420
561
|
checks.push(fail ?? {
|
package/dist/health-extras.d.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
/** Written by `verax install` before the service starts. `/healthz` echoes it. */
|
|
2
|
+
export declare const INSTALL_HEALTH_NONCE = "install-health-nonce";
|
|
3
|
+
/** Hex nonce from this install, or null when the file is absent or not that shape. */
|
|
4
|
+
export declare function readInstallHealthNonce(stateDir: string): string | null;
|
|
1
5
|
export type HealthHeartbeat = {
|
|
2
6
|
atMs: number;
|
|
3
7
|
lastDecisionN: number;
|
package/dist/health-extras.js
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
/** Written by `verax install` before the service starts. `/healthz` echoes it. */
|
|
4
|
+
export const INSTALL_HEALTH_NONCE = "install-health-nonce";
|
|
5
|
+
/** Hex nonce from this install, or null when the file is absent or not that shape. */
|
|
6
|
+
export function readInstallHealthNonce(stateDir) {
|
|
7
|
+
try {
|
|
8
|
+
const text = readFileSync(join(stateDir, INSTALL_HEALTH_NONCE), "utf8").trim();
|
|
9
|
+
if (!/^[0-9a-f]{64}$/i.test(text))
|
|
10
|
+
return null;
|
|
11
|
+
return text.toLowerCase();
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
3
17
|
export function readHeartbeat(stateDir) {
|
|
4
18
|
const path = join(stateDir, "heartbeat.json");
|
|
5
19
|
if (!existsSync(path))
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export type InitIo = {
|
|
2
|
+
stdout: {
|
|
3
|
+
write(s: string): unknown;
|
|
4
|
+
};
|
|
5
|
+
stderr: {
|
|
6
|
+
write(s: string): unknown;
|
|
7
|
+
};
|
|
8
|
+
};
|
|
9
|
+
export declare function loadEnvFile(path: string, env?: NodeJS.ProcessEnv): {
|
|
10
|
+
ok: true;
|
|
11
|
+
} | {
|
|
12
|
+
ok: false;
|
|
13
|
+
reason: string;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Drop every `VERAX_*` name before an env file is applied. Comparison is
|
|
17
|
+
* case-insensitive because Windows environment names are.
|
|
18
|
+
*/
|
|
19
|
+
export declare function clearVeraxEnv(env: NodeJS.ProcessEnv): void;
|
|
20
|
+
/** `serve --env-file` refuses a file that names neither JWKS source. */
|
|
21
|
+
export declare function envFileJwksMissing(env: NodeJS.ProcessEnv): string | null;
|
|
22
|
+
export type InitLocalOpts = {
|
|
23
|
+
/** Write the agent token here and leave no copy under the state directory. */
|
|
24
|
+
tokenPath?: string;
|
|
25
|
+
/** Installed body prints its own summary. Skip this command's user-facing text. */
|
|
26
|
+
quiet?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Install mode. State files inherit the state directory ACL (svc, Administrators, SYSTEM).
|
|
29
|
+
* Do not grant the invoking user and do not call `restrictToOwnerWin32`.
|
|
30
|
+
* Elevated install does not pass a token path under the invoking user's home.
|
|
31
|
+
* `tokenPath` remains for a non-elevated `verax init --local`.
|
|
32
|
+
*/
|
|
33
|
+
noOwnerGrant?: boolean;
|
|
34
|
+
/** Install mode. Defaults to process.env. SUDO_USER names the POSIX owner. */
|
|
35
|
+
env?: NodeJS.ProcessEnv;
|
|
36
|
+
/** Test hook. A throw here runs the rollback after the issuer directory exists and before any file is written. */
|
|
37
|
+
beforeWrite?: () => void;
|
|
38
|
+
};
|
|
39
|
+
export declare function runInitLocal(argv: readonly string[], io?: InitIo, opts?: InitLocalOpts): Promise<number>;
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { chmodSync, existsSync, lstatSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { beginWinOwnerRun, endWinOwnerRun, ensureTokenParent, mkdirLeaf, restrictToOwnerWin32, SystemToolError } from "./install.js";
|
|
6
|
+
import { calculateJwkThumbprint, exportJWK, exportPKCS8, generateKeyPair, importPKCS8, SignJWT, } from "jose";
|
|
7
|
+
import { EX_CONFIG } from "./config.js";
|
|
8
|
+
const ISSUER = "verax-local";
|
|
9
|
+
const SUBJECT = "local-agent";
|
|
10
|
+
const SCOPE = "verax:read verax:memory";
|
|
11
|
+
const DEFAULT_DAYS = 30;
|
|
12
|
+
const MAX_DAYS = 90;
|
|
13
|
+
const DEFAULT_PORT = 8787;
|
|
14
|
+
const MIN_PORT = 1024;
|
|
15
|
+
const MAX_PORT = 65535;
|
|
16
|
+
export function loadEnvFile(path, env = process.env) {
|
|
17
|
+
let text;
|
|
18
|
+
try {
|
|
19
|
+
text = readFileSync(path, "utf8");
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return { ok: false, reason: "env file is missing or unreadable" };
|
|
23
|
+
}
|
|
24
|
+
const parsed = [];
|
|
25
|
+
for (const line of text.split(/\r?\n/)) {
|
|
26
|
+
let trimmed = line.trim();
|
|
27
|
+
if (trimmed === "" || trimmed.startsWith("#"))
|
|
28
|
+
continue;
|
|
29
|
+
if (trimmed.startsWith("export "))
|
|
30
|
+
trimmed = trimmed.slice("export ".length).trim();
|
|
31
|
+
const eq = trimmed.indexOf("=");
|
|
32
|
+
if (eq <= 0)
|
|
33
|
+
continue;
|
|
34
|
+
const key = trimmed.slice(0, eq).trim();
|
|
35
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || !key.startsWith("VERAX_")) {
|
|
36
|
+
return { ok: false, reason: `env file refuses ${key}` };
|
|
37
|
+
}
|
|
38
|
+
let value = trimmed.slice(eq + 1).trim();
|
|
39
|
+
if ((value.startsWith('"') && value.endsWith('"') && value.length >= 2) ||
|
|
40
|
+
(value.startsWith("'") && value.endsWith("'") && value.length >= 2)) {
|
|
41
|
+
value = value.slice(1, -1);
|
|
42
|
+
}
|
|
43
|
+
parsed.push([key, value]);
|
|
44
|
+
}
|
|
45
|
+
clearVeraxEnv(env);
|
|
46
|
+
for (const [key, value] of parsed)
|
|
47
|
+
env[key] = value;
|
|
48
|
+
return { ok: true };
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Drop every `VERAX_*` name before an env file is applied. Comparison is
|
|
52
|
+
* case-insensitive because Windows environment names are.
|
|
53
|
+
*/
|
|
54
|
+
export function clearVeraxEnv(env) {
|
|
55
|
+
for (const key of Object.keys(env)) {
|
|
56
|
+
if (key.toUpperCase().startsWith("VERAX_"))
|
|
57
|
+
delete env[key];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** `serve --env-file` refuses a file that names neither JWKS source. */
|
|
61
|
+
export function envFileJwksMissing(env) {
|
|
62
|
+
const file = env.VERAX_JWKS_FILE?.trim() ?? "";
|
|
63
|
+
const url = env.VERAX_JWKS_URL?.trim() ?? "";
|
|
64
|
+
if (file === "" && url === "")
|
|
65
|
+
return "env file sets neither VERAX_JWKS_FILE nor VERAX_JWKS_URL";
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
function shippedPolicyPath() {
|
|
69
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
70
|
+
let resolved = null;
|
|
71
|
+
try {
|
|
72
|
+
resolved = join(dirname(fileURLToPath(import.meta.resolve("@verax-ai/proxy/package.json"))), "policy", "default.json");
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
resolved = null;
|
|
76
|
+
}
|
|
77
|
+
const candidates = [
|
|
78
|
+
resolved,
|
|
79
|
+
join(here, "..", "..", "proxy", "policy", "default.json"),
|
|
80
|
+
join(here, "..", "..", "@verax-ai", "proxy", "policy", "default.json"),
|
|
81
|
+
];
|
|
82
|
+
for (const path of candidates) {
|
|
83
|
+
if (path && existsSync(path))
|
|
84
|
+
return path;
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
/** POSIX keeps owner-only mode. Windows grants the current user only outside install mode. */
|
|
89
|
+
function ownerOnly(path, grantOwner) {
|
|
90
|
+
if (process.platform === "win32") {
|
|
91
|
+
if (grantOwner)
|
|
92
|
+
restrictToOwnerWin32(path);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
chmodSync(path, 0o600);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// The platform does not honour the mode bit.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function slashPath(path) {
|
|
103
|
+
return path.replaceAll("\\", "/");
|
|
104
|
+
}
|
|
105
|
+
function shQuote(path) {
|
|
106
|
+
return `'${slashPath(path).replaceAll("'", "'\\''")}'`;
|
|
107
|
+
}
|
|
108
|
+
function psQuote(path) {
|
|
109
|
+
return `'${slashPath(path).replaceAll("'", "''")}'`;
|
|
110
|
+
}
|
|
111
|
+
function parseInitArgs(argv) {
|
|
112
|
+
let stateDir = "";
|
|
113
|
+
let force = false;
|
|
114
|
+
let days = DEFAULT_DAYS;
|
|
115
|
+
let port = DEFAULT_PORT;
|
|
116
|
+
let sawDays = false;
|
|
117
|
+
let sawPort = false;
|
|
118
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
119
|
+
const a = argv[i];
|
|
120
|
+
if (a === "--local") {
|
|
121
|
+
stateDir = argv[i + 1] ?? "";
|
|
122
|
+
i += 1;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (a === "--force") {
|
|
126
|
+
force = true;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (a === "--days") {
|
|
130
|
+
sawDays = true;
|
|
131
|
+
const raw = argv[i + 1] ?? "";
|
|
132
|
+
i += 1;
|
|
133
|
+
if (!/^[0-9]+$/.test(raw))
|
|
134
|
+
return { error: "--days wants an integer from 1 to 90" };
|
|
135
|
+
days = Number(raw);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (a === "--port") {
|
|
139
|
+
sawPort = true;
|
|
140
|
+
const raw = argv[i + 1] ?? "";
|
|
141
|
+
i += 1;
|
|
142
|
+
if (!/^[0-9]+$/.test(raw))
|
|
143
|
+
return { error: "--port wants an integer from 1024 to 65535" };
|
|
144
|
+
port = Number(raw);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
return { error: `flag-unknown:${a}` };
|
|
148
|
+
}
|
|
149
|
+
if (stateDir === "" || stateDir.startsWith("-")) {
|
|
150
|
+
return { error: "verax init --local <stateDir> [--force] [--days N] [--port N]" };
|
|
151
|
+
}
|
|
152
|
+
if (sawDays && (!Number.isInteger(days) || days < 1 || days > MAX_DAYS)) {
|
|
153
|
+
return { error: "--days wants an integer from 1 to 90" };
|
|
154
|
+
}
|
|
155
|
+
if (sawPort && (!Number.isInteger(port) || port < MIN_PORT || port > MAX_PORT)) {
|
|
156
|
+
return { error: "--port wants an integer from 1024 to 65535" };
|
|
157
|
+
}
|
|
158
|
+
return { stateDir: resolve(stateDir), force, days, port };
|
|
159
|
+
}
|
|
160
|
+
function pathExists(target) {
|
|
161
|
+
try {
|
|
162
|
+
lstatSync(target);
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
export async function runInitLocal(argv, io = { stdout: process.stdout, stderr: process.stderr }, opts = {}) {
|
|
170
|
+
beginWinOwnerRun();
|
|
171
|
+
try {
|
|
172
|
+
return await runInitLocalBody(argv, io, opts);
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
endWinOwnerRun();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
async function runInitLocalBody(argv, io, opts) {
|
|
179
|
+
const parsed = parseInitArgs(argv);
|
|
180
|
+
if ("error" in parsed) {
|
|
181
|
+
io.stderr.write(`${parsed.error}\n`);
|
|
182
|
+
return EX_CONFIG;
|
|
183
|
+
}
|
|
184
|
+
const stateDir = parsed.stateDir;
|
|
185
|
+
const issuerDir = join(stateDir, "local-issuer");
|
|
186
|
+
if (existsSync(issuerDir) && !parsed.force) {
|
|
187
|
+
io.stderr.write(`refusing: ${issuerDir} already exists\n`);
|
|
188
|
+
return EX_CONFIG;
|
|
189
|
+
}
|
|
190
|
+
const policyDest = join(stateDir, "policy.json");
|
|
191
|
+
const policySrc = existsSync(policyDest) ? null : shippedPolicyPath();
|
|
192
|
+
if (!existsSync(policyDest) && !policySrc) {
|
|
193
|
+
io.stderr.write("shipped policy file not found\n");
|
|
194
|
+
return EX_CONFIG;
|
|
195
|
+
}
|
|
196
|
+
const { privateKey, publicKey } = await generateKeyPair("ES256", { extractable: true });
|
|
197
|
+
const pem = await exportPKCS8(privateKey);
|
|
198
|
+
const jwk = await exportJWK(publicKey);
|
|
199
|
+
const thumb = await calculateJwkThumbprint(jwk, "sha256");
|
|
200
|
+
const hex = Buffer.from(thumb, "base64url").toString("hex").slice(0, 8);
|
|
201
|
+
const kid = `verax-local-${hex}`;
|
|
202
|
+
const publicJwk = { ...jwk, alg: "ES256", use: "sig", kid };
|
|
203
|
+
const key = await importPKCS8(pem, "ES256");
|
|
204
|
+
const now = Math.floor(Date.now() / 1000);
|
|
205
|
+
const origin = `http://127.0.0.1:${parsed.port}`;
|
|
206
|
+
const token = await new SignJWT({ scope: SCOPE })
|
|
207
|
+
.setProtectedHeader({ alg: "ES256", kid })
|
|
208
|
+
.setIssuer(ISSUER)
|
|
209
|
+
.setAudience(origin)
|
|
210
|
+
.setSubject(SUBJECT)
|
|
211
|
+
.setJti(randomUUID())
|
|
212
|
+
.setIssuedAt(now)
|
|
213
|
+
.setExpirationTime(now + parsed.days * 24 * 60 * 60)
|
|
214
|
+
.sign(key);
|
|
215
|
+
const createdState = !pathExists(stateDir);
|
|
216
|
+
const issuerExisted = pathExists(issuerDir);
|
|
217
|
+
const wrote = [];
|
|
218
|
+
const createdFiles = new Set();
|
|
219
|
+
const rollback = () => {
|
|
220
|
+
if (createdState) {
|
|
221
|
+
rmSync(stateDir, { recursive: true, force: true });
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
for (const p of [...createdFiles].reverse())
|
|
225
|
+
rmSync(p, { force: true });
|
|
226
|
+
if (!issuerExisted)
|
|
227
|
+
rmSync(issuerDir, { recursive: true, force: true });
|
|
228
|
+
};
|
|
229
|
+
const writeNew = (target, contents, mode) => {
|
|
230
|
+
if (!pathExists(target))
|
|
231
|
+
createdFiles.add(target);
|
|
232
|
+
writeFileSync(target, contents, mode === undefined ? { encoding: "utf8" } : { encoding: "utf8", mode });
|
|
233
|
+
wrote.push(target);
|
|
234
|
+
};
|
|
235
|
+
const grantOwner = opts.noOwnerGrant !== true;
|
|
236
|
+
try {
|
|
237
|
+
if (createdState)
|
|
238
|
+
mkdirLeaf(stateDir, 0o700);
|
|
239
|
+
if (process.platform === "win32" && grantOwner)
|
|
240
|
+
restrictToOwnerWin32(stateDir);
|
|
241
|
+
// --force keeps the existing issuer directory and rewrites what is in it.
|
|
242
|
+
mkdirLeaf(issuerDir, 0o700, { existingOk: parsed.force === true });
|
|
243
|
+
opts.beforeWrite?.();
|
|
244
|
+
const keyPath = join(issuerDir, "key.pem");
|
|
245
|
+
const jwksPath = join(issuerDir, "jwks.json");
|
|
246
|
+
const insideToken = join(issuerDir, "agent.token");
|
|
247
|
+
const external = opts.tokenPath !== undefined && resolve(opts.tokenPath) !== resolve(insideToken);
|
|
248
|
+
const tokenPath = external ? resolve(opts.tokenPath) : insideToken;
|
|
249
|
+
if (external)
|
|
250
|
+
ensureTokenParent(dirname(tokenPath), opts.env ?? process.env);
|
|
251
|
+
const envPath = join(stateDir, "verax.env");
|
|
252
|
+
writeNew(keyPath, pem, 0o600);
|
|
253
|
+
ownerOnly(keyPath, grantOwner);
|
|
254
|
+
writeNew(jwksPath, `${JSON.stringify({ keys: [publicJwk] })}\n`, 0o600);
|
|
255
|
+
ownerOnly(jwksPath, grantOwner);
|
|
256
|
+
writeNew(tokenPath, token, 0o600);
|
|
257
|
+
ownerOnly(tokenPath, grantOwner);
|
|
258
|
+
if (external && existsSync(insideToken))
|
|
259
|
+
unlinkSync(insideToken);
|
|
260
|
+
if (!existsSync(policyDest) && policySrc) {
|
|
261
|
+
writeNew(policyDest, readFileSync(policySrc, "utf8"));
|
|
262
|
+
ownerOnly(policyDest, grantOwner);
|
|
263
|
+
}
|
|
264
|
+
const envBody = [
|
|
265
|
+
`VERAX_ISSUER=${ISSUER}`,
|
|
266
|
+
`VERAX_JWKS_FILE=${jwksPath}`,
|
|
267
|
+
`VERAX_AUDIENCE=${origin}`,
|
|
268
|
+
`VERAX_STATE_DIR=${stateDir}`,
|
|
269
|
+
`VERAX_POLICY_FILE=${policyDest}`,
|
|
270
|
+
`VERAX_BIND=127.0.0.1:${parsed.port}`,
|
|
271
|
+
"",
|
|
272
|
+
].join("\n");
|
|
273
|
+
writeNew(envPath, envBody);
|
|
274
|
+
ownerOnly(envPath, grantOwner);
|
|
275
|
+
const lines = [
|
|
276
|
+
...wrote.map((p) => `wrote ${p}`),
|
|
277
|
+
"",
|
|
278
|
+
"A shell as the same user can read this directory and approve its own calls, so for anything that matters run the body as another OS user or in a container, or keep the agent in a sandbox that cannot read the state directory.",
|
|
279
|
+
"",
|
|
280
|
+
"Start the body:",
|
|
281
|
+
`verax serve --env-file ${shQuote(envPath)}`,
|
|
282
|
+
"",
|
|
283
|
+
"Claude Code (sh):",
|
|
284
|
+
`claude mcp add --transport http verax ${origin}/mcp --header "Authorization: Bearer $(cat ${shQuote(tokenPath)})"`,
|
|
285
|
+
"",
|
|
286
|
+
"Claude Code (PowerShell):",
|
|
287
|
+
`claude mcp add --transport http verax ${origin}/mcp --header "Authorization: Bearer $(Get-Content -Raw ${psQuote(tokenPath)})"`,
|
|
288
|
+
"",
|
|
289
|
+
"Cursor / generic mcp.json:",
|
|
290
|
+
`{
|
|
291
|
+
"mcpServers": {
|
|
292
|
+
"verax": {
|
|
293
|
+
"url": "${origin}/mcp",
|
|
294
|
+
"headers": { "Authorization": "Bearer <token from your issuer>" }
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}`,
|
|
298
|
+
`The token is the content of ${tokenPath}.`,
|
|
299
|
+
"",
|
|
300
|
+
"A held call is approved on this machine with: verax approve",
|
|
301
|
+
"",
|
|
302
|
+
];
|
|
303
|
+
if (!opts.quiet)
|
|
304
|
+
io.stdout.write(lines.join("\n"));
|
|
305
|
+
return 0;
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
const externalToken = wrote.find((p) => !p.startsWith(stateDir));
|
|
309
|
+
rollback();
|
|
310
|
+
if (externalToken && createdFiles.has(externalToken))
|
|
311
|
+
rmSync(externalToken, { force: true });
|
|
312
|
+
const detail = err instanceof Error ? err.message : "owner-only failed";
|
|
313
|
+
io.stderr.write(`${detail}\n`);
|
|
314
|
+
return err instanceof SystemToolError ? EX_CONFIG : 1;
|
|
315
|
+
}
|
|
316
|
+
}
|