diff --git a/scripts/paperclip-issue-update.sh b/scripts/paperclip-issue-update.sh index f8717e31c0..c3e50bf9fa 100755 --- a/scripts/paperclip-issue-update.sh +++ b/scripts/paperclip-issue-update.sh @@ -1,166 +1,7 @@ #!/usr/bin/env bash +# Keep the repository command compatible; the deployable implementation belongs +# to the skill so agents do not need a checkout of this repository. set -euo pipefail - -usage() { - cat <<'EOF' -Usage: - scripts/paperclip-issue-update.sh [--issue-id ID] [--status STATUS] [--comment TEXT] [--dry-run] - -Reads a multiline markdown comment from stdin when stdin is piped. This preserves -newlines when building the JSON payload for PATCH /api/issues/{issueId}. - -Examples: - scripts/paperclip-issue-update.sh --issue-id "$PAPERCLIP_TASK_ID" --status in_progress <<'MD' - Investigating formatting - - - Pulled the raw comment body - - Comparing it with the run transcript - MD - - scripts/paperclip-issue-update.sh --issue-id "$PAPERCLIP_TASK_ID" --status done --dry-run <<'MD' - Done - - - Fixed the issue update helper - MD -EOF -} - -require_command() { - if ! command -v "$1" >/dev/null 2>&1; then - printf 'Missing required command: %s\n' "$1" >&2 - exit 1 - fi -} - -issue_id="${PAPERCLIP_TASK_ID:-}" -status="" -comment_arg="" -dry_run=0 - -while [[ $# -gt 0 ]]; do - case "$1" in - --issue-id) - issue_id="${2:-}" - shift 2 - ;; - --status) - status="${2:-}" - shift 2 - ;; - --comment) - comment_arg="${2:-}" - shift 2 - ;; - --dry-run) - dry_run=1 - shift - ;; - --help|-h) - usage - exit 0 - ;; - *) - printf 'Unknown argument: %s\n' "$1" >&2 - usage >&2 - exit 1 - ;; - esac -done - -if [[ -z "$issue_id" ]]; then - printf 'Missing issue id. Pass --issue-id or set PAPERCLIP_TASK_ID.\n' >&2 - exit 1 -fi - -comment="" -if [[ -n "$comment_arg" ]]; then - comment="$comment_arg" -elif [[ ! -t 0 ]]; then - comment="$(cat)" -fi - -require_command jq - -payload="$( - jq -nc \ - --arg status "$status" \ - --arg comment "$comment" \ - ' - (if $status == "" then {} else {status: $status} end) + - (if $comment == "" then {} else {comment: $comment} end) - ' -)" - -if [[ "$dry_run" == "1" ]]; then - printf '%s\n' "$payload" - exit 0 -fi - -if [[ -z "${PAPERCLIP_API_URL:-}" || -z "${PAPERCLIP_API_KEY:-}" || -z "${PAPERCLIP_RUN_ID:-}" ]]; then - printf 'Missing PAPERCLIP_API_URL, PAPERCLIP_API_KEY, or PAPERCLIP_RUN_ID.\n' >&2 - exit 1 -fi - -# A successful PATCH always returns the updated issue JSON. An empty body or a -# connection-level failure means the write did NOT land, even when a pipeline -# exit code says otherwise, so verify the response instead of inferring success. -# Two attempts total: the shared heartbeat policy stops a control-plane write -# after two consecutive failures, so the helper must not send a third. -max_attempts=2 -attempt=1 -while :; do - http_code="" - body="" - set +e - response="$( - curl -sS -m 30 -X PATCH \ - "$PAPERCLIP_API_URL/api/issues/$issue_id" \ - -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ - -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \ - -H 'Content-Type: application/json' \ - --data-binary "$payload" \ - -w '\n%{http_code}' - )" - curl_exit=$? - set -e - - if [[ "$curl_exit" -eq 0 ]]; then - http_code="${response##*$'\n'}" - body="${response%$'\n'*}" - fi - - if [[ "$curl_exit" -eq 0 && "$http_code" == 2* ]]; then - if [[ -z "$body" ]]; then - printf 'Issue update FAILED: HTTP %s with an empty response body. A real update echoes the issue JSON; treat this write as not saved.\n' "$http_code" >&2 - exit 1 - fi - if [[ -n "$status" ]]; then - returned_status="$(jq -r '.status // empty' <<<"$body" 2>/dev/null || true)" - if [[ "$returned_status" != "$status" ]]; then - printf 'Issue update FAILED: server echoed status %s instead of requested %s.\n' "${returned_status:-}" "$status" >&2 - printf '%s\n' "$body" >&2 - exit 1 - fi - fi - printf '%s\n' "$body" - exit 0 - fi - - # 4xx (other than 429) is a definitive rejection; retrying cannot change it. - if [[ "$curl_exit" -eq 0 && "$http_code" == 4* && "$http_code" != "429" ]]; then - printf 'Issue update rejected (HTTP %s).\n' "$http_code" >&2 - [[ -n "$body" ]] && printf '%s\n' "$body" >&2 - exit 1 - fi - - if (( attempt >= max_attempts )); then - printf 'Issue update FAILED after %d attempts (curl exit %s, HTTP %s). The status/comment was NOT saved — report this write as failed, do not assume it landed.\n' "$max_attempts" "$curl_exit" "${http_code:-000}" >&2 - [[ -n "$body" ]] && printf '%s\n' "$body" >&2 - exit 1 - fi - - printf 'Issue update attempt %d/%d failed (curl exit %s, HTTP %s); retrying...\n' "$attempt" "$max_attempts" "$curl_exit" "${http_code:-000}" >&2 - sleep $((attempt * 2)) - attempt=$((attempt + 1)) -done +paperclip_repo_scripts="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +exec bash "$paperclip_repo_scripts/../skills/paperclip/scripts/paperclip-issue-update.sh" "$@" diff --git a/server/src/__tests__/paperclip-issue-update-helper.test.ts b/server/src/__tests__/paperclip-issue-update-helper.test.ts index 59a81de3f6..93f3fa9f3a 100644 --- a/server/src/__tests__/paperclip-issue-update-helper.test.ts +++ b/server/src/__tests__/paperclip-issue-update-helper.test.ts @@ -1,15 +1,19 @@ import { spawn } from "node:child_process"; import http from "node:http"; +import fs from "node:fs/promises"; +import os from "node:os"; +import { ensurePaperclipSkillSymlink } from "@paperclipai/adapter-utils/server-utils"; import type { AddressInfo } from "node:net"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -// End-to-end coverage for scripts/paperclip-issue-update.sh: the helper must +// End-to-end coverage for the skill-bundled issue update helper: the helper must // only exit 0 when the server confirms the write by echoing the update, must // classify failures (retry connection-level faults and 5xx, never retry a // definitive 4xx), and must stop at two attempts total to honor the shared // bounded-write-retry rule. -const HELPER_PATH = path.resolve("scripts/paperclip-issue-update.sh"); +const HELPER_PATH = path.resolve("skills/paperclip/scripts/paperclip-issue-update.sh"); +const REPO_HELPER_PATH = path.resolve("scripts/paperclip-issue-update.sh"); interface HelperResult { code: number | null; @@ -21,6 +25,8 @@ interface RecordedRequest { method: string; url: string; body: string; + authorization: string | undefined; + runId: string | string[] | undefined; } describe("paperclip issue update helper", () => { @@ -48,6 +54,8 @@ describe("paperclip issue update helper", () => { method: req.method ?? "", url: req.url ?? "", body, + authorization: req.headers.authorization, + runId: req.headers["x-paperclip-run-id"], }; requests.push(recorded); respond(recorded, requests.length, res); @@ -66,17 +74,19 @@ describe("paperclip issue update helper", () => { return { baseUrl: `http://127.0.0.1:${port}`, requests }; } - function runHelper(apiUrl: string, args: string[]): Promise { + function runHelper(apiUrl: string, args: string[], options: { helper?: string; cwd?: string; input?: string } = {}): Promise { return new Promise((resolve, reject) => { - const child = spawn("bash", [HELPER_PATH, ...args], { + const child = spawn("bash", [options.helper ?? HELPER_PATH, ...args], { + cwd: options.cwd, env: { ...process.env, PAPERCLIP_API_URL: apiUrl, PAPERCLIP_API_KEY: "test-key", PAPERCLIP_RUN_ID: "test-run", }, - stdio: ["ignore", "pipe", "pipe"], + stdio: ["pipe", "pipe", "pipe"], }); + child.stdin.end(options.input); let stdout = ""; let stderr = ""; child.stdout.on("data", (chunk) => { @@ -109,6 +119,44 @@ describe("paperclip issue update helper", () => { expect(JSON.parse(requests[0]?.body ?? "{}")).toEqual({ status: "done", comment: "closing note" }); }); + it.each([".claude/skills", "codex-home/skills"])( + "delivers the bundled helper through %s from an unrelated workspace", async skillsHome => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip completion helper ")); + cleanupFns.push(() => fs.rm(root, { recursive: true, force: true })); + const source = path.join(root, "bundle", "paperclip"); + const installed = path.join(root, skillsHome, "paperclip"); + const cwd = path.join(root, "unrelated project"); + await fs.cp(path.dirname(path.dirname(HELPER_PATH)), source, { recursive: true }); + await fs.mkdir(path.dirname(installed), { recursive: true }); + await fs.mkdir(cwd); + await ensurePaperclipSkillSymlink(source, installed); + await expect(fs.access(path.join(cwd, "scripts/paperclip-issue-update.sh"))).rejects.toThrow(); + const savedComments: string[] = []; + const { baseUrl, requests } = await startServer((request, _attempt, res) => { + const payload = JSON.parse(request.body); + savedComments.push(payload.comment); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ id: "issue-1", status: payload.status, comment: { body: payload.comment } })); + }); + const comment = "Done\n\n- Saved [report](/TST/issues/TST-1#document-report)\n- Literal `code`, $HOME and $(not-a-command)"; + const result = await runHelper(baseUrl, ["--issue-id", "issue-1", "--status", "done"], { + helper: path.join(installed, "scripts/paperclip-issue-update.sh"), cwd, input: comment, + }); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ status: "done", comment: { body: comment } }); + expect(savedComments).toEqual([comment]); + expect(requests).toHaveLength(1); + expect(requests[0]).toMatchObject({ method: "PATCH", url: "/api/issues/issue-1", authorization: "Bearer test-key", runId: "test-run" }); + }); + + it("keeps the repository entrypoint working outside the repository", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-helper-wrapper-")); + cleanupFns.push(() => fs.rm(root, { recursive: true, force: true })); + const result = await runHelper("", [...doneArgs, "--dry-run"], { helper: REPO_HELPER_PATH, cwd: root }); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ status: "done", comment: "closing note" }); + }); + it("fails an empty 2xx body instead of treating it as success", async () => { const { baseUrl } = await startServer((_request, _attempt, res) => { res.writeHead(200, { "content-length": "0" }); diff --git a/server/src/__tests__/paperclip-skill-utils.test.ts b/server/src/__tests__/paperclip-skill-utils.test.ts index b3ca558ea0..140fc375eb 100644 --- a/server/src/__tests__/paperclip-skill-utils.test.ts +++ b/server/src/__tests__/paperclip-skill-utils.test.ts @@ -547,6 +547,10 @@ describe("paperclip skill utils", () => { expect(skillBody).toContain("Verify writes — never infer them"); expect(skillBody).toContain("An empty response body means the write FAILED"); expect(skillBody).toContain("Never pipe a disposition write through `head`/`tail`"); + expect(skillBody).toContain("resolved relative to this installed `SKILL.md`, not the task workspace"); + expect(skillBody).toContain("do not search the filesystem for it"); + expect(skillBody).toContain('bash "$paperclip_skill_dir/scripts/paperclip-issue-update.sh"'); + expect(skillBody).not.toMatch(/^scripts\/paperclip-issue-update\.sh/m); // The helper's verification behavior (HTTP status parsing, retry // classification, attempt bound, exit codes) is exercised end-to-end in // paperclip-issue-update-helper.test.ts against a live local server. diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index d742de7524..f2ec350c7d 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -210,7 +210,7 @@ the routine server-verified external-chat handoff described above. **Bounded write retry.** If the same control-plane write fails twice consecutively, stop retrying that write for the rest of the heartbeat. Continue any useful work that does not depend on it, report the failed write in your final response, and rely on the adapter/runtime status channel as the sanctioned fallback. Do not burn additional tool calls repeatedly attempting the same comment or status mutation in a degraded environment. -**Verify writes — never infer them.** A successful `PATCH /api/issues/{id}` always returns the updated issue JSON. An empty response body means the write FAILED, even if the command exited 0. Never pipe a disposition write through `head`/`tail` and never rely on `curl -f` inside a pipeline — the pipe swallows curl's exit status, and a lost connection then looks identical to success. Use `scripts/paperclip-issue-update.sh` (it checks the HTTP status, retries connection-level failures, and confirms the echoed `status`); if you must hand-roll curl, capture `-w '%{http_code}'` and check the response echoes your update. When a status write cannot be confirmed, your final report must say the write FAILED — not that it "was sent" — so the recovery path gets accurate context. +**Verify writes — never infer them.** A successful `PATCH /api/issues/{id}` always returns the updated issue JSON. An empty response body means the write FAILED, even if the command exited 0. Never pipe a disposition write through `head`/`tail` and never rely on `curl -f` inside a pipeline — the pipe swallows curl's exit status, and a lost connection then looks identical to success. Use the bundled `scripts/paperclip-issue-update.sh`, resolved relative to this installed `SKILL.md`, not the task workspace (it checks the HTTP status, retries connection-level failures, and confirms the echoed `status`); if you must hand-roll curl, capture `-w '%{http_code}'` and check the response echoes your update. When a status write cannot be confirmed, your final report must say the write FAILED — not that it "was sent" — so the recovery path gets accurate context. Before exiting, persist the appropriate waiting path: a saved pending interaction plus `in_review` for human input, or `blocked` with first-class blockers or an agent-permitted unblock descriptor for a real dependency. A comment naming someone does not create that path. @@ -230,10 +230,10 @@ Headers: X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID { "status": "done", "comment": "What was done and why." } ``` -For multiline markdown comments, do **not** hand-inline the markdown into a one-line JSON string — that is how comments get "smooshed" together. Use the helper below (or an equivalent `jq --arg` pattern reading from a heredoc/file) so literal newlines survive JSON encoding: +For multiline comments, use a heredoc/file with the helper (or `jq --arg`) to preserve newlines. Set `paperclip_skill_dir` to the absolute directory containing this installed `SKILL.md`, using the skill path/base directory supplied by your harness. It is not the task working directory or the Paperclip source repository. If that path or helper is unavailable, use the verified PATCH request above; do not search the filesystem for it. ```bash -scripts/paperclip-issue-update.sh --issue-id "$PAPERCLIP_TASK_ID" --status done <<'MD' +bash "$paperclip_skill_dir/scripts/paperclip-issue-update.sh" --issue-id "$PAPERCLIP_TASK_ID" --status done <<'MD' Done - Fixed the newline-preserving issue update path diff --git a/skills/paperclip/scripts/paperclip-issue-update.sh b/skills/paperclip/scripts/paperclip-issue-update.sh new file mode 100755 index 0000000000..f8717e31c0 --- /dev/null +++ b/skills/paperclip/scripts/paperclip-issue-update.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + scripts/paperclip-issue-update.sh [--issue-id ID] [--status STATUS] [--comment TEXT] [--dry-run] + +Reads a multiline markdown comment from stdin when stdin is piped. This preserves +newlines when building the JSON payload for PATCH /api/issues/{issueId}. + +Examples: + scripts/paperclip-issue-update.sh --issue-id "$PAPERCLIP_TASK_ID" --status in_progress <<'MD' + Investigating formatting + + - Pulled the raw comment body + - Comparing it with the run transcript + MD + + scripts/paperclip-issue-update.sh --issue-id "$PAPERCLIP_TASK_ID" --status done --dry-run <<'MD' + Done + + - Fixed the issue update helper + MD +EOF +} + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + printf 'Missing required command: %s\n' "$1" >&2 + exit 1 + fi +} + +issue_id="${PAPERCLIP_TASK_ID:-}" +status="" +comment_arg="" +dry_run=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --issue-id) + issue_id="${2:-}" + shift 2 + ;; + --status) + status="${2:-}" + shift 2 + ;; + --comment) + comment_arg="${2:-}" + shift 2 + ;; + --dry-run) + dry_run=1 + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + printf 'Unknown argument: %s\n' "$1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ -z "$issue_id" ]]; then + printf 'Missing issue id. Pass --issue-id or set PAPERCLIP_TASK_ID.\n' >&2 + exit 1 +fi + +comment="" +if [[ -n "$comment_arg" ]]; then + comment="$comment_arg" +elif [[ ! -t 0 ]]; then + comment="$(cat)" +fi + +require_command jq + +payload="$( + jq -nc \ + --arg status "$status" \ + --arg comment "$comment" \ + ' + (if $status == "" then {} else {status: $status} end) + + (if $comment == "" then {} else {comment: $comment} end) + ' +)" + +if [[ "$dry_run" == "1" ]]; then + printf '%s\n' "$payload" + exit 0 +fi + +if [[ -z "${PAPERCLIP_API_URL:-}" || -z "${PAPERCLIP_API_KEY:-}" || -z "${PAPERCLIP_RUN_ID:-}" ]]; then + printf 'Missing PAPERCLIP_API_URL, PAPERCLIP_API_KEY, or PAPERCLIP_RUN_ID.\n' >&2 + exit 1 +fi + +# A successful PATCH always returns the updated issue JSON. An empty body or a +# connection-level failure means the write did NOT land, even when a pipeline +# exit code says otherwise, so verify the response instead of inferring success. +# Two attempts total: the shared heartbeat policy stops a control-plane write +# after two consecutive failures, so the helper must not send a third. +max_attempts=2 +attempt=1 +while :; do + http_code="" + body="" + set +e + response="$( + curl -sS -m 30 -X PATCH \ + "$PAPERCLIP_API_URL/api/issues/$issue_id" \ + -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ + -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \ + -H 'Content-Type: application/json' \ + --data-binary "$payload" \ + -w '\n%{http_code}' + )" + curl_exit=$? + set -e + + if [[ "$curl_exit" -eq 0 ]]; then + http_code="${response##*$'\n'}" + body="${response%$'\n'*}" + fi + + if [[ "$curl_exit" -eq 0 && "$http_code" == 2* ]]; then + if [[ -z "$body" ]]; then + printf 'Issue update FAILED: HTTP %s with an empty response body. A real update echoes the issue JSON; treat this write as not saved.\n' "$http_code" >&2 + exit 1 + fi + if [[ -n "$status" ]]; then + returned_status="$(jq -r '.status // empty' <<<"$body" 2>/dev/null || true)" + if [[ "$returned_status" != "$status" ]]; then + printf 'Issue update FAILED: server echoed status %s instead of requested %s.\n' "${returned_status:-}" "$status" >&2 + printf '%s\n' "$body" >&2 + exit 1 + fi + fi + printf '%s\n' "$body" + exit 0 + fi + + # 4xx (other than 429) is a definitive rejection; retrying cannot change it. + if [[ "$curl_exit" -eq 0 && "$http_code" == 4* && "$http_code" != "429" ]]; then + printf 'Issue update rejected (HTTP %s).\n' "$http_code" >&2 + [[ -n "$body" ]] && printf '%s\n' "$body" >&2 + exit 1 + fi + + if (( attempt >= max_attempts )); then + printf 'Issue update FAILED after %d attempts (curl exit %s, HTTP %s). The status/comment was NOT saved — report this write as failed, do not assume it landed.\n' "$max_attempts" "$curl_exit" "${http_code:-000}" >&2 + [[ -n "$body" ]] && printf '%s\n' "$body" >&2 + exit 1 + fi + + printf 'Issue update attempt %d/%d failed (curl exit %s, HTTP %s); retrying...\n' "$attempt" "$max_attempts" "$curl_exit" "${http_code:-000}" >&2 + sleep $((attempt * 2)) + attempt=$((attempt + 1)) +done diff --git a/tests/runner-e2e/stock-harness-checks.d.mts b/tests/runner-e2e/stock-harness-checks.d.mts new file mode 100644 index 0000000000..df154fc2fa --- /dev/null +++ b/tests/runner-e2e/stock-harness-checks.d.mts @@ -0,0 +1,4 @@ +export function sourceFingerprint(): { + fingerprint: string; + sourceErrors: string[]; +}; diff --git a/tests/runner-e2e/stock-harness-checks.mjs b/tests/runner-e2e/stock-harness-checks.mjs index 9b6c4e975f..01d5c5a3cc 100644 --- a/tests/runner-e2e/stock-harness-checks.mjs +++ b/tests/runner-e2e/stock-harness-checks.mjs @@ -33,10 +33,12 @@ export const stockHarnessGates = [ "packages/adapters/opencode-local/src/server/execute.test.ts", "packages/adapters/cursor-cloud/src/server/execute.test.ts", "server/src/__tests__/codex-local-execute.test.ts", + "server/src/__tests__/paperclip-issue-update-helper.test.ts", ], required: ["keeps task and chat defaults to identity and connection guidance", "does not restore generic procedures on resume or with the legacy opt-in", "integrates the env-free store", "advertises folded routing metadata", "bounds routing descriptions", - "loads an old env-bearing file with rotated credentials", "drops a skill that fails to materialize"] }, + "loads an old env-bearing file with rotated credentials", "drops a skill that fails to materialize", "exits 0 and prints the issue JSON when the server echoes the requested status", + "fails an empty 2xx body instead of treating it as success"] }, // Hermes is not in the root Vitest project list. Run its package config so // the requested file cannot silently disappear from discovery. { id: "SH-3-hermes", name: "Hermes shared-prompt delivery", cwd: "packages/adapters/hermes", @@ -113,11 +115,13 @@ export function sourceFingerprint() { const sourceErrors = []; sources.add("skills/paperclip/SKILL.md"); sources.add("skills/paperclip/references/issue-documents.md"); + sources.add("scripts/paperclip-issue-update.sh"); + sources.add("skills/paperclip/scripts/paperclip-issue-update.sh"); for (const source of [...sources].sort()) { hash.update(source); try { hash.update("present\0").update(readFileSync(join(root, source))); } catch (error) { - if (source === "skills/paperclip/references/issue-documents.md" && error.code === "ENOENT") hash.update("absent\0"); + if ((source === "skills/paperclip/references/issue-documents.md" || source === "skills/paperclip/scripts/paperclip-issue-update.sh") && error.code === "ENOENT") hash.update("absent\0"); else sourceErrors.push(source); } } diff --git a/tests/runner-e2e/stock-harness-digest.test.ts b/tests/runner-e2e/stock-harness-digest.test.ts index 3f7a4a0810..a1287a6eb1 100644 --- a/tests/runner-e2e/stock-harness-digest.test.ts +++ b/tests/runner-e2e/stock-harness-digest.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { sourceFingerprint } from "./stock-harness-checks.mjs"; import { stockHarnessSourceDigest, stockHarnessSkillSources } from "./stock-harness.js"; @@ -17,13 +18,38 @@ describe("stock harness instruction revision", () => { expect(changed.fingerprint).not.toBe(original.fingerprint); }); - it.each(["tests/runner-e2e/checkout-activity.ts", "server/src/onboarding-assets/default/AGENTS.md", "packages/adapter-utils/src/server-utils.ts", "packages/shared/src/connection-intent-guidance.ts", "skills/paperclip/SKILL.md", "skills/paperclip/references/issue-documents.md", "packages/paperclip-runner/generated/capability/capabilities.yaml", "packages/paperclip-runner/spec/capability/capabilities.yaml", "tests/runner-e2e/stock-harness-manifest.ts", "packages/adapter-utils/src/acpx-engine/execute.ts", "packages/adapter-utils/src/acpx-engine/ephemeral-session-environment.ts", "tests/runner-e2e/stock-harness-instruction-variant.mjs", "tests/runner-e2e/automatic-retry.ts", "tests/runner-e2e/catalog.ts"])( + it.each(["scripts/paperclip-issue-update.sh", "skills/paperclip/scripts/paperclip-issue-update.sh", "tests/runner-e2e/checkout-activity.ts", "server/src/onboarding-assets/default/AGENTS.md", "packages/adapter-utils/src/server-utils.ts", "packages/shared/src/connection-intent-guidance.ts", "skills/paperclip/SKILL.md", "skills/paperclip/references/issue-documents.md", "packages/paperclip-runner/generated/capability/capabilities.yaml", "packages/paperclip-runner/spec/capability/capabilities.yaml", "tests/runner-e2e/stock-harness-manifest.ts", "packages/adapter-utils/src/acpx-engine/execute.ts", "packages/adapter-utils/src/acpx-engine/ephemeral-session-environment.ts", "tests/runner-e2e/stock-harness-instruction-variant.mjs", "tests/runner-e2e/automatic-retry.ts", "tests/runner-e2e/catalog.ts"])( "changes when the evaluated %s changes", source => { vi.mocked(readFileSync).mockImplementation(() => Buffer.from("unchanged")); const original = stockHarnessSourceDigest(); vi.mocked(readFileSync).mockImplementation(file => Buffer.from(String(file).endsWith(source) ? "changed instructions" : "unchanged")); expect(stockHarnessSourceDigest()).not.toBe(original); }); + it.each(["scripts/paperclip-issue-update.sh", "skills/paperclip/scripts/paperclip-issue-update.sh"])( + "invalidates the prerequisite fingerprint when %s changes", source => { + vi.mocked(readFileSync).mockImplementation(() => Buffer.from("unchanged")); + const original = sourceFingerprint(); + vi.mocked(readFileSync).mockImplementation(file => Buffer.from( + String(file) === resolve(import.meta.dirname, "../..", source) ? "changed helper" : "unchanged")); + expect(sourceFingerprint().fingerprint).not.toBe(original.fingerprint); + }); + it("records a missing historical bundled helper without accepting unreadable helper files", () => { + vi.mocked(readFileSync).mockImplementation(() => Buffer.from("unchanged")); + const original = sourceFingerprint(); + const digest = stockHarnessSourceDigest(); + vi.mocked(readFileSync).mockImplementation(file => { + if (String(file).endsWith("skills/paperclip/scripts/paperclip-issue-update.sh")) + throw Object.assign(new Error("absent"), { code: "ENOENT" }); + return Buffer.from("unchanged"); + }); + expect(sourceFingerprint().sourceErrors).toEqual([]); + expect(sourceFingerprint().fingerprint).not.toBe(original.fingerprint); + expect(stockHarnessSourceDigest()).not.toBe(digest); + expect(stockHarnessSkillSources()[3]).toMatchObject({ present: false, sha256: null }); + vi.mocked(readFileSync).mockImplementation(() => { throw Object.assign(new Error("unreadable"), { code: "EACCES" }); }); + expect(stockHarnessSourceDigest).toThrow("unreadable"); + expect(sourceFingerprint().sourceErrors).toContain("skills/paperclip/scripts/paperclip-issue-update.sh"); + }); it("records an absent historical recipe without introducing its content or hiding other read errors", () => { vi.mocked(readFileSync).mockImplementation(() => Buffer.from("unchanged")); const present = stockHarnessSourceDigest(); diff --git a/tests/runner-e2e/stock-harness.ts b/tests/runner-e2e/stock-harness.ts index f4830cf077..df6db87d93 100644 --- a/tests/runner-e2e/stock-harness.ts +++ b/tests/runner-e2e/stock-harness.ts @@ -7,14 +7,16 @@ import { readStockInstructionVariant } from "./stock-harness-instruction-variant // constants. Otherwise a larger shipped manual could silently update the oracle. export const STOCK_HIRE_IDENTITY = "You are an agent in a Paperclip company.\n"; export const STOCK_TEMPLATE_IDENTITY = "You are agent"; -const SKILL_SOURCES = ["../../skills/paperclip/SKILL.md", "../../skills/paperclip/references/issue-documents.md"]; +const SKILL_SOURCES = ["../../skills/paperclip/SKILL.md", "../../skills/paperclip/references/issue-documents.md", + "../../scripts/paperclip-issue-update.sh", "../../skills/paperclip/scripts/paperclip-issue-update.sh"]; export function stockHarnessSkillSources() { return SKILL_SOURCES.map(source => { try { return { path: source.replace(/^\.\.\/\.\.\//, ""), present: true, sha256: createHash("sha256").update(readFileSync(new URL(source, import.meta.url))).digest("hex") }; } catch (error) { - if (source.endsWith("/references/issue-documents.md") && (error as NodeJS.ErrnoException).code === "ENOENT") + if ((source.endsWith("/references/issue-documents.md") || source === "../../skills/paperclip/scripts/paperclip-issue-update.sh") + && (error as NodeJS.ErrnoException).code === "ENOENT") return { path: source.replace(/^\.\.\/\.\.\//, ""), present: false, sha256: null }; throw error; }