@botbuddy/cli 1.59.7 → 1.59.8

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.
@@ -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
+ }
@@ -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
+ }
package/src/wait-core.mjs CHANGED
@@ -80,6 +80,7 @@ function managedAllocationFailureDetail(signal, condition) {
80
80
  // own-session terminal branch because its wait_session_id differs) would otherwise match
81
81
  // that raw event condition and exit an unrelated wait as an ordinary match (Codex P2).
82
82
  export const WAIT_SUPERSEDED_SIGNAL_TYPE = "wait_superseded";
83
+ const HOST_APPROVAL_HOST_KEY_RE = /^bbh-[0-9a-f]{24}$/;
83
84
 
84
85
  // The USER-ARMABLE spine signal_type allowlist, for the `event:` escape hatch. The full
85
86
  // public.agent_signal_type enum is these PLUS the control types above
@@ -99,6 +100,11 @@ export const SIGNAL_TYPES = Object.freeze([
99
100
  "snapshot",
100
101
  "ticket_unblocked",
101
102
  "stack_lease",
103
+ // BOT-2324: emitted only once the exact public host approval predicate is
104
+ // true (owner-bound active settings + policy + live tenant subscription).
105
+ // It is intentionally consumed through the existing raw `event:` escape
106
+ // hatch rather than adding another parallel wait grammar.
107
+ "host_approval",
102
108
  ]);
103
109
 
104
110
  const LOCK_SUBTYPES = Object.freeze(["playwright_lane", "vite_port", "backend_port", "supabase_local"]);
@@ -406,7 +412,15 @@ const VALIDATORS = {
406
412
  if (!SIGNAL_TYPES.includes(p.type)) {
407
413
  throw new Error(`event type must be a known signal_type (${SIGNAL_TYPES.join(", ")})`);
408
414
  }
409
- return { type: p.type, subject: p.subject !== undefined ? String(p.subject) : null };
415
+ const subject = p.subject !== undefined ? String(p.subject).trim() : null;
416
+ // BOT-2324: unlike the generic raw escape hatch, this approval condition
417
+ // has one narrow public identity grammar. The service independently checks
418
+ // it for direct JSON callers; keeping the CLI equally strict prevents an
419
+ // impossible-to-match wait from being armed locally.
420
+ if (p.type === "host_approval" && (!subject || !HOST_APPROVAL_HOST_KEY_RE.test(subject))) {
421
+ throw new Error("event:type=host_approval needs subject=<canonical bbh- host key>");
422
+ }
423
+ return { type: p.type, subject };
410
424
  },
411
425
  };
412
426
 
@@ -1237,10 +1251,13 @@ function conditionMatchesSignal(condition, signal, waitSessionId) {
1237
1251
  // wake the wrong workspace. This centralizes what BOT-1247's `unblocked` matcher did
1238
1252
  // per-type (that per-type check stays as belt-and-braces). Tenantless signals
1239
1253
  // (host-shared lock_availability/container_capacity, owner/recipient-scoped
1240
- // chat/pr_state/test_run/guidance) are NEVER blocked — they keep today's semantics
1241
- // for any session (AC-5). `sessionTenant === undefined` opts a caller out of the
1242
- // central guard: a pure unit test asserting a per-type matcher in isolation. The CLI
1243
- // always threads the server-resolved tenant, so the guard is live for every real wait.
1254
+ // chat/pr_state/test_run/guidance) are NEVER blocked — they keep today's semantics
1255
+ // for any session (AC-5). `host_approval` is the deliberate exception: it is
1256
+ // owner-visible AND tenant-bound, so the relay stamps scope=owner_tenant and we
1257
+ // retain this tenant fence after its owner gate. `sessionTenant === undefined`
1258
+ // opts a caller out of the central guard: a pure unit test asserting a per-type
1259
+ // matcher in isolation. The CLI always threads the server-resolved tenant, so
1260
+ // the guard is live for every real wait.
1244
1261
  export function signalTenantBlocked(signal, sessionTenant) {
1245
1262
  if (sessionTenant === undefined) return false;
1246
1263
  if (signal.tenant_id == null) return false;