@constructive-db/machine-runner 0.2.2 → 0.3.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/README.md CHANGED
@@ -88,6 +88,24 @@ and optionally a directory. The runner:
88
88
  - keeps a detached interactive session alive with a bounded scrollback until
89
89
  a client reattaches or the session is closed.
90
90
 
91
+ Separately from sessions, the runner answers two read-only path requests,
92
+ `path_list` and `path_read`, each naming a relative `path` under an optional
93
+ `root` (itself resolved under the policy's `cwd`, as an `open`'s `cwd` is). It:
94
+
95
+ - refuses an absolute path, a `..` segment or a NUL byte (`PATH_INVALID`), and
96
+ a path — or a symlink anywhere along it — that resolves outside the root
97
+ (`PATH_OUTSIDE_ROOT`);
98
+ - answers `path_listing` (name, kind, size; symlinks reported, not followed)
99
+ or `path_content` (base64 bytes);
100
+ - refuses a directory of more than `MAX_LIST_ENTRIES` entries
101
+ (`DIRECTORY_TOO_LARGE`) and a file of more than `MAX_READ_BYTES` bytes
102
+ (`FILE_TOO_LARGE`) rather than truncating either;
103
+ - answers every failure as an `error` frame carrying the request's
104
+ `requestId` and one of the `PATH_ERROR_CODES`.
105
+
106
+ It writes nothing. Which root a request may name is the relay's decision, not
107
+ the runner's: the runner only guarantees it stays under its policy.
108
+
91
109
  That is the whole contract. An agent session is one of these whose command
92
110
  happens to be an agent program — the relay composes that command and reads
93
111
  its output; the runner ran an allow-listed command:
