@constructive-db/machine-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/esm/run.js ADDED
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ import { createLogger } from '@pgpmjs/logger';
3
+ import { defaultConfigPath, RUNNER_USAGE, runRunnerCli } from './cli';
4
+ import { loadRunnerConfig } from './config';
5
+ import { MachineRunner } from './runner';
6
+ import { ensureSpawnHelperExecutable, nodePtyRoot } from './spawn-helper';
7
+ const logger = createLogger('machine-runner');
8
+ /**
9
+ * Boot the daemon. `start` is what this process did before it grew verbs, and
10
+ * `--config <path>` / `MACHINE_RUNNER_CONFIG` still address the same file
11
+ * `enroll` writes.
12
+ */
13
+ export function bootMachineRunner(argv) {
14
+ const flagIndex = argv.indexOf('--config');
15
+ const configPath = flagIndex >= 0 ? argv[flagIndex + 1] : defaultConfigPath();
16
+ if (!configPath) {
17
+ throw new Error('machine-runner: pass --config <path> or set MACHINE_RUNNER_CONFIG to the config file');
18
+ }
19
+ const config = loadRunnerConfig(configPath);
20
+ // Before the first session, not on its failure: an unusable spawn-helper
21
+ // surfaces as EACCES from deep inside forkpty, and the fix is a mode bit npm
22
+ // dropped rather than anything the tenant asked for.
23
+ for (const helper of ensureSpawnHelperExecutable(nodePtyRoot())) {
24
+ logger.warn(`machine-runner: restored the executable bit on ${helper}`);
25
+ }
26
+ const runner = new MachineRunner({
27
+ enrollments: config.enrollments,
28
+ policy: config.policy,
29
+ logger
30
+ });
31
+ runner.start();
32
+ return runner;
33
+ }
34
+ function readStdin() {
35
+ return new Promise((resolve, reject) => {
36
+ let data = '';
37
+ process.stdin.setEncoding('utf8');
38
+ process.stdin.on('data', chunk => {
39
+ data += chunk;
40
+ });
41
+ process.stdin.on('end', () => resolve(data));
42
+ process.stdin.on('error', reject);
43
+ });
44
+ }
45
+ function daemonize(argv) {
46
+ const runner = bootMachineRunner(argv);
47
+ const shutdown = () => {
48
+ runner.stop();
49
+ process.exit(0);
50
+ };
51
+ process.on('SIGINT', shutdown);
52
+ process.on('SIGTERM', shutdown);
53
+ }
54
+ async function main(argv) {
55
+ const io = {
56
+ out: line => process.stdout.write(`${line}\n`),
57
+ readStdin,
58
+ env: process.env
59
+ };
60
+ const [command, ...rest] = argv;
61
+ // `start` takes over the process, so it is not one of the CLI's verbs.
62
+ if (command === 'start') {
63
+ daemonize(rest);
64
+ return;
65
+ }
66
+ const handled = await runRunnerCli(argv, io);
67
+ if (handled)
68
+ return;
69
+ // No verb at all keeps the pre-verb entrypoint working — the deployed unit
70
+ // and the bundle both invoke `run.js --config <path>`.
71
+ if (!command || command.startsWith('--')) {
72
+ daemonize(argv);
73
+ return;
74
+ }
75
+ throw new Error(`machine-runner: unknown command '${command}'\n\n${RUNNER_USAGE}`);
76
+ }
77
+ if (require.main === module) {
78
+ main(process.argv.slice(2)).catch(err => {
79
+ logger.error(`machine-runner failed to start: ${err instanceof Error ? err.message : err}`);
80
+ process.exit(1);
81
+ });
82
+ }
package/esm/runner.js ADDED
@@ -0,0 +1,378 @@
1
+ // One outbound connection per enrollment. The runner dials the relay, keeps
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`/
4
+ // `signal` drive it, and its output and exit stream back as frames. Nothing
5
+ // here ever listens on a port.
6
+ //
7
+ // A pty outlives the socket that asked for it. An interactive session is a
8
+ // terminal on this machine, not a state of the connection, so `detach` (and a
9
+ // dropped relay) stop the *streaming* and leave the process running; the bytes
10
+ // it writes while nobody is watching go into a capped ring buffer, and a later
11
+ // `reattach` — from whichever client the tenant then authorizes — replays that
12
+ // ring so the program repaints. The runner is the only place those bytes are
13
+ // held in memory, and it holds a bounded window of them, never a transcript.
14
+ 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
+ import { WebSocket } from 'ws';
17
+ import { PolicyViolationError, resolveSpawn } from './policy';
18
+ /** Stop presenting a credential this long before it expires. */
19
+ const CREDENTIAL_SKEW_MS = 30_000;
20
+ /**
21
+ * How much recent output an unwatched session keeps, in characters. Bounded on
22
+ * purpose: it is a repaint buffer, not a recording, and the oldest bytes are
23
+ * dropped rather than growing a detached session without limit.
24
+ */
25
+ const SCROLLBACK_LIMIT = 64 * 1024;
26
+ /**
27
+ * Signals delivered as the terminal's own control characters instead of to a
28
+ * pid. `kill(SIGINT)` would signal the session leader — the shell — which
29
+ * either ignores it or dies; writing the interrupt character lets the pty's
30
+ * line discipline deliver it to the *foreground* process group, which is how a
31
+ * keyboard `^C` stops the running command and leaves the shell prompt behind.
32
+ */
33
+ const CONTROL_CHARACTERS = {
34
+ SIGINT: '\x03',
35
+ SIGQUIT: '\x1c'
36
+ };
37
+ /** The connection for one enrollment: dial, serve sessions, redial on drop. */
38
+ export class EnrollmentRunner {
39
+ enrollment;
40
+ policy;
41
+ logger;
42
+ reconnectInitialMs;
43
+ reconnectMaxMs;
44
+ ws = null;
45
+ sessions = new Map();
46
+ /**
47
+ * The short-lived credential the relay minted for the last dial, held in
48
+ * memory only: it is never logged and never written beside the enrollment
49
+ * file, and it is dropped once it can no longer verify so the next dial falls
50
+ * back to the enrollment token and is issued a fresh one.
51
+ */
52
+ credential = null;
53
+ reconnectDelayMs;
54
+ reconnectTimer = null;
55
+ stopped = false;
56
+ constructor(options) {
57
+ this.enrollment = options.enrollment;
58
+ this.policy = options.policy;
59
+ this.logger = options.logger;
60
+ this.reconnectInitialMs = options.reconnectInitialMs ?? 250;
61
+ this.reconnectMaxMs = options.reconnectMaxMs ?? 10_000;
62
+ this.reconnectDelayMs = this.reconnectInitialMs;
63
+ }
64
+ start() {
65
+ this.stopped = false;
66
+ this.connect();
67
+ }
68
+ stop() {
69
+ this.stopped = true;
70
+ if (this.reconnectTimer) {
71
+ clearTimeout(this.reconnectTimer);
72
+ this.reconnectTimer = null;
73
+ }
74
+ // Stopping the daemon is the one case where a live terminal does not
75
+ // survive: the process tree it belongs to is going away with it.
76
+ for (const [sessionId, session] of this.sessions) {
77
+ session.proc.kill();
78
+ this.sessions.delete(sessionId);
79
+ }
80
+ this.ws?.terminate();
81
+ this.ws = null;
82
+ }
83
+ get connected() {
84
+ return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
85
+ }
86
+ /** Sessions this runner still holds, for tests and a future status frame. */
87
+ get liveSessions() {
88
+ return [...this.sessions.keys()];
89
+ }
90
+ /**
91
+ * What to present on this dial: the credential from the last exchange while it
92
+ * still verifies, else the enrollment token. The enrollment token buys an
93
+ * exchange; it is not the standing credential.
94
+ */
95
+ secret() {
96
+ const held = this.credential;
97
+ if (held && held.expiresAt * 1000 > Date.now() + CREDENTIAL_SKEW_MS)
98
+ return held.value;
99
+ this.credential = null;
100
+ return this.enrollment.token;
101
+ }
102
+ /**
103
+ * Send on whichever socket is current. A session outlives the socket it was
104
+ * opened on, so its output must never be pinned to a closed one.
105
+ */
106
+ send(frame) {
107
+ const ws = this.ws;
108
+ if (ws && ws.readyState === WebSocket.OPEN)
109
+ ws.send(encodeFrame(frame));
110
+ }
111
+ connect() {
112
+ const { relayUrl, machineId, database } = this.enrollment;
113
+ const url = new URL(RUNNER_CONNECT_PATH, relayUrl);
114
+ const ws = new WebSocket(url, {
115
+ headers: {
116
+ authorization: `Bearer ${this.secret()}`,
117
+ [HEADER_MACHINE_ID]: machineId,
118
+ [HEADER_MACHINE_DATABASE]: database
119
+ }
120
+ });
121
+ this.ws = ws;
122
+ ws.on('open', () => {
123
+ this.reconnectDelayMs = this.reconnectInitialMs;
124
+ this.logger.info(`machine-runner: '${machineId}' connected to ${relayUrl}`);
125
+ });
126
+ ws.on('message', raw => {
127
+ let frame;
128
+ try {
129
+ frame = decodeFrame(raw.toString());
130
+ }
131
+ catch (err) {
132
+ const message = err instanceof Error ? err.message : String(err);
133
+ this.logger.error(`machine-runner: bad frame from relay: ${message}`);
134
+ ws.close(4002, 'protocol error');
135
+ return;
136
+ }
137
+ this.handleFrame(ws, frame);
138
+ });
139
+ // 'close' always follows an errored socket, so the reconnect is scheduled
140
+ // there; here the failure only needs to be visible.
141
+ ws.on('error', err => {
142
+ this.logger.warn(`machine-runner: '${machineId}' socket error: ${err.message}`);
143
+ });
144
+ ws.on('close', () => {
145
+ if (this.ws !== ws)
146
+ return;
147
+ this.ws = null;
148
+ // A dropped relay is a detach the tenant never asked for: an interactive
149
+ // terminal keeps running and buffers, so a reattach after the relay comes
150
+ // back finds it alive. A command-mode session has no one to stream to and
151
+ // nothing to come back for, so it ends with its connection.
152
+ for (const [sessionId, session] of this.sessions) {
153
+ session.streaming = false;
154
+ if (session.interactive)
155
+ continue;
156
+ session.proc.kill();
157
+ this.sessions.delete(sessionId);
158
+ }
159
+ if (this.stopped)
160
+ return;
161
+ this.logger.warn(`machine-runner: '${machineId}' disconnected, reconnecting in ${this.reconnectDelayMs}ms`);
162
+ this.reconnectTimer = setTimeout(() => {
163
+ this.reconnectTimer = null;
164
+ this.connect();
165
+ }, this.reconnectDelayMs);
166
+ this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, this.reconnectMaxMs);
167
+ });
168
+ }
169
+ handleFrame(ws, frame) {
170
+ switch (frame.type) {
171
+ 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
+ });
177
+ return;
178
+ case 'input': {
179
+ const session = this.require(ws, frame.sessionId);
180
+ if (!session)
181
+ return;
182
+ session.proc.write(frame.data);
183
+ return;
184
+ }
185
+ case 'resize': {
186
+ const session = this.require(ws, frame.sessionId);
187
+ if (!session)
188
+ return;
189
+ session.cols = frame.cols;
190
+ session.rows = frame.rows;
191
+ session.proc.resize(frame.cols, frame.rows);
192
+ return;
193
+ }
194
+ case 'signal': {
195
+ const session = this.require(ws, frame.sessionId);
196
+ if (!session)
197
+ return;
198
+ this.deliverSignal(session, frame.sessionId, frame.signal);
199
+ return;
200
+ }
201
+ case 'detach': {
202
+ const session = this.require(ws, frame.sessionId);
203
+ if (!session)
204
+ return;
205
+ // The terminal keeps running; what stops is the streaming. The window it
206
+ // buffered goes back once, for the ledger to record, and is then dropped
207
+ // from this side of the wire.
208
+ session.streaming = false;
209
+ ws.send(encodeFrame({
210
+ type: 'scrollback',
211
+ sessionId: frame.sessionId,
212
+ data: session.scrollback,
213
+ truncated: session.truncated
214
+ }));
215
+ return;
216
+ }
217
+ case 'reattach': {
218
+ const session = this.require(ws, frame.sessionId);
219
+ if (!session)
220
+ return;
221
+ session.streaming = true;
222
+ if (frame.cols !== undefined && frame.rows !== undefined) {
223
+ session.cols = frame.cols;
224
+ session.rows = frame.rows;
225
+ session.proc.resize(frame.cols, frame.rows);
226
+ }
227
+ // Repaint: what the terminal looked like to whoever left it. A full-screen
228
+ // program redraws on the resize above, so this is what makes a scrolling
229
+ // session (a shell's history) come back too.
230
+ if (session.scrollback.length > 0) {
231
+ ws.send(encodeFrame({ type: 'output', sessionId: frame.sessionId, data: session.scrollback }));
232
+ }
233
+ return;
234
+ }
235
+ case 'close': {
236
+ const session = this.sessions.get(frame.sessionId);
237
+ if (session) {
238
+ session.proc.kill();
239
+ this.sessions.delete(frame.sessionId);
240
+ }
241
+ return;
242
+ }
243
+ case 'enrolled':
244
+ // The exchange's result: the tenant resolved this machine to a principal
245
+ // and minted a credential to dial with next time. Its value never reaches
246
+ // the log — only that it exists, and for how long.
247
+ this.credential = { value: frame.credential, expiresAt: frame.expiresAt };
248
+ this.logger.info(`machine-runner: '${frame.machineId}' enrolled as principal ${frame.principalId}; ` +
249
+ `credential valid for ${Math.max(0, frame.expiresAt - Math.floor(Date.now() / 1000))}s`);
250
+ return;
251
+ default:
252
+ this.logger.error(`machine-runner: unexpected '${frame.type}' frame from relay`);
253
+ ws.close(4002, 'protocol error');
254
+ }
255
+ }
256
+ /** The session a frame addresses, or an `error` frame back and undefined. */
257
+ require(ws, sessionId) {
258
+ const session = this.sessions.get(sessionId);
259
+ if (!session) {
260
+ ws.send(encodeFrame({ type: 'error', sessionId, message: 'unknown session' }));
261
+ return undefined;
262
+ }
263
+ return session;
264
+ }
265
+ /**
266
+ * Deliver a signal the way a terminal would. The two signals a keyboard
267
+ * produces go through the line discipline so they reach the foreground
268
+ * process group; the rest go to the session leader and therefore end the
269
+ * session, which is what asking for them means.
270
+ */
271
+ deliverSignal(session, sessionId, signal) {
272
+ const character = CONTROL_CHARACTERS[signal];
273
+ if (character) {
274
+ session.proc.write(character);
275
+ return;
276
+ }
277
+ try {
278
+ session.proc.kill(signal);
279
+ }
280
+ catch (err) {
281
+ // node-pty throws when the process is already gone, which is a race the
282
+ // requester should see rather than a failure of the runner.
283
+ const message = err instanceof Error ? err.message : String(err);
284
+ this.logger.warn(`machine-runner: ${signal} to session '${sessionId}' failed: ${message}`);
285
+ this.send({ type: 'error', sessionId, message: `${signal} failed: ${message}` });
286
+ }
287
+ }
288
+ openSession(ws, sessionId, command, args, shape) {
289
+ if (this.sessions.has(sessionId)) {
290
+ ws.send(encodeFrame({ type: 'error', sessionId, message: 'session id already in use' }));
291
+ return;
292
+ }
293
+ const cols = shape.cols ?? 80;
294
+ const rows = shape.rows ?? 24;
295
+ let proc;
296
+ 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
+ });
305
+ }
306
+ catch (err) {
307
+ // A rejected command is the policy answering "no": the requester learns
308
+ // why, the machine's log records it, and the connection stays up.
309
+ const message = err instanceof Error ? err.message : String(err);
310
+ if (!(err instanceof PolicyViolationError)) {
311
+ this.logger.error(`machine-runner: spawn '${command}' failed: ${message}`);
312
+ }
313
+ else {
314
+ this.logger.warn(`machine-runner: refused '${command}': ${message}`);
315
+ }
316
+ ws.send(encodeFrame({ type: 'error', sessionId, message }));
317
+ return;
318
+ }
319
+ const session = {
320
+ proc,
321
+ interactive: shape.interactive,
322
+ cols,
323
+ rows,
324
+ streaming: true,
325
+ scrollback: '',
326
+ truncated: false
327
+ };
328
+ this.sessions.set(sessionId, session);
329
+ proc.onData(data => {
330
+ // Buffered for an interactive session whether or not anyone is watching:
331
+ // that buffer is the repaint a reattach needs. Command mode streams and
332
+ // keeps nothing — its transcript is the ledger.
333
+ if (session.interactive)
334
+ this.remember(session, data);
335
+ if (session.streaming)
336
+ this.send({ type: 'output', sessionId, data });
337
+ });
338
+ proc.onExit(({ exitCode, signal }) => {
339
+ this.sessions.delete(sessionId);
340
+ if (session.streaming)
341
+ this.send({ type: 'exit', sessionId, exitCode, signal });
342
+ });
343
+ }
344
+ /** Append to the capped ring, dropping the oldest bytes past the limit. */
345
+ remember(session, data) {
346
+ const combined = session.scrollback + data;
347
+ if (combined.length <= SCROLLBACK_LIMIT) {
348
+ session.scrollback = combined;
349
+ return;
350
+ }
351
+ session.scrollback = combined.slice(combined.length - SCROLLBACK_LIMIT);
352
+ session.truncated = true;
353
+ }
354
+ }
355
+ /** The daemon: one EnrollmentRunner per enrollment in the config. */
356
+ export class MachineRunner {
357
+ runners;
358
+ constructor(options) {
359
+ if (options.enrollments.length === 0) {
360
+ throw new Error('machine-runner: at least one enrollment is required');
361
+ }
362
+ this.runners = options.enrollments.map(enrollment => new EnrollmentRunner({
363
+ enrollment,
364
+ policy: options.policy,
365
+ logger: options.logger,
366
+ reconnectInitialMs: options.reconnectInitialMs,
367
+ reconnectMaxMs: options.reconnectMaxMs
368
+ }));
369
+ }
370
+ start() {
371
+ for (const runner of this.runners)
372
+ runner.start();
373
+ }
374
+ stop() {
375
+ for (const runner of this.runners)
376
+ runner.stop();
377
+ }
378
+ }
@@ -0,0 +1,46 @@
1
+ // node-pty ships its darwin builds as prebuilt binaries in the npm tarball, and
2
+ // the tarball does not carry the executable bit for anything outside `bin`: the
3
+ // extracted `prebuilds/darwin-<arch>/spawn-helper` lands 0644, and the first
4
+ // `pty.spawn()` then dies with EACCES — on the user's own laptop, which is the
5
+ // only machine this daemon ever runs on. Restoring the bit is idempotent and
6
+ // costs two stat calls, so the runner does it itself at startup instead of in a
7
+ // postinstall script: `npm i -g` runs no lifecycle script of ours, and a
8
+ // prebuild replaced by a later reinstall would lose the fix again.
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+ /** The mode a helper binary needs: executable wherever it is readable. */
12
+ const EXECUTABLE_MODE = 0o755;
13
+ /**
14
+ * Helper binaries the installed node-pty carries: one per prebuild it shipped,
15
+ * plus the one a local node-gyp build produces. Only paths that exist.
16
+ */
17
+ function spawnHelpers(packageRoot) {
18
+ const helpers = [path.join(packageRoot, 'build', 'Release', 'spawn-helper')];
19
+ const prebuilds = path.join(packageRoot, 'prebuilds');
20
+ if (fs.existsSync(prebuilds)) {
21
+ for (const entry of fs.readdirSync(prebuilds)) {
22
+ helpers.push(path.join(prebuilds, entry, 'spawn-helper'));
23
+ }
24
+ }
25
+ return helpers.filter(helper => fs.existsSync(helper));
26
+ }
27
+ /**
28
+ * Make every spawn-helper of the node-pty rooted at `packageRoot` executable,
29
+ * and return the ones that had to be changed (nothing to say on a tree that is
30
+ * already correct — which is every Linux install, where there is no such file).
31
+ */
32
+ export function ensureSpawnHelperExecutable(packageRoot) {
33
+ const repaired = [];
34
+ for (const helper of spawnHelpers(packageRoot)) {
35
+ const mode = fs.statSync(helper).mode;
36
+ if ((mode & 0o111) !== 0)
37
+ continue;
38
+ fs.chmodSync(helper, EXECUTABLE_MODE);
39
+ repaired.push(helper);
40
+ }
41
+ return repaired;
42
+ }
43
+ /** Where the node-pty this runner will spawn through is installed. */
44
+ export function nodePtyRoot() {
45
+ return path.dirname(require.resolve('node-pty/package.json'));
46
+ }
package/index.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ export type { CliIo } from './cli';
2
+ export { defaultConfigPath, parseFlags, resolveEnrollmentToken, RUNNER_USAGE, runRunnerCli, TOKEN_ENV, writeEnrollment } from './cli';
3
+ export type { Enrollment, RunnerConfig } from './config';
4
+ export { loadRunnerConfig, parseRunnerConfig } from './config';
5
+ export type { RunnerPolicy, SpawnSpec } from './policy';
6
+ export { DEFAULT_ENV_ALLOW, PolicyViolationError, resolveSpawn } from './policy';
7
+ export type { EnrollmentRunnerOptions, MachineRunnerOptions } from './runner';
8
+ export { EnrollmentRunner, MachineRunner } from './runner';
package/index.js ADDED
@@ -0,0 +1,21 @@
1
+ "use strict";
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;
4
+ var cli_1 = require("./cli");
5
+ Object.defineProperty(exports, "defaultConfigPath", { enumerable: true, get: function () { return cli_1.defaultConfigPath; } });
6
+ Object.defineProperty(exports, "parseFlags", { enumerable: true, get: function () { return cli_1.parseFlags; } });
7
+ Object.defineProperty(exports, "resolveEnrollmentToken", { enumerable: true, get: function () { return cli_1.resolveEnrollmentToken; } });
8
+ Object.defineProperty(exports, "RUNNER_USAGE", { enumerable: true, get: function () { return cli_1.RUNNER_USAGE; } });
9
+ Object.defineProperty(exports, "runRunnerCli", { enumerable: true, get: function () { return cli_1.runRunnerCli; } });
10
+ Object.defineProperty(exports, "TOKEN_ENV", { enumerable: true, get: function () { return cli_1.TOKEN_ENV; } });
11
+ Object.defineProperty(exports, "writeEnrollment", { enumerable: true, get: function () { return cli_1.writeEnrollment; } });
12
+ var config_1 = require("./config");
13
+ Object.defineProperty(exports, "loadRunnerConfig", { enumerable: true, get: function () { return config_1.loadRunnerConfig; } });
14
+ Object.defineProperty(exports, "parseRunnerConfig", { enumerable: true, get: function () { return config_1.parseRunnerConfig; } });
15
+ var policy_1 = require("./policy");
16
+ Object.defineProperty(exports, "DEFAULT_ENV_ALLOW", { enumerable: true, get: function () { return policy_1.DEFAULT_ENV_ALLOW; } });
17
+ Object.defineProperty(exports, "PolicyViolationError", { enumerable: true, get: function () { return policy_1.PolicyViolationError; } });
18
+ Object.defineProperty(exports, "resolveSpawn", { enumerable: true, get: function () { return policy_1.resolveSpawn; } });
19
+ var runner_1 = require("./runner");
20
+ Object.defineProperty(exports, "EnrollmentRunner", { enumerable: true, get: function () { return runner_1.EnrollmentRunner; } });
21
+ Object.defineProperty(exports, "MachineRunner", { enumerable: true, get: function () { return runner_1.MachineRunner; } });
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@constructive-db/machine-runner",
3
+ "version": "0.1.0",
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
+ "author": "Constructive <developers@constructive.io>",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "pty",
9
+ "remote-control",
10
+ "terminal",
11
+ "websocket"
12
+ ],
13
+ "main": "index.js",
14
+ "module": "esm/index.js",
15
+ "types": "index.d.ts",
16
+ "bin": {
17
+ "machine-runner": "run.js"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public",
21
+ "directory": "dist"
22
+ },
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
+ "scripts": {
40
+ "build": "makage build && cp LICENSE dist/LICENSE",
41
+ "build:dev": "makage build --dev && cp LICENSE dist/LICENSE",
42
+ "clean": "makage clean",
43
+ "lint": "eslint . --fix",
44
+ "start": "node dist/run.js",
45
+ "test": "jest"
46
+ }
47
+ }
package/policy.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ export interface RunnerPolicy {
2
+ /** Commands (argv[0], exact match) a session may start. */
3
+ allowedCommands: string[];
4
+ /** Working directory every session starts in. */
5
+ cwd: string;
6
+ /**
7
+ * Environment the spawned process sees: `allow` names process.env vars to
8
+ * pass through, `set` adds explicit values. Nothing else leaks — the
9
+ * runner's own environment (tokens included) never reaches a session.
10
+ */
11
+ env?: {
12
+ allow?: string[];
13
+ set?: Record<string, string>;
14
+ };
15
+ }
16
+ /** Pass-through vars a pty session cannot reasonably run without. */
17
+ export declare const DEFAULT_ENV_ALLOW: string[];
18
+ export declare class PolicyViolationError extends Error {
19
+ constructor(message: string);
20
+ }
21
+ export interface SpawnSpec {
22
+ command: string;
23
+ args: string[];
24
+ cwd: string;
25
+ env: Record<string, string>;
26
+ }
27
+ export declare function resolveSpawn(policy: RunnerPolicy, command: string, args?: string[]): SpawnSpec;
package/policy.js ADDED
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ // Local policy: the runner, not the relay, decides what may run on the
3
+ // machine it guards. The policy is written by the machine's owner and never
4
+ // travels over the wire — a compromised relay can ask, but the answer is
5
+ // decided here.
6
+ var __importDefault = (this && this.__importDefault) || function (mod) {
7
+ return (mod && mod.__esModule) ? mod : { "default": mod };
8
+ };
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.PolicyViolationError = exports.DEFAULT_ENV_ALLOW = void 0;
11
+ exports.resolveSpawn = resolveSpawn;
12
+ const os_1 = __importDefault(require("os"));
13
+ /** Pass-through vars a pty session cannot reasonably run without. */
14
+ exports.DEFAULT_ENV_ALLOW = ['PATH', 'HOME', 'TERM', 'LANG', 'USER', 'SHELL'];
15
+ class PolicyViolationError extends Error {
16
+ constructor(message) {
17
+ super(message);
18
+ this.name = 'PolicyViolationError';
19
+ }
20
+ }
21
+ exports.PolicyViolationError = PolicyViolationError;
22
+ function resolveSpawn(policy, command, args = []) {
23
+ if (!policy.allowedCommands.includes(command)) {
24
+ throw new PolicyViolationError(`command '${command}' is not in the allowed command list`);
25
+ }
26
+ const allow = policy.env?.allow ?? exports.DEFAULT_ENV_ALLOW;
27
+ const env = {};
28
+ for (const name of allow) {
29
+ const value = process.env[name];
30
+ if (value !== undefined)
31
+ env[name] = value;
32
+ }
33
+ for (const [name, value] of Object.entries(policy.env?.set ?? {})) {
34
+ env[name] = value;
35
+ }
36
+ return {
37
+ command,
38
+ args,
39
+ cwd: policy.cwd || os_1.default.homedir(),
40
+ env
41
+ };
42
+ }
package/run.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import { MachineRunner } from './runner';
3
+ /**
4
+ * Boot the daemon. `start` is what this process did before it grew verbs, and
5
+ * `--config <path>` / `MACHINE_RUNNER_CONFIG` still address the same file
6
+ * `enroll` writes.
7
+ */
8
+ export declare function bootMachineRunner(argv: string[]): MachineRunner;