@astrofoundry/pi-astro 0.34.1 → 0.36.0

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.
Files changed (36) hide show
  1. package/README.md +3 -3
  2. package/agents/arcane.md +3 -1
  3. package/agents/inference.md +4 -5
  4. package/agents/release-reviewer.md +2 -2
  5. package/extensions/astro-discord/daily-report.test.ts +153 -77
  6. package/extensions/astro-discord/daily-report.ts +322 -53
  7. package/extensions/astro-discord/index.ts +180 -51
  8. package/extensions/astro-discord/reports.ts +0 -2
  9. package/extensions/astro-discord/tools-report.test.ts +78 -13
  10. package/extensions/astro-discord/tools-report.ts +123 -40
  11. package/extensions/specialist-gate/approvals.test.ts +4 -1
  12. package/extensions/specialist-gate/index.ts +1 -1
  13. package/package.json +1 -1
  14. package/skills/arcane/SKILL.md +16 -3
  15. package/skills/edge/SKILL.md +1 -1
  16. package/skills/inference/SKILL.md +3 -27
  17. package/skills/proxmox/SKILL.md +2 -2
  18. package/skills/security/SKILL.md +2 -2
  19. package/specialists/AGENTS.md +8 -7
  20. package/specialists/README.md +24 -18
  21. package/specialists/arcane/run.ts +223 -10
  22. package/specialists/edge/run.ts +3 -3
  23. package/specialists/entry/remote/spc-arcane-install.sh +2 -2
  24. package/specialists/entry/remote/spc-arcane-op.py +3 -0
  25. package/specialists/entry/specialist-install.sh +266 -8
  26. package/specialists/entry/specialist-install.test.ts +50 -1
  27. package/specialists/inference/run.ts +12 -94
  28. package/specialists/lib/arcane-host.test.ts +24 -1
  29. package/specialists/lib/arcane-host.ts +6 -2
  30. package/specialists/lib/deployment.ts +1 -1
  31. package/specialists/lib/stack-update.test.ts +101 -0
  32. package/specialists/lib/stack-update.ts +115 -0
  33. package/specialists/provision/provision.sh +2 -2
  34. package/specialists/wrappers.test.ts +7 -16
  35. package/specialists/entry/remote/spc-inference-hermes-entry.sh +0 -27
  36. package/specialists/entry/remote/spc-inference-pulsar-entry.sh +0 -14
@@ -1,14 +1,16 @@
1
1
  #!/bin/sh
2
2
  # /usr/local/sbin/specialist-install: installs the specialist user's Node, wrappers,
3
- # arcane-cli, and trendarr from downloads it checks itself. Installed root-owned 0755.
3
+ # arcane-cli, trendarr, Python, gcloud, bw, and jq from downloads it checks itself.
4
+ # Installed root-owned 0755.
4
5
  # Runs as root through
5
6
  # cortex ALL=(root) NOPASSWD: /usr/local/sbin/specialist-install
6
7
  # It runs only system binaries and the Node it installed, never anything the cortex
7
8
  # user can write (all of Homebrew), and it touches the specialist's home only as that
8
9
  # user, so links the specialist plants there cannot redirect a root write.
9
10
  #
10
- # specialist-install node|wrappers|arcane-cli|trendarr <version>
11
- # specialist-install rollback node|wrappers|arcane-cli|trendarr
11
+ # specialist-install node|wrappers|arcane-cli|trendarr|gcloud|bw|jq <version>
12
+ # specialist-install python <x.y.z+YYYYMMDD>
13
+ # specialist-install rollback node|wrappers|arcane-cli|trendarr|python|gcloud|bw|jq
12
14
  # specialist-install status
13
15
  set -eu
14
16
  PATH=/usr/bin:/bin:/usr/sbin:/sbin
@@ -18,11 +20,18 @@ cd /
18
20
 
19
21
  SPECIALIST=specialist
20
22
  NODE_BASE=/usr/local/libexec/specialist-node
23
+ PYTHON_BASE=/usr/local/libexec/specialist-python
24
+ GCLOUD_BASE=/usr/local/libexec/specialist-gcloud
25
+ BW_BASE=/usr/local/libexec/specialist-bw
26
+ JQ_BASE=/usr/local/libexec/specialist-jq
27
+ # Components of the official darwin-arm gcloud archive that gcloud itself needs (bq,
28
+ # gsutil, and the empty macOS bundled-python component are left out).
29
+ GCLOUD_COMPONENTS="core core-nix gcloud-deps gcloud-deps-darwin-arm gcloud-crc32c-darwin-arm"
21
30
  # Same list as the allowlist in specialist-cli; a new specialist needs both reinstalled.
22
31
  SERVICES="arcane identity network dns edge security backup proxmox inference media"
23
32
 
24
33
  usage() {
25
- echo "usage: specialist-install node|wrappers|arcane-cli|trendarr <version> | rollback node|wrappers|arcane-cli|trendarr | status" >&2
34
+ echo "usage: specialist-install node|wrappers|arcane-cli|trendarr|gcloud|bw|jq <version> | python <x.y.z+YYYYMMDD> | rollback node|wrappers|arcane-cli|trendarr|python|gcloud|bw|jq | status" >&2
26
35
  exit 2
27
36
  }
28
37
 
@@ -50,17 +59,34 @@ version_lt() {
50
59
  [ "$3" -lt "$6" ]
51
60
  }
52
61
 
