Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .codex/paperclip-tools/paperclipai-ffmemes.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" "$@"
Expand Down
9 changes: 6 additions & 3 deletions agents/.paperclip.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 7 additions & 0 deletions docs/agents/routine-observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<company-id>/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
Expand Down
9 changes: 4 additions & 5 deletions docs/paperclip-ops-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,11 +169,10 @@ curl -s -X POST "$PAPERCLIP_URL/api/secrets/<secret-id>/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/<company-id>/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/<agent-id>/wakeup" \
Expand Down
25 changes: 25 additions & 0 deletions docs/paperclip-skill-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<company-id>/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
Expand Down
4 changes: 4 additions & 0 deletions scripts/paperclip_routine_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
18 changes: 18 additions & 0 deletions tests/scripts/test_paperclip_routine_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<company-id>/skills/import failed: "
"sourceType=github trustLevel=scripts_executables "
"reason=scripts_executables_blocked."
)
}
]

flags, _latest = audit.classify_issue(issue, comments)

assert "degraded_green" in flags
107 changes: 107 additions & 0 deletions tests/test_paperclip_cli_wrapper.py
Original file line number Diff line number Diff line change
@@ -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
Loading