@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Interweb, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # @constructive-db/machine-runner
2
+
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.
9
+
10
+ ## Install
11
+
12
+ ```
13
+ npm i -g @constructive-db/machine-runner
14
+ ```
15
+
16
+ MIT-licensed and dependency-light on purpose (`node-pty`, `ws`,
17
+ `@pgpmjs/logger`): it holds no database connection and no schema knowledge — every
18
+ authorization answer comes from the relay it dials.
19
+
20
+ ## Usage
21
+
22
+ ```
23
+ machine-runner --config ~/.constructive/machine-runner.json
24
+ ```
25
+
26
+ The config carries a **list of enrollments** — one per relay/database this
27
+ machine is enrolled in — plus the local policy:
28
+
29
+ ```json
30
+ {
31
+ "enrollments": [
32
+ {
33
+ "machineId": "my-laptop",
34
+ "relayUrl": "wss://relay.example.com",
35
+ "token": "<machine token>",
36
+ "database": "db-uuid"
37
+ }
38
+ ],
39
+ "policy": {
40
+ "allowedCommands": ["echo", "ls", "git"],
41
+ "cwd": "/home/me/work",
42
+ "env": {
43
+ "allow": ["PATH", "HOME", "TERM", "LANG"],
44
+ "set": { "CONSTRUCTIVE_SESSION": "1" }
45
+ }
46
+ }
47
+ }
48
+ ```
49
+
50
+ The policy is decided here, on the machine, and never travels over the wire: a
51
+ command not in `allowedCommands` is refused with an error frame, sessions
52
+ start in the policy's `cwd`, and the spawned environment contains only the
53
+ allow-listed pass-through variables plus the explicit `set` map — the runner's
54
+ own environment (tokens included) never reaches a session.
package/cli.d.ts ADDED
@@ -0,0 +1,38 @@
1
+ import { RunnerConfig } from './config';
2
+ import { RunnerPolicy } from './policy';
3
+ /** Env var carrying the enrollment token, for non-interactive installs. */
4
+ export declare const TOKEN_ENV = "MACHINE_ENROLLMENT_TOKEN";
5
+ /** Where the runner keeps its config when nothing says otherwise. */
6
+ export declare function defaultConfigPath(): string;
7
+ export interface CliIo {
8
+ /** Everything the CLI says. Never a secret. */
9
+ out: (line: string) => void;
10
+ /** Reads the enrollment token from stdin; only called when the env var is absent. */
11
+ readStdin: () => Promise<string>;
12
+ env: NodeJS.ProcessEnv;
13
+ }
14
+ export declare function parseFlags(argv: string[]): Record<string, string | true>;
15
+ /**
16
+ * The token, from the environment or stdin. A token on the command line is a
17
+ * refusal, not a fallback — accepting it would put it in shell history, which
18
+ * is exactly the leak the paste-once flow exists to avoid.
19
+ */
20
+ export declare function resolveEnrollmentToken(flags: Record<string, string | true>, io: CliIo): Promise<string>;
21
+ /**
22
+ * Write (or extend) the runner config for one relay + machine. A machine can be
23
+ * enrolled in several databases, so an existing config is merged: the entry for
24
+ * the same machine at the same relay is replaced, everything else is kept.
25
+ */
26
+ export declare function writeEnrollment(configPath: string, enrollment: {
27
+ machineId: string;
28
+ relayUrl: string;
29
+ token: string;
30
+ database: string;
31
+ }, policy: RunnerPolicy): RunnerConfig;
32
+ export declare const RUNNER_USAGE = "machine-runner <command>\n\n enroll --relay <wss://host> --machine <uuid> --database <uuid> [--config <path>]\n [--allow bash,sh] [--cwd <dir>]\n reads the enrollment token from stdin or MACHINE_ENROLLMENT_TOKEN \u2014 never from argv\n\n start [--config <path>] dial the relay and serve sessions\n status [--config <path>] what this runner is enrolled in (never secrets)";
33
+ /**
34
+ * Run everything except `start`, which the entrypoint owns because it takes over
35
+ * the process. Returns false when the command was `start`, so the caller boots
36
+ * the daemon.
37
+ */
38
+ export declare function runRunnerCli(argv: string[], io: CliIo): Promise<boolean>;
package/cli.js ADDED
@@ -0,0 +1,175 @@
1
+ "use strict";
2
+ // The runner's own command surface: `enroll`, `start`, `status`.
3
+ //
4
+ // It is deliberately its own binary rather than a subcommand of the
5
+ // control-plane CLI: the runner is a daemon that needs a native `node-pty`
6
+ // build on the box it serves, and the box is usually not the machine the
7
+ // operator's credentials live on.
8
+ //
9
+ // The one rule that shapes `enroll`: the enrollment token is read from stdin or
10
+ // an environment variable and NEVER from argv. Shell history is a log, and a
11
+ // token in `~/.bash_history` outlives the exchange it was meant for. Passing
12
+ // `--token` is refused rather than accepted quietly.
13
+ var __importDefault = (this && this.__importDefault) || function (mod) {
14
+ return (mod && mod.__esModule) ? mod : { "default": mod };
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.RUNNER_USAGE = exports.TOKEN_ENV = void 0;
18
+ exports.defaultConfigPath = defaultConfigPath;
19
+ exports.parseFlags = parseFlags;
20
+ exports.resolveEnrollmentToken = resolveEnrollmentToken;
21
+ exports.writeEnrollment = writeEnrollment;
22
+ exports.runRunnerCli = runRunnerCli;
23
+ const fs_1 = __importDefault(require("fs"));
24
+ const os_1 = __importDefault(require("os"));
25
+ const path_1 = __importDefault(require("path"));
26
+ const config_1 = require("./config");
27
+ /** Env var carrying the enrollment token, for non-interactive installs. */
28
+ exports.TOKEN_ENV = 'MACHINE_ENROLLMENT_TOKEN';
29
+ /** Where the runner keeps its config when nothing says otherwise. */
30
+ function defaultConfigPath() {
31
+ return process.env.MACHINE_RUNNER_CONFIG || path_1.default.join(os_1.default.homedir(), '.machine-runner/config.json');
32
+ }
33
+ function parseFlags(argv) {
34
+ const flags = {};
35
+ for (let i = 0; i < argv.length; i += 1) {
36
+ const arg = argv[i];
37
+ if (!arg.startsWith('--'))
38
+ continue;
39
+ const name = arg.slice(2);
40
+ const next = argv[i + 1];
41
+ if (next === undefined || next.startsWith('--')) {
42
+ flags[name] = true;
43
+ }
44
+ else {
45
+ flags[name] = next;
46
+ i += 1;
47
+ }
48
+ }
49
+ return flags;
50
+ }
51
+ function requireFlag(flags, name) {
52
+ const value = flags[name];
53
+ if (typeof value !== 'string' || value.length === 0) {
54
+ throw new Error(`machine-runner: --${name} is required`);
55
+ }
56
+ return value;
57
+ }
58
+ function readTokenFromEnv(env) {
59
+ const value = env[exports.TOKEN_ENV];
60
+ return value && value.length > 0 ? value : null;
61
+ }
62
+ /**
63
+ * The token, from the environment or stdin. A token on the command line is a
64
+ * refusal, not a fallback — accepting it would put it in shell history, which
65
+ * is exactly the leak the paste-once flow exists to avoid.
66
+ */
67
+ async function resolveEnrollmentToken(flags, io) {
68
+ if ('token' in flags) {
69
+ throw new Error(`machine-runner: refusing a token on the command line — shell history is a log. ` +
70
+ `Pipe it on stdin or set ${exports.TOKEN_ENV}.`);
71
+ }
72
+ const fromEnv = readTokenFromEnv(io.env);
73
+ if (fromEnv)
74
+ return fromEnv;
75
+ const fromStdin = (await io.readStdin()).trim();
76
+ if (!fromStdin) {
77
+ throw new Error(`machine-runner: no enrollment token — paste it on stdin or set ${exports.TOKEN_ENV}`);
78
+ }
79
+ return fromStdin;
80
+ }
81
+ function defaultPolicy(flags) {
82
+ const allowed = typeof flags.allow === 'string' ? flags.allow.split(',').map(c => c.trim()) : null;
83
+ const cwd = typeof flags.cwd === 'string' ? flags.cwd : os_1.default.homedir();
84
+ return {
85
+ allowedCommands: allowed && allowed.length > 0 ? allowed : ['bash', 'sh', 'zsh'],
86
+ cwd
87
+ };
88
+ }
89
+ function readExistingConfig(configPath) {
90
+ if (!fs_1.default.existsSync(configPath))
91
+ return null;
92
+ // A config that exists but does not parse is a fault to surface, not to
93
+ // overwrite: overwriting would silently drop the machine's other enrollments.
94
+ return (0, config_1.loadRunnerConfig)(configPath);
95
+ }
96
+ /**
97
+ * Write (or extend) the runner config for one relay + machine. A machine can be
98
+ * enrolled in several databases, so an existing config is merged: the entry for
99
+ * the same machine at the same relay is replaced, everything else is kept.
100
+ */
101
+ function writeEnrollment(configPath, enrollment, policy) {
102
+ const existing = readExistingConfig(configPath);
103
+ const others = (existing?.enrollments ?? []).filter(e => !(e.machineId === enrollment.machineId && e.relayUrl === enrollment.relayUrl));
104
+ const next = (0, config_1.parseRunnerConfig)({
105
+ enrollments: [...others, enrollment],
106
+ policy: existing?.policy ?? policy
107
+ });
108
+ fs_1.default.mkdirSync(path_1.default.dirname(configPath), { recursive: true, mode: 0o700 });
109
+ // 0600 on the file: it holds the enrollment token until the first exchange
110
+ // replaces it with a short-lived credential.
111
+ fs_1.default.writeFileSync(configPath, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
112
+ fs_1.default.chmodSync(configPath, 0o600);
113
+ return next;
114
+ }
115
+ async function enroll(flags, io) {
116
+ const relayUrl = requireFlag(flags, 'relay');
117
+ const machineId = requireFlag(flags, 'machine');
118
+ const database = requireFlag(flags, 'database');
119
+ const configPath = typeof flags.config === 'string' ? flags.config : defaultConfigPath();
120
+ const token = await resolveEnrollmentToken(flags, io);
121
+ const config = writeEnrollment(configPath, { machineId, relayUrl, token, database }, defaultPolicy(flags));
122
+ // Facts only. The token was written to a 0600 file and is never echoed.
123
+ io.out(`enrolled machine ${machineId} at ${relayUrl}`);
124
+ io.out(`config ${configPath} (${config.enrollments.length} enrollment(s), mode 0600)`);
125
+ io.out(`allowed commands: ${config.policy.allowedCommands.join(', ')}`);
126
+ io.out('next: machine-runner start');
127
+ }
128
+ function status(flags, io) {
129
+ const configPath = typeof flags.config === 'string' ? flags.config : defaultConfigPath();
130
+ if (!fs_1.default.existsSync(configPath)) {
131
+ io.out(`no config at ${configPath} — run: machine-runner enroll --relay <wss://…> --machine <id> --database <id>`);
132
+ return;
133
+ }
134
+ const config = (0, config_1.loadRunnerConfig)(configPath);
135
+ const mode = (fs_1.default.statSync(configPath).mode & 0o777).toString(8);
136
+ io.out(`config ${configPath} (mode ${mode})`);
137
+ io.out(`cwd ${config.policy.cwd}`);
138
+ io.out(`allowed commands: ${config.policy.allowedCommands.join(', ')}`);
139
+ for (const enrollment of config.enrollments) {
140
+ // `secret stored` is the only thing said about the secret, ever.
141
+ io.out(`machine ${enrollment.machineId} relay ${enrollment.relayUrl} database ${enrollment.database} secret stored`);
142
+ }
143
+ }
144
+ exports.RUNNER_USAGE = `machine-runner <command>
145
+
146
+ enroll --relay <wss://host> --machine <uuid> --database <uuid> [--config <path>]
147
+ [--allow bash,sh] [--cwd <dir>]
148
+ reads the enrollment token from stdin or ${exports.TOKEN_ENV} — never from argv
149
+
150
+ start [--config <path>] dial the relay and serve sessions
151
+ status [--config <path>] what this runner is enrolled in (never secrets)`;
152
+ /**
153
+ * Run everything except `start`, which the entrypoint owns because it takes over
154
+ * the process. Returns false when the command was `start`, so the caller boots
155
+ * the daemon.
156
+ */
157
+ async function runRunnerCli(argv, io) {
158
+ const [command, ...rest] = argv;
159
+ const flags = parseFlags(rest);
160
+ switch (command) {
161
+ case 'enroll':
162
+ await enroll(flags, io);
163
+ return true;
164
+ case 'status':
165
+ status(flags, io);
166
+ return true;
167
+ case 'help':
168
+ case '--help':
169
+ case '-h':
170
+ io.out(exports.RUNNER_USAGE);
171
+ return true;
172
+ default:
173
+ return false;
174
+ }
175
+ }
package/config.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { RunnerPolicy } from './policy';
2
+ export interface Enrollment {
3
+ /** The machine's identity at this relay. */
4
+ machineId: string;
5
+ /** Where to dial out to, e.g. wss://relay.example.com. */
6
+ relayUrl: string;
7
+ /** The static machine token for this enrollment. Never logged. */
8
+ token: string;
9
+ /** The database this enrollment belongs to. */
10
+ database: string;
11
+ }
12
+ export interface RunnerConfig {
13
+ enrollments: Enrollment[];
14
+ policy: RunnerPolicy;
15
+ }
16
+ export declare function parseRunnerConfig(raw: unknown): RunnerConfig;
17
+ export declare function loadRunnerConfig(path: string): RunnerConfig;
package/config.js ADDED
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ // Runner configuration: a list of enrollments (one per relay/database this
3
+ // machine is enrolled in) plus the local policy. Loaded from a JSON file the
4
+ // machine's owner writes; malformed config is a deployment fault and throws.
5
+ var __importDefault = (this && this.__importDefault) || function (mod) {
6
+ return (mod && mod.__esModule) ? mod : { "default": mod };
7
+ };
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.parseRunnerConfig = parseRunnerConfig;
10
+ exports.loadRunnerConfig = loadRunnerConfig;
11
+ const fs_1 = __importDefault(require("fs"));
12
+ function requireString(obj, key, where) {
13
+ const value = obj[key];
14
+ if (typeof value !== 'string' || value.length === 0) {
15
+ throw new Error(`machine-runner config: ${where} is missing '${key}'`);
16
+ }
17
+ return value;
18
+ }
19
+ function parseRunnerConfig(raw) {
20
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
21
+ throw new Error('machine-runner config: root must be an object');
22
+ }
23
+ const root = raw;
24
+ if (!Array.isArray(root.enrollments) || root.enrollments.length === 0) {
25
+ throw new Error("machine-runner config: 'enrollments' must be a non-empty array");
26
+ }
27
+ const enrollments = root.enrollments.map((entry, i) => {
28
+ if (typeof entry !== 'object' || entry === null) {
29
+ throw new Error(`machine-runner config: enrollments[${i}] must be an object`);
30
+ }
31
+ const obj = entry;
32
+ const where = `enrollments[${i}]`;
33
+ return {
34
+ machineId: requireString(obj, 'machineId', where),
35
+ relayUrl: requireString(obj, 'relayUrl', where),
36
+ token: requireString(obj, 'token', where),
37
+ database: requireString(obj, 'database', where)
38
+ };
39
+ });
40
+ const policy = root.policy;
41
+ if (typeof policy !== 'object' || policy === null) {
42
+ throw new Error("machine-runner config: 'policy' must be an object");
43
+ }
44
+ const policyObj = policy;
45
+ if (!Array.isArray(policyObj.allowedCommands) ||
46
+ policyObj.allowedCommands.some(c => typeof c !== 'string')) {
47
+ throw new Error("machine-runner config: 'policy.allowedCommands' must be a string array");
48
+ }
49
+ if (typeof policyObj.cwd !== 'string' || policyObj.cwd.length === 0) {
50
+ throw new Error("machine-runner config: 'policy.cwd' is required");
51
+ }
52
+ return {
53
+ enrollments,
54
+ policy: policyObj
55
+ };
56
+ }
57
+ function loadRunnerConfig(path) {
58
+ let raw;
59
+ try {
60
+ raw = fs_1.default.readFileSync(path, 'utf8');
61
+ }
62
+ catch (err) {
63
+ throw new Error(`machine-runner config: cannot read '${path}': ${err instanceof Error ? err.message : err}`);
64
+ }
65
+ let parsed;
66
+ try {
67
+ parsed = JSON.parse(raw);
68
+ }
69
+ catch (err) {
70
+ throw new Error(`machine-runner config: '${path}' is not JSON: ${err instanceof Error ? err.message : err}`);
71
+ }
72
+ return parseRunnerConfig(parsed);
73
+ }
package/esm/cli.js ADDED
@@ -0,0 +1,164 @@
1
+ // The runner's own command surface: `enroll`, `start`, `status`.
2
+ //
3
+ // It is deliberately its own binary rather than a subcommand of the
4
+ // control-plane CLI: the runner is a daemon that needs a native `node-pty`
5
+ // build on the box it serves, and the box is usually not the machine the
6
+ // operator's credentials live on.
7
+ //
8
+ // The one rule that shapes `enroll`: the enrollment token is read from stdin or
9
+ // an environment variable and NEVER from argv. Shell history is a log, and a
10
+ // token in `~/.bash_history` outlives the exchange it was meant for. Passing
11
+ // `--token` is refused rather than accepted quietly.
12
+ import fs from 'fs';
13
+ import os from 'os';
14
+ import path from 'path';
15
+ import { loadRunnerConfig, parseRunnerConfig } from './config';
16
+ /** Env var carrying the enrollment token, for non-interactive installs. */
17
+ export const TOKEN_ENV = 'MACHINE_ENROLLMENT_TOKEN';
18
+ /** Where the runner keeps its config when nothing says otherwise. */
19
+ export function defaultConfigPath() {
20
+ return process.env.MACHINE_RUNNER_CONFIG || path.join(os.homedir(), '.machine-runner/config.json');
21
+ }
22
+ export function parseFlags(argv) {
23
+ const flags = {};
24
+ for (let i = 0; i < argv.length; i += 1) {
25
+ const arg = argv[i];
26
+ if (!arg.startsWith('--'))
27
+ continue;
28
+ const name = arg.slice(2);
29
+ const next = argv[i + 1];
30
+ if (next === undefined || next.startsWith('--')) {
31
+ flags[name] = true;
32
+ }
33
+ else {
34
+ flags[name] = next;
35
+ i += 1;
36
+ }
37
+ }
38
+ return flags;
39
+ }
40
+ function requireFlag(flags, name) {
41
+ const value = flags[name];
42
+ if (typeof value !== 'string' || value.length === 0) {
43
+ throw new Error(`machine-runner: --${name} is required`);
44
+ }
45
+ return value;
46
+ }
47
+ function readTokenFromEnv(env) {
48
+ const value = env[TOKEN_ENV];
49
+ return value && value.length > 0 ? value : null;
50
+ }
51
+ /**
52
+ * The token, from the environment or stdin. A token on the command line is a
53
+ * refusal, not a fallback — accepting it would put it in shell history, which
54
+ * is exactly the leak the paste-once flow exists to avoid.
55
+ */
56
+ export async function resolveEnrollmentToken(flags, io) {
57
+ if ('token' in flags) {
58
+ throw new Error(`machine-runner: refusing a token on the command line — shell history is a log. ` +
59
+ `Pipe it on stdin or set ${TOKEN_ENV}.`);
60
+ }
61
+ const fromEnv = readTokenFromEnv(io.env);
62
+ if (fromEnv)
63
+ return fromEnv;
64
+ const fromStdin = (await io.readStdin()).trim();
65
+ if (!fromStdin) {
66
+ throw new Error(`machine-runner: no enrollment token — paste it on stdin or set ${TOKEN_ENV}`);
67
+ }
68
+ return fromStdin;
69
+ }
70
+ function defaultPolicy(flags) {
71
+ const allowed = typeof flags.allow === 'string' ? flags.allow.split(',').map(c => c.trim()) : null;
72
+ const cwd = typeof flags.cwd === 'string' ? flags.cwd : os.homedir();
73
+ return {
74
+ allowedCommands: allowed && allowed.length > 0 ? allowed : ['bash', 'sh', 'zsh'],
75
+ cwd
76
+ };
77
+ }
78
+ function readExistingConfig(configPath) {
79
+ if (!fs.existsSync(configPath))
80
+ return null;
81
+ // A config that exists but does not parse is a fault to surface, not to
82
+ // overwrite: overwriting would silently drop the machine's other enrollments.
83
+ return loadRunnerConfig(configPath);
84
+ }
85
+ /**
86
+ * Write (or extend) the runner config for one relay + machine. A machine can be
87
+ * enrolled in several databases, so an existing config is merged: the entry for
88
+ * the same machine at the same relay is replaced, everything else is kept.
89
+ */
90
+ export function writeEnrollment(configPath, enrollment, policy) {
91
+ const existing = readExistingConfig(configPath);
92
+ const others = (existing?.enrollments ?? []).filter(e => !(e.machineId === enrollment.machineId && e.relayUrl === enrollment.relayUrl));
93
+ const next = parseRunnerConfig({
94
+ enrollments: [...others, enrollment],
95
+ policy: existing?.policy ?? policy
96
+ });
97
+ fs.mkdirSync(path.dirname(configPath), { recursive: true, mode: 0o700 });
98
+ // 0600 on the file: it holds the enrollment token until the first exchange
99
+ // replaces it with a short-lived credential.
100
+ fs.writeFileSync(configPath, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
101
+ fs.chmodSync(configPath, 0o600);
102
+ return next;
103
+ }
104
+ async function enroll(flags, io) {
105
+ const relayUrl = requireFlag(flags, 'relay');
106
+ const machineId = requireFlag(flags, 'machine');
107
+ const database = requireFlag(flags, 'database');
108
+ const configPath = typeof flags.config === 'string' ? flags.config : defaultConfigPath();
109
+ const token = await resolveEnrollmentToken(flags, io);
110
+ const config = writeEnrollment(configPath, { machineId, relayUrl, token, database }, defaultPolicy(flags));
111
+ // Facts only. The token was written to a 0600 file and is never echoed.
112
+ io.out(`enrolled machine ${machineId} at ${relayUrl}`);
113
+ io.out(`config ${configPath} (${config.enrollments.length} enrollment(s), mode 0600)`);
114
+ io.out(`allowed commands: ${config.policy.allowedCommands.join(', ')}`);
115
+ io.out('next: machine-runner start');
116
+ }
117
+ function status(flags, io) {
118
+ const configPath = typeof flags.config === 'string' ? flags.config : defaultConfigPath();
119
+ if (!fs.existsSync(configPath)) {
120
+ io.out(`no config at ${configPath} — run: machine-runner enroll --relay <wss://…> --machine <id> --database <id>`);
121
+ return;
122
+ }
123
+ const config = loadRunnerConfig(configPath);
124
+ const mode = (fs.statSync(configPath).mode & 0o777).toString(8);
125
+ io.out(`config ${configPath} (mode ${mode})`);
126
+ io.out(`cwd ${config.policy.cwd}`);
127
+ io.out(`allowed commands: ${config.policy.allowedCommands.join(', ')}`);
128
+ for (const enrollment of config.enrollments) {
129
+ // `secret stored` is the only thing said about the secret, ever.
130
+ io.out(`machine ${enrollment.machineId} relay ${enrollment.relayUrl} database ${enrollment.database} secret stored`);
131
+ }
132
+ }
133
+ export const RUNNER_USAGE = `machine-runner <command>
134
+
135
+ enroll --relay <wss://host> --machine <uuid> --database <uuid> [--config <path>]
136
+ [--allow bash,sh] [--cwd <dir>]
137
+ reads the enrollment token from stdin or ${TOKEN_ENV} — never from argv
138
+
139
+ start [--config <path>] dial the relay and serve sessions
140
+ status [--config <path>] what this runner is enrolled in (never secrets)`;
141
+ /**
142
+ * Run everything except `start`, which the entrypoint owns because it takes over
143
+ * the process. Returns false when the command was `start`, so the caller boots
144
+ * the daemon.
145
+ */
146
+ export async function runRunnerCli(argv, io) {
147
+ const [command, ...rest] = argv;
148
+ const flags = parseFlags(rest);
149
+ switch (command) {
150
+ case 'enroll':
151
+ await enroll(flags, io);
152
+ return true;
153
+ case 'status':
154
+ status(flags, io);
155
+ return true;
156
+ case 'help':
157
+ case '--help':
158
+ case '-h':
159
+ io.out(RUNNER_USAGE);
160
+ return true;
161
+ default:
162
+ return false;
163
+ }
164
+ }
package/esm/config.js ADDED
@@ -0,0 +1,66 @@
1
+ // Runner configuration: a list of enrollments (one per relay/database this
2
+ // machine is enrolled in) plus the local policy. Loaded from a JSON file the
3
+ // machine's owner writes; malformed config is a deployment fault and throws.
4
+ import fs from 'fs';
5
+ function requireString(obj, key, where) {
6
+ const value = obj[key];
7
+ if (typeof value !== 'string' || value.length === 0) {
8
+ throw new Error(`machine-runner config: ${where} is missing '${key}'`);
9
+ }
10
+ return value;
11
+ }
12
+ export function parseRunnerConfig(raw) {
13
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
14
+ throw new Error('machine-runner config: root must be an object');
15
+ }
16
+ const root = raw;
17
+ if (!Array.isArray(root.enrollments) || root.enrollments.length === 0) {
18
+ throw new Error("machine-runner config: 'enrollments' must be a non-empty array");
19
+ }
20
+ const enrollments = root.enrollments.map((entry, i) => {
21
+ if (typeof entry !== 'object' || entry === null) {
22
+ throw new Error(`machine-runner config: enrollments[${i}] must be an object`);
23
+ }
24
+ const obj = entry;
25
+ const where = `enrollments[${i}]`;
26
+ return {
27
+ machineId: requireString(obj, 'machineId', where),
28
+ relayUrl: requireString(obj, 'relayUrl', where),
29
+ token: requireString(obj, 'token', where),
30
+ database: requireString(obj, 'database', where)
31
+ };
32
+ });
33
+ const policy = root.policy;
34
+ if (typeof policy !== 'object' || policy === null) {
35
+ throw new Error("machine-runner config: 'policy' must be an object");
36
+ }
37
+ const policyObj = policy;
38
+ if (!Array.isArray(policyObj.allowedCommands) ||
39
+ policyObj.allowedCommands.some(c => typeof c !== 'string')) {
40
+ throw new Error("machine-runner config: 'policy.allowedCommands' must be a string array");
41
+ }
42
+ if (typeof policyObj.cwd !== 'string' || policyObj.cwd.length === 0) {
43
+ throw new Error("machine-runner config: 'policy.cwd' is required");
44
+ }
45
+ return {
46
+ enrollments,
47
+ policy: policyObj
48
+ };
49
+ }
50
+ export function loadRunnerConfig(path) {
51
+ let raw;
52
+ try {
53
+ raw = fs.readFileSync(path, 'utf8');
54
+ }
55
+ catch (err) {
56
+ throw new Error(`machine-runner config: cannot read '${path}': ${err instanceof Error ? err.message : err}`);
57
+ }
58
+ let parsed;
59
+ try {
60
+ parsed = JSON.parse(raw);
61
+ }
62
+ catch (err) {
63
+ throw new Error(`machine-runner config: '${path}' is not JSON: ${err instanceof Error ? err.message : err}`);
64
+ }
65
+ return parseRunnerConfig(parsed);
66
+ }
package/esm/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { defaultConfigPath, parseFlags, resolveEnrollmentToken, RUNNER_USAGE, runRunnerCli, TOKEN_ENV, writeEnrollment } from './cli';
2
+ export { loadRunnerConfig, parseRunnerConfig } from './config';
3
+ export { DEFAULT_ENV_ALLOW, PolicyViolationError, resolveSpawn } from './policy';
4
+ export { EnrollmentRunner, MachineRunner } from './runner';
package/esm/policy.js ADDED
@@ -0,0 +1,34 @@
1
+ // Local policy: the runner, not the relay, decides what may run on the
2
+ // machine it guards. The policy is written by the machine's owner and never
3
+ // travels over the wire — a compromised relay can ask, but the answer is
4
+ // decided here.
5
+ import os from 'os';
6
+ /** Pass-through vars a pty session cannot reasonably run without. */
7
+ export const DEFAULT_ENV_ALLOW = ['PATH', 'HOME', 'TERM', 'LANG', 'USER', 'SHELL'];
8
+ export class PolicyViolationError extends Error {
9
+ constructor(message) {
10
+ super(message);
11
+ this.name = 'PolicyViolationError';
12
+ }
13
+ }
14
+ export function resolveSpawn(policy, command, args = []) {
15
+ if (!policy.allowedCommands.includes(command)) {
16
+ throw new PolicyViolationError(`command '${command}' is not in the allowed command list`);
17
+ }
18
+ const allow = policy.env?.allow ?? DEFAULT_ENV_ALLOW;
19
+ const env = {};
20
+ for (const name of allow) {
21
+ const value = process.env[name];
22
+ if (value !== undefined)
23
+ env[name] = value;
24
+ }
25
+ for (const [name, value] of Object.entries(policy.env?.set ?? {})) {
26
+ env[name] = value;
27
+ }
28
+ return {
29
+ command,
30
+ args,
31
+ cwd: policy.cwd || os.homedir(),
32
+ env
33
+ };
34
+ }