62
+ # A python-build-standalone release: CPython x.y.z and the build date that names the release.
63
+ valid_python_version() {
64
+ case "$1" in '' | *[!0-9.+]*) return 1 ;; esac
65
+ printf '%s\n' "$1" | /usr/bin/grep -Eq '^(0|[1-9][0-9]{0,5})\.(0|[1-9][0-9]{0,5})\.(0|[1-9][0-9]{0,5})\+[0-9]{8}$'
66
+ }
67
+
68
+ # True when python release $1 is older than $2: CPython version first, then build date.
69
+ python_lt() {
70
+ version_lt "${1%+*}" "${2%+*}" && return 0
71
+ version_lt "${2%+*}" "${1%+*}" && return 1
72
+ [ "${1#*+}" -lt "${2#*+}" ]
73
+ }
74
+
53
75
  component_ok() {
54
- case "$1" in node | wrappers | arcane-cli | trendarr) return 0 ;; esac
76
+ case "$1" in node | wrappers | arcane-cli | trendarr | python | gcloud | bw | jq) return 0 ;; esac
55
77
  return 1
56
78
  }
57
79
 
58
80
  command=${1:-}
59
81
  case "$command" in
60
- node | wrappers | arcane-cli | trendarr)
82
+ node | wrappers | arcane-cli | trendarr | gcloud | bw | jq)
61
83
  [ $# -eq 2 ] || usage
62
84
  valid_version "$2" || usage
63
85
  ;;
86
+ python)
87
+ [ $# -eq 2 ] || usage
88
+ valid_python_version "$2" || usage
89
+ ;;
64
90
  rollback)
