diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..96302ed --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,87 @@ +{ + "hooks": { + "Notification": [ + { + "hooks": [ + { + "command": "sh \"${CLAUDE_PROJECT_DIR:-.}\"/.dira/hook.sh claude", + "type": "command" + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "command": "sh \"${CLAUDE_PROJECT_DIR:-.}\"/.dira/hook.sh claude", + "type": "command" + } + ], + "matcher": "*" + } + ], + "PreToolUse": [ + { + "hooks": [ + { + "command": "sh \"${CLAUDE_PROJECT_DIR:-.}\"/.dira/hook.sh claude", + "type": "command" + } + ], + "matcher": "*" + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "command": "sh \"${CLAUDE_PROJECT_DIR:-.}\"/.dira/hook.sh claude", + "type": "command" + } + ] + } + ], + "SessionStart": [ + { + "hooks": [ + { + "command": "sh \"${CLAUDE_PROJECT_DIR:-.}\"/.dira/bootstrap.sh claude", + "timeout": 300, + "type": "command" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "command": "sh \"${CLAUDE_PROJECT_DIR:-.}\"/.dira/hook.sh claude", + "type": "command" + } + ] + } + ], + "SubagentStop": [ + { + "hooks": [ + { + "command": "sh \"${CLAUDE_PROJECT_DIR:-.}\"/.dira/hook.sh claude", + "type": "command" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "command": "sh \"${CLAUDE_PROJECT_DIR:-.}\"/.dira/hook.sh claude", + "type": "command" + } + ] + } + ] + } +} diff --git a/.dira/.gitattributes b/.dira/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.dira/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/.dira/bootstrap.sh b/.dira/bootstrap.sh new file mode 100755 index 0000000..3757f87 --- /dev/null +++ b/.dira/bootstrap.sh @@ -0,0 +1,346 @@ +#!/bin/sh +# .dira/bootstrap.sh — teleport dira into a cloud agent VM, generated by +# `dira cloud init`. +# +# Wired as the SessionStart hook command (`sh .dira/bootstrap.sh `). +# On a cloud runtime it installs the pinned dira release, starts the daemon, +# claims a runner-token device when DIRA_RUNNER_TOKEN is set, then forwards +# the SessionStart event itself through hook.sh. On an ordinary machine the +# provisioning is skipped and it goes straight to the forward, so the same +# committed config is correct everywhere. Exit status is always 0 (the +# forward carries `dira hook`'s always-0 contract; every provisioning failure +# degrades to "not instrumented", never to a broken session). +# +# Explicit-mode timeout budget: SessionStart is a hook, bounded by Claude +# Code's 600s default hook timeout. Worst case inside this script is roughly +# a curl transfer or two (each capped at --max-time 120) plus a bounded +# flock wait (up to 120s) plus `dira daemon start`'s own ~10s readiness poll +# — call it 4 minutes, comfortably under the 600s default. +# +# Two non-hook modes, for runtimes that provision from their own environment +# config rather than from a session hook (Cursor cloud agents run +# `.cursor/environment.json`'s `install` at build time and `start` on every +# machine boot, and have no session-start hook to hang this off): +# +# sh .dira/bootstrap.sh --install-only download + install only, +# unconditionally. Build/snapshot +# phase; result is cached on disk. +# sh .dira/bootstrap.sh --provision-only install, start the daemon and +# claim a runner device, then exit +# without forwarding any event. +# Boot phase. Gated on being in a +# cloud runtime, like the hook path. +# +# Do not hand-edit; re-run `dira cloud init` to regenerate. + +mode="hook" +case "${1:-}" in + --install-only) + mode="install" + shift + ;; + --provision-only) + mode="provision" + shift + ;; +esac +harness="${1:-claude}" +dir="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)" + +# Pinned at generation time; override with DIRA_VERSION for a one-off. +version="${DIRA_VERSION:-0.6.0}" +# The literal pin, unaffected by a DIRA_VERSION override — used below to +# decide whether the embedded release digests still apply to what we're +# about to download. +pinned_version="0.6.0" +# Release digests embedded at generation time (empty when unpinned — the +# bootstrap then falls back to the release's own .sha256 asset). +expected_x86_64="" +expected_aarch64="" +repo="dodi-smart/dirahq-cli" + +log() { + if [ -n "${DIRA_BOOTSTRAP_DEBUG:-}" ]; then + echo "dira-bootstrap: $*" >&2 + fi +} + +# Cloud detection mirrors dira's own (`dira_core::runtime::detect`): Claude +# Code's documented marker, an explicit DIRA_RUNTIME (how a Cursor cloud +# environment declares itself), or a force flag for testing. Both +# CLAUDE_CODE_REMOTE and DIRA_RUNTIME are trimmed and blank-filtered the same +# way `runtime::detect` treats them, so a whitespace-only value doesn't +# falsely read as "set". +in_cloud() { + trimmed_remote="$(printf '%s' "${CLAUDE_CODE_REMOTE:-}" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + trimmed_runtime="$(printf '%s' "${DIRA_RUNTIME:-}" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + [ "$trimmed_remote" = "true" ] || + [ -n "$trimmed_runtime" ] || + [ -n "${DIRA_BOOTSTRAP_FORCE:-}" ] +} + +# True if $1 (a command name) resolves either on PATH or as the well-known +# per-user install location this script itself installs into. +_resolved() { + command -v "$1" >/dev/null 2>&1 || [ -x "$HOME/.local/bin/$1" ] +} + +# sha256 of $1, trying whichever of the three common tools is on PATH — +# ported from install.sh's _sha256_hex. +_sha256_hex() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + openssl dgst -sha256 "$1" | awk '{print $NF}' + fi +} + +# Pick the line naming $2 (a filename) out of the .sha256 file at $1 and +# print its digest — ported from install.sh's _extract_expected_digest. The +# .sha256 asset holds one line per file built in that release job, so the +# first line is not necessarily ours. +_extract_expected_digest() { + awk -v want="$2" ' + { + fname = $2 + sub(/^\*/, "", fname) + if (tolower(fname) == tolower(want)) { print $1; found = 1; exit } + } + END { if (!found) exit 1 } + ' "$1" +} + +# Download the pinned release tarball for this platform, verify its sha256, +# and install dira + dirad. Idempotent: both binaries already resolving +# short-circuits. GitHub release assets are on cloud runtimes' default +# network allowlists. +# +# Checksum source: when this script was generated with an embedded digest +# for the selected target *and* DIRA_VERSION hasn't overridden the pin (an +# override may name a release this generation never fetched a digest for), +# the tarball is verified against that embedded value and the release's +# .sha256 asset is never fetched at all. Otherwise the .sha256 asset is +# downloaded alongside the tarball and the matching line is selected by +# filename. Either way a digest mismatch is a hard failure — nothing is +# extracted or installed. +install_dira() { + if _resolved dira && _resolved dirad; then + return 0 + fi + expected="" + case "$(uname -s)-$(uname -m)" in + Linux-x86_64) + target="x86_64-unknown-linux-musl" + expected="$expected_x86_64" + ;; + Linux-aarch64 | Linux-arm64) + target="aarch64-unknown-linux-musl" + expected="$expected_aarch64" + ;; + *) + log "unsupported platform $(uname -s)-$(uname -m); skipping install" + return 1 + ;; + esac + base="https://github.com/$repo/releases/download/v$version" + tarball="dira-$version-$target.tar.gz" + shafile="dira-$version-$target.sha256" + tmp="$(mktemp -d)" || return 1 + log "downloading $base/$tarball" + if [ -n "$expected" ] && [ "$version" = "$pinned_version" ]; then + if ! curl -fsSL --retry 3 --proto '=https' --tlsv1.2 --connect-timeout 10 --max-time 120 \ + -o "$tmp/$tarball" "$base/$tarball"; then + log "download failed: $base/$tarball" + rm -rf "$tmp" + return 1 + fi + else + if ! curl -fsSL --retry 3 --proto '=https' --tlsv1.2 --connect-timeout 10 --max-time 120 \ + -o "$tmp/$tarball" "$base/$tarball"; then + log "download failed: $base/$tarball" + rm -rf "$tmp" + return 1 + fi + if ! curl -fsSL --retry 3 --proto '=https' --tlsv1.2 --connect-timeout 10 --max-time 120 \ + -o "$tmp/$shafile" "$base/$shafile"; then + log "download failed: $base/$shafile" + rm -rf "$tmp" + return 1 + fi + expected="$(_extract_expected_digest "$tmp/$shafile" "$tarball")" || { + log "checksum verification FAILED for $tarball: no entry for it in $shafile" + rm -rf "$tmp" + return 1 + } + fi + actual="$(_sha256_hex "$tmp/$tarball")" + if [ "$(printf '%s' "$expected" | tr 'A-F' 'a-f')" != "$(printf '%s' "$actual" | tr 'A-F' 'a-f')" ]; then + log "checksum verification FAILED for $tarball: expected $expected, got $actual" + rm -rf "$tmp" + return 1 + fi + if ! tar -xzf "$tmp/$tarball" -C "$tmp"; then + rm -rf "$tmp" + return 1 + fi + destdir="/usr/local/bin" + if [ ! -w "$destdir" ]; then + destdir="$HOME/.local/bin" + fi + mkdir -p "$destdir" + for b in dira dirad; do + src="$(find "$tmp" -type f -name "$b" | head -n 1)" + if [ -n "$src" ]; then + # rename-only replace (D-0003) does not apply here: this branch runs + # whenever dira and/or dirad is missing — the short-circuit above skips + # only when BOTH already resolve — and in either invocation mode + # (hook-driven provision, or --install-only's build phase) nothing has + # spawned $destdir/$b as a running process yet at this point in the + # script, so `install` has no live process underneath it to clobber. + install -m 0755 "$src" "$destdir/$b" + fi + done + rm -rf "$tmp" + [ -x "$destdir/dira" ] && [ -x "$destdir/dirad" ] +} + +# Install, start the daemon, and (once) claim a runner-token device. Runs +# with the flock held so two racing SessionStart hooks provision once; the +# daemon itself is single-instance either way. Every `dira` call gets +# /dev/null 2>&1 || return 0 + if ! dira daemon status /dev/null 2>&1; then + # `dira daemon start` polls readiness itself (~10s) and only reports + # success once the socket answers, so the SessionStart forward below + # isn't dropped — no extra wait loop needed here. + dira daemon start /dev/null 2>&1 || log "daemon start failed" + fi + if [ -n "${DIRA_RUNNER_TOKEN:-}" ]; then + # No token on argv: DIRA_RUNNER_TOKEN is already in this process's + # environment (the cloud runtime set it), so it's already inherited by + # the child below. `dira device link`'s --runner-token flag is declared + # with `env = DIRA_RUNNER_TOKEN` in clap, so the bare command picks the + # runner-token path from the environment on its own. Idempotent: an + # already-linked device reports and changes nothing. + dira device link /dev/null 2>&1 || + log "runner-token link failed (sync stays off; retried next session)" + fi + return 0 +} + +if [ "$mode" = "install" ]; then + # Unlike the hook and --provision-only paths, --install-only must not + # silently degrade: it's the Cursor build/snapshot phase, and a swallowed + # failure here bakes a "no dira" snapshot in permanently. Non-zero exit, + # and the failure is worth reporting unconditionally, not just under + # DIRA_BOOTSTRAP_DEBUG (install_dira's own log() calls still name the + # specific reason when debug is on). + if ! install_dira; then + echo "dira-bootstrap: --install-only failed: no dira binary installed (rerun with DIRA_BOOTSTRAP_DEBUG=1 for the reason)" >&2 + exit 1 + fi + exit 0 +fi + +if in_cloud; then + PATH="$HOME/.local/bin:/usr/local/bin:$PATH" + export PATH + # The runtime's egress proxy re-terminates TLS, so without its CA every + # HTTPS call from dira fails `UnknownIssuer` — dira trusts only the roots + # bundled in the binary (D-0011). Take the CA the runtime itself declares, + # in preference order, and promote it into dira's explicit opt-in + # (DIRASH-0033). dira deliberately never reads SSL_CERT_FILE itself — an + # ambient variable must not silently widen trust — but this script is the + # operator's own provisioning step, running only inside a detected cloud + # runtime, where a declared CA file is exactly the intended signal. The + # system bundle is last: some images install the proxy CA into it, others + # do not, so it is a fallback rather than the answer. + if [ -z "${DIRA_EXTRA_CA_CERTS:-}" ]; then + for candidate in \ + "${SSL_CERT_FILE:-}" \ + "${NODE_EXTRA_CA_CERTS:-}" \ + "$HOME/.ccr/ca-bundle.crt" \ + /etc/ssl/certs/ca-certificates.crt; do + if [ -n "$candidate" ] && [ -f "$candidate" ]; then + DIRA_EXTRA_CA_CERTS="$candidate" + export DIRA_EXTRA_CA_CERTS + log "trusting extra CA roots from $candidate" + break + fi + done + fi + # Ephemeral VM: flush eagerly so an abrupt reclaim loses at most ~15s. + if [ -z "${DIRA_SYNC_BACKSTOP_SECS:-}" ]; then + DIRA_SYNC_BACKSTOP_SECS=15 + export DIRA_SYNC_BACKSTOP_SECS + fi + # XDG_RUNTIME_DIR first, same preference order D-0008 uses for the + # control socket (per-user, session-scoped, and consistent across the + # processes that need to agree on it) — $TMPDIR is the fallback here only + # because, unlike the socket, this lock is purely local to one VM's own + # racing SessionStart hooks within a single boot, never a cross-process + # rendezvous point another host or session needs to find blind. + lockdir="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}" + lock="$lockdir/dira-bootstrap.lock" + if command -v flock >/dev/null 2>&1; then + # Probe once: some flock builds (older BusyBox) don't understand -w and + # reject it outright, rather than treating it as a timeout. Test against + # a scratch file, not the real lock, so the probe itself never contends. + flock_wait_ok=0 + probe="$lockdir/dira-bootstrap.flock-probe.$$" + if (flock -w 0 8) 8>"$probe" 2>/dev/null; then + flock_wait_ok=1 + fi + rm -f "$probe" 2>/dev/null + if ( + if [ "$flock_wait_ok" = "1" ]; then + flock -w 120 9 || { + log "provisioning lock busy after 120s; skipping this session (another session is provisioning)" + exit 0 + } + else + lock_tries=0 + lock_acquired=0 + while [ "$lock_tries" -lt 120 ]; do + flock -n 9 2>/dev/null && { + lock_acquired=1 + break + } + lock_tries=$((lock_tries + 1)) + sleep 1 + done + if [ "$lock_acquired" -ne 1 ]; then + log "provisioning lock busy after 120s (flock -n retry, no -w support); skipping this session" + exit 0 + fi + fi + provision + ) 9>"$lock"; then + : + else + log "could not open provisioning lock file $lock; provisioning without a lock" + provision || true + fi + else + log "flock not available; provisioning without a lock (races possible)" + provision || true + fi +fi + +# Boot-time provisioning has nothing to forward — there is no hook payload on +# stdin, and `dira hook` on an empty stdin would just be a no-op round trip. +if [ "$mode" = "provision" ]; then + if ! in_cloud; then + echo "dira-bootstrap: --provision-only skipped: not a cloud runtime (no CLAUDE_CODE_REMOTE/DIRA_RUNTIME/DIRA_BOOTSTRAP_FORCE detected)" >&2 + fi + exit 0 +fi + +# Forward the SessionStart event we were invoked for; stdin passes through +# untouched. hook.sh owns the always-exit-0 contract from here. +exec sh "$dir/hook.sh" "$harness" diff --git a/.dira/hook.sh b/.dira/hook.sh new file mode 100755 index 0000000..6877827 --- /dev/null +++ b/.dira/hook.sh @@ -0,0 +1,39 @@ +#!/bin/sh +# .dira/hook.sh — portable dira hook forwarder, generated by `dira cloud init`. +# Invoked by committed harness hook configs as: sh .dira/hook.sh +# +# Contract (mirrors `dira hook ` exactly): ALWAYS exit 0, write +# nothing to stdout, never block the agent loop. A machine without a dira +# binary is simply not instrumented — the event is dropped silently, the same +# way a stopped daemon drops it. +# +# Do not hand-edit; re-run `dira cloud init` to regenerate. + +harness="${1:-claude}" + +find_dira() { + if command -v dira >/dev/null 2>&1; then + command -v dira + return 0 + fi + for candidate in "$HOME/.local/bin/dira" /usr/local/bin/dira; do + if [ -x "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done + return 1 +} + +if bin="$(find_dira)"; then + # `dira hook` reads the event JSON from our inherited stdin and always + # exits 0 within its own 2s budget. + # + # DIRA_HOOK_VIA=portable marks this invocation as having come through the + # repo-committed wrapper, so `dira hook` can yield to live user-scope + # wiring for the same event instead of forwarding it twice. + DIRA_HOOK_VIA=portable + export DIRA_HOOK_VIA + exec "$bin" hook "$harness" +fi +exit 0 diff --git a/.gitignore b/.gitignore index 3e615a4..ad6c182 100644 --- a/.gitignore +++ b/.gitignore @@ -28,8 +28,9 @@ node_modules .env.* !.env.example -# Machine-specific Claude Code settings (generated per-machine by `dira init`) -.claude/settings.json +# Per-machine Claude Code settings (generated by `dira init`, which embeds this +# machine's absolute dira path). The project `settings.json` IS tracked: it +# carries the portable `dira cloud init` wiring that cloud runtimes read. .claude/settings.local.json # Harness-managed external skill cache (regenerable). skills-lock.json IS tracked