-
Notifications
You must be signed in to change notification settings - Fork 52
Expand file tree
/
Copy pathinstall.sh
More file actions
executable file
·702 lines (642 loc) · 31.5 KB
/
Copy pathinstall.sh
File metadata and controls
executable file
·702 lines (642 loc) · 31.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
#!/usr/bin/env bash
# bux install — set up Claude Code + Browser Use Cloud browser + optional
# Telegram bot on a fresh Ubuntu / Debian box.
#
# Usage:
# curl -fsSL https://raw.githubusercontent.com/browser-use/bux/main/install.sh \
# | sudo BROWSER_USE_API_KEY=bu_xxx bash
#
# Or clone + run locally:
# git clone https://github.com/browser-use/bux && cd bux && sudo ./install.sh
#
# BUX_REF (default: main) controls which ref the curl-pipe installer pulls
# from. Set it to a commit sha if you want to pin:
# curl … | sudo BUX_REF=<sha> BROWSER_USE_API_KEY=bu_xxx bash
#
# Optional env vars:
# BROWSER_USE_API_KEY — Browser Use Cloud key (required; prompts if missing)
# TG_BOT_TOKEN — Telegram bot token (enables the TG bot if set)
# TG_OWNER_ID — Telegram numeric user_id of the box owner. When set,
# the bot pins the owner identity at install time
# instead of inferring "first sender wins". Any other
# user (incl. someone who races to redeem the setup
# token) is treated as a guest. Recommended for
# cloud-provisioned boxes — the cloud knows who the
# user is and can supply this directly.
# TG_OWNER_USERNAME — Optional companion to TG_OWNER_ID (display only).
# TG_OWNER_NAME — Optional companion to TG_OWNER_ID (display only).
# WITH_ZTK — install ztk (default 1; set to 0 to skip). ztk is a
# Zig CLI that compresses long Bash tool outputs
# (git diff, ls, test runners) before they hit
# Claude's context. https://github.com/codejunkie99/ztk
#
# Re-running the script is idempotent. It will reuse existing tokens and
# configuration; delete /etc/bux/ to start clean.
set -euo pipefail
BUX_REF="${BUX_REF:-main}"
WITH_ZTK="${WITH_ZTK:-1}"
# --- pinned versions -------------------------------------------------------
# Keep all third-party version pins together so bumping is a single edit.
# SHAs are sourced from the upstream release index / git refs at pin time.
ZTK_VERSION='v0.2.1'
# codejunkie99/ztk @ tag v0.2.1
ZTK_COMMIT_SHA='c52634463811f2325a63d691dcb4d06437e93846'
ZIG_VERSION='0.16.0'
# from https://ziglang.org/download/index.json -> 0.16.0 -> x86_64-linux.shasum
ZIG_X86_64_LINUX_SHA256='70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00'
# --- pretty output ---------------------------------------------------------
c_bold=$'\033[1m'; c_dim=$'\033[2m'; c_green=$'\033[32m'; c_red=$'\033[31m'; c_reset=$'\033[0m'
say() { printf '%s➜%s %s\n' "$c_bold" "$c_reset" "$*"; }
ok() { printf '%s✓%s %s\n' "$c_green" "$c_reset" "$*"; }
warn() { printf '%s!%s %s\n' "$c_red" "$c_reset" "$*" >&2; }
die() { warn "$*"; exit 1; }
[ "$EUID" -eq 0 ] || die 'must run as root (use sudo)'
[ -f /etc/debian_version ] || die 'only debian/ubuntu is supported'
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# If the script was piped through curl, BASH_SOURCE[0] is /dev/stdin — in that
# case we fetch the rest of the repo from github at $BUX_REF. GitHub's
# archive/<ref>.tar.gz endpoint accepts branches, tags, and commit SHAs.
if [ "$REPO_DIR" = '/dev' ] || [ ! -f "$REPO_DIR/agent/browser_keeper.py" ]; then
say "fetching bux@${BUX_REF} from github"
tmpdir="$(mktemp -d)"
curl -fsSL "https://github.com/browser-use/bux/archive/${BUX_REF}.tar.gz" \
| tar -xz -C "$tmpdir" --strip-components=1 \
|| die "failed to download bux@${BUX_REF}"
REPO_DIR="$tmpdir"
fi
# --- collect config --------------------------------------------------------
BROWSER_USE_API_KEY="${BROWSER_USE_API_KEY:-}"
BUX_PROFILE_ID="${BUX_PROFILE_ID:-}"
# If /etc/bux/env already exists (rerun), seed missing values from it so the
# script is truly idempotent without making the user re-type secrets.
if [ -z "$BROWSER_USE_API_KEY" ] && [ -r /etc/bux/env ]; then
# shellcheck disable=SC1091
BROWSER_USE_API_KEY="$(. /etc/bux/env && printf %s "${BROWSER_USE_API_KEY:-}")"
# shellcheck disable=SC1091
BUX_PROFILE_ID="${BUX_PROFILE_ID:-$(. /etc/bux/env && printf %s "${BUX_PROFILE_ID:-}")}"
fi
if [ -z "$BROWSER_USE_API_KEY" ] && [ -t 0 ]; then
printf '%sBROWSER_USE_API_KEY%s (get one at https://cloud.browser-use.com/new-api-key): ' "$c_bold" "$c_reset"
read -r BROWSER_USE_API_KEY
fi
[ -n "$BROWSER_USE_API_KEY" ] || die 'BROWSER_USE_API_KEY is required (export it or pass via env)'
TG_BOT_TOKEN="${TG_BOT_TOKEN:-}"
# --- base packages ---------------------------------------------------------
say 'installing system packages'
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq \
curl git build-essential python3 python3-pip python3-venv \
unzip ca-certificates jq gnupg \
ripgrep fd-find python3-dev make gcc g++ pkg-config libssl-dev zlib1g-dev \
htop tmux vim less wget zip tree \
qrencode \
at
# Enable atd so `at now + 5min` actually runs queued jobs. We deliberately
# don't `|| true` here — if atd fails to start we end up with broken
# scheduling and no signal until the first user reminder silently misses.
# Better to fail the install loudly.
systemctl enable --now atd.service
# Allow the bux user to use `at` (Ubuntu's default at.deny excludes
# regular users; at.allow is presence-implies-deny-for-everyone-else).
echo bux > /etc/at.allow
chmod 644 /etc/at.allow
arch="$(uname -m)"
# --- gh (GitHub CLI) -------------------------------------------------------
if ! command -v gh >/dev/null 2>&1; then
say 'installing GitHub CLI'
install -d -m 0755 /etc/apt/keyrings
rm -f /etc/apt/keyrings/githubcli-archive-keyring.gpg
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
-o /etc/apt/keyrings/githubcli-archive-keyring.gpg
chmod 644 /etc/apt/keyrings/githubcli-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list
apt-get update -qq
apt-get install -y -qq gh
fi
# --- uv (fast Python package manager) -------------------------------------
# Pinned + SHA-verified release tarball. Same threat model as ttyd / nodejs:
# never `curl … | sh` as root, since one compromised redirect on astral.sh
# would execute arbitrary code on every install.
UV_VERSION='0.11.7'
case "$arch" in
x86_64)
uv_arch='x86_64-unknown-linux-gnu'
UV_SHA256='6681d691eb7f9c00ac6a3af54252f7ab29ae72f0c8f95bdc7f9d1401c23ea868'
;;
aarch64|arm64)
uv_arch='aarch64-unknown-linux-gnu'
UV_SHA256='f2ee1cde9aabb4c6e43bd3f341dadaf42189a54e001e521346dc31547310e284'
;;
*) die "unsupported arch for uv: $arch" ;;
esac
if ! command -v uv >/dev/null 2>&1 || [ "$(uv --version 2>/dev/null | awk '{print $2}')" != "$UV_VERSION" ]; then
say 'installing uv'
tmp_uv="$(mktemp -d)"
curl -fsSL "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-${uv_arch}.tar.gz" \
-o "$tmp_uv/uv.tgz"
got_sha=$(sha256sum "$tmp_uv/uv.tgz" | awk '{print $1}')
if [ "$got_sha" != "$UV_SHA256" ]; then
rm -rf "$tmp_uv"
die "uv SHA mismatch: got $got_sha"
fi
tar -xzf "$tmp_uv/uv.tgz" -C "$tmp_uv"
install -m 0755 "$tmp_uv/uv-${uv_arch}/uv" /usr/local/bin/uv
install -m 0755 "$tmp_uv/uv-${uv_arch}/uvx" /usr/local/bin/uvx
rm -rf "$tmp_uv"
fi
# --- Node.js 24 LTS via NodeSource (GPG-pinned) ----------------------------
if ! node --version 2>/dev/null | grep -q '^v24'; then
say 'installing Node.js 24 LTS'
NODESOURCE_KEY_FPR='6F71F525282841EEDAF851B42F59B5F99B1BE0B4'
install -d -m 0755 /etc/apt/keyrings
rm -f /etc/apt/keyrings/nodesource.gpg
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
| gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
got_fpr=$(gpg --no-default-keyring --keyring /etc/apt/keyrings/nodesource.gpg \
--list-keys --with-colons | awk -F: '/^fpr:/ {print $10; exit}')
[ "$got_fpr" = "$NODESOURCE_KEY_FPR" ] || die "NodeSource GPG mismatch: $got_fpr"
echo 'deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_24.x nodistro main' \
> /etc/apt/sources.list.d/nodesource.list
apt-get update -qq
apt-get install -y -qq nodejs
fi
# --- Claude Code -----------------------------------------------------------
if ! command -v claude >/dev/null 2>&1; then
say 'installing Claude Code'
npm install -g @anthropic-ai/claude-code
fi
# Codex CLI is installed below as the bux user, *after* the npm-prefix
# block that pins ~/.npm-global. Installing it here as root would land
# the binary outside that prefix and the bux PATH wouldn't pick it up.
# --- bux user + dirs -------------------------------------------------------
id -u bux >/dev/null 2>&1 || useradd -m -s /bin/bash bux
mkdir -p /opt/bux /var/log/bux /etc/bux /home/bux/.claude/skills
chown -R bux:bux /opt/bux /home/bux/.claude /var/log/bux
chown root:bux /etc/bux
chmod 2775 /etc/bux
# NOTE: we deliberately do NOT grant the bux user passwordless sudo, even
# scoped. `sudo apt install <arbitrary deb>` runs maintainer scripts as root
# and is therefore root-equivalent — same for dpkg, npm, pipx, snap. Anything
# we'd whitelist here breaks the boundary that keeps the TG bot's bot-token
# (root-owned at /etc/bux/tg.env) safe from a compromised bux user.
#
# Users get tools two ways: (1) the apt packages baked in here, and
# (2) per-user installs into $HOME via uv / pipx / npm-prefix / pyenv. If
# something's missing, add it to the apt list above and re-run the installer.
rm -f /etc/sudoers.d/bux-dev # in case an earlier install left one
# --- SSH access for the bux user -------------------------------------------
# We pre-create ~/.ssh and lock sshd to pubkey-only. Users who want ssh paste
# their pubkey into ~/.ssh/authorized_keys themselves (via the web terminal,
# `sudo -iu bux`, or whatever). No keys are seeded here, so opening port 22
# at the cloud/firewall level + sshd does NOT mean anyone can log in until
# the user adds their own key.
# Reject symlinks before touching ssh paths. /home/bux is bux-owned, so on
# a rerun the bux user could symlink ~/.ssh → /etc or ~/.ssh/authorized_keys
# → /etc/shadow and our root chown/chmod would follow the link. -L matches
# even dangling links; we don't allow symlinks here at all.
for p in /home/bux/.ssh /home/bux/.ssh/authorized_keys; do
if [ -L "$p" ]; then
die "refusing to operate on symlinked $p"
fi
done
install -d -o bux -g bux -m 0700 /home/bux/.ssh
# Don't clobber an existing authorized_keys, but always re-assert ownership
# + mode — sshd silently ignores pubkeys when authorized_keys has wrong
# perms, and a previous install or manual edit may have left it 0644.
# -f means regular file; refuse to operate on dirs/sockets/FIFOs (the -L
# check above already handled symlinks).
if [ -e /home/bux/.ssh/authorized_keys ] && [ ! -f /home/bux/.ssh/authorized_keys ]; then
die '/home/bux/.ssh/authorized_keys exists but is not a regular file'
fi
if [ ! -f /home/bux/.ssh/authorized_keys ]; then
install -o bux -g bux -m 0600 /dev/null /home/bux/.ssh/authorized_keys
fi
# -h on chown for symlink TOCTOU defense in depth. chmod has no -h variant
# but the -L test above already rejected symlinks.
chown -h bux:bux /home/bux/.ssh/authorized_keys
chmod 0600 /home/bux/.ssh/authorized_keys
cat > /etc/ssh/sshd_config.d/00-bux.conf <<'SSHD'
# bux: pubkey only, no passwords, no root login.
PasswordAuthentication no
PermitRootLogin no
PubkeyAuthentication yes
KbdInteractiveAuthentication no
SSHD
chmod 644 /etc/ssh/sshd_config.d/00-bux.conf
# Pick whichever unit name the distro ships (Ubuntu = ssh.service, RHEL-likes
# = sshd.service) and fail clearly if neither exists. Don't swallow errors —
# a botched ssh enable/reload should surface, not be hidden.
ssh_unit=''
for u in ssh.service sshd.service; do
if systemctl list-unit-files "$u" --no-legend --no-pager 2>/dev/null | grep -q .; then
ssh_unit="$u"
break
fi
done
[ -n "$ssh_unit" ] || die 'no ssh unit found (ssh.service / sshd.service)'
systemctl enable "$ssh_unit"
# `reload` requires the unit to be running already. Fall back to `restart`
# if reload fails (e.g. fresh box where sshd is enabled but not yet started).
systemctl reload "$ssh_unit" || systemctl restart "$ssh_unit"
# --- Python venv for the agent ---------------------------------------------
if [ ! -d /opt/bux/venv ]; then
sudo -u bux python3 -m venv /opt/bux/venv
fi
sudo -u bux /opt/bux/venv/bin/pip install --quiet --upgrade pip
sudo -u bux /opt/bux/venv/bin/pip install --quiet websockets httpx
# --- browser-harness-js skill ---------------------------------------------
if [ ! -d /home/bux/.claude/skills/cdp ]; then
say 'installing browser-harness-js skill'
sudo -u bux git clone --depth=1 \
https://github.com/browser-use/browser-harness-js \
/home/bux/.claude/skills/cdp
fi
if [ -f /home/bux/.claude/skills/cdp/sdk/browser-harness-js ]; then
ln -sf /home/bux/.claude/skills/cdp/sdk/browser-harness-js /usr/local/bin/browser-harness-js
chmod +x /home/bux/.claude/skills/cdp/sdk/browser-harness-js
fi
# --- ztk (compresses Bash tool outputs before they hit context) ------------
# https://github.com/codejunkie99/ztk — Zig CLI that registers a PreToolUse
# hook in ~/.claude/settings.json and compresses long stdout (git diff, ls,
# test runners, …) so they don't blow the context window. Source-audited:
# no network calls, no secret reads. Opt out with WITH_ZTK=0.
install_ztk() {
if [ "$WITH_ZTK" != "1" ]; then
say 'skipping ztk (WITH_ZTK=0)'
return 0
fi
# Only x86_64 is pinned — Zig 0.16.0 ships aarch64 too, but we haven't
# pinned that SHA yet. Skip cleanly on other arches rather than building
# from an un-verified tarball.
if [ "$arch" != 'x86_64' ]; then
warn "ztk: skipping (no pinned Zig SHA for $arch)"
return 0
fi
# Already at the pinned version? Nothing to do beyond re-running the
# hook setup (cheap; idempotent JSON merge).
if command -v ztk >/dev/null 2>&1 \
&& ztk --version 2>/dev/null | grep -q "${ZTK_VERSION#v}"; then
say "ztk ${ZTK_VERSION} already installed"
else
say "installing ztk ${ZTK_VERSION} (Zig ${ZIG_VERSION})"
# 1. Zig toolchain at /opt/zig (single global location, not per-user).
zig_bin='/opt/zig/zig'
if [ ! -x "$zig_bin" ] \
|| ! "$zig_bin" version 2>/dev/null | grep -qx "$ZIG_VERSION"; then
say "fetching Zig ${ZIG_VERSION}"
tmp_zig="$(mktemp -d)"
zig_url="https://ziglang.org/download/${ZIG_VERSION}/zig-x86_64-linux-${ZIG_VERSION}.tar.xz"
curl -fsSL "$zig_url" -o "$tmp_zig/zig.tar.xz"
got_sha=$(sha256sum "$tmp_zig/zig.tar.xz" | awk '{print $1}')
if [ "$got_sha" != "$ZIG_X86_64_LINUX_SHA256" ]; then
rm -rf "$tmp_zig"
die "Zig SHA mismatch: got $got_sha"
fi
tar -xJf "$tmp_zig/zig.tar.xz" -C "$tmp_zig"
rm -rf /opt/zig
mv "$tmp_zig/zig-x86_64-linux-${ZIG_VERSION}" /opt/zig
rm -rf "$tmp_zig"
fi
# 2. ztk source at /opt/ztk-src, checked out to the pinned commit.
if [ ! -d /opt/ztk-src/.git ]; then
rm -rf /opt/ztk-src
git clone --quiet https://github.com/codejunkie99/ztk /opt/ztk-src
fi
git -C /opt/ztk-src fetch --quiet --tags origin
git -C /opt/ztk-src checkout --quiet "$ZTK_COMMIT_SHA"
# 3. Build & install system-wide.
( cd /opt/ztk-src && "$zig_bin" build -Doptimize=ReleaseSmall )
# zig build drops the binary in zig-out/bin/ztk
install -m 0755 /opt/ztk-src/zig-out/bin/ztk /usr/local/bin/ztk
fi
# 4. Hook setup for the bux user. ztk init -g merges into existing
# ~/.claude/settings.json (JSON merge, doesn't clobber other hooks).
# Make sure the file exists first so init -g has something to merge with.
install -d -o bux -g bux -m 0755 /home/bux/.claude
if [ ! -f /home/bux/.claude/settings.json ]; then
install -o bux -g bux -m 0644 /dev/null /home/bux/.claude/settings.json
echo '{}' > /home/bux/.claude/settings.json
chown bux:bux /home/bux/.claude/settings.json
fi
sudo -u bux -H ztk init -g >/dev/null
ok 'ztk installed and PreToolUse hook registered'
}
install_ztk
# --- bux-agent-shell: web-terminal agent launcher -------------------------
# bux-ttyd.service execs this (not /usr/bin/claude) so the terminal opens the
# agent the box is configured for — codex on the free-DeepSeek path, else
# claude (ENG-4785). Symlinked BEFORE the ttyd block below, which (re)starts
# bux-ttyd.service: a fresh install.sh box never runs bootstrap.sh, so without
# this the ttyd ExecStart target is missing and the service crash-loops.
ln -sfn "$REPO_DIR/agent/bux-agent-shell" /usr/local/bin/bux-agent-shell
# --- ttyd (web terminal, localhost only) -----------------------------------
# Per-arch SHA256 from https://github.com/tsl0922/ttyd/releases/tag/1.7.7.
# Never use `latest` for binaries you exec as root.
TTYD_VERSION='1.7.7'
case "$arch" in
x86_64)
ttyd_arch=x86_64
TTYD_SHA256='8a217c968aba172e0dbf3f34447218dc015bc4d5e59bf51db2f2cd12b7be4f55'
;;
aarch64|arm64)
ttyd_arch=aarch64
TTYD_SHA256='b38acadd89d1d396a0f5649aa52c539edbad07f4bc7348b27b4f4b7219dd4165'
;;
*) die "unsupported arch: $arch" ;;
esac
# Skip reinstall if the binary is already the expected build. Checking the
# SHA directly is more reliable than parsing `ttyd --version` (output format
# has shifted across releases) and keeps reruns cheap.
installed_sha=''
if [ -f /usr/local/bin/ttyd ]; then
installed_sha=$(sha256sum /usr/local/bin/ttyd | awk '{print $1}')
fi
if [ "$installed_sha" != "$TTYD_SHA256" ]; then
say 'installing ttyd'
# Download to a tempfile and mv into place. Writing directly to
# /usr/local/bin/ttyd fails on rerun because bux-ttyd.service has the
# current binary open — curl -o truncates, OS refuses for a running ELF.
tmp_ttyd="$(mktemp)"
curl -fsSL "https://github.com/tsl0922/ttyd/releases/download/${TTYD_VERSION}/ttyd.${ttyd_arch}" \
-o "$tmp_ttyd"
got_sha=$(sha256sum "$tmp_ttyd" | awk '{print $1}')
if [ "$got_sha" != "$TTYD_SHA256" ]; then
rm -f "$tmp_ttyd"
die "ttyd SHA mismatch: got $got_sha"
fi
chmod +x "$tmp_ttyd"
# `mv` over a running binary is safe (unlinks the old inode, creates new).
mv "$tmp_ttyd" /usr/local/bin/ttyd
systemctl restart bux-ttyd.service 2>/dev/null || true
fi
# --- drop agent files ------------------------------------------------------
# /opt/bux/agent → /opt/bux/repo/agent (symlinked at the top of this script),
# so browser_keeper.py / telegram_bot.py don't need to be copied — the
# systemd units below execute them straight from the symlinked path. Only
# the system prompt gets installed (different destination — bux's home dir).
# Claude Code reads ~/CLAUDE.md and Codex reads ~/AGENTS.md; both symlink to
# the one source-of-truth file so editing once updates both CLIs.
say 'installing bux agent files'
install -o bux -g bux -m 0644 "$REPO_DIR/agent/system-prompt.md" /home/bux/system-prompt.md
ln -sfn /home/bux/system-prompt.md /home/bux/CLAUDE.md
ln -sfn /home/bux/system-prompt.md /home/bux/AGENTS.md
chown -h bux:bux /home/bux/CLAUDE.md /home/bux/AGENTS.md
# Seed an empty private/goals.md. The agent reads + writes this file. An
# empty file is fine — the agent appends entries when the user mentions
# a goal. Without this, the agent's first read of the file errors loudly
# until something writes to it.
install -d -o bux -g bux -m 0755 /opt/bux/repo/private
if [ ! -e /opt/bux/repo/private/goals.md ]; then
install -o bux -g bux -m 0644 /dev/null /opt/bux/repo/private/goals.md
fi
# --- tg-send: shell helper to push a message to the bound TG chat ---------
# Used by `at` / cron jobs (and claude from a shell) so scheduled work can
# notify the user without going through the bot's poll loop. The bot token
# lives at /etc/bux/tg.env (mode 640 root:bux — the bux user can read it,
# the helper runs as bux, no setuid magic needed).
#
# Source-controlled as agent/tg-send. Symlinked (not copied) so a plain
# `git pull` propagates changes — no bootstrap re-run needed for tweaks
# to the helper itself.
ln -sfn "$REPO_DIR/agent/tg-send" /usr/local/bin/tg-send
# --- bux-restart: restart-with-ping helper for the agent ------------------
# Drop-in for `sudo systemctl restart bux-tg` that records the current lane
# (TG_CHAT_ID / TG_THREAD_ID from env) so the post-boot announce sends a
# "✅ back online" ping — same UX as /update from TG, but callable from the
# shell when the agent is restarting itself in response to a natural-
# language request. Symlinked, same reason as tg-send.
ln -sfn "$REPO_DIR/agent/bux-restart" /usr/local/bin/bux-restart
# --- tg-approve: bridge claude/codex permission prompts to TG --------------
# Hook script invoked by claude (PreToolUse) / codex (PermissionRequest).
# Posts a "[Allow] [Deny]" inline-keyboard message in the same lane and
# blocks until the user taps. Bot side handles the callback_query and
# writes the decision into /tmp/tg-approvals/<id>.json which this script
# polls. Source-controlled as agent/tg-approve.py for readability.
install -m 0755 "$REPO_DIR/agent/tg-approve.py" /usr/local/bin/tg-approve
# --- tg-schedule: schedule a future agent turn ----------------------------
# `tg-schedule <when> [--fresh] [--name N] <prompt>` queues an at(1) job
# that, at fire time, dispatches the prompt into the bound chat's lane.
# Default mode resumes the topic the user invoked from (cache-friendly,
# full prior context). --fresh creates a brand-new forum topic with a
# clean session — only spend that on tasks where context would actively
# get in the way.
install -m 0755 "$REPO_DIR/agent/tg-schedule" /usr/local/bin/tg-schedule
install -m 0755 "$REPO_DIR/agent/tg-schedule-fire" /usr/local/bin/tg-schedule-fire
install -m 0755 "$REPO_DIR/agent/tg-run-task" /usr/local/bin/tg-run-task
install -m 0755 "$REPO_DIR/agent/new-topic" /usr/local/bin/new-topic
# Friendlier alias `schedule` for the agent + user; both names work.
ln -sfn /usr/local/bin/tg-schedule /usr/local/bin/schedule
# --- pre-seed ~/.claude.json so first `claude` run skips dialogs -----------
if [ ! -f /home/bux/.claude.json ]; then
sudo -u bux -H bash -c 'cat > /home/bux/.claude.json' <<'JSON'
{
"hasCompletedOnboarding": true,
"theme": "dark",
"hasSeenTasksHint": true,
"bypassPermissionsModeAccepted": true,
"projects": {
"/home/bux": {
"hasTrustDialogAccepted": true,
"hasCompletedProjectOnboarding": true,
"projectOnboardingSeenCount": 1,
"allowedTools": [],
"mcpContextUris": [],
"mcpServers": {},
"enabledMcpjsonServers": [],
"disabledMcpjsonServers": []
}
}
}
JSON
chmod 600 /home/bux/.claude.json
chown bux:bux /home/bux/.claude.json
fi
# --- /etc/bux/env (shared by systemd services) -----------------------------
if [ ! -f /etc/bux/env ]; then
cat > /etc/bux/env <<EOF
BROWSER_USE_API_KEY=$BROWSER_USE_API_KEY
BUX_PROFILE_ID=$BUX_PROFILE_ID
EOF
chmod 640 /etc/bux/env
chown root:bux /etc/bux/env
else
say 'keeping existing /etc/bux/env (delete it to regenerate)'
fi
# --- guard against symlinked dotfiles before we chown / append -------------
# /home/bux is bux-owned. Without this, a malicious bux user could symlink
# ~/.bashrc or ~/.profile to a root-owned path and our cat>>/chown would
# follow the link. -L matches dangling symlinks too.
for p in /home/bux/.bashrc /home/bux/.profile; do
if [ -L "$p" ]; then
die "refusing to operate on symlinked $p"
fi
if [ -e "$p" ] && [ ! -f "$p" ]; then
die "refusing to operate on non-regular-file $p"
fi
done
# --- auto-source browser env in bux's shell --------------------------------
if ! grep -q 'browser.env' /home/bux/.bashrc 2>/dev/null; then
cat >> /home/bux/.bashrc <<'BASHRC'
# Auto-source Browser Use env written by the browser-keeper.
[ -f "$HOME/.claude/browser.env" ] && . "$HOME/.claude/browser.env" 2>/dev/null || true
BASHRC
chown bux:bux /home/bux/.bashrc
fi
# --- per-user PATH so bux can upgrade their own tools without root ---------
# Goes in .profile (login shells / ssh) since .bashrc bails for
# non-interactive shells. .npm-global/bin shadows /usr/bin/<pkg>,
# .local/bin covers uv/pipx.
if ! grep -q 'npm-global' /home/bux/.profile 2>/dev/null; then
cat >> /home/bux/.profile <<'PROFILE'
# Per-user installs shadow system ones (gh/uv/etc.) so the bux user can
# upgrade their own tools without root.
export PATH="$HOME/.local/bin:$HOME/.npm-global/bin:$PATH"
PROFILE
chown bux:bux /home/bux/.profile
fi
# Pre-set npm global prefix so `npm install -g <pkg>` lands in ~/.npm-global
# without root.
sudo -u bux -H npm config set prefix /home/bux/.npm-global 2>/dev/null || true
install -d -o bux -g bux -m 0755 /home/bux/.npm-global /home/bux/.local /home/bux/.local/bin
# --- Codex CLI (alternative agent, /codex per forum topic) -----------------
# Pre-install for the bux user so `/codex login` (or auto-dispatch
# via `/codex`) works without a manual install. Auth is left to the
# user — either drop `OPENAI_API_KEY=...` into /home/bux/.secrets/openai.env,
# or run `/codex login` once and complete the device-code flow from TG.
# Install runs as bux so the binary lands in /home/bux/.npm-global/bin,
# which is on bux's PATH (set by the .profile block above). Non-fatal:
# an npm hiccup shouldn't break a Claude-only install.
if ! sudo -iu bux command -v codex >/dev/null 2>&1; then
say 'installing Codex CLI for bux'
sudo -iu bux npm install -g @openai/codex \
|| warn 'codex install failed (non-fatal — /codex login will hint how to install later)'
fi
# Enable Codex /goal feature so `/goal <X>` autopilot works out of the box.
# Setting is `goals = true` under `[features]` in ~/.codex/config.toml
# (Codex CLI v0.128.0+; experimental). Idempotent: leaves existing config
# alone if a [features] block or goals = true is already present.
sudo -u bux -H bash -c '
CODEX_CONFIG="$HOME/.codex/config.toml"
mkdir -p "$(dirname "$CODEX_CONFIG")"
if [ ! -f "$CODEX_CONFIG" ]; then
cat > "$CODEX_CONFIG" <<TOML
[features]
goals = true
TOML
elif ! grep -qE "^[[:space:]]*goals[[:space:]]*=" "$CODEX_CONFIG"; then
if grep -qE "^[[:space:]]*\[features\]" "$CODEX_CONFIG"; then
echo "install: warn — existing [features] block in $CODEX_CONFIG; add goals = true manually to enable /goal" >&2
else
printf "\n[features]\ngoals = true\n" >> "$CODEX_CONFIG"
fi
fi
chmod 0644 "$CODEX_CONFIG"
'
# --- login banner: print live browser URL on each ssh login ---------------
if ! grep -q 'BU_BROWSER_LIVE_URL' /home/bux/.profile 2>/dev/null; then
cat >> /home/bux/.profile <<'PROFILE'
# Show the live browser URL so users have one click to spectate / take over.
if [ -r "$HOME/.claude/browser.env" ]; then
. "$HOME/.claude/browser.env" 2>/dev/null || true
if [ -n "${BU_BROWSER_LIVE_URL:-}" ]; then
printf '\n \033[1mLive browser:\033[0m %s\n\n' "$BU_BROWSER_LIVE_URL"
fi
fi
PROFILE
chown bux:bux /home/bux/.profile
fi
# --- systemd units ---------------------------------------------------------
# The unit files live in agent/*.service and we symlink them into
# /etc/systemd/system. Same approach bootstrap.sh uses (see
# agent/bootstrap.sh:182), so post-install updates via `git pull` keep
# the deployed units in sync without re-running install. Previously
# install.sh wrote them via heredoc and bootstrap.sh immediately
# replaced those with symlinks — pure dead code that had also drifted
# from the source-of-truth repo files.
for unit in bux-browser-keeper.service bux-ttyd.service bux-tg.service bux-miniapp.service bux-miniapp-tunnel.service; do
ln -sfn "$REPO_DIR/agent/$unit" "/etc/systemd/system/$unit"
done
systemctl daemon-reload
systemctl enable bux-browser-keeper.service bux-ttyd.service >/dev/null
# --- optional: Telegram bot setup -----------------------------------------
if [ -n "$TG_BOT_TOKEN" ]; then
say 'configuring Telegram bot'
setup_token="$(python3 -c 'import secrets; print(secrets.token_hex(6))')"
cat > /etc/bux/tg.env <<EOF
TG_BOT_TOKEN=$TG_BOT_TOKEN
TG_SETUP_TOKEN=$setup_token
EOF
# When the provisioner already knows the owner's TG identity (cloud
# install, scripted setup), pin it now. The bot reads TG_OWNER_ID at
# startup as the authoritative box-owner — a stranger redeeming the
# setup token gets the chat allow-listed but is treated as a guest
# for every owner-only check (auto-allow, /terminal, /codex, etc).
if [ -n "${TG_OWNER_ID:-}" ]; then
printf 'TG_OWNER_ID=%s\n' "$TG_OWNER_ID" >> /etc/bux/tg.env
[ -n "${TG_OWNER_USERNAME:-}" ] && printf 'TG_OWNER_USERNAME=%s\n' "$TG_OWNER_USERNAME" >> /etc/bux/tg.env
[ -n "${TG_OWNER_NAME:-}" ] && printf 'TG_OWNER_NAME=%s\n' "$TG_OWNER_NAME" >> /etc/bux/tg.env
fi
# 0o640 root:bux so the tg-send helper can read the bot token from
# `at` jobs running as bux. Worst-case leak: someone with bux access
# can call sendMessage, but only the bound chat receives it — they
# can't spam arbitrary users.
chmod 640 /etc/bux/tg.env
chown root:bux /etc/bux/tg.env
# Keep the Mini App backend + HTTPS tunnel warm so /agency opens
# immediately after reboot. The API still requires Telegram initData
# signed by this box's bot token and matching the box owner.
systemctl enable bux-tg.service >/dev/null
systemctl enable bux-miniapp.service bux-miniapp-tunnel.service >/dev/null
systemctl restart bux-tg.service
systemctl restart bux-miniapp.service bux-miniapp-tunnel.service
# Resolve bot username for the user-facing instructions.
bot_username=$(curl -fsSL "https://api.telegram.org/bot${TG_BOT_TOKEN}/getMe" | jq -r '.result.username' 2>/dev/null || echo '')
printf '\n%sTelegram bot is live.%s\n' "$c_bold" "$c_reset"
if [ -n "$bot_username" ] && [ "$bot_username" != 'null' ]; then
# Forum-first onboarding: deeplink opens Telegram's "select or create a
# group" picker AND pre-prompts the user to grant the admin rights we
# need (manage topics + pin messages). With admin status the bot reads
# all messages in the group regardless of the BotFather privacy toggle,
# so users don't have to fiddle with /setprivacy. Forum topics become
# parallel agent lanes once the user enables Topics on the group.
# Telegram's deeplink `admin=` parameter takes the bare ChatAdminRights
# flag names (no `can_` prefix). The Bot API JSON form (used by
# setMyDefaultAdministratorRights) is the one that uses can_*; don't
# mix the two namespaces.
forum_url="https://t.me/${bot_username}?startgroup=true&admin=manage_topics+pin_messages"
printf '\n Recommended: add me to a group (then enable Topics for parallel lanes).\n'
printf ' %sScan the QR or open:%s\n\n' "$c_bold" "$c_reset"
printf ' %s\n\n' "$forum_url"
if command -v qrencode >/dev/null 2>&1; then
qrencode -t ANSIUTF8 -m 1 "$forum_url"
printf '\n'
fi
printf ' In Telegram: tap "Create New Group" in the picker → name it → tap "Allow"\n'
printf ' on the admin-rights prompt. Send any message in any topic to bind.\n\n'
printf '%s Advanced — DM only (no group, no parallel lanes):%s\n' "$c_dim" "$c_reset"
printf '%s https://t.me/%s%s\n' "$c_dim" "$bot_username" "$c_reset"
else
printf ' Open the bot in Telegram and send any message in any chat to bind.\n'
fi
printf ' (The first chat wins — nobody else can bind after that.)\n'
fi
# --- start everything ------------------------------------------------------
systemctl restart bux-browser-keeper.service bux-ttyd.service
# --- final summary ---------------------------------------------------------
echo
ok 'bux is installed.'
echo
printf '%sNext:%s\n' "$c_bold" "$c_reset"
printf ' • Become the %sbux%s user and launch Claude Code:\n' "$c_bold" "$c_reset"
printf ' %ssudo -iu bux%s\n' "$c_dim" "$c_reset"
printf ' %scd ~ && claude%s\n' "$c_dim" "$c_reset"
printf ' • First run: type %s/login%s in Claude Code and complete the OAuth flow.\n' "$c_bold" "$c_reset"
printf ' • The browser is already running — check: %scat /home/bux/.claude/browser.env%s\n' "$c_dim" "$c_reset"
echo
if [ -z "$TG_BOT_TOKEN" ]; then
printf ' %s(optional)%s Add a Telegram bot: create one via @BotFather, then:\n' "$c_dim" "$c_reset"
printf ' %sTG_BOT_TOKEN=<token> sudo ./install.sh%s\n' "$c_dim" "$c_reset"
echo
fi