65
91
  [ $# -eq 2 ] || usage
66
92
  component_ok "$2" || usage
@@ -230,6 +256,217 @@ rollback_node() {
230
256
  echo "node $previous is current; previous $current"
231
257
  }
232
258
 
259
+ # Root-owned tools for the specialist: each version in its own folder under
260
+ # /usr/local/libexec/specialist-<tool>, behind the links current and previous.
261
+ check_tool_base() {
262
+ check_root_dir /
263
+ check_root_dir /usr
264
+ check_root_dir /usr/local
265
+ ensure_root_dir /usr/local/libexec
266
+ ensure_root_dir "$1"
267
+ }
268
+
269
+ # $1 base, $2 link name, $3 version check, $4 executable inside a version folder.
270
+ tool_link() {
271
+ target=$(/usr/bin/readlink "$1/$2" 2>/dev/null) || return 0
272
+ "$3" "$target" && [ -x "$1/$target/$4" ] && printf '%s' "$target"
273
+ return 0
274
+ }
275
+
276
+ switch_tool_link() {
277
+ /bin/rm -f "$1/.$2.tmp"
278
+ (umask 022 && /bin/ln -s "$3" "$1/.$2.tmp")
279
+ /bin/mv -h "$1/.$2.tmp" "$1/$2"
280
+ }
281
+
282
+ new_tool_folder() {
283
+ /bin/rm -rf "$1.partial"
284
+ /bin/mkdir -m 0755 "$1.partial"
285
+ }
286
+
287
+ seal_tool_folder() {
288
+ /usr/sbin/chown -R root:wheel "$1.partial"
289
+ /bin/chmod -R u=rwX,go=rX "$1.partial"
290
+ }
291
+
292
+ # Moves the checked $base/$version.partial into place, makes it current, keeps the old current as previous.
293
+ activate_tool() {
294
+ /bin/rm -rf "$1/$2"
295
+ /bin/mv "$1/$2.partial" "$1/$2"
296
+ [ -z "$3" ] || switch_tool_link "$1" previous "$3"
297
+ switch_tool_link "$1" current "$2"
298
+ for d in "$1"/*; do
299
+ n=${d##*/}
300
+ case "$n" in current | previous | "$2" | "$3") continue ;; esac
301
+ /bin/rm -rf "$d"
302
+ done
303
+ }
304
+
305
+ # $1 base, $2 label, $3 version check, $4 executable, $5 smoke function taking the executable's path.
306
+ rollback_tool() {
307
+ check_tool_base "$1"
308
+ current=$(tool_link "$1" current "$3" "$4")
309
+ previous=$(tool_link "$1" previous "$3" "$4")
310
+ [ -n "$current" ] && [ -n "$previous" ] || fail "no previous $2 to roll back to"
311
+ "$5" "$1/$previous/$4" "$previous" || fail "$2 $previous fails its check"
312
+ switch_tool_link "$1" current "$previous"
313
+ switch_tool_link "$1" previous "$current"
314
+ echo "$2 $previous is current; previous $current"
315
+ }
316
+
317
+ # Python from python-build-standalone: gcloud needs Python 3.10 to 3.15, and its macOS
318
+ # installer offers only Homebrew's (owned by cortex) or an unchecked python.org package.
319
+ SMOKE_PYTHON='"$1" -c "import ssl, sqlite3, sys; print(\"%d.%d.%d\" % sys.version_info[:3])"'
320
+ SMOKE_GCLOUD='cfg=$(/usr/bin/mktemp -d); trap "/bin/rm -rf \"\$cfg\"" EXIT
321
+ CLOUDSDK_PYTHON=$2 CLOUDSDK_CONFIG=$cfg CLOUDSDK_CORE_DISABLE_PROMPTS=1 "$1" version 2>/dev/null | /usr/bin/head -n 1'
322
+ SMOKE_BW='app=$(/usr/bin/mktemp -d); trap "/bin/rm -rf \"\$app\"" EXIT
323
+ BITWARDENCLI_APPDATA_DIR=$app "$1" --version 2>/dev/null'
324
+ SMOKE_JQ='"$1" --version'
325
+
326
+ smoke_python() {
327
+ [ "$(as_specialist "$SMOKE_PYTHON" "$1")" = "${2%+*}" ]
328
+ }
329
+
330
+ current_python() {
331
+ python=$(tool_link "$PYTHON_BASE" current valid_python_version bin/python3)
332
+ [ -n "$python" ] || fail "no specialist Python yet: run specialist-install python <x.y.z+YYYYMMDD> first"
333
+ printf '%s' "$PYTHON_BASE/$python/bin/python3"
334
+ }
335
+
336
+ # gcloud $1 (bin/gcloud) answers with version $2, run by Python $3.
337
+ gcloud_answers() {
338
+ [ "$(as_specialist "$SMOKE_GCLOUD" "$1" "$3")" = "Google Cloud SDK $2" ]
339
+ }
340
+
341
+ smoke_gcloud() {
342
+ gcloud_answers "$1" "$2" "$(current_python)"
343
+ }
344
+
345
+ smoke_bw() {
346
+ [ "$(as_specialist "$SMOKE_BW" "$1")" = "$2" ]
347
+ }
348
+
349
+ smoke_jq() {
350
+ [ "$(as_specialist "$SMOKE_JQ" "$1")" = "jq-$2" ]
351
+ }
352
+
353
+ install_python() {
354
+ version=$1
355
+ check_tool_base "$PYTHON_BASE"
356
+ current=$(tool_link "$PYTHON_BASE" current valid_python_version bin/python3)
357
+ if [ -n "$current" ] && python_lt "$version" "$current"; then fail "python $version is older than the installed $current; use rollback"; fi
358
+ [ "$current" != "$version" ] || { echo "python $version is already current"; return 0; }
359
+ tag=${version#*+}
360
+ name="cpython-$version-aarch64-apple-darwin-install_only_stripped.tar.gz"
361
+ base="https://github.com/astral-sh/python-build-standalone/releases/download/$tag"
362
+ fetch "$base/$name" "$work/$name"
363
+ fetch "$base/SHA256SUMS" "$work/SHA256SUMS"
364
+ check_sha256 "$work" "$name" "$work/SHA256SUMS"
365
+ dest="$PYTHON_BASE/$version"
366
+ new_tool_folder "$dest"
367
+ /usr/bin/tar -xzf "$work/$name" -C "$dest.partial" --strip-components 1
368
+ seal_tool_folder "$dest"
369
+ smoke_python "$dest.partial/bin/python3" "$version" || fail "python $version does not run"
370
+ gcloud=$(tool_link "$GCLOUD_BASE" current valid_version bin/gcloud)
371
+ if [ -n "$gcloud" ]; then
372
+ gcloud_answers "$GCLOUD_BASE/$gcloud/bin/gcloud" "$gcloud" "$dest.partial/bin/python3" || fail "gcloud $gcloud fails with python $version; current stays ${current:-unset}"
373
+ fi
374
+ activate_tool "$PYTHON_BASE" "$version" "$current"
375
+ echo "python $version is current${current:+; previous $current}"
376
+ }
377
+
378
+ # gcloud from the components of Google's release snapshot, each archive checked against
379
+ # the sha256 the snapshot lists; the snapshot and the archives come from dl.google.com.
380
+ install_gcloud() {
381
+ version=$1
382
+ python=$(current_python)
383
+ check_tool_base "$GCLOUD_BASE"
384
+ current=$(tool_link "$GCLOUD_BASE" current valid_version bin/gcloud)
385
+ if [ -n "$current" ] && version_lt "$version" "$current"; then fail "gcloud $version is older than the installed $current; use rollback"; fi
386
+ [ "$current" != "$version" ] || { echo "gcloud $version is already current"; return 0; }
387
+ channel=https://dl.google.com/dl/cloudsdk/channels/rapid
388
+ snapshot="$work/components.json"
389
+ fetch "$channel/components-v$version.json" "$snapshot"
390
+ [ "$(json_field "$snapshot" version)" = "$version" ] || fail "gcloud $version has no release snapshot"
391
+ dest="$GCLOUD_BASE/$version"
392
+ new_tool_folder "$dest"
393
+ for want in $GCLOUD_COMPONENTS; do
394
+ i=0
395
+ found=""
396
+ while [ "$i" -lt 1000 ]; do
397
+ id=$(json_field "$snapshot" "components.$i.id") || break
398
+ if [ "$id" = "$want" ]; then found=$i; break; fi
399
+ i=$((i + 1))
400
+ done
401
+ [ -n "$found" ] || fail "gcloud $version has no component $want"
402
+ source=$(json_field "$snapshot" "components.$found.data.source") || fail "component $want has no archive"
403
+ sum=$(json_field "$snapshot" "components.$found.data.checksum") || fail "component $want has no checksum"
404
+ printf '%s\n' "$source" | /usr/bin/grep -Eq '^components/google-cloud-sdk-[a-z0-9-]+-[0-9]{14}\.tar\.gz$' || fail "unexpected archive for component $want"
405
+ printf '%s\n' "$sum" | /usr/bin/grep -Eq '^[0-9a-f]{64}$' || fail "unexpected checksum for component $want"
406
+ fetch "$channel/$source" "$work/$want.tgz"
407
+ [ "$(/usr/bin/shasum -a 256 "$work/$want.tgz" | /usr/bin/awk '{ print $1 }')" = "$sum" ] || fail "checksum mismatch for component $want"
408
+ /usr/bin/tar -xzf "$work/$want.tgz" -C "$dest.partial"
409
+ done
410
+ seal_tool_folder "$dest"
411
+ gcloud_answers "$dest.partial/bin/gcloud" "$version" "$python" || fail "gcloud $version does not run"
412
+ activate_tool "$GCLOUD_BASE" "$version" "$current"
413
+ echo "gcloud $version is current${current:+; previous $current}"
414
+ }
415
+
416
+ # Bitwarden CLI: the native macOS arm64 build, checked against the sha256 digest GitHub publishes for the release asset.
417
+ install_bw() {
418
+ version=$1
419
+ check_tool_base "$BW_BASE"
420
+ current=$(tool_link "$BW_BASE" current valid_version bw)
421
+ if [ -n "$current" ] && version_lt "$version" "$current"; then fail "bw $version is older than the installed $current; use rollback"; fi
422
+ [ "$current" != "$version" ] || { echo "bw $version is already current"; return 0; }
423
+ name="bw-macos-arm64-$version.zip"
424
+ release="$work/bw-release.json"
425
+ fetch "https://api.github.com/repos/bitwarden/clients/releases/tags/cli-v$version" "$release"
426
+ i=0
427
+ found=""
428
+ while [ "$i" -lt 100 ]; do
429
+ asset=$(json_field "$release" "assets.$i.name") || break
430
+ if [ "$asset" = "$name" ]; then found=$i; break; fi
431
+ i=$((i + 1))
432
+ done
433
+ [ -n "$found" ] || fail "bw $version has no $name"
434
+ digest=$(json_field "$release" "assets.$found.digest") || fail "$name has no published digest"
435
+ printf '%s\n' "$digest" | /usr/bin/grep -Eq '^sha256:[0-9a-f]{64}$' || fail "unexpected digest for $name"
436
+ url="https://github.com/bitwarden/clients/releases/download/cli-v$version/$name"
437
+ [ "$(json_field "$release" "assets.$found.browser_download_url")" = "$url" ] || fail "unexpected download location for $name"
438
+ fetch "$url" "$work/$name"
439
+ [ "sha256:$(/usr/bin/shasum -a 256 "$work/$name" | /usr/bin/awk '{ print $1 }')" = "$digest" ] || fail "checksum mismatch for $name"
440
+ dest="$BW_BASE/$version"
441
+ new_tool_folder "$dest"
442
+ /usr/bin/unzip -q "$work/$name" bw -d "$dest.partial" || fail "$name has no bw"
443
+ /bin/chmod 0755 "$dest.partial/bw"
444
+ seal_tool_folder "$dest"
445
+ smoke_bw "$dest.partial/bw" "$version" || fail "bw $version does not run"
446
+ activate_tool "$BW_BASE" "$version" "$current"
447
+ echo "bw $version is current${current:+; previous $current}"
448
+ }
449
+
450
+ # jq: the macOS arm64 binary, checked against the release's sha256sum.txt.
451
+ install_jq() {
452
+ version=$1
453
+ check_tool_base "$JQ_BASE"
454
+ current=$(tool_link "$JQ_BASE" current valid_version jq)
455
+ if [ -n "$current" ] && version_lt "$version" "$current"; then fail "jq $version is older than the installed $current; use rollback"; fi
456
+ [ "$current" != "$version" ] || { echo "jq $version is already current"; return 0; }
457
+ base="https://github.com/jqlang/jq/releases/download/jq-$version"
458
+ fetch "$base/jq-macos-arm64" "$work/jq-macos-arm64"
459
+ fetch "$base/sha256sum.txt" "$work/sha256sum.txt"
460
+ check_sha256 "$work" jq-macos-arm64 "$work/sha256sum.txt"
461
+ dest="$JQ_BASE/$version"
462
+ new_tool_folder "$dest"
463
+ /usr/bin/install -m 0755 "$work/jq-macos-arm64" "$dest.partial/jq"
464
+ seal_tool_folder "$dest"
465
+ smoke_jq "$dest.partial/jq" "$version" || fail "jq $version does not run"
466
+ activate_tool "$JQ_BASE" "$version" "$current"
467
+ echo "jq $version is current${current:+; previous $current}"
468
+ }
469
+
233
470
  WRAPPER_VERSION='v=$(cat "$HOME/.specialists/bin/VERSION" 2>/dev/null) || exit 0; printf "%s" "$v"'
234
471
 
235
472
  # Moves the new wrapper files in and the old ones aside, two renames per item, then
@@ -433,13 +670,26 @@ json_version() {
433
670
  if valid_version "$1"; then printf '"%s"' "$1"; else printf 'null'; fi
434
671
  }
435
672
 
673
+ json_python() {
674
+ if valid_python_version "$1"; then printf '"%s"' "$1"; else printf 'null'; fi
675
+ }
676
+
677
+ # {"current":..,"previous":..} of one root-owned tool: $1 base, $2 version check, $3 executable, $4 JSON writer.
678
+ json_tool() {
679
+ printf '{"current":%s,"previous":%s}' "$("$4" "$(tool_link "$1" current "$2" "$3")")" "$("$4" "$(tool_link "$1" previous "$2" "$3")")"
680
+ }
681
+
436
682
  status() {
437
683
  wrappers=$(as_specialist "$WRAPPER_VERSION" || true)
438
684
  arcane=$(as_specialist "$ARCANE_VERSION" || true)
439
685
  trendarr=$(as_specialist "$TRENDARR_VERSION" || true)
440
- printf '{"node":{"current":%s,"previous":%s},"wrappers":%s,"arcaneCli":%s,"trendarr":%s}\n' \
686
+ printf '{"node":{"current":%s,"previous":%s},"wrappers":%s,"arcaneCli":%s,"trendarr":%s,"python":%s,"gcloud":%s,"bw":%s,"jq":%s}\n' \
441
687
  "$(json_version "$(node_link current)")" "$(json_version "$(node_link previous)")" \
442
- "$(json_version "$wrappers")" "$(json_version "$arcane")" "$(json_version "$trendarr")"
688
+ "$(json_version "$wrappers")" "$(json_version "$arcane")" "$(json_version "$trendarr")" \
689
+ "$(json_tool "$PYTHON_BASE" valid_python_version bin/python3 json_python)" \
690
+ "$(json_tool "$GCLOUD_BASE" valid_version bin/gcloud json_version)" \
691
+ "$(json_tool "$BW_BASE" valid_version bw json_version)" \
692
+ "$(json_tool "$JQ_BASE" valid_version jq json_version)"
443
693
  }
444
694
 
445
695
  case "$command" in
@@ -447,12 +697,20 @@ case "$command" in
447
697
  wrappers) install_wrappers "$2" ;;