package/esm/files.js ADDED
@@ -0,0 +1,179 @@
1
+ // Read-only access to paths under the policy cwd. The relay names a root (the
2
+ // same kind of value an `open` frame's `cwd` is: relative to, and confined to,
3
+ // the policy cwd) and a path relative to that root; the runner answers with a
4
+ // listing or a file's bytes. Nothing here knows what the root is for.
5
+ import { MAX_LIST_ENTRIES, MAX_READ_BYTES, relativePathProblem } from '@constructive-db/machine-protocol';
6
+ import { constants } from 'fs';
7
+ import fs from 'fs/promises';
8
+ import path from 'path';
9
+ import { PolicyViolationError, resolveCwd } from './policy';
10
+ /** A path request refused for a reason the requester can act on. */
11
+ export class PathAccessError extends Error {
12
+ code;
13
+ constructor(code, message) {
14
+ super(message);
15
+ this.code = code;
16
+ this.name = 'PathAccessError';
17
+ }
18
+ }
19
+ /** An errno failure as a refusal; anything else is a bug and propagates. */
20
+ function fsFailure(err, requested) {
21
+ const errno = err?.code;
22
+ if (typeof errno !== 'string')
23
+ throw err;
24
+ if (errno === 'ENOENT' || errno === 'ENOTDIR') {
25
+ return new PathAccessError('PATH_NOT_FOUND', `'${requested}' does not exist`);
26
+ }
27
+ return new PathAccessError('PATH_UNREADABLE', `'${requested}' could not be read: ${err.message}`);
28
+ }
29
+ /**
30
+ * The real path `requested` names under `root`. Refuses an absolute or
31
+ * traversing path outright, a root outside the policy cwd, and — after
32
+ * resolving every symlink — a target that is not the root or inside it.
33
+ */
34
+ export async function resolveUnder(policy, root, requested) {
35
+ const problem = relativePathProblem(requested);
36
+ if (problem)
37
+ throw new PathAccessError('PATH_INVALID', `path '${requested}' ${problem}`);
38
+ let base;
39
+ try {
40
+ base = resolveCwd(policy, root);
41
+ }
42
+ catch (err) {
43
+ if (!(err instanceof PolicyViolationError))
44
+ throw err;
45
+ throw new PathAccessError('PATH_OUTSIDE_ROOT', err.message);
46
+ }
47
+ let realBase;
48
+ try {
49
+ realBase = await fs.realpath(base);
50
+ }
51
+ catch (err) {
52
+ throw fsFailure(err, root ?? '.');
53
+ }
54
+ let real;
55
+ try {
56
+ real = await fs.realpath(path.join(base, requested));
57
+ }
58
+ catch (err) {
59
+ throw fsFailure(err, requested);
60
+ }
61
+ if (real !== realBase && !real.startsWith(realBase + path.sep)) {
62
+ throw new PathAccessError('PATH_OUTSIDE_ROOT', `'${requested}' resolves outside its root`);
63
+ }
64
+ return real;
65
+ }
66
+ /**
67
+ * Refuse unless `requested` still resolves under the root to the same file as
68
+ * `seen` (what was opened or listed). A path component swapped for a symlink
69
+ * after {@link resolveUnder} looked would otherwise be followed out of the root.
70
+ */
71
+ async function assertStillUnder(policy, root, requested, seen) {
72
+ const now = await fs.stat(await resolveUnder(policy, root, requested)).catch(err => {
73
+ if (err instanceof PathAccessError)
74
+ throw err;
75
+ throw fsFailure(err, requested);
76
+ });
77
+ if (now.dev !== seen.dev || now.ino !== seen.ino) {
78
+ throw new PathAccessError('PATH_OUTSIDE_ROOT', `'${requested}' changed while being read`);
79
+ }
80
+ }
81
+ function entryKind(entry) {
82
+ if (entry.isSymbolicLink())
83
+ return 'symlink';
84
+ if (entry.isFile())
85
+ return 'file';
86
+ if (entry.isDirectory())
87
+ return 'directory';
88
+ return 'other';
89
+ }
90
+ /**
91
+ * The entries of a directory under `root`, sorted by name. A directory with
92
+ * more than {@link MAX_LIST_ENTRIES} entries is refused, not truncated, and is
93
+ * not read past the limit.
94
+ */
95
+ export async function listPath(policy, root, requested) {
96
+ const target = await resolveUnder(policy, root, requested);
97
+ let dir;
98
+ let stat;
99
+ try {
100
+ stat = await fs.stat(target);
101
+ if (!stat.isDirectory()) {
102
+ throw new PathAccessError('NOT_A_DIRECTORY', `'${requested}' is not a directory`);
103
+ }
104
+ dir = await fs.opendir(target);
105
+ }
106
+ catch (err) {
107
+ if (err instanceof PathAccessError)
108
+ throw err;
109
+ throw fsFailure(err, requested);
110
+ }
111
+ const entries = [];
112
+ try {
113
+ for await (const dirent of dir) {
114
+ if (entries.length === MAX_LIST_ENTRIES) {
115
+ throw new PathAccessError('DIRECTORY_TOO_LARGE', `'${requested}' has more than ${MAX_LIST_ENTRIES} entries`);
116
+ }
117
+ const kind = entryKind(dirent);
118
+ const entry = { name: dirent.name, kind };
119
+ if (kind === 'file')
120
+ entry.size = (await fs.lstat(path.join(target, dirent.name))).size;
121
+ entries.push(entry);
122
+ }
123
+ }
124
+ catch (err) {
125
+ // Throwing out of `for await` closes the directory handle.
126
+ if (err instanceof PathAccessError)
127
+ throw err;
128
+ throw fsFailure(err, requested);
129
+ }
130
+ await assertStillUnder(policy, root, requested, stat);
131
+ return entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
132
+ }
133
+ /**
134
+ * A file's bytes, base64. A file over {@link MAX_READ_BYTES} is refused, not
135
+ * truncated — including one that grew past the limit after it was measured.
136
+ * The final component is opened `O_NOFOLLOW`, so a symlink swapped in after
137
+ * {@link resolveUnder} looked is refused rather than followed.
138
+ */
139
+ export async function readPath(policy, root, requested) {
140
+ const target = await resolveUnder(policy, root, requested);
141
+ let handle;
142
+ try {
143
+ const stat = await fs.stat(target);
144
+ if (!stat.isFile())
145
+ throw new PathAccessError('NOT_A_FILE', `'${requested}' is not a file`);
146
+ if (stat.size > MAX_READ_BYTES) {
147
+ throw new PathAccessError('FILE_TOO_LARGE', `'${requested}' is ${stat.size} bytes; the limit is ${MAX_READ_BYTES}`);
148
+ }
149
+ handle = await fs.open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
150
+ }
151
+ catch (err) {
152
+ if (err instanceof PathAccessError)
153
+ throw err;
154
+ throw fsFailure(err, requested);
155
+ }
156
+ try {
157
+ await assertStillUnder(policy, root, requested, await handle.stat());
158
+ const buffer = Buffer.alloc(MAX_READ_BYTES + 1);
159
+ let size = 0;
160
+ for (;;) {
161
+ const { bytesRead } = await handle.read(buffer, size, buffer.length - size, size);
162
+ if (bytesRead === 0)
163
+ break;
164
+ size += bytesRead;
165
+ if (size > MAX_READ_BYTES) {
166
+ throw new PathAccessError('FILE_TOO_LARGE', `'${requested}' grew past the ${MAX_READ_BYTES}-byte limit while being read`);
167
+ }
168
+ }
169
+ return { data: buffer.subarray(0, size).toString('base64'), size };
170
+ }
171
+ catch (err) {
172
+ if (err instanceof PathAccessError)
173
+ throw err;
174
+ throw fsFailure(err, requested);
175
+ }
176
+ finally {
177
+ await handle.close();
178
+ }
179
+ }
package/esm/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { defaultConfigPath, parseFlags, resolveEnrollmentToken, RUNNER_USAGE, runRunnerCli, TOKEN_ENV, writeEnrollment } from './cli';
2
2
  export { loadRunnerConfig, parseRunnerConfig } from './config';
