#!/bin/sh # Action State Receipts, install script rc22-p0.0.17-s0.0.17-r1 # capsulectl v0.1.0-rc22 (commit e66369627c41ff54a8318b39b7d835c16181beba), the deal skill, the Action State plugin 0.0.17 and its # actionstate-rules skill 0.0.17. Linux x86_64 only: anywhere else it installs nothing. # # sh install.sh --skills-dir SKILLS_DIR [--envelope envelope.json [--comparison comparison.json]] # [--repin-earlier-install] # # It runs the steps of the install skill, https://actionstate.ai/install/rc22-p0.0.17-s0.0.17-r1/actionstate-install/SKILL.md, # in the skill's order and with the skill's step ids (each "## N." heading of the skill carries one, # {#step-NN-...}; the site's build fails if the two lists differ). It first downloads and checks EVERYTHING # into a new temporary folder (steps 1-3), and installs only after every check passed (steps 4-12). It stops # at the first failure. # # Output. Progress on stderr: lines "step NN [step-NN-...]: +Ns ...", N the seconds since that step began # (each step's total goes in the summary's "steps"). The result on stdout, one block between # "==== RESULT ====" and "==== END ====". On success (exit 0) it is the short message the plugin wrote # (report/ready.md); the rules table is on the rules page. On failure it is one sentence naming the step that # stopped, and what was changed. # What the install verified is written to ~/.local/share/actionstate/install-summary.json. # Everything this script prints states a result, never an instruction: an agent reads it, and an agent host's # prompt-injection guard stopped installs whose output told the agent what to do (Muse, 2026-10-13). # scripts/install-apply.sh's script_lint keeps it so. # It first reads https://actionstate.ai/install/rc22-p0.0.17-s0.0.17-r1/release.json, this install's machine file, and records # it (its SHA-256 and ETag) in the summary under "page". If release.json names another install, capsulectl or plugin # version than this script's, it warns on stderr and goes on. # # --repin-earlier-install: pass it only after the person said yes to the install skill's step 11 question. The deal # profile's rules checker pin then names this install's plugin and rules if an earlier Action State install pinned # the same checker (the same Command, an older plugin SHA-256 or other rules digests). Without it such a pin stops step 11 # and nothing about it is changed. A pin with any other Command is the person's own: never changed, flag or not. # # --envelope: the CapabilityEnvelope you wrote (actionstate-rules skill, Step 1), with the comparison # you wrote from it (Step 2; default: comparison.json beside the envelope). The table is sealed into # sealed/ beside the envelope, and the plugin's report into report/ beside it: a page that checks its digests and # completeness when opened (capsulectl verify --bundle checks the signatures). # Without --envelope the script stops after the install, and you do step 12. # # Every version and SHA-256 below was filled in from the page's own values when the page was built. A new # release is a new script at a new address; this file never changes. # # It reaches only the page's hosts: github.com (served from release-assets.githubusercontent.com and # codeload.github.com), actionstate.ai and witness.agentactioncapsule.org, plus Sigstore's trust roots # (tuf-repo-cdn.sigstore.dev, tuf-repo.github.com) if gh is installed. It writes only where the page # does: the folder capsulectl is installed in, SKILLS_DIR and a _backups folder beside it, # ~/.local/share/capsule-deal, capsulectl's profiles, ~/.local/lib/capsulectl/plugins, # ~/.config/actionstate, ~/.local/share/actionstate, and sealed/ and report/ beside the envelope. It never changes a # profile named actionstate-local (an earlier install's) or an existing ~/.config/actionstate/seed. It never signs in to # anything. POSIX sh; it needs curl. set -eu TAG=v0.1.0-rc22 COMMIT=e66369627c41ff54a8318b39b7d835c16181beba REPO=action-state-group/capsule-cli REL=https://github.com/$REPO/releases/download/$TAG SITE=https://actionstate.ai PAGE=$SITE/install/rc22-p0.0.17-s0.0.17-r1/release.json WITNESS=https://witness.agentactioncapsule.org WITNESS_KEY=39bb654c9dc0afe1c0edef0deffaa69099b8518836c9ba26e0491535840f96b5 PLUGIN_VERSION=0.0.17 PLUGIN_FILE=capsulectl-actionstate-linux-amd64-v$PLUGIN_VERSION SKILL_ARCHIVE=actionstate-rules-skill-v0.0.17.tar.gz PACK_ID=asg/everyday/0.3.8 PACK_DIGEST=ac7697061fd1c50177000648e2458f6236068679fc67ae924870ee8fb1cd8488 # The seller's rules the same plugin carries (Muse #141): a seller's deal check runs them. The pin names both digests # as a set, or capsulectl reports every seller's check as not evaluated. A set needs capsulectl rc16 or later. SELLER_PACK_ID=asg/seller/0.1.5 SELLER_PACK_DIGEST=81013aedbc3d930dcb9a4871d932373eca9de6968e5cff9fb450158d1fcc576a RULES_PROFILE=actionstate-rules # The deal profile's materiality predicate (step 6), pinned by name and checked by its digest: a pick the person had no # choice in (quantity 1) does not pause a check; another quantity, or a size, variant or delivery the agent picks, still # does. Needs capsulectl rc16 or later. MATERIALITY=builtin:neutral MATERIALITY_DIGEST=05ec66b4a21025a66288cafc74e7fa1c540cc62695c5f536df84f4a7d7f66d4a SUMMARY_DIR=$HOME/.local/share/actionstate SUMMARY=$SUMMARY_DIR/install-summary.json # ---- output ------------------------------------------------------------------------------------------- STEP=00 SID=start HUMAN='' CHANGED='' W='' STEPS='' STEP_T0=$(date +%s) # Seconds since the current step began: on every step line, and per step in the summary. elapsed() { echo $(( $(date +%s) - STEP_T0 )); } say() { printf 'step %s [%s]: +%ss %s\n' "$STEP" "$SID" "$(elapsed)" "$*" >&2; } step_json() { printf '{"id":"%s","seconds":%s}' "$SID" "$(elapsed)"; } begin() { [ "$SID" = start ] || STEPS="$STEPS${STEPS:+,}$(step_json)"; STEP=$1 SID=step-$1-$2 STEP_T0=$(date +%s); say "begin"; } # human SENTENCE: what the person is told if the current step fails. Plain words only. human() { HUMAN=$1; } # changed WHAT: something this run changed on the machine, in plain words, for the failure block. changed() { CHANGED="$CHANGED${CHANGED:+; }$1"; } person() { echo "==== RESULT ====" cat echo "==== END ====" } die() { printf 'install.sh: step %s [%s]: %s\n' "$STEP" "$SID" "$*" >&2 if [ -n "$CHANGED" ] && write_summary failed "$*"; then changed "a record of this attempt was written to $SUMMARY"; fi { printf '%s\n' "$HUMAN" if [ -z "$CHANGED" ]; then echo "Nothing was installed; your machine is unchanged." else echo "Already changed on this computer: $CHANGED."; fi; } | person exit 1 } refuse() { printf 'install.sh: refusing to run: %s\n' "$1" >&2; printf '%s\nNothing was installed; your machine is unchanged.\n' "$2" | person; exit 2; } # ==== 1. Find the platform: Linux x86_64 only, before anything else ===================================== # Nothing is read, written or downloaded before this check. Off Linux x86_64 the script installs nothing: # Action State rules run only there, and a machine with receipts but no rules is not what was asked for. begin 01 platform os=$(uname -s) arch=$(uname -m) if ! [ "$(uname -s)/$(uname -m)" = Linux/x86_64 ]; then case $os/$arch in Darwin/arm64) plain="a Mac with Apple silicon" ;; Darwin/*) plain="a Mac" ;; Linux/aarch64|Linux/arm64) plain="a Linux computer with an ARM processor" ;; Linux/*) plain="a Linux computer with an $arch processor" ;; *) plain="a $os computer with an $arch processor" ;; esac printf 'install.sh: step 01 [step-01-platform]: refusing: %s/%s; Action State rules run only on Linux/x86_64, so nothing is installed\n' "$os" "$arch" >&2 printf "This device is %s, and Action State rules can't run on it yet: they need a Linux computer with an Intel or AMD (x86_64) processor.\nNothing was installed; your machine is unchanged.\n" "$plain" | person exit 1 fi PLATFORM=linux-amd64 BIN_SHA=a870a6128bb8bee5fd537ef7b418158de5d3cd81743a9c5e80fefe74573511ed say "$os/$arch = $PLATFORM" usage() { echo "usage: sh install.sh --skills-dir SKILLS_DIR [--envelope envelope.json [--comparison comparison.json]] [--repin-earlier-install]" >&2 echo " SKILLS_DIR the folder your agent host loads skills from" >&2 echo " --repin-earlier-install only after the person said yes to the install skill's step 11 question" >&2 exit 2 } SKILLS_DIR='' ENVELOPE='' COMPARISON='' REPIN=no while [ $# -gt 0 ]; do case $1 in --skills-dir) [ $# -ge 2 ] || usage; SKILLS_DIR=$2; shift 2 ;; --envelope) [ $# -ge 2 ] || usage; ENVELOPE=$2; shift 2 ;; --comparison) [ $# -ge 2 ] || usage; COMPARISON=$2; shift 2 ;; --repin-earlier-install) REPIN=yes; shift ;; -h|--help) usage ;; *) echo "install.sh: unknown argument: $1" >&2; usage ;; esac done [ -n "$SKILLS_DIR" ] || refuse "--skills-dir is empty (the folder your agent host loads skills from)" \ "The install was started without the folder the agent loads skills from." [ -n "$ENVELOPE" ] || [ -z "$COMPARISON" ] || refuse "--comparison needs --envelope" \ "The install was started with a comparison file but no description of what the agent can do." fetch() { curl -fsSL --proto '=https' --proto-redir '=https' --retry 2 -o "$2" "$1" || die "download failed: $1"; } # A JSON string (the values here never hold control characters). js() { printf '"%s"' "$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g')"; } # jget JSON PATH: the raw value at a dotted key path ("witness.ok"); exit 1 if absent. jget() { printf '%s\n' "$1" | awk -v want="$2" ' function ws() { while (pos <= n && index(" \t\r\n", substr(s, pos, 1)) > 0) pos++ } function str( c, start) { start = pos; pos++ while (pos <= n) { c = substr(s, pos, 1) if (c == "\\") pos += 2 else if (c == "\"") { pos++; return substr(s, start, pos - start) } else pos++ } bad = 1; return "" } function val(p, c, start, k) { ws(); start = pos; c = substr(s, pos, 1) if (c == "{") { pos++; ws() if (substr(s, pos, 1) == "}") pos++ else while (!bad) { ws(); if (substr(s, pos, 1) != "\"") { bad = 1; return } k = str(); k = substr(k, 2, length(k) - 2); ws() if (substr(s, pos, 1) != ":") { bad = 1; return } pos++; val(p == "" ? k : p "." k); ws() c = substr(s, pos, 1); pos++ if (c == "}") break if (c != ",") { bad = 1; return } } } else if (c == "[") { pos++; ws() if (substr(s, pos, 1) == "]") pos++ else while (!bad) { val(p "[]"); ws() c = substr(s, pos, 1); pos++ if (c == "]") break if (c != ",") { bad = 1; return } } } else if (c == "\"") str() else while (pos <= n && index(",}] \t\r\n", substr(s, pos, 1)) == 0) pos++ if (p == want && !found) { found = 1; out = substr(s, start, pos - start) } } { s = s $0 "\n" } END { n = length(s); pos = 1; val(""); if (bad || !found) exit 1; print out }' } jstr() { jget "$1" "$2" | sed 's/^"//; s/"$//'; } if command -v sha256sum >/dev/null 2>&1; then sha() { sha256sum "$1" | awk '{print $1}'; } shacheck() { sha256sum -c "$1"; } else sha() { shasum -a 256 "$1" | awk '{print $1}'; } shacheck() { shasum -a 256 -c "$1"; } fi # check DIR FILE WANT [SUMFILE]: the first field of SUMFILE (default: a line written from WANT) is WANT, # compared as a field, and `sha256sum -c SUMFILE` passes. Never a byte compare of the line. check() { s=${4:-$2.sha256} [ -n "${4:-}" ] || printf '%s %s\n' "$3" "$2" > "$1/$s" [ "$(awk '{print $1}' "$1/$s")" = "$3" ] || die "$s does not say the page's SHA-256 for $2 ($3)" (cd "$1" && shacheck "$s" >/dev/null) || die "$2 has SHA-256 $(sha "$1/$2"), not $3 as the page says" } BACKUPS= # move_out NAME: move every folder under SKILLS_DIR that holds a NAME skill (and SKILLS_DIR/NAME itself) # out of SKILLS_DIR, to SKILLS_DIR/../_backups/NAME-[-n]. Never renamed in place. move_out() { { [ -e "$SKILLS_DIR/$1" ] && printf '%s\n' "$SKILLS_DIR/$1/SKILL.md" find "$SKILLS_DIR" -name SKILL.md -exec grep -lx "name: $1" {} + 2>/dev/null || true; } > "$W/old-$1" bk=$(cd "$SKILLS_DIR/.." && pwd)/_backups while IFS= read -r f; do d=${f%/SKILL.md} [ -e "$d" ] || continue [ "$d" != "$SKILLS_DIR" ] || die "a $1 SKILL.md sits directly in $SKILLS_DIR, not in a folder of its own; it was not moved" mkdir -p "$bk" || die "cannot create $bk" t="$bk/$1-$(date +%F)" i=1 while [ -e "$t" ]; do i=$((i + 1)); t="$bk/$1-$(date +%F)-$i"; done mv "$d" "$t" || die "cannot move $d out to $t" say "moved an earlier copy out of the skills folder: $d -> $t" changed "an earlier copy of your agent's instructions was moved from $d to $t" BACKUPS="$BACKUPS${BACKUPS:+,}$(js "$t")" done < "$W/old-$1" } # exactly_one NAME: the page's exactly-one check. Sets EO_RC and EO_PATH. exactly_one() { EO_PATH=$(find "$SKILLS_DIR" -name SKILL.md -exec grep -lx "name: $1" {} + 2>/dev/null) || true n=$(printf '%s' "$EO_PATH" | grep -c '') || true if [ "$n" -eq 1 ]; then EO_RC=0; else EO_RC=1; fi [ "$EO_RC" -eq 0 ] || die "FAIL: $n $1 skills under $SKILLS_DIR: $(printf '%s' "$EO_PATH" | tr '\n' ' ')" say "exactly one $1 skill (exit 0): $EO_PATH" } # The evidence file. Every value is null until its step ran. HOW='' PROV='' PROV_REASON='' DEST='' VERSION_LINE='' DEAL_RC='' DEAL_PATH='' WITNESS_BLOCK='' TICK='' CADENCE_LOG='' CORE_DOCTOR_RC='' PLUGIN_SHA='' DEAL_PLUGIN_SHA='' DEAL_PLUGIN_CHECKS='' DEAL_HOW='' DEAL_REASON='' PREV_CORE='' PLUGIN_LS='' RULES_RC='' RULES_PATH='' RECORDED_OPERATOR='' NEW_RECORD='' RULES_PROFILE_CREATED='' VERIFY_LINE='' TABLE='' REPORT_HTML='' REPORT_BUNDLE='' REPORT_ROOT='' REPORT_VERIFY_RC='' REPORT_VERDICT='' SEED_REUSED='' RC_SHA='' RC_DIGEST='' RC_DIGESTS='' RC_STATE='' MAT_STATE='' MAT_DIGEST='' AS_DOCTOR_RC='' AS_DOCTOR_OUT='' PAGE_SHA='' PAGE_ETAG='' WARNINGS="" jn() { if [ -n "$1" ]; then js "$1"; else printf null; fi; } # string or null jr() { if [ -n "$1" ]; then printf '%s' "$1"; else printf null; fi; } # raw JSON or null # provenance (per artifact): attested, or checksum_only with the reason. Only capsulectl and its deal plugin have a # build attestation; the deal plugin's is checked by `capsulectl plugin install deal` itself (step 4). The deal plugin # is the last artifact, so the earlier ones keep the positions the install skill's record names. NOATT='"provenance":"checksum_only","provenance_reason":"no_attestation_published"' write_summary() { mkdir -p "$SUMMARY_DIR" || return 1 plugin="{\"name\":\"Action State plugin\",\"version\":\"$PLUGIN_VERSION\",\"sha256\":$(jn "$PLUGIN_SHA"),$NOATT,\"path\":$(jn "${PLUGIN_SHA:+$HOME/.local/lib/capsulectl/plugins/capsulectl-actionstate}")}" rules="{\"name\":\"actionstate-rules skill\",\"version\":\"0.0.17\",\"sha256\":\"f305124f3234039bc3469a75edde3a247664f7ea24d167d7307a841ea7c1751b\",$NOATT,\"path\":$(jn "$RULES_PATH")}" cat > "$SUMMARY.tmp" </dev/null 2>&1 || die "$c is required and was not found" done # A host that has never loaded a skill has no skills folder yet (a new Muse account, 2026-10-16): it is created. human "The skills folder the install was given could not be created." if [ -d "$SKILLS_DIR" ]; then : else mkdir -p "$SKILLS_DIR" || die "cannot create SKILLS_DIR $SKILLS_DIR"; changed "the skills folder $SKILLS_DIR was created"; fi SKILLS_DIR=$(cd "$SKILLS_DIR" && pwd) if [ -n "$ENVELOPE" ]; then human "The description of what your agent can do for you, which your rules table is made from, was missing or did not match its comparison file." [ -f "$ENVELOPE" ] || die "--envelope $ENVELOPE is not a file" ENVDIR=$(cd "$(dirname "$ENVELOPE")" && pwd) ENVELOPE=$ENVDIR/${ENVELOPE##*/} [ -n "$COMPARISON" ] || COMPARISON=$ENVDIR/comparison.json [ -f "$COMPARISON" ] || die "no comparison file at $COMPARISON (the actionstate-rules skill, Step 2)" # A chat surface turns marketplace.sale into [marketplace.sale](http://marketplace.sale); the plugin refuses # that as a transport fault. Caught here too, before anything is installed. Reported, never cleaned. for f in "$ENVELOPE" "$COMPARISON"; do if grep -nE '\]\(https?://' "$f" >&2; then die "transport fault: a value in $f came back linked (above), as text written through chat comes back; it was not changed"; fi done bound=$(jstr "$(cat "$COMPARISON")" baseline_envelope_sha256) || die "$COMPARISON has no baseline_envelope_sha256" [ "$bound" = "$(sha "$ENVELOPE")" ] || die "$COMPARISON's baseline_envelope_sha256 is $bound, but the envelope file's SHA-256 is $(sha "$ENVELOPE")" say "envelope $ENVELOPE and comparison $COMPARISON are files, bound by SHA-256" fi # logged PROFILE_SHOW_JSON: a SQLite profile with a log id and a checkpoint signing key (what the plugin's install check # requires of actionstate-rules). Sets PT and PLG. logged() { PT=$(jstr "$1" Type) || PT= PLG=$(jstr "$1" LogID) || PLG= ck=$(jstr "$1" Checkpoint.Signing.File || true)$(jstr "$1" Checkpoint.Signing.Value || true)$(jstr "$1" Checkpoint.Signing.Env || true) [ "$PT" = sqlite ] && [ -n "$PLG" ] && [ -n "$ck" ] } # An existing actionstate-rules profile that cannot hold the report stops the install before anything is installed # (read-only, with whatever capsulectl is already on PATH; step 12 checks again). An earlier install's actionstate-local # is never read here: it is not used, and it does not stop the install. if command -v capsulectl >/dev/null 2>&1 && pl=$(capsulectl profile list 2>/dev/null) \ && printf '%s' "$pl" | grep -q "\"profiles\":\\[[^]]*\"$RULES_PROFILE\"" && ps=$(capsulectl profile show "$RULES_PROFILE" 2>/dev/null); then if ! logged "$ps"; then human "Your rules record on this computer can't be used by this version." die "the existing $RULES_PROFILE profile is ${PT:-of no type} with log_id \"$PLG\" (and needs a checkpoint signing key); the report needs a SQLite profile with a log and a checkpoint key (the actionstate-rules skill's Setup). It was not changed" fi fi # What the earlier install was (this run replaces its summary): the plugin version and rules it pinned, so step 11 can # name them, old to new, if it finds that install's pin. From a summary whose result is "installed", its own versions; # from one that stopped, the "earlier_install" it carried forward (a stopped run's own versions are this install's). # Read only, best effort: unknown leaves them empty. OLD_PLUGIN_VERSION='' OLD_PACK_ID='' if [ -f "$SUMMARY" ]; then if grep -q '"result":"installed"' "$SUMMARY"; then OLD_PLUGIN_VERSION=$(sed -n 's/.*"name":"Action State plugin","version":"\([^"]*\)".*/\1/p' "$SUMMARY" | head -n 1) OLD_PACK_ID=$(sed -n 's/.*"rules_checker":{"profile":"deal","state":[^,]*,"pack_id":"\([^"]*\)".*/\1/p' "$SUMMARY" | head -n 1) else OLD_PLUGIN_VERSION=$(sed -n 's/.*"earlier_install":{"plugin_version":"\([^"]*\)".*/\1/p' "$SUMMARY" | head -n 1) OLD_PACK_ID=$(sed -n 's/.*"earlier_install":{"plugin_version":[^,]*,"pack_id":"\([^"]*\)".*/\1/p' "$SUMMARY" | head -n 1) fi fi W=$(mktemp -d "${TMPDIR:-/tmp}/actionstate-install.XXXXXX") || die "cannot create a temporary folder" trap 'rm -rf "$W"' EXIT trap 'exit 1' HUP INT TERM say "working in a new empty folder, $W; nothing is installed until every check has passed" # ==== 2. Download, and check the SHA-256 (every file the install uses) ================================== begin 02 download human "The install files couldn't be downloaded from github.com or actionstate.ai." # The release file this install follows (u127, u230): its SHA-256 and ETag go in the summary under "page", so which # release.json an install followed is in the record. One naming other versions is a warning, never a stop. # release.json is read before any check, so no text of it reaches the output unless it is a version-shaped word # ([0-9A-Za-z.-], at most 64), and the ETag keeps only the characters an ETag uses. if curl -fsSL --proto '=https' --proto-redir '=https' --retry 2 -H 'Cache-Control: no-cache' -D "$W/release.headers" -o "$W/release.json" "$PAGE"; then PAGE_SHA=$(sha "$W/release.json") PAGE_ETAG=$(tr -d '\r' < "$W/release.headers" | awk 'tolower($1) == "etag:" { sub(/^[^:]*:[ \t]*/, ""); e = $0 } END { print e }' | tr -cd '0-9A-Za-z"/._:-' | cut -c1-80) named=$(for k in install_v tag version; do sed -n "s/^[[:space:]]*\"$k\": \"\([0-9A-Za-z.-]\{1,64\}\)\".*/\1/p" "$W/release.json" | head -n 1; done | tr '\n' ' ' | sed 's/ $//') if [ "$named" = "rc22-p0.0.17-s0.0.17-r1 $TAG $PLUGIN_VERSION" ]; then say "read $PAGE (sha256 $PAGE_SHA, etag ${PAGE_ETAG:-none}): it names this install, $named" else say "WARNING: $PAGE names ${named:-no versions}, but this is rc22-p0.0.17-s0.0.17-r1 $TAG $PLUGIN_VERSION: release.json and this script differ" fi else say "WARNING: could not read $PAGE, so the summary records no release file; going on" fi BIN=capsulectl-$TAG-$PLATFORM BUNDLE=capsulectl-$TAG.sigstore.json fetch "$REL/$BIN" "$W/$BIN" fetch "$REL/$BUNDLE" "$W/$BUNDLE" fetch "https://github.com/$REPO/archive/refs/tags/$TAG.tar.gz" "$W/deal-src.tar.gz" fetch "$SITE/plugin/$PLUGIN_FILE" "$W/$PLUGIN_FILE" fetch "$SITE/plugin/$PLUGIN_FILE.sha256" "$W/$PLUGIN_FILE.served.sha256" fetch "$SITE/plugin/$SKILL_ARCHIVE" "$W/$SKILL_ARCHIVE" human "A downloaded file didn't match what actionstate.ai/install publishes." check "$W" "$BIN" "$BIN_SHA" check "$W" "$BUNDLE" ff73abc0d2613ea4ef3eb62359742a43da7c3fefd5a402238bf5c5f9d81ee292 check "$W" deal-src.tar.gz 17e689a94cac45465a07f7c0de9326a1f37c24a19001ec0702261d66dc0cfd7b if ! { mkdir "$W/deal-x" && tar -xzf "$W/deal-src.tar.gz" -C "$W/deal-x"; }; then die "cannot unpack the source archive"; fi DEAL_SRC=$(find "$W/deal-x" -path '*/skills/deal/SKILL.md') [ "$(printf '%s\n' "$DEAL_SRC" | grep -c .)" -eq 1 ] || die "the source archive does not hold exactly one skills/deal/SKILL.md" [ "$(sha "$DEAL_SRC")" = ba05a20f71692fc104d9fbd8eb1bd7c1f18b836774a61fd69fa7c274b3d9f74b ] || die "skills/deal/SKILL.md in the archive is not the page's SKILL.md" # The served .sha256 must say the page's value (its first field), and the file must pass sha256sum -c. check "$W" "$PLUGIN_FILE" 7c2c1b16c4ef7ed74f6779b16bd41857b29f2d28db2844c3195ca6837cb6417e "$PLUGIN_FILE.served.sha256" PLUGIN_SHA=$(sha "$W/$PLUGIN_FILE") check "$W" "$SKILL_ARCHIVE" f305124f3234039bc3469a75edde3a247664f7ea24d167d7307a841ea7c1751b if ! { mkdir "$W/rules-x" && tar -xzf "$W/$SKILL_ARCHIVE" -C "$W/rules-x"; }; then die "cannot unpack $SKILL_ARCHIVE"; fi grep -qx 'name: actionstate-rules' "$W/rules-x/actionstate-rules/SKILL.md" 2>/dev/null \ || die "$SKILL_ARCHIVE does not hold actionstate-rules/SKILL.md" RULES_SKILL_MD_SHA=$(sha "$W/rules-x/actionstate-rules/SKILL.md") [ "$RULES_SKILL_MD_SHA" = d2e99dee912bb3b16b7a8b587d343bcd5d6197a292ba4d473ac3558c17f06fbb ] || die "actionstate-rules/SKILL.md in $SKILL_ARCHIVE is $RULES_SKILL_MD_SHA, not the page's d2e99dee912bb3b16b7a8b587d343bcd5d6197a292ba4d473ac3558c17f06fbb" say "$BIN, $BUNDLE, the deal source archive, $PLUGIN_FILE ($PLUGIN_SHA) and $SKILL_ARCHIVE match the page's SHA-256" # ==== 3. Check where it was built, if you can (never signs in) ========================================== begin 03 provenance human "The Action State program's build record didn't check out." verify() { (cd "$W" && GH_PROMPT_DISABLED=1 GH_NO_UPDATE_NOTIFIER=1 gh attestation verify "$BIN" --bundle "$BUNDLE" \ --repo "$REPO" --signer-workflow "$REPO/.github/workflows/release.yml" &1) } # gh is optional: only this check uses it, and it is never installed, updated or signed in to. Absent, too old # (its verify --help lacks a flag we use) or only asking to sign in: checksum only, and go on. A gh that runs the # check and reports a failure stops the install. if ! command -v gh >/dev/null 2>&1; then GH_CASE=A HOW=checksum_only PROV_REASON=gh_absent PROV="provenance: checksum only (gh not installed)" elif ! help=$(GH_PROMPT_DISABLED=1 GH_NO_UPDATE_NOTIFIER=1 gh attestation verify --help &1) \ || ! printf '%s' "$help" | grep -q -- '--bundle' || ! printf '%s' "$help" | grep -q -- '--signer-workflow'; then GH_CASE=A HOW=checksum_only PROV_REASON=gh_too_old PROV="provenance: checksum only (gh too old)" say "$(gh --version 2>/dev/null | head -n 1): its attestation verify --help lacks --bundle or --signer-workflow" else GH_CASE=B if out=$(verify); then HOW=attested PROV_REASON=attestation_verified PROV="provenance checked: attestation verified (bundle); checksum matched" elif printf '%s' "$out" | grep -qiE 'gh auth login|not logged in|authenticat|sign in|log in' \ && ! printf '%s' "$out" | grep -qiE 'digest|mismatch|does not match|no matching|certificate|signature|workflow|verif'; then HOW=checksum_only PROV_REASON=gh_login_prompt PROV="provenance: checksum only (gh asked to sign in)" else die "gh attestation verify failed, so the build attestation did not verify: $(printf '%s' "$out" | head -n 3)" fi fi say "gh case $GH_CASE: $PROV (summary: provenance $HOW, $PROV_REASON)" # ==== 4. Install the binary ============================================================================= begin 04 binary human "Your agent couldn't put the Action State program in a folder it can run programs from. The install stopped at step 4." cur=$(command -v capsulectl 2>/dev/null) || cur= case $cur in /*) DEST=${cur%/*}; [ -w "$DEST" ] || die "capsulectl is already at $cur, in a folder this user cannot write to; it was not changed" ;; *) DEST=$HOME/.local/bin mkdir -p "$DEST" || die "cannot create $DEST" ;; esac # ---- version-order begin (scripts/install-version-order-test.sh runs this block) ---- # Never backwards: once a newer capsulectl has opened the receipts store, an older one must not run on it (the store # move is one-way: capsule-cli#242, #294, #271). The capsulectl this step would replace, $DEST/capsulectl (on PATH or # not), is read first: one newer than this install, one that names no release, or one whose version this install # cannot order stops the install with nothing changed. # release_tag LINE: the release tag a `capsulectl --version` line names, or nothing. release_tag() { printf '%s\n' "$1" | sed -n 's/^capsulectl \(v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\(-[0-9A-Za-z.]*\)\{0,1\}\) (commit [0-9a-f]*)$/\1/p'; } # order A B: exit 0 when tag A is later than B, 1 when it is not, 2 when either cannot be ordered. SemVer precedence: # X.Y.Z numerically, a release above its own pre-releases, pre-release identifiers dot by dot, an identifier of # letters then digits (rc16) by its letters and then numerically by its digits, and more identifiers above fewer. order() { awk -v a="$1" -v b="$2" ' function parse(t, out, i, n, c) { if (t !~ /^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?$/) return 0 sub(/^v/, "", t); out["pre"] = "" if ((i = index(t, "-")) > 0) { out["pre"] = substr(t, i + 1); t = substr(t, 1, i - 1) } split(t, c, "."); out[1] = c[1] + 0; out[2] = c[2] + 0; out[3] = c[3] + 0 return 1 } function ident(x, part) { # letters then digits, letters only, or digits only; anything else cannot be ordered if (x ~ /^[A-Za-z]*[0-9]+$/) { match(x, /[0-9]+$/); part["s"] = substr(x, 1, RSTART - 1); part["n"] = substr(x, RSTART) + 0; return 1 } if (x ~ /^[A-Za-z]+$/) { part["s"] = x; part["n"] = -1; return 1 } return 0 } BEGIN { if (!parse(a, A) || !parse(b, B)) exit 2 for (k = 1; k <= 3; k++) { if (A[k] > B[k]) exit 0; if (A[k] < B[k]) exit 1 } if (A["pre"] == B["pre"]) exit 1 if (A["pre"] == "") exit 0 if (B["pre"] == "") exit 1 na = split(A["pre"], pa, "."); nb = split(B["pre"], pb, ".") for (k = 1; k <= na && k <= nb; k++) { if (!ident(pa[k], x) || !ident(pb[k], y)) exit 2 if (x["s"] != y["s"]) exit (x["s"] > y["s"] ? 0 : 1) if (x["n"] != y["n"]) exit (x["n"] > y["n"] ? 0 : 1) } exit (na > nb ? 0 : 1) }' } # ---- version-order end ---- DEAL_PLUGIN=$HOME/.local/lib/capsulectl/plugins/capsulectl-deal if [ -e "$DEST/capsulectl" ]; then old=$("$DEST/capsulectl" --version 2>/dev/null) || old= oldtag=$(release_tag "$old") human "Couldn't tell which version of the Action State program is installed, so nothing was changed." [ -n "$oldtag" ] || die "capsulectl at $DEST/capsulectl printed \"$old\", which names no release, so this install cannot tell whether it is newer than $TAG; it was not changed" ord=0; order "$oldtag" "$TAG" || ord=$? [ "$ord" -ne 2 ] || die "capsulectl at $DEST/capsulectl is $oldtag, which this install cannot order against $TAG; it was not changed" human "A newer version of the Action State program is already installed, so nothing was changed." [ "$ord" -ne 0 ] || die "capsulectl at $DEST/capsulectl is $oldtag, newer than this install's $TAG; an older capsulectl must not run on a store a newer one opened, so it was not changed" fi human "Your agent couldn't put the Action State program in a folder it can run programs from. The install stopped at step 4." # Copies of the capsulectl and deal plugin that are here, kept until the new deal plugin is in place, so a failed # deal step puts back the pair that was here (or, on a new machine, removes this install's capsulectl): the install # never leaves a capsulectl and a deal plugin of different versions. Both are taken before anything is replaced. PREV_CORE='' PREV_DEAL='' if [ -e "$DEST/capsulectl" ]; then cp -p "$DEST/capsulectl" "$DEST/.capsulectl.prev.$$" || { rm -f "$DEST/.capsulectl.prev.$$"; die "cannot keep a copy of $DEST/capsulectl; nothing was changed"; } PREV_CORE=$DEST/.capsulectl.prev.$$ fi if [ -e "$DEAL_PLUGIN" ]; then cp -p "$DEAL_PLUGIN" "$DEST/.capsulectl-deal.prev.$$" \ || { rm -f "$DEST/.capsulectl-deal.prev.$$" "${PREV_CORE:-}"; die "cannot keep a copy of $DEAL_PLUGIN; nothing was changed"; } PREV_DEAL=$DEST/.capsulectl-deal.prev.$$ fi # back_out WHY: put back the capsulectl and deal plugin that were here (removing this install's where there were # none), then stop, saying what could not be put back. back_out() { left= if [ -n "$PREV_DEAL" ]; then { cp -p "$PREV_DEAL" "$DEAL_PLUGIN.prev.$$" && mv -f "$DEAL_PLUGIN.prev.$$" "$DEAL_PLUGIN" && rm -f "$PREV_DEAL"; } \ || { rm -f "$DEAL_PLUGIN.prev.$$"; left="$left; the earlier deal plugin is still at $PREV_DEAL"; } else rm -f "$DEAL_PLUGIN" || left="$left; $DEAL_PLUGIN could not be removed"; fi if [ -n "$PREV_CORE" ]; then mv -f "$PREV_CORE" "$DEST/capsulectl" || left="$left; the earlier capsulectl is still at $PREV_CORE" else rm -f "$DEST/capsulectl" || left="$left; $DEST/capsulectl could not be removed"; fi [ -z "$left" ] || die "$1; putting back what was here failed$left" if [ -n "$PREV_CORE" ]; then changed "then the earlier Action State program and deal plugin were put back"; die "$1; the earlier capsulectl and deal plugin were put back"; fi changed "then this install's Action State program was removed again"; die "$1; this install's capsulectl was removed again" } if ! { cp "$W/$BIN" "$DEST/.capsulectl.new.$$" && chmod 0755 "$DEST/.capsulectl.new.$$" \ && mv -f "$DEST/.capsulectl.new.$$" "$DEST/capsulectl"; }; then rm -f "$DEST/.capsulectl.new.$$" "${PREV_CORE:-}" "${PREV_DEAL:-}"; die "cannot install $DEST/capsulectl" fi changed "the Action State program was installed as $DEST/capsulectl" hash -r 2>/dev/null || true # On PATH, nothing may shadow it. Off PATH (a Muse chat never has ~/.local/bin on it, 2026-10-16) this script runs it # by its path, through a function, and names it to the plugin as CAPSULECTL_BIN; the shell's PATH is not changed, and # everything the install leaves behind (the schedule line, the standing rule) names capsulectl by its full path. case ":$PATH:" in *":$DEST:"*) [ "$(command -v capsulectl)" = "$DEST/capsulectl" ] || back_out "another capsulectl comes first on PATH: $(command -v capsulectl)" ;; *) say "$DEST is not on PATH: this script runs $DEST/capsulectl by its path" ;; esac capsulectl() { "$DEST/capsulectl" "$@"; } CAPSULECTL_BIN=$DEST/capsulectl; export CAPSULECTL_BIN VERSION_LINE=$(capsulectl --version) || back_out "capsulectl --version failed" [ "$VERSION_LINE" = "capsulectl $TAG (commit $COMMIT)" ] || back_out "capsulectl --version printed \"$VERSION_LINE\", not \"capsulectl $TAG (commit $COMMIT)\"" say "installed $DEST/capsulectl: $VERSION_LINE" # The deal plugin (capsule-cli#236): `deal` is no longer in the core. The capsulectl just installed fetches its own # release's plugin, checks it against that release's SHA256SUMS, and runs gh attestation verify when gh is on PATH; # then the file must have the page's SHA-256. It refuses a plugin folder another user could change and prints the fix # (stock Ubuntu: chmod go-w ~/.local ~/.local/lib ~/.local/lib/capsulectl). A gh that step 3 could not use (too old, # or asking to sign in) is kept off PATH for it, so the plugin is checked the way the binary was: by checksum only. human "The deal plugin couldn't be installed. The install stopped at step 4." case $PROV_REASON in gh_too_old|gh_login_prompt) nogh= old_ifs=$IFS; IFS=: for d in $PATH; do [ -x "$d/gh" ] || nogh=${nogh:+$nogh:}$d; done IFS=$old_ifs out=$(env PATH="$nogh" "$DEST/capsulectl" plugin install deal 2>&1) || back_out "capsulectl plugin install deal failed: $(printf '%s\n' "$out" | tail -n 3)" DEAL_HOW=checksum_only DEAL_REASON=$PROV_REASON ;; *) out=$("$DEST/capsulectl" plugin install deal 2>&1) || back_out "capsulectl plugin install deal failed: $(printf '%s\n' "$out" | tail -n 3)" case $out in *'"provenance (gh attestation verify)"'*) DEAL_HOW=attested DEAL_REASON=attestation_verified ;; *) DEAL_HOW=checksum_only DEAL_REASON=gh_absent ;; esac ;; esac got=$(sha "$DEAL_PLUGIN") [ "$got" = 4c34846617b0b5c6325b1a77d7513b1a9fb79cfb68a410c742038d91d1c8f5a1 ] || back_out "$DEAL_PLUGIN was $got, not the page's 4c34846617b0b5c6325b1a77d7513b1a9fb79cfb68a410c742038d91d1c8f5a1" DEAL_PLUGIN_SHA=$got rm -f "${PREV_CORE:-}" "${PREV_DEAL:-}"; PREV_CORE='' PREV_DEAL='' changed "the deal plugin was installed as $DEAL_PLUGIN" DEAL_PLUGIN_CHECKS=$(printf '%s\n' "$out" | grep '^{' | head -n 1) say "deal plugin installed: $DEAL_PLUGIN_SHA, matching the page; provenance $DEAL_HOW ($DEAL_REASON)" # ==== 5. Install the deal skill, with no second copy ==================================================== begin 05 deal-skill human "Your agent couldn't add the receipts instructions to its skills folder. The install stopped at step 5." move_out deal cp -R "${DEAL_SRC%/SKILL.md}" "$SKILLS_DIR/deal" || die "cannot copy the deal skill to $SKILLS_DIR/deal" changed "the receipts instructions were added to $SKILLS_DIR/deal" exactly_one deal DEAL_RC=$EO_RC DEAL_PATH=$EO_PATH # ==== 6. Create the deal profile, with the witness ====================================================== begin 06 deal-profile human "Your receipts store couldn't be set up. The install stopped at step 6." profiles=$(capsulectl profile list) || die "capsulectl profile list failed" if printf '%s' "$profiles" | grep -q '"profiles":\[[^]]*"deal"'; then say "a deal profile exists: deal init skipped" else had_store=no; [ ! -e "$HOME/.local/share/capsule-deal" ] || had_store=yes if ! capsulectl --profile deal deal init --dir "$HOME/.local/share/capsule-deal" --materiality "$MATERIALITY" >/dev/null; then [ "$had_store" = yes ] || [ ! -e "$HOME/.local/share/capsule-deal" ] \ || changed "a partly created receipts store was left in $HOME/.local/share/capsule-deal" die "capsulectl deal init failed" fi changed "a receipts store and its two keys were created in $HOME/.local/share/capsule-deal" MAT_STATE=pinned fi capsulectl profile update --profile deal --checkpoint-endpoint "$WITNESS" --checkpoint-public-key "$WITNESS_KEY" >/dev/null \ || die "capsulectl profile update (the witness and its key) failed" say "deal profile names the witness $WITNESS and its key" # The materiality predicate: pinned above on a new profile; an existing one without a predicate gets it now. One that # already pins it is left as is; one that pins another predicate is the person's: left as is, and said. mat() { out=$(capsulectl --profile deal doctor) || die "capsulectl --profile deal doctor failed" MAT_DIGEST=$(jstr "$out" profile.materiality.digest) || MAT_DIGEST=; } mat if [ -z "$MAT_DIGEST" ]; then capsulectl profile update --profile deal --materiality "$MATERIALITY" >/dev/null \ || die "capsulectl profile update --materiality $MATERIALITY failed" changed "the deal profile now pins the materiality predicate $MATERIALITY" MAT_STATE=pinned mat elif [ "$MAT_DIGEST" != "$MATERIALITY_DIGEST" ]; then MAT_STATE=other say "the deal profile pins another materiality predicate ($MAT_DIGEST): the person's, left as is" fi MAT_STATE=${MAT_STATE:-already_pinned} if [ "$MAT_STATE" != other ]; then [ "$MAT_DIGEST" = "$MATERIALITY_DIGEST" ] && [ "$(jget "$out" profile.materiality.ok)" = true ] \ || die "the deal profile's materiality predicate is ${MAT_DIGEST:-not set}, not $MATERIALITY ($MATERIALITY_DIGEST)" say "deal profile pins the materiality predicate $MATERIALITY ($MAT_STATE)" fi # ==== 7. Reach the witness ============================================================================== begin 07 witness human "Your computer couldn't reach the public record at witness.agentactioncapsule.org. Network access to agentactioncapsule.org is needed." out=$(capsulectl doctor --profile deal --check-witness) || die "capsulectl doctor --check-witness failed: $out" WITNESS_BLOCK=$(jget "$out" witness) || die "the doctor output has no witness block" [ "$(jget "$out" witness.ok)" = true ] \ || die "the witness check is not ok: issue: $(jstr "$out" witness.issue || echo none)" say "witness ok" # ==== 8. The first tick (scheduling the checkpoint cadence every 5 minutes is yours) ==== begin 08 first-tick TICK=$(capsulectl --profile deal cll checkpoint cadence) || die "the first checkpoint cadence run failed: $TICK" case $TICK in *network_consent_needed*) die "the witness needs network access to agentactioncapsule.org (network_consent_needed)" ;; esac TICK_STATE=$(jstr "$TICK" state) || die "the tick output has no state: $TICK" CADENCE_LOG=$(jstr "$TICK" cadence_log) || die "the tick output has no cadence_log: $TICK" case $TICK_STATE in ticked|not_due|pending) ;; *) die "the tick's state is \"$TICK_STATE\": $TICK" ;; esac if [ "$(jget "$TICK" delivered)" != "[]" ]; then changed "one mark carrying none of your content was sent to the public record at witness.agentactioncapsule.org" fi # A first checkpoint that was not delivered, pending for any reason but not_attempted (not sent yet), is a warning # in the summary and said here. The install still counts as installed. if [ "$(jget "$TICK" delivered)" = "[]" ]; then for r in $(printf '%s' "$TICK" | grep -o '"reason":"[^"]*"' | sed 's/^"reason":"//; s/"$//' | sort -u); do [ "$r" != not_attempted ] || continue case $r in witness_error) w="the witness answered the first checkpoint with an error" ;; *) w="the first checkpoint was not delivered to the witness" ;; esac WARNINGS="${WARNINGS:+$WARNINGS,}$(js "first_tick: $r: $w; it stays pending and is retried at every tick (see first_tick.pending)")" say "warning: $w ($r); it is retried at every tick" done fi say "first tick: $TICK_STATE; cadence log $CADENCE_LOG" # The schedule line the agent schedules (the skill's step 8), recorded where capsulectl actionstate doctor # --install-state reads it: a cron line naming capsulectl by its full path, never a bare capsulectl, so the # scheduler's PATH cannot pick another one or none. human "Your agent couldn't record the checkpoint schedule. The install stopped at step 8." CADENCE_LINE_FILE=$HOME/.config/actionstate/cadence-schedule case $DEST$HOME in *"'"*|*%*) die "the schedule line cannot quote $DEST/capsulectl or $HOME/checkpoint-cadence.log (a ' or % in the path)" ;; esac CADENCE_LINE="*/5 * * * * '$DEST/capsulectl' --profile deal cll checkpoint cadence --wait-up-to 5m >> '$HOME/checkpoint-cadence.log'" # shellcheck disable=SC2174 # as in step 12: ~/.config/actionstate is 0700 if ! { mkdir -p -m 0700 "$HOME/.config/actionstate" && printf '%s\n' "$CADENCE_LINE" > "$CADENCE_LINE_FILE"; }; then die "cannot write $CADENCE_LINE_FILE" fi changed "the checkpoint schedule's line was written to $CADENCE_LINE_FILE" say "schedule line in $CADENCE_LINE_FILE: $CADENCE_LINE" # ==== 10. Check the whole install ======================================================================= # Both install checks run once each, at the end, after the plugin's pin: no step reads their result before # then. Steps 11 and 12 check what they rely on themselves (plugin ls, exactly one skill, the pack from rules list, # the pin, the logged profile). begin 10 install-check say "capsulectl doctor --install-check and capsulectl actionstate doctor --install-check run once each, last, after step 12" core_doctor() { out=$(capsulectl doctor --install-check --profile deal --skills-dir "$SKILLS_DIR" --expect-version "$TAG" \ --expect-commit "$COMMIT" --expect-skill-sha256 ba05a20f71692fc104d9fbd8eb1bd7c1f18b836774a61fd69fa7c274b3d9f74b) && CORE_DOCTOR_RC=0 || CORE_DOCTOR_RC=$? [ "$CORE_DOCTOR_RC" -eq 0 ] || die "install incomplete: capsulectl doctor --install-check exited $CORE_DOCTOR_RC: $out" say "capsulectl doctor --install-check: exit 0" } # ==== 11. The Action State plugin, and its actionstate-rules skill ====================================== begin 11 plugin human "The rules add-on couldn't be set up. The install stopped at step 11." PLUGINS=$HOME/.local/lib/capsulectl/plugins if ! { install -d -m 0755 "$PLUGINS" && chmod 0755 "$PLUGINS" && install -m 0755 "$W/$PLUGIN_FILE" "$PLUGINS/capsulectl-actionstate"; }; then die "cannot install $PLUGINS/capsulectl-actionstate" fi changed "the Action State rules add-on was installed in $PLUGINS" # loaded LS_JSON: capsulectl lists this plugin, from $PLUGINS, at $PLUGIN_VERSION. Each plugin's entry starts {"name":; # its presentations (plugin 0.0.12) are objects inside it, so the entry is cut out first, never matched across a '}'. loaded() { printf '%s\n' "$1" | sed 's/{"name":/\n{"name":/g' | grep "\"path\":\"$PLUGINS/capsulectl-actionstate\",\"plugin_api\":" \ | grep -q "\"version\":\"$PLUGIN_VERSION (commit "; } PLUGIN_LS=$(capsulectl plugin ls) || die "capsulectl plugin ls failed" loaded "$PLUGIN_LS" || die "FAIL: capsulectl did not load the plugin: $PLUGIN_LS" say "plugin $PLUGIN_VERSION listed by capsulectl plugin ls" # The page modules the report and receipt pages are built with (capsulectl rc16 presentation build), beside the plugin. out=$(capsulectl actionstate presentation install --json) || die "capsulectl actionstate presentation install --json failed: $out" changed "the add-on's page modules were installed in $PLUGINS/capsulectl-actionstate.d" PLUGIN_LS=$(capsulectl plugin ls) || die "capsulectl plugin ls failed" loaded "$PLUGIN_LS" || die "FAIL: capsulectl did not load the plugin: $PLUGIN_LS" case $PLUGIN_LS in *'"presentations_refused"'*) die "capsulectl refused the plugin's page modules: $PLUGIN_LS" ;; esac say "page modules and wording pack: $(printf '%s' "$out" | grep -o '"installed":true' | wc -l | tr -d ' ') files written" # The plugin's capsulectl wording pack (page step 11, line 6; actionstate-site#73), pinned on the deal profile so the # approval cards carry Action State's tags. It runs on every install, an update included, and before the plugin's own # install check, which stops the install on wording-pin-stale while the profile still pins an earlier install's pack. # As with the rules checker, only an Action State pin is changed: no pin, or a pack whose file name starts # actionstate.capsulectl-en- (the plugin's WordingPackPrefix). Any other pack is the person's own and is left as it is. # ---- wording-pin begin (scripts/install-wording-pin-test.sh runs this block as is) # pin_wording PACK: pin PACK on the deal profile unless it pins the person's own pack. A failed profile show stops. pin_wording() { show=$(capsulectl profile show deal) || die "capsulectl profile show deal failed" PINNED_WORDING=$(jstr "$show" Wording.Path) || PINNED_WORDING= case ${PINNED_WORDING##*/} in ''|actionstate.capsulectl-en-*) capsulectl profile update --profile deal --wording "$1" >/dev/null \ || die "capsulectl profile update --profile deal --wording $1 failed" changed "your approval cards were set to the add-on's wording, $1" show=$(capsulectl profile show deal) || die "capsulectl profile show deal failed" [ "$(jstr "$show" Wording.Path)" = "$1" ] \ || die "the deal profile's wording is not $1 after the pin: $(printf '%s' "$show" | grep -o '"Wording":{[^}]*}')" say "wording pack pinned on the deal profile: $1" ;; *) say "the deal profile pins the person's own wording pack, $PINNED_WORDING: left as it is; this install's is $1" ;; esac } # ---- wording-pin end WORDING=$(jstr "$out" capsulectl_wording.path) && [ -n "$WORDING" ] \ || die "capsulectl actionstate presentation install --json named no capsulectl_wording.path: $out" pin_wording "$WORDING" move_out actionstate-rules cp -R "$W/rules-x/actionstate-rules" "$SKILLS_DIR/actionstate-rules" || die "cannot copy the skill to $SKILLS_DIR/actionstate-rules" changed "the rules instructions were added to $SKILLS_DIR/actionstate-rules" exactly_one actionstate-rules RULES_RC=$EO_RC RULES_PATH=$EO_PATH # The plugin's own install check (page step 12, after Setup): this plugin, this skill, exactly one copy of it, # the logged actionstate-rules profile, and the page's pack. as_doctor() { out=$(capsulectl actionstate doctor --install-check --skills-dir "$SKILLS_DIR" --expect-version "$PLUGIN_VERSION" \ --expect-skill-sha256 "$RULES_SKILL_MD_SHA" 2>&1) && AS_DOCTOR_RC=0 || AS_DOCTOR_RC=$? AS_DOCTOR_OUT=$(printf '%s\n' "$out" | grep '^{' | head -n 1) [ "$AS_DOCTOR_RC" -eq 0 ] || die "install incomplete: capsulectl actionstate doctor --install-check exited $AS_DOCTOR_RC: $out" say "capsulectl actionstate doctor --install-check: exit 0" # The pack the plugin evaluates is the one the page names, and the one the rules checker was pinned to. [ "$(jstr "$AS_DOCTOR_OUT" install_check.pack.pack_id)" = "$PACK_ID" ] \ && [ "$(jstr "$AS_DOCTOR_OUT" install_check.pack.definition_digest)" = "$PACK_DIGEST" ] \ || die "install incomplete: the plugin's pack is not $PACK_ID $PACK_DIGEST, as the page says: $AS_DOCTOR_OUT" # The pin covers a buyer's deals and a seller's (rules-checker-role-not-covered otherwise, which is an issue on rc16). [ "$(jget "$AS_DOCTOR_OUT" install_check.rules_checker.covers)" = '["buyer","seller"]' ] \ || die "install incomplete: the rules checker pin does not cover both buyer and seller deals: $AS_DOCTOR_OUT" } # The plugin's own install check runs last, once the actionstate-rules profile it requires exists. # The rules checker (page step 11; the actionstate-rules skill, "The rules checker your purchases run"): at first install # the install pins this plugin, run as check --emit external-check-result/v0, to a set of two digests: the installed # rules' from rules list --json (a buyer's deal) and the seller's rules the plugin carries (a seller's deal, Muse #141), # and shows it. An existing pin is changed only as ruled (u190, amended by u287): this install's pin # goes on; the pin an earlier Action State install wrote (the same Command, an older plugin SHA-256 or rules digest) is # re-pinned only with --repin-earlier-install, the person's yes, and otherwise stops the install unchanged; any other pin # (another Command) is the person's own and stops the install, naming both. human "Your rules couldn't be connected to your purchases. The install stopped at step 11." RL=$(capsulectl actionstate rules list --json) || die "capsulectl actionstate rules list --json failed" D=$(jstr "$RL" definition_digest) || D= [ "$D" = "$PACK_DIGEST" ] && [ "$(jstr "$RL" pack_id)" = "$PACK_ID" ] \ || die "the installed rules are $(jstr "$RL" pack_id || echo none) $D, not $PACK_ID $PACK_DIGEST as the page says" PIN_CMD="[\"$HOME/.local/lib/capsulectl/plugins/capsulectl-actionstate\",\"check\",\"--emit\",\"external-check-result/v0\"]" PIN_DIGESTS="[\"$D\",\"$SELLER_PACK_DIGEST\"]" show=$(capsulectl profile show deal) || die "capsulectl profile show deal failed" pinned() { printf '%s' "$1" | grep -qF "\"RulesChecker\":{\"Command\":$PIN_CMD" \ && [ "$(jstr "$1" RulesChecker.SHA256)" = "7c2c1b16c4ef7ed74f6779b16bd41857b29f2d28db2844c3195ca6837cb6417e" ] && [ "$(jget "$1" RulesChecker.DefinitionDigests)" = "$PIN_DIGESTS" ]; } # pin_now STATE: the pin the skill shows, written with the plugin root in full, then read back. pin_now() { printf '{"command":%s,"definition_digests":%s}\n' "$PIN_CMD" "$PIN_DIGESTS" > "$W/rules-checker.json" capsulectl profile update --profile deal --rules-checker "$W/rules-checker.json" >/dev/null \ || die "capsulectl profile update --rules-checker failed" changed "your install pinned the Action State checker to your rules ($PACK_ID, and $SELLER_PACK_ID when you sell) on your receipts" show=$(capsulectl profile show deal) || die "capsulectl profile show deal failed" pinned "$show" || die "the pin capsulectl recorded is not the one written: $(printf '%s' "$show" | grep -o '"RulesChecker":{[^}]*}')" RC_STATE=$1 } if printf '%s' "$show" | grep -qF '"RulesChecker":{"Command":null'; then pin_now pinned elif pinned "$show"; then RC_STATE=already_pinned elif printf '%s' "$show" | grep -qF "\"RulesChecker\":{\"Command\":$PIN_CMD,"; then # An earlier Action State install's pin: the same Command, an older plugin SHA-256 or other rules digests (one digest # alone, as every install before rc16 pinned, included). if [ "$REPIN" = yes ]; then pin_now repinned else human "Your purchases are still connected to the rules checker from your earlier Action State install, so they are not checked against your rules yet; nothing about that was changed." die "earlier-install pin: the deal profile pins this install's checker Command with an older plugin or rules ($(printf '%s' "$show" | grep -o '"RulesChecker":{[^}]*}')); the earlier install was plugin ${OLD_PLUGIN_VERSION:-unknown}, rules ${OLD_PACK_ID:-unknown}; this install is plugin $PLUGIN_VERSION, rules $PACK_ID. Nothing about the pin was changed; --repin-earlier-install re-pins it" fi else die "the deal profile already pins another rules checker: $(printf '%s' "$show" | grep -o '"RulesChecker":{[^}]*}'); this install would pin {\"command\":$PIN_CMD,\"definition_digests\":$PIN_DIGESTS} (plugin sha256 7c2c1b16c4ef7ed74f6779b16bd41857b29f2d28db2844c3195ca6837cb6417e). Nothing was changed" fi RC_SHA=$(jstr "$show" RulesChecker.SHA256) || RC_SHA= RC_DIGESTS=$(jget "$show" RulesChecker.DefinitionDigests) || RC_DIGESTS= case $RC_DIGESTS in *"\"$PACK_DIGEST\""*) RC_DIGEST=$PACK_DIGEST ;; *) RC_DIGEST= ;; esac say "rules checker on the deal profile ($RC_STATE): $PACK_ID and $SELLER_PACK_ID, digests $RC_DIGESTS, plugin sha256 $RC_SHA" # ==== 12. Show the person their rules (Setup, then compare) ============================================ begin 12 rules human "Your rules record couldn't be set up. The install stopped at step 12." profiles=$(capsulectl profile list) || die "capsulectl profile list failed" if printf '%s' "$profiles" | grep -q "\"profiles\":\\[[^]]*\"$RULES_PROFILE\""; then RULES_PROFILE_CREATED=false # An existing actionstate-rules is used as it is only if it can hold the report; otherwise it is left as it is. show=$(capsulectl profile show "$RULES_PROFILE") || die "capsulectl profile show $RULES_PROFILE failed" if ! logged "$show"; then human "Your rules record on this computer can't be used by this version. It was left as it is." die "the existing $RULES_PROFILE profile is ${PT:-of no type} with log_id \"$PLG\" (and needs a checkpoint signing key); the report needs a SQLite profile with a log and a checkpoint key (the actionstate-rules skill's Setup). It was not changed" fi op=$(jstr "$show" Operator) || op= if [ "$op" = "$RULES_PROFILE" ]; then say "an $RULES_PROFILE profile exists (sqlite, log $PLG, a checkpoint key, operator $RULES_PROFILE): used as it is" else # u170: an earlier record names someone (or nothing) as operator. A new operator alone would leave that name in a # Standard copy, which carries the log's earlier records, so the profile moves to a fresh record file with the # fixed label; the old file, its key and its records are left exactly as they are. NEW_RECORD=$HOME/.local/share/actionstate/rules-$(date +%Y-%m-%d).db i=1 while [ -e "$NEW_RECORD" ]; do i=$((i + 1)); NEW_RECORD=$HOME/.local/share/actionstate/rules-$(date +%Y-%m-%d)-$i.db; done capsulectl profile update --profile "$RULES_PROFILE" --operator "$RULES_PROFILE" --sqlite-path "$NEW_RECORD" >/dev/null \ || die "capsulectl profile update --profile $RULES_PROFILE --operator $RULES_PROFILE --sqlite-path $NEW_RECORD failed" changed "your rules record was started again in $NEW_RECORD, labelled $RULES_PROFILE; the earlier record was left as it is" capsulectl store init --profile "$RULES_PROFILE" >/dev/null || die "capsulectl store init failed for $NEW_RECORD" say "the $RULES_PROFILE profile named another operator: moved to a fresh record $NEW_RECORD with operator $RULES_PROFILE; the earlier record file is untouched" fi else # The skill's Setup, with the fixed operator label (u169: no personal name, as deal's profile is "deal"). A seed # an earlier install made is used as it is, never replaced (key generate refuses to), so an # earlier actionstate-local profile that signs with it keeps working. That profile itself is never read or changed. SEED=$HOME/.config/actionstate/seed # shellcheck disable=SC2174 # the skill's Setup line, as written: both folders 0700 mkdir -p -m 0700 "$HOME/.config/actionstate" "$HOME/.local/share/actionstate" || die "cannot create ~/.config/actionstate and ~/.local/share/actionstate" if [ -e "$SEED" ]; then out=$(capsulectl key show-public "$SEED") || die "$SEED exists and capsulectl key show-public cannot read it; it was not changed" SEED_REUSED=true say "the existing seed $SEED is used as it is (an earlier install made it)" else out=$(capsulectl key generate --output "$SEED") || die "capsulectl key generate failed" SEED_REUSED=false changed "a key for your rules record was created in $HOME/.config/actionstate" fi pub=$(jstr "$out" public_key) || die "no public_key for $SEED" capsulectl profile create --name "$RULES_PROFILE" --type sqlite --sqlite-path "$HOME/.local/share/actionstate/rules.db" \ --operator "$RULES_PROFILE" --signing-key-file "$SEED" --trusted-key "$pub" \ --log-id "$RULES_PROFILE" --checkpoint-signing-key-file "$SEED" --checkpoint-trusted-key "$pub" >/dev/null || die "capsulectl profile create failed" RULES_PROFILE_CREATED=true changed "a rules record labelled $RULES_PROFILE was set up in $HOME/.local/share/actionstate" capsulectl store init --profile "$RULES_PROFILE" >/dev/null || die "capsulectl store init failed" say "created the $RULES_PROFILE profile (sqlite, log $RULES_PROFILE), operator \"$RULES_PROFILE\"" fi show=$(capsulectl profile show "$RULES_PROFILE") || die "capsulectl profile show $RULES_PROFILE failed" RECORDED_OPERATOR=$(jstr "$show" Operator) || die "the $RULES_PROFILE profile has no Operator" [ "$RECORDED_OPERATOR" = "$RULES_PROFILE" ] || die "the $RULES_PROFILE profile's operator is not $RULES_PROFILE after Setup" if [ -n "$ENVELOPE" ]; then human "Your rules table couldn't be made from your agent's description of what it can do." # sealed/, as on the page; a run that finds it already there (a re-run) seals into sealed-2, sealed-3, ... SEALED=sealed i=1 while [ -e "$ENVDIR/$SEALED" ]; do i=$((i + 1)); SEALED=sealed-$i; done out=$(cd "$ENVDIR" && capsulectl actionstate rules compare --envelope "$ENVELOPE" --comparison "$COMPARISON" \ --profile "$RULES_PROFILE" --out-dir "$SEALED" 2>&1) || die "capsulectl actionstate rules compare refused: $out" # The table is for the person, at the end; here only what the plugin printed after it. printf '%s\n' "$out" | sed -n '/^Sealed record/,$p' >&2 TABLE=$ENVDIR/$SEALED/table.md [ -s "$TABLE" ] || die "the plugin wrote no $TABLE" changed "your rules table was sealed in $ENVDIR/$SEALED" VERIFY_LINE=$(printf '%s\n' "$out" | grep '^Check it:' | head -n 1) || VERIFY_LINE= say "the plugin wrote $TABLE ($(sha "$TABLE"))" # The report (the skill's Step 3): a page that checks its digests and completeness when opened, in report/ beside # sealed/ (report-2 beside sealed-2, ...). capsulectl verify --bundle checks its signatures: it must say VALID before # the person is pointed at it. human "Your rules table was made, but the page that shows it couldn't be. The install stopped at step 12." REPORT=report${SEALED#sealed} [ ! -e "$ENVDIR/$REPORT" ] || die "$ENVDIR/$REPORT already exists; it was left as it is" out=$(cd "$ENVDIR" && capsulectl actionstate report --profile "$RULES_PROFILE" --from "$SEALED" --out-dir "$REPORT" --receipts 2>&1) \ || die "capsulectl actionstate report refused: $out" printf '%s\n' "$out" >&2 REPORT_HTML=$ENVDIR/$REPORT/report.html REPORT_BUNDLE=$ENVDIR/$REPORT/report.json READY_MD=$ENVDIR/$REPORT/ready.md [ -s "$REPORT_HTML" ] && [ -s "$REPORT_BUNDLE" ] && [ -s "$READY_MD" ] || die "the plugin wrote no $REPORT_HTML, $REPORT_BUNDLE or $READY_MD" changed "your report was written to $ENVDIR/$REPORT" REPORT_ROOT=$(jstr "$(cat "$REPORT_BUNDLE")" root) || REPORT_ROOT= (cd "$ENVDIR" && capsulectl verify --bundle "$REPORT/report.json") > "$W/verify-bundle.json" 2> "$W/verify-bundle.err" \ && REPORT_VERIFY_RC=0 || REPORT_VERIFY_RC=$? REPORT_VERDICT=$(jstr "$(cat "$W/verify-bundle.json")" verdict) || REPORT_VERDICT= [ "$REPORT_VERIFY_RC" -eq 0 ] && [ "$REPORT_VERDICT" = VALID ] \ || die "capsulectl verify --bundle $REPORT/report.json: exit $REPORT_VERIFY_RC, verdict ${REPORT_VERDICT:-none}, not VALID" say "capsulectl verify --bundle $REPORT/report.json: VALID (exit 0); the page is $REPORT_HTML" fi # ==== 10, last: both doctors, once each, before the table ================================================ # The core check (page step 10) and the plugin's own (page step 12: this plugin, this skill, exactly one copy, the # logged profile, the pack). begin 10 install-check human "The install is incomplete: its final check found a problem. The install stopped at step 10." core_doctor as_doctor write_summary installed || die "cannot write $SUMMARY" say "the evidence is in $SUMMARY" if [ -z "$ENVELOPE" ]; then echo "install.sh: installed; no rules table (no --envelope). Summary: $SUMMARY" exit 0 fi # The person's short message, as the plugin wrote it: the limits, what the agent can do and what it asks first. The # rules table is on the rules page the agent attaches in a message of its own (the report printed its path above). person < "$READY_MD"