448
698
  arcane-cli) install_arcane_cli "$2" ;;
449
699
  trendarr) install_trendarr "$2" ;;
700
+ python) install_python "$2" ;;
701
+ gcloud) install_gcloud "$2" ;;
702
+ bw) install_bw "$2" ;;
703
+ jq) install_jq "$2" ;;
450
704
  rollback)
451
705
  case "$2" in
452
706
  node) rollback_node ;;
453
707
  wrappers) rollback_wrappers ;;
454
708
  arcane-cli) rollback_arcane_cli ;;
455
709
  trendarr) rollback_trendarr ;;
710
+ python) rollback_tool "$PYTHON_BASE" python valid_python_version bin/python3 smoke_python ;;
711
+ gcloud) rollback_tool "$GCLOUD_BASE" gcloud valid_version bin/gcloud smoke_gcloud ;;
712
+ bw) rollback_tool "$BW_BASE" bw valid_version bw smoke_bw ;;
713
+ jq) rollback_tool "$JQ_BASE" jq valid_version jq smoke_jq ;;
456
714
  esac
457
715
  ;;
458
716
  status) status ;;
@@ -40,13 +40,33 @@ describe("specialist-install", () => {
40
40
  [["rollback", "pi"]],
41
41
  [["rollback", "node", "extra"]],
42
42
  [["status", "now"]],
43
+ [["python", "3.14.8"]],
44
+ [["python", "3.14.8+2026101"]],
45
+ [["python", "3.14.8+20261013+1"]],
46
+ [["python", "3.14.8-20261013"]],
47
+ [["gcloud", "589.0"]],
48
+ [["bw", "cli-v2026.10.0"]],
49
+ [["jq", "jq-1.8.2"]],
50
+ [["rollback", "brew"]],
43
51
  ])("refuses %j with a usage error before anything else", (args) => {
44
52
  const result = install(...args);
45
53
  expect(result.status).toBe(2);
46
54
  expect(result.stderr).toMatch(/^usage: specialist-install/);
47
55
  });
