@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 +21 -0
- package/README.md +54 -0
- package/cli.d.ts +38 -0
- package/cli.js +175 -0
- package/config.d.ts +17 -0
- package/config.js +73 -0
- package/esm/cli.js +164 -0
- package/esm/config.js +66 -0
- package/esm/index.js +4 -0
- package/esm/policy.js +34 -0
- package/esm/run.js +82 -0
- package/esm/runner.js +378 -0
- package/esm/spawn-helper.js +46 -0
- package/index.d.ts +8 -0
- package/index.js +21 -0
- package/package.json +47 -0
- package/policy.d.ts +27 -0
- package/policy.js +42 -0
- package/run.d.ts +8 -0
- package/run.js +85 -0
- package/runner.d.ts +76 -0
- package/runner.js +416 -0
- package/spawn-helper.d.ts +8 -0
- package/spawn-helper.js +53 -0
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
|
+
}
|