@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 +128 -7
- package/esm/index.js +2 -1
- package/esm/policy.js +39 -2
- package/esm/process.js +101 -0
- package/esm/runner.js +41 -24
- package/index.d.ts +3 -1
- package/index.js +5 -1
- package/package.json +23 -19
- package/policy.d.ts +12 -2
- package/policy.js +40 -2
- package/process.d.ts +23 -0
- package/process.js +141 -0
- package/run.js +0 -0
- package/runner.js +41 -57
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
|
|
5
|
-
machine relay over WebSocket — it never
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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 --
|
|
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": "
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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 =
|
|
294
|
-
const rows =
|
|
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 =
|
|
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
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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 =
|
|
330
|
-
const rows =
|
|
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 =
|
|
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
|
|
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) {
|