48
56
 
49
- it.skipIf(isRoot).each([[["node", "24.22.0"]], [["wrappers", "0.31.0"]], [["arcane-cli", "2.16.1"]], [["trendarr", "0.6.0"]], [["rollback", "trendarr"]], [["status"]]])(
57
+ it.skipIf(isRoot).each([
58
+ [["node", "24.22.0"]],
59
+ [["wrappers", "0.31.0"]],
60
+ [["arcane-cli", "2.16.1"]],
61
+ [["trendarr", "0.6.0"]],
62
+ [["python", "3.14.8+20261013"]],
63
+ [["gcloud", "589.0.0"]],
64
+ [["bw", "2026.10.0"]],
65
+ [["jq", "1.8.2"]],
66
+ [["rollback", "trendarr"]],
67
+ [["rollback", "gcloud"]],
68
+ [["status"]],
69
+ ])(
50
70
  "accepts %j and then requires root",
51
71
  (args) => {
52
72
  const result = install(...args);
@@ -74,6 +94,35 @@ describe("specialist-install", () => {
74
94
  expect(shellFunction("version_lt", `version_lt ${a} ${b}`).status).toBe(older ? 0 : 1);
75
95
  });
76
96
 
97
+ it.each(["3.14.8+20261013", "3.10.0+20250101", "3.15.0+20270401"])("valid_python_version accepts %s", (v) => {
98
+ expect(shellFunction("valid_python_version", `valid_python_version '${v}'`).status).toBe(0);
99
+ });
100
+
101
+ it.each(["", "3.14.8", "3.14+20261013", "3.14.8+2026101", "3.14.8+20261013a", "+20261013", "3.14.8+ 20261013"])("valid_python_version rejects %j", (v) => {
102
+ expect(shellFunction("valid_python_version", `valid_python_version '${v}'`).status).toBe(1);
103
+ });
104
+
105
+ it.each([
106
+ ["3.14.8+20261009", "3.14.8+20261013", true],
107
+ ["3.14.8+20261013", "3.14.8+20261013", false],
108
+ ["3.14.9+20261001", "3.14.8+20261013", false],
109
+ ["3.14.7+20261101", "3.14.8+20261013", true],
110
+ ])("python_lt %s < %s is %s", (a, b, older) => {
111
+ const script = readFileSync(SCRIPT, "utf-8");
112
+ const lt = /^version_lt\(\) \{\n[\s\S]*?^\}/m.exec(script)?.[0] ?? "";
113
+ expect(shellFunction("python_lt", `${lt}\npython_lt '${a}' '${b}'`).status).toBe(older ? 0 : 1);
114
+ });
115
+
116
+ it("installs gcloud from fixed components and checked downloads only", () => {
117
+ const text = readFileSync(SCRIPT, "utf-8");
118
+ expect(/^GCLOUD_COMPONENTS="([^"]+)"$/m.exec(text)?.[1].split(" ")).toEqual(["core", "core-nix", "gcloud-deps", "gcloud-deps-darwin-arm", "gcloud-crc32c-darwin-arm"]);
119
+ expect(text).toContain("channel=https://dl.google.com/dl/cloudsdk/channels/rapid\n");
120
+ expect(text).toContain('fetch "$channel/components-v$version.json"');
121
+ expect(text).toContain("https://github.com/astral-sh/python-build-standalone/releases/download/$tag");
122
+ expect(text).toContain("https://api.github.com/repos/bitwarden/clients/releases/tags/cli-v$version");
123
+ expect(text).toContain("https://github.com/jqlang/jq/releases/download/jq-$version");
124
+ });
125
+
77
126
  it("installs the same services specialist-cli accepts", () => {
78
127
  const services = /^SERVICES="([^"]+)"$/m.exec(readFileSync(SCRIPT, "utf-8"))?.[1].split(" ");
79
128
  const allowed = /^ {2}([a-z|]+)\) ;;$/m.exec(readFileSync(ENTRY, "utf-8"))?.[1].split("|");
@@ -13,30 +13,16 @@ const SERVICE = "inference";
13
13
  interface InferenceConfig extends Record<string, string> {
14
14
  nexusHost: string;
15
15
  nexusUser: string;
16
- hermesHost: string;
17
- hermesUser: string;
18
- pulsarHost: string;
19
- pulsarUser: string;
20
16
  europaHealthUrl: string;
21
- /** GitHub repository whose releases carry the Hermes upgrade notes, owner/name. */
22
- hermesRepo: string;
23
17
  }
24
18
 
25
19
  const SHAPE = {
26
20
  nexusHost: "string",
27
21
  nexusUser: "string",
28
- hermesHost: "string",
29
- hermesUser: "string",
30
- pulsarHost: "string",
31
- pulsarUser: "string",
32
22
  europaHealthUrl: "string",
33
- hermesRepo: "string",
34
23
  } as const;
35
24
 
36
- type Target = "nexus" | "hermes" | "pulsar";
37
-
38
25
  interface RemoteSpec {
39
- target: Target;
40
26
  args: number;
41
27
  help: string;
42
28
  timeoutMs: number;
@@ -45,40 +31,27 @@ interface RemoteSpec {
45
31
 
46
32
  const MINUTE = 60_000;
47
33
 
48
- /** Fixed remote commands; the forced commands on Nexus (nexus), LXC 101 (hermes), and Pulsar (root) accept exactly these names. */
34
+ /** Fixed remote commands; the forced command on Nexus (user nexus) accepts exactly these names. */
49
35
  export const INFERENCE_COMMANDS: Readonly<Record<string, RemoteSpec>> = {
50
- "nexus-status": { target: "nexus", args: 0, help: "LaunchAgent states and /health codes of the embed and rerank servers", timeoutMs: MINUTE },
51
- "nexus-restart": { target: "nexus", args: 1, help: "nexus-restart <embed|rerank> launchctl kickstart -k", timeoutMs: 2 * MINUTE },
52
- "nexus-start": { target: "nexus", args: 1, help: "nexus-start <embed|rerank> launchctl bootstrap from the tracked plist", timeoutMs: 2 * MINUTE },
53
- "nexus-stop": { target: "nexus", args: 1, help: "nexus-stop <embed|rerank> --confirm launchctl bootout", timeoutMs: 2 * MINUTE, confirm: true },
54
- "nexus-log": { target: "nexus", args: 2, help: "nexus-log <embed|rerank> <lines>", timeoutMs: MINUTE },
55
- "nexus-disk": { target: "nexus", args: 0, help: "free space on the Nexus disk", timeoutMs: MINUTE },
56
- "hermes-status": { target: "hermes", args: 0, help: "both gateway user services", timeoutMs: MINUTE },
57
- "hermes-journal": { target: "hermes", args: 1, help: "hermes-journal <since> user journal of both gateways", timeoutMs: MINUTE },
58
- "hermes-errors": { target: "hermes", args: 0, help: "hermes logs errors", timeoutMs: MINUTE },
59
- "hermes-restart": { target: "hermes", args: 1, help: "hermes-restart <gateway|api>", timeoutMs: 2 * MINUTE },
60
- "hermes-api-health": { target: "hermes", args: 0, help: "/health and /v1/models codes on localhost:8650 (expect 200 and 401)", timeoutMs: MINUTE },
61
- "hermes-version": { target: "hermes", args: 0, help: "installed Hermes version", timeoutMs: MINUTE },
62
- "hermes-guest-status": { target: "pulsar", args: 0, help: "pct status 101", timeoutMs: MINUTE },
63
- "hermes-snapshots": { target: "pulsar", args: 0, help: "pct listsnapshot 101", timeoutMs: MINUTE },
64
- "hermes-upgrade": { target: "pulsar", args: 0, help: "hermes-upgrade --confirm snapshot LXC 101, hermes update, restart, health check", timeoutMs: 20 * MINUTE, confirm: true },
36
+ "nexus-status": { args: 0, help: "LaunchAgent states and /health codes of the embed and rerank servers", timeoutMs: MINUTE },
37
+ "nexus-restart": { args: 1, help: "nexus-restart <embed|rerank> launchctl kickstart -k", timeoutMs: 2 * MINUTE },
38
+ "nexus-start": { args: 1, help: "nexus-start <embed|rerank> launchctl bootstrap from the tracked plist", timeoutMs: 2 * MINUTE },
39
+ "nexus-stop": { args: 1, help: "nexus-stop <embed|rerank> --confirm launchctl bootout", timeoutMs: 2 * MINUTE, confirm: true },
40
+ "nexus-log": { args: 2, help: "nexus-log <embed|rerank> <lines>", timeoutMs: MINUTE },
41
+ "nexus-disk": { args: 0, help: "free space on the Nexus disk", timeoutMs: MINUTE },
65
42
  };
66
43
 
67
44
  const NEXUS_SERVICES = ["embed", "rerank"];
68
- const HERMES_SERVICES = ["gateway", "api"];
69
- const SINCE = /^[A-Za-z0-9 :\-+]{1,40}$/;
70
45
 
71
- const HELP = `inference specialist (llama.cpp on Nexus, Hermes on LXC 101, Europa health)
46
+ const HELP = `inference specialist (llama.cpp on Nexus, Europa health)
72
47
 
73
48
  <command> [args] fixed operations through forced-command SSH keys
74
49
  ${Object.entries(INFERENCE_COMMANDS)
75
50
  .map(([name, c]) => ` ${name.padEnd(22)} ${c.help}`)
76
51
  .join("\n")}
77
52
  europa-health GET the Europa embedding /health over HTTP (Europa is usually off by design)
78
- hermes-releases [n] latest n upstream Hermes releases, notes cut at 6000 chars (default 5)
79
- hermes-release <tag> one release with its full notes; read every release between the installed version and the target before hermes-upgrade
80
53
 
81
- Europa control and Hermes configuration are operator tasks.`;
54
+ Europa control is an operator task.`;
82
55
 
83
56
  export function buildInferenceRemote(args: string[]): { remote: string; spec: RemoteSpec } {
84
57
  const [name, ...given] = args;
@@ -91,63 +64,14 @@ export function buildInferenceRemote(args: string[]): { remote: string; spec: Re
91
64
  rest = rest.filter((_, i) => i !== index);
92
65
  }
93
66
  if (rest.length !== spec.args) throw new UsageError(`${name} takes ${spec.args} argument(s)`);
94
- if (name.startsWith("nexus-") && spec.args >= 1 && !NEXUS_SERVICES.includes(rest[0])) throw new UsageError(`service must be one of ${NEXUS_SERVICES.join(", ")}`);
67
+ if (spec.args >= 1 && !NEXUS_SERVICES.includes(rest[0])) throw new UsageError(`service must be one of ${NEXUS_SERVICES.join(", ")}`);
95
68
  if (name === "nexus-log") {
96
69
  const n = Number(rest[1]);
97
70
  if (!Number.isInteger(n) || n < 1 || n > 2000) throw new UsageError("lines must be an integer from 1 to 2000");
98
71
  }
99
- if (name === "hermes-restart" && !HERMES_SERVICES.includes(rest[0])) throw new UsageError(`service must be one of ${HERMES_SERVICES.join(", ")}`);
100
- if (name === "hermes-journal" && !SINCE.test(rest[0])) throw new UsageError("since: letters, digits, spaces, colon, plus, minus only");
101
72
  return { remote: [name, ...rest].join(" "), spec };
102
73
  }
103
74
 
104
- interface GithubRelease {
105
- tag_name?: string;
106
- name?: string;
107
- published_at?: string;
108
- html_url?: string;
109
- prerelease?: boolean;
110
- body?: string;
111
- }
112
-
113
- /** Trims GitHub release objects to what an upgrade decision needs. */
114
- export function compactReleases(releases: GithubRelease[]): { tag: string; name: string; published: string; prerelease: boolean; url: string; notes: string }[] {
115
- return releases.map((r) => ({
116
- tag: r.tag_name ?? "?",
117
- name: r.name ?? "",
118
- published: r.published_at ?? "",
119
- prerelease: r.prerelease === true,
120
- url: r.html_url ?? "",
121
- notes: (r.body ?? "").slice(0, 6000),
122
- }));
123
- }
124
-
125
- const RELEASE_TAG = /^[A-Za-z0-9._-]{1,60}$/;
126
-
127
- async function github(config: InferenceConfig, path: string): Promise<unknown> {
128
- if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(config.hermesRepo)) throw new ServiceError("hermesRepo must be owner/name");
129
- const response = await fetch(`https://api.github.com/repos/${config.hermesRepo}${path}`, {
130
- headers: { Accept: "application/vnd.github+json", "User-Agent": "pi-astro-inference-specialist" },
131
- signal: AbortSignal.timeout(30_000),
132
- });
133
- if (!response.ok) throw new ServiceError(`GitHub answered ${response.status} for ${config.hermesRepo}${path}`);
134
- return response.json();
135
- }
136
-
137
- async function hermesReleases(config: InferenceConfig, countArg: string | undefined): Promise<number> {
138
- const n = countArg === undefined ? 5 : Number(countArg);
139
- if (!Number.isInteger(n) || n < 1 || n > 30) throw new UsageError("hermes-releases [n]: n must be 1..30");
140
- printJson({ repository: config.hermesRepo, releases: compactReleases((await github(config, `/releases?per_page=${n}`)) as GithubRelease[]) });
141
- return 0;
142
- }
143
-
144
- async function hermesRelease(config: InferenceConfig, tag: string | undefined): Promise<number> {
145
- if (tag === undefined || !RELEASE_TAG.test(tag)) throw new UsageError("hermes-release <tag>, e.g. v2026.9.14");
146
- const release = (await github(config, `/releases/tags/${encodeURIComponent(tag)}`)) as GithubRelease;
147
- printJson({ repository: config.hermesRepo, ...compactReleases([release])[0], notes: release.body ?? "" });
148
- return 0;
149
- }
150
-
151
75
  async function europaHealth(config: InferenceConfig): Promise<number> {
152
76
  const result = await run("/usr/bin/curl", ["--silent", "--output", "/dev/null", "--max-time", "5", "--write-out", "%{http_code}", config.europaHealthUrl], { timeoutMs: 15_000 });
153
77
  const httpCode = result.stdout.trim();
@@ -163,15 +87,9 @@ export async function command(args: string[]): Promise<number> {
163
87
  }
164
88
  const config = readConfig<InferenceConfig>(SERVICE, SHAPE);
165
89
  if (args[0] === "europa-health") return europaHealth(config);
166
- if (args[0] === "hermes-releases") return hermesReleases(config, args[1]);
167
- if (args[0] === "hermes-release") return hermesRelease(config, args[1]);
168
90
  const { remote, spec } = buildInferenceRemote(args);
169
- const targets: Record<Target, { host: string; user: string; keyFile: string }> = {
170
- nexus: { host: config.nexusHost, user: config.nexusUser, keyFile: secretPath(SERVICE, "SSH_KEY_NEXUS") },
171
- hermes: { host: config.hermesHost, user: config.hermesUser, keyFile: secretPath(SERVICE, "SSH_KEY_HERMES") },
172
- pulsar: { host: config.pulsarHost, user: config.pulsarUser, keyFile: secretPath(SERVICE, "SSH_KEY_PULSAR") },
173
- };
174
- const result = await sshFixed({ ...targets[spec.target], knownHostsFile: join(specialistsHome(), "config", "known_hosts") }, remote, spec.timeoutMs);
91
+ const nexus = { host: config.nexusHost, user: config.nexusUser, keyFile: secretPath(SERVICE, "SSH_KEY_NEXUS") };
92
+ const result = await sshFixed({ ...nexus, knownHostsFile: join(specialistsHome(), "config", "known_hosts") }, remote, spec.timeoutMs);
175
93
  if (result.stdout.length > 0) printRaw(result.stdout);
176
94
  if (result.code !== 0) throw new ServiceError(result.stderr.trim() || `${args[0]} exited ${result.code}`);
177
95
  return 0;
@@ -75,6 +75,29 @@ describe("Arcane host commands", () => {
75
75
  ["arcane-backup.timer", null],
76
76
  ]);
77
77
  });
78
+
79
+ it("compares the probes in their subfolder and ignores names outside host/", () => {
80
+ dir = mkdtempSync(join(tmpdir(), "audit-"));
81
+ mkdirSync(join(dir, "host", "spc-arcane-probes"), { recursive: true });
82
+ writeFileSync(join(dir, "host", "spc-arcane-probes", "host_probe.py"), "print(1)\n");
83
+ writeFileSync(join(dir, "outside.txt"), "x\n");
84
+ const probe = createHash("sha256").update("print(1)\n").digest("hex");
85
+ const outside = createHash("sha256").update("x\n").digest("hex");
86
+ expect(
87
+ compareAudit(
88
+ [
89
+ { name: "spc-arcane-probes/host_probe.py", state: "present", sha256: probe },
90
+ { name: "../outside.txt", state: "present", sha256: outside },
91
+ { name: "/etc/passwd", state: "present", sha256: outside },
92
+ ],
93
+ dir,
94
+ ).map((r) => [r.name, r.match]),
95
+ ).toEqual([
96
+ ["spc-arcane-probes/host_probe.py", true],
97
+ ["../outside.txt", null],
98
+ ["/etc/passwd", null],
99
+ ]);
100
+ });
78
101
  });
