Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/hand/install/install.sh

40.3 KiB, 1 run

created by r2519314175:905, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1#!/usr/bin/env bash
2#
3# Registers Daimond's machine hand with the browsers on this machine, and does
4# the two small setup steps that used to be typed by hand.
5#
6# This one small file in one per-browser directory is the whole cost of the
7# design. A web page cannot create a process, so the capability lives in a
8# program outside the page, and Chrome will only connect that program to an
9# extension the program itself names. Naming it is what this script does. There
10# is no port, no daemon and no secret -- and the price of having none of those
11# is that a file has to be written by hand, once, per browser.
12#
13# It writes JSON. It builds nothing, downloads nothing, starts nothing, and
14# needs no root for the per-user directories it uses.
15#
16# ./install.sh # find the built binary, register it
17# ./install.sh --workspace ~/work # ...and grant that folder, in the same run
18# ./install.sh --terminal-workspace ~ # ...and let a TERMINAL reach the whole account
19# ./install.sh --remote # ...and set up the ssh a Terminal may use
20# ./install.sh --check # diagnose an install, changing nothing
21# ./install.sh /path/to/daimond-hand # register a particular binary
22# ./install.sh --dir /some/profile/NativeMessagingHosts /path/to/binary
23# ./install.sh --list # say what it would write, and where
24# ./install.sh --selftest # run this script's own tests
25#
26# --workspace does what step 2 of install/README.md used to ask you to type: it
27# creates the journal directory at mode 700 and writes the granted folder into
28# `root.txt` beside it. Which folder to grant is still yours to choose, and so
29# is the approval in the browser -- those are decisions and stay explicit.
30#
31# The extension id is pinned by the public key in ext/manifest.json, so it is
32# the same in every browser and on every machine. Override it with
33# DAIMOND_HAND_EXT_ID only if you are loading a build whose key you changed.
34
35set -euo pipefail
36
37HOST='com.oxedyne.daimond.hand'
38EXT_ID="${DAIMOND_HAND_EXT_ID:-mpliijponglmmffjnonahhignkpkhmij}"
39
40HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
41REPO="$(cd "$HERE/../.." && pwd)"
42
43# ── Where each browser looks ─────────────────────────────────────────
44#
45# LINUX (implemented). Per user, under the browser's own configuration root,
46# which is also its default user-data-dir. A browser started with an explicit
47# --user-data-dir reads <that dir>/NativeMessagingHosts instead, which is what
48# --dir is for.
49#
50# System-wide equivalents, for a machine where every account should have it:
51# /etc/opt/chrome/native-messaging-hosts/
52# /etc/chromium/native-messaging-hosts/
53# /etc/opt/edge/native-messaging-hosts/
54# /etc/brave/native-messaging-hosts/
55# Note the different spelling: system directories are lower case and hyphenated,
56# per-user ones are CamelCase. That is Chrome's own inconsistency, not ours.
57#
58# MACOS (not implemented yet; the paths are here so they are not rediscovered).
59# ~/Library/Application Support/Google/Chrome/NativeMessagingHosts/
60# ~/Library/Application Support/Chromium/NativeMessagingHosts/
61# ~/Library/Application Support/BraveSoftware/Brave-Browser/NativeMessagingHosts/
62# ~/Library/Application Support/Microsoft Edge/NativeMessagingHosts/
63# system-wide: /Library/Google/Chrome/NativeMessagingHosts/
64# The file format is identical; only the directory differs. macOS also requires
65# the binary to be signed and notarised before Gatekeeper will run it from a
66# browser, which is a packaging job, not a path.
67#
68# WINDOWS (not implemented yet). There is no directory: the manifest is found
69# through the registry, and the manifest file itself may live anywhere.
70# HKCU\Software\Google\Chrome\NativeMessagingHosts\com.oxedyne.daimond.hand
71# HKCU\Software\Chromium\NativeMessagingHosts\com.oxedyne.daimond.hand
72# HKCU\Software\BraveSoftware\Brave-Browser\NativeMessagingHosts\com.oxedyne.daimond.hand
73# HKCU\Software\Microsoft\Edge\NativeMessagingHosts\com.oxedyne.daimond.hand
74# The key's DEFAULT value is the absolute path to the .json file. Use HKLM for a
75# machine-wide install. "path" inside the manifest must then be either absolute
76# or relative to the manifest's own directory.
77
78CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}"
79SNAP="$HOME/snap"
80FLAT="$HOME/.var/app"
81
82# Label, packaging, then the browser's profile root. The native messaging
83# directory is that root with NativeMessagingHosts on the end.
84#
85# The packaging field is the reason this table grew. A snap or a flatpak browser
86# is CONFINED, and the confinement reaches the programs it starts: the hand
87# inherits it, and cannot open the hidden directories in $HOME where it keeps
88# its record. Registering the host in such a profile succeeds and then produces
89# a hand that exits before it can say why -- so these entries exist to be found
90# and refused, not to be written into.
91#
92# Snap arranges itself two ways and both are here. The chromium snap sets its
93# own --user-data-dir under `common`; the others get a remapped HOME, so their
94# profile is the ordinary path with ~/snap/<pkg>/current in front of it.
95# Flatpak redirects XDG_CONFIG_HOME to ~/.var/app/<id>/config, so a flatpak
96# profile is the ordinary path with that in front of it.
97BROWSERS=(
98 "Google Chrome|deb|$CONFIG/google-chrome"
99 "Google Chrome Beta|deb|$CONFIG/google-chrome-beta"
100 "Google Chrome Dev|deb|$CONFIG/google-chrome-unstable"
101 "Chromium|deb|$CONFIG/chromium"
102 "Brave|deb|$CONFIG/BraveSoftware/Brave-Browser"
103 "Brave Beta|deb|$CONFIG/BraveSoftware/Brave-Browser-Beta"
104 "Microsoft Edge|deb|$CONFIG/microsoft-edge"
105 "Vivaldi|deb|$CONFIG/vivaldi"
106 "Opera|deb|$CONFIG/opera"
107
108 "Chromium (snap)|snap|$SNAP/chromium/common/chromium"
109 "Chromium (snap)|snap|$SNAP/chromium/current/.config/chromium"
110 "Brave (snap)|snap|$SNAP/brave/current/.config/BraveSoftware/Brave-Browser"
111 "Vivaldi (snap)|snap|$SNAP/vivaldi/current/.config/vivaldi"
112 "Opera (snap)|snap|$SNAP/opera/current/.config/opera"
113 "Chromium (snap)|snap|$SNAP/chromium-mir-kiosk/common/chromium"
114
115 "Chromium (flatpak)|flatpak|$FLAT/org.chromium.Chromium/config/chromium"
116 "Google Chrome (flatpak)|flatpak|$FLAT/com.google.Chrome/config/google-chrome"
117 "Brave (flatpak)|flatpak|$FLAT/com.brave.Browser/config/BraveSoftware/Brave-Browser"
118 "Microsoft Edge (flatpak)|flatpak|$FLAT/com.microsoft.Edge/config/microsoft-edge"
119 "Vivaldi (flatpak)|flatpak|$FLAT/com.vivaldi.Vivaldi/config/vivaldi"
120 "Opera (flatpak)|flatpak|$FLAT/com.opera.Opera/config/opera"
121 "Ungoogled Chromium (flatpak)|flatpak|$FLAT/io.github.ungoogled_software.ungoogled_chromium/config/chromium"
122)
123
124# Where the hand keeps its record, spelled exactly as `journal::default_dir`
125# spells it. If these two ever disagree, the script writes `root.txt` somewhere
126# the hand will not look, which is a silent failure -- so this line is the one
127# to change when that one changes.
128JDIR="${DAIMOND_HAND_JOURNAL_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/daimond/hand/journal}"
129
130# ── Arguments ────────────────────────────────────────────────────────
131
132ONLY_DIR=''
133LIST_ONLY=0
134CHECK_ONLY=0
135PATHS_ONLY=0
136WORKSPACE=''
137# The widest a TERMINAL may ever reach. Empty and a terminal gets the granted
138# folder, which is what every build before 2026-08-26 did.
139TERM_WORKSPACE=''
140BINARY=''
141REMOTE=0
142
143while [ $# -gt 0 ]; do
144 case "$1" in
145 --dir) ONLY_DIR="${2:?--dir needs a directory}"; shift 2 ;;
146 --workspace|-w)
147 WORKSPACE="${2:?--workspace needs a folder}"; shift 2 ;;
148 --terminal-workspace|-t)
149 TERM_WORKSPACE="${2:?--terminal-workspace needs a folder}"; shift 2 ;;
150 --terminal-workspace=*)
151 TERM_WORKSPACE="${1#*=}"; shift ;;
152 --list) LIST_ONLY=1; shift ;;
153 --check) CHECK_ONLY=1; shift ;;
154 # Daimond's own ssh key, its own host list, and the wrapper that puts both on
155 # an `ssh` command line without the user typing them. See "The Remote
156 # toolchain" below for what it writes and what it deliberately does not.
157 --remote) REMOTE=1; shift ;;
158 # Every native messaging directory this script knows about, one per line,
159 # found or not. `uninstall.sh` reads it, so the table of browsers lives in
160 # exactly one file rather than in two that drift.
161 --paths) PATHS_ONLY=1; shift ;;
162 --selftest) SELFTEST=1; shift ;;
163 -h|--help)
164 # The leading comment block, and not a line past it. A fixed line count
165 # printed seven lines of shell after the last comment the day the block
166 # grew shorter than the number; the block's own end is the only marker
167 # that stays right.
168 awk 'NR==1 { next } /^#/ { sub(/^# ?/, ""); print; next } { exit }' "${BASH_SOURCE[0]}"
169 exit 0 ;;
170 -*) echo "install.sh: unknown option $1" >&2; exit 2 ;;
171 *) BINARY="$1"; shift ;;
172 esac
173done
174
175if [ "$PATHS_ONLY" = 1 ]; then
176 for entry in "${BROWSERS[@]}"; do
177 printf '%s/NativeMessagingHosts\n' "${entry##*|}"
178 done
179 exit 0
180fi
181
182# ── Small helpers ────────────────────────────────────────────────────
183
184# Whether a directory belongs to a confined browser, judged by its path. Used
185# for --dir, where there is no table entry to consult.
186confined_path() {
187 case "$1" in
188 "$SNAP"/*|*/snap/*/common/*|*/snap/*/current/*) echo snap ;;
189 "$FLAT"/*|*/.var/app/*) echo flatpak ;;
190 *) echo '' ;;
191 esac
192}
193
194# The sentence that saves the hour. Printed wherever a confined browser is
195# found, and never suppressed -- a snap browser that installs quietly is the
196# whole of the failure this script exists to prevent. It says what happens
197# rather than that this is unsupported, because "unsupported" invites a
198# workaround and there is not one.
199say_confined() {
200 cat >&2 <<EOF
201
202A $1 browser cannot run Machine Operations. Its confinement extends to the
203programs it starts, so the hand can only see the files in \$HOME that are not
204hidden -- and its journal is at $JDIR,
205behind one that is. The hand exits before it can open the journal it would have
206used to say so, and the browser reports only "Native host has exited".
207
208Fix: a Chromium-family browser installed from a .deb. Moving the journal will
209not help, because the browser gives the hand its own environment, so
210DAIMOND_HAND_JOURNAL_DIR never reaches it.
211EOF
212}
213
214# ── Selftest ─────────────────────────────────────────────────────────
215#
216# Run before anything reads the filesystem for real, so a test run never touches
217# the invoking user's browsers. Each case builds a throwaway home directory that
218# looks like one arrangement, runs THIS script against it with HOME redirected,
219# and asserts the exit status and what was said. `dev/publish.mjs --selftest` is
220# the same technique in JavaScript.
221selftest() {
222 local pass=0 fail=0
223
224 # Not /tmp: it is a tmpfs, and the fleet has been OOM-killed off it three
225 # times. ~/.cache is a real filesystem.
226 mkdir -p "${XDG_CACHE_HOME:-$HOME/.cache}"
227 BASE="$(mktemp -d "${XDG_CACHE_HOME:-$HOME/.cache}/daimond-install-selftest.XXXXXX")"
228 trap 'rm -rf "$BASE"' EXIT
229
230 local out status
231 check() {
232 local name="$1" ok="$2" detail="${3:-}"
233 if [ "$ok" = 1 ]; then
234 pass=$((pass + 1)); printf ' ok %s\n' "$name"
235 else
236 fail=$((fail + 1)); printf ' FAIL %s%s\n' "$name" "${detail:+ -- $detail}"
237 fi
238 }
239
240 # A home directory with the given profile directories in it, and a
241 # stand-in binary for the script to register.
242 fixture() {
243 local name="$1"; shift
244 local h="$BASE/$name"
245 mkdir -p "$h"
246 printf '#!/bin/sh\necho "daimond-hand 0.0.0-fixture"\n' > "$h/daimond-hand"
247 chmod 755 "$h/daimond-hand"
248 local d
249 for d in "$@"; do mkdir -p "$h/$d"; done
250 echo "$h"
251 }
252
253 # Run this script with HOME pointed at a fixture, capturing both streams.
254 run_in() {
255 local h="$1"; shift
256 set +e
257 out="$(HOME="$h" XDG_CONFIG_HOME= XDG_DATA_HOME= XDG_CACHE_HOME= \
258 DAIMOND_HAND_JOURNAL_DIR= \
259 bash "${BASH_SOURCE[0]}" "$@" 2>&1)"
260 status=$?
261 set -e
262 }
263
264 echo "install.sh --selftest"
265
266 # 1. A snap profile is found, named, and refused.
267 local h
268 h="$(fixture snap "snap/chromium/common/chromium")"
269 run_in "$h" "$h/daimond-hand"
270 check 'a snap chromium profile is refused' \
271 "$( [ "$status" = 3 ] && grep -qi 'snap browser cannot run' <<<"$out" && echo 1 || echo 0 )" \
272 "exit $status"
273 check 'and the refusal names the .deb fix' \
274 "$( grep -q '\.deb' <<<"$out" && echo 1 || echo 0 )"
275 check 'and nothing was written into the snap profile' \
276 "$( [ ! -e "$h/snap/chromium/common/chromium/NativeMessagingHosts/$HOST.json" ] && echo 1 || echo 0 )"
277
278 # 2. A flatpak profile, the same.
279 h="$(fixture flatpak ".var/app/org.chromium.Chromium/config/chromium")"
280 run_in "$h" "$h/daimond-hand"
281 check 'a flatpak chromium profile is refused' \
282 "$( [ "$status" = 3 ] && grep -qi 'flatpak browser cannot run' <<<"$out" && echo 1 || echo 0 )" \
283 "exit $status"
284 check 'and nothing was written into the flatpak profile' \
285 "$( [ ! -e "$h/.var/app/org.chromium.Chromium/config/chromium/NativeMessagingHosts/$HOST.json" ] && echo 1 || echo 0 )"
286
287 # 3. A .deb profile is installed into, and says so.
288 h="$(fixture deb ".config/google-chrome")"
289 run_in "$h" "$h/daimond-hand"
290 check 'a .deb chrome profile is registered' \
291 "$( [ "$status" = 0 ] && [ -f "$h/.config/google-chrome/NativeMessagingHosts/$HOST.json" ] && echo 1 || echo 0 )" \
292 "exit $status"
293 check 'and the manifest names the binary it was given' \
294 "$( grep -qF "$h/daimond-hand" "$h/.config/google-chrome/NativeMessagingHosts/$HOST.json" && echo 1 || echo 0 )"
295 check 'and it prints where to load the extension from' \
296 "$( grep -qF "$REPO/ext" <<<"$out" && echo 1 || echo 0 )"
297
298 # 4. Nothing at all: the likeliest reason is named, which is the other
299 # thing an hour was lost to.
300 h="$(fixture none)"
301 run_in "$h" "$h/daimond-hand"
302 check 'no profile at all is a refusal' \
303 "$( [ "$status" = 1 ] && echo 1 || echo 0 )" "exit $status"
304 check 'and it says the profile appears only after a first run' \
305 "$( grep -qi 'run it once' <<<"$out" && echo 1 || echo 0 )" \
306 "$(head -1 <<<"$out")"
307 check 'and it lists snap and flatpak among the places it looked' \
308 "$( grep -q 'snap/chromium' <<<"$out" && grep -q '\.var/app' <<<"$out" && echo 1 || echo 0 )"
309
310 # 5. Both kinds present: the usable one is used and the other is named.
311 h="$(fixture both ".config/chromium" "snap/chromium/common/chromium")"
312 run_in "$h" "$h/daimond-hand"
313 check 'a usable browser beside a confined one still installs' \
314 "$( [ "$status" = 0 ] && [ -f "$h/.config/chromium/NativeMessagingHosts/$HOST.json" ] && echo 1 || echo 0 )" \
315 "exit $status"
316 check 'and the confined one is named as skipped' \
317 "$( grep -qi 'skipped' <<<"$out" && grep -q 'snap' <<<"$out" && echo 1 || echo 0 )"
318
319 # 6. --dir stays an escape hatch, and warns when it is pointed at a snap.
320 h="$(fixture dirsnap "snap/chromium/common/chromium")"
321 run_in "$h" --dir "$h/snap/chromium/common/chromium/NativeMessagingHosts" "$h/daimond-hand"
322 check '--dir into a snap profile writes, and warns' \
323 "$( [ "$status" = 0 ] \
324 && [ -f "$h/snap/chromium/common/chromium/NativeMessagingHosts/$HOST.json" ] \
325 && grep -qi 'snap browser cannot run' <<<"$out" && echo 1 || echo 0 )" \
326 "exit $status"
327
328 h="$(fixture dirplain)"
329 run_in "$h" --dir "$h/profile/NativeMessagingHosts" "$h/daimond-hand"
330 check '--dir into an ordinary profile writes, and does not warn' \
331 "$( [ "$status" = 0 ] \
332 && [ -f "$h/profile/NativeMessagingHosts/$HOST.json" ] \
333 && ! grep -qi 'cannot run Machine Operations' <<<"$out" && echo 1 || echo 0 )" \
334 "exit $status"
335
336 # 7. --workspace does the two steps that used to be typed.
337 h="$(fixture ws ".config/chromium")"
338 mkdir -p "$h/work"
339 run_in "$h" --workspace "$h/work" "$h/daimond-hand"
340 check '--workspace writes root.txt beside the journal' \
341 "$( [ "$status" = 0 ] && grep -qxF "$(cd "$h/work" && pwd -P)" "$h/.local/share/daimond/hand/journal/root.txt" && echo 1 || echo 0 )" \
342 "exit $status"
343 check 'and creates the journal directory at 0700' \
344 "$( [ "$(stat -c %a "$h/.local/share/daimond/hand/journal")" = 700 ] && echo 1 || echo 0 )" \
345 "$(stat -c %a "$h/.local/share/daimond/hand/journal" 2>/dev/null || echo absent)"
346
347 # 8. ...and refuses the two folders that would be wrong.
348 # On the message, not only the status: the home directory also contains the
349 # journal, so a status check alone passes on the wrong guard.
350 h="$(fixture wshome ".config/chromium")"
351 run_in "$h" --workspace "$h" "$h/daimond-hand"
352 check '--workspace refuses the home directory itself' \
353 "$( [ "$status" = 2 ] && grep -q 'bounds everything' <<<"$out" && echo 1 || echo 0 )" \
354 "exit $status"
355
356 h="$(fixture wsmissing ".config/chromium")"
357 run_in "$h" --workspace "$h/nope" "$h/daimond-hand"
358 check '--workspace refuses a folder that is not there' \
359 "$( [ "$status" = 2 ] && grep -qi 'does not exist' <<<"$out" && echo 1 || echo 0 )" "exit $status"
360
361 h="$(fixture wsinside ".config/chromium")"
362 mkdir -p "$h/.local/share"
363 run_in "$h" --workspace "$h/.local/share" "$h/daimond-hand"
364 check '--workspace refuses a folder containing the journal' \
365 "$( [ "$status" = 2 ] && grep -qi 'journal' <<<"$out" && echo 1 || echo 0 )" "exit $status"
366
367 # 9. --check fails on an install that is not done, and says which line.
368 h="$(fixture chk ".config/chromium")"
369 run_in "$h" --check "$h/daimond-hand"
370 check '--check fails before anything is set up' \
371 "$( [ "$status" = 1 ] && echo 1 || echo 0 )" "exit $status"
372 # The FAIL line for root.txt specifically, not merely a FAIL somewhere and
373 # the words root.txt somewhere else.
374 check 'and names root.txt as the missing piece' \
375 "$( grep -qE '^ FAIL root\.txt' <<<"$out" && echo 1 || echo 0 )"
376
377 # 10. ...and passes on one that is.
378 h="$(fixture chkok ".config/chromium")"
379 mkdir -p "$h/work"
380 run_in "$h" --workspace "$h/work" "$h/daimond-hand"
381 run_in "$h" --check "$h/daimond-hand"
382 check '--check passes on an install this script just made' \
383 "$( [ "$status" = 0 ] && ! grep -q 'FAIL' <<<"$out" && echo 1 || echo 0 )" \
384 "$(grep FAIL <<<"$out" | head -2 | tr '\n' ' ')"
385
386 # 10b. A confined browser beside a usable one is a note, not a failure --
387 # but it must be said, because using that one makes every other line
388 # pass while nothing works.
389 h="$(fixture chkboth ".config/chromium" "snap/chromium/common/chromium")"
390 mkdir -p "$h/work"
391 run_in "$h" --workspace "$h/work" "$h/daimond-hand"
392 run_in "$h" --check "$h/daimond-hand"
393 check '--check names a confined browser found beside a usable one' \
394 "$( [ "$status" = 0 ] && grep -qE '^ note browser.*snap' <<<"$out" && echo 1 || echo 0 )" \
395 "exit $status"
396
397 # 11. ...and fails again the moment the journal directory is opened up,
398 # which is the mode the hand itself refuses to start on.
399 chmod 755 "$h/.local/share/daimond/hand/journal"
400 run_in "$h" --check "$h/daimond-hand"
401 check '--check catches a journal directory that is not 0700' \
402 "$( [ "$status" = 1 ] && grep -q '700' <<<"$out" && echo 1 || echo 0 )" "exit $status"
403
404 # 12. --check catches a registration pointing at a binary that has gone.
405 h="$(fixture chkstale ".config/chromium")"
406 mkdir -p "$h/work"
407 run_in "$h" --workspace "$h/work" "$h/daimond-hand"
408 rm -f "$h/daimond-hand"
409 run_in "$h" --check
410 check '--check catches a registration whose binary has gone' \
411 "$( [ "$status" = 1 ] && echo 1 || echo 0 )" "exit $status"
412
413 # 13. --paths, which uninstall.sh reads, covers all three packagings.
414 h="$(fixture paths)"
415 run_in "$h" --paths
416 check '--paths lists deb, snap and flatpak directories' \
417 "$( grep -q "$h/.config/chromium/NativeMessagingHosts" <<<"$out" \
418 && grep -q "$h/snap/chromium/common/chromium/NativeMessagingHosts" <<<"$out" \
419 && grep -q "$h/.var/app/org.chromium.Chromium/config/chromium/NativeMessagingHosts" <<<"$out" \
420 && echo 1 || echo 0 )"
421
422 echo
423 echo " $pass passed, $fail failed"
424 [ "$fail" = 0 ]
425}
426
427if [ "${SELFTEST:-0}" = 1 ]; then
428 if selftest; then exit 0; else exit 1; fi
429fi
430
431# ── The binary ───────────────────────────────────────────────────────
432#
433# A release build first, because that is what anyone running this for real will
434# have; a debug build second, because that is what a developer has to hand. The
435# path is written into the manifest verbatim, so it must be absolute -- a
436# relative path would be resolved against the manifest's directory, which is not
437# where anybody's build output lives.
438
439if [ -z "$BINARY" ]; then
440 for cand in "$REPO/hand/target/release/daimond-hand" "$REPO/hand/target/debug/daimond-hand"; do
441 if [ -x "$cand" ]; then BINARY="$cand"; break; fi
442 done
443fi
444
445if [ -z "$BINARY" ] && [ "$CHECK_ONLY" = 0 ]; then
446 cat >&2 <<EOF
447install.sh: no daimond-hand binary found.
448
449Build it first, from $REPO:
450
451 cargo build --release --manifest-path hand/Cargo.toml
452
453then run this again. To register something else -- a mock host, or a binary you
454keep elsewhere -- pass its path:
455
456 ./install.sh /path/to/daimond-hand
457EOF
458 exit 1
459fi
460
461if [ -n "$BINARY" ]; then
462 BINARY="$(cd "$(dirname "$BINARY")" && pwd)/$(basename "$BINARY")"
463 if [ ! -x "$BINARY" ] && [ "$CHECK_ONLY" = 0 ]; then
464 echo "install.sh: $BINARY is not executable. Chrome will not run it." >&2
465 exit 1
466 fi
467fi
468
469# ── Finding the browsers ─────────────────────────────────────────────
470#
471# Only browsers that are actually installed. Writing into a directory for a
472# browser nobody has is harmless but dishonest: the report would claim work that
473# means nothing.
474
475USABLE=() # label|dir, for browsers whose hand can reach its own journal
476CONFINED=() # label|dir|kind, for the ones whose hand cannot
477
478for entry in "${BROWSERS[@]}"; do
479 label="${entry%%|*}"
480 rest="${entry#*|}"
481 kind="${rest%%|*}"
482 prof="${rest#*|}"
483 [ -d "$prof" ] || continue
484 if [ "$kind" = deb ]; then
485 USABLE+=("$label|$prof/NativeMessagingHosts")
486 else
487 CONFINED+=("$label|$prof/NativeMessagingHosts|$kind")
488 fi
489done
490
491# ── The manifest ─────────────────────────────────────────────────────
492#
493# Written from here rather than copied from the template beside this script, so
494# there is exactly one place the path and the extension id are decided. The
495# template is kept for reading, and for anyone registering the host by hand on a
496# platform this script does not cover yet.
497
498manifest() {
499 cat <<EOF
500{
501 "name": "$HOST",
502 "description": "Daimond's machine hand: runs commands for the Daimond Hands extension.",
503 "path": "$BINARY",
504 "type": "stdio",
505 "allowed_origins": [
506 "chrome-extension://$EXT_ID/"
507 ]
508}
509EOF
510}
511
512write_to() {
513 local label="$1" dir="$2"
514 if [ "$LIST_ONLY" = 1 ]; then
515 printf ' %-24s %s/%s.json\n' "$label" "$dir" "$HOST"
516 return 0
517 fi
518 mkdir -p "$dir"
519 manifest > "$dir/$HOST.json"
520 chmod 644 "$dir/$HOST.json"
521 printf ' %-24s %s/%s.json\n' "$label" "$dir" "$HOST"
522}
523
524# ── Checking, which changes nothing ──────────────────────────────────
525
526CHECK_FAILED=0
527
528# A passing line is a name and a value, because nothing has to be explained
529# about something that works; a failing one carries the fix on the line below
530# it. Prose on every line would bury the one line that matters.
531pass() { printf ' ok %-14s %s\n' "$1" "$2"; }
532bad() { printf ' FAIL %-14s %s\n' "$1" "$2"; printf ' %-14s %s\n' 'fix' "$3"; CHECK_FAILED=1; }
533note() { printf ' note %-14s %s\n' "$1" "$2"; }
534
535# The one command to run when anything goes wrong.
536run_check() {
537 echo "Daimond's machine hand -- checking this install"
538 echo
539
540 # 1. A browser whose hand could reach its own files.
541 if [ "${#USABLE[@]}" -gt 0 ]; then
542 for u in "${USABLE[@]}"; do
543 pass 'browser' "${u%%|*} $(dirname "${u#*|}")"
544 done
545 # Not a failure -- there is a usable browser -- but if the one being
546 # used is the confined one, every line below passes and nothing works.
547 for c in ${CONFINED[@]+"${CONFINED[@]}"}; do
548 note 'browser' "${c%%|*} is also installed and cannot run the hand: confined. Use the browser above."
549 done
550 elif [ "${#CONFINED[@]}" -gt 0 ]; then
551 for c in "${CONFINED[@]}"; do
552 local clabel="${c%%|*}" ckind="${c##*|}"
553 bad 'browser' "$clabel is a $ckind package: confined, so the hand cannot reach its journal in a hidden directory" \
554 'install a Chromium-family browser from a .deb'
555 done
556 else
557 bad 'browser' 'no browser profile found' \
558 'the profile appears the first time a browser runs -- install a .deb Chromium-family browser and run it once'
559 fi
560
561 # 2. The registration, in each profile that could use it.
562 local seen=0 dir f mpath morigin
563 for u in "${USABLE[@]}" ${CONFINED[@]+"${CONFINED[@]}"}; do
564 dir="$(echo "$u" | cut -d'|' -f2)"
565 f="$dir/$HOST.json"
566 [ -f "$f" ] || continue
567 seen=$((seen + 1))
568 mpath="$(sed -n 's/.*"path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$f" | head -1)"
569 morigin="$(sed -n 's|.*chrome-extension://\([^/]*\)/.*|\1|p' "$f" | head -1)"
570 if [ ! -x "$mpath" ]; then
571 bad 'registration' "$f names '$mpath', which will not run: the browser can start nothing" \
572 'rebuild the hand, then run install.sh again'
573 elif [ "$morigin" != "$EXT_ID" ]; then
574 bad 'registration' "$f names extension $morigin, so this build of the extension is refused" \
575 'DAIMOND_HAND_EXT_ID=<the id at chrome://extensions> install.sh'
576 else
577 pass 'registration' "$f"
578 fi
579 done
580 if [ "$seen" = 0 ]; then
581 bad 'registration' "no $HOST.json anywhere: the browser has nothing naming the hand" \
582 'run install.sh with no arguments'
583 fi
584
585 # 3. The binary, actually run rather than merely present.
586 if [ -z "$BINARY" ]; then
587 bad 'binary' 'not built' \
588 'cargo build --release --manifest-path hand/Cargo.toml'
589 elif [ ! -x "$BINARY" ]; then
590 bad 'binary' "$BINARY is not executable" 'chmod +x it, or rebuild'
591 else
592 local v
593 if v="$("$BINARY" --version 2>&1)"; then
594 pass 'binary' "$BINARY ($v)"
595 else
596 bad 'binary' "$BINARY will not run: $v" \
597 'rebuild on this machine -- a binary from another distribution may want a newer glibc'
598 fi
599 fi
600
601 # 4. The journal directory: present, private, and writable.
602 if [ ! -d "$JDIR" ]; then
603 bad 'journal' "$JDIR is not there, and the hand serves nothing it cannot record" \
604 'install.sh --workspace <the folder you want Daimond to work in>'
605 else
606 local mode
607 mode="$(stat -c %a "$JDIR")"
608 if [ "$mode" != 700 ]; then
609 bad 'journal' "$JDIR is mode $mode: the record of every command would be readable by other users, so the hand refuses it" \
610 "chmod 700 '$JDIR'"
611 elif ! touch "$JDIR/.write-probe" 2>/dev/null; then
612 bad 'journal' "$JDIR cannot be written, so the hand will exit at startup" \
613 'check who owns it, and that the browser is not a snap or flatpak'
614 else
615 rm -f "$JDIR/.write-probe"
616 pass 'journal' "$JDIR"
617 fi
618 fi
619
620 # 5. The granted folder.
621 local root=''
622 if [ -f "$JDIR/root.txt" ]; then
623 root="$(grep -v '^[[:space:]]*#' "$JDIR/root.txt" | grep -v '^[[:space:]]*$' | head -1 | tr -d '[:space:]')"
624 fi
625 if [ -z "$root" ]; then
626 bad 'root.txt' 'missing: the hand is not told which folder it may work in, and never guesses' \
627 'install.sh --workspace <the folder you want Daimond to work in>'
628 elif [ "${root:0:1}" != / ]; then
629 bad 'root.txt' "'$root' is relative, so it would resolve against wherever the browser was started" \
630 'write the full path, starting at /'
631 elif [ ! -d "$root" ]; then
632 bad 'root.txt' "'$root' does not exist" \
633 'create it, or grant a different folder'
634 else
635 pass 'root.txt' "$root"
636 fi
637
638 # 6. The record must not live inside the folder a command may write to, or
639 # every command is refused.
640 if [ -n "$root" ] && [ -d "$root" ]; then
641 case "$(cd "$JDIR" 2>/dev/null && pwd -P || echo "$JDIR")/" in
642 "$(cd "$root" && pwd -P)"/*)
643 bad 'fence' 'the journal is inside the granted folder, so a command could rewrite the record -- every command is refused' \
644 'grant a folder that does not contain the journal' ;;
645 *) pass 'fence' 'journal outside the grant' ;;
646 esac
647 fi
648
649 # 7. The extension, which the browser has to be pointed at by hand.
650 if [ -f "$REPO/ext/manifest.json" ]; then
651 pass 'extension' "$REPO/ext"
652 else
653 bad 'extension' "$REPO/ext/manifest.json is not there, so there is nothing to load" \
654 'run this script from inside a Daimond checkout'
655 fi
656
657 echo
658 if [ "$CHECK_FAILED" = 0 ]; then
659 echo "All good. If Daimond still says the hand disconnected, restart the browser:"
660 echo "it reads the registration only at startup."
661 else
662 echo "Fix from the top -- a later line often fails only because an earlier one did."
663 fi
664 return "$CHECK_FAILED"
665}
666
667if [ "$CHECK_ONLY" = 1 ]; then
668 if run_check; then exit 0; else exit 1; fi
669fi
670
671# ── The workspace, and the journal directory beside it ───────────────
672#
673# The two steps install/README.md used to ask for by hand. Which folder is a
674# decision and stays an argument; creating the directory at the right mode and
675# writing the file are not decisions, and are done here.
676
677if [ -n "$WORKSPACE" ]; then
678 if [ ! -d "$WORKSPACE" ]; then
679 echo "install.sh: '$WORKSPACE' does not exist, and the hand will not fence a folder" >&2
680 echo "it cannot resolve. Create it, then run this again." >&2
681 exit 2
682 fi
683 WORKSPACE="$(cd "$WORKSPACE" && pwd -P)"
684 if [ "$WORKSPACE" = "$(cd "$HOME" && pwd -P)" ] || [ "$WORKSPACE" = / ]; then
685 echo "install.sh: refusing '$WORKSPACE'. The granted folder bounds everything any" >&2
686 echo "command can read or write, so granting your whole account grants everything." >&2
687 echo "Make a folder for the work." >&2
688 exit 2
689 fi
690 # The record must be outside the fence, or the hand refuses every command
691 # rather than write the journal where a command could edit it.
692 case "$JDIR/" in
693 "$WORKSPACE"/*)
694 echo "install.sh: the journal at '$JDIR' is inside '$WORKSPACE', so a command could" >&2
695 echo "rewrite the record of itself. The hand refuses every command in that shape." >&2
696 echo "Grant a folder that does not contain the journal." >&2
697 exit 2 ;;
698 esac
699
700 if [ -d "$JDIR" ]; then
701 # Only the journal's own furniture, and the file this script writes, may
702 # be in a directory it will chmod. DAIMOND_HAND_JOURNAL_DIR can name
703 # anything, and re-permissioning somebody's data to 700 because they
704 # pointed it at the wrong place is not this script's to do.
705 for n in "$JDIR"/* "$JDIR"/.*; do
706 [ -e "$n" ] || continue
707 case "$(basename "$n")" in
708 .|..|root.txt|lock|head.json|head.json.new|hand-*.jsonl|foreign-*) ;;
709 *)
710 echo "install.sh: '$JDIR' holds '$(basename "$n")', which the journal did not write." >&2
711 echo "Tightening it to 700 would re-permission your files, so it is left alone." >&2
712 echo "Empty it, or point DAIMOND_HAND_JOURNAL_DIR at a directory of its own." >&2
713 exit 2 ;;
714 esac
715 done
716 fi
717 mkdir -p "$JDIR"
718 chmod 700 "$JDIR"
719 printf '# The one folder Daimond'"'"'s machine hand may work in.\n%s\n' "$WORKSPACE" > "$JDIR/root.txt"
720 chmod 600 "$JDIR/root.txt"
721fi
722
723# ── The terminal's ceiling ───────────────────────────────────────────
724#
725# A terminal is the user at a keyboard and a command is a daimon, so the two may
726# have different sizes. This writes the widest a TERMINAL may ever reach; it is a
727# CEILING and not where one opens, and the page may choose a folder within it and
728# may never widen past it.
729#
730# Written here, on the machine, and never by a page: a page that could name its own
731# root could name a wider one, and every other grant in this program rests on its
732# not being able to.
733#
734# The home directory IS allowed here, unlike --workspace, and that is the point of
735# the flag. What protects the secrets inside it is no longer where the line happens
736# to be drawn: ALWAYS_DENIED in src/tools.rs refuses .ssh, .gnupg, .aws, .netrc and
737# the credential files on every fence, whatever root is granted.
738if [ -n "$TERM_WORKSPACE" ]; then
739 if [ ! -d "$TERM_WORKSPACE" ]; then
740 echo "install.sh: '$TERM_WORKSPACE' does not exist, so it cannot be a terminal's ceiling" >&2
741 exit 2
742 fi
743 TERM_WORKSPACE="$(cd "$TERM_WORKSPACE" && pwd -P)"
744 if [ "$TERM_WORKSPACE" = / ]; then
745 echo "install.sh: refusing '/'. A terminal's ceiling is a folder, not the machine." >&2
746 exit 2
747 fi
748 mkdir -p "$JDIR"
749 chmod 700 "$JDIR"
750 printf '# The widest a Daimond TERMINAL may reach. Not where one opens.\n%s\n' \
751 "$TERM_WORKSPACE" > "$JDIR/terminal-root.txt"
752 chmod 600 "$JDIR/terminal-root.txt"
753fi
754
755# ── The Remote toolchain: an ssh key that is Daimond's own ───────────
756#
757# The owner asked for one sentence: an ssh to another machine, from a Terminal
758# in Daimond, with a session that survives a network dropout. Three refusals
759# stood in the way, and the first was `~/.ssh/known_hosts: Permission denied`.
760#
761# The tempting repair is to lend `~/.ssh`. It is the wrong one: that directory
762# holds the keys the rest of a person's life runs on. So Daimond gets a key of
763# its OWN, here, and a host list of its own beside it -- and revoking Daimond's
764# reach to any machine is then one line removed from that machine's
765# `authorized_keys`, which is not true of a grant of the user's own key.
766#
767# THE WRAPPER IS WHY `ssh argonaut` WORKS TYPED EXACTLY LIKE THAT. OpenSSH takes
768# the home directory from the passwd entry and not from `HOME`, so there is no
769# environment variable that could point it at another key or another host list --
770# the flags have to be on the command line, and something has to put them there.
771# The Remote toolkit puts this directory first on a Terminal's `PATH`, read-only,
772# so nothing a fenced command does can add a second program to it.
773#
774# RUNNING THIS IS THE PERMISSION. It was a grant given to one Diamond at a time
775# until 2026-08-26, and the owner's objection is `dev/BLOCKERS.md` B12 -- a
776# Terminal is not tied to a Diamond, so neither is what one may reach. The hand
777# reads the key and the wrapper back (`exec::remote_ready`) and tells the page
778# `remote:ready`; a machine where this never ran says nothing and gets nothing.
779#
780# NOTHING HERE INSTALLS THE KEY ANYWHERE. Which machines Daimond may reach is a
781# decision, and it stays one: the public key is printed, with the line to add and
782# the `restrict,pty` in front of it that says a shell and nothing else.
783
784if [ "$REMOTE" = 1 ]; then
785 RDIR="$HOME/.config/oxedyne/daimond-hand"
786 SSH_BIN="$(command -v ssh || true)"
787 if [ -z "$SSH_BIN" ]; then
788 echo "install.sh: there is no ssh on this machine, so there is nothing for the" >&2
789 echo "Remote toolchain to wrap. Install openssh-client and run this again." >&2
790 exit 2
791 fi
792 mkdir -p "$RDIR/ssh" "$RDIR/bin"
793 chmod 700 "$HOME/.config/oxedyne" "$RDIR" "$RDIR/ssh"
794 chmod 755 "$RDIR/bin"
795 if [ ! -f "$RDIR/ssh/id_daimond" ]; then
796 # No passphrase: nothing can type one into a fenced terminal, and a key
797 # that cannot be used is not a safer key but an unused one. What bounds
798 # it is `restrict,pty` at the far end and the fence at this one.
799 ssh-keygen -q -t ed25519 -N '' -C "daimond@$(hostname)" -f "$RDIR/ssh/id_daimond"
800 fi
801 chmod 600 "$RDIR/ssh/id_daimond"
802 chmod 644 "$RDIR/ssh/id_daimond.pub"
803 # Daimond's own, and never the user's: a `known_hosts` has to be WRITTEN to
804 # learn a host, and a writable `~/.ssh` is a way to add an authorized key.
805 [ -f "$RDIR/known_hosts" ] || : > "$RDIR/known_hosts"
806 chmod 600 "$RDIR/known_hosts"
807
808 cat > "$RDIR/bin/ssh" <<WRAPPER
809#!/bin/sh
810# Daimond's ssh. Written by hand/install/install.sh; edit that, not this.
811#
812# The paths are absolute because a fenced terminal has no HOME unless something
813# granted it one, and because OpenSSH would not read HOME anyway.
814#
815# With ONE argument -- \`ssh argonaut\` -- the far end is a tmux attached if it is
816# already there and started if it is not. That is what survives a network
817# dropout: the programs keep running on the other machine, and the next
818# \`ssh argonaut\` walks back into them mid-sentence. Anything else is passed
819# straight through, so \`ssh argonaut uptime\` is still one line and one answer.
820set -eu
821KEY='$RDIR/ssh/id_daimond'
822KH='$RDIR/known_hosts'
823OPT_ID='-oIdentitiesOnly=yes'
824OPT_KH="-oUserKnownHostsFile=\$KH"
825OPT_SHK='-oStrictHostKeyChecking=accept-new'
826FAR='if command -v tmux >/dev/null 2>&1; then exec tmux new -A -s daimond; else exec "\${SHELL:-/bin/sh}" -l; fi'
827if [ \$# -eq 1 ]; then
828 exec '$SSH_BIN' -i "\$KEY" "\$OPT_ID" "\$OPT_KH" "\$OPT_SHK" -t "\$1" "\$FAR"
829fi
830exec '$SSH_BIN' -i "\$KEY" "\$OPT_ID" "\$OPT_KH" "\$OPT_SHK" "\$@"
831WRAPPER
832 chmod 755 "$RDIR/bin/ssh"
833
834 echo "Daimond's ssh"
835 echo " key $RDIR/ssh/id_daimond"
836 echo " host list $RDIR/known_hosts"
837 echo " wrapper $RDIR/bin/ssh -> $SSH_BIN"
838 echo
839 echo "On every machine Daimond may reach, add this one line to ~/.ssh/authorized_keys."
840 echo "'restrict,pty' is a shell and nothing else: no port forwarding, no agent, no X11."
841 echo
842 echo " restrict,pty $(cat "$RDIR/ssh/id_daimond.pub")"
843 echo
844 echo "Then open a Terminal in Daimond -- in any Diamond, or none -- and ssh. Nothing"
845 echo "has to be granted to a Diamond: this is a setting on THIS computer, and running"
846 echo "this script is what turns it on. No command a daimon runs can reach the key,"
847 echo "whatever it asks for -- the grant is lent to a terminal you opened by hand."
848 echo
849fi
850
851# ── Writing ──────────────────────────────────────────────────────────
852
853echo "Daimond's machine hand"
854echo " binary $BINARY"
855echo " extension chrome-extension://$EXT_ID/"
856if [ -n "$WORKSPACE" ]; then
857 echo " granted folder $WORKSPACE"
858 echo " journal $JDIR"
859fi
860if [ -n "$TERM_WORKSPACE" ]; then
861 echo " terminal ceiling $TERM_WORKSPACE"
862fi
863echo
864
865# What is left, and only what is left. The two decisions -- which folder, and
866# the approval -- are named as decisions; the rest is mechanism this script has
867# already done.
868next_steps() {
869 echo
870 echo "Next:"
871 if [ -z "$WORKSPACE" ] && [ ! -f "$JDIR/root.txt" ]; then
872 cat <<EOF
873 - Grant a folder. It bounds everything any command can read or write, so pick
874 one for the work rather than your home directory; until then the hand
875 refuses to serve a page at all.
876 $HERE/install.sh --workspace ~/work
877EOF
878 fi
879 cat <<EOF
880 - Load $REPO/ext at chrome://extensions
881 (Developer mode, "Load unpacked").
882 - Restart the browser -- it reads the file above only at startup.
883 - In Daimond, open the folder you granted. The first command opens a window
884 asking you to allow this; nothing runs until you do, and the Daimond Hands
885 toolbar icon takes it back.
886
887If anything does not work: $HERE/install.sh --check
888EOF
889}
890
891if [ -n "$ONLY_DIR" ]; then
892 write_to "(given)" "$ONLY_DIR"
893 kind="$(confined_path "$ONLY_DIR")"
894 if [ -n "$kind" ]; then
895 say_confined "$kind"
896 echo >&2
897 echo "Written anyway: --dir means you know better. If the hand disconnects on the" >&2
898 echo "first command, this is why." >&2
899 fi
900 next_steps
901 exit 0
902fi
903
904if [ "${#USABLE[@]}" = 0 ]; then
905 if [ "${#CONFINED[@]}" -gt 0 ]; then
906 echo "Found, and NOT installed into:" >&2
907 for c in "${CONFINED[@]}"; do
908 printf ' %-24s %s\n' "${c%%|*}" "$(dirname "$(echo "$c" | cut -d'|' -f2)")" >&2
909 done
910 say_confined "$(echo "${CONFINED[0]}" | cut -d'|' -f3)"
911 echo >&2
912 echo "To write there anyway, if you know better:" >&2
913 echo " $HERE/install.sh --dir <profile>/NativeMessagingHosts '$BINARY'" >&2
914 exit 3
915 fi
916 cat >&2 <<EOF
917No browser profile found. The likeliest reason is that the browser has never
918been started -- the profile appears on first run, not when the package is
919installed. Install a .deb Chromium-family browser, run it once, and try again.
920
921Looked for:
922$(for entry in "${BROWSERS[@]}"; do printf ' %-8s %s\n' "$(echo "$entry" | cut -d'|' -f2)" "${entry##*|}"; done)
923
924If your browser keeps its profile elsewhere, name that directory with
925NativeMessagingHosts on the end:
926
927 $HERE/install.sh --dir /path/to/profile/NativeMessagingHosts '$BINARY'
928EOF
929 exit 1
930fi
931
932for u in "${USABLE[@]}"; do
933 write_to "${u%%|*}" "${u#*|}"
934done
935
936if [ "$LIST_ONLY" = 1 ]; then
937 if [ "${#CONFINED[@]}" -gt 0 ]; then
938 echo
939 echo "Skipped, because a confined browser cannot run the hand:"
940 for c in "${CONFINED[@]}"; do
941 printf ' %-24s %s\n' "${c%%|*}" "$(dirname "$(echo "$c" | cut -d'|' -f2)")"
942 done
943 fi
944 echo
945 echo "Nothing was written. That is what --list is for; run it without --list to install."
946 exit 0
947fi
948
949if [ "${#CONFINED[@]}" -gt 0 ]; then
950 echo
951 echo "Skipped, because a confined browser cannot run the hand:"
952 for c in "${CONFINED[@]}"; do
953 printf ' %-24s %s\n' "${c%%|*}" "$(dirname "$(echo "$c" | cut -d'|' -f2)")"
954 done
955 say_confined "$(echo "${CONFINED[0]}" | cut -d'|' -f3)"
956fi
957
958next_steps