#!/usr/bin/env bash # tokenfund: your agent keeps working on 20vision/myclawn-city until your limit. Stop any time with Ctrl-C. # It uses your existing Claude Code or Codex login. No password, no new user, no second login. # The agent works on a throwaway copy in a temp folder, inside its harness's own sandbox: it can't read your home folder, # and its network is limited to package registries. Your GitHub login stays with you: this script opens the pull requests. set -uo pipefail API=https://fund.myclawn.com; SLUG='20vision-myclawn-city'; REPO='20vision/myclawn-city'; BUDGET=100000; MODEL=''; WEEKLY=0; MODE='' say() { printf '\n\033[1;35mtokenfund\033[0m %s\n' "$*"; } die() { say "$*"; exit 1; } # Which agent runs: Claude Code if it's installed, otherwise Codex. Pick one with TF_HARNESS=claude or TF_HARNESS=codex. HARNESS="${TF_HARNESS:-}" if [ -z "$HARNESS" ]; then if [ -n "$MODEL" ] || command -v claude >/dev/null; then HARNESS=claude; elif command -v codex >/dev/null; then HARNESS=codex; else HARNESS=claude; fi fi case "$HARNESS" in claude) AGENT="Claude Code" ;; codex) AGENT=Codex ;; *) die "Unknown agent \"$HARNESS\". Use TF_HARNESS=claude or TF_HARNESS=codex." ;; esac # Only Claude Code reports which model ran, so a project that requires a model needs Claude Code. [ -n "$MODEL" ] && [ "$HARNESS" != claude ] && die "This project only accepts $MODEL, and only Claude Code reports which model ran. Please use Claude Code for it." for c in git curl gh tar "$HARNESS"; do command -v "$c" >/dev/null || die "Please install $c first."; done gh auth status >/dev/null 2>&1 || die "Please run: gh auth login. Then press + again." BIN="$(command -v "$HARNESS")" WORK="$(cd "$(mktemp -d /tmp/tokenfund.XXXXXX 2>/dev/null || mktemp -d)" && pwd -P)" case "$WORK/" in "$HOME"/*) die "The temp folder is inside your home folder, which the sandbox hides. Set TMPDIR=/tmp and try again." ;; esac P="$WORK/project"; RUN="$WORK/run" if [ "$(uname -s)" = Linux ]; then NEED=bwrap; [ "$HARNESS" = claude ] && NEED="bwrap socat" for c in $NEED; do command -v "$c" >/dev/null || die "$AGENT's sandbox needs bubblewrap${NEED#bwrap}. Please install it (e.g. sudo apt install bubblewrap socat), then try again."; done if ! bwrap --unshare-user --ro-bind / / true 2>/dev/null; then say "Your system blocks the sandbox (Ubuntu 24+ default). Allowing it needs your password once." sudo -v || die "Nothing was changed." printf 'abi ,\ninclude \nprofile bwrap /usr/bin/bwrap flags=(unconfined) {\n userns,\n include if exists \n}\n' | sudo tee /etc/apparmor.d/bwrap >/dev/null sudo apparmor_parser -r /etc/apparmor.d/bwrap bwrap --unshare-user --ro-bind / / true 2>/dev/null || die "The sandbox still can't start. Nothing was sent." fi fi if [ "$HARNESS" = codex ]; then env -i HOME="$HOME" USER="${USER:-$(id -un)}" PATH="$PATH" ${CODEX_HOME:+CODEX_HOME="$CODEX_HOME"} "$BIN" login status >/dev/null 2>&1 \ || die "Codex isn't logged in here. Run: codex login, then try again." case "$HOME" in *'"'*|*\\*) die "Your home folder path has a quote or backslash, which Codex's sandbox settings can't hold." ;; esac else env -i HOME="$HOME" USER="${USER:-$(id -un)}" PATH="$PATH" ${CLAUDE_CONFIG_DIR:+CLAUDE_CONFIG_DIR="$CLAUDE_CONFIG_DIR"} "$BIN" auth status >/dev/null 2>&1 \ || die "Claude Code isn't logged in here. Run: claude, log in, then try again." fi DOMAINS="registry.npmjs.org pypi.org files.pythonhosted.org crates.io static.crates.io index.crates.io proxy.golang.org" SETTINGS='{"sandbox":{"enabled":true,"failIfUnavailable":true,"allowUnsandboxedCommands":false,"autoAllowBashIfSandboxed":true,"filesystem":{"denyRead":["~/"],"allowRead":["."]},"network":{"allowedDomains":["registry.npmjs.org","pypi.org","files.pythonhosted.org","crates.io","static.crates.io","index.crates.io","proxy.golang.org"]}}}' # Codex: its own sandbox (bubblewrap on Linux, Seatbelt on Mac) with your home folder unreadable and network only to package registries. CODEX_PERM="permissions={tokenfund={extends=\":workspace\",filesystem={\"$HOME\"=\"deny\"},network={enabled=true,mode=\"full\",domains={$(for d in $DOMAINS; do printf '"%s"="allow",' "$d"; done | sed 's/,$//')}}}}" git clone --quiet --depth 50 "https://github.com/$REPO" "$P" || die "Could not download the project." cd "$P" || exit 1 # Connects your GitHub account with a temporary gist. Your GitHub login is never sent to us. TF_ID="$HOME/.config/tokenfund/key" if [ ! -s "$TF_ID" ]; then NONCE="$(curl -fsS -X POST "$API/api/id/start")" || die "The site can't be reached." GIST_URL="$(printf '%s\n' "$NONCE" | gh gist create -f tokenfund-proof.txt - 2>/dev/null)" || die "Please run: gh auth refresh -s gist. Then try again." GIST="${GIST_URL##*/}" sleep 2 KEY="$(curl -fsS -X POST "$API/api/id/verify?gist=$GIST&nonce=$NONCE&login=$(gh api user -q .login)")"; RC=$? gh api -X DELETE "gists/$GIST" >/dev/null 2>&1 [ "$RC" = 0 ] && [ -n "$KEY" ] || die "Could not verify your GitHub account. Try again in a minute." mkdir -p "$(dirname "$TF_ID")"; (umask 077; printf '%s' "$KEY" > "$TF_ID") say "GitHub account connected." fi TF_KEY="$(cat "$TF_ID")" # Weekly giving: the site keeps the weekly amount and hands out an even share each day, so it never all goes on day one. STATE="$HOME/.local/state/tokenfund"; NAME="tokenfund-$SLUG" install_daily() { mkdir -p "$STATE"; H=$((9 + RANDOM % 12)); MI=$((RANDOM % 60)) CMD="cd /tmp && curl -fsSL '$API/a/$SLUG?mode=daily&h=$HARNESS' -o '$STATE/$SLUG.sh' && bash '$STATE/$SLUG.sh' >> '$STATE/$SLUG.log' 2>&1" if [ "$(uname -s)" = Darwin ]; then PL="$HOME/Library/LaunchAgents/com.myclawn.$NAME.plist"; mkdir -p "$(dirname "$PL")" cat > "$PL" < Labelcom.myclawn.$NAME ProgramArguments/bin/bash-c$CMD EnvironmentVariablesPATH$PATH StartCalendarIntervalHour$HMinute$MI LowPriorityIONice10 PLIST launchctl unload "$PL" 2>/dev/null; launchctl load -w "$PL" || return 1 elif systemctl --user show-environment >/dev/null 2>&1; then U="$HOME/.config/systemd/user"; mkdir -p "$U" printf '[Unit]\nDescription=tokenfund: daily share of your weekly tokens for %s\n[Service]\nType=oneshot\nNice=10\nEnvironment=PATH=%s\nExecStart=/bin/bash -c "%s"\n' "$REPO" "$PATH" "$CMD" > "$U/$NAME.service" printf '[Unit]\nDescription=tokenfund daily run for %s\n[Timer]\nOnCalendar=*-*-* %02d:%02d:00\nPersistent=true\n[Install]\nWantedBy=timers.target\n' "$REPO" "$H" "$MI" > "$U/$NAME.timer" systemctl --user daemon-reload && systemctl --user enable --now "$NAME.timer" >/dev/null 2>&1 || return 1 elif command -v crontab >/dev/null; then { crontab -l 2>/dev/null | grep -v "# $NAME\$"; echo "$MI $H * * * PATH=$PATH /bin/bash -c \"$CMD\" # $NAME"; } | crontab - || return 1 else return 1 fi printf '%02d:%02d' "$H" "$MI" } if [ "$WEEKLY" -gt 0 ] && [ "$MODE" != daily ]; then curl -fsS -m 20 -X POST -H "x-tf-key: $TF_KEY" "$API/api/pledge/$SLUG?weekly=$WEEKLY" >/dev/null || die "Could not save your weekly amount." AT="$(install_daily)" || die "Could not set up the daily run on this computer." say "Every week: $WEEKLY tokens, spread over the days (runs daily at $AT). Stop any time: bash <(curl -fsSL $API/stop/$SLUG)" MODE=daily fi if [ "$MODE" = daily ]; then B="$(curl -fsS -m 20 -H "x-tf-key: $TF_KEY" "$API/api/budget/$SLUG")" || die "The site can't be reached. Trying again tomorrow." case "$B" in stopped) bash <(curl -fsSL "$API/stop/$SLUG") >/dev/null 2>&1; die "Weekly giving to $REPO is stopped." ;; ''|*[!0-9]*) die "Unexpected answer from the site. Trying again tomorrow." ;; esac [ "$B" -lt 30000 ] && { rm -rf "$WORK"; say "Today's share is used up. Next run tomorrow."; exit 0; } BUDGET=$B fi S="" release() { [ -n "$S" ] && curl -fsS -m 10 -X POST "$API/api/release/$S" >/dev/null 2>&1; S=""; } trap 'release; rm -rf "$WORK"; say "Stopped. Tokens used: $USED"; exit 0' INT TERM # Git runs on the agent's copy only through this repo's own git folder, so nothing the agent writes can configure git. gw() { git -C "$P" -c core.fsmonitor=false -c core.hooksPath=/dev/null --work-tree="$RUN" "$@"; } USED=0; PRS=0; EMPTY=0; FAILS=0 FORK="" if [ "$BUDGET" -gt 0 ]; then say "Working on $REPO up to $BUDGET tokens. Press Ctrl-C to stop."; else say "Working on $REPO with no limit. Press Ctrl-C to stop."; fi while :; do if [ "$BUDGET" -gt 0 ] && [ $((BUDGET-USED)) -lt 30000 ]; then break; fi git fetch --quiet --depth 50 origin HEAD && git reset --quiet --hard FETCH_HEAD && git clean -fdxq # The agent gets a fresh copy without git or links, so nothing it writes runs or lands here unchecked. rm -rf "$RUN" && mkdir -p "$RUN" && git archive HEAD | tar -x -C "$RUN" && find "$RUN" -type l -delete || die "Could not prepare the project." S="$(curl -sS -X POST -H "x-tf-key: $TF_KEY" -w '\n%{http_code}' "$API/api/session/$SLUG?sha=$(git rev-parse HEAD)")" || die "The site can't be reached." CODE="${S##*$'\n'}"; S="${S%$'\n'*}" [ "$CODE" = 409 ] && { rm -rf "$WORK"; say "The project's owner paused new work for now (too many pull requests waiting, or paused). Tokens used: $USED"; exit 0; } [ "$CODE" = 200 ] || die "${S:-This project is closed.}" case "$S" in *[!a-f0-9]*|'') die "Unexpected answer from the site." ;; esac M=(); [ -n "$MODEL" ] && M=(--model "$MODEL") PROMPT="Call the tokenfund start_session tool with project $SLUG and session $S, then follow what it returns." RES=(); TRY=0; T=0; DONE_OK=0 # One work step = one task. If the agent runs out of turns, it continues the same task (up to 2 more times) instead of starting over. while :; do if [ "$HARNESS" = codex ]; then # Your Codex config, rules, apps and web search stay off; only the tokenfund MCP server is added. ARGS=(exec --json --ephemeral --skip-git-repo-check --ignore-user-config --ignore-rules \ --enable network_proxy --disable apps -c 'web_search="disabled"' \ -c 'default_permissions="tokenfund"' -c "$CODEX_PERM" -c 'approval_policy="never"' \ -c "mcp_servers.tokenfund.url=\"$API/mcp\"" -c 'mcp_servers.tokenfund.default_tools_approval_mode="approve"' "$PROMPT") else ARGS=(-p "$PROMPT" ${RES[@]+"${RES[@]}"} ${M[@]+"${M[@]}"} \ --setting-sources "" --settings "$SETTINGS" --strict-mcp-config --permission-mode dontAsk \ --mcp-config "{\"mcpServers\":{\"tokenfund\":{\"type\":\"http\",\"url\":\"$API/mcp\"}}}" \ --tools "Bash,Read,Edit,Write,Glob,Grep" \ --allowedTools "mcp__tokenfund" "Bash" "Read(./**)" "Edit(./**)" "Write(./**)" \ --disallowedTools "Read(~/**)" "Edit(~/**)" "Write(~/**)" \ --max-turns 80 --output-format json) fi # Clean environment: none of your shell's keys or settings reach the agent. (cd "$RUN" && exec env -i HOME="$HOME" USER="${USER:-$(id -un)}" PATH="$PATH" LANG="${LANG:-C.UTF-8}" TERM=dumb \ ${CLAUDE_CONFIG_DIR:+CLAUDE_CONFIG_DIR="$CLAUDE_CONFIG_DIR"} ${CODEX_HOME:+CODEX_HOME="$CODEX_HOME"} "$BIN" "${ARGS[@]}" < /dev/null > "$WORK/run.json" 2> "$WORK/run.err") # Codex reports usage in its turn.completed event; only that line is sent. if [ "$HARNESS" = codex ]; then grep '^{"type":"turn.completed"' "$WORK/run.json" | tail -n 1 > "$WORK/usage.json"; else cp "$WORK/run.json" "$WORK/usage.json"; fi R="$(curl -fsS -m 30 --data-binary @"$WORK/usage.json" "$API/api/usage/$S?h=$HARNESS" 2>/dev/null)" [ "$R" = wrong_model ] && { release; rm -rf "$WORK"; die "This project only accepts $MODEL, and your agent ran another model. Nothing was sent."; } case "$R" in ''|*[!0-9]*) R=0 ;; esac T=$((T+R)); USED=$((USED+R)) grep -q '"subtype":"success"' "$WORK/run.json" && DONE_OK=1 [ "$HARNESS" = codex ] && [ -s "$WORK/usage.json" ] && DONE_OK=1 [ -f "$RUN/.tokenfund-pr.md" ] && break # Codex has no turn limit, so a step never needs continuing. [ "$HARNESS" = codex ] && break grep -q '"subtype":"error_max_turns"' "$WORK/run.json" || break TRY=$((TRY+1)); [ "$TRY" -gt 2 ] && break [ "$BUDGET" -gt 0 ] && [ $((BUDGET-USED)) -lt 30000 ] && break SID="$(grep -o '"session_id":"[^"]*"' "$WORK/run.json" | head -1 | cut -d'"' -f4)"; [ -n "$SID" ] || break say "Still working on the same task. Tokens used: $USED" RES=(--resume "$SID"); PROMPT="You ran out of turns. Continue the same task from where you stopped and finish it, including writing .tokenfund-pr.md." done if [ "$T" = 0 ]; then release; FAILS=$((FAILS+1)) [ "$FAILS" -ge 2 ] && { sed -n 1,5p "$WORK/run.err" >&2; die "The agent couldn't run. Check your $AGENT login."; } continue fi FAILS=0 PRF="$RUN/.tokenfund-pr.md" if [ ! -f "$PRF" ] || [ -L "$PRF" ]; then release # Only an agent that finished normally without a change means "nothing to do". Running out of turns doesn't. if [ "$DONE_OK" = 0 ]; then say "Couldn't finish this task in time. Trying the next one. Tokens used: $USED"; continue; fi EMPTY=$((EMPTY+1)); say "No finished change this round. Tokens used: $USED" [ "$EMPTY" -ge 2 ] && { curl -fsS -m 10 -X POST -H "x-tf-key: $TF_KEY" "$API/api/idle/$SLUG" >/dev/null 2>&1; say "Nothing more to do right now."; break; } continue fi EMPTY=0 head -c 20000 "$PRF" > "$WORK/pr.md"; rm -f "$PRF" TITLE="$(head -n1 "$WORK/pr.md" | cut -c1-70)"; BODY="$(tail -n +2 "$WORK/pr.md")" find "$RUN" -name .git -prune -exec rm -rf {} + ; find "$RUN" -type l -delete gw add -A if git diff --cached --quiet; then release; continue; fi # Last check before anything leaves this computer. if git diff --cached --summary | grep -qE 'mode 1(2|6)0000' \ || git diff --cached --name-only | grep -qE '^(\.github/workflows/|\.claude/|\.codex/|\.mcp\.json|\.git)' \ || [ "$(git diff --cached --numstat | awk '{s+=$1+$2} END {print s+0}')" -gt 3000 ] \ || git diff --cached | grep -qE 'BEGIN [A-Z ]*PRIVATE KEY|sk-ant-|ghp_[A-Za-z0-9]{20}|github_pat_|sk-proj-|AKIA[0-9A-Z]{16}|\.claude/\.credentials|\.codex/auth\.json'; then git reset --quiet; release; say "Blocked a change that failed the safety check. It was not sent."; continue fi git -c user.useConfigOnly=false commit --quiet --no-verify -m "${TITLE:-tokenfund contribution}" if [ -z "$FORK" ]; then gh repo fork "$REPO" --remote --remote-name tokenfund-fork >/dev/null 2>&1 FORK=origin; git remote | grep -q '^tokenfund-fork$' && FORK=tokenfund-fork fi ME="$(gh api user -q .login)" if git push --quiet "$FORK" "HEAD:refs/heads/tokenfund-$S" 2>/dev/null \ && URL="$(gh pr create --repo "$REPO" --head "$ME:tokenfund-$S" --title "${TITLE:-tokenfund contribution}" --body "$BODY Contributed via tokenfund.myclawn.com · session $S")"; then curl -fsS -m 30 -X POST "$API/api/receipt/$S?pr=$URL" >/dev/null 2>&1 S=""; PRS=$((PRS+1)); say "Opened $URL · Tokens used: $USED" else release; say "Could not open the pull request. Trying the next task." fi done rm -rf "$WORK" say "Done. $PRS pull requests, $USED tokens. Thank you!"