@boardwalk-labs/runner 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.
Files changed (97) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +68 -0
  3. package/dist/bin.d.ts +2 -0
  4. package/dist/bin.js +214 -0
  5. package/dist/contract.d.ts +154 -0
  6. package/dist/contract.js +196 -0
  7. package/dist/daemon/daemon.d.ts +45 -0
  8. package/dist/daemon/daemon.js +129 -0
  9. package/dist/daemon/identity.d.ts +15 -0
  10. package/dist/daemon/identity.js +44 -0
  11. package/dist/daemon/index.d.ts +3 -0
  12. package/dist/daemon/index.js +4 -0
  13. package/dist/daemon/pool_client.d.ts +31 -0
  14. package/dist/daemon/pool_client.js +101 -0
  15. package/dist/index.d.ts +1 -0
  16. package/dist/index.js +8 -0
  17. package/dist/runtime/agent/budget.d.ts +123 -0
  18. package/dist/runtime/agent/budget.js +174 -0
  19. package/dist/runtime/agent/events.d.ts +46 -0
  20. package/dist/runtime/agent/events.js +8 -0
  21. package/dist/runtime/agent/model_rates.d.ts +7 -0
  22. package/dist/runtime/agent/model_rates.js +21 -0
  23. package/dist/runtime/agent/secret_redactor.d.ts +42 -0
  24. package/dist/runtime/agent/secret_redactor.js +98 -0
  25. package/dist/runtime/broker_artifact_store.d.ts +17 -0
  26. package/dist/runtime/broker_artifact_store.js +46 -0
  27. package/dist/runtime/broker_child_dispatcher.d.ts +31 -0
  28. package/dist/runtime/broker_child_dispatcher.js +88 -0
  29. package/dist/runtime/broker_event_publisher.d.ts +25 -0
  30. package/dist/runtime/broker_event_publisher.js +69 -0
  31. package/dist/runtime/broker_tool_host.d.ts +49 -0
  32. package/dist/runtime/broker_tool_host.js +240 -0
  33. package/dist/runtime/cancel_watcher.d.ts +31 -0
  34. package/dist/runtime/cancel_watcher.js +77 -0
  35. package/dist/runtime/credit_watcher.d.ts +30 -0
  36. package/dist/runtime/credit_watcher.js +78 -0
  37. package/dist/runtime/direct_inference.d.ts +29 -0
  38. package/dist/runtime/direct_inference.js +78 -0
  39. package/dist/runtime/index.d.ts +85 -0
  40. package/dist/runtime/index.js +525 -0
  41. package/dist/runtime/inference_transport.d.ts +5 -0
  42. package/dist/runtime/inference_transport.js +9 -0
  43. package/dist/runtime/leaf_executor.d.ts +104 -0
  44. package/dist/runtime/leaf_executor.js +333 -0
  45. package/dist/runtime/lease_renewer.d.ts +31 -0
  46. package/dist/runtime/lease_renewer.js +80 -0
  47. package/dist/runtime/main.d.ts +1 -0
  48. package/dist/runtime/main.js +7 -0
  49. package/dist/runtime/phase_tracker.d.ts +26 -0
  50. package/dist/runtime/phase_tracker.js +45 -0
  51. package/dist/runtime/program_log_capture.d.ts +20 -0
  52. package/dist/runtime/program_log_capture.js +79 -0
  53. package/dist/runtime/program_runner.d.ts +82 -0
  54. package/dist/runtime/program_runner.js +116 -0
  55. package/dist/runtime/program_worker.d.ts +177 -0
  56. package/dist/runtime/program_worker.js +272 -0
  57. package/dist/runtime/recording_secret_resolver.d.ts +9 -0
  58. package/dist/runtime/recording_secret_resolver.js +22 -0
  59. package/dist/runtime/run_abort.d.ts +16 -0
  60. package/dist/runtime/run_abort.js +38 -0
  61. package/dist/runtime/run_event_emitter.d.ts +24 -0
  62. package/dist/runtime/run_event_emitter.js +63 -0
  63. package/dist/runtime/runner_control_client.d.ts +184 -0
  64. package/dist/runtime/runner_control_client.js +501 -0
  65. package/dist/runtime/runtime_flusher.d.ts +41 -0
  66. package/dist/runtime/runtime_flusher.js +90 -0
  67. package/dist/runtime/sandbox_config.d.ts +21 -0
  68. package/dist/runtime/sandbox_config.js +34 -0
  69. package/dist/runtime/support/index.d.ts +53 -0
  70. package/dist/runtime/support/index.js +114 -0
  71. package/dist/runtime/suspension.d.ts +141 -0
  72. package/dist/runtime/suspension.js +120 -0
  73. package/dist/runtime/testing_artifact_build.d.ts +27 -0
  74. package/dist/runtime/testing_artifact_build.js +96 -0
  75. package/dist/runtime/tools/artifacts.d.ts +146 -0
  76. package/dist/runtime/tools/artifacts.js +125 -0
  77. package/dist/runtime/tools/types.d.ts +114 -0
  78. package/dist/runtime/tools/types.js +61 -0
  79. package/dist/runtime/tools/web_search.d.ts +66 -0
  80. package/dist/runtime/tools/web_search.js +165 -0
  81. package/dist/runtime/wire/artifact_storage.d.ts +37 -0
  82. package/dist/runtime/wire/artifact_storage.js +93 -0
  83. package/dist/runtime/wire/artifact_verify.d.ts +1 -0
  84. package/dist/runtime/wire/artifact_verify.js +8 -0
  85. package/dist/runtime/wire/human_input.d.ts +62 -0
  86. package/dist/runtime/wire/human_input.js +156 -0
  87. package/dist/runtime/wire/inference_proxy.d.ts +70 -0
  88. package/dist/runtime/wire/inference_proxy.js +157 -0
  89. package/dist/runtime/wire/manifest.d.ts +16 -0
  90. package/dist/runtime/wire/manifest.js +18 -0
  91. package/dist/runtime/wire/run.d.ts +54 -0
  92. package/dist/runtime/wire/run.js +2 -0
  93. package/dist/runtime/workflow_host.d.ts +194 -0
  94. package/dist/runtime/workflow_host.js +382 -0
  95. package/dist/runtime/workspace_store.d.ts +73 -0
  96. package/dist/runtime/workspace_store.js +118 -0
  97. package/package.json +70 -0