3
+ export { listPath, PathAccessError, readPath, resolveUnder } from './files';
3
4
  export { DEFAULT_ENV_ALLOW, PolicyViolationError, resolveCwd, resolveSpawn } from './policy';
4
5
  export { pipeProcess, ptyProcess } from './process';
5
6
  export { EnrollmentRunner, MachineRunner } from './runner';
package/esm/runner.js CHANGED
@@ -19,8 +19,9 @@
19
19
  // `reattach` — from whichever client the tenant then authorizes — replays that
20
20
  // ring so the program repaints. The runner is the only place those bytes are
21
21
  // held in memory, and it holds a bounded window of them, never a transcript.
22
- import { decodeFrame, encodeFrame, HEADER_MACHINE_DATABASE, HEADER_MACHINE_ID, RUNNER_CONNECT_PATH } from '@constructive-db/machine-protocol';
22
+ import { decodeFrame, encodeFrame, HEADER_MACHINE_DATABASE, HEADER_MACHINE_ID, MAX_PATH_REQUESTS_IN_FLIGHT, RUNNER_CONNECT_PATH } from '@constructive-db/machine-protocol';
23
23
  import { WebSocket } from 'ws';
24
+ import { listPath, PathAccessError, readPath } from './files';
24
25
  import { PolicyViolationError, resolveSpawn } from './policy';
25
26
  import { pipeProcess, ptyProcess } from './process';
26
27
  /** Stop presenting a credential this long before it expires. */
