@constructive-db/machine-runner 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -1,11 +1,20 @@
1
1
  # @constructive-db/machine-runner
2
2
 
3
3
  The machine runner: the Node daemon a user installs on their own machine for
4
- Remote Control (constructive-planning#1690, phase 0). It dials **out** to a
5
- machine relay over WebSocket — it never listens on a port — spawns
6
- policy-checked processes in a pty (`node-pty`), and streams their bytes back
7
- as `@constructive-db/machine-protocol` frames. When the relay connection
8
- drops, it redials with exponential backoff and re-registers the machine.
4
+ Remote Control (constructive-planning#1690). It is a **remote control and
5
+ nothing else**: it dials **out** to a machine relay over WebSocket — it never
6
+ listens on a port — runs the allow-listed commands the relay hands it (in a pty
7
+ for a terminal, on pipes otherwise), streams their bytes back as
8
+ `@constructive-db/machine-protocol` frames, and stops them when told. When the
9
+ relay connection drops, it redials with exponential backoff and re-registers
10
+ the machine.
11
+
12
+ The runner knows nothing about what it runs. It has no notion of agents,
13
+ Claude, Codex, runs, approvals or events; it never reads a byte of a
14
+ command's output. Everything agentic sits *above* it, as ordinary commands
15
+ (`constructive-agent-cli` from `@constructive-db/agent-cli`,
16
+ `constructive-agent-host` from `@constructive-db/agent-host`) that the relay
17
+ asks it to run and whose stdout the relay interprets. Keep it that way.
9
18
 
10
19
  ## Install
11
20
 
@@ -20,9 +29,19 @@ authorization answer comes from the relay it dials.
20
29
  ## Usage
21
30
 
22
31
  ```
23
- machine-runner --config ~/.constructive/machine-runner.json
32
+ machine-runner enroll --relay wss://relay.example.com \
33
+ --machine <machine-uuid> --database <database-uuid>
34
+ # the enrollment token is read from stdin or MACHINE_ENROLLMENT_TOKEN, never argv
35
+
36
+ machine-runner start # dial the relay and serve sessions
37
+ machine-runner status # what this runner is enrolled in, never secrets
24
38
  ```
25
39
 
40
+ `enroll` writes the config `start` reads: `~/.machine-runner/config.json` at
41
+ mode 0600, or wherever `--config <path>` / `MACHINE_RUNNER_CONFIG` points. A
42
+ bare `machine-runner --config <path>` still boots the daemon, which is how the
43
+ deployed unit and the bundle invoke it.
44
+
26
45
  The config carries a **list of enrollments** — one per relay/database this
27
46
  machine is enrolled in — plus the local policy:
28
47
 
@@ -30,7 +49,7 @@ machine is enrolled in — plus the local policy:
30
49
  {
31
50
  "enrollments": [
32
51
  {
33
- "machineId": "my-laptop",
52
+ "machineId": "<machine-uuid>",
34
53
  "relayUrl": "wss://relay.example.com",
35
54
  "token": "<machine token>",
36
55
  "database": "db-uuid"
@@ -52,3 +71,105 @@ command not in `allowedCommands` is refused with an error frame, sessions
52
71
  start in the policy's `cwd`, and the spawned environment contains only the
53
72
  allow-listed pass-through variables plus the explicit `set` map — the runner's
54
73
  own environment (tokens included) never reaches a session.
74
+
75
+ ## What a session is
76
+
77
+ An `open` frame names a command, its arguments, whether it wants a terminal,
78
+ and optionally a directory. The runner:
79
+
80
+ - refuses the command unless it is in `allowedCommands`, and refuses a `cwd`
81
+ that resolves outside the policy's `cwd` (a sibling such as
82
+ `/home/me/work-other` is outside `/home/me/work`);
83
+ - spawns it in a pty (`interactive: true`, with `cols`/`rows`) or on pipes,
84
+ in that directory, with the projected environment;
85
+ - streams `output` frames (pipe output tagged `stdout`/`stderr`; pty output is
86
+ the terminal's byte stream), writes `input` frames to its stdin, applies
87
+ `resize` and `signal`, and reports `exit` or `error`;
88
+ - keeps a detached interactive session alive with a bounded scrollback until
89
+ a client reattaches or the session is closed.
90
+
91
+ That is the whole contract. An agent session is one of these whose command
92
+ happens to be an agent program — the relay composes that command and reads
93
+ its output; the runner ran an allow-listed command:
94
+
95
+ ```json
96
+ "policy": {
97
+ "allowedCommands": ["bash", "git", "constructive-agent-cli", "constructive-agent-host"],
98
+ "cwd": "/home/me/work",
99
+ "env": { "allow": ["PATH", "HOME"] }
100
+ }
101
+ ```
102
+
103
+ ---
104
+
105
+ ## Education and Tutorials
106
+
107
+ 1. 🚀 [Quickstart: Getting Up and Running](https://constructive.io/learn/quickstart)
108
+ Get started with modular databases in minutes. Install prerequisites and deploy your first module.
109
+
110
+ 2. 📦 [Modular PostgreSQL Development with Database Packages](https://constructive.io/learn/modular-postgres)
111
+ Learn to organize PostgreSQL projects with pgpm workspaces and reusable database modules.
112
+
113
+ 3. ✏️ [Authoring Database Changes](https://constructive.io/learn/authoring-database-changes)
114
+ Master the workflow for adding, organizing, and managing database changes with pgpm.
115
+
116
+ 4. 🧪 [End-to-End PostgreSQL Testing with TypeScript](https://constructive.io/learn/e2e-postgres-testing)
117
+ Master end-to-end PostgreSQL testing with ephemeral databases, RLS testing, and CI/CD automation.
118
+
119
+ 5. ⚡ [Supabase Testing](https://constructive.io/learn/supabase)
120
+ Use TypeScript-first tools to test Supabase projects with realistic RLS, policies, and auth contexts.
121
+
122
+ 6. 💧 [Drizzle ORM Testing](https://constructive.io/learn/drizzle-testing)
123
+ Run full-stack tests with Drizzle ORM, including database setup, teardown, and RLS enforcement.
124
+
125
+ 7. 🔧 [Troubleshooting](https://constructive.io/learn/troubleshooting)
126
+ Common issues and solutions for pgpm, PostgreSQL, and testing.
127
+
128
+ ## Related Constructive Tooling
129
+
130
+ ### 📦 Package Management
131
+
132
+ * [pgpm](https://github.com/constructive-io/constructive/tree/main/pgpm/pgpm): **🖥️ PostgreSQL Package Manager** for modular Postgres development. Works with database workspaces, scaffolding, migrations, seeding, and installing database packages.
133
+
134
+ ### 🧪 Testing
135
+
136
+ * [pgsql-test](https://github.com/constructive-io/constructive/tree/main/postgres/pgsql-test): **📊 Isolated testing environments** with per-test transaction rollbacks—ideal for integration tests, complex migrations, and RLS simulation.
137
+ * [pglite-test](https://github.com/constructive-io/constructive/tree/main/postgres/pglite-test): **🪶 Drop-in pgsql-test replacement backed by PGlite** — in-process Postgres, no server required, instance-per-suite isolation.
138
+ * [pgsql-seed](https://github.com/constructive-io/constructive/tree/main/postgres/pgsql-seed): **🌱 PostgreSQL seeding utilities** for CSV, JSON, SQL data loading, and pgpm deployment.
139
+ * [supabase-test](https://github.com/constructive-io/constructive/tree/main/postgres/supabase-test): **🧪 Supabase-native test harness** preconfigured for the local Supabase stack—per-test rollbacks, JWT/role context helpers, and CI/GitHub Actions ready.
140
+ * [graphile-test](https://github.com/constructive-io/constructive/tree/main/graphile/graphile-test): **🔐 Authentication mocking** for Graphile-focused test helpers and emulating row-level security contexts.
141
+ * [pg-query-context](https://github.com/constructive-io/constructive/tree/main/postgres/pg-query-context): **🔒 Session context injection** to add session-local context (e.g., `SET LOCAL`) into queries—ideal for setting `role`, `jwt.claims`, and other session settings.
142
+
143
+ ### 🧠 Parsing & AST
144
+
145
+ * [pgsql-parser](https://www.npmjs.com/package/pgsql-parser): **🔄 SQL conversion engine** that interprets and converts PostgreSQL syntax.
146
+ * [libpg-query-node](https://www.npmjs.com/package/libpg-query): **🌉 Node.js bindings** for `libpg_query`, converting SQL into parse trees.
147
+ * [pg-proto-parser](https://www.npmjs.com/package/pg-proto-parser): **📦 Protobuf parser** for parsing PostgreSQL Protocol Buffers definitions to generate TypeScript interfaces, utility functions, and JSON mappings for enums.
148
+ * [@pgsql/enums](https://www.npmjs.com/package/@pgsql/enums): **🏷️ TypeScript enums** for PostgreSQL AST for safe and ergonomic parsing logic.
149
+ * [@pgsql/types](https://www.npmjs.com/package/@pgsql/types): **📝 Type definitions** for PostgreSQL AST nodes in TypeScript.
150
+ * [@pgsql/utils](https://www.npmjs.com/package/@pgsql/utils): **🛠️ AST utilities** for constructing and transforming PostgreSQL syntax trees.
151
+
152
+ ### 📚 Documentation & Skills
153
+
154
+ * [constructive-skills](https://github.com/constructive-io/constructive-skills): **📖 Platform documentation and AI agent skills** — feature catalog, blueprint reference, SDK guides (i18n, billing, limits, events, uploads, security, entities, search, AI), and deployment guides.
155
+
156
+ Install skills for AI coding agents:
157
+
158
+ ```bash
159
+ # All platform skills (security, blueprints, codegen, billing, etc.)
160
+ npx skills add constructive-io/constructive-skills
161
+
162
+ # Individual repo skills (pgpm, testing, CLI, search, etc.)
163
+ npx skills add https://github.com/constructive-io/constructive --skill pgpm
164
+ npx skills add https://github.com/constructive-io/constructive --skill constructive-testing
165
+ ```
166
+
167
+ ## Credits
168
+
169
+ **🛠 Built by the [Constructive](https://constructive.io) team — creators of modular Postgres tooling for secure, composable backends. If you like our work, contribute on [GitHub](https://github.com/constructive-io).**
170
+
171
+ ## Disclaimer
172
+
173
+ AS DESCRIBED IN THE LICENSES, THE SOFTWARE IS PROVIDED "AS IS", AT YOUR OWN RISK, AND WITHOUT WARRANTIES OF ANY KIND.
174
+
175
+ No developer or entity involved in creating this software will be liable for any claims or damages whatsoever associated with your use, inability to use, or your interaction with other users of the code, including any direct, indirect, incidental, special, exemplary, punitive or consequential damages, or loss of profits, cryptocurrencies, tokens, or anything else of value.
package/esm/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { defaultConfigPath, parseFlags, resolveEnrollmentToken, RUNNER_USAGE, runRunnerCli, TOKEN_ENV, writeEnrollment } from './cli';
2
2
  export { loadRunnerConfig, parseRunnerConfig } from './config';
3
- export { DEFAULT_ENV_ALLOW, PolicyViolationError, resolveSpawn } from './policy';
3
+ export { DEFAULT_ENV_ALLOW, PolicyViolationError, resolveCwd, resolveSpawn } from './policy';
4
+ export { pipeProcess, ptyProcess } from './process';
4
5
  export { EnrollmentRunner, MachineRunner } from './runner';
package/esm/policy.js CHANGED
@@ -2,7 +2,9 @@
2
2
  // machine it guards. The policy is written by the machine's owner and never
3
3
  // travels over the wire — a compromised relay can ask, but the answer is
4
4
  // decided here.
5
+ import fs from 'fs';
5
6
  import os from 'os';
7
+ import path from 'path';
6
8
  /** Pass-through vars a pty session cannot reasonably run without. */
7
9
  export const DEFAULT_ENV_ALLOW = ['PATH', 'HOME', 'TERM', 'LANG', 'USER', 'SHELL'];
8
10
  export class PolicyViolationError extends Error {
@@ -11,7 +13,42 @@ export class PolicyViolationError extends Error {
11
13
  this.name = 'PolicyViolationError';
12
14
  }
13
15
  }
14
- export function resolveSpawn(policy, command, args = []) {
16
+ /**
17
+ * Where a path really points: symlinks resolved through the deepest ancestor
18
+ * that exists, the rest appended as written. A path that does not exist yet is
19
+ * judged by where it would be created.
20
+ */
21
+ function realPath(target) {
22
+ let existing = target;
23
+ const rest = [];
24
+ while (!fs.existsSync(existing)) {
25
+ const parent = path.dirname(existing);
26
+ if (parent === existing)
27
+ return target;
28
+ rest.unshift(path.basename(existing));
29
+ existing = parent;
30
+ }
31
+ return path.join(fs.realpathSync.native(existing), ...rest);
32
+ }
33
+ /**
34
+ * The directory a session runs in: the policy root, or a requested directory
35
+ * resolved against it — and refused when it would land outside, symlinks
36
+ * included. The request is the client's; where the root is, is the machine
37
+ * owner's.
38
+ */
39
+ export function resolveCwd(policy, requested) {
40
+ const root = path.resolve(policy.cwd || os.homedir());
41
+ if (requested === undefined)
42
+ return root;
43
+ const resolved = path.resolve(root, requested);
44
+ const realRoot = realPath(root);
45
+ const real = realPath(resolved);
46
+ if (real !== realRoot && !real.startsWith(realRoot + path.sep)) {
47
+ throw new PolicyViolationError(`cwd '${requested}' is outside the policy root`);
48
+ }
49
+ return resolved;
50
+ }
51
+ export function resolveSpawn(policy, command, args = [], cwd) {
15
52
  if (!policy.allowedCommands.includes(command)) {
16
53
  throw new PolicyViolationError(`command '${command}' is not in the allowed command list`);
17
54
  }
@@ -28,7 +65,7 @@ export function resolveSpawn(policy, command, args = []) {
28
65
  return {
29
66
  command,
30
67
  args,
31
- cwd: policy.cwd || os.homedir(),
68
+ cwd: resolveCwd(policy, cwd),
32
69
  env
33
70
  };
34
71
  }
package/esm/process.js ADDED
@@ -0,0 +1,101 @@
1
+ // The two ways a session's process runs: in a pty, when the session is a
2
+ // terminal, or on pipes, when it is a command. Both are driven through the
3
+ // same small interface; the runner does not care which it holds beyond
4
+ // whether it can be resized.
5
+ import { spawn as spawnChild } from 'child_process';
6
+ import * as pty from 'node-pty';
7
+ import os from 'os';
8
+ export function ptyProcess(spec, cols, rows) {
9
+ const proc = pty.spawn(spec.command, spec.args, {
10
+ name: 'xterm-256color',
11
+ cols,
12
+ rows,
13
+ cwd: spec.cwd,
14
+ env: spec.env
15
+ });
16
+ return {
17
+ write: data => proc.write(data),
18
+ resize: (c, r) => proc.resize(c, r),
19
+ kill: signal => proc.kill(signal),
20
+ onData: listener => proc.onData(data => listener(data)),
21
+ onExit: listener => proc.onExit(listener),
22
+ // A pty has no asynchronous failure: `pty.spawn` throws, and everything
23
+ // after that is an exit.
24
+ onError: () => { }
25
+ };
26
+ }
27
+ function signalNumber(signal) {
28
+ if (!signal)
29
+ return undefined;
30
+ return os.constants.signals[signal];
31
+ }
32
+ export function pipeProcess(spec) {
33
+ const dataListeners = [];
34
+ const exitListeners = [];
35
+ const errorListeners = [];
36
+ let exited = false;
37
+ const child = spawnChild(spec.command, spec.args, {
38
+ cwd: spec.cwd,
39
+ env: spec.env,
40
+ stdio: ['pipe', 'pipe', 'pipe']
41
+ });
42
+ child.stdout.setEncoding('utf8').on('data', (data) => {
43
+ for (const listener of dataListeners)
44
+ listener(data, 'stdout');
45
+ });
46
+ child.stderr.setEncoding('utf8').on('data', (data) => {
47
+ for (const listener of dataListeners)
48
+ listener(data, 'stderr');
49
+ });
50
+ // A program that could not start (ENOENT, EACCES) reports once, as an
51
+ // error and then as the exit it never had; nothing follows.
52
+ child.on('error', err => {
53
+ if (exited)
54
+ return;
55
+ exited = true;
56
+ for (const listener of errorListeners)
57
+ listener(err);
58
+ for (const listener of exitListeners)
59
+ listener({ exitCode: -1 });
60
+ });
61
+ child.on('exit', (code, signal) => {
62
+ if (exited)
63
+ return;
64
+ exited = true;
65
+ const number = signalNumber(signal);
66
+ // A signal death has no code of its own; report it as a shell would.
67
+ const exitCode = code ?? (number !== undefined ? 128 + number : -1);
68
+ for (const listener of exitListeners) {
69
+ listener({ exitCode, ...(number !== undefined ? { signal: number } : {}) });
70
+ }
71
+ });
72
+ // A child that closed its stdin early makes the next write EPIPE. While it
73
+ // runs that is the session's failure; once it has exited, the exit already
74
+ // said everything and there is nobody left to tell.
75
+ child.stdin.on('error', err => {
76
+ if (exited)
77
+ return;
78
+ for (const listener of errorListeners)
79
+ listener(err);
80
+ });
81
+ return {
82
+ write(data) {
83
+ if (!exited && child.stdin.writable)
84
+ child.stdin.write(data);
85
+ },
86
+ kill(signal) {
87
+ if (exited)
88
+ return;
89
+ child.kill(signal);
90
+ },
91
+ onData(listener) {
92
+ dataListeners.push(listener);
93
+ },
94
+ onExit(listener) {
95
+ exitListeners.push(listener);
96
+ },
97
+ onError(listener) {
98
+ errorListeners.push(listener);
99
+ }
100
+ };
101
+ }
package/esm/runner.js CHANGED
@@ -1,9 +1,17 @@
1
1
  // One outbound connection per enrollment. The runner dials the relay, keeps
2
2
  // the socket alive with a reconnect loop, and serves sessions: an `open`
3
- // frame spawns a pty (after the local policy says yes), `input`/`resize`/
3
+ // frame spawns a process (after the local policy says yes), `input`/`resize`/
4
4
  // `signal` drive it, and its output and exit stream back as frames. Nothing
5
5
  // here ever listens on a port.
6
6
  //
7
+ // The runner is a remote control and nothing more. It runs the command it is
8
+ // handed — in a pty when the session is a terminal, on pipes when it is a
9
+ // command — moves the bytes, and reports the exit. It does not know what the
10
+ // command is. A coding agent, an agent host, a build: each is an allow-listed
11
+ // program on pipes whose stdout the runner forwards without reading. Whatever
12
+ // vocabulary such a program speaks is between it and the relay's clients;
13
+ // none of it is here, and none of it may be added here.
14
+ //
7
15
  // A pty outlives the socket that asked for it. An interactive session is a
8
16
  // terminal on this machine, not a state of the connection, so `detach` (and a
9
17
  // dropped relay) stop the *streaming* and leave the process running; the bytes
@@ -12,9 +20,9 @@
12
20
  // ring so the program repaints. The runner is the only place those bytes are
13
21
  // held in memory, and it holds a bounded window of them, never a transcript.
14
22
  import { decodeFrame, encodeFrame, HEADER_MACHINE_DATABASE, HEADER_MACHINE_ID, RUNNER_CONNECT_PATH } from '@constructive-db/machine-protocol';
15
- import * as pty from 'node-pty';
16
23
  import { WebSocket } from 'ws';
17
24
  import { PolicyViolationError, resolveSpawn } from './policy';
25
+ import { pipeProcess, ptyProcess } from './process';
18
26
  /** Stop presenting a credential this long before it expires. */
19
27
  const CREDENTIAL_SKEW_MS = 30_000;
20
28
  /**
@@ -169,11 +177,7 @@ export class EnrollmentRunner {
169
177
  handleFrame(ws, frame) {
170
178
  switch (frame.type) {
171
179
  case 'open':
172
- this.openSession(ws, frame.sessionId, frame.command, frame.args ?? [], {
173
- interactive: frame.interactive === true,
174
- cols: frame.cols,
175
- rows: frame.rows
176
- });
180
+ this.openSession(ws, frame);
177
181
  return;
178
182
  case 'input': {
179
183
  const session = this.require(ws, frame.sessionId);
@@ -186,6 +190,14 @@ export class EnrollmentRunner {
186
190
  const session = this.require(ws, frame.sessionId);
187
191
  if (!session)
188
192
  return;
193
+ if (!session.proc.resize) {
194
+ ws.send(encodeFrame({
195
+ type: 'error',
196
+ sessionId: frame.sessionId,
197
+ message: 'command session has no terminal to resize'
198
+ }));
199
+ return;
200
+ }
189
201
  session.cols = frame.cols;
190
202
  session.rows = frame.rows;
191
203
  session.proc.resize(frame.cols, frame.rows);
@@ -219,7 +231,7 @@ export class EnrollmentRunner {
219
231
  if (!session)
220
232
  return;
221
233
  session.streaming = true;
222
- if (frame.cols !== undefined && frame.rows !== undefined) {
234
+ if (frame.cols !== undefined && frame.rows !== undefined && session.proc.resize) {
223
235
  session.cols = frame.cols;
224
236
  session.rows = frame.rows;
225
237
  session.proc.resize(frame.cols, frame.rows);
@@ -270,7 +282,7 @@ export class EnrollmentRunner {
270
282
  */
271
283
  deliverSignal(session, sessionId, signal) {
272
284
  const character = CONTROL_CHARACTERS[signal];
273
- if (character) {
285
+ if (character && session.proc.resize) {
274
286
  session.proc.write(character);
275
287
  return;
276
288
  }
@@ -285,23 +297,20 @@ export class EnrollmentRunner {
285
297
  this.send({ type: 'error', sessionId, message: `${signal} failed: ${message}` });
286
298
  }
287
299
  }
288
- openSession(ws, sessionId, command, args, shape) {
300
+ openSession(ws, frame) {
301
+ const { sessionId, command } = frame;
302
+ const args = frame.args ?? [];
303
+ const interactive = frame.interactive === true;
289
304
  if (this.sessions.has(sessionId)) {
290
305
  ws.send(encodeFrame({ type: 'error', sessionId, message: 'session id already in use' }));
291
306
  return;
292
307
  }
293
- const cols = shape.cols ?? 80;
294
- const rows = shape.rows ?? 24;
308
+ const cols = frame.cols ?? 80;
309
+ const rows = frame.rows ?? 24;
295
310
  let proc;
296
311
  try {
297
- const spec = resolveSpawn(this.policy, command, args);
298
- proc = pty.spawn(spec.command, spec.args, {
299
- name: 'xterm-256color',
300
- cols,
301
- rows,
302
- cwd: spec.cwd,
303
- env: spec.env
304
- });
312
+ const spec = resolveSpawn(this.policy, command, args, frame.cwd);
313
+ proc = interactive ? ptyProcess(spec, cols, rows) : pipeProcess(spec);
305
314
  }
306
315
  catch (err) {
307
316
  // A rejected command is the policy answering "no": the requester learns
@@ -318,7 +327,7 @@ export class EnrollmentRunner {
318
327
  }
319
328
  const session = {
320
329
  proc,
321
- interactive: shape.interactive,
330
+ interactive,
322
331
  cols,
323
332
  rows,
324
333
  streaming: true,
@@ -326,20 +335,28 @@ export class EnrollmentRunner {
326
335
  truncated: false
327
336
  };
328
337
  this.sessions.set(sessionId, session);
329
- proc.onData(data => {
338
+ proc.onData((data, stream) => {
330
339
  // Buffered for an interactive session whether or not anyone is watching:
331
340
  // that buffer is the repaint a reattach needs. Command mode streams and
332
341
  // keeps nothing — its transcript is the ledger.
333
342
  if (session.interactive)
334
343
  this.remember(session, data);
335
- if (session.streaming)
336
- this.send({ type: 'output', sessionId, data });
344
+ if (session.streaming) {
345
+ this.send({ type: 'output', sessionId, data, ...(stream ? { stream } : {}) });
346
+ }
337
347
  });
338
348
  proc.onExit(({ exitCode, signal }) => {
339
349
  this.sessions.delete(sessionId);
340
350
  if (session.streaming)
341
351
  this.send({ type: 'exit', sessionId, exitCode, signal });
342
352
  });
353
+ // The exit that follows — the process's own, or the one a failed start
354
+ // reports — is what ends the session.
355
+ proc.onError(err => {
356
+ this.logger.error(`machine-runner: session '${sessionId}' process failed: ${err.message}`);
357
+ this.send({ type: 'error', sessionId, message: `process failed: ${err.message}` });
358
+ proc.kill('SIGTERM');
359
+ });
343
360
  }
344
361
  /** Append to the capped ring, dropping the oldest bytes past the limit. */
345
362
  remember(session, data) {
package/index.d.ts CHANGED
@@ -3,6 +3,8 @@ export { defaultConfigPath, parseFlags, resolveEnrollmentToken, RUNNER_USAGE, ru
3
3
  export type { Enrollment, RunnerConfig } from './config';
4
4
  export { loadRunnerConfig, parseRunnerConfig } from './config';
5
5
  export type { RunnerPolicy, SpawnSpec } from './policy';
6
- export { DEFAULT_ENV_ALLOW, PolicyViolationError, resolveSpawn } from './policy';
6
+ export { DEFAULT_ENV_ALLOW, PolicyViolationError, resolveCwd, resolveSpawn } from './policy';
7
+ export type { ProcessExit, SessionProcess } from './process';
8
+ export { pipeProcess, ptyProcess } from './process';
7
9
  export type { EnrollmentRunnerOptions, MachineRunnerOptions } from './runner';
8
10
  export { EnrollmentRunner, MachineRunner } from './runner';
package/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.MachineRunner = exports.EnrollmentRunner = exports.resolveSpawn = exports.PolicyViolationError = exports.DEFAULT_ENV_ALLOW = exports.parseRunnerConfig = exports.loadRunnerConfig = exports.writeEnrollment = exports.TOKEN_ENV = exports.runRunnerCli = exports.RUNNER_USAGE = exports.resolveEnrollmentToken = exports.parseFlags = exports.defaultConfigPath = void 0;
3
+ exports.MachineRunner = exports.EnrollmentRunner = exports.ptyProcess = exports.pipeProcess = exports.resolveSpawn = exports.resolveCwd = exports.PolicyViolationError = exports.DEFAULT_ENV_ALLOW = exports.parseRunnerConfig = exports.loadRunnerConfig = exports.writeEnrollment = exports.TOKEN_ENV = exports.runRunnerCli = exports.RUNNER_USAGE = exports.resolveEnrollmentToken = exports.parseFlags = exports.defaultConfigPath = void 0;
4
4
  var cli_1 = require("./cli");
5
5
  Object.defineProperty(exports, "defaultConfigPath", { enumerable: true, get: function () { return cli_1.defaultConfigPath; } });
6
6
  Object.defineProperty(exports, "parseFlags", { enumerable: true, get: function () { return cli_1.parseFlags; } });
@@ -15,7 +15,11 @@ Object.defineProperty(exports, "parseRunnerConfig", { enumerable: true, get: fun
15
15
  var policy_1 = require("./policy");
16
16
  Object.defineProperty(exports, "DEFAULT_ENV_ALLOW", { enumerable: true, get: function () { return policy_1.DEFAULT_ENV_ALLOW; } });
17
17
  Object.defineProperty(exports, "PolicyViolationError", { enumerable: true, get: function () { return policy_1.PolicyViolationError; } });
18
+ Object.defineProperty(exports, "resolveCwd", { enumerable: true, get: function () { return policy_1.resolveCwd; } });
18
19
  Object.defineProperty(exports, "resolveSpawn", { enumerable: true, get: function () { return policy_1.resolveSpawn; } });
20
+ var process_1 = require("./process");
21
+ Object.defineProperty(exports, "pipeProcess", { enumerable: true, get: function () { return process_1.pipeProcess; } });
22
+ Object.defineProperty(exports, "ptyProcess", { enumerable: true, get: function () { return process_1.ptyProcess; } });
19
23
  var runner_1 = require("./runner");
20
24
  Object.defineProperty(exports, "EnrollmentRunner", { enumerable: true, get: function () { return runner_1.EnrollmentRunner; } });
21
25
  Object.defineProperty(exports, "MachineRunner", { enumerable: true, get: function () { return runner_1.MachineRunner; } });
package/package.json CHANGED
@@ -1,9 +1,18 @@
1
1
  {
2
2
  "name": "@constructive-db/machine-runner",
3
- "version": "0.1.0",
3
+ "version": "0.2.2",
4
4
  "description": "The machine runner: a Node daemon installed on a user's own machine that dials out to a machine relay over WebSocket, spawns policy-checked processes in a pty, and streams their bytes back. No inbound port, ever.",
5
5
  "author": "Constructive <developers@constructive.io>",
6
6
  "license": "MIT",
7
+ "homepage": "https://github.com/constructive-io/constructive",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/constructive-io/constructive",
11
+ "directory": "agentic/machine-runner"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/constructive-io/constructive/issues"
15
+ },
7
16
  "keywords": [
8
17
  "pty",
9
18
  "remote-control",
@@ -20,28 +29,23 @@
20
29
  "access": "public",
21
30
  "directory": "dist"
22
31
  },
23
- "dependencies": {
24
- "@pgpmjs/logger": "^2.25.0",
25
- "node-pty": "1.1.0",
26
- "ws": "^8.18.0",
27
- "@constructive-db/machine-protocol": "0.1.0"
28
- },
29
- "devDependencies": {
30
- "@types/jest": "^29.5.14",
31
- "@types/node": "^22.10.4",
32
- "@types/ws": "^8.5.13",
33
- "jest": "^29.7.0",
34
- "makage": "^0.1.12",
35
- "ts-jest": "^29.2.0",
36
- "typescript": "^5.1.6",
37
- "@constructive-db/machine-relay": "0.1.0"
38
- },
39
32
  "scripts": {
33
+ "prepack": "npm run build",
40
34
  "build": "makage build && cp LICENSE dist/LICENSE",
41
35
  "build:dev": "makage build --dev && cp LICENSE dist/LICENSE",
42
36
  "clean": "makage clean",
43
37
  "lint": "eslint . --fix",
44
38
  "start": "node dist/run.js",
45
39
  "test": "jest"
46
- }
47
- }
40
+ },
41
+ "dependencies": {
42
+ "@constructive-db/machine-protocol": "0.2.2",
43
+ "@pgpmjs/logger": "^2.26.0",
44
+ "node-pty": "1.1.0",
45
+ "ws": "^8.21.3"
46
+ },
47
+ "devDependencies": {
48
+ "@types/ws": "^8.5.13"
49
+ },
50
+ "gitHead": "81eed19332cc231de0fde02bc6e87002357f9536"
51
+ }
package/policy.d.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  export interface RunnerPolicy {
2
2
  /** Commands (argv[0], exact match) a session may start. */
3
3
  allowedCommands: string[];
4
- /** Working directory every session starts in. */
4
+ /**
5
+ * The root every session runs under: where a session starts by default, and
6
+ * the directory a requested `cwd` must stay inside.
7
+ */
5
8
  cwd: string;
6
9
  /**
7
10
  * Environment the spawned process sees: `allow` names process.env vars to
@@ -24,4 +27,11 @@ export interface SpawnSpec {
24
27
  cwd: string;
25
28
  env: Record<string, string>;
26
29
  }
27
- export declare function resolveSpawn(policy: RunnerPolicy, command: string, args?: string[]): SpawnSpec;
30
+ /**
31
+ * The directory a session runs in: the policy root, or a requested directory
32
+ * resolved against it — and refused when it would land outside, symlinks
33
+ * included. The request is the client's; where the root is, is the machine
34
+ * owner's.
35
+ */
36
+ export declare function resolveCwd(policy: RunnerPolicy, requested?: string): string;
37
+ export declare function resolveSpawn(policy: RunnerPolicy, command: string, args?: string[], cwd?: string): SpawnSpec;
package/policy.js CHANGED
@@ -8,8 +8,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
8
8
  };
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
10
  exports.PolicyViolationError = exports.DEFAULT_ENV_ALLOW = void 0;
11
+ exports.resolveCwd = resolveCwd;
11
12
  exports.resolveSpawn = resolveSpawn;
13
+ const fs_1 = __importDefault(require("fs"));
12
14
  const os_1 = __importDefault(require("os"));
15
+ const path_1 = __importDefault(require("path"));
13
16
  /** Pass-through vars a pty session cannot reasonably run without. */
14
17
  exports.DEFAULT_ENV_ALLOW = ['PATH', 'HOME', 'TERM', 'LANG', 'USER', 'SHELL'];
15
18
  class PolicyViolationError extends Error {
@@ -19,7 +22,42 @@ class PolicyViolationError extends Error {
19
22
  }
20
23
  }
21
24
  exports.PolicyViolationError = PolicyViolationError;
22
- function resolveSpawn(policy, command, args = []) {
25
+ /**
26
+ * Where a path really points: symlinks resolved through the deepest ancestor
27
+ * that exists, the rest appended as written. A path that does not exist yet is
28
+ * judged by where it would be created.
29
+ */
30
+ function realPath(target) {
31
+ let existing = target;
32
+ const rest = [];
33
+ while (!fs_1.default.existsSync(existing)) {
34
+ const parent = path_1.default.dirname(existing);
35
+ if (parent === existing)
36
+ return target;
37
+ rest.unshift(path_1.default.basename(existing));
38
+ existing = parent;
39
+ }
40
+ return path_1.default.join(fs_1.default.realpathSync.native(existing), ...rest);
41
+ }
42
+ /**
43
+ * The directory a session runs in: the policy root, or a requested directory
44
+ * resolved against it — and refused when it would land outside, symlinks
45
+ * included. The request is the client's; where the root is, is the machine
46
+ * owner's.
47
+ */
48
+ function resolveCwd(policy, requested) {
49
+ const root = path_1.default.resolve(policy.cwd || os_1.default.homedir());
50
+ if (requested === undefined)
51
+ return root;
52
+ const resolved = path_1.default.resolve(root, requested);
53
+ const realRoot = realPath(root);
54
+ const real = realPath(resolved);
55
+ if (real !== realRoot && !real.startsWith(realRoot + path_1.default.sep)) {
56
+ throw new PolicyViolationError(`cwd '${requested}' is outside the policy root`);
57
+ }
58
+ return resolved;
59
+ }
60
+ function resolveSpawn(policy, command, args = [], cwd) {
23
61
  if (!policy.allowedCommands.includes(command)) {
24
62
  throw new PolicyViolationError(`command '${command}' is not in the allowed command list`);
25
63
  }
@@ -36,7 +74,7 @@ function resolveSpawn(policy, command, args = []) {
36
74
  return {
37
75
  command,
38
76
  args,
39
- cwd: policy.cwd || os_1.default.homedir(),
77
+ cwd: resolveCwd(policy, cwd),
40
78
  env
41
79
  };
42
80
  }
package/process.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ import type { SignalName } from '@constructive-db/machine-protocol';
2
+ import type { SpawnSpec } from './policy';
3
+ export interface ProcessExit {
4
+ exitCode: number;
5
+ signal?: number;
6
+ }
7
+ /** What the runner drives, whether it is a pty or a pair of pipes. */
8
+ export interface SessionProcess {
9
+ write(data: string): void;
10
+ /** Absent where there is no terminal to resize. */
11
+ resize?(cols: number, rows: number): void;
12
+ kill(signal?: SignalName): void;
13
+ /**
14
+ * Bytes the process wrote. A pty has one stream; a process on pipes says
15
+ * which of its two a chunk came from.
16
+ */
17
+ onData(listener: (data: string, stream?: 'stdout' | 'stderr') => void): void;
18
+ onExit(listener: (event: ProcessExit) => void): void;
19
+ /** The process failed to run at all (a pty reports that by throwing from spawn). */
20
+ onError(listener: (err: Error) => void): void;
21
+ }
22
+ export declare function ptyProcess(spec: SpawnSpec, cols: number, rows: number): SessionProcess;
23
+ export declare function pipeProcess(spec: SpawnSpec): SessionProcess;
package/process.js ADDED
@@ -0,0 +1,141 @@
1
+ "use strict";
2
+ // The two ways a session's process runs: in a pty, when the session is a
3
+ // terminal, or on pipes, when it is a command. Both are driven through the
4
+ // same small interface; the runner does not care which it holds beyond
5
+ // whether it can be resized.
6
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
7
+ if (k2 === undefined) k2 = k;
8
+ var desc = Object.getOwnPropertyDescriptor(m, k);
9
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
10
+ desc = { enumerable: true, get: function() { return m[k]; } };
11
+ }
12
+ Object.defineProperty(o, k2, desc);
13
+ }) : (function(o, m, k, k2) {
14
+ if (k2 === undefined) k2 = k;
15
+ o[k2] = m[k];
16
+ }));
17
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
18
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
19
+ }) : function(o, v) {
20
+ o["default"] = v;
21
+ });
22
+ var __importStar = (this && this.__importStar) || (function () {
23
+ var ownKeys = function(o) {
24
+ ownKeys = Object.getOwnPropertyNames || function (o) {
25
+ var ar = [];
26
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
27
+ return ar;
28
+ };
29
+ return ownKeys(o);
30
+ };
31
+ return function (mod) {
32
+ if (mod && mod.__esModule) return mod;
33
+ var result = {};
34
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
35
+ __setModuleDefault(result, mod);
36
+ return result;
37
+ };
38
+ })();
39
+ var __importDefault = (this && this.__importDefault) || function (mod) {
40
+ return (mod && mod.__esModule) ? mod : { "default": mod };
41
+ };
42
+ Object.defineProperty(exports, "__esModule", { value: true });
43
+ exports.ptyProcess = ptyProcess;
44
+ exports.pipeProcess = pipeProcess;
45
+ const child_process_1 = require("child_process");
46
+ const pty = __importStar(require("node-pty"));
47
+ const os_1 = __importDefault(require("os"));
48
+ function ptyProcess(spec, cols, rows) {
49
+ const proc = pty.spawn(spec.command, spec.args, {
50
+ name: 'xterm-256color',
51
+ cols,
52
+ rows,
53
+ cwd: spec.cwd,
54
+ env: spec.env
55
+ });
56
+ return {
57
+ write: data => proc.write(data),
58
+ resize: (c, r) => proc.resize(c, r),
59
+ kill: signal => proc.kill(signal),
60
+ onData: listener => proc.onData(data => listener(data)),
61
+ onExit: listener => proc.onExit(listener),
62
+ // A pty has no asynchronous failure: `pty.spawn` throws, and everything
63
+ // after that is an exit.
64
+ onError: () => { }
65
+ };
66
+ }
67
+ function signalNumber(signal) {
68
+ if (!signal)
69
+ return undefined;
70
+ return os_1.default.constants.signals[signal];
71
+ }
72
+ function pipeProcess(spec) {
73
+ const dataListeners = [];
74
+ const exitListeners = [];
75
+ const errorListeners = [];
76
+ let exited = false;
77
+ const child = (0, child_process_1.spawn)(spec.command, spec.args, {
78
+ cwd: spec.cwd,
79
+ env: spec.env,
80
+ stdio: ['pipe', 'pipe', 'pipe']
81
+ });
82
+ child.stdout.setEncoding('utf8').on('data', (data) => {
83
+ for (const listener of dataListeners)
84
+ listener(data, 'stdout');
85
+ });
86
+ child.stderr.setEncoding('utf8').on('data', (data) => {
87
+ for (const listener of dataListeners)
88
+ listener(data, 'stderr');
89
+ });
90
+ // A program that could not start (ENOENT, EACCES) reports once, as an
91
+ // error and then as the exit it never had; nothing follows.
92
+ child.on('error', err => {
93
+ if (exited)
94
+ return;
95
+ exited = true;
96
+ for (const listener of errorListeners)
97
+ listener(err);
98
+ for (const listener of exitListeners)
99
+ listener({ exitCode: -1 });
100
+ });
101
+ child.on('exit', (code, signal) => {
102
+ if (exited)
103
+ return;
104
+ exited = true;
105
+ const number = signalNumber(signal);
106
+ // A signal death has no code of its own; report it as a shell would.
107
+ const exitCode = code ?? (number !== undefined ? 128 + number : -1);
108
+ for (const listener of exitListeners) {
109
+ listener({ exitCode, ...(number !== undefined ? { signal: number } : {}) });
110
+ }
111
+ });
112
+ // A child that closed its stdin early makes the next write EPIPE. While it
113
+ // runs that is the session's failure; once it has exited, the exit already
114
+ // said everything and there is nobody left to tell.
115
+ child.stdin.on('error', err => {
116
+ if (exited)
117
+ return;
118
+ for (const listener of errorListeners)
119
+ listener(err);
120
+ });
121
+ return {
122
+ write(data) {
123
+ if (!exited && child.stdin.writable)
124
+ child.stdin.write(data);
125
+ },
126
+ kill(signal) {
127
+ if (exited)
128
+ return;
129
+ child.kill(signal);
130
+ },
131
+ onData(listener) {
132
+ dataListeners.push(listener);
133
+ },
134
+ onExit(listener) {
135
+ exitListeners.push(listener);
136
+ },
137
+ onError(listener) {
138
+ errorListeners.push(listener);
139
+ }
140
+ };
141
+ }
package/run.js CHANGED
File without changes
package/runner.js CHANGED
@@ -1,10 +1,18 @@
1
1
  "use strict";
2
2
  // One outbound connection per enrollment. The runner dials the relay, keeps
3
3
  // the socket alive with a reconnect loop, and serves sessions: an `open`
4
- // frame spawns a pty (after the local policy says yes), `input`/`resize`/
4
+ // frame spawns a process (after the local policy says yes), `input`/`resize`/
5
5
  // `signal` drive it, and its output and exit stream back as frames. Nothing
6
6
  // here ever listens on a port.
7
7
  //
8
+ // The runner is a remote control and nothing more. It runs the command it is
9
+ // handed — in a pty when the session is a terminal, on pipes when it is a
10
+ // command — moves the bytes, and reports the exit. It does not know what the
11
+ // command is. A coding agent, an agent host, a build: each is an allow-listed
12
+ // program on pipes whose stdout the runner forwards without reading. Whatever
13
+ // vocabulary such a program speaks is between it and the relay's clients;
14
+ // none of it is here, and none of it may be added here.
15
+ //
8
16
  // A pty outlives the socket that asked for it. An interactive session is a
9
17
  // terminal on this machine, not a state of the connection, so `detach` (and a
10
18
  // dropped relay) stop the *streaming* and leave the process running; the bytes
@@ -12,45 +20,12 @@
12
20
  // `reattach` — from whichever client the tenant then authorizes — replays that
13
21
  // ring so the program repaints. The runner is the only place those bytes are
14
22
  // held in memory, and it holds a bounded window of them, never a transcript.
15
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
16
- if (k2 === undefined) k2 = k;
17
- var desc = Object.getOwnPropertyDescriptor(m, k);
18
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
19
- desc = { enumerable: true, get: function() { return m[k]; } };
20
- }
21
- Object.defineProperty(o, k2, desc);
22
- }) : (function(o, m, k, k2) {
23
- if (k2 === undefined) k2 = k;
24
- o[k2] = m[k];
25
- }));
26
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
27
- Object.defineProperty(o, "default", { enumerable: true, value: v });
28
- }) : function(o, v) {
29
- o["default"] = v;
30
- });
31
- var __importStar = (this && this.__importStar) || (function () {
32
- var ownKeys = function(o) {
33
- ownKeys = Object.getOwnPropertyNames || function (o) {
34
- var ar = [];
35
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
36
- return ar;
37
- };
38
- return ownKeys(o);
39
- };
40
- return function (mod) {
41
- if (mod && mod.__esModule) return mod;
42
- var result = {};
43
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
44
- __setModuleDefault(result, mod);
45
- return result;
46
- };
47
- })();
48
23
  Object.defineProperty(exports, "__esModule", { value: true });
49
24
  exports.MachineRunner = exports.EnrollmentRunner = void 0;
50
25
  const machine_protocol_1 = require("@constructive-db/machine-protocol");
51
- const pty = __importStar(require("node-pty"));
52
26
  const ws_1 = require("ws");
53
27
  const policy_1 = require("./policy");
28
+ const process_1 = require("./process");
54
29
  /** Stop presenting a credential this long before it expires. */
55
30
  const CREDENTIAL_SKEW_MS = 30_000;
56
31
  /**
@@ -205,11 +180,7 @@ class EnrollmentRunner {
205
180
  handleFrame(ws, frame) {
206
181
  switch (frame.type) {
207
182
  case 'open':
208
- this.openSession(ws, frame.sessionId, frame.command, frame.args ?? [], {
209
- interactive: frame.interactive === true,
210
- cols: frame.cols,
211
- rows: frame.rows
212
- });
183
+ this.openSession(ws, frame);
213
184
  return;
214
185
  case 'input': {
215
186
  const session = this.require(ws, frame.sessionId);
@@ -222,6 +193,14 @@ class EnrollmentRunner {
222
193
  const session = this.require(ws, frame.sessionId);
223
194
  if (!session)
224
195
  return;
196
+ if (!session.proc.resize) {
197
+ ws.send((0, machine_protocol_1.encodeFrame)({
198
+ type: 'error',
199
+ sessionId: frame.sessionId,
200
+ message: 'command session has no terminal to resize'
201
+ }));
202
+ return;
203
+ }
225
204
  session.cols = frame.cols;
226
205
  session.rows = frame.rows;
227
206
  session.proc.resize(frame.cols, frame.rows);
@@ -255,7 +234,7 @@ class EnrollmentRunner {
255
234
  if (!session)
256
235
  return;
257
236
  session.streaming = true;
258
- if (frame.cols !== undefined && frame.rows !== undefined) {
237
+ if (frame.cols !== undefined && frame.rows !== undefined && session.proc.resize) {
259
238
  session.cols = frame.cols;
260
239
  session.rows = frame.rows;
261
240
  session.proc.resize(frame.cols, frame.rows);
@@ -306,7 +285,7 @@ class EnrollmentRunner {
306
285
  */
307
286
  deliverSignal(session, sessionId, signal) {
308
287
  const character = CONTROL_CHARACTERS[signal];
309
- if (character) {
288
+ if (character && session.proc.resize) {
310
289
  session.proc.write(character);
311
290
  return;
312
291
  }
@@ -321,23 +300,20 @@ class EnrollmentRunner {
321
300
  this.send({ type: 'error', sessionId, message: `${signal} failed: ${message}` });
322
301
  }
323
302
  }
324
- openSession(ws, sessionId, command, args, shape) {
303
+ openSession(ws, frame) {
304
+ const { sessionId, command } = frame;
305
+ const args = frame.args ?? [];
306
+ const interactive = frame.interactive === true;
325
307
  if (this.sessions.has(sessionId)) {
326
308
  ws.send((0, machine_protocol_1.encodeFrame)({ type: 'error', sessionId, message: 'session id already in use' }));
327
309
  return;
328
310
  }
329
- const cols = shape.cols ?? 80;
330
- const rows = shape.rows ?? 24;
311
+ const cols = frame.cols ?? 80;
312
+ const rows = frame.rows ?? 24;
331
313
  let proc;
332
314
  try {
333
- const spec = (0, policy_1.resolveSpawn)(this.policy, command, args);
334
- proc = pty.spawn(spec.command, spec.args, {
335
- name: 'xterm-256color',
336
- cols,
337
- rows,
338
- cwd: spec.cwd,
339
- env: spec.env
340
- });
315
+ const spec = (0, policy_1.resolveSpawn)(this.policy, command, args, frame.cwd);
316
+ proc = interactive ? (0, process_1.ptyProcess)(spec, cols, rows) : (0, process_1.pipeProcess)(spec);
341
317
  }
342
318
  catch (err) {
343
319
  // A rejected command is the policy answering "no": the requester learns
@@ -354,7 +330,7 @@ class EnrollmentRunner {
354
330
  }
355
331
  const session = {
356
332
  proc,
357
- interactive: shape.interactive,
333
+ interactive,
358
334
  cols,
359
335
  rows,
360
336
  streaming: true,
@@ -362,20 +338,28 @@ class EnrollmentRunner {
362
338
  truncated: false
363
339
  };
364
340
  this.sessions.set(sessionId, session);
365
- proc.onData(data => {
341
+ proc.onData((data, stream) => {
366
342
  // Buffered for an interactive session whether or not anyone is watching:
367
343
  // that buffer is the repaint a reattach needs. Command mode streams and
368
344
  // keeps nothing — its transcript is the ledger.
369
345
  if (session.interactive)
370
346
  this.remember(session, data);
371
- if (session.streaming)
372
- this.send({ type: 'output', sessionId, data });
347
+ if (session.streaming) {
348
+ this.send({ type: 'output', sessionId, data, ...(stream ? { stream } : {}) });
349
+ }
373
350
  });
374
351
  proc.onExit(({ exitCode, signal }) => {
375
352
  this.sessions.delete(sessionId);
376
353
  if (session.streaming)
377
354
  this.send({ type: 'exit', sessionId, exitCode, signal });
378
355
  });
356
+ // The exit that follows — the process's own, or the one a failed start
357
+ // reports — is what ends the session.
358
+ proc.onError(err => {
359
+ this.logger.error(`machine-runner: session '${sessionId}' process failed: ${err.message}`);
360
+ this.send({ type: 'error', sessionId, message: `process failed: ${err.message}` });
361
+ proc.kill('SIGTERM');
362
+ });
379
363
  }
380
364
  /** Append to the capped ring, dropping the oldest bytes past the limit. */
381
365
  remember(session, data) {