@verax-ai/body 0.1.1 → 0.1.2

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.
@@ -0,0 +1,33 @@
1
+ import { type FileLedger } from "@verax-ai/proxy";
2
+ export type AgentRow = {
3
+ brain: string;
4
+ decisions: number;
5
+ allowed: number;
6
+ denied: number;
7
+ deferred: number;
8
+ /** Approvals waiting on an operator now, whatever the window. */
9
+ pending: number;
10
+ /** The newest decision in the window, or null when there was none. */
11
+ lastMs: number | null;
12
+ /** What the roster declares; null for an agent the roster does not name. */
13
+ roster: {
14
+ state: string;
15
+ label: string;
16
+ group: string | null;
17
+ } | null;
18
+ };
19
+ export type AgentsAnswer = {
20
+ fromMs: number;
21
+ toMs: number;
22
+ /** Pending first, then the most recently active; ties by name. */
23
+ agents: AgentRow[];
24
+ /** Decisions in the window whose inputs document was not found or did not match its hash. */
25
+ unattributed: number;
26
+ };
27
+ export declare function agentsWindow(opts: {
28
+ ledger: FileLedger;
29
+ stateDir: string;
30
+ inventoryFile: string | null | undefined;
31
+ fromMs: number;
32
+ toMs: number;
33
+ }): Promise<AgentsAnswer>;
package/dist/agents.js ADDED
@@ -0,0 +1,59 @@
1
+ // One row per agent for the panel's status tab: what each did in a window
2
+ // of the ledger, what is waiting on an operator for it, when it last acted,
3
+ // and what the roster says about it. A company runs hundreds of agents; the
4
+ // operator reads this list, not the records. The window is read from the
5
+ // end of the ledger, so the answer costs the window and not the ledger.
6
+ import { parseInventory } from "@verax-ai/inventory";
7
+ import { loadApprovalsFromDir } from "@verax-ai/proxy";
8
+ import { matchingInputs } from "./inputs-read.js";
9
+ import { readInventoryFile } from "./inventory-file.js";
10
+ export async function agentsWindow(opts) {
11
+ const { rows } = await opts.ledger.decisionsWindow(opts.fromMs, opts.toMs);
12
+ const inputs = (await matchingInputs(opts.stateDir, rows));
13
+ const byBrain = new Map();
14
+ const rowFor = (brain) => {
15
+ let row = byBrain.get(brain);
16
+ if (!row) {
17
+ row = { brain, decisions: 0, allowed: 0, denied: 0, deferred: 0, pending: 0, lastMs: null, roster: null };
18
+ byBrain.set(brain, row);
19
+ }
20
+ return row;
21
+ };
22
+ let unattributed = 0;
23
+ for (const d of rows) {
24
+ const ref = d.claims.ref;
25
+ const brain = typeof ref === "string" ? inputs[ref]?.principal?.brain : undefined;
26
+ if (typeof brain !== "string" || brain === "") {
27
+ unattributed += 1;
28
+ continue;
29
+ }
30
+ const row = rowFor(brain);
31
+ row.decisions += 1;
32
+ if (d.claims.decision === "allow")
33
+ row.allowed += 1;
34
+ else if (d.claims.decision === "deny")
35
+ row.denied += 1;
36
+ else if (d.claims.decision === "defer")
37
+ row.deferred += 1;
38
+ if (row.lastMs === null || d.claims.timestampMs > row.lastMs)
39
+ row.lastMs = d.claims.timestampMs;
40
+ }
41
+ for (const approval of loadApprovalsFromDir(opts.stateDir)) {
42
+ if (approval.status === "pending")
43
+ rowFor(approval.brain).pending += 1;
44
+ }
45
+ const door = readInventoryFile(opts.inventoryFile);
46
+ const parsed = door.inventory == null ? null : parseInventory(door.inventory);
47
+ if (parsed && parsed.ok) {
48
+ const groups = new Map(parsed.value.groups.map((g) => [g.id, g.label]));
49
+ for (const agent of parsed.value.agents) {
50
+ rowFor(agent.id).roster = {
51
+ state: agent.state,
52
+ label: agent.label,
53
+ group: agent.groupId === null ? null : (groups.get(agent.groupId) ?? agent.groupId),
54
+ };
55
+ }
56
+ }
57
+ const agents = [...byBrain.values()].sort((a, b) => b.pending - a.pending || (b.lastMs ?? -1) - (a.lastMs ?? -1) || a.brain.localeCompare(b.brain));
58
+ return { fromMs: opts.fromMs, toMs: opts.toMs, agents, unattributed };
59
+ }
@@ -1,6 +1,28 @@
1
+ import { existsSync, readFileSync } from "node:fs";
1
2
  import { userInfo } from "node:os";
