@botbuddy/cli 1.59.6 → 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.
- 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/usage/codex.mjs +55 -12
- package/src/usage/sync.mjs +26 -2
- package/src/wait-core.mjs +22 -5
- package/src/wait.mjs +4 -1
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
// BOT-2055: the Node CLI is only the privileged installer. The installed
|
|
2
|
+
// workload is the exact verified Go payload and contains no Node dependency.
|
|
3
|
+
import { lstat, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { execFile as execFileCallback, spawn } from "node:child_process";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
|
|
10
|
+
const execFile = promisify(execFileCallback);
|
|
11
|
+
export const destinations = Object.freeze({
|
|
12
|
+
darwin: "/Library/LaunchDaemons/com.botbuddy.daemon.plist",
|
|
13
|
+
linux: "/etc/systemd/system/botbuddy-daemon.service",
|
|
14
|
+
});
|
|
15
|
+
const fail = (code) => { throw new Error(code); };
|
|
16
|
+
const safeAbsolutePath = (value) => typeof value === "string" && /^\/(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+$/.test(value);
|
|
17
|
+
const safeSshHost = (value) => typeof value === "string" && /^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9._-]+$/.test(value);
|
|
18
|
+
const safeRelease = (release) => release && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(release.releaseVersion)
|
|
19
|
+
&& /^(darwin-arm64|linux-x64)$/.test(release.target)
|
|
20
|
+
&& /^[0-9a-f]{40}$/.test(release.sourceCommit)
|
|
21
|
+
&& /^[0-9a-f]{64}$/.test(release.sha256)
|
|
22
|
+
&& Buffer.isBuffer(release.payload);
|
|
23
|
+
const sshOptions = Object.freeze(["-o", "BatchMode=yes", "-o", "ForwardAgent=no", "-o", "StrictHostKeyChecking=yes"]);
|
|
24
|
+
|
|
25
|
+
export async function serviceUserUid(user, exec = execFile) {
|
|
26
|
+
if (typeof user !== "string" || !/^[a-z_][a-z0-9_-]*$/i.test(user)) fail("daemon_service_user_invalid");
|
|
27
|
+
let result;
|
|
28
|
+
try { result = await exec("id", ["-u", user]); } catch { fail("daemon_service_user_invalid"); }
|
|
29
|
+
const uid = Number.parseInt(String(result?.stdout ?? "").trim(), 10);
|
|
30
|
+
if (!Number.isSafeInteger(uid) || uid <= 0 || String(uid) !== String(result?.stdout ?? "").trim()) fail("daemon_service_user_invalid");
|
|
31
|
+
return uid;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function installedBinaryPath(release) {
|
|
35
|
+
return `/opt/botbuddy/daemon/releases/${release.releaseVersion}/${release.target}/botbuddy-daemon`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function checksumCommand(platform) {
|
|
39
|
+
return platform === "darwin" ? ["shasum", ["-a", "256"]] : ["sha256sum", []];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function checksumMatches(output, expected) {
|
|
43
|
+
return String(output ?? "").trim().split(/\s+/)[0] === expected;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function expectedBootoutAbsent(error) {
|
|
47
|
+
return /Boot-out failed:\s*3:|Could not find service/.test(String(error?.stderr ?? error?.message ?? ""));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function assertSshHost(host) {
|
|
51
|
+
if (!safeSshHost(host)) fail("daemon_remote_host_invalid");
|
|
52
|
+
return host;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function remoteScript(host, args, script, { timeout = 30_000, maxOutput = 16 * 1024 } = {}) {
|
|
56
|
+
assertSshHost(host);
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const child = spawn("ssh", [...sshOptions, host, ...args], { stdio: ["pipe", "pipe", "pipe"] });
|
|
59
|
+
let stdout = "";
|
|
60
|
+
let stderr = "";
|
|
61
|
+
let bounded = true;
|
|
62
|
+
let timedOut = false;
|
|
63
|
+
const timer = setTimeout(() => { timedOut = true; child.kill("SIGTERM"); }, timeout);
|
|
64
|
+
const append = (current, chunk) => {
|
|
65
|
+
const next = current + String(chunk);
|
|
66
|
+
if (next.length > maxOutput) { bounded = false; child.kill("SIGTERM"); return current; }
|
|
67
|
+
return next;
|
|
68
|
+
};
|
|
69
|
+
child.stdout.on("data", (chunk) => { stdout = append(stdout, chunk); });
|
|
70
|
+
child.stderr.on("data", (chunk) => { stderr = append(stderr, chunk); });
|
|
71
|
+
child.on("error", (error) => { clearTimeout(timer); reject(error); });
|
|
72
|
+
child.on("close", (code) => {
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
if (timedOut) return reject(new Error("daemon_remote_command_timeout"));
|
|
75
|
+
if (!bounded) return reject(new Error("daemon_remote_command_output_exceeded"));
|
|
76
|
+
return code === 0 ? resolve({ stdout, stderr }) : reject(Object.assign(new Error("daemon_remote_command_failed"), { code }));
|
|
77
|
+
});
|
|
78
|
+
child.stdin.end(script);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const remotePrepareScript = String.raw`set -eu
|
|
83
|
+
directory=$(mktemp -d /tmp/botbuddy-daemon.XXXXXX)
|
|
84
|
+
chmod 0700 "$directory"
|
|
85
|
+
printf '%s\n' "$directory"
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
const remoteCleanupScript = String.raw`set -eu
|
|
89
|
+
directory=$1
|
|
90
|
+
case "$directory" in /tmp/botbuddy-daemon.[A-Za-z0-9]*) ;; *) exit 80 ;; esac
|
|
91
|
+
rm -rf -- "$directory"
|
|
92
|
+
`;
|
|
93
|
+
|
|
94
|
+
async function remoteTemporaryDirectory(host, runScript = remoteScript) {
|
|
95
|
+
const result = await runScript(host, ["sh", "-s", "--"], remotePrepareScript);
|
|
96
|
+
const directory = String(result?.stdout ?? "").trim();
|
|
97
|
+
if (!/^\/tmp\/botbuddy-daemon\.[A-Za-z0-9]+$/.test(directory)) fail("daemon_remote_staging_invalid");
|
|
98
|
+
return directory;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// This is intentionally fixed shell, passed on stdin to ssh. Caller data is
|
|
102
|
+
// positional-only and validated before transport; the target needs no Node/npm.
|
|
103
|
+
export const remoteInstallScript = String.raw`set -eu
|
|
104
|
+
directory=$1
|
|
105
|
+
platform=$2
|
|
106
|
+
user=$3
|
|
107
|
+
version=$4
|
|
108
|
+
commit=$5
|
|
109
|
+
runtime_target=$6
|
|
110
|
+
digest=$7
|
|
111
|
+
config=$8
|
|
112
|
+
destination=$9
|
|
113
|
+
shift 9
|
|
114
|
+
binary=$1
|
|
115
|
+
case "$directory" in /tmp/botbuddy-daemon.[A-Za-z0-9]*) ;; *) exit 80 ;; esac
|
|
116
|
+
binary_stage=
|
|
117
|
+
definition_stage=
|
|
118
|
+
cleanup() {
|
|
119
|
+
test -z "$binary_stage" || sudo rm -f -- "$binary_stage" || true
|
|
120
|
+
test -z "$definition_stage" || sudo rm -f -- "$definition_stage" || true
|
|
121
|
+
rm -rf -- "$directory"
|
|
122
|
+
}
|
|
123
|
+
trap cleanup EXIT HUP INT TERM
|
|
124
|
+
test "$(id -u "$user")" -gt 0
|
|
125
|
+
test -f "$directory/botbuddy-daemon"
|
|
126
|
+
test -f "$directory/definition"
|
|
127
|
+
case "$platform" in
|
|
128
|
+
darwin) group=wheel; expected_destination=/Library/LaunchDaemons/com.botbuddy.daemon.plist ;;
|
|
129
|
+
linux) group=root; expected_destination=/etc/systemd/system/botbuddy-daemon.service ;;
|
|
130
|
+
*) exit 81 ;;
|
|
131
|
+
esac
|
|
132
|
+
test "$destination" = "$expected_destination"
|
|
133
|
+
# The staging directory starts private to the SSH user. It is made traverse-
|
|
134
|
+
# only so the distinct service user can execute the already-verified payload,
|
|
135
|
+
# but cannot list or modify the staging contents.
|
|
136
|
+
chmod 0711 "$directory"
|
|
137
|
+
chmod 0755 "$directory/botbuddy-daemon"
|
|
138
|
+
if [ "$platform" = darwin ]; then
|
|
139
|
+
actual=$(shasum -a 256 "$directory/botbuddy-daemon" | awk '{print $1}')
|
|
140
|
+
else
|
|
141
|
+
actual=$(sha256sum "$directory/botbuddy-daemon" | awk '{print $1}')
|
|
142
|
+
fi
|
|
143
|
+
test "$actual" = "$digest"
|
|
144
|
+
# Verify identity and configuration as the future non-root service user first.
|
|
145
|
+
test "$(sudo -u "$user" "$directory/botbuddy-daemon" --version)" = "{\"version\":\"$version\",\"commit\":\"$commit\",\"target\":\"$runtime_target\"}"
|
|
146
|
+
test "$(sudo -u "$user" "$directory/botbuddy-daemon" --check-config --config "$config")" = '{"valid":true}'
|
|
147
|
+
binary_directory=$(dirname "$binary")
|
|
148
|
+
service_directory=$(dirname "$destination")
|
|
149
|
+
safe_ancestry() {
|
|
150
|
+
path=$1
|
|
151
|
+
old_ifs=$IFS
|
|
152
|
+
IFS=/
|
|
153
|
+
set -- $path
|
|
154
|
+
IFS=$old_ifs
|
|
155
|
+
current=
|
|
156
|
+
for segment in "$@"; do
|
|
157
|
+
test -n "$segment" || continue
|
|
158
|
+
current=$current/$segment
|
|
159
|
+
if sudo test -L "$current"; then exit 82; fi
|
|
160
|
+
if ! sudo test -e "$current"; then return 0; fi
|
|
161
|
+
sudo test -d "$current"
|
|
162
|
+
if [ "$platform" = darwin ]; then
|
|
163
|
+
owner=$(sudo stat -f '%u' "$current")
|
|
164
|
+
mode=$(sudo stat -f '%OLp' "$current")
|
|
165
|
+
else
|
|
166
|
+
owner=$(sudo stat -c '%u' "$current")
|
|
167
|
+
mode=$(sudo stat -c '%a' "$current")
|
|
168
|
+
fi
|
|
169
|
+
test "$owner" = 0
|
|
170
|
+
case "$mode" in *[2367][0-7]|*[0-7][2367]) exit 82 ;; esac
|
|
171
|
+
done
|
|
172
|
+
}
|
|
173
|
+
safe_ancestry "$binary_directory"
|
|
174
|
+
safe_ancestry "$service_directory"
|
|
175
|
+
if sudo test -e "$binary"; then
|
|
176
|
+
if [ "$platform" = darwin ]; then actual=$(sudo shasum -a 256 "$binary" | awk '{print $1}'); else actual=$(sudo sha256sum "$binary" | awk '{print $1}'); fi
|
|
177
|
+
test "$actual" = "$digest"
|
|
178
|
+
else
|
|
179
|
+
sudo install -d -o root -g "$group" -m 0755 "$binary_directory"
|
|
180
|
+
binary_stage=$(sudo mktemp "$binary_directory/.botbuddy-daemon.XXXXXXXX")
|
|
181
|
+
sudo install -o root -g "$group" -m 0755 "$directory/botbuddy-daemon" "$binary_stage"
|
|
182
|
+
if [ "$platform" = darwin ]; then actual=$(sudo shasum -a 256 "$binary_stage" | awk '{print $1}'); else actual=$(sudo sha256sum "$binary_stage" | awk '{print $1}'); fi
|
|
183
|
+
test "$actual" = "$digest"
|
|
184
|
+
sudo mv -n "$binary_stage" "$binary"
|
|
185
|
+
binary_stage=
|
|
186
|
+
if [ "$platform" = darwin ]; then actual=$(sudo shasum -a 256 "$binary" | awk '{print $1}'); else actual=$(sudo sha256sum "$binary" | awk '{print $1}'); fi
|
|
187
|
+
test "$actual" = "$digest"
|
|
188
|
+
fi
|
|
189
|
+
sudo install -d -o root -g "$group" -m 0755 "$service_directory"
|
|
190
|
+
definition_stage=$(sudo mktemp "$service_directory/.botbuddy-daemon.XXXXXXXX")
|
|
191
|
+
sudo install -o root -g "$group" -m 0644 "$directory/definition" "$definition_stage"
|
|
192
|
+
sudo mv -f "$definition_stage" "$destination"
|
|
193
|
+
definition_stage=
|
|
194
|
+
case "$platform" in
|
|
195
|
+
darwin)
|
|
196
|
+
if ! bootout=$(sudo launchctl bootout system/com.botbuddy.daemon 2>&1); then
|
|
197
|
+
case "$bootout" in *"Boot-out failed: 3:"*|*"Could not find service"*) ;; *) printf '%s\n' "$bootout" >&2; exit 83 ;; esac
|
|
198
|
+
fi
|
|
199
|
+
sudo launchctl bootstrap system "$destination"
|
|
200
|
+
;;
|
|
201
|
+
linux)
|
|
202
|
+
sudo systemctl daemon-reload
|
|
203
|
+
sudo systemctl enable botbuddy-daemon.service
|
|
204
|
+
sudo systemctl restart botbuddy-daemon.service
|
|
205
|
+
;;
|
|
206
|
+
esac
|
|
207
|
+
printf '%s\n' '{"outcome":"installed"}'
|
|
208
|
+
`;
|
|
209
|
+
|
|
210
|
+
// Bootstrap must establish the selected Go executable before it asks that
|
|
211
|
+
// executable to create or inspect target identity. This phase deliberately
|
|
212
|
+
// excludes profile/config validation, service definitions, and activation: the
|
|
213
|
+
// existing remote installer owns those post-enrollment checks.
|
|
214
|
+
export const remoteStageReleaseScript = String.raw`set -eu
|
|
215
|
+
directory=$1
|
|
216
|
+
platform=$2
|
|
217
|
+
user=$3
|
|
218
|
+
version=$4
|
|
219
|
+
commit=$5
|
|
220
|
+
runtime_target=$6
|
|
221
|
+
digest=$7
|
|
222
|
+
binary=$8
|
|
223
|
+
case "$directory" in /tmp/botbuddy-daemon.[A-Za-z0-9]*) ;; *) exit 80 ;; esac
|
|
224
|
+
binary_stage=
|
|
225
|
+
cleanup() {
|
|
226
|
+
test -z "$binary_stage" || sudo -n rm -f -- "$binary_stage" || true
|
|
227
|
+
rm -rf -- "$directory"
|
|
228
|
+
}
|
|
229
|
+
trap cleanup EXIT HUP INT TERM
|
|
230
|
+
test "$(id -u "$user")" -gt 0
|
|
231
|
+
test -f "$directory/botbuddy-daemon"
|
|
232
|
+
case "$platform" in
|
|
233
|
+
darwin) group=wheel ;;
|
|
234
|
+
linux) group=root ;;
|
|
235
|
+
*) exit 81 ;;
|
|
236
|
+
esac
|
|
237
|
+
binary_directory=$(dirname "$binary")
|
|
238
|
+
safe_ancestry() {
|
|
239
|
+
path=$1
|
|
240
|
+
old_ifs=$IFS
|
|
241
|
+
IFS=/
|
|
242
|
+
set -- $path
|
|
243
|
+
IFS=$old_ifs
|
|
244
|
+
current=
|
|
245
|
+
for segment in "$@"; do
|
|
246
|
+
test -n "$segment" || continue
|
|
247
|
+
current=$current/$segment
|
|
248
|
+
if sudo -n test -L "$current"; then exit 82; fi
|
|
249
|
+
if ! sudo -n test -e "$current"; then return 0; fi
|
|
250
|
+
sudo -n test -d "$current"
|
|
251
|
+
if [ "$platform" = darwin ]; then owner=$(sudo -n stat -f '%u' "$current"); else owner=$(sudo -n stat -c '%u' "$current"); fi
|
|
252
|
+
test "$owner" = 0
|
|
253
|
+
if [ "$platform" = darwin ]; then mode=$(sudo -n stat -f '%OLp' "$current"); else mode=$(sudo -n stat -c '%a' "$current"); fi
|
|
254
|
+
case "$mode" in *[2367][0-7]|*[0-7][2367]) exit 82 ;; esac
|
|
255
|
+
done
|
|
256
|
+
}
|
|
257
|
+
verify_binary() {
|
|
258
|
+
sudo -n test -f "$binary"
|
|
259
|
+
sudo -n test ! -L "$binary"
|
|
260
|
+
if [ "$platform" = darwin ]; then owner=$(sudo -n stat -f '%u' "$binary"); else owner=$(sudo -n stat -c '%u' "$binary"); fi
|
|
261
|
+
test "$owner" = 0
|
|
262
|
+
if [ "$platform" = darwin ]; then mode=$(sudo -n stat -f '%OLp' "$binary"); else mode=$(sudo -n stat -c '%a' "$binary"); fi
|
|
263
|
+
case "$mode" in 755|100755) ;; *) exit 82 ;; esac
|
|
264
|
+
if [ "$platform" = darwin ]; then actual=$(sudo -n shasum -a 256 "$binary" | awk '{print $1}'); else actual=$(sudo -n sha256sum "$binary" | awk '{print $1}'); fi
|
|
265
|
+
test "$actual" = "$digest"
|
|
266
|
+
test "$(sudo -n -u "$user" -- "$binary" --version)" = "{\"version\":\"$version\",\"commit\":\"$commit\",\"target\":\"$runtime_target\"}"
|
|
267
|
+
}
|
|
268
|
+
safe_ancestry "$binary_directory"
|
|
269
|
+
if sudo -n test -e "$binary"; then
|
|
270
|
+
verify_binary
|
|
271
|
+
else
|
|
272
|
+
sudo -n install -d -o root -g "$group" -m 0755 "$binary_directory"
|
|
273
|
+
binary_stage=$(sudo -n mktemp "$binary_directory/.botbuddy-daemon.XXXXXXXX")
|
|
274
|
+
sudo -n install -o root -g "$group" -m 0755 "$directory/botbuddy-daemon" "$binary_stage"
|
|
275
|
+
if [ "$platform" = darwin ]; then actual=$(sudo -n shasum -a 256 "$binary_stage" | awk '{print $1}'); else actual=$(sudo -n sha256sum "$binary_stage" | awk '{print $1}'); fi
|
|
276
|
+
test "$actual" = "$digest"
|
|
277
|
+
sudo -n mv -n "$binary_stage" "$binary"
|
|
278
|
+
binary_stage=
|
|
279
|
+
verify_binary
|
|
280
|
+
fi
|
|
281
|
+
printf '%s\n' '{"outcome":"staged"}'
|
|
282
|
+
`;
|
|
283
|
+
|
|
284
|
+
export async function stageRemoteDaemonRelease({
|
|
285
|
+
platform, host, user, release,
|
|
286
|
+
exec = execFile, runScript = remoteScript, makeTemp = mkdtemp, write = writeFile, remove = rm,
|
|
287
|
+
} = {}) {
|
|
288
|
+
if (!destinations[platform] || !safeSshHost(host) || typeof user !== "string" || !/^[a-z_][a-z0-9_-]*$/i.test(user) || !safeRelease(release)) {
|
|
289
|
+
fail("daemon_remote_stage_invalid");
|
|
290
|
+
}
|
|
291
|
+
const runtimeTarget = release.target === "linux-x64" ? "linux-amd64" : release.target;
|
|
292
|
+
const localDirectory = await makeTemp(join(tmpdir(), "botbuddy-daemon-"));
|
|
293
|
+
const snapshot = join(localDirectory, "botbuddy-daemon");
|
|
294
|
+
let remoteDirectory;
|
|
295
|
+
let remoteCleaned = false;
|
|
296
|
+
try {
|
|
297
|
+
await write(snapshot, release.payload, { mode: 0o500 });
|
|
298
|
+
remoteDirectory = await remoteTemporaryDirectory(host, runScript);
|
|
299
|
+
await exec("scp", ["-q", ...sshOptions, "--", snapshot, `${host}:${remoteDirectory}/`], { timeout: 30_000, maxBuffer: 16 * 1024 });
|
|
300
|
+
const result = await runScript(host, ["sh", "-s", "--", remoteDirectory, platform, user, release.releaseVersion, release.sourceCommit, runtimeTarget, release.sha256, installedBinaryPath(release)], remoteStageReleaseScript);
|
|
301
|
+
if (String(result?.stdout ?? "").trim() !== '{"outcome":"staged"}') fail("daemon_remote_stage_unreadable");
|
|
302
|
+
remoteCleaned = true;
|
|
303
|
+
return { outcome: "staged", binary: installedBinaryPath(release), service_user: user, host };
|
|
304
|
+
} finally {
|
|
305
|
+
if (remoteDirectory && !remoteCleaned) await runScript(host, ["sh", "-s", "--", remoteDirectory], remoteCleanupScript, { timeout: 15_000 }).catch(() => {});
|
|
306
|
+
await remove(localDirectory, { recursive: true, force: true });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export async function installRemoteService({
|
|
311
|
+
platform, host, user, definition, release, config,
|
|
312
|
+
exec = execFile, runScript = remoteScript, makeTemp = mkdtemp, write = writeFile, remove = rm,
|
|
313
|
+
} = {}) {
|
|
314
|
+
if (!destinations[platform] || !safeSshHost(host) || typeof definition !== "string" || !definition.length || !safeRelease(release) || !safeAbsolutePath(config)) fail("daemon_remote_install_invalid");
|
|
315
|
+
const runtimeTarget = release.target === "linux-x64" ? "linux-amd64" : release.target;
|
|
316
|
+
const localDirectory = await makeTemp(join(tmpdir(), "botbuddy-daemon-"));
|
|
317
|
+
const snapshot = join(localDirectory, "botbuddy-daemon");
|
|
318
|
+
const definitionFile = join(localDirectory, "definition");
|
|
319
|
+
let remoteDirectory;
|
|
320
|
+
let remoteCleaned = false;
|
|
321
|
+
try {
|
|
322
|
+
await write(snapshot, release.payload, { mode: 0o500 });
|
|
323
|
+
await write(definitionFile, definition, { mode: 0o600 });
|
|
324
|
+
remoteDirectory = await remoteTemporaryDirectory(host, runScript);
|
|
325
|
+
await exec("scp", ["-q", ...sshOptions, "--", snapshot, definitionFile, `${host}:${remoteDirectory}/`], { timeout: 30_000, maxBuffer: 16 * 1024 });
|
|
326
|
+
const result = await runScript(host, ["sh", "-s", "--", remoteDirectory, platform, user, release.releaseVersion, release.sourceCommit, runtimeTarget, release.sha256, config, destinations[platform], installedBinaryPath(release)], remoteInstallScript);
|
|
327
|
+
if (String(result?.stdout ?? "").trim() !== '{"outcome":"installed"}') fail("daemon_remote_install_unreadable");
|
|
328
|
+
remoteCleaned = true;
|
|
329
|
+
return { outcome: "installed", destination: destinations[platform], binary: installedBinaryPath(release), service_user: user, host };
|
|
330
|
+
} finally {
|
|
331
|
+
if (remoteDirectory && !remoteCleaned) await runScript(host, ["sh", "-s", "--", remoteDirectory], remoteCleanupScript, { timeout: 15_000 }).catch(() => {});
|
|
332
|
+
await remove(localDirectory, { recursive: true, force: true });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function safeRootAncestry(path, inspect = lstat) {
|
|
337
|
+
const segments = path.split("/").filter(Boolean);
|
|
338
|
+
let current = "";
|
|
339
|
+
for (const segment of segments) {
|
|
340
|
+
current += `/${segment}`;
|
|
341
|
+
const stat = await inspect(current).catch((error) => error?.code === "ENOENT" ? null : Promise.reject(error));
|
|
342
|
+
if (!stat) return;
|
|
343
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== 0 || (stat.mode & 0o022) !== 0) fail("daemon_install_unsafe_destination");
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export async function installService({
|
|
348
|
+
platform, user, definition, release, config,
|
|
349
|
+
exec = execFile, makeTemp = mkdtemp, write = writeFile, remove = rm, random = randomUUID, inspect = lstat,
|
|
350
|
+
} = {}) {
|
|
351
|
+
if (!destinations[platform] || typeof definition !== "string" || !definition.length || !safeRelease(release) || !safeAbsolutePath(config)) fail("daemon_install_invalid");
|
|
352
|
+
await serviceUserUid(user, exec);
|
|
353
|
+
const destination = destinations[platform];
|
|
354
|
+
const binary = installedBinaryPath(release);
|
|
355
|
+
const binaryDirectory = binary.slice(0, binary.lastIndexOf("/"));
|
|
356
|
+
const serviceDirectory = destination.slice(0, destination.lastIndexOf("/"));
|
|
357
|
+
// Never let sudo create through an attacker-controlled pre-existing path.
|
|
358
|
+
await safeRootAncestry(binaryDirectory, inspect);
|
|
359
|
+
await safeRootAncestry(serviceDirectory, inspect);
|
|
360
|
+
const tempDirectory = await makeTemp(join(tmpdir(), "botbuddy-daemon-"));
|
|
361
|
+
const snapshot = join(tempDirectory, "botbuddy-daemon");
|
|
362
|
+
const definitionFile = join(tempDirectory, platform === "darwin" ? "daemon.plist" : "daemon.service");
|
|
363
|
+
const binaryStage = join(binaryDirectory, `.botbuddy-daemon.${release.sha256}.${random()}`);
|
|
364
|
+
const definitionStage = join(serviceDirectory, `.${destination.slice(destination.lastIndexOf("/") + 1)}.${random()}`);
|
|
365
|
+
let binaryStaged = false;
|
|
366
|
+
let definitionStaged = false;
|
|
367
|
+
try {
|
|
368
|
+
// `payload` was read and hash-checked by validateDaemonRelease. Snapshot it
|
|
369
|
+
// before sudo so a source pathname cannot be replaced during installation.
|
|
370
|
+
await write(snapshot, release.payload, { mode: 0o500 });
|
|
371
|
+
await write(definitionFile, definition, { mode: 0o600 });
|
|
372
|
+
const [checksumCommandName, checksumArgs] = checksumCommand(platform);
|
|
373
|
+
let existing = false;
|
|
374
|
+
try { await exec("sudo", ["test", "-e", binary]); existing = true; }
|
|
375
|
+
catch (error) { if (error?.code !== 1) throw error; }
|
|
376
|
+
if (existing) {
|
|
377
|
+
const result = await exec("sudo", [checksumCommandName, ...checksumArgs, binary]);
|
|
378
|
+
if (!checksumMatches(result.stdout, release.sha256)) fail("daemon_install_existing_payload_mismatch");
|
|
379
|
+
}
|
|
380
|
+
const group = platform === "darwin" ? "wheel" : "root";
|
|
381
|
+
if (!existing) {
|
|
382
|
+
await exec("sudo", ["install", "-d", "-o", "root", "-g", group, "-m", "0755", binaryDirectory]);
|
|
383
|
+
await exec("sudo", ["install", "-o", "root", "-g", group, "-m", "0755", snapshot, binaryStage]);
|
|
384
|
+
binaryStaged = true;
|
|
385
|
+
const checksumResult = await exec("sudo", [checksumCommandName, ...checksumArgs, binaryStage]);
|
|
386
|
+
if (!checksumMatches(checksumResult.stdout, release.sha256)) fail("daemon_install_digest_invalid");
|
|
387
|
+
await exec("sudo", ["mv", "-n", binaryStage, binary]);
|
|
388
|
+
binaryStaged = false;
|
|
389
|
+
const finalResult = await exec("sudo", [checksumCommandName, ...checksumArgs, binary]);
|
|
390
|
+
if (!checksumMatches(finalResult.stdout, release.sha256)) fail("daemon_install_existing_payload_mismatch");
|
|
391
|
+
}
|
|
392
|
+
await exec("sudo", ["install", "-o", "root", "-g", group, "-m", "0644", definitionFile, definitionStage]);
|
|
393
|
+
definitionStaged = true;
|
|
394
|
+
await exec("sudo", ["mv", "-f", definitionStage, destination]);
|
|
395
|
+
definitionStaged = false;
|
|
396
|
+
if (platform === "darwin") {
|
|
397
|
+
try { await exec("sudo", ["launchctl", "bootout", "system/com.botbuddy.daemon"]); }
|
|
398
|
+
catch (error) { if (!expectedBootoutAbsent(error)) throw error; }
|
|
399
|
+
await exec("sudo", ["launchctl", "bootstrap", "system", destination]);
|
|
400
|
+
} else {
|
|
401
|
+
await exec("sudo", ["systemctl", "daemon-reload"]);
|
|
402
|
+
await exec("sudo", ["systemctl", "enable", "botbuddy-daemon.service"]);
|
|
403
|
+
await exec("sudo", ["systemctl", "restart", "botbuddy-daemon.service"]);
|
|
404
|
+
}
|
|
405
|
+
return { outcome: "installed", destination, binary, service_user: user };
|
|
406
|
+
} finally {
|
|
407
|
+
if (binaryStaged) await exec("sudo", ["rm", "-f", binaryStage]).catch(() => {});
|
|
408
|
+
if (definitionStaged) await exec("sudo", ["rm", "-f", definitionStage]).catch(() => {});
|
|
409
|
+
await remove(tempDirectory, { recursive: true, force: true });
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export async function serviceStatus({ platform, host, exec = execFile } = {}) {
|
|
414
|
+
if (!destinations[platform]) fail("daemon_status_invalid");
|
|
415
|
+
if (host !== undefined) assertSshHost(host);
|
|
416
|
+
try {
|
|
417
|
+
const command = platform === "darwin" ? "launchctl" : "systemctl";
|
|
418
|
+
const args = platform === "darwin"
|
|
419
|
+
? ["print", "system/com.botbuddy.daemon"]
|
|
420
|
+
: ["show", "--property=LoadState", "--property=ActiveState", "botbuddy-daemon.service"];
|
|
421
|
+
const result = host ? await exec("ssh", [...sshOptions, host, command, ...args]) : await exec(command, args);
|
|
422
|
+
const output = String(result?.stdout ?? "");
|
|
423
|
+
if (platform === "darwin") {
|
|
424
|
+
const state = /^\s*state\s*=\s*(.+?)\s*$/m.exec(output)?.[1];
|
|
425
|
+
if (!state) fail("daemon_status_unreadable");
|
|
426
|
+
return { outcome: "status", platform, loaded: true, active: state === "running" };
|
|
427
|
+
}
|
|
428
|
+
const load = /^LoadState=(.+)$/m.exec(output)?.[1];
|
|
429
|
+
const active = /^ActiveState=(.+)$/m.exec(output)?.[1];
|
|
430
|
+
if (!load || !active) fail("daemon_status_unreadable");
|
|
431
|
+
if (load === "not-found") return { outcome: "status", platform, loaded: false, active: false };
|
|
432
|
+
if (load !== "loaded") fail("daemon_status_unexpected_load_state");
|
|
433
|
+
return { outcome: "status", platform, loaded: true, active: active === "active" };
|
|
434
|
+
} catch (error) {
|
|
435
|
+
if (platform === "darwin" && /Could not find service/.test(String(error?.stderr ?? error?.message ?? ""))) return { outcome: "status", platform, loaded: false, active: false };
|
|
436
|
+
throw error;
|
|
437
|
+
}
|
|
438
|
+
}
|