@sanoma-ai/workflows 0.11.17 → 0.11.18

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.
Files changed (94) hide show
  1. package/README.md +13 -9
  2. package/dist/call.d.ts +35 -4
  3. package/dist/call.js +128 -40
  4. package/dist/call.js.map +1 -1
  5. package/dist/client.d.ts +17 -8
  6. package/dist/client.js +90 -51
  7. package/dist/client.js.map +1 -1
  8. package/dist/collection.d.ts +25 -7
  9. package/dist/collection.js +236 -91
  10. package/dist/collection.js.map +1 -1
  11. package/dist/config.d.ts +8 -5
  12. package/dist/config.js +10 -10
  13. package/dist/config.js.map +1 -1
  14. package/dist/db/company.d.ts +20 -0
  15. package/dist/db/company.js +9 -1
  16. package/dist/db/company.js.map +1 -1
  17. package/dist/describe.js +1 -1
  18. package/dist/describe.js.map +1 -1
  19. package/dist/description.js +1 -1
  20. package/dist/description.js.map +1 -1
  21. package/dist/errors.d.ts +2 -1
  22. package/dist/errors.js +3 -2
  23. package/dist/errors.js.map +1 -1
  24. package/dist/grid.js +3 -2
  25. package/dist/grid.js.map +1 -1
  26. package/dist/host-derive.d.ts +13 -8
  27. package/dist/host-derive.js +26 -13
  28. package/dist/host-derive.js.map +1 -1
  29. package/dist/index.d.ts +2 -0
  30. package/dist/index.js +2 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/ledger.d.ts +7 -0
  33. package/dist/ledger.js +32 -2
  34. package/dist/ledger.js.map +1 -1
  35. package/dist/local-sandbox.d.ts +8 -1
  36. package/dist/local-sandbox.js +3 -2
  37. package/dist/local-sandbox.js.map +1 -1
  38. package/dist/op.d.ts +3 -2
  39. package/dist/op.js.map +1 -1
  40. package/dist/resources.d.ts +1 -1
  41. package/dist/resources.js +9 -6
  42. package/dist/resources.js.map +1 -1
  43. package/dist/runner-box.d.ts +23 -0
  44. package/dist/runner-box.js +181 -0
  45. package/dist/runner-box.js.map +1 -0
  46. package/dist/runner-protocol.d.ts +73 -0
  47. package/dist/runner-protocol.js +34 -0
  48. package/dist/runner-protocol.js.map +1 -0
  49. package/dist/sandbox-contract.d.ts +14 -5
  50. package/dist/sandbox-contract.js.map +1 -1
  51. package/dist/sandbox-runner.cjs +7 -0
  52. package/dist/scenario.js +16 -7
  53. package/dist/scenario.js.map +1 -1
  54. package/dist/schema.js +6 -3
  55. package/dist/schema.js.map +1 -1
  56. package/dist/secret.d.ts +2 -1
  57. package/dist/secret.js +3 -2
  58. package/dist/secret.js.map +1 -1
  59. package/dist/shared.d.ts +39 -5
  60. package/dist/shared.js +13 -1
  61. package/dist/shared.js.map +1 -1
  62. package/dist/sites.js +10 -8
  63. package/dist/sites.js.map +1 -1
  64. package/dist/source.d.ts +21 -0
  65. package/dist/source.js.map +1 -1
  66. package/drizzle/company/0003_ledger_target.sql +2 -0
  67. package/drizzle/company/meta/0003_snapshot.json +845 -0
  68. package/drizzle/company/meta/_journal.json +7 -0
  69. package/package.json +5 -2
  70. package/src/call.ts +181 -60
  71. package/src/client.ts +118 -67
  72. package/src/collection.ts +265 -97
  73. package/src/config.ts +20 -20
  74. package/src/db/company.ts +9 -1
  75. package/src/describe.ts +1 -1
  76. package/src/description.ts +1 -1
  77. package/src/errors.ts +3 -2
  78. package/src/grid.ts +3 -1
  79. package/src/host-derive.ts +38 -18
  80. package/src/index.ts +2 -0
  81. package/src/ledger.ts +42 -2
  82. package/src/local-sandbox.ts +3 -2
  83. package/src/op.ts +3 -2
  84. package/src/resources.ts +8 -6
  85. package/src/runner-box.ts +218 -0
  86. package/src/runner-protocol.ts +61 -0
  87. package/src/sandbox-contract.ts +11 -4
  88. package/src/sandbox-runner.ts +187 -0
  89. package/src/scenario.ts +16 -6
  90. package/src/schema.ts +14 -3
  91. package/src/secret.ts +6 -2
  92. package/src/shared.ts +40 -4
  93. package/src/sites.ts +11 -7
  94. package/src/source.ts +14 -0
package/src/grid.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  ANONYMOUS,
8
8
  ANONYMOUS_PRINCIPAL,
9
9
  groupNamesOf,
10
+ isBatch,
10
11
  previewRequests,
11
12
  VISITORS,
12
13
  visitorPrincipal,
