@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/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 jwks = Boolean(env.VERAX_JWKS_URL?.trim());
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: issuer && jwks && audience ? "ok" : "fail",
17
- detail: issuer && jwks && audience
18
- ? "VERAX_ISSUER, VERAX_JWKS_URL, and VERAX_AUDIENCE are set"
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
- const seenScope = tokenScope ?? (mintScope !== "" ? mintScope : null);
171
- if (seenScope !== null) {
172
- const parts = seenScope.split(/\s+/).filter((s) => s !== "");
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: "ok",
177
- detail: "development token scope includes verax:audit",
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: "warn",
184
- detail: "development token scope lacks verax:audit; the panel cannot read /api/ledger or /healthz counts",
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
- if (decisionN > 0) {
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 (copy.corrupt || copyEffects.corrupt) {
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 ?? {
@@ -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;
@@ -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
+ }