79
102
  });
80
103
 
@@ -115,7 +138,7 @@ describe("deployment checks", () => {
115
138
  const expect_ = { project: "grimoire", commit: "abc1234", services: { "grimoire-api-1": D1 }, jobs: { "grimoire-migrate": D2 }, noHealth: [] };
116
139
  const sync = syncFacts({ lastSyncCommit: "abc1234ffff", lastSyncStatus: "success", lastSyncAt: "t1" });
117
140
 
118
- it("parses the arguments arcane-ops took", () => {
141
+ it("parses the host command arguments", () => {
119
142
  expect(parseDeploymentArgs(["wait", "grimoire", "--commit", "abc1234", "--container", `grimoire-api-1=${D1}`, "--timeout", "120"])).toMatchObject({
120
143
  wait: true,
121
144
  timeoutS: 120,
@@ -134,15 +134,19 @@ export interface AuditFile {
134
134
  gid?: number;
135
135
  }
136
136
 
137
+ /** A tracked name under `host/`, such as `arcane-backup.sh` or `spc-arcane-probes/host_probe.py`; no `..`, no absolute path. */
138
+ const AUDIT_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*(\/[A-Za-z0-9][A-Za-z0-9._-]*)?$/;
139
+
137
140
  /**
138
141
  * Compares the host's file hashes with `host/<name>` in the specialist's
139
- * checkout of the arcane repository; `match` is null when either side is missing.
142
+ * checkout of the arcane repository; `match` is null when either side is missing
143
+ * or the host reports a name outside `host/`.
140
144
  */
141
145
  export function compareAudit(files: readonly AuditFile[], checkout: string): { name: string; state: string; match: boolean | null; mode?: number; uid?: number; gid?: number }[] {
142
146
  return files.map((f) => {
143
147
  const source = join(checkout, "host", f.name);
144
148
  let expected: string | null = null;
145
- if (existsSync(source) && lstatSync(source).isFile()) expected = createHash("sha256").update(readFileSync(source)).digest("hex");
149
+ if (AUDIT_NAME.test(f.name) && existsSync(source) && lstatSync(source).isFile()) expected = createHash("sha256").update(readFileSync(source)).digest("hex");
146
150
  return { name: f.name, state: f.state, match: f.state !== "present" || expected === null ? null : f.sha256 === expected, mode: f.mode, uid: f.uid, gid: f.gid };
147
151
  });
148
152
  }
@@ -119,7 +119,7 @@ export function syncFacts(row: Record<string, unknown>): SyncFacts {
119
119
  };
120
120
  }
121
121
 
122
- /** The checks of arcane-ops `deployment check`, as one pure comparison. */
122
+ /** The deployment checks (sync commit and state, hook, each container's project, image, state, health) as one pure comparison. */
123
123
  export function deploymentChecks(expect: DeploymentExpectation, sync: SyncFacts, containers: readonly (ContainerFacts | null)[], syncAfter: SyncFacts): { status: "ok" | "failed" | "unknown"; checks: DeploymentCheck[] } {
124
124
  const checks: DeploymentCheck[] = [];
125
125
  const commitOk = sync.commit !== null && (sync.commit.startsWith(expect.commit) || expect.commit.startsWith(sync.commit));