diff --git a/.codex/paperclip-tools/paperclipai-ffmemes.sh b/.codex/paperclip-tools/paperclipai-ffmemes.sh index 57b74a60..a8f8bc22 100755 --- a/.codex/paperclip-tools/paperclipai-ffmemes.sh +++ b/.codex/paperclip-tools/paperclipai-ffmemes.sh @@ -21,6 +21,9 @@ fi export PAPERCLIP_COMPANY_ID="${PAPERCLIP_COMPANY_ID:-96ee7b2e-6df2-43c8-bbe3-53e19297308a}" export PAPERCLIP_CONTEXT="${PAPERCLIP_CONTEXT:-$tool_root/context.json}" +export NPM_CONFIG_CACHE="${NPM_CONFIG_CACHE:-$tool_root/.npm-cache}" +export npm_config_cache="$NPM_CONFIG_CACHE" +mkdir -p "$NPM_CONFIG_CACHE" if [[ -n "${PAPERCLIPAI_BIN:-}" ]]; then exec "$PAPERCLIPAI_BIN" "$@" diff --git a/agents/.paperclip.yaml b/agents/.paperclip.yaml index 8f6cb0ed..cb9b8e7c 100644 --- a/agents/.paperclip.yaml +++ b/agents/.paperclip.yaml @@ -5,9 +5,12 @@ schema: "paperclip/v1" # agents, and do not set it globally in the Paperclip host env: Codex CLI # 0.122+ treats that as API-key billing instead of subscription billing. -# gstack skills source — Paperclip pulls skills from this repo at the recorded ref. -# Bump `ref` only after `agents/deploy.sh --dry-run` shows the new catalog with -# zero unknown-desired-skills failures. +# gstack skills source for desired-skill validation. FFmemes does not direct +# import upstream gstack via /skills/import because Paperclip rejects +# executable-script skills (`scripts_executables_blocked`). Use the curated live +# catalog plus per-agent paperclip_skill_sync; see docs/paperclip-skill-catalog.md. +# Bump `ref` only after `agents/deploy.sh --dry-run` shows the catalog with zero +# unknown-desired-skills failures. skills: source: "https://github.com/garrytan/gstack" ref: "main" diff --git a/docs/agents/routine-observability.md b/docs/agents/routine-observability.md index bbf41d93..30bab167 100644 --- a/docs/agents/routine-observability.md +++ b/docs/agents/routine-observability.md @@ -66,6 +66,13 @@ update path, the run is degraded. Keep one open `[maintenance:gstack-update-blocked]` issue instead of closing each daily run as green. +Direct upstream gstack imports are currently not a green update path for +FFmemes. If a run attempts +`POST /api/companies//skills/import` and receives +`scripts_executables` / `scripts_executables_blocked`, classify it as degraded: +the intended policy is curated catalog validation plus per-agent +`paperclip_skill_sync`, documented in `docs/paperclip-skill-catalog.md`. + ### Paperclip Update Check This is not only a SHA poll, and queueing a Coolify deployment is not terminal diff --git a/docs/paperclip-ops-runbook.md b/docs/paperclip-ops-runbook.md index 679b4803..9b8636eb 100644 --- a/docs/paperclip-ops-runbook.md +++ b/docs/paperclip-ops-runbook.md @@ -169,11 +169,10 @@ curl -s -X POST "$PAPERCLIP_URL/api/secrets//rotate" \ -H "Content-Type: application/json" \ -d "$(jq -n --arg value "$SECRET_VALUE" '{"value":$value}')" -# Import gstack skills -curl -s -X POST "$PAPERCLIP_URL/api/companies//skills/import" \ - -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{"source": "https://github.com/garrytan/gstack"}' +# Do not import upstream gstack directly in FFmemes operations. The current +# Paperclip trust policy rejects executable-script skills with HTTP 422 +# (`scripts_executables_blocked`). See docs/paperclip-skill-catalog.md. +# Use agents/deploy.sh --dry-run plus paperclip_skill_sync instead. # Wake an agent manually curl -s -X POST "$PAPERCLIP_URL/api/agents//wakeup" \ diff --git a/docs/paperclip-skill-catalog.md b/docs/paperclip-skill-catalog.md index 4280417a..986d61c6 100644 --- a/docs/paperclip-skill-catalog.md +++ b/docs/paperclip-skill-catalog.md @@ -39,6 +39,31 @@ Skill catalog preflight (dry-run): When `failed_count > 0`, the apply pass is blocked. `dry-run` still completes so the operator sees the full diff and the unknown skill names. +## Import policy + +Do not use `POST /api/companies//skills/import` for the upstream +`https://github.com/garrytan/gstack` source in FFmemes operations. Paperclip +currently classifies at least one required gstack skill (`browse`) as an +executable-script source and rejects the import with `HTTP 422` +(`scripts_executables_blocked`). That is a supply-chain safety decision, not a +transient import failure. + +The safe operating mode is: + +1. Treat the live Paperclip skill catalog as the curated source available to + agents. +2. Use `agents/deploy.sh --dry-run` to verify desired skill paths against that + catalog. +3. Use `paperclip_skill_sync` to attach the desired skill list to each agent. +4. Open or update a single `[maintenance:gstack-update-blocked]` issue when the + catalog cannot be refreshed from upstream safely. + +A gstack update check must not close green solely because the per-agent +preflight is clean if an attempted catalog refresh/import failed. Outcome +comments that mention `/skills/import`, `scripts_executables`, or +`scripts_executables_blocked` are degraded until a trusted-source mechanism, +sanitized upstream package, or explicit Paperclip-side allowlist is available. + ## Team-mode (gstack) decision: docs-only GStack supports a "team-mode" that lets multiple agents share the same skill diff --git a/scripts/paperclip_routine_audit.py b/scripts/paperclip_routine_audit.py index 9e9b842d..e4af5a56 100755 --- a/scripts/paperclip_routine_audit.py +++ b/scripts/paperclip_routine_audit.py @@ -72,6 +72,10 @@ "no local gstack install", "0 updated", "skills no longer in upstream", + "scripts_executables_blocked", + "scripts_executables", + "/skills/import", + "contains executable scripts and cannot be imported", ) INTERACTION_LIST_KEYS = ("interactions", "items", "data") CONFIRMATION_KIND_MARKERS = ("confirmation", "request_confirmation") diff --git a/tests/scripts/test_paperclip_routine_audit.py b/tests/scripts/test_paperclip_routine_audit.py index e918dadb..2f34968e 100644 --- a/tests/scripts/test_paperclip_routine_audit.py +++ b/tests/scripts/test_paperclip_routine_audit.py @@ -302,3 +302,21 @@ def test_freeform_approval_comment_is_not_publish_approval_signal(): flags, _latest = audit.classify_issue(issue, comments) assert "approved_without_publish_marker" not in flags + + +def test_gstack_rejected_script_import_is_degraded_green(): + issue = {"title": "gstack Update Check", "description": ""} + comments = [ + { + "body": ( + "Skill preflight clean: failed_count=0.\n" + "POST /api/companies//skills/import failed: " + "sourceType=github trustLevel=scripts_executables " + "reason=scripts_executables_blocked." + ) + } + ] + + flags, _latest = audit.classify_issue(issue, comments) + + assert "degraded_green" in flags diff --git a/tests/test_paperclip_cli_wrapper.py b/tests/test_paperclip_cli_wrapper.py new file mode 100644 index 00000000..f884b2be --- /dev/null +++ b/tests/test_paperclip_cli_wrapper.py @@ -0,0 +1,107 @@ +"""Regression tests for the repo-local Paperclip CLI wrapper.""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +WRAPPER = REPO_ROOT / ".codex" / "paperclip-tools" / "paperclipai-ffmemes.sh" +DEFAULT_NPM_CACHE = REPO_ROOT / ".codex" / "paperclip-tools" / ".npm-cache" + + +def _fake_npx(fake_bin: Path) -> Path: + npx = fake_bin / "npx" + npx.write_text( + """#!/usr/bin/env bash +set -euo pipefail +printf 'NPM_CONFIG_CACHE=%s\\n' "${NPM_CONFIG_CACHE:-}" +printf 'npm_config_cache=%s\\n' "${npm_config_cache:-}" +printf 'args=%s\\n' "$*" +""", + encoding="utf-8", + ) + npx.chmod(0o755) + return npx + + +def test_wrapper_uses_repo_local_npm_cache_for_npx_fallback(tmp_path: Path) -> None: + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _fake_npx(fake_bin) + + env = os.environ.copy() + env.pop("NPM_CONFIG_CACHE", None) + env.pop("npm_config_cache", None) + env.pop("PAPERCLIPAI_BIN", None) + env["PATH"] = f"{fake_bin}{os.pathsep}{env['PATH']}" + env["PAPERCLIPAI_VERSION"] = "test-version" + + result = subprocess.run( + ["bash", str(WRAPPER), "--version"], + cwd=REPO_ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0 + assert f"NPM_CONFIG_CACHE={DEFAULT_NPM_CACHE}" in result.stdout + assert f"npm_config_cache={DEFAULT_NPM_CACHE}" in result.stdout + assert "args=--yes paperclipai@test-version --version" in result.stdout + + +def test_wrapper_honors_explicit_npm_cache_override(tmp_path: Path) -> None: + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _fake_npx(fake_bin) + override_cache = tmp_path / "npm-cache" + + env = os.environ.copy() + env.pop("npm_config_cache", None) + env.pop("PAPERCLIPAI_BIN", None) + env["PATH"] = f"{fake_bin}{os.pathsep}{env['PATH']}" + env["NPM_CONFIG_CACHE"] = str(override_cache) + + result = subprocess.run( + ["bash", str(WRAPPER), "issue", "list"], + cwd=REPO_ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0 + assert f"NPM_CONFIG_CACHE={override_cache}" in result.stdout + assert f"npm_config_cache={override_cache}" in result.stdout + assert override_cache.is_dir() + + +def test_wrapper_overwrites_stale_lowercase_npm_cache(tmp_path: Path) -> None: + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _fake_npx(fake_bin) + stale_cache = tmp_path / "stale-cache" + + env = os.environ.copy() + env.pop("NPM_CONFIG_CACHE", None) + env.pop("PAPERCLIPAI_BIN", None) + env["PATH"] = f"{fake_bin}{os.pathsep}{env['PATH']}" + env["npm_config_cache"] = str(stale_cache) + + result = subprocess.run( + ["bash", str(WRAPPER), "--version"], + cwd=REPO_ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0 + assert f"NPM_CONFIG_CACHE={DEFAULT_NPM_CACHE}" in result.stdout + assert f"npm_config_cache={DEFAULT_NPM_CACHE}" in result.stdout + assert f"npm_config_cache={stale_cache}" not in result.stdout