@namewta/speculo 1.0.12 → 1.0.13

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.
@@ -1,7 +1,7 @@
1
1
  /** Strict host-key SSH and subprocess local transport. No passwords in argv. */
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { readFileSync } from "node:fs";
4
- import { dirname, join, resolve } from "node:path";
4
+ import { dirname, isAbsolute, join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { canonical, digest, noSymlinks, OpsError, redact, UnknownResult } from "./core.mjs";
7
7
  import { fingerprint } from "./agent.mjs";
@@ -30,6 +30,102 @@ export function hostTransportDigest(host) {
30
30
  return digest({ host, known_hosts_digest: kh });
31
31
  }
32
32
 
33
+ export function validateSshEndpoint(c) {
34
+ if (!c || typeof c !== "object") throw new OpsError("ssh endpoint missing");
35
+ for (const k of ["hostname", "username", "known_hosts"]) {
36
+ if (!c[k]) throw new OpsError("ssh connection missing " + k);
37
+ }
38
+ if (!/^[A-Za-z0-9_.:-]+$/.test(c.hostname) || c.hostname.startsWith("-")) throw new OpsError("unsafe SSH hostname");
39
+ if (!/^[A-Za-z0-9_.-]+$/.test(c.username) || c.username.startsWith("-")) throw new OpsError("unsafe SSH username");
40
+ if (![undefined, "posix", "powershell"].includes(c.shell)) throw new OpsError("unsupported remote shell");
41
+ }
42
+
43
+ export function sshArgv(connection, { scp = false } = {}) {
44
+ validateSshEndpoint(connection);
45
+ const kh = resolve(connection.known_hosts);
46
+ noSymlinks(kh, { allowMissing: false });
47
+ const argv = [
48
+ scp ? "scp" : "ssh",
49
+ ...(scp ? [] : ["-T"]),
50
+ "-o", "BatchMode=yes",
51
+ "-o", "StrictHostKeyChecking=yes",
52
+ "-o", `UserKnownHostsFile=${kh}`,
53
+ "-o", "ConnectTimeout=15",
54
+ "-o", "ServerAliveInterval=15",
55
+ "-o", "ServerAliveCountMax=3",
56
+ scp ? "-P" : "-p", String(connection.port ?? 22),
57
+ ];
58
+ if (connection.identity_file) argv.push("-i", connection.identity_file, "-o", "IdentitiesOnly=yes");
59
+ return argv;
60
+ }
61
+
62
+ function spawnTransport(argv, { input, timeout, encoding = "buffer" } = {}) {
63
+ return transportHooks.spawnSync(argv[0], argv.slice(1), {
64
+ input,
65
+ encoding,
66
+ timeout: timeout * 1000,
67
+ maxBuffer: 64 * 1024 * 1024,
68
+ windowsHide: true,
69
+ });
70
+ }
71
+
72
+ function buffers(p) {
73
+ const stdout = Buffer.isBuffer(p.stdout) ? p.stdout.toString("utf8") : (p.stdout || "");
74
+ const stderr = Buffer.isBuffer(p.stderr) ? p.stderr.toString("utf8") : (p.stderr || "");
75
+ return { stdout, stderr };
76
+ }
77
+
78
+ export function posixCall(endpoint, script, { timeout = 180, args = [], sudo = false } = {}) {
79
+ validateSshEndpoint(endpoint);
80
+ if ((endpoint.shell ?? "posix") === "powershell") {
81
+ throw new OpsError("POSIX bootstrap is Linux-only; PowerShell targets still require an existing Node");
82
+ }
83
+ const useSudo = sudo || endpoint.sudo;
84
+ let remote = ["/bin/sh", "-s", "--", ...args.map(String)];
85
+ if (useSudo) remote = ["sudo", "-n", "--", ...remote];
86
+ const argv = sshArgv(endpoint);
87
+ argv.push("--", endpoint.username + "@" + endpoint.hostname, shlexJoin(remote));
88
+ let p;
89
+ try {
90
+ p = spawnTransport(argv, { input: Buffer.from(String(script), "utf8"), timeout });
91
+ } catch (e) {
92
+ throw new OpsError("target posix unavailable: " + e);
93
+ }
94
+ if (p.error && (p.error.code === "ETIMEDOUT" || p.signal === "SIGTERM")) {
95
+ throw new OpsError("target posix unavailable: " + p.error);
96
+ }
97
+ const { stdout, stderr } = buffers(p);
98
+ if (p.status !== 0) {
99
+ throw new OpsError("target posix failed: " + redact(stderr, []).slice(-2000));
100
+ }
101
+ return { status: p.status, stdout, stderr };
102
+ }
103
+
104
+ export function posixSend(endpoint, localFile, remotePath, { timeout = 180 } = {}) {
105
+ validateSshEndpoint(endpoint);
106
+ if ((endpoint.shell ?? "posix") === "powershell") {
107
+ throw new OpsError("POSIX bootstrap is Linux-only; PowerShell targets still require an existing Node");
108
+ }
109
+ noSymlinks(localFile, { allowMissing: false });
110
+ if (typeof remotePath !== "string" || !isAbsolute(remotePath) || remotePath.split("/").includes("..") || /[\s\n\r]/.test(remotePath)) {
111
+ throw new OpsError("remote scp path must be absolute without spaces or ..");
112
+ }
113
+ const argv = sshArgv(endpoint, { scp: true });
114
+ argv.push("--", localFile, `${endpoint.username}@${endpoint.hostname}:${remotePath}`);
115
+ let p;
116
+ try {
117
+ p = spawnTransport(argv, { input: Buffer.alloc(0), timeout });
118
+ } catch (e) {
119
+ throw new OpsError("target scp unavailable: " + e);
120
+ }
121
+ if (p.error && (p.error.code === "ETIMEDOUT" || p.signal === "SIGTERM")) {
122
+ throw new OpsError("target scp unavailable: " + p.error);
123
+ }
124
+ const { stderr } = buffers(p);
125
+ if (p.status !== 0) throw new OpsError("target scp failed: " + redact(stderr, []).slice(-2000));
126
+ return { status: 0, remotePath };
127
+ }
128
+
33
129
  function injectRequest(source, request) {
34
130
  const b64 = Buffer.from(canonical(request)).toString("base64");
35
131
  const assign = `globalThis.OPS_REQUEST = JSON.parse(Buffer.from(${JSON.stringify(b64)}, "base64").toString("utf8"));`;
@@ -49,13 +145,7 @@ export function call(host, request, { timeout = 1800 } = {}) {
49
145
  const argv = [process.execPath, "--input-type=module"];
50
146
  let p;
51
147
  try {
52
- p = transportHooks.spawnSync(argv[0], argv.slice(1), {
53
- input: content,
54
- encoding: "utf8",
55
- timeout: timeout * 1000,
56
- maxBuffer: 64 * 1024 * 1024,
57
- windowsHide: true,
58
- });
148
+ p = spawnTransport(argv, { input: content, timeout, encoding: "utf8" });
59
149
  } catch (e) {
60
150
  if (["step", "lock", "unlock"].includes(request.action)) throw new UnknownResult("target transport interrupted; inspect receipts before retry");
61
151
  throw new OpsError("target read unavailable: " + e);
@@ -63,11 +153,7 @@ export function call(host, request, { timeout = 1800 } = {}) {
63
153
  return decode(p, request);
64
154
  }
65
155
  const c = host.connection;
66
- const kh = resolve(c.known_hosts);
67
- noSymlinks(kh, { allowMissing: false });
68
- const argv = ["ssh", "-T", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", "-o", `UserKnownHostsFile=${kh}`,
69
- "-o", "ConnectTimeout=15", "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=3", "-p", String(c.port ?? 22)];
70
- if (c.identity_file) argv.push("-i", c.identity_file, "-o", "IdentitiesOnly=yes");
156
+ const argv = sshArgv(c);
71
157
  let remote = [c.node, "--input-type=module"];
72
158
  if (c.sudo) remote = ["sudo", "-n", "--", ...remote];
73
159
  let remoteCommand;
@@ -82,13 +168,7 @@ export function call(host, request, { timeout = 1800 } = {}) {
82
168
  argv.push("--", c.username + "@" + c.hostname, remoteCommand);
83
169
  let p;
84
170
  try {
85
- p = transportHooks.spawnSync(argv[0], argv.slice(1), {
86
- input: Buffer.from(content, "utf8"),
87
- encoding: "buffer",
88
- timeout: timeout * 1000,
89
- maxBuffer: 64 * 1024 * 1024,
90
- windowsHide: true,
91
- });
171
+ p = spawnTransport(argv, { input: Buffer.from(content, "utf8"), timeout });
92
172
  } catch (e) {
93
173
  if (["step", "lock", "unlock"].includes(request.action)) throw new UnknownResult("target transport interrupted; inspect receipts before retry");
94
174
  throw new OpsError("target read unavailable: " + e);
@@ -101,8 +181,7 @@ function decode(p, request) {
101
181
  if (["step", "lock", "unlock"].includes(request.action)) throw new UnknownResult("target transport interrupted; inspect receipts before retry");
102
182
  throw new OpsError("target read unavailable: " + p.error);
103
183
  }
104
- const stdout = Buffer.isBuffer(p.stdout) ? p.stdout.toString("utf8") : (p.stdout || "");
105
- const stderr = Buffer.isBuffer(p.stderr) ? p.stderr.toString("utf8") : (p.stderr || "");
184
+ const { stdout, stderr } = buffers(p);
106
185
  if (p.status !== 0) {
107
186
  const text = redact(stderr, request.secrets || []).slice(-2000);
108
187
  if (["step", "lock", "unlock"].includes(request.action)) throw new UnknownResult("target transport failed: " + text);
@@ -27,6 +27,7 @@ try {
27
27
  for (const name of readdirSync(join(root, "common/schemas")).filter((x) => x.endsWith(".json"))) {
28
28
  JSON.parse(readFileSync(join(root, "common/schemas", name), "utf8"));
29
29
  }
30
+ JSON.parse(readFileSync(join(root, "common/toolchains/volta-linux.json"), "utf8"));
30
31
  validateStatus(JSON.parse(readFileSync(join(root, "_state/status.json"), "utf8")));
31
32
  console.log("resource schema valid");
32
33
  console.log(JSON.stringify({