@@ -71,7 +72,8 @@ export function permissionsGrid(
71
72
  ];
72
73
  const opsById = new Map(description.ops.map((op) => [op.id, op]));
73
74
  const workflows = description.workflows.filter((w) => !w.builtin);
74
- const direct = description.ops.filter((op) => op.direct).toSorted((a, b) => a.id.localeCompare(b.id));
75
+ // A batch has no row: the policy decides its entries, the save and the delete, which have theirs.
76
+ const direct = description.ops.filter((op) => op.direct && !isBatch(op)).toSorted((a, b) => a.id.localeCompare(b.id));
75
77
  // Every row's question, in the grid's order: each workflow's start, then the operations it may
76
78
  // call, as the run form asks them; then each direct operation.
77
79
  const asked = workflows.map((w) => previewRequests(w, opsById));
@@ -11,6 +11,7 @@ import { runtimeImage, sourceKeyId } from "./host.ts";
11
11
  import type { Bindings } from "./lockfile.ts";
12
12
  import { type Resource, valueAt } from "./resource.ts";
13
13
  import { secret, type Secret, type Secrets } from "./secret.ts";
14
+ import type { SecretConfiguration } from "./secret-store.ts";
14
15
  import { sourceKeyPair, sshPublicKey } from "./ssh-key.ts";
15
16
  import { mintInstanceToken, WAKEUP_TOKEN } from "./wakeup-service.ts";
16
17
 
