@rome-os/node-core 0.1.1 → 0.1.3

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/dist/executor.js CHANGED
@@ -1,77 +1,142 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { platform } from "node:os";
3
3
  import { actionError, isRecord } from "./actions.js";
4
- export function createExecutor(name, send, secrets = []) {
4
+ import { decodeMeta, encodeMeta, FRAME_TYPE, frameFits, MAX_FRAME_BYTES, } from "./frame.js";
5
+ import { parseTransferMessage, TRANSFER_VERSION, TransferHost } from "./transfer.js";
6
+ function execInput(input) {
7
+ if (!isRecord(input) ||
8
+ typeof input.command !== "string" ||
9
+ !input.command ||
10
+ input.command.includes("\0") ||
11
+ (input.args !== undefined &&
12
+ (!Array.isArray(input.args) ||
13
+ !input.args.every((arg) => typeof arg === "string" && !arg.includes("\0")))) ||
14
+ (input.cwd !== undefined && (typeof input.cwd !== "string" || input.cwd.includes("\0"))))
15
+ return null;
16
+ return {
17
+ command: input.command,
18
+ args: (input.args ?? []),
19
+ cwd: input.cwd,
20
+ };
21
+ }
22
+ export function redactBytes(data, secrets) {
23
+ for (const secret of secrets) {
24
+ const needle = Buffer.from(secret);
25
+ if (!needle.byteLength)
26
+ continue;
27
+ for (let at = data.indexOf(needle); at !== -1; at = data.indexOf(needle, at + needle.length))
28
+ data.fill(0x2a, at, at + needle.byteLength);
29
+ }
30
+ return data;
31
+ }
32
+ export function createExecutor(name, send, secrets = [], sendFrame = () => false) {
5
33
  const children = new Set();
34
+ const transfers = new TransferHost();
6
35
  let generation = 0;
7
- const handlers = {
8
- "system.info": async () => ({
9
- type: "response",
10
- ok: true,
11
- result: {
12
- name,
13
- platform: platform() === "darwin" ? "macos" : platform() === "win32" ? "windows" : platform(),
14
- actions: ["system.info", "exec"],
15
- },
16
- }),
17
- exec: async (input) => {
18
- if (!isRecord(input) ||
19
- typeof input.command !== "string" ||
20
- !input.command ||
21
- input.command.includes("\0") ||
22
- (input.args !== undefined &&
23
- (!Array.isArray(input.args) ||
24
- !input.args.every((arg) => typeof arg === "string" && !arg.includes("\0")))) ||
25
- (input.cwd !== undefined && (typeof input.cwd !== "string" || input.cwd.includes("\0"))))
26
- return actionError("invalid_args", "exec requires command, optional string args[], and optional cwd.");
27
- if (children.size >= 8)
28
- return actionError("busy", "The computer is already running eight programs.");
29
- const command = input.command;
30
- const args = (input.args ?? []);
31
- const cwd = input.cwd;
32
- return new Promise((resolve) => {
33
- const environment = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(?:ROME_.*(?:TOKEN|SECRET|CREDENTIAL)|NODE_OPTIONS)$/i.test(key)));
34
- let child;
35
- try {
36
- child = spawn(command, args, {
37
- cwd,
38
- env: environment,
39
- shell: false,
40
- windowsHide: true,
41
- stdio: ["ignore", "pipe", "pipe"],
42
- });
43
- }
44
- catch {
45
- resolve(actionError("exec_failed", "Could not start the program."));
46
- return;
47
- }
48
- children.add(child);
49
- const output = { stdout: [], stderr: [] };
50
- for (const stream of ["stdout", "stderr"]) {
51
- child[stream]?.on("data", (chunk) => {
52
- output[stream].push(chunk);
53
- });
54
- }
55
- child.once("error", () => {
56
- children.delete(child);
57
- resolve(actionError("exec_failed", "Could not start the program. Check the executable and working directory."));
36
+ const info = () => ({
37
+ type: "response",
38
+ ok: true,
39
+ result: {
40
+ name,
41
+ platform: platform() === "darwin" ? "macos" : platform() === "win32" ? "windows" : platform(),
42
+ actions: ["system.info", "exec"],
43
+ frameVersion: 1,
44
+ transferVersion: TRANSFER_VERSION,
45
+ },
46
+ });
47
+ const invalidExec = () => actionError("invalid_args", "exec requires command, optional string args[], and optional cwd.");
48
+ function runProgram({ command, args, cwd }, stdin) {
49
+ if (children.size >= 8)
50
+ return Promise.resolve(actionError("busy", "The computer is already running eight programs."));
51
+ return new Promise((resolve) => {
52
+ const environment = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(?:ROME_.*(?:TOKEN|SECRET|CREDENTIAL)|NODE_OPTIONS)$/i.test(key)));
53
+ let child;
54
+ try {
55
+ child = spawn(command, args, {
56
+ cwd,
57
+ env: environment,
58
+ shell: false,
59
+ windowsHide: true,
60
+ stdio: [stdin ? "pipe" : "ignore", "pipe", "pipe"],
58
61
  });
59
- child.once("close", (exitCode, signal) => {
60
- children.delete(child);
61
- const redact = (chunks) => secrets.reduce((text, secret) => text.replaceAll(secret, "[redacted]"), Buffer.concat(chunks).toString("utf8"));
62
- resolve({
63
- type: "response",
64
- ok: true,
65
- result: {
66
- exitCode,
67
- signal,
68
- stdout: redact(output.stdout),
69
- stderr: redact(output.stderr),
70
- truncated: { stdout: false, stderr: false },
71
- },
72
- });
62
+ }
63
+ catch {
64
+ resolve(actionError("exec_failed", "Could not start the program."));
65
+ return;
66
+ }
67
+ children.add(child);
68
+ if (stdin && child.stdin) {
69
+ child.stdin.on("error", () => { });
70
+ child.stdin.end(stdin);
71
+ }
72
+ const output = { stdout: [], stderr: [] };
73
+ for (const stream of ["stdout", "stderr"]) {
74
+ child[stream]?.on("data", (chunk) => {
75
+ output[stream].push(chunk);
76
+ });
77
+ }
78
+ child.once("error", () => {
79
+ children.delete(child);
80
+ resolve(actionError("exec_failed", "Could not start the program. Check the executable and working directory."));
81
+ });
82
+ child.once("close", (exitCode, signal) => {
83
+ children.delete(child);
84
+ resolve({
85
+ exitCode,
86
+ signal,
87
+ stdout: Buffer.concat(output.stdout),
88
+ stderr: Buffer.concat(output.stderr),
73
89
  });
74
90
  });
91
+ });
92
+ }
93
+ const redactText = (data) => secrets.reduce((text, secret) => text.replaceAll(secret, "[redacted]"), data.toString("utf8"));
94
+ const isOutput = (value) => !("type" in value);
95
+ const handlers = {
96
+ "system.info": async () => info(),
97
+ exec: async (input) => {
98
+ const parsed = execInput(input);
99
+ if (!parsed)
100
+ return invalidExec();
101
+ const output = await runProgram(parsed);
102
+ if (!isOutput(output))
103
+ return output;
104
+ return {
105
+ type: "response",
106
+ ok: true,
107
+ result: {
108
+ exitCode: output.exitCode,
109
+ signal: output.signal,
110
+ stdout: redactText(output.stdout),
111
+ stderr: redactText(output.stderr),
112
+ truncated: { stdout: false, stderr: false },
113
+ },
114
+ };
115
+ },
116
+ };
117
+ const empty = new Uint8Array();
118
+ const frameHandlers = {
119
+ "system.info": async () => ({ response: info(), body: empty }),
120
+ exec: async (input, body) => {
121
+ const parsed = execInput(input);
122
+ if (!parsed)
123
+ return { response: invalidExec(), body: empty };
124
+ const output = await runProgram(parsed, body);
125
+ if (!isOutput(output))
126
+ return { response: output, body: empty };
127
+ return {
128
+ response: {
129
+ type: "response",
130
+ ok: true,
131
+ result: {
132
+ exitCode: output.exitCode,
133
+ signal: output.signal,
134
+ stderr: redactText(output.stderr),
135
+ truncated: { stdout: false, stderr: false },
136
+ },
137
+ },
138
+ body: redactBytes(output.stdout, secrets),
139
+ };
75
140
  },
76
141
  };
77
142
  return {
@@ -90,8 +155,57 @@ export function createExecutor(name, send, secrets = []) {
90
155
  if (current === generation)
91
156
  send(reply);
92
157
  },
158
+ async receiveFrame(frame) {
159
+ if (frame.type !== FRAME_TYPE.request)
160
+ return;
161
+ const request = decodeMeta(frame.meta);
162
+ const transfer = parseTransferMessage(request);
163
+ if (transfer) {
164
+ transfers.receive(frame.id, frame.peer, transfer, frame.body);
165
+ return;
166
+ }
167
+ if (!isRecord(request) || request.type !== "request" || typeof request.action !== "string")
168
+ return;
169
+ const current = generation;
170
+ if (request.action === "transfer.open") {
171
+ void transfers.open(frame.id, frame.peer, request.args, (meta, body = empty) => {
172
+ if (current !== generation)
173
+ return false;
174
+ return sendFrame({
175
+ type: FRAME_TYPE.response,
176
+ id: frame.id,
177
+ peer: frame.peer,
178
+ meta: encodeMeta(meta),
179
+ body,
180
+ });
181
+ });
182
+ return;
183
+ }
184
+ const handler = Object.hasOwn(frameHandlers, request.action)
185
+ ? frameHandlers[request.action]
186
+ : undefined;
187
+ const result = handler
188
+ ? await handler(request.args, frame.body).catch(() => ({
189
+ response: actionError("exec_failed", "The action failed."),
190
+ body: empty,
191
+ }))
192
+ : {
193
+ response: actionError("unsupported_action", "The computer does not support this action."),
194
+ body: empty,
195
+ };
196
+ if (current !== generation)
197
+ return;
198
+ let meta = encodeMeta(result.response);
199
+ let body = result.body;
200
+ if (!frameFits(meta.byteLength, body.byteLength)) {
201
+ meta = encodeMeta(actionError("output_too_large", `The output does not fit in one ${MAX_FRAME_BYTES / 1024 / 1024} MiB frame and was discarded. The program ran. Use rome-node cp to copy large files.`));
202
+ body = empty;
203
+ }
204
+ sendFrame({ type: FRAME_TYPE.response, id: frame.id, peer: frame.peer, meta, body });
205
+ },
93
206
  disconnect() {
94
207
  generation++;
208
+ transfers.abortAll();
95
209
  for (const child of children) {
96
210
  child.kill("SIGTERM");
97
211
  const timer = setTimeout(() => {
@@ -0,0 +1,24 @@
1
+ export declare const FRAME_VERSION = 1;
2
+ export declare const FRAME_TYPE: {
3
+ readonly request: 1;
4
+ readonly response: 2;
5
+ readonly routeError: 3;
6
+ };
7
+ export type FrameType = (typeof FRAME_TYPE)[keyof typeof FRAME_TYPE];
8
+ export declare const FRAME_HEADER_BYTES = 40;
9
+ export declare const MAX_FRAME_BYTES: number;
10
+ export declare function frameFits(metaBytes: number, bodyBytes: number): boolean;
11
+ export interface Frame {
12
+ type: FrameType;
13
+ id: string;
14
+ peer: string;
15
+ meta: Uint8Array;
16
+ body: Uint8Array;
17
+ }
18
+ export declare function isUuid(value: unknown): value is string;
19
+ export declare function uuidBytes(id: string): Uint8Array;
20
+ export declare function uuidString(bytes: Uint8Array): string;
21
+ export declare function parseFrame(data: Uint8Array): Frame | null;
22
+ export declare function encodeFrame(frame: Frame): Uint8Array;
23
+ export declare function encodeMeta(value: unknown): Uint8Array;
24
+ export declare function decodeMeta(meta: Uint8Array): unknown;
package/dist/frame.js ADDED
@@ -0,0 +1,64 @@
1
+ export const FRAME_VERSION = 1;
2
+ export const FRAME_TYPE = { request: 1, response: 2, routeError: 3 };
3
+ export const FRAME_HEADER_BYTES = 40;
4
+ export const MAX_FRAME_BYTES = 32 * 1024 * 1024;
5
+ export function frameFits(metaBytes, bodyBytes) {
6
+ return FRAME_HEADER_BYTES + metaBytes + bodyBytes <= MAX_FRAME_BYTES;
7
+ }
8
+ const PEER_OFFSET = 20;
9
+ const META_LENGTH_OFFSET = 36;
10
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
11
+ export function isUuid(value) {
12
+ return typeof value === "string" && UUID.test(value);
13
+ }
14
+ export function uuidBytes(id) {
15
+ if (!UUID.test(id))
16
+ throw new TypeError("UUID required");
17
+ return Buffer.from(id.replaceAll("-", ""), "hex");
18
+ }
19
+ export function uuidString(bytes) {
20
+ const hex = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("hex");
21
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
22
+ }
23
+ export function parseFrame(data) {
24
+ if (data.byteLength < FRAME_HEADER_BYTES)
25
+ return null;
26
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
27
+ const type = view.getUint8(1);
28
+ const metaEnd = FRAME_HEADER_BYTES + view.getUint32(META_LENGTH_OFFSET);
29
+ if (view.getUint8(0) !== FRAME_VERSION || view.getUint16(2) !== 0)
30
+ return null;
31
+ if (type < FRAME_TYPE.request || type > FRAME_TYPE.routeError || metaEnd > data.byteLength)
32
+ return null;
33
+ return {
34
+ type: type,
35
+ id: uuidString(data.subarray(4, PEER_OFFSET)),
36
+ peer: uuidString(data.subarray(PEER_OFFSET, META_LENGTH_OFFSET)),
37
+ meta: data.subarray(FRAME_HEADER_BYTES, metaEnd),
38
+ body: data.subarray(metaEnd),
39
+ };
40
+ }
41
+ export function encodeFrame(frame) {
42
+ const metaEnd = FRAME_HEADER_BYTES + frame.meta.byteLength;
43
+ const bytes = new Uint8Array(metaEnd + frame.body.byteLength);
44
+ const view = new DataView(bytes.buffer);
45
+ view.setUint8(0, FRAME_VERSION);
46
+ view.setUint8(1, frame.type);
47
+ view.setUint32(META_LENGTH_OFFSET, frame.meta.byteLength);
48
+ bytes.set(uuidBytes(frame.id), 4);
49
+ bytes.set(uuidBytes(frame.peer), PEER_OFFSET);
50
+ bytes.set(frame.meta, FRAME_HEADER_BYTES);
51
+ bytes.set(frame.body, metaEnd);
52
+ return bytes;
53
+ }
54
+ export function encodeMeta(value) {
55
+ return new TextEncoder().encode(JSON.stringify(value));
56
+ }
57
+ export function decodeMeta(meta) {
58
+ try {
59
+ return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(meta));
60
+ }
61
+ catch {
62
+ return undefined;
63
+ }
64
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { connectGateway, type GatewayClientOptions, type ConnectionStatus, type ClientSocket, } from "./client.js";
2
2
  export { MAX_MESSAGE_BYTES, byteLength, validId, type GatewayMessage, type InboundEnvelope, type OutboundEnvelope, } from "./protocol.js";
3
3
  export { CloudError, cloudOrigin, cloudRequest, gatewayConfig } from "./cloud.js";
4
- export { actionError, isRecord, parseResponse, type ActionResponse, type ActionRequest, } from "./actions.js";
4
+ export { actionError, isRecord, parseResponse, type ActionResponse, type ActionRequest, type BinaryActionResult, } from "./actions.js";
5
+ export { FRAME_TYPE, FRAME_VERSION, MAX_FRAME_BYTES, frameFits, decodeMeta, encodeFrame, encodeMeta, isUuid, parseFrame, uuidBytes, uuidString, type Frame, type FrameType, } from "./frame.js";
package/dist/index.js CHANGED
@@ -2,3 +2,4 @@ export { connectGateway, } from "./client.js";
2
2
  export { MAX_MESSAGE_BYTES, byteLength, validId, } from "./protocol.js";
3
3
  export { CloudError, cloudOrigin, cloudRequest, gatewayConfig } from "./cloud.js";
4
4
  export { actionError, isRecord, parseResponse, } from "./actions.js";
5
+ export { FRAME_TYPE, FRAME_VERSION, MAX_FRAME_BYTES, frameFits, decodeMeta, encodeFrame, encodeMeta, isUuid, parseFrame, uuidBytes, uuidString, } from "./frame.js";
@@ -0,0 +1,100 @@
1
+ import type { ConnectionStatus } from "./client.js";
2
+ export type Outcome = "ok" | "error" | "aborted" | "unknown_outcome" | "interrupted";
3
+ export declare const OUTCOMES: readonly Outcome[];
4
+ export interface ActionRow {
5
+ id: number;
6
+ direction: "in" | "out";
7
+ peer: string | null;
8
+ action: string;
9
+ mode: "json" | "binary" | "copy";
10
+ command: string | null;
11
+ args: string[] | string | null;
12
+ cwd: string | null;
13
+ startedAt: number;
14
+ endedAt: number | null;
15
+ outcome: Outcome | null;
16
+ errorCode: string | null;
17
+ exitCode: number | null;
18
+ signal: string | null;
19
+ stdinBytes: number;
20
+ stdoutBytes: number;
21
+ stderrBytes: number;
22
+ transfer: {
23
+ direction: "push" | "pull";
24
+ localPath: string;
25
+ remotePath: string;
26
+ bytes: number | null;
27
+ sha256: string | null;
28
+ } | null;
29
+ truncated?: true;
30
+ }
31
+ export interface RoleStatus {
32
+ deviceId?: string;
33
+ name?: string;
34
+ auth: "required" | "authorized" | "revoked";
35
+ connection: ConnectionStatus;
36
+ since: number;
37
+ reconnects: number;
38
+ running: number;
39
+ }
40
+ export interface NodeStatus {
41
+ version: string;
42
+ startedAt: number;
43
+ roles: {
44
+ caller?: RoleStatus;
45
+ host?: RoleStatus;
46
+ };
47
+ }
48
+ export type NodeEvent = {
49
+ type: "snapshot";
50
+ status: NodeStatus;
51
+ running: ActionRow[];
52
+ } | {
53
+ type: "connection";
54
+ role: "caller" | "host";
55
+ status: ConnectionStatus;
56
+ at: number;
57
+ } | {
58
+ type: "action";
59
+ id: number;
60
+ phase: "start" | "end";
61
+ row: ActionRow;
62
+ };
63
+ export type ActionResult = "running" | "succeeded" | "failed" | "no_result";
64
+ export declare const ACTION_RESULTS: readonly ActionResult[];
65
+ export interface LogQuery {
66
+ kind: "actions";
67
+ before?: number;
68
+ limit?: number;
69
+ direction?: "in" | "out";
70
+ peer?: string;
71
+ outcome?: Outcome[];
72
+ result?: ActionResult[];
73
+ since?: number;
74
+ until?: number;
75
+ text?: string;
76
+ }
77
+ export interface LogStatsQuery {
78
+ since: number;
79
+ until?: number;
80
+ bucketMs: number;
81
+ direction?: "in" | "out";
82
+ peer?: string;
83
+ }
84
+ interface LogStatsGroup {
85
+ peer: string | null;
86
+ bucket: number;
87
+ result: ActionResult;
88
+ count: number;
89
+ }
90
+ export interface LogStats {
91
+ since: number;
92
+ until: number;
93
+ bucketMs: number;
94
+ groups: LogStatsGroup[];
95
+ }
96
+ export interface LogResult<T> {
97
+ rows: T[];
98
+ hasMore: boolean;
99
+ }
100
+ export {};
@@ -0,0 +1,13 @@
1
+ export const OUTCOMES = [
2
+ "ok",
3
+ "error",
4
+ "aborted",
5
+ "unknown_outcome",
6
+ "interrupted",
7
+ ];
8
+ export const ACTION_RESULTS = [
9
+ "running",
10
+ "succeeded",
11
+ "failed",
12
+ "no_result",
13
+ ];
@@ -0,0 +1,67 @@
1
+ import { type ActionRow, type LogQuery, type LogResult, type LogStats, type LogStatsQuery, type Outcome } from "./observe-protocol.js";
2
+ export declare const MAX_ROW_CHARS: number;
3
+ export interface ObserveStoreOptions {
4
+ path: string;
5
+ secrets?: string[];
6
+ retentionMs?: number;
7
+ maxBytes?: number;
8
+ now?: () => number;
9
+ }
10
+ export interface ActionStart {
11
+ direction: "in" | "out";
12
+ peer: string | null;
13
+ action: string;
14
+ mode: ActionRow["mode"];
15
+ command?: string | null;
16
+ args?: string[] | string | null;
17
+ cwd?: string | null;
18
+ stdinBytes?: number;
19
+ transfer?: {
20
+ direction: "push" | "pull";
21
+ localPath: string;
22
+ remotePath: string;
23
+ } | null;
24
+ }
25
+ export interface ActionEnd {
26
+ outcome: Exclude<Outcome, "interrupted">;
27
+ errorCode?: string | null;
28
+ exitCode?: number | null;
29
+ signal?: string | null;
30
+ stdoutBytes?: number;
31
+ stderrBytes?: number;
32
+ transferBytes?: number | null;
33
+ sha256?: string | null;
34
+ }
35
+ export declare class QueryError extends Error {
36
+ }
37
+ export declare class ObserveStore {
38
+ private readonly options;
39
+ private db?;
40
+ private statements;
41
+ private readonly secrets;
42
+ private readonly retentionMs;
43
+ private readonly maxBytes;
44
+ private readonly now;
45
+ constructor(options: ObserveStoreOptions);
46
+ open(): void;
47
+ private attach;
48
+ private connect;
49
+ private discard;
50
+ private guard;
51
+ private statement;
52
+ private run;
53
+ private all;
54
+ private mask;
55
+ startAction(start: ActionStart): ActionRow | null;
56
+ endAction(id: number, end: ActionEnd): ActionRow | null;
57
+ private action;
58
+ running(): ActionRow[];
59
+ query(query: LogQuery): LogResult<ActionRow>;
60
+ private limit;
61
+ private queryActions;
62
+ stats(query: LogStatsQuery): LogStats;
63
+ prune(): void;
64
+ private compact;
65
+ private usedBytes;
66
+ close(): void;
67
+ }