#!/bin/sh # Action State Receipts, install script rc14-p0.0.9-s0.0.8 # capsulectl v0.1.0-rc14 (commit 49651e2f156e370208dec605ded653c5f0a9a4bf), the deal skill, the Action State plugin 0.0.9 and its # actionstate-rules skill 0.0.8. Linux x86_64 only: anywhere else it installs nothing. # # sh install.sh --operator NAME --skills-dir SKILLS_DIR [--envelope envelope.json [--comparison comparison.json]] # # It runs the steps of https://actionstate.ai/install, in the page's order and with the page's step ids # (each "## N." heading on the page 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. For you, the agent, 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"). For the person, # on stdout, one block between "==== FOR THE PERSON ====" and "==== END ====": the rules table as the # plugin wrote it (sealed/table.md), or on failure one sentence and what was changed. Relay that block # verbatim and only that block; never summarise the step lines to the person. What the install verified # is written to ~/.local/share/actionstate/install-summary.json (the evidence; read it back for the report). # It first reads https://actionstate.ai/install.md, the page you follow, and records which page that was # (its SHA-256 and ETag) in the summary. If that page names a different install script than this one, it # warns you on stderr and goes on: one of the two is stale, so re-read the page before you report. # # --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, a page that checks itself when opened, into report/ beside it. # 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-rc14 COMMIT=49651e2f156e370208dec605ded653c5f0a9a4bf REPO=action-state-group/capsule-cli REL=https://github.com/$REPO/releases/download/$TAG SITE=https://actionstate.ai PAGE=$SITE/install.md WITNESS=https://witness.agentactioncapsule.org WITNESS_KEY=39bb654c9dc0afe1c0edef0deffaa69099b8518836c9ba26e0491535840f96b5 PLUGIN_VERSION=0.0.9 PLUGIN_FILE=capsulectl-actionstate-linux-amd64-v$PLUGIN_VERSION SKILL_ARCHIVE=actionstate-rules-skill-v0.0.8.tar.gz PACK_ID=asg/everyday/0.3.4 PACK_DIGEST=cd98aaec5acf8cea86f91fc21dd7df6b36a67c7b55340489b66e6ce88b85117b RULES_PROFILE=actionstate-rules 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 "==== FOR THE PERSON ====" 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=c8d1ea26cb042319ac6ad562c3dfac7ecc23687bd5c8b60a938904c61f1be627 say "$os/$arch = $PLATFORM" usage() { echo "usage: sh install.sh --operator NAME --skills-dir SKILLS_DIR [--envelope envelope.json [--comparison comparison.json]]" >&2 echo " NAME the name the person gave for the operator of their rules record; never choose one" >&2 echo " SKILLS_DIR the folder your agent host loads skills from" >&2 exit 2 } OPERATOR='' SKILLS_DIR='' ENVELOPE='' COMPARISON='' while [ $# -gt 0 ]; do case $1 in --operator) [ $# -ge 2 ] || usage; OPERATOR=$2; shift 2 ;; --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 ;; -h|--help) usage ;; *) echo "install.sh: unknown argument: $1" >&2; usage ;; esac done [ -n "$OPERATOR" ] || refuse "--operator is empty. Ask the person what name to record as the operator of their rules record, and pass it; never choose one for them. If they would rather not give one, follow https://actionstate.ai/install by hand." \ "Your agent needs the name to record as the operator of your rules record before it can install; tell it the name you want, or ask it to follow the steps on actionstate.ai/install by hand." [ -n "$SKILLS_DIR" ] || refuse "--skills-dir is empty (the folder your agent host loads skills from)" \ "Your agent did not say where it keeps its skills, so nothing could be installed; ask it to run the install again with that folder." [ -n "$ENVELOPE" ] || [ -z "$COMPARISON" ] || refuse "--comparison needs --envelope" \ "Your agent started the install the wrong way; ask it to run it again as actionstate.ai/install shows." 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; move it out by hand" 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='' PLUGIN_LS='' RULES_RC='' RULES_PATH='' RECORDED_OPERATOR='' RULES_PROFILE_CREATED='' VERIFY_LINE='' TABLE='' REPORT_HTML='' REPORT_BUNDLE='' REPORT_ROOT='' REPORT_VERIFY_RC='' REPORT_VERDICT='' SEED_REUSED='' RC_SHA='' RC_DIGEST='' RC_STATE='' 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 has a build attestation. 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.8\",\"sha256\":\"945943794420260052b1ae77bc5701ff4ae55e9c58939c15f495a752c59d6a7d\",$NOATT,\"path\":$(jn "$RULES_PATH")}" cat > "$SUMMARY.tmp" </dev/null 2>&1 || die "$c is required and was not found" done human "Your agent pointed the install at a skills folder that doesn't exist; ask it to run the install again with the folder it loads skills from." [ -d "$SKILLS_DIR" ] || die "SKILLS_DIR $SKILLS_DIR is not a folder: pass the folder your agent host loads skills from" SKILLS_DIR=$(cd "$SKILLS_DIR" && pwd) if [ -n "$ENVELOPE" ]; then human "Your agent's description of what it can do for you wasn't ready, so nothing was installed; ask it to write it again as actionstate.ai/install says, then run the install again." [ -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); write the file again with your file tool, never through chat"; 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, so nothing was changed; ask your agent to tell Action State." 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). Stop and tell the person; do not update or replace it" 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 "Your agent couldn't download the install files; check the internet connection and that your agent may reach github.com and actionstate.ai, then run the install again." # The page this install follows (u127): its SHA-256 and ETag go in the summary, so which page an install # followed is in the record. A page naming another script is a warning for the agent, never a stop. if curl -fsSL --proto '=https' --proto-redir '=https' --retry 2 -H 'Cache-Control: no-cache' -D "$W/page.headers" -o "$W/page.md" "$PAGE"; then PAGE_SHA=$(sha "$W/page.md") PAGE_ETAG=$(tr -d '\r' < "$W/page.headers" | awk 'tolower($1) == "etag:" { sub(/^[^:]*:[ \t]*/, ""); e = $0 } END { print e }') named=$(grep -o '/install/[0-9A-Za-z.-]*/install\.sh' "$W/page.md" | sed 's#^/install/##; s#/install\.sh$##' | sort -u | tr '\n' ' ' | sed 's/ $//') if [ "$named" = rc14-p0.0.9-s0.0.8 ]; then say "read $PAGE (sha256 $PAGE_SHA, etag ${PAGE_ETAG:-none}): it names this script, rc14-p0.0.9-s0.0.8" else say "WARNING: $PAGE names install script ${named:-none}, but this is rc14-p0.0.9-s0.0.8: the page you followed or this script is stale. Going on; before you report, re-read $PAGE and run the script it names if it differs" fi else say "WARNING: could not read $PAGE, so the summary records no page; 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, so nothing was installed. Please try again later, and tell Action State if it happens again." check "$W" "$BIN" "$BIN_SHA" check "$W" "$BUNDLE" ac4828cedc40fdbb5a4dc3ac3aaaf99a24fd92b0872de1afa9537dece5368c31 check "$W" deal-src.tar.gz febc716659b2601579a4f7e7d8cf4d4795cdfce1a3222554dc3eac8d287e3800 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")" = f27aacc5c255293fada8f15ad5c62dbc59d9ed427f8c319e9282c93fca88226c ] || 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" 71cd6b7963f3b5c70b9b0f13a3499097dcaeccdc8f7ec3f4d42d43c822e63a79 "$PLUGIN_FILE.served.sha256" PLUGIN_SHA=$(sha "$W/$PLUGIN_FILE") check "$W" "$SKILL_ARCHIVE" 945943794420260052b1ae77bc5701ff4ae55e9c58939c15f495a752c59d6a7d 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" = cf1f55a297969ddd665dea68aca8a0e6a289a246dd2b7f3fd5fac1b3401b9595 ] || die "actionstate-rules/SKILL.md in $SKILL_ARCHIVE is $RULES_SKILL_MD_SHA, not the page's cf1f55a297969ddd665dea68aca8a0e6a289a246dd2b7f3fd5fac1b3401b9595" 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, so nothing was installed. Please tell Action State." 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; ask it to follow the steps on actionstate.ai/install by hand, from 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 you cannot write to; replace it there, or remove it, and run again" ;; *) DEST=$HOME/.local/bin case ":$PATH:" in *":$DEST:"*) ;; *) die "$DEST is not on PATH: add it (PATH=\"\$HOME/.local/bin:\$PATH\") and run again" ;; esac mkdir -p "$DEST" || die "cannot create $DEST" ;; esac 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.$$"; die "cannot install $DEST/capsulectl" fi changed "the Action State program was installed as $DEST/capsulectl" hash -r 2>/dev/null || true [ "$(command -v capsulectl)" = "$DEST/capsulectl" ] || die "another capsulectl comes first on PATH: $(command -v capsulectl)" VERSION_LINE=$(capsulectl --version) || die "capsulectl --version failed" [ "$VERSION_LINE" = "capsulectl $TAG (commit $COMMIT)" ] || die "capsulectl --version printed \"$VERSION_LINE\", not \"capsulectl $TAG (commit $COMMIT)\"" say "installed $DEST/capsulectl: $VERSION_LINE" # ==== 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; ask it to follow the steps on actionstate.ai/install by hand, from 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; ask your agent to follow the steps on actionstate.ai/install by hand, from 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" >/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" 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" # ==== 7. Reach the witness ============================================================================== begin 07 witness human "Your computer couldn't reach the public record at witness.agentactioncapsule.org. If your agent asks to allow that site, choose \"Always allow this site\", then run the install again." 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); consent: $(jstr "$out" witness.consent || echo none)" say "witness ok: $WITNESS_BLOCK" # ==== 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 a network grant (\"Always allow this site\" for agentactioncapsule.org): $TICK" ;; 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: $TICK" done fi say "first tick: $TICK_STATE; cadence log $CADENCE_LOG; $TICK" # ==== 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 f27aacc5c255293fada8f15ad5c62dbc59d9ed427f8c319e9282c93fca88226c) && 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; ask your agent to follow the steps on actionstate.ai/install by hand, from 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" PLUGIN_LS=$(capsulectl plugin ls) || die "capsulectl plugin ls failed" printf '%s\n' "$PLUGIN_LS" | grep -q "\"path\":\"$PLUGINS/capsulectl-actionstate\",\"plugin_api\":\"[^\"]*\"[^}]*\"version\":\"$PLUGIN_VERSION (commit " \ || die "FAIL: capsulectl did not load the plugin: $PLUGIN_LS" say "plugin $PLUGIN_VERSION listed by capsulectl plugin ls" 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: $AS_DOCTOR_OUT" # 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 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 the installed rules' digest from # rules list --json, and shows it. An existing pin is never changed (PM ruling u190): this install's pin goes on; any other # pin stops the install, naming both. human "Your rules couldn't be connected to your purchases; ask your agent to follow the steps on actionstate.ai/install by hand, from 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\"]" 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)" = "71cd6b7963f3b5c70b9b0f13a3499097dcaeccdc8f7ec3f4d42d43c822e63a79" ] && [ "$(jstr "$1" RulesChecker.DefinitionDigest)" = "$PACK_DIGEST" ]; } if printf '%s' "$show" | grep -qF '"RulesChecker":{"Command":null'; then printf '{"command":%s,"definition_digest":"%s"}\n' "$PIN_CMD" "$D" > "$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) 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=pinned elif pinned "$show"; then RC_STATE=already_pinned else die "the deal profile already pins another rules checker: $(printf '%s' "$show" | grep -o '"RulesChecker":{[^}]*}'); this install would pin {\"command\":$PIN_CMD,\"definition_digest\":\"$PACK_DIGEST\"} (plugin sha256 71cd6b7963f3b5c70b9b0f13a3499097dcaeccdc8f7ec3f4d42d43c822e63a79). Nothing was changed: a changed pin is the person's decision. Tell the person both" fi RC_SHA=$(jstr "$show" RulesChecker.SHA256) || RC_SHA= RC_DIGEST=$(jstr "$show" RulesChecker.DefinitionDigest) || RC_DIGEST= say "rules checker on the deal profile ($RC_STATE): $PACK_ID $RC_DIGEST, plugin sha256 $RC_SHA" # ==== 12. Show the person their rules (Setup with the operator, then compare) =========================== begin 12 rules human "Your rules record couldn't be set up; ask your agent to follow the steps on actionstate.ai/install by hand, from 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; ask your agent to tell Action State." 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. Stop and tell the person; do not update or replace it" fi say "an $RULES_PROFILE profile exists (sqlite, log $PLG, a checkpoint key): used as it is" else # The skill's Setup. 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 "$OPERATOR" --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 naming \"$OPERATOR\" as its operator 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 \"$OPERATOR\"" 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" say "Operator on the sealed record: $RECORDED_OPERATOR" if [ -n "$ENVELOPE" ]; then human "Your rules table couldn't be made from your agent's description of what it can do; ask your agent to check that description against actionstate.ai/install, then run the install again." # 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 itself when opened, in report/ beside sealed/ (report-2 beside # sealed-2, ...). It must verify VALID before the person is pointed at it. human "Your rules table was made, but the page that shows it couldn't be; ask your agent to follow the steps on actionstate.ai/install by hand, from step 12." REPORT=report${SEALED#sealed} [ ! -e "$ENVDIR/$REPORT" ] || die "$ENVDIR/$REPORT already exists; move it aside and run again" out=$(cd "$ENVDIR" && capsulectl actionstate report --profile "$RULES_PROFILE" --from "$SEALED" --out-dir "$REPORT" 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 [ -s "$REPORT_HTML" ] && [ -s "$REPORT_BUNDLE" ] || die "the plugin wrote no $REPORT_HTML or $REPORT_BUNDLE" 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. Ask your agent to follow the steps on actionstate.ai/install by hand, from 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 --envelope, so no rules table yet: do step 12 of https://actionstate.ai/install by hand. Summary: $SUMMARY" exit 0 fi { cat "$TABLE" printf '\nYour report: %s\nOpen it in any browser: it checks itself when opened, with no internet connection.\n' "$REPORT_HTML" printf 'Your install pinned the Action State checker to your rules.\n'; } | person