@@ -51,6 +52,7 @@ export class EnrollmentRunner {
51
52
  reconnectMaxMs;
52
53
  ws = null;
53
54
  sessions = new Map();
55
+ pathRequestsInFlight = 0;
54
56
  /**
55
57
  * The short-lived credential the relay minted for the last dial, held in
56
58
  * memory only: it is never logged and never written beside the enrollment
@@ -252,6 +254,22 @@ export class EnrollmentRunner {
252
254
  }
253
255
  return;
254
256
  }
257
+ case 'path_list':
258
+ case 'path_read':
259
+ if (this.pathRequestsInFlight >= MAX_PATH_REQUESTS_IN_FLIGHT) {
260
+ ws.send(encodeFrame({
261
+ type: 'error',
262
+ requestId: frame.requestId,
263
+ code: 'TOO_MANY_PATH_REQUESTS',
264
+ message: `${MAX_PATH_REQUESTS_IN_FLIGHT} path requests are already being answered`
265
+ }));
266
+ return;
267
+ }
268
+ this.pathRequestsInFlight += 1;
269
+ void this.answerPath(ws, frame).finally(() => {
270
+ this.pathRequestsInFlight -= 1;
271
+ });
272
+ return;
255
273
  case 'enrolled':
256
274
  // The exchange's result: the tenant resolved this machine to a principal
257
275
  // and minted a credential to dial with next time. Its value never reaches
@@ -265,6 +283,39 @@ export class EnrollmentRunner {
265
283
  ws.close(4002, 'protocol error');
266
284
  }
267
285
  }
286
+ /**
287
+ * Answer a read-only path request on the socket it arrived on: a listing, a
288
+ * file's bytes, or an `error` carrying the request id and why. A refusal
289
+ * is the requester's to act on; any other failure is also logged here.
290
+ */
291
+ async answerPath(ws, frame) {
292
+ const { requestId } = frame;
293
+ let reply;
294
+ try {
295
+ reply =
296
+ frame.type === 'path_list'
297
+ ? {
298
+ type: 'path_listing',
299
+ requestId,
300
+ entries: await listPath(this.policy, frame.root, frame.path)
301
+ }
302
+ : { type: 'path_content', requestId, ...(await readPath(this.policy, frame.root, frame.path)) };
303
+ }
304
+ catch (err) {
305
+ const message = err instanceof Error ? err.message : String(err);
306
+ if (!(err instanceof PathAccessError)) {
307
+ this.logger.error(`machine-runner: ${frame.type} '${frame.path}' failed: ${message}`);
308
+ }
309
+ reply = {
310
+ type: 'error',
311
+ requestId,
312
+ message,
313
+ code: err instanceof PathAccessError ? err.code : 'PATH_UNREADABLE'
314
+ };
315
+ }
316
+ if (ws.readyState === WebSocket.OPEN)
317
+ ws.send(encodeFrame(reply));
318
+ }
268
319
  /** The session a frame addresses, or an `error` frame back and undefined. */
269
320
  require(ws, sessionId) {
270
321
  const session = this.sessions.get(sessionId);
package/files.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ import { PathEntry, PathErrorCode } from '@constructive-db/machine-protocol';
2
+ import { RunnerPolicy } from './policy';
3
+ /** A path request refused for a reason the requester can act on. */
4
+ export declare class PathAccessError extends Error {
5
+ readonly code: PathErrorCode;
6
+ constructor(code: PathErrorCode, message: string);
7
+ }
8
+ /**
9
+ * The real path `requested` names under `root`. Refuses an absolute or
10
+ * traversing path outright, a root outside the policy cwd, and — after
11
+ * resolving every symlink — a target that is not the root or inside it.
12
+ */
13
+ export declare function resolveUnder(policy: RunnerPolicy, root: string | undefined, requested: string): Promise<string>;
14
+ /**
15
+ * The entries of a directory under `root`, sorted by name. A directory with
16
+ * more than {@link MAX_LIST_ENTRIES} entries is refused, not truncated, and is
17
+ * not read past the limit.
18
+ */
19
+ export declare function listPath(policy: RunnerPolicy, root: string | undefined, requested: string): Promise<PathEntry[]>;
20
+ /**
21
+ * A file's bytes, base64. A file over {@link MAX_READ_BYTES} is refused, not
22
+ * truncated — including one that grew past the limit after it was measured.
23
+ * The final component is opened `O_NOFOLLOW`, so a symlink swapped in after
24
+ * {@link resolveUnder} looked is refused rather than followed.
25
+ */
26
+ export declare function readPath(policy: RunnerPolicy, root: string | undefined, requested: string): Promise<{
27
+ data: string;
28
+ size: number;
29
+ }>;
package/files.js ADDED
@@ -0,0 +1,189 @@
1
+ "use strict";
2
+ // Read-only access to paths under the policy cwd. The relay names a root (the
3
+ // same kind of value an `open` frame's `cwd` is: relative to, and confined to,
4
+ // the policy cwd) and a path relative to that root; the runner answers with a
5
+ // listing or a file's bytes. Nothing here knows what the root is for.
6
+ var __importDefault = (this && this.__importDefault) || function (mod) {
7
+ return (mod && mod.__esModule) ? mod : { "default": mod };
8
+ };
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.PathAccessError = void 0;
11
+ exports.resolveUnder = resolveUnder;
12
+ exports.listPath = listPath;
13
+ exports.readPath = readPath;
14
+ const machine_protocol_1 = require("@constructive-db/machine-protocol");
15
+ const fs_1 = require("fs");
16
+ const promises_1 = __importDefault(require("fs/promises"));
17
+ const path_1 = __importDefault(require("path"));
18
+ const policy_1 = require("./policy");
19
+ /** A path request refused for a reason the requester can act on. */
20
+ class PathAccessError extends Error {
21
+ code;
22
+ constructor(code, message) {
23
+ super(message);
24
+ this.code = code;
25
+ this.name = 'PathAccessError';
26
+ }
27
+ }
28
+ exports.PathAccessError = PathAccessError;
29
+ /** An errno failure as a refusal; anything else is a bug and propagates. */
30
+ function fsFailure(err, requested) {
31
+ const errno = err?.code;
32
+ if (typeof errno !== 'string')
33
+ throw err;
34
+ if (errno === 'ENOENT' || errno === 'ENOTDIR') {
35
+ return new PathAccessError('PATH_NOT_FOUND', `'${requested}' does not exist`);
36
+ }
37
+ return new PathAccessError('PATH_UNREADABLE', `'${requested}' could not be read: ${err.message}`);
38
+ }
39
+ /**
40
+ * The real path `requested` names under `root`. Refuses an absolute or
41
+ * traversing path outright, a root outside the policy cwd, and — after
42
+ * resolving every symlink — a target that is not the root or inside it.
43
+ */
44
+ async function resolveUnder(policy, root, requested) {
45
+ const problem = (0, machine_protocol_1.relativePathProblem)(requested);
46
+ if (problem)
47
+ throw new PathAccessError('PATH_INVALID', `path '${requested}' ${problem}`);
48
+ let base;
49
+ try {
50
+ base = (0, policy_1.resolveCwd)(policy, root);
51
+ }
52
+ catch (err) {
53
+ if (!(err instanceof policy_1.PolicyViolationError))
54
+ throw err;
55
+ throw new PathAccessError('PATH_OUTSIDE_ROOT', err.message);
56
+ }
57
+ let realBase;
58
+ try {
59
+ realBase = await promises_1.default.realpath(base);
60
+ }
61
+ catch (err) {
62
+ throw fsFailure(err, root ?? '.');
63
+ }
64
+ let real;
65
+ try {
66
+ real = await promises_1.default.realpath(path_1.default.join(base, requested));
67
+ }
68
+ catch (err) {
69
+ throw fsFailure(err, requested);
70
+ }
71
+ if (real !== realBase && !real.startsWith(realBase + path_1.default.sep)) {
72
+ throw new PathAccessError('PATH_OUTSIDE_ROOT', `'${requested}' resolves outside its root`);
73
+ }
74
+ return real;
75
+ }
76
+ /**
77
+ * Refuse unless `requested` still resolves under the root to the same file as
78
+ * `seen` (what was opened or listed). A path component swapped for a symlink
79
+ * after {@link resolveUnder} looked would otherwise be followed out of the root.
80
+ */
81
+ async function assertStillUnder(policy, root, requested, seen) {
82
+ const now = await promises_1.default.stat(await resolveUnder(policy, root, requested)).catch(err => {
83
+ if (err instanceof PathAccessError)
84
+ throw err;
85
+ throw fsFailure(err, requested);
86
+ });
87
+ if (now.dev !== seen.dev || now.ino !== seen.ino) {
88
+ throw new PathAccessError('PATH_OUTSIDE_ROOT', `'${requested}' changed while being read`);
89
+ }
90
+ }
91
+ function entryKind(entry) {
92
+ if (entry.isSymbolicLink())
93
+ return 'symlink';
94
+ if (entry.isFile())
95
+ return 'file';
96
+ if (entry.isDirectory())
97
+ return 'directory';
98
+ return 'other';
99
+ }
100
+ /**
101
+ * The entries of a directory under `root`, sorted by name. A directory with
102
+ * more than {@link MAX_LIST_ENTRIES} entries is refused, not truncated, and is
103
+ * not read past the limit.
104
+ */
105
+ async function listPath(policy, root, requested) {
106
+ const target = await resolveUnder(policy, root, requested);
107
+ let dir;
108
+ let stat;
109
+ try {
110
+ stat = await promises_1.default.stat(target);
111
+ if (!stat.isDirectory()) {
112
+ throw new PathAccessError('NOT_A_DIRECTORY', `'${requested}' is not a directory`);
113
+ }
114
+ dir = await promises_1.default.opendir(target);
115
+ }
116
+ catch (err) {
117
+ if (err instanceof PathAccessError)
118
+ throw err;
119
+ throw fsFailure(err, requested);
120
+ }
121
+ const entries = [];
122
+ try {
123
+ for await (const dirent of dir) {
124
+ if (entries.length === machine_protocol_1.MAX_LIST_ENTRIES) {
125
+ throw new PathAccessError('DIRECTORY_TOO_LARGE', `'${requested}' has more than ${machine_protocol_1.MAX_LIST_ENTRIES} entries`);
126
+ }
127
+ const kind = entryKind(dirent);
128
+ const entry = { name: dirent.name, kind };
129
+ if (kind === 'file')
130
+ entry.size = (await promises_1.default.lstat(path_1.default.join(target, dirent.name))).size;
131
+ entries.push(entry);
132
+ }
133
+ }
134
+ catch (err) {
135
+ // Throwing out of `for await` closes the directory handle.
136
+ if (err instanceof PathAccessError)
137
+ throw err;
138
+ throw fsFailure(err, requested);
139
+ }
140
+ await assertStillUnder(policy, root, requested, stat);
141
+ return entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
142
+ }
143
+ /**
144
+ * A file's bytes, base64. A file over {@link MAX_READ_BYTES} is refused, not
145
+ * truncated — including one that grew past the limit after it was measured.
146
+ * The final component is opened `O_NOFOLLOW`, so a symlink swapped in after
147
+ * {@link resolveUnder} looked is refused rather than followed.
148
+ */
149
+ async function readPath(policy, root, requested) {
150
+ const target = await resolveUnder(policy, root, requested);
151
+ let handle;
152
+ try {
153
+ const stat = await promises_1.default.stat(target);
154
+ if (!stat.isFile())
155
+ throw new PathAccessError('NOT_A_FILE', `'${requested}' is not a file`);
156
+ if (stat.size > machine_protocol_1.MAX_READ_BYTES) {
157
+ throw new PathAccessError('FILE_TOO_LARGE', `'${requested}' is ${stat.size} bytes; the limit is ${machine_protocol_1.MAX_READ_BYTES}`);
158
+ }
159
+ handle = await promises_1.default.open(target, fs_1.constants.O_RDONLY | fs_1.constants.O_NOFOLLOW);
160
+ }
161
+ catch (err) {
162
+ if (err instanceof PathAccessError)
163
+ throw err;
164
+ throw fsFailure(err, requested);
165
+ }
166
+ try {
167
+ await assertStillUnder(policy, root, requested, await handle.stat());
168
+ const buffer = Buffer.alloc(machine_protocol_1.MAX_READ_BYTES + 1);
169
+ let size = 0;
170
+ for (;;) {
171
+ const { bytesRead } = await handle.read(buffer, size, buffer.length - size, size);
172
+ if (bytesRead === 0)
173
+ break;
174
+ size += bytesRead;
175
+ if (size > machine_protocol_1.MAX_READ_BYTES) {
176
+ throw new PathAccessError('FILE_TOO_LARGE', `'${requested}' grew past the ${machine_protocol_1.MAX_READ_BYTES}-byte limit while being read`);
177
+ }
178
+ }
179
+ return { data: buffer.subarray(0, size).toString('base64'), size };
180
+ }
181
+ catch (err) {
182
+ if (err instanceof PathAccessError)
183
+ throw err;
184
+ throw fsFailure(err, requested);
185
+ }
186
+ finally {
187
+ await handle.close();
188
+ }
189
+ }
package/index.d.ts CHANGED
@@ -2,6 +2,7 @@ export type { CliIo } from './cli';
2
2
  export { defaultConfigPath, parseFlags, resolveEnrollmentToken, RUNNER_USAGE, runRunnerCli, TOKEN_ENV, writeEnrollment } from './cli';
3
3
  export type { Enrollment, RunnerConfig } from './config';
4
4
  export { loadRunnerConfig, parseRunnerConfig } from './config';
5
+ export { listPath, PathAccessError, readPath, resolveUnder } from './files';
5
6
  export type { RunnerPolicy, SpawnSpec } from './policy';
6
7
  export { DEFAULT_ENV_ALLOW, PolicyViolationError, resolveCwd, resolveSpawn } from './policy';
7
8
  export type { ProcessExit, SessionProcess } from './process';
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.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;
3
+ exports.MachineRunner = exports.EnrollmentRunner = exports.ptyProcess = exports.pipeProcess = exports.resolveSpawn = exports.resolveCwd = exports.PolicyViolationError = exports.DEFAULT_ENV_ALLOW = exports.resolveUnder = exports.readPath = exports.PathAccessError = exports.listPath = 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; } });
@@ -12,6 +12,11 @@ Object.defineProperty(exports, "writeEnrollment", { enumerable: true, get: funct
12
12
  var config_1 = require("./config");
13
13
  Object.defineProperty(exports, "loadRunnerConfig", { enumerable: true, get: function () { return config_1.loadRunnerConfig; } });
14
14
  Object.defineProperty(exports, "parseRunnerConfig", { enumerable: true, get: function () { return config_1.parseRunnerConfig; } });
15
+ var files_1 = require("./files");
16
+ Object.defineProperty(exports, "listPath", { enumerable: true, get: function () { return files_1.listPath; } });
17
+ Object.defineProperty(exports, "PathAccessError", { enumerable: true, get: function () { return files_1.PathAccessError; } });
18
+ Object.defineProperty(exports, "readPath", { enumerable: true, get: function () { return files_1.readPath; } });
19
+ Object.defineProperty(exports, "resolveUnder", { enumerable: true, get: function () { return files_1.resolveUnder; } });
15
20
  var policy_1 = require("./policy");
16
21
  Object.defineProperty(exports, "DEFAULT_ENV_ALLOW", { enumerable: true, get: function () { return policy_1.DEFAULT_ENV_ALLOW; } });
17
22
  Object.defineProperty(exports, "PolicyViolationError", { enumerable: true, get: function () { return policy_1.PolicyViolationError; } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@constructive-db/machine-runner",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
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",
@@ -39,7 +39,7 @@
39
39
  "test": "jest"
40
40
  },
41
41
  "dependencies": {
42
- "@constructive-db/machine-protocol": "0.2.2",
42
+ "@constructive-db/machine-protocol": "0.3.0",
43
43
  "@pgpmjs/logger": "^2.26.0",
44
44
  "node-pty": "1.1.0",
45
45
  "ws": "^8.21.3"
@@ -47,5 +47,5 @@
47
47
  "devDependencies": {
48
48
  "@types/ws": "^8.5.13"
49
49
  },
50
- "gitHead": "81eed19332cc231de0fde02bc6e87002357f9536"
50
+ "gitHead": "bdcd72c542def42905ba3729304bf47e55085137"
51
51
  }
package/runner.d.ts CHANGED
@@ -18,6 +18,7 @@ export declare class EnrollmentRunner {
18
18
  private readonly reconnectMaxMs;
19
19
  private ws;
20
20
  private readonly sessions;
21
+ private pathRequestsInFlight;
21
22
  /**
22
23
  * The short-lived credential the relay minted for the last dial, held in
23
24
  * memory only: it is never logged and never written beside the enrollment
@@ -47,6 +48,12 @@ export declare class EnrollmentRunner {
47
48
  private send;
48
49
  private connect;
49
50
  private handleFrame;
51
+ /**
52
+ * Answer a read-only path request on the socket it arrived on: a listing, a
53
+ * file's bytes, or an `error` carrying the request id and why. A refusal
54
+ * is the requester's to act on; any other failure is also logged here.
55
+ */
56
+ private answerPath;
50
57
  /** The session a frame addresses, or an `error` frame back and undefined. */
51
58
  private require;
52
59
  /**
package/runner.js CHANGED
@@ -24,6 +24,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
24
24
  exports.MachineRunner = exports.EnrollmentRunner = void 0;
25
25
  const machine_protocol_1 = require("@constructive-db/machine-protocol");
26
26
  const ws_1 = require("ws");
27
+ const files_1 = require("./files");
27
28
  const policy_1 = require("./policy");
28
29
  const process_1 = require("./process");
29
30
  /** Stop presenting a credential this long before it expires. */
@@ -54,6 +55,7 @@ class EnrollmentRunner {
54
55
  reconnectMaxMs;
55
56
  ws = null;
56
57
  sessions = new Map();
58
+ pathRequestsInFlight = 0;
57
59
  /**
58
60
  * The short-lived credential the relay minted for the last dial, held in
59
61
  * memory only: it is never logged and never written beside the enrollment
@@ -255,6 +257,22 @@ class EnrollmentRunner {
255
257
  }
256
258
  return;
257
259
  }
260
+ case 'path_list':
261
+ case 'path_read':
262
+ if (this.pathRequestsInFlight >= machine_protocol_1.MAX_PATH_REQUESTS_IN_FLIGHT) {
263
+ ws.send((0, machine_protocol_1.encodeFrame)({
264
+ type: 'error',
265
+ requestId: frame.requestId,
266
+ code: 'TOO_MANY_PATH_REQUESTS',
267
+ message: `${machine_protocol_1.MAX_PATH_REQUESTS_IN_FLIGHT} path requests are already being answered`
268
+ }));
269
+ return;
270
+ }
271
+ this.pathRequestsInFlight += 1;
272
+ void this.answerPath(ws, frame).finally(() => {
273
+ this.pathRequestsInFlight -= 1;
274
+ });
275
+ return;
258
276
  case 'enrolled':
259
277
  // The exchange's result: the tenant resolved this machine to a principal
260
278
  // and minted a credential to dial with next time. Its value never reaches
@@ -268,6 +286,39 @@ class EnrollmentRunner {
268
286
  ws.close(4002, 'protocol error');
269
287
  }
270
288
  }
289
+ /**
290
+ * Answer a read-only path request on the socket it arrived on: a listing, a
291
+ * file's bytes, or an `error` carrying the request id and why. A refusal
292
+ * is the requester's to act on; any other failure is also logged here.
293
+ */
294
+ async answerPath(ws, frame) {
295
+ const { requestId } = frame;
296
+ let reply;
297
+ try {
298
+ reply =
299
+ frame.type === 'path_list'
300
+ ? {
301
+ type: 'path_listing',
302
+ requestId,
303
+ entries: await (0, files_1.listPath)(this.policy, frame.root, frame.path)
304
+ }
305
+ : { type: 'path_content', requestId, ...(await (0, files_1.readPath)(this.policy, frame.root, frame.path)) };
306
+ }
307
+ catch (err) {
308
+ const message = err instanceof Error ? err.message : String(err);
309
+ if (!(err instanceof files_1.PathAccessError)) {
310
+ this.logger.error(`machine-runner: ${frame.type} '${frame.path}' failed: ${message}`);
311
+ }
312
+ reply = {
313
+ type: 'error',
314
+ requestId,
315
+ message,
316
+ code: err instanceof files_1.PathAccessError ? err.code : 'PATH_UNREADABLE'
317
+ };
318
+ }
319
+ if (ws.readyState === ws_1.WebSocket.OPEN)
320
+ ws.send((0, machine_protocol_1.encodeFrame)(reply));
321
+ }
271
322
  /** The session a frame addresses, or an `error` frame back and undefined. */
272
323
  require(ws, sessionId) {
273
324
  const session = this.sessions.get(sessionId);