@@ -78,8 +79,10 @@ export interface HostFacts {
78
79
  unbound?: ReadonlyMap<string, readonly string[]>;
79
80
  /** True when the config binds `SANOMA_SIGNING_KEY`: the host is given that key, else its own (`HOST_SIGNING_KEY`). */
80
81
  signingKeyBound?: boolean;
81
- /** Explicit environment delivery: destination variable to logical secret name, never values. */
82
+ /** Explicit environment delivery (`host: true`): destination variable to logical secret name, never values. */
82
83
  hostEnvironment?: Readonly<Record<string, string>>;
84
+ /** Of `hostEnvironment`'s logical names, those with no value where apply runs: the host cannot be given them. */
85
+ hostEnvironmentUnset?: readonly string[];
83
86
  /**
84
87
  * While the config has no deploy-key type, the GitHub repositories a host may clone (`origin`'s,
85
88
  * a declared `source`'s) that GitHub shows to no one: private (or not there). One GitHub does
@@ -227,8 +230,8 @@ const nameOf = (repository: string) =>
227
230
  * the caller leaves out, with what names them, so apply changes nothing it cannot give all it needs.
228
231
  * Problems: a host with no `database`, a name (none to tell, or another host's), an image or a
229
232
  * source that cannot be told, a credential its vendor's driver reads from this machine's
230
- * environment that nothing binds, a private repository it has no deploy key for, two hosts that
231
- * would attach the sites' domains.
233
+ * environment that nothing binds, a binding given to it (`host: true`) with no value where apply
234
+ * runs, a private repository it has no deploy key for, two hosts that would attach the sites' domains.
232
235
  */
233
236
  export function deriveHosts(
234
237
  resources: DeclaredResource[],
@@ -355,10 +358,16 @@ export function deriveHosts(
355
358
  const carried = [...vendors].flatMap((vendor) => facts.carried.get(vendor) ?? []);
356
359
  for (const name of [...carried, ...facts.githubApp]) secrets[name] = secret(name);
357
360
  for (const [environment, name] of Object.entries(facts.hostEnvironment ?? {})) {
358
- if (Object.hasOwn(secrets, environment) && secrets[environment]!.$secret !== name)
361
+ const derived = Object.hasOwn(secrets, environment);
362
+ if (derived && secrets[environment]!.$secret !== name)
359
363
  problem(`${environment} has a derived host binding and cannot be replaced by ${name}`);
360
- else if (!Object.hasOwn(secrets, environment))
361
- Object.defineProperty(secrets, environment, { value: secret(name), enumerable: true });
364
+ // Its binding is `{ env }` (`host` takes no other, and none is generated): the variable it is
365
+ // read from where apply runs. Unset, it fails the host's update, a derived one's too.
366
+ else if (facts.hostEnvironmentUnset?.includes(name))
367
+ problem(
368
+ `${name} (\`host: true\`) has no value where apply runs, so the host cannot be given it: set ${environment}, which its binding in sanoma.config.ts reads, or take \`host: true\` off that binding`,
369
+ );
370
+ else if (!derived) Object.defineProperty(secrets, environment, { value: secret(name), enumerable: true });
362
371
  }
363
372
  // One the driver reads here from the environment alone would be left off the host, and a
364
373
  // Worker updated without it would lose it.
@@ -440,8 +449,9 @@ export async function hostRootFacts(root: string): Promise<HostRootFacts> {
440
449
  * What the declared hosts that derive (`deriveHosts`) are filled from: the root's facts
441
450
  * (`rootFacts`, asked only when a declared host derives: `hostRootFacts`, or a snapshot's), and of
442
451
  * `secrets` (none outside a run) which
443
- * optional credentials, App keys and source key are stored, and which credentials the drivers
444
- * read from this machine's environment alone (`unbound`). With `mint` (apply, never drift or a
452
+ * optional credentials, App keys and source key are stored, which credentials the drivers
453
+ * read from this machine's environment alone (`unbound`), and which bindings given to the hosts
454
+ * (`host: true`) have no value (`hostEnvironmentUnset`). With `mint` (apply, never drift or a
445
455
  * sandbox run), the source key (where the config has the deploy-key type) and, when a wakeup is
446
456
  * declared, the wakeup's token are minted first when none is stored, so the host is given them
447
457
  * from its first apply. With no deploy-key type, GitHub is asked which repositories a host may clone are private.
@@ -454,32 +464,41 @@ export async function hostFacts(options: {
454
464
  drivers: ReadonlyMap<string, Driver>;
455
465
  secrets?: Secrets;
456
466
  builtInStore: boolean;
457
- /** True when the config binds `SANOMA_SIGNING_KEY` (`{ env }` or `{ bootstrap }`). */
458
- signingKeyBound?: boolean;
459
- hostEnvironment?: Readonly<Record<string, string>>;
467
+ /** The config's secret bindings: whether it binds `SANOMA_SIGNING_KEY`, and those given to the hosts (`host: true`). */
468
+ bindings?: SecretConfiguration["bindings"];
460
469
  sender?: string;
461
470
  mint: boolean;
471
+ /** A sandbox run's: its calls resolve no stored value, so a binding given to the hosts with none is no problem. */
472
+ sandbox?: boolean;
462
473
  }): Promise<HostFacts | undefined> {
463
474
  const { rootFacts, resources, types, drivers, secrets, mint } = options;
464
475
  const typeOf = (r: DeclaredResource) => types.get(`${r.vendor}.${r.type}`);
465
476
  const deriving = resources.filter((r) => typeOf(r)?.host?.derives);
466
477
  if (!deriving.length) return undefined;
467
478
  const hosts = deriving.filter((r) => !typeOf(r)?.host?.bindsBootstrap);
468
- // Which optional credentials and App keys are stored: read at once, and only for a host given secrets.
479
+ // The store is read only in a run, and only for a host given secrets.
480
+ const reading = secrets !== undefined && hosts.length > 0;
481
+ // Which optional credentials and App keys are stored: read at once.
469
482
  const stored = async (names: readonly string[]) =>
470
- secrets && hosts.length
483
+ reading
471
484
  ? new Set((await Promise.all(names.map(async (n) => ((await secrets.get(n)) === undefined ? [] : [n])))).flat())
472
485
  : new Set<string>();
473
486
  const statuses = [...drivers].flatMap(([vendor, { env }]) =>
474
487
  env ? statusesOf(env, () => undefined).map((c) => ({ vendor, ...c })) : [],
475
488
  );
489
+ const bindings = options.bindings ?? {};
490
+ const hostEnvironment: Record<string, string> = Object.fromEntries(
491
+ Object.entries(bindings).flatMap(([name, b]) => (b.host && b.env !== undefined ? [[b.env, name]] : [])),
492
+ );
493
+ const delivered = Object.values(hostEnvironment);
476
494
  // Only what the store holds: a marker for a name it does not would fail the host's deploy.
477
- const held = await stored(statuses.map((c) => c.name));
495
+ const held = await stored([...statuses.map((c) => c.name), ...delivered]);
496
+ const hostEnvironmentUnset = reading && !options.sandbox ? delivered.filter((name) => !held.has(name)) : [];
478
497
  const carried = new Map<string, string[]>();
479
498
  const unbound = new Map<string, string[]>();
480
499
  for (const c of statuses) {
481
500
  // The driver reads the environment first: set there alone, it runs here and the host is not given it.
482
- const into = held.has(c.name) ? carried : secrets && hosts.length && process.env[c.name] ? unbound : undefined;
501
+ const into = held.has(c.name) ? carried : reading && process.env[c.name] ? unbound : undefined;
483
502
  into?.set(c.vendor, [...(into.get(c.vendor) ?? []), c.name]);
484
503
  }
485
504
  const githubApp = [...(await stored(Object.values(GITHUB_APP)))];
@@ -487,7 +506,7 @@ export async function hostFacts(options: {
487
506
  const keyed = types.has(DEPLOY_KEY_TYPE);
488
507
  const [{ version, origin, domains }, sourceKey] = await Promise.all([
489
508
  rootFacts(),
490
- secrets && hosts.length && keyed ? sourceKeyOf(secrets, mint) : undefined,
509
+ reading && keyed ? sourceKeyOf(secrets, mint) : undefined,
491
510
  ]);
492
511
  // What a host may clone: `origin`, else the `source` it declares (with no deploy-key type, no
493
512
  // GitHub repository is declared to be one).
@@ -510,8 +529,9 @@ export async function hostFacts(options: {
510
529
  builtInStore: options.builtInStore,
511
530
  carried,
512
531
  ...(unbound.size > 0 && { unbound }),
513
- ...(options.signingKeyBound && { signingKeyBound: true }),
514
- ...(options.hostEnvironment && { hostEnvironment: options.hostEnvironment }),
532
+ ...(bindings.SANOMA_SIGNING_KEY !== undefined && { signingKeyBound: true }),
533
+ ...(delivered.length > 0 && { hostEnvironment }),
534
+ ...(hostEnvironmentUnset.length > 0 && { hostEnvironmentUnset }),
515
535
  ...(options.sender !== undefined && { sender: options.sender }),
516
536
  githubApp,
517
537
  ...(sourceKey !== undefined && { sourceKey }),
package/src/index.ts CHANGED
@@ -263,6 +263,8 @@ export {
263
263
  type SandboxSpec,
264
264
  } from "./sandbox-contract.ts";
265
265
  export { LOCAL_SANDBOX, localBackend } from "./local-sandbox.ts";
266
+ export { runnerBox, runnerHealth, runnerScript } from "./runner-box.ts";
267
+ export { RUNNER_HEALTH, RUNNER_PORT, RUNNER_PROTOCOL, type RunnerHealth } from "./runner-protocol.ts";
266
268
  export type { Chat, Chats } from "./chats.ts";
267
269
  export type { Tokens } from "./tokens.ts";
268
270
  export { type InviteRequest, openInvite, sealInvite, sealLoginCode } from "./seal.ts";
package/src/ledger.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import type { ConnectionSnapshot } from "./connection.ts";
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
3
  import { randomUUID } from "node:crypto";
4
- import { appendFile, mkdir, readFile, truncate } from "node:fs/promises";
4
+ import { appendFile, mkdir, readdir, readFile, truncate } from "node:fs/promises";
5
5
  import { join, resolve } from "node:path";
6
- import { eq, sql } from "drizzle-orm";
6
+ import { desc, eq, sql } from "drizzle-orm";
7
7
  import { setTimeout as delay } from "node:timers/promises";
8
8
  import { isDeepStrictEqual } from "node:util";
9
9
  import { type Db, lazyDb, pgCode } from "./db.ts";
@@ -48,6 +48,11 @@ export type LedgerRecord = {
48
48
  type: "op.called";
49
49
  connection?: ConnectionSnapshot;
50
50
  op: string;
51
+ /**
52
+ * The instance the call acted on (`data:outbound/Acme`), from the operation's `target`: what
53
+ * `readByTarget` finds the record by. Absent for an operation that declares none.
54
+ */
55
+ target?: string;
51
56
  /**
52
57
  * The outline node of the call that made it (`OutlineNode.path`): where the step is in the
53
58
  * code. Absent in records written before the worker placed calls (ledgers from 0.1).
@@ -234,6 +239,8 @@ export interface LedgerStore {
234
239
  append(record: LedgerRecord): Promise<void>;
235
240
  /** A run's records in `seq` order. */
236
241
  read(runId: string): Promise<LedgerRecord[]>;
242
+ /** The `op.called` records whose `target` it is, across every run, newest first, at most `limit`: a record's history. */
243
+ readByTarget(target: string, limit: number): Promise<LedgerRecord[]>;
237
244
  /**
238
245
  * Releases what the store holds (a connection pool), for good. Called by the one owner of the
239
246
  * store: the worker's `stop` and `SanomaClient.close` close the Postgres store each made for a
@@ -261,6 +268,13 @@ export function ownedLedger(config: { ledger?: LedgerStore; databaseUrl: string
261
268
 
262
269
  const bySeq = (a: LedgerRecord, b: LedgerRecord) => a.seq - b.seq;
263
270
 
271
+ /** The calls among `records` that acted on `target`, as `readByTarget` reads them: newest first, by `at`, then `seq`. */
272
+ const actedOn = (records: LedgerRecord[], target: string, limit: number): LedgerRecord[] =>
273
+ records
274
+ .filter((r) => r.type === "op.called" && r.target === target)
275
+ .toSorted((a, b) => b.at - a.at || b.seq - a.seq)
276
+ .slice(0, limit);
277
+
264
278
  /** An error that trying again would only repeat. */
265
279
  const final = (message: string, cause: unknown) => Object.assign(new Error(message, { cause }), { retryable: false });
266
280
 
@@ -364,6 +378,16 @@ export function jsonlLedger(dir: string): LedgerStore {
364
378
  // A case-insensitive file system can map two run ids to one file.
365
379
  return (await load(path)).records.filter((r) => r.runId === runId).toSorted(bySeq);
366
380
  },
381
+ async readByTarget(target, limit) {
382
+ await Promise.all([...queues.values()].map((pending) => pending.catch(() => {})));
383
+ const files = await readdir(root).catch((err: NodeJS.ErrnoException) => {
384
+ if (err.code === "ENOENT") return [];
385
+ throw err;
386
+ });
387
+ const runs = await Promise.all(files.filter((f) => f.endsWith(".jsonl")).map((f) => load(join(root, f))));
388
+ const records = runs.flatMap((run) => run.records);
389
+ return actedOn(records, target, limit);
390
+ },
367
391
  };
368
392
  }
369
393
 
@@ -398,6 +422,18 @@ export function postgresLedger(databaseUrl: string): LedgerStore & { close(): Pr
398
422
  // In seq order, as the other stores read; two records of one seq in write order.
399
423
  return rows.map((r) => r.record);
400
424
  },
425
+ async readByTarget(target, limit) {
426
+ const rows = await (
427
+ await store.db()
428
+ )
429
+ .select({ record: ledger.record })
430
+ .from(ledger)
431
+ .where(eq(ledger.target, target))
432
+ // Newest first, as the other stores read; two records of one `at` and `seq` in write order.
433
+ .orderBy(desc(sql`(${ledger.record} ->> 'at')::bigint`), desc(ledger.seq), desc(ledger.n))
434
+ .limit(limit);
435
+ return rows.map((r) => r.record);
436
+ },
401
437
  close: store.close,
402
438
  };
403
439
  }
@@ -438,6 +474,10 @@ export function memoryLedger(): LedgerStore {
438
474
  async read(runId) {
439
475
  return [...(runs.get(runId)?.values() ?? [])].map((r) => structuredClone(r)).toSorted(bySeq);
440
476
  },
477
+ async readByTarget(target, limit) {
478
+ const records = [...runs.values()].flatMap((run) => [...run.values()]);
479
+ return actedOn(records, target, limit).map((r) => structuredClone(r));
480
+ },
441
481
  };
442
482
  }
443
483
 
@@ -40,9 +40,10 @@ function boxDir(dir: string, name: string): string {
40
40
  /**
41
41
  * The box at `home`, over `node:child_process` and `node:fs`: a command is `bash -c` in a process
42
42
  * group of its own (killed whole by `kill`, and by `stop`), with `base` under the caller's `env`;
43
- * a relative path or `cwd` is under `home`, any other this machine's.
43
+ * a relative path or `cwd` is under `home`, any other this machine's. The runner in a remote box
44
+ * (`sandbox-runner.ts`) serves its own machine's.
44
45
  */
45
- function localBox(id: string, home: string, base: Record<string, string>): Box {
46
+ export function localBox(id: string, home: string, base: Record<string, string>): Box {
46
47
  /** The process groups its commands lead, killed by `stop` whether or not their leader has exited. */
47
48
  const groups = new Set<number>();
48
49
  const at = (path: string) => resolve(home, path);
package/src/op.ts CHANGED
@@ -365,8 +365,9 @@ export interface ConnectorSetup {
365
365
  remote?: () => RemoteHalf;
366
366
  /**
367
367
  * Makes the connector's sandbox backend, which opens the boxes of the type that meets the
368
- * `Sandbox` contract by name: by the app, when a coding agent's turn needs a box. It reads its
369
- * credentials through `context.secrets`.
368
+ * `Sandbox` contract by name: by the app, once per declared resource of that type, when a coding
369
+ * agent's turn needs a box. It reads its credentials through `context.secrets` and the
370
+ * resource's fields from `context.declared`.
370
371
  */
371
372
  sandbox?: (context: SandboxContext) => SandboxBackend | Promise<SandboxBackend>;
372
373
  }
package/src/resources.ts CHANGED
@@ -203,7 +203,7 @@ export function admit(
203
203
  * Each declaration is joined to its binding in the lockfile (`readLockfile`) by its key,
204
204
  * `<file>#<export>`; a binding no export declares is an orphan, unless its file failed to load.
205
205
  * Problems: the lockfile's, a binding of another type than its declaration's, two declarations
206
- * bound to one object, and several wakeups.
206
+ * bound to one object, and several wakeups or sandboxes.
207
207
  */
208
208
  export function loadDataFiles(
209
209
  root: string | undefined,
@@ -367,13 +367,15 @@ function declarationsOf(
367
367
  if (why === undefined) boundBy.set(object, id);
368
368
  else leaveOut(id, why);
369
369
  }
370
- // An instance registers with one wakeup: several are each a problem, and left out, as is any that names one.
371
- const wakeups = [...declarations.keys()].filter((id) => types.get(declarations.get(id)!.value[DECLARED])?.wakeup);
372
- if (wakeups.length > 1) {
373
- for (const id of wakeups) {
370
+ // An instance registers with one wakeup, and opens its coding agents' boxes in one sandbox: several
371
+ // of either are each a problem, and left out, as is any that names one.
372
+ for (const contract of ["wakeup", "sandbox"] as const) {
373
+ const ids = [...declarations.keys()].filter((id) => types.get(declarations.get(id)!.value[DECLARED])?.[contract]);
374
+ if (ids.length < 2) continue;
375
+ for (const id of ids) {
374
376
  leaveOut(
375
377
  id,
376
- `an instance has one wakeup: leave out this one or ${wakeups.filter((other) => other !== id).join(", ")}`,
378
+ `an instance has one ${contract}: leave out this one or ${ids.filter((other) => other !== id).join(", ")}`,
377
379
  );
378
380
  }
379
381
  }
@@ -0,0 +1,218 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { fileURLToPath } from "node:url";
3
+ import {
4
+ FILE,
5
+ frame,
6
+ RUNNER_HEALTH,
7
+ type RunnerAnswer,
8
+ type RunnerHealth,
9
+ type RunnerRequest,
10
+ STDERR,
11
+ STDIN,
12
+ unframe,
13
+ } from "./runner-protocol.ts";
14
+ import type { Box, Process } from "./sandbox-contract.ts";
15
+
16
+ // A box whose commands and files go through the runner (`sandbox-runner.ts`), a small server of
17
+ // ours a backend starts inside a remote box, over one WebSocket: so every vendor's backend is
18
+ // "open the box, start the runner, talk to it", whatever the vendor's own exec can or cannot
19
+ // stream. The protocol is in `runner-protocol.ts`. One connection carries many processes; a
20
+ // dropped one ends them here (exit 255) but kills nothing in the box.
21
+
22
+ /**
23
+ * The runner as one file (`dist/sandbox-runner.cjs`, its dependencies bundled in), which a
24
+ * backend writes into a box and starts with the box's `node` (22 or later).
25
+ */
26
+ export async function runnerScript(): Promise<Uint8Array> {
27
+ // `../dist/` from `src/` (the tests) and from `dist/` alike.
28
+ const at = new URL("../dist/sandbox-runner.cjs", import.meta.url);
29
+ return readFile(at).catch(() => {
30
+ throw new Error(`${fileURLToPath(at)} is missing: build @sanoma-ai/workflows`);
31
+ });
32
+ }
33
+
34
+ /**
35
+ * What the runner at `url` (asked with `headers`) answers at `RUNNER_HEALTH`, or undefined when nothing
36
+ * answers as a runner: a backend starts one then, and replaces one of another `protocol`.
37
+ */
38
+ export const runnerHealth = (url: string, headers?: Record<string, string>): Promise<RunnerHealth | undefined> =>
39
+ fetch(`${url}${RUNNER_HEALTH}`, { headers, signal: AbortSignal.timeout(5_000) })
40
+ .then(async (res) => {
41
+ const health = res.ok ? ((await res.json()) as Partial<RunnerHealth> | null) : null;
42
+ return typeof health?.protocol === "number" && typeof health.pid === "number"
43
+ ? { protocol: health.protocol, pid: health.pid }
44
+ : undefined;
45
+ })
46
+ .catch(() => undefined);
47
+
48
+ const encoder = new TextEncoder();
49
+
50
+ /** Drops a write or close the reader no longer wants (it cancelled). */
51
+ const ignore = () => {};
52
+
53
+ /** What an id of the connection waits for: a process's output and exit, or a request's answer. */
54
+ interface Waiter {
55
+ bytes(stream: number, bytes: Uint8Array): void;
56
+ answer(answer: RunnerAnswer): void;
57
+ /** The connection dropped. */
58
+ lost(): void;
59
+ }
60
+
61
+ /** An open connection to the runner, and what its ids wait for. */
62
+ interface Connection {
63
+ ws: WebSocket;
64
+ waiting: Map<number, Waiter>;
65
+ }
66
+
67
+ /** How long the runner's WebSocket may take to open, in ms. */
68
+ const OPEN_MS = 30_000;
69
+
70
+ /**
71
+ * The box `id` whose runner is at `url` (`http(s)://`, the WebSocket's upgrade sent with
72
+ * `headers`): its commands, files and `stop` (which kills every command in it and closes the
73
+ * connection). Connects at the first call, and again at the next call after a drop or a `stop`.
74
+ * How its ports are reached is the vendor's, so the backend adds `expose`.
75
+ */
76
+ export function runnerBox({
77
+ url,
78
+ headers,
79
+ id,
80
+ }: {
81
+ url: string;
82
+ headers?: Record<string, string>;
83
+ id: string;
84
+ }): Omit<Box, "expose"> {
85
+ const where = `the runner at ${new URL(url).host}`;
86
+ let current: Promise<Connection> | undefined;
87
+ let last = 0;
88
+
89
+ const connect = () => (current ??= open());
90
+ function open(): Promise<Connection> {
91
+ const opening = new Promise<Connection>((resolve, reject) => {
92
+ // Node's WebSocket (undici) takes `headers` beside the standard's protocols.
93
+ const ws = new WebSocket(url.replace(/^http/, "ws"), { headers } as unknown as string[]);
94
+ ws.binaryType = "arraybuffer";
95
+ const connection: Connection = { ws, waiting: new Map() };
96
+ const late = setTimeout(() => ws.close(), OPEN_MS);
97
+ ws.addEventListener("open", () => {
98
+ clearTimeout(late);
99
+ resolve(connection);
100
+ });
101
+ ws.addEventListener("message", ({ data }: { data: string | ArrayBuffer }) => {
102
+ try {
103
+ if (typeof data !== "string") {
104
+ const { id: of, stream, bytes } = unframe(new Uint8Array(data));
105
+ return connection.waiting.get(of)?.bytes(stream, bytes);
106
+ }
107
+ const answer = JSON.parse(data) as RunnerAnswer;
108
+ if (answer.id !== undefined) connection.waiting.get(answer.id)?.answer(answer);
109
+ } catch {
110
+ // A frame that is not the protocol's (another runner's) is dropped.
111
+ }
112
+ });
113
+ ws.addEventListener("close", ({ code, reason }) => {
114
+ clearTimeout(late);
115
+ if (current === opening) current = undefined;
116
+ reject(new Error(`${where} closed the connection (${reason || code})`));
117
+ for (const waiter of connection.waiting.values()) waiter.lost();
118
+ connection.waiting.clear();
119
+ });
120
+ });
121
+ return opening;
122
+ }
123
+
124
+ /** Sends `request` (and `bytes` after it): resolves with the file it answers, null for none, or nothing for `ok`. */
125
+ async function ask(
126
+ request: { op: "read"; path: string } | { op: "write"; path: string; mode?: number } | { op: "stop" },
127
+ bytes?: Uint8Array,
128
+ ): Promise<Uint8Array | null | undefined> {
129
+ const { ws, waiting } = await connect();
130
+ const n = ++last;
131
+ return new Promise((resolve, reject) => {
132
+ const settle = (done: () => void) => {
133
+ waiting.delete(n);
134
+ done();
135
+ };
136
+ waiting.set(n, {
137
+ bytes: (_, file) => settle(() => resolve(file)),
138
+ answer: (answer) =>
139
+ settle(() =>
140
+ answer.op === "error"
141
+ ? reject(new Error(`${where}: ${answer.message}`))
142
+ : resolve(answer.op === "absent" ? null : undefined),
143
+ ),
144
+ lost: () => reject(new Error(`${where} closed the connection`)),
145
+ });
146
+ ws.send(JSON.stringify({ ...request, id: n }));
147
+ if (bytes) ws.send(frame(n, FILE, bytes));
148
+ });
149
+ }
150
+
151
+ return {
152
+ id,
153
+ async run(command, { cwd, env, stdin = false, signal } = {}): Promise<Process> {
154
+ const { ws, waiting } = await connect();
155
+ const n = ++last;
156
+ const stdout = new TransformStream<Uint8Array, Uint8Array>();
157
+ const stderr = new TransformStream<Uint8Array, Uint8Array>();
158
+ const out = stdout.writable.getWriter();
159
+ const err = stderr.writable.getWriter();
160
+ const exited = new Promise<{ exitCode: number }>((done) => {
161
+ const end = (exitCode: number) => {
162
+ waiting.delete(n);
163
+ out.close().catch(ignore);
164
+ err.close().catch(ignore);
165
+ done({ exitCode });
166
+ };
167
+ waiting.set(n, {
168
+ bytes: (stream, bytes) => void (stream === STDERR ? err : out).write(bytes).catch(ignore),
169
+ answer(answer) {
170
+ if (answer.op === "exit") return end(answer.code);
171
+ if (answer.op !== "error") return;
172
+ err.write(encoder.encode(`${where}: ${answer.message}\n`)).catch(ignore);
173
+ end(127);
174
+ },
175
+ lost: () => end(255),
176
+ });
177
+ });
178
+ ws.send(JSON.stringify({ op: "run", id: n, command, cwd, env, stdin } satisfies RunnerRequest));
179
+ const kill = (sig: NodeJS.Signals = "SIGKILL") => {
180
+ if (waiting.has(n)) ws.send(JSON.stringify({ op: "kill", id: n, signal: sig } satisfies RunnerRequest));
181
+ };
182
+ const onAbort = () => kill();
183
+ if (signal?.aborted) onAbort();
184
+ signal?.addEventListener("abort", onAbort, { once: true });
185
+ void exited.then(() => signal?.removeEventListener("abort", onAbort));
186
+ const endInput = () => void ws.send(JSON.stringify({ op: "stdin-end", id: n } satisfies RunnerRequest));
187
+ return {
188
+ stdin: new WritableStream<Uint8Array>({
189
+ write(bytes) {
190
+ if (!stdin) throw new Error("its stdin is closed: run it with stdin: true");
191
+ ws.send(frame(n, STDIN, bytes));
192
+ },
193
+ close: endInput,
194
+ abort: endInput,
195
+ }),
196
+ stdout: stdout.readable,
197
+ stderr: stderr.readable,
198
+ wait: () => exited,
199
+ kill,
200
+ };
201
+ },
202
+ read: async (path) => (await ask({ op: "read", path })) ?? null,
203
+ async write(path, bytes, { mode } = {}) {
204
+ await ask({ op: "write", path, mode }, bytes);
205
+ },
206
+ async stop() {
207
+ // This connection, not one a call opens meanwhile after a drop.
208
+ const closing = connect();
209
+ try {
210
+ await ask({ op: "stop" });
211
+ } finally {
212
+ // The next call opens a new connection, not this closing one.
213
+ if (current === closing) current = undefined;
214
+ (await closing.catch(() => undefined))?.ws.close();
215
+ }
216
+ },
217
+ };
218
+ }
@@ -0,0 +1,61 @@
1
+ // The runner's protocol, over one WebSocket per box, shared by the runner (`sandbox-runner.ts`)
2
+ // and its client (`runner-box.ts`): text frames for control, as JSON, each carrying the `id` of
3
+ // the process or the request it is about; binary frames for bytes, `frame(id, stream, bytes)`.
4
+
5
+ /** The port the runner listens on in a box. */
6
+ export const RUNNER_PORT = 7700;
7
+
8
+ /**
9
+ * Where the runner answers its health: not `/health`, which Agent37's edge answers itself on
10
+ * every port, so that path never reaches what listens there.
11
+ */
12
+ export const RUNNER_HEALTH = "/runner/health";
13
+
14
+ /**
15
+ * The protocol's version, which the runner's health answers: a backend replaces a runner
16
+ * that answers another, one an earlier release started in a box that slept.
17
+ */
18
+ export const RUNNER_PROTOCOL = 2;
19
+
20
+ /** What the runner's health (`RUNNER_HEALTH`) answers: its protocol, and its pid, which a backend replacing it ends. */
21
+ export interface RunnerHealth {
22
+ protocol: number;
23
+ pid: number;
24
+ }
25
+
26
+ /** What a binary frame's bytes are: a process's stdin, stdout or stderr, or a file read or written. */
27
+ export const STDIN = 0;
28
+ export const STDOUT = 1;
29
+ export const STDERR = 2;
30
+ export const FILE = 3;
31
+
32
+ /** What the client says, as text. A `write`'s bytes follow it as the frame `(id, FILE)`. */
33
+ export type RunnerRequest =
34
+ | { op: "run"; id: number; command: string; cwd?: string; env?: Record<string, string>; stdin: boolean }
35
+ | { op: "stdin-end"; id: number }
36
+ | { op: "kill"; id: number; signal: NodeJS.Signals }
37
+ | { op: "read"; id: number; path: string }
38
+ | { op: "write"; id: number; path: string; mode?: number }
39
+ | { op: "stop"; id: number };
40
+
41
+ /** What the runner answers, as text. A `read` that finds a file answers the frame `(id, FILE)`. */
42
+ export type RunnerAnswer =
43
+ | { op: "exit"; id: number; code: number }
44
+ | { op: "absent"; id: number }
45
+ | { op: "ok"; id: number }
46
+ | { op: "error"; id?: number; message: string };
47
+
48
+ /** A binary frame: `id` (4 bytes), `stream` (1 byte), then `bytes`. */
49
+ export function frame(id: number, stream: number, bytes: Uint8Array): Uint8Array<ArrayBuffer> {
50
+ const framed = new Uint8Array(5 + bytes.length);
51
+ new DataView(framed.buffer).setUint32(0, id);
52
+ framed[4] = stream;
53
+ framed.set(bytes, 5);
54
+ return framed;
55
+ }
56
+
57
+ export const unframe = (framed: Uint8Array) => ({
58
+ id: new DataView(framed.buffer, framed.byteOffset, framed.byteLength).getUint32(0),
59
+ stream: framed[4]!,
60
+ bytes: framed.subarray(5),
61
+ });
@@ -80,14 +80,21 @@ export interface Box {
80
80
  * vendor keeps its state, so no snapshot handle crosses the contract.
81
81
  */
82
82
  export interface SandboxBackend {
83
- /** The box `name`, made from `image` with `size` vCPUs when there is none, woken or started when stopped. */
84
- open(opts: { name: string; image: string; size: number }): Promise<Box>;
83
+ /**
84
+ * The box `name`, made from `image` with `size` vCPUs when there is none (left out, the backend's
85
+ * own), woken or started when stopped.
86
+ */
87
+ open(opts: { name: string; image?: string; size?: number }): Promise<Box>;
85
88
  /** Deletes the box `name` and whatever the vendor kept of it: the next `open` finds it empty. */
86
89
  discard(opts: { name: string }): Promise<void>;
87
90
  }
88
91
 
89
- /** What the app gives a connector's sandbox half (`ConnectorSetup.sandbox`): the secrets, as a model half reads them. */
90
- export type SandboxContext = Pick<LoginContext, "secrets">;
92
+ /**
93
+ * What the app gives a connector's sandbox half (`ConnectorSetup.sandbox`): the secrets, as a model
94
+ * half reads them, and the declared fields of the resource whose boxes it opens, which hold the
95
+ * vendor's facts (its region, its type of machine) beside the image and size `open` takes.
96
+ */
97
+ export type SandboxContext = Pick<LoginContext, "secrets"> & { declared: Record<string, unknown> };
91
98
 
92
99
  /**
93
100
  * Throws what is wrong with a resource type's `sandbox`: a type apply cannot change (not