@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/cli.js CHANGED
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync } from "node:fs";
3
- import { dirname, join } from "node:path";
2
+ import { readFileSync, realpathSync } from "node:fs";
3
+ import path, { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { runApprove } from "./approve-cli.js";
6
+ import { attachBodyLog } from "./body-log.js";
6
7
  import { runDemo } from "./demo.js";
7
- import { desktopMain } from "./desktop.js";
8
- import { doctorExit, runDoctor } from "./doctor.js";
8
+ import { doctorBodyLog, doctorExit, runDoctor } from "./doctor.js";
9
+ import { clearVeraxEnv, envFileJwksMissing, loadEnvFile, runInitLocal } from "./init-local.js";
10
+ import { clearCliCodeCheckPassed, defaultElevated, directoryAccess, doctorStateTarget, elevatedCommandCodeRefusal, linuxNodeLabelCheck, linuxSelinuxCheck, liveInstalledChecks, markCliCodeCheckPassed, runInstall, runUninstall, SystemToolError, unreadableSentence } from "./install.js";
9
11
  import { runHalt } from "./halt.js";
10
12
  import { main } from "./main.js";
11
13
  import { runOperator } from "./operator-cli.js";
@@ -18,10 +20,31 @@ const HELP = `verax - the body an agent asks before it acts, and the ledger it a
18
20
  Usage: verax <command> [options]
19
21
 
20
22
  (no command) serve MCP over Streamable HTTP at /mcp on VERAX_BIND (default 127.0.0.1:8787)
23
+ serve [--env-file <file>] [--log-file <file>]
24
+ same as serving, after loading KEY=VALUE lines from that file.
25
+ --log-file appends this process's stdout and stderr
26
+ init --local <stateDir> [--force] [--days N] [--port N]
27
+ write a loopback key, one agent token, and verax.env
28
+ install [--port N] [--days N] [--force]
29
+ copy this body to an administrator-owned directory and run it
30
+ as another account. Needs an elevated shell, and this program
31
+ must live in a directory only an administrator can change.
32
+ On Windows the agent token is under %ProgramData%\\Verax\\agent-token\\<SID>\\.
33
+ On Linux and macOS a process running as the invoking user writes ~/.verax/agent.token.
34
+ uninstall [--keep-state]
35
+ stop that body and remove its code. Needs an elevated shell
36
+ and the same administrator-owned copy.
21
37
  doctor [--json] check the configuration this process would run with
22
38
  demo [--keep] run a loopback body against a temporary ledger and print what it recorded
23
39
  [--with-conarium] also fetch Conarium with npx, attach it as a child, and put a masked read through the gate
24
- approve <args> approve a waiting request from this machine
40
+ approve <args> approve a waiting request from this machine.
41
+ On Windows the panel with a passkey is how a held call is approved.
42
+ An elevated CLI approve is a fallback from a separate administrator
43
+ account, not this account elevated:
44
+ "%ProgramFiles%\\verax-cli\\verax.cmd" approve.
45
+ Linux and macOS the root-owned Node, for example
46
+ sudo /opt/verax-node/<dir>/bin/node /opt/verax-cli/lib/node_modules/@verax-ai/body/dist/cli.js approve
47
+ or sudo /usr/bin/node with that same cli.js.
25
48
  operator <args> enrol an operator and manage their passkeys
26
49
  reconcile <args> compare the ledger against a statement
27
50
  verify <stateDir> read a ledger back without a body: signatures, chain,
@@ -29,14 +52,13 @@ Usage: verax <command> [options]
29
52
  witness <stateDir> run the witness alongside a body
30
53
  halt <stateDir> stop the body from allowing anything further
31
54
  unlock [--force] <stateDir> clear a stale ledger lock
32
- desktop <args> open the local panel, joining the body that holds the ledger if one is up
33
55
 
34
56
  --help, -h print this
35
57
  --version, -v print the version
36
58
 
37
- Serving needs VERAX_ISSUER, VERAX_JWKS_URL, VERAX_AUDIENCE, VERAX_STATE_DIR and
38
- VERAX_POLICY_FILE; \`verax doctor\` names what is missing. Records stay on this
39
- machine, under VERAX_STATE_DIR.
59
+ Serving needs VERAX_ISSUER, one of VERAX_JWKS_URL or VERAX_JWKS_FILE, VERAX_AUDIENCE,
60
+ VERAX_STATE_DIR and VERAX_POLICY_FILE; \`verax doctor\` names what is missing.
61
+ Records stay on this machine, under VERAX_STATE_DIR.
40
62
  `;
41
63
  function version() {
42
64
  // The published package answers with its own version, not a copy of it.
@@ -53,78 +75,215 @@ function version() {
53
75
  }
54
76
  return "unknown";
55
77
  }
56
- const argv = process.argv.slice(2);
57
- // Asking what this is must work before it is configured. Without this, the
58
- // first thing a published `verax --help` said was that three environment
59
- // variables were missing.
60
- if (argv[0] === "--help" || argv[0] === "-h" || argv[0] === "help") {
61
- process.stdout.write(HELP);
62
- process.exit(0);
63
- }
64
- if (argv[0] === "--version" || argv[0] === "-v") {
65
- process.stdout.write(`${version()}\n`);
66
- process.exit(0);
67
- }
68
- if (argv[0] === "demo") {
69
- process.exit(await runDemo(argv, process.env, {
70
- stdout: process.stdout,
71
- stderr: process.stderr,
72
- stdin: process.stdin,
73
- isTTY: Boolean(process.stdin.isTTY),
74
- }));
75
- }
76
- if (argv[0] === "approve") {
77
- process.exit(await runApprove(argv));
78
- }
79
- if (argv[0] === "operator") {
80
- process.exit(await runOperator(argv));
81
- }
82
- if (argv[0] === "halt") {
83
- const stateDir = argv[1];
84
- if (!stateDir) {
85
- process.stderr.write("verax halt <stateDir>\n");
86
- process.exit(78);
78
+ function isCliEntry() {
79
+ const entry = process.argv[1];
80
+ if (!entry)
81
+ return false;
82
+ // npm starts the CLI through a symbolic link (`bin/verax`, `node_modules/.bin/verax`): compare
83
+ // real paths, or a linked start runs nothing and exits 0.
84
+ try {
85
+ return realpathSync(path.resolve(entry)) === realpathSync(fileURLToPath(import.meta.url));
86
+ }
87
+ catch {
88
+ return false;
87
89
  }
88
- process.exit(runHalt(stateDir));
89
- }
90
- if (argv[0] === "unlock") {
91
- const force = argv.includes("--force");
92
- const stateDir = argv.slice(1).find((a) => a !== "--force");
93
- if (!stateDir) {
94
- process.stderr.write("verax unlock [--force] <stateDir>\n");
95
- process.exit(78);
96
- }
97
- process.exit(runUnlock(stateDir, (s) => process.stderr.write(s), { force }));
98
90
  }
99
- if (argv[0] === "doctor") {
100
- const json = argv.includes("--json");
101
- const checks = runDoctor(process.env, process.argv);
102
- if (json) {
103
- process.stdout.write(`${JSON.stringify({ checks })}\n`);
104
- }
105
- else {
106
- for (const c of checks) {
107
- process.stdout.write(`${c.level}\t${c.id}\t${c.detail}\n`);
91
+ /**
92
+ * One gate for every command except `--help` and `--version`. An elevated
93
+ * process refuses code the invoking user can change before the command runs.
94
+ * Install and approve keep their own check for direct callers; the flag stops
95
+ * a second check in this process.
96
+ */
97
+ export async function runCli(argv, hooks = {}) {
98
+ const stdout = hooks.stdout ?? process.stdout;
99
+ const stderr = hooks.stderr ?? process.stderr;
100
+ const platform = hooks.platform ?? process.platform;
101
+ const env = hooks.env ?? process.env;
102
+ if (argv[0] === "--help" || argv[0] === "-h" || argv[0] === "help") {
103
+ stdout.write(HELP);
104
+ return 0;
105
+ }
106
+ if (argv[0] === "--version" || argv[0] === "-v") {
107
+ stdout.write(`${version()}\n`);
108
+ return 0;
109
+ }
110
+ let isElevated = false;
111
+ try {
112
+ isElevated = hooks.elevated ? hooks.elevated() : defaultElevated(platform);
113
+ }
114
+ catch (err) {
115
+ if (err instanceof SystemToolError) {
116
+ stderr.write(`${err.message}\n`);
117
+ return 78;
108
118
  }
119
+ throw err;
120
+ }
121
+ if (isElevated) {
122
+ const refusal = elevatedCommandCodeRefusal(platform, env, hooks.codeProbe, {
123
+ execPathProbe: hooks.execPathProbe,
124
+ execArgv: hooks.execArgv,
125
+ });
126
+ if (refusal) {
127
+ stderr.write(refusal.endsWith("\n") ? refusal : `${refusal}\n`);
128
+ return 78;
129
+ }
130
+ markCliCodeCheckPassed();
131
+ }
132
+ try {
133
+ if (isElevated && argv[0] === "demo" && argv.includes("--with-conarium")) {
134
+ stderr.write("refusing: demo --with-conarium fetches and runs code with npx; run it from a terminal that is not elevated\n");
135
+ return 78;
136
+ }
137
+ return await dispatchCli(argv, stdout, stderr, env);
138
+ }
139
+ finally {
140
+ clearCliCodeCheckPassed();
109
141
  }
110
- process.exit(doctorExit(checks));
111
- }
112
- if (argv[0] === "reconcile") {
113
- process.exit(runReconcile(argv));
114
- }
115
- if (argv[0] === "verify") {
116
- process.exit(await runVerify(argv.slice(1)));
117
142
  }
118
- if (argv[0] === "desktop") {
119
- process.exit(await desktopMain(argv));
143
+ async function dispatchCli(argv, stdout, stderr, env) {
144
+ if (argv[0] === "demo") {
145
+ return runDemo(argv, env, {
146
+ stdout,
147
+ stderr,
148
+ stdin: process.stdin,
149
+ isTTY: Boolean(process.stdin.isTTY),
150
+ });
151
+ }
152
+ if (argv[0] === "approve") {
153
+ return runApprove(argv);
154
+ }
155
+ if (argv[0] === "operator") {
156
+ return runOperator(argv);
157
+ }
158
+ if (argv[0] === "halt") {
159
+ const stateDir = argv[1];
160
+ if (!stateDir) {
161
+ stderr.write("verax halt <stateDir>\n");
162
+ return 78;
163
+ }
164
+ return runHalt(stateDir, (s) => stderr.write(s));
165
+ }
166
+ if (argv[0] === "unlock") {
167
+ const force = argv.includes("--force");
168
+ const stateDir = argv.slice(1).find((a) => a !== "--force");
169
+ if (!stateDir) {
170
+ stderr.write("verax unlock [--force] <stateDir>\n");
171
+ return 78;
172
+ }
173
+ return runUnlock(stateDir, (s) => stderr.write(s), { force });
174
+ }
175
+ if (argv[0] === "init") {
176
+ return runInitLocal(argv.slice(1));
177
+ }
178
+ if (argv[0] === "install") {
179
+ return runInstall(argv);
180
+ }
181
+ if (argv[0] === "uninstall") {
182
+ return runUninstall(argv);
183
+ }
184
+ if (argv[0] === "serve") {
185
+ const logFlag = argv.indexOf("--log-file");
186
+ if (logFlag !== -1) {
187
+ const logPath = argv[logFlag + 1];
188
+ if (!logPath || logPath.startsWith("-")) {
189
+ stderr.write("verax serve --log-file <file>\n");
190
+ return 78;
191
+ }
192
+ const attached = attachBodyLog(logPath);
193
+ if (!attached.ok) {
194
+ stderr.write(`${attached.reason}\n`);
195
+ return 78;
196
+ }
197
+ stderr.write("verax serve starting\n");
198
+ }
199
+ const fileFlag = argv.indexOf("--env-file");
200
+ if (fileFlag !== -1) {
201
+ const file = argv[fileFlag + 1];
202
+ if (!file || file.startsWith("-")) {
203
+ stderr.write("verax serve --env-file <file>\n");
204
+ return 78;
205
+ }
206
+ clearVeraxEnv(process.env);
207
+ const loaded = loadEnvFile(file);
208
+ if (!loaded.ok) {
209
+ stderr.write(`${loaded.reason}\n`);
210
+ return 78;
211
+ }
212
+ const missingJwks = envFileJwksMissing(process.env);
213
+ if (missingJwks) {
214
+ stderr.write(`${missingJwks}\n`);
215
+ return 78;
216
+ }
217
+ }
218
+ await main();
219
+ // main() returns once the server is listening. The open socket keeps the
220
+ // process up; this promise stops the rest of the dispatcher from running.
221
+ await new Promise(() => { });
222
+ }
223
+ if (argv[0] === "doctor") {
224
+ const json = argv.includes("--json");
225
+ const target = doctorStateTarget(env);
226
+ if (target && directoryAccess(target) === "unreadable") {
227
+ stderr.write(`${unreadableSentence(target, "doctor")}\n`);
228
+ return 77;
229
+ }
230
+ const nodeLabel = process.platform === "linux" ? linuxNodeLabelCheck() : null;
231
+ const checks = [
232
+ ...runDoctor(env, process.argv),
233
+ ...liveInstalledChecks(),
234
+ ...(process.platform === "linux" ? [linuxSelinuxCheck()] : []),
235
+ ...(nodeLabel ? [nodeLabel] : []),
236
+ ];
237
+ const bodyLog = target ? doctorBodyLog(target) : "";
238
+ if (json) {
239
+ stdout.write(`${JSON.stringify(target ? { checks, bodyLog } : { checks })}\n`);
240
+ }
241
+ else {
242
+ for (const c of checks) {
243
+ stdout.write(`${c.level}\t${c.id}\t${c.detail}\n`);
244
+ }
245
+ if (bodyLog !== "")
246
+ stdout.write(bodyLog);
247
+ }
248
+ return doctorExit(checks);
249
+ }
250
+ if (argv[0] === "reconcile") {
251
+ return runReconcile(argv, (s) => stderr.write(s));
252
+ }
253
+ if (argv[0] === "verify") {
254
+ return runVerify(argv.slice(1), (s) => stdout.write(`${s}\n`));
255
+ }
256
+ if (argv[0] === "desktop") {
257
+ stderr.write("desktop-not-in-0.4.0: the desktop panel ships in 0.4.1\n");
258
+ return 64;
259
+ }
260
+ if (argv[0] === "witness") {
261
+ const stateDir = argv[1];
262
+ if (!stateDir) {
263
+ stderr.write("verax witness <stateDir>\n");
264
+ return 78;
265
+ }
266
+ await runWitness(stateDir);
267
+ return 0;
268
+ }
269
+ const head = argv[0];
270
+ if (head !== undefined && !head.startsWith("--")) {
271
+ stderr.write(`unknown command: ${head}\n`);
272
+ stderr.write(HELP);
273
+ return 64;
274
+ }
275
+ // `main()` reads `--inventory`. Any other leading flag is not a serve option.
276
+ if (head !== undefined && head.startsWith("--") && head !== "--inventory") {
277
+ stderr.write(`unknown option: ${head}\n`);
278
+ stderr.write(HELP);
279
+ return 64;
280
+ }
281
+ await main();
282
+ // main() returns once the server is listening. The open socket keeps the
283
+ // process up, the same way as `serve`.
284
+ await new Promise(() => { });
285
+ return 0;
120
286
  }
121
- if (argv[0] === "witness") {
122
- const stateDir = argv[1];
123
- if (!stateDir) {
124
- process.stderr.write("verax witness <stateDir>\n");
125
- process.exit(78);
126
- }
127
- await runWitness(stateDir);
128
- process.exit(0);
287
+ if (isCliEntry()) {
288
+ process.exit(await runCli(process.argv.slice(2)));
129
289
  }
130
- await main();
package/dist/config.d.ts CHANGED
@@ -8,6 +8,19 @@ export type BodyConfig = {
8
8
  bindPort: number;
9
9
  policyFile: string;
10
10
  tlsTerminated: boolean;
11
+ /**
12
+ * Path to a JWKS file read once at start. Set instead of `jwksUrl`.
13
+ * Loopback binds only.
14
+ */
15
+ jwksFile?: string | null;
16
+ /**
17
+ * JWKS captured once at desktop start. Verification uses this set and does
18
+ * not refetch `jwksUrl`. Null when the body should use the remote set.
19
+ * Not a substitute for `jwksFile` (that mode refuses operator scopes).
20
+ */
21
+ jwksPin?: {
22
+ keys: Record<string, unknown>[];
23
+ } | null;
11
24
  /** Path to an inventory JSON file. Missing file is absence, not a fault. */
12
25
  inventoryFile?: string | null;
13
26
  /**
@@ -17,7 +30,25 @@ export type BodyConfig = {
17
30
  * no downstream; a named file that cannot be attached stops the body.
18
31
  */
19
32
  downstreamFile?: string | null;
33
+ /** Origins allowed to call this HTTP server. Empty means no browser origin. */
34
+ allowedOrigins?: readonly string[];
35
+ /**
36
+ * Bytes one tenant may keep under `memory/`, summed across stored versions.
37
+ * Default is 1 MiB. `VERAX_MEMORY_QUOTA_BYTES` overrides it.
38
+ */
39
+ memoryQuotaBytes?: number;
40
+ /**
41
+ * Bytes one tenant may append to `outbox.jsonl`.
42
+ * Default is 1 MiB. `VERAX_MESSAGE_QUOTA_BYTES` overrides it.
43
+ */
44
+ messageQuotaBytes?: number;
20
45
  };
46
+ /** Per-tenant `memory.put` cap. The HTTP body cap does not reset this total. */
47
+ export declare const DEFAULT_MEMORY_QUOTA_BYTES: number;
48
+ /** Per-tenant `message.send` cap. The HTTP body cap does not reset this total. */
49
+ export declare const DEFAULT_MESSAGE_QUOTA_BYTES: number;
50
+ export declare function memoryQuotaBytes(env?: NodeJS.ProcessEnv): number;
51
+ export declare function messageQuotaBytes(env?: NodeJS.ProcessEnv): number;
21
52
  export type ConfigResult = {
22
53
  ok: true;
23
54
  value: BodyConfig;
package/dist/config.js CHANGED
@@ -1,4 +1,24 @@
1
+ import { readFileSync } from "node:fs";
1
2
  export const EX_CONFIG = 78;
3
+ /** Per-tenant `memory.put` cap. The HTTP body cap does not reset this total. */
4
+ export const DEFAULT_MEMORY_QUOTA_BYTES = 1024 * 1024;
5
+ /** Per-tenant `message.send` cap. The HTTP body cap does not reset this total. */
6
+ export const DEFAULT_MESSAGE_QUOTA_BYTES = 1024 * 1024;
7
+ function positiveIntQuota(raw, fallback) {
8
+ const text = raw?.trim() ?? "";
9
+ if (text === "")
10
+ return fallback;
11
+ const n = Number(text);
12
+ if (!Number.isInteger(n) || n <= 0)
13
+ return fallback;
14
+ return n;
15
+ }
16
+ export function memoryQuotaBytes(env = process.env) {
17
+ return positiveIntQuota(env.VERAX_MEMORY_QUOTA_BYTES, DEFAULT_MEMORY_QUOTA_BYTES);
18
+ }
19
+ export function messageQuotaBytes(env = process.env) {
20
+ return positiveIntQuota(env.VERAX_MESSAGE_QUOTA_BYTES, DEFAULT_MESSAGE_QUOTA_BYTES);
21
+ }
2
22
  const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
3
23
  function parseBind(raw) {
4
24
  const text = raw && raw.trim() !== "" ? raw.trim() : "127.0.0.1:8787";
@@ -14,11 +34,49 @@ export function isLoopbackHost(host) {
14
34
  const stripped = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
15
35
  return LOOPBACK.has(stripped);
16
36
  }
37
+ function jwksPinParses(raw) {
38
+ try {
39
+ const parsed = JSON.parse(raw);
40
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.keys) || parsed.keys.length === 0)
41
+ return null;
42
+ if (!parsed.keys.every((key) => key !== null && typeof key === "object" && !Array.isArray(key)))
43
+ return null;
44
+ return { keys: parsed.keys };
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ function jwksFileParses(path) {
51
+ try {
52
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
53
+ return Array.isArray(parsed.keys) && parsed.keys.length > 0;
54
+ }
55
+ catch {
56
+ return false;
57
+ }
58
+ }
17
59
  export function loadConfig(env) {
18
60
  const issuer = env.VERAX_ISSUER?.trim() ?? "";
19
61
  const jwksUrl = env.VERAX_JWKS_URL?.trim() ?? "";
62
+ const jwksFile = env.VERAX_JWKS_FILE?.trim() ?? "";
63
+ const jwksPinRaw = env.VERAX_JWKS_PIN?.trim() ?? "";
20
64
  const audience = env.VERAX_AUDIENCE?.trim() ?? "";
21
- if (issuer === "" || jwksUrl === "" || audience === "") {
65
+ let jwksPin = null;
66
+ if (jwksPinRaw !== "") {
67
+ jwksPin = jwksPinParses(jwksPinRaw);
68
+ if (!jwksPin) {
69
+ return { ok: false, code: EX_CONFIG, reason: "VERAX_JWKS_PIN is unparsable" };
70
+ }
71
+ }
72
+ if (jwksUrl !== "" && jwksFile !== "") {
73
+ return {
74
+ ok: false,
75
+ code: EX_CONFIG,
76
+ reason: "both VERAX_JWKS_URL and VERAX_JWKS_FILE",
77
+ };
78
+ }
79
+ if (issuer === "" || audience === "" || (jwksUrl === "" && jwksFile === "")) {
22
80
  return {
23
81
  ok: false,
24
82
  code: EX_CONFIG,
@@ -27,6 +85,13 @@ export function loadConfig(env) {
27
85
  }
28
86
  const bind = parseBind(env.VERAX_BIND);
29
87
  const tlsTerminated = env.VERAX_TLS_TERMINATED === "1";
88
+ if (jwksFile !== "" && !isLoopbackHost(bind.host)) {
89
+ return {
90
+ ok: false,
91
+ code: EX_CONFIG,
92
+ reason: "VERAX_JWKS_FILE needs a loopback bind",
93
+ };
94
+ }
30
95
  if (!isLoopbackHost(bind.host) && !tlsTerminated) {
31
96
  return {
32
97
  ok: false,
@@ -34,6 +99,13 @@ export function loadConfig(env) {
34
99
  reason: "non-loopback bind without VERAX_TLS_TERMINATED=1",
35
100
  };
36
101
  }
102
+ if (jwksFile !== "" && !jwksFileParses(jwksFile)) {
103
+ return {
104
+ ok: false,
105
+ code: EX_CONFIG,
106
+ reason: "VERAX_JWKS_FILE is missing or unparsable",
107
+ };
108
+ }
37
109
  const stateDir = env.VERAX_STATE_DIR?.trim() ?? "";
38
110
  if (stateDir === "") {
39
111
  return { ok: false, code: EX_CONFIG, reason: "missing VERAX_STATE_DIR" };
@@ -49,6 +121,8 @@ export function loadConfig(env) {
49
121
  value: {
50
122
  issuer,
51
123
  jwksUrl,
124
+ jwksFile: jwksFile === "" ? null : jwksFile,
125
+ jwksPin,
52
126
  audience,
53
127
  stateDir,
54
128
  bindHost: bind.host,
@@ -57,6 +131,12 @@ export function loadConfig(env) {
57
131
  tlsTerminated,
58
132
  inventoryFile: inventoryRaw === "" ? null : inventoryRaw,
59
133
  downstreamFile: downstreamRaw === "" ? null : downstreamRaw,
134
+ allowedOrigins: (env.VERAX_ALLOWED_ORIGINS ?? "")
135
+ .split(",")
136
+ .map((item) => item.trim())
137
+ .filter((item) => item !== ""),
138
+ memoryQuotaBytes: memoryQuotaBytes(env),
139
+ messageQuotaBytes: messageQuotaBytes(env),
60
140
  },
61
141
  };
62
142
  }
package/dist/demo.d.ts CHANGED
@@ -14,5 +14,7 @@ export type DemoOpts = {
14
14
  command: string;
15
15
  args: string[];
16
16
  };
17
+ /** mkdtemp prefix. Tests pass a unique one so parallel files do not share verax-demo-. */
18
+ statePrefix?: string;
17
19
  };
18
20
  export declare function runDemo(argv: string[], env: NodeJS.ProcessEnv, io: DemoIo, opts?: DemoOpts): Promise<number>;
package/dist/demo.js CHANGED
@@ -229,6 +229,8 @@ async function readAnswer(stdin) {
229
229
  resolve(value);
230
230
  };
231
231
  const onData = (chunk) => {
232
+ if (typeof stdin.pause === "function")
233
+ stdin.pause();
232
234
  done(String(chunk).split(/\r?\n/)[0] ?? "");
233
235
  };
234
236
  const onEnd = () => done("");
@@ -289,7 +291,8 @@ export async function runDemo(argv, env, io, opts) {
289
291
  process.once("SIGINT", onStop);
290
292
  process.once("SIGTERM", onStop);
291
293
  try {
292
- stateDir = mkdtempSync(join(tmpdir(), "verax-demo-"));
294
+ const statePrefix = opts?.statePrefix && opts.statePrefix.length > 0 ? opts.statePrefix : "verax-demo-";
295
+ stateDir = mkdtempSync(join(tmpdir(), statePrefix));
293
296
  const policyFile = writeDemoPolicy(stateDir, withConarium);
294
297
  const { privateKey, publicKey } = await generateKeyPair("ES256");
295
298
  const jwk = { ...(await exportJWK(publicKey)), alg: "ES256", use: "sig", kid: KID };
@@ -369,7 +372,10 @@ export async function runDemo(argv, env, io, opts) {
369
372
  writeOut("Approve this payment as the operator on this machine? [y/N]\n");
370
373
  const answer = await readAnswer(io.stdin);
371
374
  if (answer.trim().toLowerCase() === "y") {
372
- const code = await runApprove(["approve", stateDir, held.ref], (s) => writeErr(s), () => undefined);
375
+ const code = await runApprove(["approve", stateDir, held.ref], writeErr, () => undefined, {
376
+ isTTY: io.isTTY,
377
+ ask: async () => readAnswer(io.stdin),
378
+ }, { elevated: () => false });
373
379
  if (code !== 0)
374
380
  throw new Error("demo: approve failed");
375
381
  approved = true;
@@ -0,0 +1,19 @@
1
+ /** Env the desktop launcher sets on the issuer and the body. Absent outside that launch. */
2
+ export declare const DESKTOP_PARENT_PID = "VERAX_DESKTOP_PARENT_PID";
3
+ export type DesktopParentWatch = {
4
+ env?: NodeJS.ProcessEnv;
5
+ /** True when `pid` is still a process. Default is `process.kill(pid, 0)`. */
6
+ pidAlive?: (pid: number) => boolean;
7
+ exit?: (code: number) => void;
8
+ schedule?: (fn: () => void, ms: number) => {
9
+ unref?: () => void;
10
+ };
11
+ };
12
+ /** `process.kill(pid, 0)` — EPERM means the process exists. */
13
+ export declare function desktopParentAlive(pid: number): boolean;
14
+ /**
15
+ * When `VERAX_DESKTOP_PARENT_PID` is set, exit once that pid is gone.
16
+ * Polls every 2s. No-op when the variable is unset, so a normal body or issuer
17
+ * start does not watch anything.
18
+ */
19
+ export declare function watchDesktopParent(hooks?: DesktopParentWatch): void;
@@ -0,0 +1,44 @@
1
+ /** Env the desktop launcher sets on the issuer and the body. Absent outside that launch. */
2
+ export const DESKTOP_PARENT_PID = "VERAX_DESKTOP_PARENT_PID";
3
+ /** `process.kill(pid, 0)` — EPERM means the process exists. */
4
+ export function desktopParentAlive(pid) {
5
+ try {
6
+ process.kill(pid, 0);
7
+ return true;
8
+ }
9
+ catch (err) {
10
+ return err.code === "EPERM";
11
+ }
12
+ }
13
+ /**
14
+ * When `VERAX_DESKTOP_PARENT_PID` is set, exit once that pid is gone.
15
+ * Polls every 2s. No-op when the variable is unset, so a normal body or issuer
16
+ * start does not watch anything.
17
+ */
18
+ export function watchDesktopParent(hooks = {}) {
19
+ const env = hooks.env ?? process.env;
20
+ const raw = env[DESKTOP_PARENT_PID]?.trim() ?? "";
21
+ if (raw === "")
22
+ return;
23
+ const exit = hooks.exit ?? ((code) => process.exit(code));
24
+ const pid = Number(raw);
25
+ if (!Number.isInteger(pid) || pid <= 0) {
26
+ exit(1);
27
+ return;
28
+ }
29
+ const alive = hooks.pidAlive ?? desktopParentAlive;
30
+ if (!alive(pid)) {
31
+ exit(1);
32
+ return;
33
+ }
34
+ const schedule = hooks.schedule ??
35
+ ((fn, ms) => {
36
+ const timer = setInterval(fn, ms);
37
+ timer.unref();
38
+ return timer;
39
+ });
40
+ schedule(() => {
41
+ if (!alive(pid))
42
+ exit(1);
43
+ }, 2_000);
44
+ }