@botbuddy/cli 1.59.7 → 1.59.9
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/daemon/bin/darwin-arm64/botbuddy-daemon +0 -0
- package/daemon/bin/linux-x64/botbuddy-daemon +0 -0
- package/daemon-release/darwin-arm64.json +7 -0
- package/daemon-release/linux-x64.json +7 -0
- package/daemon-release/manifest.json +22 -0
- package/package.json +3 -1
- package/src/api.mjs +62 -0
- package/src/commands.mjs +6 -0
- package/src/daemon/approval-wait.mjs +126 -0
- package/src/daemon/bootstrap-resume.mjs +151 -0
- package/src/daemon/bootstrap.mjs +386 -0
- package/src/daemon/commands.mjs +478 -0
- package/src/daemon/enrollment.mjs +483 -0
- package/src/daemon/entrypoint.mjs +14 -0
- package/src/daemon/installer.mjs +438 -0
- package/src/daemon/profile-delivery.mjs +332 -0
- package/src/daemon/release.mjs +81 -0
- package/src/daemon/runtime-config.mjs +85 -0
- package/src/daemon/service.mjs +38 -0
- package/src/oauth-device.mjs +11 -0
- package/src/publish-native-guards.mjs +44 -0
- package/src/wait-core.mjs +63 -18
- package/src/wait.mjs +4 -1
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// BOT-2057: the requester-side handoff for a private daemon profile. Secrets
|
|
2
|
+
// enter this module only as the encoded stdin payload; the destination, sudo
|
|
3
|
+
// user and daemon argument vector are all independently validated and fixed.
|
|
4
|
+
import { spawn as realSpawn } from "node:child_process";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
encodeDaemonProfileInstallArtifact,
|
|
9
|
+
encodeDaemonProfileInstallFromBootstrapIdentityArtifact,
|
|
10
|
+
encodeDaemonProfileInstallFromHelperIdentityArtifact,
|
|
11
|
+
DaemonEnrollmentError,
|
|
12
|
+
} from "./enrollment.mjs";
|
|
13
|
+
import { assertSshHost } from "./installer.mjs";
|
|
14
|
+
|
|
15
|
+
const SSH_OPTIONS = Object.freeze([
|
|
16
|
+
"-o", "BatchMode=yes",
|
|
17
|
+
"-o", "ForwardAgent=no",
|
|
18
|
+
"-o", "StrictHostKeyChecking=yes",
|
|
19
|
+
]);
|
|
20
|
+
const SAFE_ABSOLUTE_PATH = /^\/(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+$/;
|
|
21
|
+
const SAFE_SERVICE_USER = /^[a-z_][a-z0-9_-]*$/i;
|
|
22
|
+
const DAEMON_HOST_KEY_RE = /^bbh-[0-9a-f]{24}$/;
|
|
23
|
+
const P256_SPKI_PREFIX = Buffer.from("3059301306072a8648ce3d020106082a8648ce3d030107034200", "hex");
|
|
24
|
+
const MAX_OUTPUT_BYTES = 16 * 1024;
|
|
25
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
26
|
+
|
|
27
|
+
const invalid = () => new DaemonEnrollmentError("daemon_profile_delivery_invalid");
|
|
28
|
+
const failed = () => new DaemonEnrollmentError("daemon_profile_delivery_failed");
|
|
29
|
+
|
|
30
|
+
function safeTransport({ sshHost, sshBin, spawnImpl, env, timeoutMs }) {
|
|
31
|
+
try { assertSshHost(sshHost); } catch { throw invalid(); }
|
|
32
|
+
if (
|
|
33
|
+
typeof sshBin !== "string" || sshBin.length === 0 ||
|
|
34
|
+
typeof spawnImpl !== "function" || !env || typeof env !== "object" ||
|
|
35
|
+
!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60_000
|
|
36
|
+
) throw invalid();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function safeInputs({ sshHost, serviceUser, daemonBinary, profilePath, expectedHostKey, sshBin, spawnImpl, env, timeoutMs, directInstall }) {
|
|
40
|
+
safeTransport({ sshHost, sshBin, spawnImpl, env, timeoutMs });
|
|
41
|
+
if (
|
|
42
|
+
typeof serviceUser !== "string" || !SAFE_SERVICE_USER.test(serviceUser) ||
|
|
43
|
+
typeof daemonBinary !== "string" || !SAFE_ABSOLUTE_PATH.test(daemonBinary) ||
|
|
44
|
+
typeof profilePath !== "string" || !SAFE_ABSOLUTE_PATH.test(profilePath) || !profilePath.endsWith("/profile.json") ||
|
|
45
|
+
typeof expectedHostKey !== "string" || !DAEMON_HOST_KEY_RE.test(expectedHostKey) ||
|
|
46
|
+
typeof directInstall !== "boolean"
|
|
47
|
+
) throw invalid();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function safeHelperIdentityInputs({ sshHost, serviceUser, daemonBinary, expectedHostKey, sshBin, spawnImpl, env, timeoutMs, directInstall }) {
|
|
51
|
+
safeTransport({ sshHost, sshBin, spawnImpl, env, timeoutMs });
|
|
52
|
+
if (
|
|
53
|
+
typeof serviceUser !== "string" || !SAFE_SERVICE_USER.test(serviceUser) ||
|
|
54
|
+
typeof daemonBinary !== "string" || !SAFE_ABSOLUTE_PATH.test(daemonBinary) ||
|
|
55
|
+
typeof expectedHostKey !== "string" || !DAEMON_HOST_KEY_RE.test(expectedHostKey) ||
|
|
56
|
+
typeof directInstall !== "boolean"
|
|
57
|
+
) throw invalid();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function safeBootstrapIdentityInputs({ sshHost, serviceUser, daemonBinary, sshBin, spawnImpl, env, timeoutMs, directInstall }) {
|
|
61
|
+
safeTransport({ sshHost, sshBin, spawnImpl, env, timeoutMs });
|
|
62
|
+
if (
|
|
63
|
+
typeof serviceUser !== "string" || !SAFE_SERVICE_USER.test(serviceUser) ||
|
|
64
|
+
typeof daemonBinary !== "string" || !SAFE_ABSOLUTE_PATH.test(daemonBinary) ||
|
|
65
|
+
typeof directInstall !== "boolean"
|
|
66
|
+
) throw invalid();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function remoteDaemonCommand({ serviceUser, daemonBinary, directInstall, args }) {
|
|
70
|
+
return directInstall ? [daemonBinary, ...args] : ["sudo", "-n", "-u", serviceUser, "--", daemonBinary, ...args];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function fixedSsh({ sshHost, sshBin, spawnImpl, env, timeoutMs, command, stdin = Buffer.alloc(0) }) {
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
let child;
|
|
76
|
+
let settled = false;
|
|
77
|
+
let timer;
|
|
78
|
+
let stdout = Buffer.alloc(0);
|
|
79
|
+
let stderr = Buffer.alloc(0);
|
|
80
|
+
const finish = (result, value) => {
|
|
81
|
+
if (settled) return;
|
|
82
|
+
settled = true;
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
result(value);
|
|
85
|
+
};
|
|
86
|
+
const refuse = () => finish(reject, failed());
|
|
87
|
+
const terminate = () => {
|
|
88
|
+
try { child?.kill("SIGTERM"); } catch { /* process may already be gone */ }
|
|
89
|
+
};
|
|
90
|
+
const append = (current, chunk) => {
|
|
91
|
+
const next = Buffer.concat([current, Buffer.from(chunk)]);
|
|
92
|
+
if (next.length > MAX_OUTPUT_BYTES) {
|
|
93
|
+
terminate();
|
|
94
|
+
refuse();
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return next;
|
|
98
|
+
};
|
|
99
|
+
try {
|
|
100
|
+
child = spawnImpl(sshBin, [...SSH_OPTIONS, sshHost, ...command], {
|
|
101
|
+
stdio: ["pipe", "pipe", "pipe"], shell: false, windowsHide: true, env,
|
|
102
|
+
});
|
|
103
|
+
} catch {
|
|
104
|
+
refuse();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (!child || !child.stdin || !child.stdout || !child.stderr || typeof child.on !== "function") {
|
|
108
|
+
terminate();
|
|
109
|
+
refuse();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
timer = setTimeout(() => { terminate(); refuse(); }, timeoutMs);
|
|
113
|
+
child.stdout.on("data", (chunk) => { const next = append(stdout, chunk); if (next) stdout = next; });
|
|
114
|
+
child.stderr.on("data", (chunk) => { const next = append(stderr, chunk); if (next) stderr = next; });
|
|
115
|
+
child.stdin.on("error", refuse);
|
|
116
|
+
child.on("error", refuse);
|
|
117
|
+
child.on("close", (code) => {
|
|
118
|
+
if (code !== 0 || stderr.length !== 0) {
|
|
119
|
+
refuse();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
finish(resolve, stdout);
|
|
123
|
+
});
|
|
124
|
+
try { child.stdin.end(stdin); } catch { terminate(); refuse(); }
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parseUid(output) {
|
|
129
|
+
const text = Buffer.from(output).toString("utf8");
|
|
130
|
+
if (!/^[1-9][0-9]{0,9}\n?$/.test(text)) throw failed();
|
|
131
|
+
const uid = Number.parseInt(text, 10);
|
|
132
|
+
if (!Number.isSafeInteger(uid) || uid > 2_147_483_647) throw failed();
|
|
133
|
+
return uid;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function parseBootstrapIdentityReceipt(output) {
|
|
137
|
+
const text = Buffer.from(output).toString("utf8");
|
|
138
|
+
let metadata;
|
|
139
|
+
try { metadata = JSON.parse(text); } catch { throw failed(); }
|
|
140
|
+
if (
|
|
141
|
+
!metadata || typeof metadata !== "object" || Array.isArray(metadata) ||
|
|
142
|
+
Object.keys(metadata).length !== 3 || metadata.version !== 1 ||
|
|
143
|
+
typeof metadata.host_key !== "string" || !DAEMON_HOST_KEY_RE.test(metadata.host_key) ||
|
|
144
|
+
typeof metadata.public_key_spki_base64 !== "string" ||
|
|
145
|
+
!/^[A-Za-z0-9+/]+={0,2}$/.test(metadata.public_key_spki_base64) ||
|
|
146
|
+
metadata.public_key_spki_base64.length % 4 !== 0
|
|
147
|
+
) throw failed();
|
|
148
|
+
if (text !== `${JSON.stringify(metadata)}\n`) throw failed();
|
|
149
|
+
let spki;
|
|
150
|
+
try { spki = Buffer.from(metadata.public_key_spki_base64, "base64"); } catch { throw failed(); }
|
|
151
|
+
if (
|
|
152
|
+
spki.toString("base64") !== metadata.public_key_spki_base64 ||
|
|
153
|
+
spki.length !== P256_SPKI_PREFIX.length + 65 ||
|
|
154
|
+
!spki.subarray(0, P256_SPKI_PREFIX.length).equals(P256_SPKI_PREFIX) ||
|
|
155
|
+
spki[P256_SPKI_PREFIX.length] !== 0x04
|
|
156
|
+
) throw failed();
|
|
157
|
+
const derivedHostKey = `bbh-${createHash("sha256").update(spki).digest("hex").slice(0, 24)}`;
|
|
158
|
+
if (derivedHostKey !== metadata.host_key) throw failed();
|
|
159
|
+
return {
|
|
160
|
+
version: metadata.version,
|
|
161
|
+
host_key: metadata.host_key,
|
|
162
|
+
public_key_spki_base64: metadata.public_key_spki_base64,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Read both remote UIDs over fixed, non-privileged SSH argv before OAuth is
|
|
168
|
+
* requested. The caller may choose direct installation only on an observed
|
|
169
|
+
* same-UID result; a JSON enrollment spec cannot claim that privilege.
|
|
170
|
+
*/
|
|
171
|
+
export async function verifyRemoteServiceIdentity({
|
|
172
|
+
sshHost,
|
|
173
|
+
serviceUser,
|
|
174
|
+
sshBin = "ssh",
|
|
175
|
+
spawnImpl = realSpawn,
|
|
176
|
+
env = process.env,
|
|
177
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
178
|
+
} = {}) {
|
|
179
|
+
safeTransport({ sshHost, sshBin, spawnImpl, env, timeoutMs });
|
|
180
|
+
if (typeof serviceUser !== "string" || !SAFE_SERVICE_USER.test(serviceUser)) throw invalid();
|
|
181
|
+
const sshUid = parseUid(await fixedSsh({ sshHost, sshBin, spawnImpl, env, timeoutMs, command: ["id", "-u"] }));
|
|
182
|
+
const serviceUid = parseUid(await fixedSsh({ sshHost, sshBin, spawnImpl, env, timeoutMs, command: ["id", "-u", serviceUser] }));
|
|
183
|
+
return { directInstall: sshUid === serviceUid };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function remoteBootstrapIdentity({
|
|
187
|
+
operation,
|
|
188
|
+
sshHost,
|
|
189
|
+
serviceUser,
|
|
190
|
+
daemonBinary,
|
|
191
|
+
directInstall,
|
|
192
|
+
sshBin = "ssh",
|
|
193
|
+
spawnImpl = realSpawn,
|
|
194
|
+
env = process.env,
|
|
195
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
196
|
+
} = {}) {
|
|
197
|
+
safeBootstrapIdentityInputs({ sshHost, serviceUser, daemonBinary, directInstall, sshBin, spawnImpl, env, timeoutMs });
|
|
198
|
+
if (operation !== "create" && operation !== "inspect") throw invalid();
|
|
199
|
+
const command = remoteDaemonCommand({
|
|
200
|
+
serviceUser, daemonBinary, directInstall,
|
|
201
|
+
args: [`--${operation}-bootstrap-identity`],
|
|
202
|
+
});
|
|
203
|
+
return parseBootstrapIdentityReceipt(await fixedSsh({ sshHost, sshBin, spawnImpl, env, timeoutMs, command }));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Creation is fresh-only on the target. It returns exactly the target's public
|
|
207
|
+
// metadata DTO; no key bytes, profile, approval, or registration are involved.
|
|
208
|
+
export async function createRemoteBootstrapIdentity(options = {}) {
|
|
209
|
+
return remoteBootstrapIdentity({ ...options, operation: "create" });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function inspectRemoteBootstrapIdentity(options = {}) {
|
|
213
|
+
return remoteBootstrapIdentity({ ...options, operation: "inspect" });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// This runs before device authorization. It never accepts a source path: the
|
|
217
|
+
// target daemon resolves the one canonical Swift Helper identity under its own
|
|
218
|
+
// UID and replies with a fixed, secret-free receipt.
|
|
219
|
+
export async function verifyRemoteHelperIdentity({
|
|
220
|
+
sshHost,
|
|
221
|
+
serviceUser,
|
|
222
|
+
daemonBinary,
|
|
223
|
+
expectedHostKey,
|
|
224
|
+
directInstall,
|
|
225
|
+
sshBin = "ssh",
|
|
226
|
+
spawnImpl = realSpawn,
|
|
227
|
+
env = process.env,
|
|
228
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
229
|
+
} = {}) {
|
|
230
|
+
safeHelperIdentityInputs({ sshHost, serviceUser, daemonBinary, expectedHostKey, directInstall, sshBin, spawnImpl, env, timeoutMs });
|
|
231
|
+
const command = remoteDaemonCommand({
|
|
232
|
+
serviceUser, daemonBinary, directInstall,
|
|
233
|
+
args: ["--check-helper-identity", "--expected-host-key", expectedHostKey],
|
|
234
|
+
});
|
|
235
|
+
const stdout = await fixedSsh({ sshHost, sshBin, spawnImpl, env, timeoutMs, command });
|
|
236
|
+
if (!stdout.equals(Buffer.from('{"identity":"verified"}\n'))) throw failed();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function deliverDaemonProfileWithInstaller({
|
|
240
|
+
artifact,
|
|
241
|
+
sshHost,
|
|
242
|
+
serviceUser,
|
|
243
|
+
daemonBinary,
|
|
244
|
+
profilePath,
|
|
245
|
+
expectedHostKey,
|
|
246
|
+
sshBin,
|
|
247
|
+
spawnImpl,
|
|
248
|
+
env,
|
|
249
|
+
timeoutMs,
|
|
250
|
+
directInstall,
|
|
251
|
+
encodeArtifact,
|
|
252
|
+
installFlag,
|
|
253
|
+
}) {
|
|
254
|
+
safeInputs({ sshHost, serviceUser, daemonBinary, profilePath, expectedHostKey, sshBin, spawnImpl, env, timeoutMs, directInstall });
|
|
255
|
+
let payload;
|
|
256
|
+
try { payload = encodeArtifact(artifact); } catch { throw invalid(); }
|
|
257
|
+
const command = remoteDaemonCommand({
|
|
258
|
+
serviceUser, daemonBinary, directInstall,
|
|
259
|
+
args: [installFlag, "--profile", profilePath, "--expected-host-key", expectedHostKey],
|
|
260
|
+
});
|
|
261
|
+
const stdout = await fixedSsh({ sshHost, sshBin, spawnImpl, env, timeoutMs, command, stdin: payload });
|
|
262
|
+
if (!stdout.equals(Buffer.from('{"installed":true}\n'))) throw failed();
|
|
263
|
+
return { installed: true };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Feed a verified, bounded artifact to the public daemon installer over a
|
|
268
|
+
* fixed SSH argv. This intentionally returns no remote diagnostics: SSH and
|
|
269
|
+
* installer output can contain attacker-controlled text or credential values.
|
|
270
|
+
*/
|
|
271
|
+
export async function deliverDaemonProfileOverSSH({
|
|
272
|
+
artifact,
|
|
273
|
+
sshHost,
|
|
274
|
+
serviceUser,
|
|
275
|
+
daemonBinary,
|
|
276
|
+
profilePath,
|
|
277
|
+
expectedHostKey,
|
|
278
|
+
sshBin = "ssh",
|
|
279
|
+
spawnImpl = realSpawn,
|
|
280
|
+
env = process.env,
|
|
281
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
282
|
+
directInstall = false,
|
|
283
|
+
} = {}) {
|
|
284
|
+
return deliverDaemonProfileWithInstaller({
|
|
285
|
+
artifact, sshHost, serviceUser, daemonBinary, profilePath, expectedHostKey,
|
|
286
|
+
sshBin, spawnImpl, env, timeoutMs, directInstall,
|
|
287
|
+
encodeArtifact: encodeDaemonProfileInstallArtifact,
|
|
288
|
+
installFlag: "--install-profile",
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export async function deliverDaemonProfileFromHelperIdentityOverSSH({
|
|
293
|
+
artifact,
|
|
294
|
+
sshHost,
|
|
295
|
+
serviceUser,
|
|
296
|
+
daemonBinary,
|
|
297
|
+
profilePath,
|
|
298
|
+
expectedHostKey,
|
|
299
|
+
sshBin = "ssh",
|
|
300
|
+
spawnImpl = realSpawn,
|
|
301
|
+
env = process.env,
|
|
302
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
303
|
+
directInstall = false,
|
|
304
|
+
} = {}) {
|
|
305
|
+
return deliverDaemonProfileWithInstaller({
|
|
306
|
+
artifact, sshHost, serviceUser, daemonBinary, profilePath, expectedHostKey,
|
|
307
|
+
sshBin, spawnImpl, env, timeoutMs, directInstall,
|
|
308
|
+
encodeArtifact: encodeDaemonProfileInstallFromHelperIdentityArtifact,
|
|
309
|
+
installFlag: "--install-profile-from-helper",
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export async function deliverDaemonProfileFromBootstrapIdentityOverSSH({
|
|
314
|
+
artifact,
|
|
315
|
+
sshHost,
|
|
316
|
+
serviceUser,
|
|
317
|
+
daemonBinary,
|
|
318
|
+
profilePath,
|
|
319
|
+
expectedHostKey,
|
|
320
|
+
sshBin = "ssh",
|
|
321
|
+
spawnImpl = realSpawn,
|
|
322
|
+
env = process.env,
|
|
323
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
324
|
+
directInstall = false,
|
|
325
|
+
} = {}) {
|
|
326
|
+
return deliverDaemonProfileWithInstaller({
|
|
327
|
+
artifact, sshHost, serviceUser, daemonBinary, profilePath, expectedHostKey,
|
|
328
|
+
sshBin, spawnImpl, env, timeoutMs, directInstall,
|
|
329
|
+
encodeArtifact: encodeDaemonProfileInstallFromBootstrapIdentityArtifact,
|
|
330
|
+
installFlag: "--install-profile-from-bootstrap-identity",
|
|
331
|
+
});
|
|
332
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// BOT-2055: verify the exact extracted universal CLI artifact before a native
|
|
2
|
+
// service definition can name its Go daemon binary. Hashes provide integrity;
|
|
3
|
+
// publication provenance is gated separately on the reviewed runtime source.
|
|
4
|
+
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { lstat, readFile } from "node:fs/promises";
|
|
7
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
8
|
+
|
|
9
|
+
const TARGETS = Object.freeze({
|
|
10
|
+
"darwin-arm64": { platform: "darwin", arch: "arm64", goos: "darwin", goarch: "arm64" },
|
|
11
|
+
"linux-x64": { platform: "linux", arch: "x64", goos: "linux", goarch: "amd64" },
|
|
12
|
+
});
|
|
13
|
+
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
14
|
+
const fail = (code) => { throw new Error(code); };
|
|
15
|
+
|
|
16
|
+
function targetFor(platform, arch) {
|
|
17
|
+
return Object.entries(TARGETS).find(([, value]) => value.platform === platform && value.arch === arch);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function safeDirectory(path) {
|
|
21
|
+
const stat = await lstat(path).catch(() => null);
|
|
22
|
+
if (!stat?.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o022) !== 0) fail("daemon_release_unsafe_directory");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function safeFile(path, { executable = false } = {}) {
|
|
26
|
+
const stat = await lstat(path).catch(() => null);
|
|
27
|
+
if (!stat?.isFile() || stat.isSymbolicLink() || (stat.mode & 0o022) !== 0 || (executable && (stat.mode & 0o111) === 0)) fail("daemon_release_unsafe_file");
|
|
28
|
+
return stat;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function safeDirectoryPath(root, directory) {
|
|
32
|
+
const relativePath = relative(root, directory);
|
|
33
|
+
if (relativePath.startsWith("..") || isAbsolute(relativePath)) fail("daemon_release_manifest_invalid");
|
|
34
|
+
let current = root;
|
|
35
|
+
await safeDirectory(current);
|
|
36
|
+
for (const segment of relativePath.split("/").filter(Boolean)) {
|
|
37
|
+
current = join(current, segment);
|
|
38
|
+
await safeDirectory(current);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function exactKeys(value, keys) {
|
|
43
|
+
return value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function validTarget(name, value) {
|
|
47
|
+
const expected = TARGETS[name];
|
|
48
|
+
return expected && exactKeys(value, ["goos", "goarch", "binary", "sha256", "mode"]) && value.goos === expected.goos && value.goarch === expected.goarch && value.binary === `daemon/bin/${name}/botbuddy-daemon` && /^[0-9a-f]{64}$/.test(value.sha256) && value.mode === "0755";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function validateDaemonVersion(output, { releaseVersion, sourceCommit, target } = {}) {
|
|
52
|
+
let version;
|
|
53
|
+
try { version = JSON.parse(output); } catch { fail("daemon_release_version_invalid"); }
|
|
54
|
+
if (!exactKeys(version, ["version", "commit", "target"]) || version.version !== releaseVersion || version.commit !== sourceCommit || version.target !== target) fail("daemon_release_version_invalid");
|
|
55
|
+
return version;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function validateDaemonRelease({ releaseRoot } = {}, { platform = process.platform, arch = process.arch, read = readFile } = {}) {
|
|
59
|
+
if (typeof releaseRoot !== "string" || !isAbsolute(releaseRoot) || releaseRoot.includes("\0") || resolve(releaseRoot) !== releaseRoot) fail("daemon_release_invalid");
|
|
60
|
+
const selected = targetFor(platform, arch);
|
|
61
|
+
if (!selected) fail("daemon_release_target_unsupported");
|
|
62
|
+
const [targetName] = selected;
|
|
63
|
+
await safeDirectory(releaseRoot);
|
|
64
|
+
const manifestPath = join(releaseRoot, "daemon-release", "manifest.json");
|
|
65
|
+
await safeDirectoryPath(releaseRoot, join(releaseRoot, "daemon-release"));
|
|
66
|
+
await safeFile(manifestPath);
|
|
67
|
+
let manifest;
|
|
68
|
+
try { manifest = JSON.parse(await read(manifestPath, "utf8")); } catch { fail("daemon_release_manifest_invalid"); }
|
|
69
|
+
if (!exactKeys(manifest, ["schema_version", "release_version", "source_commit", "source_sha256", "targets"]) || manifest.schema_version !== 1 || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(manifest.release_version) || !/^[0-9a-f]{40}$/.test(manifest.source_commit) || !/^[0-9a-f]{64}$/.test(manifest.source_sha256) || !exactKeys(manifest.targets, Object.keys(TARGETS)) || !Object.entries(manifest.targets).every(([name, value]) => validTarget(name, value))) fail("daemon_release_manifest_invalid");
|
|
70
|
+
const target = manifest.targets[targetName];
|
|
71
|
+
const binary = join(releaseRoot, target.binary);
|
|
72
|
+
if (relative(releaseRoot, binary).startsWith("..")) fail("daemon_release_manifest_invalid");
|
|
73
|
+
await safeDirectoryPath(releaseRoot, join(releaseRoot, "daemon", "bin", targetName));
|
|
74
|
+
const stat = await safeFile(binary, { executable: true });
|
|
75
|
+
if ((stat.mode & 0o777) !== 0o755) fail("daemon_release_unsafe_file");
|
|
76
|
+
const bytes = await read(binary);
|
|
77
|
+
const afterRead = await safeFile(binary, { executable: true });
|
|
78
|
+
if (stat.dev !== afterRead.dev || stat.ino !== afterRead.ino || (afterRead.mode & 0o777) !== 0o755) fail("daemon_release_unsafe_file");
|
|
79
|
+
if (digest(bytes) !== target.sha256) fail("daemon_release_digest_invalid");
|
|
80
|
+
return { releaseRoot, releaseVersion: manifest.release_version, sourceCommit: manifest.source_commit, sourceSha256: manifest.source_sha256, target: targetName, sha256: target.sha256, binary, payload: bytes, manifestPath };
|
|
81
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const fail = (code) => {
|
|
2
|
+
throw new Error(code);
|
|
3
|
+
};
|
|
4
|
+
const cleanAbsolute = (value) =>
|
|
5
|
+
typeof value === "string" &&
|
|
6
|
+
/^\/(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+$/.test(value);
|
|
7
|
+
const identityFields = new Set([
|
|
8
|
+
"job_id",
|
|
9
|
+
"lease_id",
|
|
10
|
+
"project_id",
|
|
11
|
+
"bundle_path",
|
|
12
|
+
"bundle_sha256",
|
|
13
|
+
"stack_path",
|
|
14
|
+
"worktree_root",
|
|
15
|
+
"expected_job_id",
|
|
16
|
+
"expected_lease_id",
|
|
17
|
+
"expected_project_id",
|
|
18
|
+
]);
|
|
19
|
+
const fields = new Set([
|
|
20
|
+
"host_key",
|
|
21
|
+
"state_dir",
|
|
22
|
+
"docker_endpoint",
|
|
23
|
+
"expected_engine_id",
|
|
24
|
+
// This must be the host's enrolled hardware UUID (for example the macOS
|
|
25
|
+
// IOPlatformUUID collected by cli/src/machine-id.mjs), supplied by the
|
|
26
|
+
// installer/host policy. It is never generated, defaulted, or sourced from
|
|
27
|
+
// a requester because registration binds it to the remote host identity.
|
|
28
|
+
"machine_id",
|
|
29
|
+
"supabase_binary",
|
|
30
|
+
"docker_cli",
|
|
31
|
+
"host_credential_profile",
|
|
32
|
+
"device_consent",
|
|
33
|
+
"runtime_version",
|
|
34
|
+
"runtime_git_sha",
|
|
35
|
+
"runtime_protocol_version",
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
// buildRuntimeConfig is the only supported CLI serializer for daemon host
|
|
39
|
+
// policy. Dynamic claim authority is intentionally not representable here.
|
|
40
|
+
export function buildRuntimeConfig(input) {
|
|
41
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
42
|
+
fail("daemon_runtime_config_invalid");
|
|
43
|
+
}
|
|
44
|
+
for (const key of Object.keys(input)) {
|
|
45
|
+
if (identityFields.has(key)) {
|
|
46
|
+
fail("daemon_runtime_config_dynamic_identity_forbidden");
|
|
47
|
+
}
|
|
48
|
+
if (!fields.has(key)) fail("daemon_runtime_config_unknown_field");
|
|
49
|
+
}
|
|
50
|
+
const config = {
|
|
51
|
+
host_key: input.host_key,
|
|
52
|
+
state_dir: input.state_dir,
|
|
53
|
+
docker_endpoint: input.docker_endpoint,
|
|
54
|
+
expected_engine_id: input.expected_engine_id,
|
|
55
|
+
machine_id: input.machine_id,
|
|
56
|
+
supabase_binary: input.supabase_binary,
|
|
57
|
+
docker_cli: input.docker_cli,
|
|
58
|
+
host_credential_profile: input.host_credential_profile,
|
|
59
|
+
device_consent: input.device_consent,
|
|
60
|
+
runtime_version: input.runtime_version,
|
|
61
|
+
runtime_protocol_version: input.runtime_protocol_version,
|
|
62
|
+
};
|
|
63
|
+
if (
|
|
64
|
+
typeof config.host_key !== "string" || !cleanAbsolute(config.state_dir) ||
|
|
65
|
+
typeof config.docker_endpoint !== "string" ||
|
|
66
|
+
!config.docker_endpoint.startsWith("unix:///") ||
|
|
67
|
+
typeof config.expected_engine_id !== "string" ||
|
|
68
|
+
typeof config.machine_id !== "string" || !config.machine_id.trim() ||
|
|
69
|
+
!cleanAbsolute(config.supabase_binary) ||
|
|
70
|
+
!cleanAbsolute(config.docker_cli) ||
|
|
71
|
+
!cleanAbsolute(config.host_credential_profile) ||
|
|
72
|
+
config.device_consent !== true ||
|
|
73
|
+
typeof config.runtime_version !== "string" || !config.runtime_version ||
|
|
74
|
+
!Number.isInteger(config.runtime_protocol_version) ||
|
|
75
|
+
config.runtime_protocol_version < 2
|
|
76
|
+
) fail("daemon_runtime_config_invalid");
|
|
77
|
+
if (input.runtime_git_sha !== undefined) {
|
|
78
|
+
if (
|
|
79
|
+
typeof input.runtime_git_sha !== "string" ||
|
|
80
|
+
!/^[0-9a-f]{40}$/i.test(input.runtime_git_sha)
|
|
81
|
+
) fail("daemon_runtime_config_invalid");
|
|
82
|
+
config.runtime_git_sha = input.runtime_git_sha;
|
|
83
|
+
}
|
|
84
|
+
return config;
|
|
85
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// BOT-2055: deterministic native service definitions for the standalone Go
|
|
2
|
+
// daemon. These definitions are root-owned, but the daemon itself never runs
|
|
3
|
+
// as root and has no Node/npm/launcher dependency.
|
|
4
|
+
|
|
5
|
+
const safeUser = /^[a-z_][a-z0-9_-]*$/i;
|
|
6
|
+
const safeAbsolutePath = /^\/(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+$/;
|
|
7
|
+
// `assertSpec` admits only a constrained username and absolute path alphabet;
|
|
8
|
+
// no caller-controlled value can introduce XML syntax into this plist.
|
|
9
|
+
const xml = (value) => String(value);
|
|
10
|
+
|
|
11
|
+
function validPath(value) {
|
|
12
|
+
return typeof value === "string" && safeAbsolutePath.test(value) && !value.includes("..") && !/[\s\r\n\0]/.test(value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function assertSpec(spec) {
|
|
16
|
+
if (!spec || typeof spec !== "object" || typeof spec.user !== "string" || spec.user === "root" || !safeUser.test(spec.user) || ![spec.home, spec.binary, spec.config].every(validPath)) {
|
|
17
|
+
throw new Error("daemon_service_config_invalid");
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function renderMacDaemon(spec) {
|
|
22
|
+
assertSpec(spec);
|
|
23
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
24
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
25
|
+
<plist version="1.0"><dict>
|
|
26
|
+
<key>Label</key><string>com.botbuddy.daemon</string>
|
|
27
|
+
<key>UserName</key><string>${xml(spec.user)}</string>
|
|
28
|
+
<key>ProgramArguments</key><array><string>${xml(spec.binary)}</string><string>--config</string><string>${xml(spec.config)}</string></array>
|
|
29
|
+
<key>EnvironmentVariables</key><dict><key>HOME</key><string>${xml(spec.home)}</string><key>PATH</key><string>/usr/local/bin:/usr/bin:/bin</string></dict>
|
|
30
|
+
<key>RunAtLoad</key><true/><key>KeepAlive</key><true/><key>ThrottleInterval</key><integer>5</integer>
|
|
31
|
+
<key>ProcessType</key><string>Background</string>
|
|
32
|
+
</dict></plist>\n`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function renderLinuxUnit(spec) {
|
|
36
|
+
assertSpec(spec);
|
|
37
|
+
return `[Unit]\nDescription=BotBuddy daemon\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=exec\nUser=${spec.user}\nEnvironment=HOME=${spec.home}\nEnvironment=PATH=/usr/local/bin:/usr/bin:/bin\nExecStart=${spec.binary} --config ${spec.config}\nRestart=on-failure\nRestartSec=5\n\n[Install]\nWantedBy=multi-user.target\n`;
|
|
38
|
+
}
|
package/src/oauth-device.mjs
CHANGED
|
@@ -18,6 +18,10 @@ export const SLOW_DOWN_STEP_SEC = 5;
|
|
|
18
18
|
export const DEFAULT_POLL_INTERVAL_SEC = 5;
|
|
19
19
|
// The approval screen shows the requesting host; keep it short.
|
|
20
20
|
export const REQUESTING_HOST_MAX = 80;
|
|
21
|
+
// BOT-2057: this is deliberately only an optional device-authorization
|
|
22
|
+
// attribute. The backend, not the requester, resolves it to a persisted,
|
|
23
|
+
// consented P-256 host and carries that binding across refreshes.
|
|
24
|
+
export const DAEMON_HOST_KEY_RE = /^bbh-[0-9a-f]{24}$/;
|
|
21
25
|
|
|
22
26
|
const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
23
27
|
|
|
@@ -57,6 +61,7 @@ export async function requestDeviceAuthorization({
|
|
|
57
61
|
callerHarness = null,
|
|
58
62
|
callerTicket = null,
|
|
59
63
|
requestingHost = null,
|
|
64
|
+
daemonHostKey = null,
|
|
60
65
|
fetch: fetchImpl = fetch,
|
|
61
66
|
}) {
|
|
62
67
|
const body = { client_id: clientId, scope };
|
|
@@ -75,6 +80,12 @@ export async function requestDeviceAuthorization({
|
|
|
75
80
|
if (ticket) body.caller_ticket = ticket;
|
|
76
81
|
const host = sanitizeCallerField(requestingHost, REQUESTING_HOST_MAX);
|
|
77
82
|
if (host) body.requesting_host = host;
|
|
83
|
+
if (daemonHostKey !== null) {
|
|
84
|
+
if (typeof daemonHostKey !== "string" || !DAEMON_HOST_KEY_RE.test(daemonHostKey)) {
|
|
85
|
+
throw new OAuthCallbackError("daemon_host_key_invalid", "Daemon enrollment requires a valid enrolled host key");
|
|
86
|
+
}
|
|
87
|
+
body.host_key = daemonHostKey;
|
|
88
|
+
}
|
|
78
89
|
|
|
79
90
|
const res = await fetchImpl(`${serverUrl}/device_authorization`, {
|
|
80
91
|
method: "POST",
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// BOT-2034: native daemon payloads are version-bound at build time. The normal
|
|
3
|
+
// release path builds and verifies them for the package version before packing;
|
|
4
|
+
// archive reconciliation and late package-version repair cannot safely recreate
|
|
5
|
+
// that provenance from the CLI tree alone.
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { isMainModule } from "./daemon/entrypoint.mjs";
|
|
9
|
+
|
|
10
|
+
export function hasNativeDaemonPayload(packageManifest) {
|
|
11
|
+
const files = packageManifest?.files;
|
|
12
|
+
return Array.isArray(files) && (files.includes("daemon/") || files.includes("daemon-release/"));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function assertHistoricalNativeReconciliationSafe(packageManifest) {
|
|
16
|
+
if (hasNativeDaemonPayload(packageManifest)) {
|
|
17
|
+
throw new Error("native_daemon_historical_reconciliation_unavailable");
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function assertNativeVersionRepairSafe(packageManifest, { requestedVersion } = {}) {
|
|
22
|
+
if (!hasNativeDaemonPayload(packageManifest) || requestedVersion === packageManifest.version) return;
|
|
23
|
+
throw new Error("native_daemon_release_repair_unsupported");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function usage() {
|
|
27
|
+
console.error("usage: publish-native-guards.mjs --historical-reconcile <package.json> | --version-repair <package.json> <version>");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (isMainModule(import.meta.url)) {
|
|
31
|
+
const [operation, packagePath, requestedVersion] = process.argv.slice(2);
|
|
32
|
+
try {
|
|
33
|
+
if (!packagePath || !["--historical-reconcile", "--version-repair"].includes(operation)) {
|
|
34
|
+
usage();
|
|
35
|
+
process.exit(2);
|
|
36
|
+
}
|
|
37
|
+
const packageManifest = JSON.parse(readFileSync(packagePath, "utf8"));
|
|
38
|
+
if (operation === "--historical-reconcile") assertHistoricalNativeReconciliationSafe(packageManifest);
|
|
39
|
+
else assertNativeVersionRepairSafe(packageManifest, { requestedVersion });
|
|
40
|
+
} catch (error) {
|
|
41
|
+
console.error(String(error?.message ?? error));
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
}
|