2
- import { approvePending, approvalsLogFor, enqueueApprovalCommand, FileLedger, loadApprovalsFromDir } from "@verax-ai/proxy";
3
+ import { join } from "node:path";
4
+ import { approvePending, approvalsLogFor, createApprovalBudgetGuard, enqueueApprovalCommand, FileLedger, loadApprovalsFromDir, loadPolicy, } from "@verax-ai/proxy";
3
5
  import { loadOrCreateSigners } from "./keys.js";
6
+ function policyForApprove(stateDir, policyHash) {
7
+ const fromEnv = process.env.VERAX_POLICY_FILE?.trim();
8
+ if (fromEnv) {
9
+ try {
10
+ return loadPolicy(readFileSync(fromEnv, "utf8"));
11
+ }
12
+ catch {
13
+ // Fall through to the snapshot the body wrote for this hash.
14
+ }
15
+ }
16
+ const snap = join(stateDir, "policies", `${policyHash}.json`);
17
+ if (!existsSync(snap))
18
+ return null;
19
+ try {
20
+ return loadPolicy(JSON.parse(readFileSync(snap, "utf8")));
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
4
26
  function operatorName() {
5
27
  try {
6
28
  const name = userInfo().username;
@@ -65,6 +87,7 @@ export async function runApprove(argv, writeErr = (s) => process.stderr.write(s)
65
87
  writeErr("approve-unknown-ref\n");
66
88
  return 78;
67
89
  }
90
+ const policy = policyForApprove(stateDir, defer.claims.policyHash);
68
91
  const result = await approvePending({
69
92
  ledger,
70
93
  recordSigner: signers.recordSigner,
@@ -75,6 +98,9 @@ export async function runApprove(argv, writeErr = (s) => process.stderr.write(s)
75
98
  via: "cli",
76
99
  policyHash: defer.claims.policyHash,
77
100
  approvals,
101
+ ...(policy
102
+ ? { budgetGuard: createApprovalBudgetGuard({ policy, approvals, now: () => Date.now() }) }
103
+ : {}),
78
104
  });
79
105
  if (!result.ok) {
80
106
  writeErr(`approve-${result.reason}\n`);
package/dist/cli.js CHANGED
@@ -23,7 +23,7 @@ Usage: verax <command> [options]
23
23
  witness <stateDir> run the witness alongside a body
24
24
  halt <stateDir> stop the body from allowing anything further
25
25
  unlock [--force] <stateDir> clear a stale ledger lock
26
- desktop <args> open the local panel
26
+ desktop <args> open the local panel, joining the body that holds the ledger if one is up
27
27
 
28
28
  --help, -h print this
29
29
  --version, -v print the version
package/dist/desktop.d.ts CHANGED
@@ -17,6 +17,29 @@ export declare function parseDesktopArgs(argv: string[]): DesktopOpts | {
17
17
  error: string;
18
18
  };
19
19
  export declare function portOpen(port: number, host?: string): Promise<boolean>;
20
+ /** GET /healthz on the loopback port; true only for a 200. */
21
+ export declare function healthzUp(port: number): Promise<boolean>;
22
+ export type DesktopMode = {
23
+ mode: "spawn";
24
+ } | {
25
+ mode: "attach";
26
+ pid: number;
27
+ } | {
28
+ error: "desktop-body-locked";
29
+ pid: number;
30
+ };
31
+ /**
32
+ * One ledger, one body. The desktop used to build its own issuer and body on
33
+ * every open, which on a machine whose body starts at logon put the window on
34
+ * a second, empty ledger: the panel looked broken while the real decisions sat
35
+ * in the directory the lock names. So the lock is read first. Held by a live
36
+ * process that answers /healthz where this run was told to look, the panel
37
+ * joins that body. Held by a live process that does not answer there, the
38
+ * desktop stops: someone owns the ledger and is not where we were pointed, and
39
+ * a second body would be refused by the lock anyway. A dead or unreadable lock
40
+ * is the body's own to clear (`verax unlock`); the desktop builds as before.
41
+ */
42
+ export declare function desktopMode(stateDir: string, bodyPort: number, probe?: (port: number) => Promise<boolean>): Promise<DesktopMode>;
20
43
  export declare function killTree(pid: number | undefined): void;
21
44
  /** The panel's redirect is its own origin, so the issuer has to be told which port
22
45
  * this run put it on: the allow-list default only holds 5173 and 4173. */
package/dist/desktop.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
2
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
3
+ import { get } from "node:http";
3
4
  import { createConnection } from "node:net";
4
5
  import { dirname, join } from "node:path";
5
6
  import { fileURLToPath } from "node:url";
7
+ import { pidAlive, readLockFile } from "./unlock.js";
6
8
  const here = dirname(fileURLToPath(import.meta.url));
7
9
  const repoRoot = join(here, "..", "..", "..");
8
10
  /** Newest modification time under a file or directory, ignoring build output. */
@@ -110,6 +112,42 @@ async function waitPort(port, ms) {
110
112
  }
111
113
  return false;
112
114
  }
115
+ /** GET /healthz on the loopback port; true only for a 200. */
116
+ export function healthzUp(port) {
117
+ return new Promise((resolve) => {
118
+ const req = get({ host: "127.0.0.1", port, path: "/healthz", timeout: 3_000 }, (res) => {
119
+ res.resume();
120
+ resolve(res.statusCode === 200);
121
+ });
122
+ req.once("timeout", () => {
123
+ req.destroy();
124
+ resolve(false);
125
+ });
126
+ req.once("error", () => resolve(false));
127
+ });
128
+ }
129
+ /**
130
+ * One ledger, one body. The desktop used to build its own issuer and body on
131
+ * every open, which on a machine whose body starts at logon put the window on
132
+ * a second, empty ledger: the panel looked broken while the real decisions sat
133
+ * in the directory the lock names. So the lock is read first. Held by a live
134
+ * process that answers /healthz where this run was told to look, the panel
135
+ * joins that body. Held by a live process that does not answer there, the
136
+ * desktop stops: someone owns the ledger and is not where we were pointed, and
137
+ * a second body would be refused by the lock anyway. A dead or unreadable lock
138
+ * is the body's own to clear (`verax unlock`); the desktop builds as before.
139
+ */
140
+ export async function desktopMode(stateDir, bodyPort, probe = healthzUp) {
141
+ const lockPath = join(stateDir, "ledger.lock");
142
+ if (!existsSync(lockPath))
143
+ return { mode: "spawn" };
144
+ const lock = readLockFile(lockPath);
145
+ if (!lock || !pidAlive(lock.pid))
146
+ return { mode: "spawn" };
147
+ if (await probe(bodyPort))
148
+ return { mode: "attach", pid: lock.pid };
149
+ return { error: "desktop-body-locked", pid: lock.pid };
150
+ }
113
151
  export function killTree(pid) {
114
152
  if (pid == null)
115
153
  return;
@@ -206,36 +244,48 @@ export async function runDesktop(opts, writeErr = (s) => process.stderr.write(s)
206
244
  killTree(c.pid);
207
245
  };
208
246
  try {
209
- const issuer = spawnLogged(process.execPath, ["--experimental-strip-types", issuerScript, "--out", tokenPath], issuerEnv(cleanEnv(), opts, audience, issuerUrl), repoRoot);
210
- kids.push(issuer);
211
- collectOutput(issuer, log);
212
- if (!(await waitPort(opts.issuerPort, 15_000))) {
213
- writeErr("desktop-issuer-timeout\n");
214
- stopAll();
215
- return 1;
216
- }
217
- const token = readFileSync(tokenPath, "utf8").trim();
218
- if (token === "") {
219
- writeErr("desktop-token-missing\n");
220
- stopAll();
247
+ const decided = await desktopMode(opts.stateDir, opts.bodyPort);
248
+ if ("error" in decided) {
249
+ writeErr(`${decided.error}:${decided.pid}\n`);
221
250
  return 1;
222
251
  }
223
- const body = spawnLogged(process.execPath, ["--experimental-strip-types", mainTs], {
224
- ...cleanEnv(),
225
- VERAX_STATE_DIR: opts.stateDir,
226
- VERAX_ISSUER: issuerUrl,
227
- VERAX_JWKS_URL: `${issuerUrl}/.well-known/jwks.json`,
228
- VERAX_AUDIENCE: audience,
229
- VERAX_BIND: `127.0.0.1:${opts.bodyPort}`,
230
- VERAX_POLICY_FILE: policy,
231
- ...(opts.inventoryFile ? { VERAX_INVENTORY_FILE: opts.inventoryFile } : {}),
232
- }, repoRoot);
233
- kids.push(body);
234
- collectOutput(body, log);
235
- if (!(await waitPort(opts.bodyPort, 15_000))) {
236
- writeErr("desktop-body-timeout\n");
237
- stopAll();
238
- return 1;
252
+ // Joining a running body: its issuer is whatever the body's resource
253
+ // metadata names, and its token is not ours to read. The panel's own
254
+ // origin has to be on that issuer's allow-list (VERAX_DEV_REDIRECT_URIS on
255
+ // the running issuer), which this run cannot set after the fact.
256
+ let token = null;
257
+ if (decided.mode === "spawn") {
258
+ const issuer = spawnLogged(process.execPath, ["--experimental-strip-types", issuerScript, "--out", tokenPath], issuerEnv(cleanEnv(), opts, audience, issuerUrl), repoRoot);
259
+ kids.push(issuer);
260
+ collectOutput(issuer, log);
261
+ if (!(await waitPort(opts.issuerPort, 15_000))) {
262
+ writeErr("desktop-issuer-timeout\n");
263
+ stopAll();
264
+ return 1;
265
+ }
266
+ token = readFileSync(tokenPath, "utf8").trim();
267
+ if (token === "") {
268
+ writeErr("desktop-token-missing\n");
269
+ stopAll();
270
+ return 1;
271
+ }
272
+ const body = spawnLogged(process.execPath, ["--experimental-strip-types", mainTs], {
273
+ ...cleanEnv(),
274
+ VERAX_STATE_DIR: opts.stateDir,
275
+ VERAX_ISSUER: issuerUrl,
276
+ VERAX_JWKS_URL: `${issuerUrl}/.well-known/jwks.json`,
277
+ VERAX_AUDIENCE: audience,
278
+ VERAX_BIND: `127.0.0.1:${opts.bodyPort}`,
279
+ VERAX_POLICY_FILE: policy,
280
+ ...(opts.inventoryFile ? { VERAX_INVENTORY_FILE: opts.inventoryFile } : {}),
281
+ }, repoRoot);
282
+ kids.push(body);
283
+ collectOutput(body, log);
284
+ if (!(await waitPort(opts.bodyPort, 15_000))) {
285
+ writeErr("desktop-body-timeout\n");
286
+ stopAll();
287
+ return 1;
288
+ }
239
289
  }
240
290
  const panelSources = [
241
291
  join(panelDir, "src"),
@@ -290,12 +340,14 @@ export async function runDesktop(opts, writeErr = (s) => process.stderr.write(s)
290
340
  const browser = spawnLogged(browserBin, browserArgv, cleanEnv(), repoRoot, false);
291
341
  kids.push(browser);
292
342
  collectOutput(browser, log);
293
- if (log.text.includes(token)) {
343
+ if (token !== null && log.text.includes(token)) {
294
344
  writeErr("desktop-token-leaked\n");
295
345
  stopAll();
296
346
  return 1;
297
347
  }
298
- process.stdout.write(`desktop-ready issuer=${opts.issuerPort} body=${opts.bodyPort} panel=${opts.panelPort}\n`);
348
+ process.stdout.write(decided.mode === "attach"
349
+ ? `desktop-ready body=${opts.bodyPort} panel=${opts.panelPort} attached=1 pid=${decided.pid}\n`
350
+ : `desktop-ready issuer=${opts.issuerPort} body=${opts.bodyPort} panel=${opts.panelPort}\n`);
299
351
  const onSignal = () => {
300
352
  stopAll();
301
353
  process.exit(1);
package/dist/doctor.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { indexCoverage, listPieceFiles } from "@verax-ai/proxy";
3
4
  import { loadConfig, isLoopbackHost } from "./config.js";
4
5
  import { pidAlive, readLockFile } from "./unlock.js";
5
6
  const SECRET_RE = /sk-|-----BEGIN|Bearer |eyJ[A-Za-z0-9_-]{10,}\./;
@@ -263,7 +264,51 @@ function heartbeatMaxMs(env) {
263
264
  }
264
265
  function evidenceChecks(stateDir, env) {
265
266
  const checks = [];
266
- const src = countJsonl(join(stateDir, "decisions.jsonl"));
267
+ let pieces;
268
+ try {
269
+ pieces = listPieceFiles(stateDir);
270
+ }
271
+ catch (err) {
272
+ checks.push({ id: "ledger-manifest", level: "fail", detail: err.message });
273
+ return checks;
274
+ }
275
+ const srcLines = pieces.reduce((s, p) => s + countJsonl(p.decisions).lines, 0);
276
+ const decisionN = pieces.reduce((s, p) => s + (p.closed ? p.n : countJsonl(p.decisions).lines), 0);
277
+ if (decisionN > 0) {
278
+ const known = new Set(pieces.map((p) => p.id));
279
+ const cov = indexCoverage(stateDir);
280
+ if (cov === null) {
281
+ checks.push({
282
+ id: "ledger-index",
283
+ level: "fail",
284
+ detail: `index names 0 of ${decisionN} decisions; the next open rebuilds it, a running body misses the rest until then`,
285
+ });
286
+ }
287
+ else {
288
+ const ghost = [...cov.pieces].find((id) => !known.has(id));
289
+ if (ghost !== undefined) {
290
+ checks.push({
291
+ id: "ledger-index",
292
+ level: "fail",
293
+ detail: `index names piece ${ghost} that the manifest does not`,
294
+ });
295
+ }
296
+ else if (cov.refs === decisionN) {
297
+ checks.push({
298
+ id: "ledger-index",
299
+ level: "ok",
300
+ detail: `index names ${decisionN} of ${decisionN} decisions`,
301
+ });
302
+ }
303
+ else {
304
+ checks.push({
305
+ id: "ledger-index",
306
+ level: "fail",
307
+ detail: `index names ${cov.refs} of ${decisionN} decisions; the next open rebuilds it, a running body misses the rest until then`,
308
+ });
309
+ }
310
+ }
311
+ }
267
312
  const hbPath = join(stateDir, "heartbeat.json");
268
313
  let hb = null;
269
314
  if (existsSync(hbPath)) {
@@ -274,7 +319,7 @@ function evidenceChecks(stateDir, env) {
274
319
  hb = null;
275
320
  }
276
321
  }
277
- if (src.lines > 0 || hb) {
322
+ if (srcLines > 0 || hb) {
278
323
  if (!hb || typeof hb.atMs !== "number") {
279
324
  checks.push({
280
325
  id: "heartbeat",
@@ -297,46 +342,58 @@ function evidenceChecks(stateDir, env) {
297
342
  });
298
343
  }
299
344
  }
300
- // Both halves of the evidence are mirrored, so both are compared: an effects
301
- // copy that quietly drops rows is the same silence as a missing decision copy.
302
- const srcEffects = countJsonl(join(stateDir, "effects.jsonl"));
303
- const copy = countJsonl(join(stateDir, "evidence-copy", "decisions.jsonl"));
304
- const copyEffects = countJsonl(join(stateDir, "evidence-copy", "effects.jsonl"));
305
- const anything = src.lines > 0 ||
306
- srcEffects.lines > 0 ||
307
- copy.lines > 0 ||
308
- copyEffects.lines > 0 ||
309
- copy.corrupt ||
310
- copyEffects.corrupt;
311
- if (anything) {
345
+ // Each piece is mirrored on its own, so a short copy on one piece is a fail
346
+ // even when another piece's copy still matches.
347
+ let anything = false;
348
+ let fail = null;
349
+ let decisionLines = 0;
350
+ let effectLines = 0;
351
+ for (const piece of pieces) {
352
+ const src = countJsonl(piece.decisions);
353
+ const srcEffects = countJsonl(piece.effects);
354
+ const copy = countJsonl(piece.copyDecisions);
355
+ const copyEffects = countJsonl(piece.copyEffects);
356
+ decisionLines += src.lines;
357
+ effectLines += srcEffects.lines;
358
+ const pieceAnything = src.lines > 0 ||
359
+ srcEffects.lines > 0 ||
360
+ copy.lines > 0 ||
361
+ copyEffects.lines > 0 ||
362
+ copy.corrupt ||
363
+ copyEffects.corrupt;
364
+ if (!pieceAnything)
365
+ continue;
366
+ anything = true;
367
+ if (fail)
368
+ continue;
312
369
  if (copy.corrupt || copyEffects.corrupt) {
313
- checks.push({
370
+ fail = {
314
371
  id: "evidence-copy",
315
372
  level: "fail",
316
- detail: `evidence-copy/${copy.corrupt ? "decisions" : "effects"}.jsonl is corrupt`,
317
- });
373
+ detail: `evidence-copy piece ${piece.id} ${copy.corrupt ? "decisions" : "effects"}.jsonl is corrupt`,
374
+ };
318
375
  }
319
376
  else if (copy.lines < src.lines) {
320
- checks.push({
377
+ fail = {
321
378
  id: "evidence-copy",
322
379
  level: "fail",
323
- detail: `evidence copy is stale: ${copy.lines} lines behind source ${src.lines}`,
324
- });
380
+ detail: `evidence copy is stale on piece ${piece.id}: ${copy.lines} lines behind source ${src.lines}`,
381
+ };
325
382
  }
326
383
  else if (copyEffects.lines < srcEffects.lines) {
327
- checks.push({
384
+ fail = {
328
385
  id: "evidence-copy",
329
386
  level: "fail",
330
- detail: `evidence copy is stale on effects: ${copyEffects.lines} effect line(s) behind source ${srcEffects.lines}`,
331
- });
332
- }
333
- else {
334
- checks.push({
335
- id: "evidence-copy",
336
- level: "ok",
337
- detail: `evidence copy has ${copy.lines} decision line(s) and ${copyEffects.lines} effect line(s)`,
338
- });
387
+ detail: `evidence copy is stale on piece ${piece.id} effects: ${copyEffects.lines} effect line(s) behind source ${srcEffects.lines}`,
388
+ };
339
389
  }
340
390
  }
391
+ if (anything) {
392
+ checks.push(fail ?? {
393
+ id: "evidence-copy",
394
+ level: "ok",
395
+ detail: `evidence copy has ${decisionLines} decision line(s) and ${effectLines} effect line(s)`,
396
+ });
397
+ }
341
398
  return checks;
342
399
  }
@@ -0,0 +1,28 @@
1
+ import type { Principal, ToolCall, ToolResult } from "@verax-ai/proxy";
2
+ export type DownstreamToolFn = (call: ToolCall, principal: Principal, ref?: string) => Promise<ToolResult>;
3
+ export type DownstreamSpec = {
4
+ prefix: string;
5
+ command: string;
6
+ args?: string[];
7
+ cwd?: string;
8
+ env?: Record<string, string>;
9
+ timeoutMs?: number;
10
+ };
11
+ export type DownstreamTool = {
12
+ name: string;
13
+ fn: DownstreamToolFn;
14
+ };
15
+ export type DownstreamSession = {
16
+ prefix: string;
17
+ tools: readonly DownstreamTool[];
18
+ close(): Promise<void>;
19
+ };
20
+ export declare class DownstreamCallError extends Error {
21
+ readonly prefix: string;
22
+ readonly tool: string;
23
+ constructor(prefix: string, tool: string, cause?: unknown);
24
+ }
25
+ export declare function prefixedName(prefix: string, childName: string): string;
26
+ export declare function extraToolNameOk(name: string): boolean;
27
+ export declare function parseDownstreamJson(raw: string): DownstreamSpec;
28
+ export declare function openDownstream(spec: DownstreamSpec): Promise<DownstreamSession>;