@@ -0,0 +1,37 @@
1
+ /** S3 key for a workflow's persistent workspace snapshot. Scoped PER WORKFLOW (not per run), so the
2
+ * snapshot carries across every run of the workflow. Derived server-side from the run token's
3
+ * org + workflow, so a runner can only ever reach its own workflow's workspace. */
4
+ export declare function workspaceS3Key(orgId: string, workflowId: string): string;
5
+ /** Tag key/value the lifecycle rule filters on. */
6
+ export declare const RUN_ARTIFACT_RETENTION_TAG: {
7
+ key: string;
8
+ value: string;
9
+ };
10
+ /** The same tag as the URL-encoded query string S3's `Tagging` param / `x-amz-tagging` header want. */
11
+ export declare const RUN_ARTIFACT_RETENTION_TAGGING: string;
12
+ /** S3 key for a run's artifact. The prefix is the per-run isolation boundary the broker enforces:
13
+ * `orgs/{org}/runs/{run}/...` (ArtifactService.register re-checks it). */
14
+ export declare function artifactS3Key(orgId: string, runId: string, token: string, name: string, contentType: string): string;
15
+ /** Pick a file extension: prefer the name's own, else map the content type, else none. */
16
+ export declare function extFor(name: string, contentType: string): string;
17
+ /**
18
+ * Map an agent-supplied content type to one safe to serve inline from a Boardwalk origin. Active
19
+ * types are forced to text/plain so the browser renders them as inert text instead of executing
20
+ * embedded script (review #32). The base type (before any `;` params) is what's matched. The body is
21
+ * never altered — only the type the object is SERVED as.
22
+ *
23
+ * MUST run server-side (in the broker), never on the untrusted runner: the served content type is
24
+ * what the CDN returns, so the runner must not be able to choose an active type.
25
+ */
26
+ export declare function neutralizeActiveContentType(contentType: string): string;
27
+ /** Largest raw artifact body the broker will accept INLINE (proxied). Comfortably under the 5 MiB
28
+ * control-plane body cap so the base64 string + JSON envelope fit. Above this ⇒ presigned PUT. */
29
+ export declare const ARTIFACT_PROXY_MAX_BYTES: number;
30
+ /** Hard ceiling on a single artifact (presigned path included) — bounds runner + broker memory. */
31
+ export declare const ARTIFACT_MAX_BYTES: number;
32
+ /** True when a body must use the presigned-PUT path instead of the inline proxy (it's too big to
33
+ * buffer through the broker). Pure — the worker's BrokerArtifactStore routes on it. */
34
+ export declare function shouldPresignArtifact(byteLength: number): boolean;
35
+ /** Decode an artifact body string to its raw bytes (base64 → binary, else UTF-8). The decoded length
36
+ * is the artifact's true byte size — what the proxy/presign routing + the catalog row key off. */
37
+ export declare function decodeArtifactBody(body: string, encoding?: "utf8" | "base64"): Buffer;
@@ -0,0 +1,93 @@
1
+ // Artifact storage policy — the server-side rules for turning an agent's artifact-write request into
2
+ // a stored S3 object (MASTER_SPEC §13.6, review #32). These used to live in the `artifacts` TOOL,
3
+ // which runs on the UNTRUSTED runner; under the Runner Credential Broker (docs/RUNNER_BROKER.md) the
4
+ // broker owns them so a malicious runner can't bypass content-type neutralization or escape its
5
+ // run's key prefix. Pure functions — unit-tested exhaustively.
6
+ import * as nodePath from "node:path";
7
+ /** S3 key for a workflow's persistent workspace snapshot. Scoped PER WORKFLOW (not per run), so the
8
+ * snapshot carries across every run of the workflow. Derived server-side from the run token's
9
+ * org + workflow, so a runner can only ever reach its own workflow's workspace. */
10
+ export function workspaceS3Key(orgId, workflowId) {
11
+ return `orgs/${orgId}/workflows/${workflowId}/workspace.tar.gz`;
12
+ }
13
+ // ---- run-artifact retention tag (Phase 18.5 S3 lifecycle safety net) ----
14
+ //
15
+ // Run artifacts share the `orgs/{org}/...` prefix with workspace snapshots (`.../workflows/...`) and
16
+ // program artifacts (`.../program-artifacts/...`), and S3 lifecycle prefix filters can't express
17
+ // `orgs/*/runs/*` (the org id is a wildcard segment). So the safety-net rule keys off an OBJECT TAG
18
+ // applied at write time to run artifacts ONLY — never to workspace snapshots or program code, which
19
+ // must persist. The bucket's lifecycle rule (cdk/foundation/buckets.ts) expires objects carrying this
20
+ // tag after 365 days, catching orphans the reaper never sees (e.g. a large upload that never committed
21
+ // a catalog row). Tag at PUT time (inline + presigned) so an uncommitted orphan is still covered.
22
+ /** Tag key/value the lifecycle rule filters on. */
23
+ export const RUN_ARTIFACT_RETENTION_TAG = { key: "boardwalk-retention", value: "run-artifact" };
24
+ /** The same tag as the URL-encoded query string S3's `Tagging` param / `x-amz-tagging` header want. */
25
+ export const RUN_ARTIFACT_RETENTION_TAGGING = `${RUN_ARTIFACT_RETENTION_TAG.key}=${RUN_ARTIFACT_RETENTION_TAG.value}`;
26
+ /** S3 key for a run's artifact. The prefix is the per-run isolation boundary the broker enforces:
27
+ * `orgs/{org}/runs/{run}/...` (ArtifactService.register re-checks it). */
28
+ export function artifactS3Key(orgId, runId, token, name, contentType) {
29
+ return `orgs/${orgId}/runs/${runId}/${token}${extFor(name, contentType)}`;
30
+ }
31
+ const EXT_BY_CONTENT_TYPE = {
32
+ "application/json": ".json",
33
+ "text/plain": ".txt",
34
+ "text/csv": ".csv",
35
+ "text/markdown": ".md",
36
+ "text/html": ".html",
37
+ "application/pdf": ".pdf",
38
+ "image/png": ".png",
39
+ "image/jpeg": ".jpg",
40
+ };
41
+ /** Pick a file extension: prefer the name's own, else map the content type, else none. */
42
+ export function extFor(name, contentType) {
43
+ const fromName = nodePath.extname(name);
44
+ if (fromName !== "")
45
+ return fromName;
46
+ return EXT_BY_CONTENT_TYPE[contentType.toLowerCase()] ?? "";
47
+ }
48
+ /** Content types a browser renders as ACTIVE content (can run script) when navigated to. */
49
+ const ACTIVE_CONTENT_TYPES = new Set([
50
+ "text/html",
51
+ "application/xhtml+xml",
52
+ "image/svg+xml",
53
+ "application/xml",
54
+ "text/xml",
55
+ "text/javascript",
56
+ "application/javascript",
57
+ "application/ecmascript",
58
+ ]);
59
+ const NEUTRAL_CONTENT_TYPE = "text/plain; charset=utf-8";
60
+ /**
61
+ * Map an agent-supplied content type to one safe to serve inline from a Boardwalk origin. Active
62
+ * types are forced to text/plain so the browser renders them as inert text instead of executing
63
+ * embedded script (review #32). The base type (before any `;` params) is what's matched. The body is
64
+ * never altered — only the type the object is SERVED as.
65
+ *
66
+ * MUST run server-side (in the broker), never on the untrusted runner: the served content type is
67
+ * what the CDN returns, so the runner must not be able to choose an active type.
68
+ */
69
+ export function neutralizeActiveContentType(contentType) {
70
+ const base = (contentType.split(";")[0] ?? "").trim().toLowerCase();
71
+ return ACTIVE_CONTENT_TYPES.has(base) ? NEUTRAL_CONTENT_TYPE : contentType;
72
+ }
73
+ // ---- size policy: proxy small bodies through the broker, presign large ones straight to S3 ----
74
+ //
75
+ // The proxy path (`POST .../artifacts`) buffers the base64 body through the Runner Control body
76
+ // limit (5 MiB wire), so it's bounded WELL under that. Anything larger takes the presigned-PUT path
77
+ // (`POST .../artifacts/presign`, docs/RUNNER_BROKER.md §7 step 4): the broker signs an S3 PUT and the
78
+ // runner streams the bytes straight to S3, so the body never passes through the control plane.
79
+ /** Largest raw artifact body the broker will accept INLINE (proxied). Comfortably under the 5 MiB
80
+ * control-plane body cap so the base64 string + JSON envelope fit. Above this ⇒ presigned PUT. */
81
+ export const ARTIFACT_PROXY_MAX_BYTES = 4 * 1024 * 1024;
82
+ /** Hard ceiling on a single artifact (presigned path included) — bounds runner + broker memory. */
83
+ export const ARTIFACT_MAX_BYTES = 100 * 1024 * 1024;
84
+ /** True when a body must use the presigned-PUT path instead of the inline proxy (it's too big to
85
+ * buffer through the broker). Pure — the worker's BrokerArtifactStore routes on it. */
86
+ export function shouldPresignArtifact(byteLength) {
87
+ return byteLength > ARTIFACT_PROXY_MAX_BYTES;
88
+ }
89
+ /** Decode an artifact body string to its raw bytes (base64 → binary, else UTF-8). The decoded length
90
+ * is the artifact's true byte size — what the proxy/presign routing + the catalog row key off. */
91
+ export function decodeArtifactBody(body, encoding) {
92
+ return Buffer.from(body, encoding === "base64" ? "base64" : "utf8");
93
+ }
@@ -0,0 +1 @@
1
+ export declare function verifyArtifactDigest(bytes: Uint8Array, expectedDigest: string): boolean;
@@ -0,0 +1,8 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Content-addressed program verification (runner/CONTRACT.md invariant 3): the digest is
3
+ // checked BEFORE extraction; a mismatch aborts the run.
4
+ import { createHash } from "node:crypto";
5
+ export function verifyArtifactDigest(bytes, expectedDigest) {
6
+ const actual = createHash("sha256").update(bytes).digest("hex");
7
+ return actual === expectedDigest.toLowerCase();
8
+ }
@@ -0,0 +1,62 @@
1
+ import { z } from "zod";
2
+ import type { HumanInputResult, JsonValue } from "@boardwalk-labs/workflow";
3
+ /** The response form a gate presents — discriminated on `kind`. Mirrors SDK `HumanInputSpec` (the
4
+ * inferred output widens optionals to `T | undefined`; {@link parseHumanInputSpec} returns it). */
5
+ export declare const humanInputSpecSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
6
+ kind: z.ZodLiteral<"text">;
7
+ multiline: z.ZodOptional<z.ZodBoolean>;
8
+ placeholder: z.ZodOptional<z.ZodString>;
9
+ required: z.ZodOptional<z.ZodBoolean>;
10
+ }, z.core.$strip>, z.ZodObject<{
11
+ kind: z.ZodLiteral<"choice">;
12
+ options: z.ZodArray<z.ZodString>;
13
+ allowOther: z.ZodOptional<z.ZodBoolean>;
14
+ otherLabel: z.ZodOptional<z.ZodString>;
15
+ }, z.core.$strip>, z.ZodObject<{
16
+ kind: z.ZodLiteral<"multiselect">;
17
+ options: z.ZodArray<z.ZodString>;
18
+ allowOther: z.ZodOptional<z.ZodBoolean>;
19
+ otherLabel: z.ZodOptional<z.ZodString>;
20
+ min: z.ZodOptional<z.ZodNumber>;
21
+ max: z.ZodOptional<z.ZodNumber>;
22
+ }, z.core.$strip>], "kind">;
23
+ /** Parsed spec shape (optionals as `T | undefined`); a SDK `HumanInputSpec` is assignable to it. */
24
+ export type ParsedHumanInputSpec = z.infer<typeof humanInputSpecSchema>;
25
+ /** Parse an `unknown` (jsonb) spec into a typed spec. A tool-level gate may omit a spec entirely
26
+ * (the model just asked a free-text question) → text; a malformed/foreign spec also degrades to
27
+ * free text rather than stranding the gate unanswerable. */
28
+ export declare function parseHumanInputSpec(raw: unknown): ParsedHumanInputSpec;
29
+ /** Re-hydrate a stored (jsonb) human-input result into the exact-optional SDK {@link HumanInputResult}
30
+ * (the host returns it from a resumed `humanInput()` seam). The value was validated on submit, so a
31
+ * parse failure here is a corrupt journal — surfaced as a clear program error. */
32
+ export declare function normalizeHumanInputResult(raw: unknown): HumanInputResult;
33
+ /** Convert a validated {@link HumanInputResult} to a {@link JsonValue} for storage (the result is an
34
+ * interface with optional props, which TS won't structurally accept as JsonValue; an explicit object
35
+ * literal IS assignable to the index signature). The inverse of {@link normalizeHumanInputResult}. */
36
+ export declare function humanInputResultToJson(result: HumanInputResult): JsonValue;
37
+ /** What to do when a gate's `timeout` elapses with no answer (docs/SUSPENSION.md §4.3). Mirrors the
38
+ * SDK's `HumanInputOptions.onTimeout`: fail the run, or resolve the gate with a default value. */
39
+ export type OnTimeoutPolicy = {
40
+ kind: "fail";
41
+ } | {
42
+ kind: "value";
43
+ value: HumanInputResult;
44
+ };
45
+ /** Parse a stored (jsonb) `on_timeout` into a policy. NULL / unrecognized ⇒ the SDK default `fail`;
46
+ * a `{ value }` whose value doesn't validate as a result also degrades to `fail` (never strands). */
47
+ export declare function parseOnTimeout(raw: unknown): OnTimeoutPolicy;
48
+ /** A responder's raw submission, before validation against the gate's spec. */
49
+ export interface RawHumanInputSubmission {
50
+ /** Free text, or the chosen option / typed "other" value for a `choice` gate. */
51
+ value?: unknown;
52
+ /** Selected values for a `multiselect` gate. */
53
+ values?: unknown;
54
+ /** The typed freeform value for a `multiselect` "Other..." entry. */
55
+ other?: unknown;
56
+ }
57
+ /**
58
+ * Validate a raw submission against the gate's spec, producing the typed {@link HumanInputResult}
59
+ * the program receives. Throws `VALIDATION_FAILED` with a precise message on any mismatch — the
60
+ * respond surfaces turn that into a 400 so a bad answer never resumes the run with garbage.
61
+ */
62
+ export declare function validateHumanInputResponse(spec: ParsedHumanInputSpec, raw: RawHumanInputSubmission): HumanInputResult;
@@ -0,0 +1,156 @@
1
+ // Human-input form spec + response validation (docs/SUSPENSION.md §4.3).
2
+ //
3
+ // The `HumanInputSpec` is the single source of truth for BOTH the UI form and server-side
4
+ // validation, so a `humanInput()` gate needs no JSON Schema. A responder's raw submission (from
5
+ // REST / MCP / CLI / web) is validated here against the gate's stored spec into a typed
6
+ // `HumanInputResult`, which becomes the seam's memoized journal value. Pure + exhaustively testable.
7
+ //
8
+ // These mirror the SDK's `@boardwalk-labs/workflow` types exactly (the wire contract); we re-derive
9
+ // them as Zod schemas because the stored `input_spec` is `unknown` jsonb that must be parsed at the
10
+ // boundary, never trusted (CLAUDE.md: predicates/Zod, no casts).
11
+ import { z } from "zod";
12
+ import { AppError, ErrorCode } from "../support/index.js";
13
+ /** The response form a gate presents — discriminated on `kind`. Mirrors SDK `HumanInputSpec` (the
14
+ * inferred output widens optionals to `T | undefined`; {@link parseHumanInputSpec} returns it). */
15
+ export const humanInputSpecSchema = z.discriminatedUnion("kind", [
16
+ z.object({
17
+ kind: z.literal("text"),
18
+ multiline: z.boolean().optional(),
19
+ placeholder: z.string().optional(),
20
+ required: z.boolean().optional(),
21
+ }),
22
+ z.object({
23
+ kind: z.literal("choice"),
24
+ options: z.array(z.string()).min(1),
25
+ allowOther: z.boolean().optional(),
26
+ otherLabel: z.string().optional(),
27
+ }),
28
+ z.object({
29
+ kind: z.literal("multiselect"),
30
+ options: z.array(z.string()).min(1),
31
+ allowOther: z.boolean().optional(),
32
+ otherLabel: z.string().optional(),
33
+ min: z.number().int().nonnegative().optional(),
34
+ max: z.number().int().nonnegative().optional(),
35
+ }),
36
+ ]);
37
+ /** The validated per-kind result, for parsing a stored (jsonb) result back. */
38
+ const storedResultSchema = z.union([
39
+ z.object({ value: z.string(), isOther: z.boolean() }).strict(),
40
+ z.object({ value: z.string() }).strict(),
41
+ z.object({ values: z.array(z.string()), other: z.string().optional() }).strict(),
42
+ ]);
43
+ /** Parse an `unknown` (jsonb) spec into a typed spec. A tool-level gate may omit a spec entirely
44
+ * (the model just asked a free-text question) → text; a malformed/foreign spec also degrades to
45
+ * free text rather than stranding the gate unanswerable. */
46
+ export function parseHumanInputSpec(raw) {
47
+ if (raw === undefined || raw === null)
48
+ return { kind: "text" };
49
+ const parsed = humanInputSpecSchema.safeParse(raw);
50
+ return parsed.success ? parsed.data : { kind: "text" };
51
+ }
52
+ /** Re-hydrate a stored (jsonb) human-input result into the exact-optional SDK {@link HumanInputResult}
53
+ * (the host returns it from a resumed `humanInput()` seam). The value was validated on submit, so a
54
+ * parse failure here is a corrupt journal — surfaced as a clear program error. */
55
+ export function normalizeHumanInputResult(raw) {
56
+ const parsed = storedResultSchema.safeParse(raw);
57
+ if (!parsed.success) {
58
+ throw new AppError(ErrorCode.INTERNAL_ERROR, "Stored human-input result is malformed", {
59
+ kind: "human_input_corrupt",
60
+ });
61
+ }
62
+ const r = parsed.data;
63
+ if ("values" in r)
64
+ return r.other !== undefined ? { values: r.values, other: r.other } : { values: r.values };
65
+ if ("isOther" in r)
66
+ return { value: r.value, isOther: r.isOther };
67
+ return { value: r.value };
68
+ }
69
+ /** Convert a validated {@link HumanInputResult} to a {@link JsonValue} for storage (the result is an
70
+ * interface with optional props, which TS won't structurally accept as JsonValue; an explicit object
71
+ * literal IS assignable to the index signature). The inverse of {@link normalizeHumanInputResult}. */
72
+ export function humanInputResultToJson(result) {
73
+ if ("values" in result) {
74
+ return result.other !== undefined
75
+ ? { values: result.values, other: result.other }
76
+ : { values: result.values };
77
+ }
78
+ if ("isOther" in result)
79
+ return { value: result.value, isOther: result.isOther };
80
+ return { value: result.value };
81
+ }
82
+ /** Parse a stored (jsonb) `on_timeout` into a policy. NULL / unrecognized ⇒ the SDK default `fail`;
83
+ * a `{ value }` whose value doesn't validate as a result also degrades to `fail` (never strands). */
84
+ export function parseOnTimeout(raw) {
85
+ if (raw === null || raw === undefined || raw === "fail")
86
+ return { kind: "fail" };
87
+ if (typeof raw === "object" && "value" in raw) {
88
+ const result = storedResultSchema.safeParse(raw.value);
89
+ if (result.success)
90
+ return { kind: "value", value: normalizeHumanInputResult(raw.value) };
91
+ }
92
+ return { kind: "fail" };
93
+ }
94
+ /**
95
+ * Validate a raw submission against the gate's spec, producing the typed {@link HumanInputResult}
96
+ * the program receives. Throws `VALIDATION_FAILED` with a precise message on any mismatch — the
97
+ * respond surfaces turn that into a 400 so a bad answer never resumes the run with garbage.
98
+ */
99
+ export function validateHumanInputResponse(spec, raw) {
100
+ switch (spec.kind) {
101
+ case "text":
102
+ return validateText(spec, raw);
103
+ case "choice":
104
+ return validateChoice(spec, raw);
105
+ case "multiselect":
106
+ return validateMultiSelect(spec, raw);
107
+ }
108
+ }
109
+ function fail(message) {
110
+ throw new AppError(ErrorCode.VALIDATION_FAILED, message, { kind: "human_input_invalid" });
111
+ }
112
+ function validateText(spec, raw) {
113
+ if (typeof raw.value !== "string")
114
+ fail("This question expects a text `value`.");
115
+ const value = raw.value;
116
+ if (spec.required === true && value.trim().length === 0)
117
+ fail("A response is required.");
118
+ return { value };
119
+ }
120
+ function validateChoice(spec, raw) {
121
+ if (typeof raw.value !== "string")
122
+ fail("This question expects a single `value`.");
123
+ const value = raw.value;
124
+ const isOther = !spec.options.includes(value);
125
+ if (isOther && spec.allowOther === false) {
126
+ fail(`"${value}" is not one of the allowed options.`);
127
+ }
128
+ return { value, isOther };
129
+ }
130
+ function validateMultiSelect(spec, raw) {
131
+ if (!Array.isArray(raw.values) || !raw.values.every((v) => typeof v === "string")) {
132
+ fail("This question expects an array of string `values`.");
133
+ }
134
+ const values = raw.values;
135
+ for (const v of values) {
136
+ if (!spec.options.includes(v))
137
+ fail(`"${v}" is not one of the allowed options.`);
138
+ }
139
+ let other;
140
+ if (raw.other !== undefined) {
141
+ if (typeof raw.other !== "string")
142
+ fail("`other` must be a string.");
143
+ if (spec.allowOther === false)
144
+ fail("This question does not allow an open-text answer.");
145
+ if (raw.other.trim().length > 0)
146
+ other = raw.other;
147
+ }
148
+ const total = values.length + (other !== undefined ? 1 : 0);
149
+ if (spec.min !== undefined && total < spec.min) {
150
+ fail(`Select at least ${String(spec.min)} option(s).`);
151
+ }
152
+ if (spec.max !== undefined && total > spec.max) {
153
+ fail(`Select at most ${String(spec.max)} option(s).`);
154
+ }
155
+ return other !== undefined ? { values, other } : { values };
156
+ }
@@ -0,0 +1,70 @@
1
+ import type { ChatMessage, ChatTurn, ToolSpec } from "@boardwalk-labs/engine/core";
2
+ import type { NormalizedReasoning } from "@boardwalk-labs/workflow";
3
+ /** `POST /runner/v1/runs/{run_id}/inference` (run-token authed; the addressed run must match). */
4
+ export declare const RUNNER_INFERENCE_PATH_RE: RegExp;
5
+ /** Build the inference path for a run. */
6
+ export declare function runnerInferencePath(runId: string): string;
7
+ /** Response content type — newline-delimited JSON frames. */
8
+ export declare const INFERENCE_NDJSON_CONTENT_TYPE = "application/x-ndjson";
9
+ /**
10
+ * The request body the worker POSTs to `/inference` — one neutral model turn (the engine's
11
+ * `ModelTurnRequest`, minus the seam: no endpoint, no key). `model`/`provider` are the `agent()`
12
+ * call's (both opaque, both optional); the broker resolves them SERVER-SIDE against the run's org
13
+ * (the orgId comes from the verified run token, never the body — so this can't reach another org).
14
+ */
15
+ export interface InferenceProxyRequest {
16
+ /** The model id, as the agent() call gave it (opaque, passed verbatim). Undefined ⇒ provider routes. */
17
+ model: string | undefined;
18
+ /** The provider the agent() call named (undefined ⇒ the managed `boardwalk` lane). */
19
+ provider: string | undefined;
20
+ /** The conversation so far. */
21
+ messages: readonly ChatMessage[];
22
+ /** The tools advertised to the model this turn. */
23
+ tools: readonly ToolSpec[];
24
+ /** Normalized reasoning-effort control (the agent() call's `AgentOptions.reasoning`, undefined ⇒
25
+ * provider default). The broker encodes it into the resolved provider's wire body per protocol. */
26
+ reasoning?: NormalizedReasoning;
27
+ }
28
+ /** A broker/model error surfaced as the terminal frame so the worker's stream throws. */
29
+ export interface ProxyError {
30
+ code: string;
31
+ message: string;
32
+ }
33
+ /** A parsed response frame. `ping` is a no-payload heartbeat the broker emits during a long model
34
+ * turn to keep the connection producing bytes (so idle/body timeouts don't sever it); the worker
35
+ * ignores it. */
36
+ export type InferenceFrame = {
37
+ kind: "delta";
38
+ text: string;
39
+ } | {
40
+ kind: "result";
41
+ turn: ChatTurn;
42
+ modelRef: string;
43
+ costMicros: number;
44
+ } | {
45
+ kind: "error";
46
+ error: ProxyError;
47
+ } | {
48
+ kind: "ping";
49
+ };
50
+ /** Serialize the request body. (Binary message content — multimodal image/doc bytes — is not yet
51
+ * supported on this path; v0 agent flows are text + JSON tool results.) */
52
+ export declare function serializeInferenceRequest(req: InferenceProxyRequest): string;
53
+ /** Parse + minimally validate the request body (the runner is semi-trusted; fail closed on shape).
54
+ * The conversation is shape-trusted past the array check — it was built by the engine loop on the
55
+ * runner from the broker's own prior frames, and the broker's adapters re-render every field. */
56
+ export declare function parseInferenceRequest(body: string): InferenceProxyRequest;
57
+ /** Serialize one streamed text delta as a single NDJSON line (trailing "\n" included). */
58
+ export declare function serializeDeltaFrame(text: string): string;
59
+ /** Serialize the single terminal turn result as one NDJSON line. `costMicros` is the turn's EXACT
60
+ * upstream cost (OpenRouter `usage.cost` × 1e6) on the managed lane — 0 for BYO or when unavailable.
61
+ * The worker feeds it to the budget guardrail so `max_usd` tracks real spend, not a token estimate. */
62
+ export declare function serializeResultFrame(turn: ChatTurn, modelRef: string, costMicros?: number): string;
63
+ /** Serialize a terminal error as a single NDJSON line. */
64
+ export declare function serializeErrorFrame(error: ProxyError): string;
65
+ /** Serialize a heartbeat (no payload) as a single NDJSON line. The broker emits these on an interval
66
+ * during a long model turn so the worker↔broker connection keeps producing bytes — neither side's
67
+ * idle/body timeout fires while the model is generating but not yet streaming text. */
68
+ export declare function serializeHeartbeatFrame(): string;
69
+ /** Parse one NDJSON frame line. Throws on a malformed/unknown frame (the line must be non-empty). */
70
+ export declare function parseInferenceFrame(line: string): InferenceFrame;
@@ -0,0 +1,157 @@
1
+ // Inference proxy protocol (docs/RUNNER_BROKER.md §4 — "Inference (class 2)").
2
+ //
3
+ // The runner never invokes a model directly: under the Runner Credential Broker it holds no
4
+ // managed-inference creds and no BYO provider key. Instead the `agent()` leaf's model turn (the
5
+ // engine's `LeafIo.streamModel` seam) POSTs the conversation to the broker's `/inference`
6
+ // endpoint; the broker resolves the REAL model server-side (managed under the broker's creds, or a
7
+ // BYO key it reads from Secrets Manager), invokes the matching engine adapter, and streams the
8
+ // model's text back so the loop can show it live.
9
+ //
10
+ // This module is the shared wire contract for that exchange — imported by both the worker client
11
+ // (serialize request / parse response frames) and the broker handler (parse request / serialize
12
+ // response frames), so the two can never drift.
13
+ //
14
+ // Transport: the response is **newline-delimited JSON** (one frame per line). A model turn streams
15
+ // many text deltas; NDJSON lets the runner consume them incrementally over the raw socket (the
16
+ // buffered REST path can't stream — MASTER_SPEC §4.2). Each frame is one of:
17
+ // { "t": "delta", "text": <string> } — a streamed assistant-text chunk
18
+ // { "t": "result", "turn": <ChatTurn>, "modelRef": <string>, "costMicros": <number> } — terminal turn
19
+ // { "t": "error", "error": { code, message } } — a terminal model/broker error
20
+ //
21
+ // A well-formed stream is zero-or-more `delta` frames followed by EXACTLY ONE `result` frame, OR a
22
+ // single terminal `error` frame. The neutral conversation shapes (ChatMessage / ChatTurn / ToolSpec)
23
+ // are the engine's `@boardwalk-labs/engine/core` types — so the worker's loop and the broker's
24
+ // adapters speak the same wire as `boardwalk dev` and the self-hosted server (one agent loop).
25
+ /** `POST /runner/v1/runs/{run_id}/inference` (run-token authed; the addressed run must match). */
26
+ export const RUNNER_INFERENCE_PATH_RE = /^\/runner\/v1\/runs\/([^/]+)\/inference$/;
27
+ /** Build the inference path for a run. */
28
+ export function runnerInferencePath(runId) {
29
+ return `/runner/v1/runs/${runId}/inference`;
30
+ }
31
+ /** Response content type — newline-delimited JSON frames. */
32
+ export const INFERENCE_NDJSON_CONTENT_TYPE = "application/x-ndjson";
33
+ // ---- request serialize / parse ----
34
+ /** Serialize the request body. (Binary message content — multimodal image/doc bytes — is not yet
35
+ * supported on this path; v0 agent flows are text + JSON tool results.) */
36
+ export function serializeInferenceRequest(req) {
37
+ return JSON.stringify(req);
38
+ }
39
+ /** Parse + minimally validate the request body (the runner is semi-trusted; fail closed on shape).
40
+ * The conversation is shape-trusted past the array check — it was built by the engine loop on the
41
+ * runner from the broker's own prior frames, and the broker's adapters re-render every field. */
42
+ export function parseInferenceRequest(body) {
43
+ const parsed = JSON.parse(body);
44
+ const rec = asRecord(parsed);
45
+ if (rec === null)
46
+ throw new Error("Inference request must be a JSON object");
47
+ if (rec.model !== undefined && typeof rec.model !== "string") {
48
+ throw new Error("Inference request `model` must be a string when present");
49
+ }
50
+ if (rec.provider !== undefined && typeof rec.provider !== "string") {
51
+ throw new Error("Inference request `provider` must be a string when present");
52
+ }
53
+ if (!Array.isArray(rec.messages)) {
54
+ throw new Error("Inference request requires a messages array");
55
+ }
56
+ if (!Array.isArray(rec.tools)) {
57
+ throw new Error("Inference request requires a tools array");
58
+ }
59
+ return {
60
+ model: typeof rec.model === "string" ? rec.model : undefined,
61
+ provider: typeof rec.provider === "string" ? rec.provider : undefined,
62
+ // The engine's ChatMessage/ToolSpec are plain JSON-shaped data (no class instances) — they
63
+ // round-trip through JSON unchanged, so the parsed arrays ARE the wire shapes.
64
+ messages: rec.messages,
65
+ tools: rec.tools,
66
+ // Reasoning is small JSON the engine built via `normalizeReasoning` on the runner; pass it
67
+ // through when present (an object), and let the broker's adapters render its fields. A
68
+ // non-object is dropped — the provider default then applies (no reasoning steering).
69
+ ...(asRecord(rec.reasoning) !== null
70
+ ? { reasoning: rec.reasoning }
71
+ : {}),
72
+ };
73
+ }
74
+ // ---- response frame serialize / parse ----
75
+ /** Serialize one streamed text delta as a single NDJSON line (trailing "\n" included). */
76
+ export function serializeDeltaFrame(text) {
77
+ return `${JSON.stringify({ t: "delta", text })}\n`;
78
+ }
79
+ /** Serialize the single terminal turn result as one NDJSON line. `costMicros` is the turn's EXACT
80
+ * upstream cost (OpenRouter `usage.cost` × 1e6) on the managed lane — 0 for BYO or when unavailable.
81
+ * The worker feeds it to the budget guardrail so `max_usd` tracks real spend, not a token estimate. */
82
+ export function serializeResultFrame(turn, modelRef, costMicros = 0) {
83
+ return `${JSON.stringify({ t: "result", turn, modelRef, costMicros })}\n`;
84
+ }
85
+ /** Serialize a terminal error as a single NDJSON line. */
86
+ export function serializeErrorFrame(error) {
87
+ return `${JSON.stringify({ t: "error", error })}\n`;
88
+ }
89
+ /** Serialize a heartbeat (no payload) as a single NDJSON line. The broker emits these on an interval
90
+ * during a long model turn so the worker↔broker connection keeps producing bytes — neither side's
91
+ * idle/body timeout fires while the model is generating but not yet streaming text. */
92
+ export function serializeHeartbeatFrame() {
93
+ return `${JSON.stringify({ t: "ping" })}\n`;
94
+ }
95
+ /** Parse one NDJSON frame line. Throws on a malformed/unknown frame (the line must be non-empty). */
96
+ export function parseInferenceFrame(line) {
97
+ const parsed = JSON.parse(line);
98
+ const rec = asRecord(parsed);
99
+ if (rec === null)
100
+ throw new Error("Malformed inference frame");
101
+ switch (rec.t) {
102
+ case "delta":
103
+ return { kind: "delta", text: typeof rec.text === "string" ? rec.text : "" };
104
+ case "result":
105
+ return {
106
+ kind: "result",
107
+ turn: toChatTurn(rec.turn),
108
+ modelRef: toModelRef(rec.modelRef),
109
+ // A pre-cost-forwarding broker (rolling deploy) omits this → 0 → the worker falls back to the
110
+ // representative-rate estimate, exactly as before. Non-number / negative also clamps to 0.
111
+ costMicros: typeof rec.costMicros === "number" && rec.costMicros > 0 ? rec.costMicros : 0,
112
+ };
113
+ case "error":
114
+ return { kind: "error", error: toProxyError(rec.error) };
115
+ case "ping":
116
+ return { kind: "ping" };
117
+ default:
118
+ throw new Error(`Unknown inference frame kind: ${String(rec.t)}`);
119
+ }
120
+ }
121
+ /** Narrow a parsed `result` frame's turn back to the engine's `ChatTurn`, failing closed on shape —
122
+ * the worker's loop relies on `toolCalls`/`usage`/`wantsTools` being present and well-typed. */
123
+ function toChatTurn(value) {
124
+ const rec = asRecord(value);
125
+ if (rec === null)
126
+ throw new Error("Inference result frame has no turn");
127
+ if (typeof rec.text !== "string")
128
+ throw new Error("Inference result turn missing text");
129
+ if (!Array.isArray(rec.toolCalls))
130
+ throw new Error("Inference result turn missing toolCalls");
131
+ const usage = asRecord(rec.usage) ?? {};
132
+ return {
133
+ text: rec.text,
134
+ toolCalls: rec.toolCalls,
135
+ usage: {
136
+ ...(typeof usage.inputTokens === "number" ? { inputTokens: usage.inputTokens } : {}),
137
+ ...(typeof usage.outputTokens === "number" ? { outputTokens: usage.outputTokens } : {}),
138
+ },
139
+ wantsTools: rec.wantsTools === true,
140
+ };
141
+ }
142
+ function toModelRef(value) {
143
+ if (typeof value !== "string" || value.length === 0) {
144
+ throw new Error("Inference result frame missing modelRef");
145
+ }
146
+ return value;
147
+ }
148
+ function toProxyError(value) {
149
+ const rec = asRecord(value);
150
+ const code = rec !== null && typeof rec.code === "string" ? rec.code : "INTERNAL_ERROR";
151
+ const message = rec !== null && typeof rec.message === "string" ? rec.message : "Inference failed";
152
+ return { code, message };
153
+ }
154
+ /** A plain object with string keys, or null. Avoids unsafe casts on parsed JSON. */
155
+ function asRecord(value) {
156
+ return typeof value === "object" && value !== null ? value : null;
157
+ }
@@ -0,0 +1,16 @@
1
+ import type { SecretRef, WorkflowManifest } from "@boardwalk-labs/workflow";
2
+ export { workflowManifestSchema } from "@boardwalk-labs/workflow";
3
+ export type { WorkflowManifest } from "@boardwalk-labs/workflow";
4
+ /** The manifest's zod-derived budget shape (matches the platform's `domain/models` alias). */
5
+ export type Budget = NonNullable<WorkflowManifest["budget"]>;
6
+ /** Pre-rename alias — runtime code (secret resolvers, tool context) imports this name. */
7
+ export type SecretRefManifest = SecretRef;
8
+ /** The secret name a whole-value `${{ secrets.NAME }}` env value references, or null. */
9
+ export declare function parseSecretEnvRef(value: string): string | null;
10
+ /**
11
+ * The secret names a run may resolve — mirrors the broker's server-side allowlist
12
+ * (`permissions.secrets` exactly; see the backend's domain/models/manifest.ts).
13
+ */
14
+ export declare function effectiveSecretAllowlist(manifest: Pick<WorkflowManifest, "permissions">): {
15
+ name: string;
16
+ }[];
@@ -0,0 +1,18 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ export { workflowManifestSchema } from "@boardwalk-labs/workflow";
3
+ /** The ONLY supported interpolation: a whole-value `${{ secrets.NAME }}` reference. */
4
+ const SECRET_ENV_REF_RE = /^\$\{\{\s*secrets\.([A-Za-z0-9_-]+)\s*\}\}$/;
5
+ /** The secret name a whole-value `${{ secrets.NAME }}` env value references, or null. */
6
+ export function parseSecretEnvRef(value) {
7
+ return SECRET_ENV_REF_RE.exec(value.trim())?.[1] ?? null;
8
+ }
9
+ /**
10
+ * The secret names a run may resolve — mirrors the broker's server-side allowlist
11
+ * (`permissions.secrets` exactly; see the backend's domain/models/manifest.ts).
12
+ */
13
+ export function effectiveSecretAllowlist(manifest) {
14
+ const names = new Set();
15
+ for (const ref of manifest.permissions?.secrets ?? [])
16
+ names.add(ref.name);
17
+ return [...names].map((name) => ({ name }));
18
+ }