@polycore/runner 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/config.ts ADDED
@@ -0,0 +1,289 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ import { z } from "zod";
4
+
5
+ /**
6
+ * Firestore credentials for one environment of one project: the GCP project
7
+ * plus the path to a **read-only** service-account key
8
+ * (`roles/datastore.viewer`), the Layer-1 credential that the datastore
9
+ * itself guarantees cannot mutate.
10
+ */
11
+ export const FirestoreEnvironmentSchema = z.object({
12
+ /** GCP project id the environment maps to. */
13
+ projectId: z.string().min(1),
14
+ /**
15
+ * Absolute path to the read-only service-account JSON key for this
16
+ * environment. The key never leaves the runner's host. **Optional**: when
17
+ * omitted, the connector falls back to Application Default Credentials, the
18
+ * idiomatic path when the runner runs inside GCP, where the runtime service
19
+ * account supplies the (read-only) credential and no key file is shipped.
20
+ */
21
+ serviceAccountKeyPath: z.string().min(1).optional(),
22
+ });
23
+
24
+ export type FirestoreEnvironment = z.infer<typeof FirestoreEnvironmentSchema>;
25
+
26
+ /**
27
+ * One environment of one project, namespaced per connector family so a
28
+ * single environment can carry credentials for several backends (Firestore
29
+ * today; SQL, HTTP APIs like Stripe/RevenueCat, etc. as they land). A
30
+ * built-in connector is advertised for a project only when at least one of
31
+ * the project's environments configures its family.
32
+ */
33
+ export const EnvironmentConfigSchema = z.object({
34
+ firestore: FirestoreEnvironmentSchema.optional(),
35
+ });
36
+
37
+ export type EnvironmentConfig = z.infer<typeof EnvironmentConfigSchema>;
38
+
39
+ /**
40
+ * One project (product/system) this runner serves, e.g. a company's two
41
+ * products are two projects. Each project has its own environments and
42
+ * credentials, its own customer-authored actions, and optionally an
43
+ * agent-context document (e.g. its database schema) advertised to the
44
+ * control plane.
45
+ */
46
+ export const ProjectConfigSchema = z
47
+ .object({
48
+ /** Environment name (`dev`, `staging`, `prod`, …) → its credentials. */
49
+ environments: z
50
+ .record(z.string(), EnvironmentConfigSchema)
51
+ .refine((envs) => Object.keys(envs).length > 0, {
52
+ message: "At least one environment must be configured.",
53
+ }),
54
+ /** Local-CLI default for `runner <capability>` runs. The control plane
55
+ * keeps its own per-project default for front-door asks. */
56
+ defaultEnvironment: z.string().min(1).optional(),
57
+ /**
58
+ * Absolute path to a directory of customer-authored actions for this
59
+ * project (one folder per action, each with an `action.ts`). Loaded and
60
+ * served alongside the built-in connectors.
61
+ */
62
+ actionsDir: z.string().min(1).optional(),
63
+ /**
64
+ * Secret values for this project's actions, keyed by the names actions
65
+ * declare in `secrets`. Missing keys fall back to the runner-level
66
+ * `secrets`, then `process.env`.
67
+ */
68
+ secrets: z.record(z.string(), z.string()).optional(),
69
+ /**
70
+ * Agent grounding for this project (markdown/plain text, e.g. the
71
+ * product's database schema). Advertised to the control plane at
72
+ * enrollment and injected into the agent's system prompt when this
73
+ * project is in scope. Use `contextFile` for anything longer than a line.
74
+ */
75
+ context: z.string().optional(),
76
+ /** Absolute path to a file holding the agent context (wins over `context`). */
77
+ contextFile: z.string().min(1).optional(),
78
+ })
79
+ .refine((p) => p.context === undefined || p.contextFile === undefined, {
80
+ message: 'Set "context" or "contextFile", not both.',
81
+ });
82
+
83
+ export type ProjectConfig = z.infer<typeof ProjectConfigSchema>;
84
+
85
+ /**
86
+ * How the runner reaches the control plane. Provisioned at enrollment; the
87
+ * `signingSecret` is shared out-of-band (never sent on the wire) so the
88
+ * runner can verify dispatch envelopes.
89
+ */
90
+ export const ControlPlaneConfigSchema = z.object({
91
+ /** Control-plane WebSocket URL (`ws://…` locally, `wss://…` in prod). */
92
+ url: z.string().min(1),
93
+ /** This runner's id (`rnr_...` from the enroll response). */
94
+ runnerId: z.string().min(1),
95
+ /** One-time-ish enrollment secret presented on join. */
96
+ joinToken: z.string().min(1),
97
+ /** Shared HMAC secret used to verify dispatch envelopes. */
98
+ signingSecret: z.string().min(1),
99
+ });
100
+
101
+ export type ControlPlaneConfig = z.infer<typeof ControlPlaneConfigSchema>;
102
+
103
+ /**
104
+ * The runner's configuration: which projects it serves (each with its own
105
+ * environments/credentials/actions), and optionally how to reach the
106
+ * control plane. One runner serves 1..N projects of ONE organization;
107
+ * data separation across products is per-project config, not per-process.
108
+ */
109
+ export const RunnerConfigSchema = z.object({
110
+ projects: z
111
+ .record(z.string(), ProjectConfigSchema)
112
+ .refine((projects) => Object.keys(projects).length > 0, {
113
+ message: "At least one project must be configured.",
114
+ }),
115
+ controlPlane: ControlPlaneConfigSchema.optional(),
116
+ /**
117
+ * Runner-level secret fallback for action secrets, shared by every
118
+ * project. Per-project `secrets` win. Missing keys fall back to
119
+ * `process.env`. These never reach the control plane.
120
+ */
121
+ secrets: z.record(z.string(), z.string()).optional(),
122
+ });
123
+
124
+ export type RunnerConfig = z.infer<typeof RunnerConfigSchema>;
125
+
126
+ /** Env var the CLI falls back to when no explicit config path is given. */
127
+ export const RUNNER_CONFIG_ENV = "POLYCORE_RUNNER_CONFIG";
128
+
129
+ /** Parse and validate a runner config from an already-read string. */
130
+ export function parseRunnerConfig(json: string): RunnerConfig {
131
+ const data: unknown = JSON.parse(json);
132
+ return RunnerConfigSchema.parse(data);
133
+ }
134
+
135
+ /** Read and validate a runner config from a JSON file path. */
136
+ export function loadRunnerConfig(path: string): RunnerConfig {
137
+ return parseRunnerConfig(readFileSync(path, "utf8"));
138
+ }
139
+
140
+ /** Env var carrying the projects map (JSON) for env-driven config. */
141
+ export const RUNNER_PROJECTS_ENV = "POLYCORE_PROJECTS";
142
+
143
+ /**
144
+ * Whether the environment is set up to build a config without a file, used
145
+ * by the CLI to choose between the local file path and the container path.
146
+ */
147
+ export function hasEnvConfig(source: NodeJS.ProcessEnv): boolean {
148
+ return typeof source[RUNNER_PROJECTS_ENV] === "string";
149
+ }
150
+
151
+ /**
152
+ * Build a runner config entirely from environment variables, the production
153
+ * path when the runner runs in a container. The non-secret structure
154
+ * (`POLYCORE_PROJECTS`) and the outbound link's secrets
155
+ * (`POLYCORE_JOIN_TOKEN`, `POLYCORE_SIGNING_SECRET`, injected from a secret
156
+ * manager) all arrive as env vars, so nothing is written to disk. The
157
+ * assembled object is validated by the same schema as a file config, so a
158
+ * missing/invalid field fails loud at startup.
159
+ *
160
+ * This is a *pure* function over the passed env map, the CLI entry point is
161
+ * the only place that reads `process.env` and hands it in.
162
+ */
163
+ export function buildRunnerConfigFromEnv(
164
+ source: NodeJS.ProcessEnv,
165
+ ): RunnerConfig {
166
+ const projectsRaw = source[RUNNER_PROJECTS_ENV];
167
+ if (projectsRaw === undefined) {
168
+ throw new Error(
169
+ `${RUNNER_PROJECTS_ENV} is required to build a config from the environment.`,
170
+ );
171
+ }
172
+ // Include the control-plane block only when at least one of its vars is set.
173
+ // Omitting it entirely keeps `controlPlane` validly optional (so `runner list`
174
+ // can load actions in a container without enrollment creds), while a *partial*
175
+ // block stays included so the schema pinpoints the missing field.
176
+ const cpUrl = source["POLYCORE_CONTROL_PLANE_URL"];
177
+ const cpRunnerId = source["POLYCORE_RUNNER_ID"];
178
+ const cpJoinToken = source["POLYCORE_JOIN_TOKEN"];
179
+ const cpSigningSecret = source["POLYCORE_SIGNING_SECRET"];
180
+ const hasAnyControlPlane =
181
+ cpUrl !== undefined ||
182
+ cpRunnerId !== undefined ||
183
+ cpJoinToken !== undefined ||
184
+ cpSigningSecret !== undefined;
185
+
186
+ const candidate = {
187
+ projects: parseJsonEnv(projectsRaw, RUNNER_PROJECTS_ENV),
188
+ ...(hasAnyControlPlane
189
+ ? {
190
+ controlPlane: {
191
+ url: cpUrl,
192
+ runnerId: cpRunnerId,
193
+ joinToken: cpJoinToken,
194
+ signingSecret: cpSigningSecret,
195
+ },
196
+ }
197
+ : {}),
198
+ ...(source["POLYCORE_SECRETS"] === undefined
199
+ ? {}
200
+ : {
201
+ secrets: parseJsonEnv(source["POLYCORE_SECRETS"], "POLYCORE_SECRETS"),
202
+ }),
203
+ };
204
+ return RunnerConfigSchema.parse(candidate);
205
+ }
206
+
207
+ function parseJsonEnv(raw: string, name: string): unknown {
208
+ try {
209
+ return JSON.parse(raw) as unknown;
210
+ } catch (error) {
211
+ const message = error instanceof Error ? error.message : String(error);
212
+ throw new Error(`${name} must be valid JSON: ${message}`, { cause: error });
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Resolve the project to run against: an explicit choice, else the only
218
+ * project if there is exactly one. Throws with the valid names listed so
219
+ * callers self-correct.
220
+ */
221
+ export function resolveProject(
222
+ config: RunnerConfig,
223
+ requested?: string,
224
+ ): { slug: string; project: ProjectConfig } {
225
+ const slugs = Object.keys(config.projects);
226
+ const chosen = requested ?? (slugs.length === 1 ? slugs[0] : undefined);
227
+ if (chosen === undefined) {
228
+ throw new Error(
229
+ `No project specified. Available: ${slugs.map((s) => `"${s}"`).join(", ")}.`,
230
+ );
231
+ }
232
+ const project = config.projects[chosen];
233
+ if (!project) {
234
+ throw new Error(
235
+ `Unknown project "${chosen}". Available: ${slugs
236
+ .map((s) => `"${s}"`)
237
+ .join(", ")}.`,
238
+ );
239
+ }
240
+ return { slug: chosen, project };
241
+ }
242
+
243
+ /**
244
+ * Resolve the environment to run against within a project: an explicit
245
+ * choice, else the project's default, else the only environment if there is
246
+ * exactly one. Throws with the valid names listed so callers self-correct.
247
+ */
248
+ export function resolveEnvironment(
249
+ project: ProjectConfig,
250
+ requested?: string,
251
+ ): { name: string; env: EnvironmentConfig } {
252
+ const names = Object.keys(project.environments);
253
+ const chosen =
254
+ requested ??
255
+ project.defaultEnvironment ??
256
+ (names.length === 1 ? names[0] : undefined);
257
+
258
+ if (chosen === undefined) {
259
+ throw new Error(
260
+ `No environment specified and no default set. Available: ${names
261
+ .map((n) => `"${n}"`)
262
+ .join(", ")}.`,
263
+ );
264
+ }
265
+
266
+ const env = project.environments[chosen];
267
+ if (!env) {
268
+ throw new Error(
269
+ `Unknown environment "${chosen}". Available: ${names
270
+ .map((n) => `"${n}"`)
271
+ .join(", ")}.`,
272
+ );
273
+ }
274
+ return { name: chosen, env };
275
+ }
276
+
277
+ /**
278
+ * The agent-context document a project advertises: the inline `context`
279
+ * string, or the contents of `contextFile`. Read lazily (at enrollment) so
280
+ * a re-connect picks up edits without a restart.
281
+ */
282
+ export function resolveProjectContext(
283
+ project: ProjectConfig,
284
+ ): string | undefined {
285
+ if (project.contextFile !== undefined) {
286
+ return readFileSync(project.contextFile, "utf8");
287
+ }
288
+ return project.context;
289
+ }
package/src/connect.ts ADDED
@@ -0,0 +1,363 @@
1
+ import {
2
+ type ConnectorDescriptor,
3
+ type ControlToRunner,
4
+ type DispatchMessage,
5
+ parseControlToRunner,
6
+ type ProjectAdvertisement,
7
+ PROTOCOL_VERSION,
8
+ type RunnerToControl,
9
+ serializeMessage,
10
+ verifyEnvelope,
11
+ } from "@polycore/protocol";
12
+ import WebSocket from "ws";
13
+ import { z } from "zod";
14
+
15
+ import type { ControlPlaneConfig, RunnerConfig } from "./config.js";
16
+ import { resolveProjectContext } from "./config.js";
17
+ import type { ConnectorRegistry } from "./connector.js";
18
+
19
+ /** Describe a registry's connectors for enrollment (Zod → JSON Schema). */
20
+ export function describeConnectors(
21
+ registry: ConnectorRegistry,
22
+ ): ConnectorDescriptor[] {
23
+ return registry.list().map((capability) => ({
24
+ name: capability.name,
25
+ description: capability.description,
26
+ effect: capability.effect,
27
+ kind: capability.kind,
28
+ params: z.toJSONSchema(capability.params),
29
+ }));
30
+ }
31
+
32
+ /**
33
+ * Build the per-project advertisement the join message carries: each
34
+ * project's environments, its capability catalog, and its agent context.
35
+ */
36
+ export function describeProjects(
37
+ config: RunnerConfig,
38
+ registries: ReadonlyMap<string, ConnectorRegistry>,
39
+ ): ProjectAdvertisement[] {
40
+ return Object.entries(config.projects).map(([slug, project]) => {
41
+ const registry = registries.get(slug);
42
+ if (!registry) {
43
+ throw new Error(`No registry built for project "${slug}".`);
44
+ }
45
+ const context = resolveProjectContext(project);
46
+ return {
47
+ slug,
48
+ environments: Object.keys(project.environments),
49
+ connectors: describeConnectors(registry),
50
+ ...(context === undefined ? {} : { context }),
51
+ };
52
+ });
53
+ }
54
+
55
+ /** Exponential-backoff schedule for re-dialing a dropped control plane. */
56
+ export interface ReconnectOptions {
57
+ /** Delay before the first reconnect attempt. */
58
+ readonly initialDelayMs: number;
59
+ /** Ceiling the backoff is clamped to. */
60
+ readonly maxDelayMs: number;
61
+ /** Multiplier applied per consecutive failed attempt. */
62
+ readonly factor: number;
63
+ }
64
+
65
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
66
+ const DEFAULT_PONG_TIMEOUT_MS = 10_000;
67
+ const DEFAULT_RECONNECT: ReconnectOptions = {
68
+ initialDelayMs: 1_000,
69
+ maxDelayMs: 30_000,
70
+ factor: 2,
71
+ };
72
+
73
+ export interface RunnerClientOptions {
74
+ readonly controlPlane: ControlPlaneConfig;
75
+ readonly config: RunnerConfig;
76
+ /** One registry per project slug (see `buildProjectRegistries`). */
77
+ readonly registries: ReadonlyMap<string, ConnectorRegistry>;
78
+ readonly log?: (message: string) => void;
79
+ /** Interval between heartbeat pings once enrolled (default 30s). */
80
+ readonly heartbeatIntervalMs?: number;
81
+ /** How long to wait for a pong before declaring the link dead (default 10s). */
82
+ readonly pongTimeoutMs?: number;
83
+ /**
84
+ * Exponential-backoff reconnect schedule, or `false` to disable reconnect
85
+ * entirely (used by tests that drive a single connection by hand).
86
+ */
87
+ readonly reconnect?: ReconnectOptions | false;
88
+ }
89
+
90
+ /**
91
+ * The runner's outbound link to the control plane. It dials *out* (no
92
+ * inbound ports), enrolls with its join token, then serves signed dispatch
93
+ * envelopes: verify the signature, run the connector, return the result.
94
+ *
95
+ * The link is self-healing: a WebSocket-level ping/pong heartbeat detects a
96
+ * silently dead control plane, and any drop triggers an exponential-backoff
97
+ * reconnect that re-enrolls from scratch. `connect()` resolves on the *first*
98
+ * successful enrollment; the loop keeps the runner online after that until
99
+ * {@link close} is called or the join is permanently rejected.
100
+ */
101
+ export class RunnerClient {
102
+ private socket: WebSocket | undefined;
103
+ private readonly log: (message: string) => void;
104
+
105
+ private readonly heartbeatIntervalMs: number;
106
+ private readonly pongTimeoutMs: number;
107
+ private readonly reconnect: ReconnectOptions | false;
108
+
109
+ /** Set by {@link close}; suppresses any further reconnect. */
110
+ private stopped = false;
111
+ /** A `rejected` join is a config error, never worth retrying. */
112
+ private terminal = false;
113
+ private joined = false;
114
+ private reconnectAttempts = 0;
115
+
116
+ private heartbeatTimer: ReturnType<typeof setInterval> | undefined;
117
+ private pongTimer: ReturnType<typeof setTimeout> | undefined;
118
+ private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
119
+ private awaitingPong = false;
120
+
121
+ private connectPromise: Promise<void> | undefined;
122
+ private firstConnectResolve: (() => void) | undefined;
123
+ private firstConnectReject: ((error: Error) => void) | undefined;
124
+
125
+ constructor(private readonly opts: RunnerClientOptions) {
126
+ this.log = opts.log ?? (() => undefined);
127
+ this.heartbeatIntervalMs =
128
+ opts.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
129
+ this.pongTimeoutMs = opts.pongTimeoutMs ?? DEFAULT_PONG_TIMEOUT_MS;
130
+ this.reconnect = opts.reconnect ?? DEFAULT_RECONNECT;
131
+ }
132
+
133
+ /**
134
+ * Dial the control plane and enroll. Resolves once enrollment first
135
+ * succeeds; rejects only on a permanent rejection (bad token / unknown
136
+ * runner). Transient network failures are retried with backoff, so a
137
+ * control plane that is merely down at startup is awaited, not failed.
138
+ */
139
+ public connect(): Promise<void> {
140
+ if (this.connectPromise) return this.connectPromise;
141
+ this.connectPromise = new Promise<void>((resolve, reject) => {
142
+ this.firstConnectResolve = resolve;
143
+ this.firstConnectReject = reject;
144
+ });
145
+ this.openConnection();
146
+ return this.connectPromise;
147
+ }
148
+
149
+ public close(): void {
150
+ this.stopped = true;
151
+ this.stopHeartbeat();
152
+ if (this.reconnectTimer) {
153
+ clearTimeout(this.reconnectTimer);
154
+ this.reconnectTimer = undefined;
155
+ }
156
+ this.socket?.close();
157
+ }
158
+
159
+ private openConnection(): void {
160
+ if (this.stopped || this.terminal) return;
161
+ const { url } = this.opts.controlPlane;
162
+ const socket = new WebSocket(url);
163
+ this.socket = socket;
164
+ this.joined = false;
165
+
166
+ socket.on("open", () => {
167
+ this.log(`Connected to ${url}; enrolling…`);
168
+ this.send({
169
+ type: "join",
170
+ protocolVersion: PROTOCOL_VERSION,
171
+ runnerId: this.opts.controlPlane.runnerId,
172
+ joinToken: this.opts.controlPlane.joinToken,
173
+ projects: describeProjects(this.opts.config, this.opts.registries),
174
+ });
175
+ });
176
+
177
+ socket.on("message", (data: WebSocket.RawData) => {
178
+ this.onMessage(socket, data);
179
+ });
180
+
181
+ socket.on("error", (error) => {
182
+ // ws always emits `close` after `error`; reconnect is driven there so
183
+ // we don't double-schedule. Just surface the cause.
184
+ this.log(`Socket error: ${describe(error)}`);
185
+ });
186
+
187
+ socket.on("close", () => {
188
+ this.onClose();
189
+ });
190
+ }
191
+
192
+ private onMessage(socket: WebSocket, data: WebSocket.RawData): void {
193
+ let message: ControlToRunner;
194
+ try {
195
+ message = parseControlToRunner(rawToString(data));
196
+ } catch (error) {
197
+ this.log(`Ignoring unparseable message: ${describe(error)}`);
198
+ return;
199
+ }
200
+ if (message.type === "joined") {
201
+ this.joined = true;
202
+ this.reconnectAttempts = 0;
203
+ this.log(`Enrolled (session ${message.sessionId}).`);
204
+ this.startHeartbeat();
205
+ this.firstConnectResolve?.();
206
+ this.firstConnectResolve = undefined;
207
+ this.firstConnectReject = undefined;
208
+ return;
209
+ }
210
+ if (message.type === "rejected") {
211
+ this.terminal = true;
212
+ const error = new Error(`Control plane rejected join: ${message.reason}`);
213
+ this.firstConnectReject?.(error);
214
+ this.firstConnectReject = undefined;
215
+ this.firstConnectResolve = undefined;
216
+ socket.close();
217
+ return;
218
+ }
219
+ if (message.type === "pong") {
220
+ this.onPong();
221
+ return;
222
+ }
223
+ void this.handleDispatch(message);
224
+ }
225
+
226
+ private onClose(): void {
227
+ this.stopHeartbeat();
228
+ if (this.joined) this.log("Connection closed.");
229
+ this.joined = false;
230
+ if (this.stopped || this.terminal) return;
231
+ this.scheduleReconnect();
232
+ }
233
+
234
+ private scheduleReconnect(): void {
235
+ if (this.reconnect === false) return;
236
+ const { initialDelayMs, maxDelayMs, factor } = this.reconnect;
237
+ const delay = Math.min(
238
+ maxDelayMs,
239
+ initialDelayMs * factor ** this.reconnectAttempts,
240
+ );
241
+ this.reconnectAttempts += 1;
242
+ this.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})…`);
243
+ this.reconnectTimer = setTimeout(() => {
244
+ this.reconnectTimer = undefined;
245
+ this.openConnection();
246
+ }, delay);
247
+ }
248
+
249
+ private startHeartbeat(): void {
250
+ this.stopHeartbeat();
251
+ this.heartbeatTimer = setInterval(() => {
252
+ const socket = this.socket;
253
+ if (socket?.readyState !== WebSocket.OPEN) return;
254
+ // A still-outstanding ping means the previous pong never came back:
255
+ // the link is silently dead. Force it closed so reconnect kicks in.
256
+ if (this.awaitingPong) {
257
+ this.log("Heartbeat timed out; terminating dead connection.");
258
+ socket.terminate();
259
+ return;
260
+ }
261
+ this.awaitingPong = true;
262
+ this.pongTimer = setTimeout(() => {
263
+ if (!this.awaitingPong) return;
264
+ this.log("Heartbeat timed out; terminating dead connection.");
265
+ this.socket?.terminate();
266
+ }, this.pongTimeoutMs);
267
+ // Application-level ping (a real message), NOT the WebSocket protocol's
268
+ // ping frame: a proxy in front of the control plane (Cloud Run) answers
269
+ // protocol pings itself, masking a dead backend. Only a message the
270
+ // control plane application must echo proves the link is truly alive.
271
+ this.send({ type: "ping" });
272
+ }, this.heartbeatIntervalMs);
273
+ }
274
+
275
+ private onPong(): void {
276
+ this.awaitingPong = false;
277
+ if (this.pongTimer) {
278
+ clearTimeout(this.pongTimer);
279
+ this.pongTimer = undefined;
280
+ }
281
+ }
282
+
283
+ private stopHeartbeat(): void {
284
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
285
+ if (this.pongTimer) clearTimeout(this.pongTimer);
286
+ this.heartbeatTimer = undefined;
287
+ this.pongTimer = undefined;
288
+ this.awaitingPong = false;
289
+ }
290
+
291
+ private async handleDispatch(message: DispatchMessage): Promise<void> {
292
+ const { envelope, sig } = message;
293
+ if (!verifyEnvelope(envelope, sig, this.opts.controlPlane.signingSecret)) {
294
+ this.log(`Rejected dispatch ${envelope.id}: bad signature.`);
295
+ this.send({
296
+ type: "result",
297
+ id: envelope.id,
298
+ ok: false,
299
+ error: { message: "envelope signature verification failed" },
300
+ });
301
+ return;
302
+ }
303
+ try {
304
+ const registry = this.opts.registries.get(envelope.project);
305
+ const project = this.opts.config.projects[envelope.project];
306
+ if (!registry || !project) {
307
+ throw new Error(
308
+ `Unknown project "${envelope.project}". Configured: ${[
309
+ ...this.opts.registries.keys(),
310
+ ]
311
+ .map((s) => `"${s}"`)
312
+ .join(", ")}.`,
313
+ );
314
+ }
315
+ const env = project.environments[envelope.environment];
316
+ if (!env) {
317
+ throw new Error(
318
+ `Unknown environment "${envelope.environment}" for project "${envelope.project}". ` +
319
+ `Configured: ${Object.keys(project.environments)
320
+ .map((n) => `"${n}"`)
321
+ .join(", ")}.`,
322
+ );
323
+ }
324
+ const dispatched = await registry.dispatch(
325
+ envelope.capability,
326
+ envelope.args,
327
+ {
328
+ project: envelope.project,
329
+ environment: envelope.environment,
330
+ env,
331
+ log: (m) => this.log(m),
332
+ },
333
+ );
334
+ this.send({
335
+ type: "result",
336
+ id: envelope.id,
337
+ ok: true,
338
+ result: dispatched.result,
339
+ });
340
+ } catch (error) {
341
+ this.send({
342
+ type: "result",
343
+ id: envelope.id,
344
+ ok: false,
345
+ error: { message: describe(error) },
346
+ });
347
+ }
348
+ }
349
+
350
+ private send(message: RunnerToControl): void {
351
+ this.socket?.send(serializeMessage(message));
352
+ }
353
+ }
354
+
355
+ function describe(error: unknown): string {
356
+ return error instanceof Error ? error.message : String(error);
357
+ }
358
+
359
+ function rawToString(data: WebSocket.RawData): string {
360
+ if (Buffer.isBuffer(data)) return data.toString("utf8");
361
+ if (Array.isArray(data)) return Buffer.concat(data).toString("utf8");
362
+ return Buffer.from(data).toString("utf8");
363
+ }