diff --git a/.agents/skills/create-agent-adapter/SKILL.md b/.agents/skills/create-agent-adapter/SKILL.md
index 6d11362b09..8c87c349e5 100644
--- a/.agents/skills/create-agent-adapter/SKILL.md
+++ b/.agents/skills/create-agent-adapter/SKILL.md
@@ -156,7 +156,7 @@ Guidelines:
- Use `info` for successful checks and context.
Severity policy is product-critical: warnings are not save blockers.
-Example: for `claude_local`, detected `ANTHROPIC_API_KEY` must be a `warn`, not an `error`, because Claude can still run (it just uses API-key auth instead of subscription auth).
+Example: for `claude_local`, an explicitly configured `ANTHROPIC_API_KEY` or selected managed API connection is `info`: the user chose that authentication. An ambient server key overriding subscription login remains `warn`, not `error`.
---
diff --git a/.github/cloud-migrator-deploy/cloudfront-read-statement.json b/.github/cloud-migrator-deploy/cloudfront-read-statement.json
new file mode 100644
index 0000000000..cf36a18d2f
--- /dev/null
+++ b/.github/cloud-migrator-deploy/cloudfront-read-statement.json
@@ -0,0 +1,14 @@
+{
+ "Sid": "AllowCloudFrontReadCloudMigrators",
+ "Effect": "Allow",
+ "Principal": {
+ "Service": "cloudfront.amazonaws.com"
+ },
+ "Action": "s3:GetObject",
+ "Resource": "arn:aws:s3:::paperclipai-runner-e2e-history-078455283791-us-east-1/cloud-migrators/v1/*",
+ "Condition": {
+ "StringEquals": {
+ "AWS:SourceArn": "arn:aws:cloudfront::078455283791:distribution/E3GTU28BBO2SFR"
+ }
+ }
+}
diff --git a/.github/cloud-migrator-deploy/trust-policy.json b/.github/cloud-migrator-deploy/trust-policy.json
new file mode 100644
index 0000000000..db8d148aa3
--- /dev/null
+++ b/.github/cloud-migrator-deploy/trust-policy.json
@@ -0,0 +1,18 @@
+{
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Effect": "Allow",
+ "Principal": {
+ "Federated": "arn:aws:iam::078455283791:oidc-provider/token.actions.githubusercontent.com"
+ },
+ "Action": "sts:AssumeRoleWithWebIdentity",
+ "Condition": {
+ "StringEquals": {
+ "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
+ "token.actions.githubusercontent.com:sub": "repo:paperclipai/paperclip:ref:refs/heads/master"
+ }
+ }
+ }
+ ]
+}
diff --git a/.github/cloud-migrator-deploy/upload-policy.json b/.github/cloud-migrator-deploy/upload-policy.json
new file mode 100644
index 0000000000..478ba9d36d
--- /dev/null
+++ b/.github/cloud-migrator-deploy/upload-policy.json
@@ -0,0 +1,25 @@
+{
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Effect": "Allow",
+ "Action": "s3:PutObject",
+ "Resource": "arn:aws:s3:::paperclipai-runner-e2e-history-078455283791-us-east-1/cloud-migrators/v1/*",
+ "Condition": {
+ "StringEquals": {
+ "s3:if-none-match": "*"
+ }
+ }
+ },
+ {
+ "Effect": "Allow",
+ "Action": "s3:ListBucket",
+ "Resource": "arn:aws:s3:::paperclipai-runner-e2e-history-078455283791-us-east-1",
+ "Condition": {
+ "StringLike": {
+ "s3:prefix": "cloud-migrators/v1/*"
+ }
+ }
+ }
+ ]
+}
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 9aae3c6d06..0f234d2553 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -37,3 +37,6 @@ updates:
day: monday
time: "06:00"
open-pull-requests-limit: 5
+ ignore:
+ # This first-party workflow follows CODEOWNERS-protected master.
+ - dependency-name: "paperclipai/paperclip/.github/workflows/pr-trusted.yml"
diff --git a/.github/scripts/tests/cloud-readiness.test.mjs b/.github/scripts/tests/cloud-readiness.test.mjs
index 25233a335b..da4dd06bb6 100644
--- a/.github/scripts/tests/cloud-readiness.test.mjs
+++ b/.github/scripts/tests/cloud-readiness.test.mjs
@@ -1,20 +1,47 @@
import test from "node:test";
import assert from "node:assert/strict";
-import { readFileSync } from "node:fs";
-import { waitForCloudArtifacts } from "../../../scripts/cloud-readiness.mjs";
+import { existsSync, readFileSync } from "node:fs";
+import { gzipSync } from "node:zlib";
+import { waitForCloudArtifacts, verifyManifestProvenance, migratorPublished } from "../../../scripts/cloud-readiness.mjs";
+import { artifactBase, descriptor } from "../../../scripts/cloud-migrator-artifacts.mjs";
import { previewManifest } from "../../../scripts/preview-artifacts.mjs";
const sha = "a".repeat(40);
+const version = `0.0.0-preview.g${sha}`;
const digest = `sha256:${"b".repeat(64)}`;
const json = (body, status = 200) => new Response(JSON.stringify(body), { status });
-function registry({ missing = new Set(), failure, wrongImage = false, wrongPackage = false } = {}) {
- return async (url) => {
+const producer = { id: 123, head_sha: sha, head_branch: "master", path: ".github/workflows/cloud-migrator-artifacts.yml",
+ head_repository: { id: 1170821064, full_name: "paperclipai/paperclip" }, event: "push", status: "completed", conclusion: "success" };
+function bundle() {
+ const packages = {}; const files = new Map();
+ const entries = { "": { dependencies: { "@paperclipai/db": version } } };
+ for (const name of ["db", "shared"]) {
+ const metadata = previewManifest({ name: `@paperclipai/${name}`, dependencies: {} }, sha);
+ const bytes = Buffer.from(JSON.stringify(metadata));
+ const header = Buffer.alloc(512); header.write("package/package.json"); header.write(bytes.length.toString(8).padStart(11, "0"), 124, 11); header[156] = 48;
+ const padded = Buffer.alloc(Math.ceil(bytes.length / 512) * 512); bytes.copy(padded);
+ const archive = gzipSync(Buffer.concat([header, padded, Buffer.alloc(1024)]));
+ const pin = descriptor(archive, "tgz"); packages[name] = pin; files.set(pin.url, archive);
+ entries[`node_modules/@paperclipai/${name}`] = { version, resolved: pin.url, integrity: pin.integrity, dependencies: metadata.dependencies };
+ }
+ const lock = Buffer.from(JSON.stringify({ lockfileVersion: 3, packages: entries }));
+ const manifest = { version: 1, sourceSha: sha, packageVersion: version, packages, lockfile: descriptor(lock, "json") };
+ files.set(manifest.lockfile.url, lock);
+ const bytes = Buffer.from(JSON.stringify(manifest) + "\n");
+ files.set(`${artifactBase}/${sha}/manifest.json`, bytes);
+ return { manifest, bytes, files };
+}
+function registry({ missing = new Set(), failure, wrongImage = false, run = producer, objects = bundle() } = {}) {
+ return async (url, options) => {
+ assert.ok(!url.startsWith("https://registry.npmjs.org/"), "readiness must never wait for npm");
if (failure) return json({}, failure);
- if (url.startsWith("https://registry.npmjs.org/")) {
- const name = decodeURIComponent(new URL(url).pathname.split("/")[1]);
- if (missing.has(name.split("/")[1])) return json({}, 404);
- const pkg = previewManifest({ name, version: "0.0.0" }, sha);
- return json({ ...pkg, ...(wrongPackage ? { gitHead: "c".repeat(40) } : {}), dist: { integrity: "sha512-fixture", tarball: "https://registry.npmjs.org/fixture.tgz" } });
+ if (url.startsWith("https://api.github.com/")) {
+ assert.match(url, new RegExp(`head_sha=${sha}&per_page=100&page=1$`));
+ return json({ total_count: missing.has("migrator") ? 0 : 1, workflow_runs: missing.has("migrator") ? [] : [run] });
+ }
+ if (url.startsWith(artifactBase)) {
+ assert.equal(options.headers?.Authorization, undefined, "GitHub credentials stay off the artifact origin");
+ return objects.files.has(url) ? new Response(objects.files.get(url)) : json({}, 403);
}
if (url.includes("/token?")) return json({ token: "fixture" });
if (url.includes("/manifests/")) return missing.has("image") ? json({}, 404) : json({ config: { digest } });
@@ -22,70 +49,96 @@ function registry({ missing = new Set(), failure, wrongImage = false, wrongPacka
throw new Error(`Unexpected request: ${url}`);
};
}
+const noSignature = async () => {}; // Signature enforcement is exercised separately below.
-test("readiness requires the image and both exact-source packages on the successful poll", async () => {
- const missing = new Set(["image", "shared", "db"]);
- let clock = 0;
- const states = [];
+test("readiness rechecks image and publisher, then verifies the exact signed bundle with no npm requests", async () => {
+ const missing = new Set(["image", "migrator"]); const objects = bundle(); let clock = 0; let signatures = 0;
const result = await waitForCloudArtifacts(sha, {
- fetchImpl: registry({ missing }), now: () => clock, intervalMs: 10, timeoutMs: 100, log: (message) => states.push(message),
+ fetchImpl: registry({ missing, objects }), token: "fixture", now: () => clock, intervalMs: 10, timeoutMs: 100, log: () => {},
+ verifyProvenance: async (bytes, source) => { assert.deepEqual(bytes, objects.bytes); assert.equal(source, sha); signatures++; },
sleep: async (ms) => {
clock += ms;
if (clock === 10) missing.delete("image");
- if (clock === 20) missing.delete("shared");
- if (clock === 30) { missing.delete("db"); missing.add("image"); }
- if (clock === 40) missing.delete("image");
+ if (clock === 20) { missing.delete("migrator"); missing.add("image"); }
+ if (clock === 30) missing.delete("image");
},
});
- assert.equal(clock, 40, "an artifact disappearing before the final poll must prevent readiness");
- assert.deepEqual(result, { version: 1, sha, packageVersion: `0.0.0-preview.g${sha}` });
- assert.match(states.at(-1), /Cloud artifacts available/);
+ assert.equal(clock, 30); assert.equal(signatures, 1);
+ assert.deepEqual(result, { version: 1, sha, packageVersion: version });
});
-test("missing artifacts time out with a precise inventory and bounded sleep", async () => {
- let clock = 0;
- const sleeps = [];
- await assert.rejects(waitForCloudArtifacts(sha, {
- fetchImpl: registry({ missing: new Set(["db"]) }), now: () => clock, timeoutMs: 25, intervalMs: 20, log: () => {},
- sleep: async (ms) => { sleeps.push(ms); clock += ms; },
- }), /timed out.*missing: db/);
- assert.deepEqual(sleeps, [20, 5]);
-});
-
-for (const fixture of [{ failure: 403 }, { failure: 503 }, { wrongImage: true }, { wrongPackage: true }]) {
- test(`registry errors and identity mismatches fail without waiting: ${JSON.stringify(fixture)}`, async () => {
+test("missing or in-progress publishers time out with a precise inventory and bounded sleep", async () => {
+ for (const fixture of [{ missing: new Set(["migrator"]) }, { run: { ...producer, status: "in_progress", conclusion: null } }]) {
+ let clock = 0; const sleeps = [];
await assert.rejects(waitForCloudArtifacts(sha, {
- fetchImpl: registry(fixture), sleep: async () => assert.fail("must not retry an invalid artifact or upstream error"), log: () => {},
- }));
+ fetchImpl: registry(fixture), now: () => clock, timeoutMs: 25, intervalMs: 20, log: () => {}, verifyProvenance: noSignature,
+ sleep: async (ms) => { sleeps.push(ms); clock += ms; },
+ }), /timed out.*missing: migrator/);
+ assert.deepEqual(sleeps, [20, 5]);
+ }
+});
+
+for (const fixture of [{ failure: 403 }, { failure: 503 }, { wrongImage: true },
+ ...["failure", "cancelled", "skipped"].map((conclusion) => ({ run: { ...producer, conclusion } })),
+ ...[{ head_sha: "b".repeat(40) }, { head_branch: "feature" }, { path: ".github/workflows/evil.yml" },
+ { head_repository: { id: 123, full_name: "someone/paperclip" } }, { event: "pull_request" }].map((wrong) => ({ run: { ...producer, ...wrong } }))]) {
+ test(`upstream errors, failed publication and identity mismatches fail immediately: ${JSON.stringify(fixture)}`, async () => {
+ await assert.rejects(waitForCloudArtifacts(sha, { fetchImpl: registry(fixture), verifyProvenance: noSignature,
+ sleep: async () => assert.fail("must not retry an invalid artifact or upstream error"), log: () => {} }));
});
}
+test("successful publication cannot hide inaccessible or corrupt archives or an invalid signature", async () => {
+ for (const corrupt of [false, true]) {
+ const objects = bundle();
+ if (corrupt) objects.files.set(objects.manifest.packages.db.url, Buffer.from("corrupt"));
+ else objects.files.delete(objects.manifest.packages.db.url);
+ await assert.rejects(waitForCloudArtifacts(sha, { fetchImpl: registry({ objects }), verifyProvenance: noSignature, log: () => {} }), /download failed|immutable pin/);
+ }
+ await assert.rejects(waitForCloudArtifacts(sha, { fetchImpl: registry(), verifyProvenance: async () => { throw new Error("invalid signature"); }, log: () => {} }), /invalid signature/);
+});
+
+test("CLI verifies the exact bytes, source, master workflow and hosted runner and cleans up on failure", () => {
+ let temporary;
+ assert.throws(() => verifyManifestProvenance(Buffer.from("exact manifest\n"), sha, { exec: (cmd, args) => {
+ assert.equal(cmd, "gh"); assert.deepEqual(args.slice(0, 2), ["attestation", "verify"]); temporary = args[2];
+ assert.equal(readFileSync(temporary, "utf8"), "exact manifest\n");
+ for (const [flag, value] of [["--repo", "paperclipai/paperclip"], ["--source-digest", sha], ["--source-ref", "refs/heads/master"],
+ ["--cert-identity", "https://github.com/paperclipai/paperclip/.github/workflows/cloud-migrator-artifacts.yml@refs/heads/master"]]) assert.equal(args[args.indexOf(flag) + 1], value);
+ assert.ok(args.includes("--deny-self-hosted-runners")); throw new Error("verification rejected");
+ } }), /verification rejected/);
+ assert.equal(existsSync(temporary), false);
+});
+
test("invalid source and timing configuration are rejected before registry access", async () => {
const fetchImpl = async () => assert.fail("invalid inputs must not reach a registry");
await assert.rejects(waitForCloudArtifacts("master", { fetchImpl }), /full immutable commit SHA/);
- for (const options of [{ timeoutMs: 0 }, { intervalMs: -1 }, { timeoutMs: Infinity }]) {
- await assert.rejects(waitForCloudArtifacts(sha, { ...options, fetchImpl }), /positive finite/);
- }
+ for (const options of [{ timeoutMs: 0 }, { intervalMs: -1 }, { timeoutMs: Infinity }]) await assert.rejects(waitForCloudArtifacts(sha, { ...options, fetchImpl }), /positive finite/);
});
-test("the versioned readiness job requires successful source, image and artifact jobs", () => {
+test("versioned readiness retains every source gate and removes duplicate automatic npm publication", () => {
const workflow = readFileSync(new URL("../../workflows/cloud-readiness.yml", import.meta.url), "utf8");
assert.match(workflow, /push:\s*\n\s*branches: \[master\]/);
- assert.match(workflow, /group: cloud-readiness-\$\{\{ github.sha \}\}/);
assert.match(workflow, /uses: \.\/\.github\/workflows\/release-verify.yml\s+with:\s+ref: \$\{\{ github.sha \}\}/);
assert.match(workflow, /uses: \.\/\.github\/workflows\/docker-cloud.yml/);
+ assert.match(workflow, /attestations: read/); assert.match(workflow, /GH_TOKEN: \$\{\{ github.token \}\}/);
const ready = workflow.split(" ready:")[1];
- assert.match(ready, /name: Cloud deployable v1/);
- assert.match(ready, /needs: \[verify, image, artifacts\]/);
+ assert.match(ready, /name: Cloud deployable v1/); assert.match(ready, /needs: \[verify, image, artifacts\]/);
assert.match(ready, /if: github.repository == 'paperclipai\/paperclip' && github.ref == 'refs\/heads\/master'/);
assert.doesNotMatch(ready, /^\s*(?:if:.*always\(|continue-on-error:)/m);
assert.doesNotMatch(workflow, /secrets: inherit|id-token: write|actions: write|checks: write|uses: .*@v\d\b/);
- const cloud = readFileSync(new URL("../../workflows/docker-cloud.yml", import.meta.url), "utf8");
- assert.doesNotMatch(cloud, /^ push:/m, "the master image must build only once");
- const migrator = readFileSync(new URL("../../workflows/cloud-artifacts.yml", import.meta.url), "utf8");
- assert.match(migrator, /push:\s*\n\s*branches: \[master\]/);
- assert.match(migrator, /SOURCE_SHA: \$\{\{ github.sha \}\}/);
- assert.match(migrator, /gh workflow run release.yml .*--ref master/);
- assert.match(migrator, /--field channel=cloud-migrator/);
- assert.match(migrator, /--field source_ref="\$SOURCE_SHA"/);
+ assert.equal(existsSync(new URL("../../workflows/cloud-artifacts.yml", import.meta.url)), false);
+});
+
+
+test("later manual failures or pending retries cannot hide an earlier successful immutable publication", async () => {
+ for (const latest of [{ status: "completed", conclusion: "failure" }, { status: "in_progress", conclusion: null }]) {
+ let calls = 0;
+ assert.equal(await migratorPublished(sha, async (url) => {
+ calls++;
+ if (url.endsWith("page=1")) return json({ total_count: 101, workflow_runs: Array.from({ length: 100 }, (_, i) => ({ ...producer, ...latest, id: 200 + i, event: "workflow_dispatch" })) });
+ assert.ok(url.endsWith("page=2")); return json({ total_count: 101, workflow_runs: [producer] });
+ }), true);
+ assert.equal(calls, 2);
+ }
});
diff --git a/.github/scripts/tests/post-merge-runner-routing.test.mjs b/.github/scripts/tests/post-merge-runner-routing.test.mjs
index 68a2cada62..b0adab147b 100644
--- a/.github/scripts/tests/post-merge-runner-routing.test.mjs
+++ b/.github/scripts/tests/post-merge-runner-routing.test.mjs
@@ -11,7 +11,7 @@ const base = {
};
const expectedJobs = {
"cloud-readiness.yml": [],
- "cloud-artifacts.yml": ["dispatch_migrator"],
+ "cloud-migrator-artifacts.yml": [],
"release-verify.yml": ["typecheck", "general_tests", "serialized_tests", "runner_workflow_evals", "verify_paperclip_runner", "build"],
"runner-chaos-evals.yml": ["chaos_and_recovery"],
"release.yml": ["plan_preview", "package_preview"],
diff --git a/.github/scripts/tests/pr-runner-rust-cache.test.mjs b/.github/scripts/tests/pr-runner-rust-cache.test.mjs
new file mode 100644
index 0000000000..59f3e2698d
--- /dev/null
+++ b/.github/scripts/tests/pr-runner-rust-cache.test.mjs
@@ -0,0 +1,55 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+
+const read = (name) => readFileSync(new URL(`../../workflows/${name}`, import.meta.url), "utf8");
+const prWorkflow = read("pr-trusted.yml");
+const releaseWorkflow = read("release-verify.yml");
+const pr = prWorkflow.split(" verify_paperclip_runner:")[1].split(" build:")[0];
+const release = releaseWorkflow.split(" verify_paperclip_runner:")[1].split(" build:")[0];
+
+// The key is computed from these inputs. A pull request that disagrees with
+// the master writer on any of them misses every time and silently recompiles
+// all 313 third-party crates in both profiles, which is exactly the cost this
+// restore exists to remove.
+const keyInputs = [
+ /uses: Swatinem\/rust-cache@([0-9a-f]{40}) # v[0-9.]+/,
+ /workspaces: (packages\/paperclip-runner\/runner -> target)/,
+ /shared-key: (release-runner-v1)/,
+ /cache-workspace-crates: (false)/,
+ /cache-bin: (false)/,
+];
+
+test("the PR lane restores the Rust cache under the same key the master push writes", () => {
+ for (const pattern of keyInputs) {
+ const mine = pr.match(pattern);
+ const theirs = release.match(pattern);
+ assert.ok(mine, `PR lane is missing ${pattern}`);
+ assert.ok(theirs, `master writer is missing ${pattern}`);
+ assert.equal(mine[1], theirs[1], `key input drifted from the master writer: ${pattern}`);
+ }
+ assert.doesNotMatch(pr, /prefix-key:|cache-on-failure: true|cache-all-crates: true/);
+});
+
+test("the PR lane pins the compiler before the key is computed", () => {
+ const select = pr.indexOf(" - name: Select the pinned Runner Rust toolchain");
+ const cache = pr.indexOf(" - name: Restore Runner Rust dependencies (read only)");
+ const verify = pr.indexOf(" - name: Verify Paperclip Runner\n");
+ assert.ok(select >= 0 && cache > select && verify > cache);
+ const setup = pr.slice(select, cache);
+ assert.match(setup, /working-directory: packages\/paperclip-runner/);
+ assert.match(setup, /rustup show active-toolchain/);
+ assert.match(setup, /echo "RUSTUP_TOOLCHAIN=\$toolchain" >> "\$GITHUB_ENV"/);
+ // The gate routes to either ubuntu-latest or the public PR fleet, so a
+ // missing rustup must cost the cache, never the pull request.
+ assert.match(setup, /command -v rustup/);
+ assert.doesNotMatch(setup, /set -euo pipefail/);
+});
+
+test("a pull request never writes to or evicts the master cache entry", () => {
+ const step = pr.split(" - name: Restore Runner Rust dependencies (read only)")[1]
+ .split(" - name: Verify Paperclip Runner\n")[0];
+ assert.equal(step.match(/^\s*save-if: (.+)$/m)?.[1], "false");
+ assert.doesNotMatch(step, /^\s*if:/m, "the restore must not be conditional; a miss is already free");
+ assert.doesNotMatch(prWorkflow, /uses: Swatinem\/rust-cache@[0-9a-f]{40}[\s\S]*?save-if: (?!false)/);
+});
diff --git a/.github/workflows/cloud-artifacts.yml b/.github/workflows/cloud-artifacts.yml
deleted file mode 100644
index ecc0dcadb2..0000000000
--- a/.github/workflows/cloud-artifacts.yml
+++ /dev/null
@@ -1,34 +0,0 @@
-name: Cloud artifacts
-
-on:
- push:
- branches: [master]
- workflow_dispatch:
-
-permissions: {}
-
-jobs:
- dispatch_migrator:
- name: Start exact-source cloud migrator publication
- if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master'
- runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
- timeout-minutes: 5
- permissions:
- actions: write
- steps:
- # This separate workflow starts at merge, outside the full npm release's
- # concurrency group. Publication stays in release.yml so npm recognizes
- # the established trusted-publisher identity and npm-canary environment.
- # No source checkout or package code runs with the dispatch credential.
- - name: Dispatch the migrator-only release
- env:
- GH_TOKEN: ${{ github.token }}
- SOURCE_SHA: ${{ github.sha }}
- run: |
- set -euo pipefail
- request_id="$(cat /proc/sys/kernel/random/uuid)"
- gh workflow run release.yml --repo "$GITHUB_REPOSITORY" --ref master \
- --field channel=cloud-migrator \
- --field source_ref="$SOURCE_SHA" \
- --field request_id="$request_id"
- echo "Started Cloud migrator $SOURCE_SHA in release.yml (request $request_id)." >> "$GITHUB_STEP_SUMMARY"
diff --git a/.github/workflows/cloud-migrator-artifacts.yml b/.github/workflows/cloud-migrator-artifacts.yml
new file mode 100644
index 0000000000..f4da1b25b1
--- /dev/null
+++ b/.github/workflows/cloud-migrator-artifacts.yml
@@ -0,0 +1,90 @@
+name: Cloud migrator artifacts
+run-name: Cloud migrator artifacts ${{ github.sha }}
+
+on:
+ push:
+ branches: [master]
+ workflow_dispatch:
+
+permissions: {}
+concurrency:
+ group: cloud-migrator-artifacts-${{ github.sha }}
+ cancel-in-progress: false
+
+jobs:
+ build:
+ if: github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master'
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ ref: ${{ github.sha }}
+ persist-credentials: false
+ - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
+ with:
+ version: 9.15.4
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
+ with:
+ node-version: 24
+ - name: Install migrator build dependencies
+ run: pnpm install --ignore-scripts --no-frozen-lockfile --filter @paperclipai/db... --filter @paperclipai/shared...
+ - name: Build exact-source packages and dependency lockfile
+ env:
+ SOURCE_SHA: ${{ github.sha }}
+ run: |
+ node scripts/preview-artifacts.mjs pack . migrator-artifacts "$SOURCE_SHA"
+ node scripts/cloud-migrator-artifacts.mjs build migrator-artifacts "$SOURCE_SHA"
+ node scripts/cloud-migrator-artifacts.mjs verify-install migrator-artifacts "$SOURCE_SHA"
+ - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: cloud-migrator-bundle
+ path: |
+ migrator-artifacts/db.tgz
+ migrator-artifacts/shared.tgz
+ migrator-artifacts/package-lock.json
+ migrator-artifacts/manifest.json
+ if-no-files-found: error
+ retention-days: 3
+
+ publish:
+ needs: build
+ if: github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master'
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ permissions:
+ contents: read
+ id-token: write
+ attestations: write
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ ref: ${{ github.sha }}
+ persist-credentials: false
+ sparse-checkout: scripts
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
+ with:
+ node-version: 24
+ - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
+ with:
+ name: cloud-migrator-bundle
+ path: migrator-artifacts
+ - name: Validate the complete bundle before attestation
+ env:
+ SOURCE_SHA: ${{ github.sha }}
+ run: node scripts/cloud-migrator-artifacts.mjs validate migrator-artifacts "$SOURCE_SHA"
+ - name: Attest the manifest and every content hash it pins
+ uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4
+ with:
+ subject-path: migrator-artifacts/manifest.json
+ - uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6
+ with:
+ role-to-assume: arn:aws:iam::078455283791:role/paperclip-cloud-migrator-github
+ aws-region: us-east-1
+ role-duration-seconds: 900
+ - name: Publish immutable migrator and verify public downloads
+ env:
+ SOURCE_SHA: ${{ github.sha }}
+ run: node scripts/cloud-migrator-artifacts.mjs publish migrator-artifacts "$SOURCE_SHA"
diff --git a/.github/workflows/cloud-readiness.yml b/.github/workflows/cloud-readiness.yml
index 453cad2172..0da006cb45 100644
--- a/.github/workflows/cloud-readiness.yml
+++ b/.github/workflows/cloud-readiness.yml
@@ -37,6 +37,8 @@ jobs:
timeout-minutes: 35
permissions:
contents: read
+ actions: read
+ attestations: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
@@ -46,6 +48,7 @@ jobs:
node-version: 24
- name: Wait for verified image and exact-source migrator
env:
+ GH_TOKEN: ${{ github.token }}
SOURCE_SHA: ${{ github.sha }}
run: node scripts/cloud-readiness.mjs "$SOURCE_SHA"
@@ -91,6 +94,8 @@ jobs:
env:
SOURCE_SHA: ${{ github.sha }}
run: |
- echo "Cloud deployable v1: $SOURCE_SHA" >> "$GITHUB_STEP_SUMMARY"
- echo "Source verification passed; the full-SHA image and exact-source migrator are available." >> "$GITHUB_STEP_SUMMARY"
- echo "Deployment tooling must still resolve and pin the image and migrator and validate migration compatibility." >> "$GITHUB_STEP_SUMMARY"
+ {
+ echo "Cloud deployable v1: $SOURCE_SHA"
+ echo "Source verification passed; the full-SHA image and exact-source migrator are available."
+ echo "Deployment tooling must still resolve and pin the image and migrator and validate migration compatibility."
+ } >> "$GITHUB_STEP_SUMMARY"
diff --git a/.github/workflows/pr-trusted.yml b/.github/workflows/pr-trusted.yml
index f8b68cac61..fcb8a41f39 100644
--- a/.github/workflows/pr-trusted.yml
+++ b/.github/workflows/pr-trusted.yml
@@ -658,6 +658,42 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
+ # Same restore-only contract as the pnpm store above, for the Rust
+ # dependency tree: master's post-merge verification is the sole writer
+ # of release-runner-v1, and PR merge refs must not save branch-scoped
+ # copies of a ~680MB target directory. Every key input below has to
+ # match that writer in release-verify.yml exactly or each PR misses and
+ # recompiles all 313 third-party crates in both profiles. A miss is a
+ # slow run, never a wrong one.
+ - name: Select the pinned Runner Rust toolchain
+ working-directory: packages/paperclip-runner
+ run: |
+ set -uo pipefail
+
+ # release-verify.yml runs on a single post-merge fleet image; the
+ # gate here can route to ubuntu-latest or the public PR fleet, so
+ # this tolerates an image without rustup instead of failing every
+ # pull request. Without the pin the cache key simply will not match.
+ if ! command -v rustup >/dev/null 2>&1; then
+ echo '::notice title=Runner Rust cache::rustup is unavailable; building with the image default toolchain'
+ exit 0
+ fi
+
+ rustup show
+ toolchain="$(rustup show active-toolchain | awk '{print $1}')"
+ echo "RUSTUP_TOOLCHAIN=$toolchain" >> "$GITHUB_ENV"
+
+ - name: Restore Runner Rust dependencies (read only)
+ uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
+ with:
+ workspaces: packages/paperclip-runner/runner -> target
+ shared-key: release-runner-v1
+ # Mirror the master writer: these also feed the cache key.
+ cache-workspace-crates: false
+ cache-bin: false
+ # Restore only. Never let a pull request evict master's entry.
+ save-if: false
+
- name: Verify Paperclip Runner
run: pnpm --filter @paperclipai/paperclip-runner check:all
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index f2dff59b78..388c13838c 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -10,5 +10,6 @@ permissions:
jobs:
ci:
- # Pin: #13300 merge — restore-only dependency caches and parallel native verification.
- uses: paperclipai/paperclip/.github/workflows/pr-trusted.yml@44dde2dec42a22746a2f36b595acacc9ccfa1df6
+ # Master requires CODEOWNERS review for .github/**, including this workflow.
+ # The AWS runner group permits pr-trusted.yml@refs/heads/master.
+ uses: paperclipai/paperclip/.github/workflows/pr-trusted.yml@master
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a11e7967fc..878899cb24 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -71,7 +71,21 @@ env:
# 07:07:40.) The publish jobs' timeout-minutes are sized for several
# laggard packages; if most of a batch lags the full budget, npm is having
# a real incident and the job failing is correct.
- NPM_PUBLISH_VERIFY_ATTEMPTS: "60"
+ #
+ # Raised to 30 minutes on 2026-09-14, when 10 was not enough twice in a
+ # row: @paperclipai/server was accepted at 17:49:09 and became visible at
+ # 18:04:29 — five minutes after the poll gave up. Each timeout aborts the
+ # release mid-batch, leaving that version half-published, and because the
+ # next version number is derived from what is already on npm the retry
+ # moves to a new number and meets the same lag. Waiting is cheap; a
+ # half-published release is not.
+ #
+ # Packages are published and verified one at a time, so the publish jobs'
+ # timeout-minutes went to 150 alongside this: a 30-minute build plus four
+ # packages each lagging the full budget still finishes inside the job,
+ # instead of the job timing out mid-batch and leaving the same
+ # half-published state this budget exists to avoid.
+ NPM_PUBLISH_VERIFY_ATTEMPTS: "180"
NPM_PUBLISH_VERIFY_DELAY_SECONDS: "10"
jobs:
@@ -326,7 +340,7 @@ jobs:
if: github.event_name == 'push'
needs: verify_canary
runs-on: ubuntu-latest
- timeout-minutes: 90
+ timeout-minutes: 150
environment: npm-canary
outputs:
canary_version: ${{ steps.canary_tag.outputs.version }}
@@ -582,7 +596,7 @@ jobs:
(needs.smoke_nightly.result == 'success' ||
(needs.smoke_nightly.result == 'skipped' && github.event_name == 'workflow_dispatch' && inputs.dry_run))
runs-on: ubuntu-latest
- timeout-minutes: 90
+ timeout-minutes: 150
environment: npm-canary
# The workflow-level concurrency group is per event, so a forced dispatch
# nightly could otherwise overlap the scheduled one and race it to the
@@ -848,7 +862,7 @@ jobs:
(needs.verify_beta_candidate.result == 'success' ||
(needs.verify_beta_candidate.result == 'skipped' && needs.select_beta.outputs.mode == 'promote'))
runs-on: ubuntu-latest
- timeout-minutes: 90
+ timeout-minutes: 150
environment: npm-beta
# Serialize beta publishes so two dispatches cannot race to the same next
# -beta.N version; release.sh additionally refuses to double-publish a
@@ -1271,7 +1285,7 @@ jobs:
if: github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && !inputs.dry_run
needs: [preflight_stable, verify_stable]
runs-on: ubuntu-latest
- timeout-minutes: 90
+ timeout-minutes: 150
environment: npm-stable
permissions:
contents: write
diff --git a/AGENTS.md b/AGENTS.md
index 486b3d8cb0..0e31e80d7c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -74,6 +74,11 @@ pnpm dev
1. Keep changes company-scoped.
Every domain entity should be scoped to a company and company boundaries must be enforced in routes/services.
+Explicit exception: announcement dismissals are instance-wide user preferences,
+keyed by user and announcement so they persist across companies. Their audit
+context must still validate company membership. The announcement publication-ID
+registry is instance-level feed metadata; it contains no company or user data.
+
2. Keep contracts synchronized.
If you change schema/API behavior, update all impacted layers:
- `packages/db` schema and exports
diff --git a/announcements/current.json b/announcements/current.json
new file mode 100644
index 0000000000..74bd397253
--- /dev/null
+++ b/announcements/current.json
@@ -0,0 +1,4 @@
+{
+ "schemaVersion": 1,
+ "announcement": null
+}
diff --git a/announcements/examples/animated/assets/6ac073cb3447e26f856b15ec058bb9d4d036b0babff60af88ee0dd49cabfa124.png b/announcements/examples/animated/assets/6ac073cb3447e26f856b15ec058bb9d4d036b0babff60af88ee0dd49cabfa124.png
new file mode 100644
index 0000000000..47f39dd404
Binary files /dev/null and b/announcements/examples/animated/assets/6ac073cb3447e26f856b15ec058bb9d4d036b0babff60af88ee0dd49cabfa124.png differ
diff --git a/announcements/examples/animated/assets/78bafb6adbfd9da899cdbb5d934b4c0b9df5d419d6f0a5104a87a7b25dcc6bd8.html b/announcements/examples/animated/assets/78bafb6adbfd9da899cdbb5d934b4c0b9df5d419d6f0a5104a87a7b25dcc6bd8.html
new file mode 100644
index 0000000000..25b5d98e89
--- /dev/null
+++ b/announcements/examples/animated/assets/78bafb6adbfd9da899cdbb5d934b4c0b9df5d419d6f0a5104a87a7b25dcc6bd8.html
@@ -0,0 +1,12 @@
+
+
ONE IDEA. A WHOLE TEAM.
Plan
Build
Review
diff --git a/announcements/examples/animated/current.json b/announcements/examples/animated/current.json
new file mode 100644
index 0000000000..7f728ae95c
--- /dev/null
+++ b/announcements/examples/animated/current.json
@@ -0,0 +1,27 @@
+{
+ "schemaVersion": 1,
+ "announcement": {
+ "id": "preview-animated-team",
+ "eyebrow": "Staging preview",
+ "title": "From one idea to a working team",
+ "description": "Set a goal, bring in your agents, and follow the work as it moves forward.",
+ "image": {
+ "path": "assets/6ac073cb3447e26f856b15ec058bb9d4d036b0babff60af88ee0dd49cabfa124.png",
+ "alt": "Paperclip. Ideas become work."
+ },
+ "secondaryLink": {
+ "kind": "external",
+ "label": "Learn more",
+ "url": "https://paperclip.ing"
+ },
+ "primaryAction": {
+ "kind": "route",
+ "label": "Explore your projects",
+ "path": "/projects"
+ },
+ "animation": {
+ "path": "assets/78bafb6adbfd9da899cdbb5d934b4c0b9df5d419d6f0a5104a87a7b25dcc6bd8.html",
+ "alt": "Agents plan, build and review work together."
+ }
+ }
+}
diff --git a/announcements/examples/none/current.json b/announcements/examples/none/current.json
new file mode 100644
index 0000000000..74bd397253
--- /dev/null
+++ b/announcements/examples/none/current.json
@@ -0,0 +1,4 @@
+{
+ "schemaVersion": 1,
+ "announcement": null
+}
diff --git a/announcements/examples/staging/assets/6ac073cb3447e26f856b15ec058bb9d4d036b0babff60af88ee0dd49cabfa124.png b/announcements/examples/staging/assets/6ac073cb3447e26f856b15ec058bb9d4d036b0babff60af88ee0dd49cabfa124.png
new file mode 100644
index 0000000000..47f39dd404
Binary files /dev/null and b/announcements/examples/staging/assets/6ac073cb3447e26f856b15ec058bb9d4d036b0babff60af88ee0dd49cabfa124.png differ
diff --git a/announcements/examples/staging/current.json b/announcements/examples/staging/current.json
new file mode 100644
index 0000000000..536be5a986
--- /dev/null
+++ b/announcements/examples/staging/current.json
@@ -0,0 +1,23 @@
+{
+ "schemaVersion": 1,
+ "announcement": {
+ "id": "staging-announcement-2026-09-14",
+ "eyebrow": "Staging preview",
+ "title": "Your next idea starts here",
+ "description": "Bring your agents and work together in one place. Explore your projects, or learn more about Paperclip.",
+ "image": {
+ "path": "assets/6ac073cb3447e26f856b15ec058bb9d4d036b0babff60af88ee0dd49cabfa124.png",
+ "alt": "Paperclip. Ideas become work."
+ },
+ "secondaryLink": {
+ "kind": "external",
+ "label": "Learn more",
+ "url": "https://paperclip.ing"
+ },
+ "primaryAction": {
+ "kind": "route",
+ "label": "Explore your projects",
+ "path": "/projects"
+ }
+ }
+}
diff --git a/doc/AGENT-ARTIFACTS.md b/doc/AGENT-ARTIFACTS.md
index 8832a7d526..4fe76cc770 100644
--- a/doc/AGENT-ARTIFACTS.md
+++ b/doc/AGENT-ARTIFACTS.md
@@ -5,10 +5,28 @@ must be attached to the Paperclip issue before the agent chooses a final
disposition. A local workspace path is not enough, because cloud users and
reviewers often cannot access the agent's disk.
-Use the helper bundled with the Paperclip skill from the repo root:
+## Native runner
+
+When `register_deliverable` is available, use it for files in the bound local or
+remote workspace. Supply a workspace-relative `contentRef`, basename `filename`,
+`contentType`, exact `byteSize` and SHA-256, `title`, and a stable `idempotencyKey`.
+The tool verifies the file, stores an attachment and artifact work product, and
+binds it to the response. Generic API tools and a legacy API key are unnecessary.
+
+Wait for the receipt. It includes `attachmentId`, `contentPath`, and
+`downloadPath`, along with the existing command, revision, entity references,
+and disposition. Reuse the original key after an ambiguous result. A receipt
+confirms storage and response binding in Paperclip; it does not confirm delivery
+to an external chat provider. If registration fails, use the returned error to
+resolve the failure or explain the limitation; do not describe a workspace path
+as an uploaded file.
+
+## Legacy adapters
+
+Use Bash to run the helper bundled with the Paperclip skill from the repo root; installed skill files may not retain executable permissions:
```sh
-skills/paperclip/scripts/paperclip-upload-artifact.sh path/to/output.webm \
+bash skills/paperclip/scripts/paperclip-upload-artifact.sh path/to/output.webm \
--title "Walkthrough render" \
--summary "Rendered walkthrough for review"
```
@@ -27,6 +45,16 @@ It uploads the file to
artifact work product on `POST /api/issues/{issueId}/work-products` by default.
The command prints issue-safe markdown links for the final task comment.
+## Task artifact presentation
+
+While a task is open, a new agent attachment, work product, or document opens
+the task's Artifacts tab and reveals the side panel (or mobile drawer). This
+uses stored object IDs, so it works with either runner. Uploading a file and
+registering its work product counts as one arrival. Existing history, revisions,
+and repeated query refreshes preserve the user's tab selection. Plans retain
+their existing Plan-tab behavior; unregistered user input attachments remain in
+the conversation.
+
## Uploaded Artifacts vs Workspace Files
Use uploaded artifacts for deliverables: videos, PDFs, screenshots, archives,
@@ -96,7 +124,7 @@ available, not the preferred way to deliver files to users.
Upload an `.mp4` render:
```sh
-skills/paperclip/scripts/paperclip-upload-artifact.sh dist/demo.mp4 \
+bash skills/paperclip/scripts/paperclip-upload-artifact.sh dist/demo.mp4 \
--title "Demo video render" \
--summary "MP4 render for board review"
```
@@ -104,7 +132,7 @@ skills/paperclip/scripts/paperclip-upload-artifact.sh dist/demo.mp4 \
Upload a `.webm` render:
```sh
-skills/paperclip/scripts/paperclip-upload-artifact.sh out/walkthrough.webm \
+bash skills/paperclip/scripts/paperclip-upload-artifact.sh out/walkthrough.webm \
--title "Walkthrough video" \
--summary "WebM walkthrough render"
```
@@ -113,7 +141,7 @@ The helper detects `.mp4`, `.webm`, and `.mov` content types. If a renderer uses
an unusual extension, pass the MIME type explicitly:
```sh
-skills/paperclip/scripts/paperclip-upload-artifact.sh render.bin \
+bash skills/paperclip/scripts/paperclip-upload-artifact.sh render.bin \
--title "Demo video render" \
--content-type video/mp4
```
@@ -144,3 +172,24 @@ curl -sS -X POST \
Use `type: "artifact"`, `provider: "paperclip"`, and metadata containing the
uploaded `attachmentId`. The server canonicalizes `contentType`, `byteSize`,
`contentPath`, `openPath`, `downloadPath`, and `originalFilename`.
+
+## Verification
+
+The file-delivery integration suite runs the real helper through queue and
+HTTP/2 gateways against a disposable API, database, and storage. It also tests
+native registration with generic API tools disabled, duplicate retries, Unicode
+filenames, company isolation, and downloads after deleting the workspace.
+
+```sh
+pnpm exec vitest run server/src/__tests__/file-delivery-bridges.test.ts
+```
+
+To run the same suite on disposable Daytona sandboxes, install the standalone
+Daytona plugin's dependencies and set `DAYTONA_API_KEY` in the test process:
+
+```sh
+PAPERCLIP_FILE_DELIVERY_DAYTONA=1 pnpm exec vitest run server/src/__tests__/file-delivery-bridges.test.ts
+```
+
+The live fixture deletes each sandbox before checking that its attachments
+remain downloadable from Paperclip. It does not run unless explicitly enabled.
diff --git a/doc/ANNOUNCEMENTS.md b/doc/ANNOUNCEMENTS.md
new file mode 100644
index 0000000000..896bec2a32
--- /dev/null
+++ b/doc/ANNOUNCEMENTS.md
@@ -0,0 +1,290 @@
+# In-app announcements
+
+Paperclip displays one optional announcement card in the board UI. Its feed is
+`https://pages.paperclip.ing/announcements/v1/current.json`. The instance fetches
+JSON on demand and renders it with native components.
+
+## Operator configuration
+
+- `PAPERCLIP_ANNOUNCEMENTS_ENABLED=false` disables fetching and display.
+- `PAPERCLIP_ANNOUNCEMENTS_FEED_URL` overrides the public HTTPS manifest URL.
+ Credentials, query strings, private destinations and redirects are rejected.
+
+Announcements are independent of telemetry. Feed/media requests originate from
+the instance without account IDs, company data, cookies or event tracking. The
+host sees ordinary server network request metadata. The browser requests only
+its own Paperclip API.
+
+## Authoring and publishing
+
+The shared `announcementManifestSchema` defines the format:
+
+```json
+{
+ "schemaVersion": 1,
+ "announcement": {
+ "id": "2026-09-projects",
+ "eyebrow": "New in Paperclip",
+ "title": "Your next idea starts here",
+ "description": "Bring your agents and work together in a project.",
+ "secondaryLink": { "kind": "external", "label": "Learn more", "url": "https://paperclip.ing" },
+ "primaryAction": { "kind": "route", "label": "Open projects", "path": "/projects" }
+ }
+}
+```
+
+Content is plain text. Every manifest object rejects unknown fields, including
+misspellings in actions and media. Optional fields: `image: { path, alt }`,
+`animation: { path, alt }`, `expiresAt` (ISO
+timestamp), and `minimumPaperclipVersion` (stable `major.minor.patch`). Internal
+actions accept stable pages in `ANNOUNCEMENT_APP_ROUTES` and use the selected
+company. External HTTPS links open a new tab. Actions only navigate.
+
+Images are `assets/.png`, `.jpg` or `.webp`, at most 2 MiB, relative to
+the manifest directory. Use an approximately 2.6:1 banner with important content
+near the center; mobile crops it shorter. The manifest is limited to 64 KiB.
+Run `shasum -a 256 hero.png` to get the image digest, copy the file to
+`announcements/assets/.png`, and use `assets/.png` in the
+manifest. An image correction changes this asset filename while retaining the
+announcement ID.
+
+Edit `announcements/current.json`, put its image under `announcements/assets/`,
+then run:
+
+```sh
+node cli/node_modules/tsx/dist/cli.mjs scripts/publish-announcements.ts announcements --dry-run
+```
+
+Set `PAPERCLIP_PAGE_BUCKET`, optionally `PAPERCLIP_PAGE_BASE_URL`, and the page
+uploader's namespaced `PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID` and
+`PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY` (optional `PAPERCLIP_PAGE_AWS_SESSION_TOKEN`),
+or `PAPERCLIP_PAGE_AWS_PROFILE`. Ambient AWS credentials also work.
+For a host serving a subdirectory, `PAPERCLIP_PAGE_DEFAULT_PREFIX` prepends a
+validated path to both S3 keys and public URLs. Use lowercase letters, numbers
+and hyphens in each segment, without leading/trailing slashes.
+
+```sh
+node cli/node_modules/tsx/dist/cli.mjs scripts/publish-announcements.ts announcements --publish
+```
+
+The helper rejects symlinks, validates asset digests and animated HTML, uploads assets first and
+the manifest last, and verifies the public manifest and asset headers. It writes
+only the resolved announcement prefix; no remote objects are deleted or
+infrastructure changed. Allow up
+to six minutes for CDN propagation. Manifest caching is five minutes; immutable
+assets use one year. Before first publication verify the distribution's active
+cache policy has minimum TTL <= 300 and maximum TTL >= 300 for the manifest,
+and maximum TTL >= 31536000 for assets. Check the behavior matching each path,
+including any referenced cache policy. Public response headers alone cannot
+prove the effective cache lifetime or override a higher minimum. See
+[AWS cache expiration](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Expiration.html).
+
+Retain the ID when fixing copy/images/animations. Use a new ID to announce something new.
+ETags improve fetching but never determine redisplay.
+
+## Animated hero media
+
+An announcement can show a self-contained **HTML/CSS animation** in its hero
+area. The headline, description, close button and actions remain native
+Paperclip controls. Add an `animation` alongside the required static `image`:
+
+```json
+"image": { "path": "assets/.png", "alt": "A team working together" },
+"animation": { "path": "assets/.html", "alt": "Agents plan, build and review work together." }
+```
+
+Replace the placeholders with the files' actual 64-character SHA-256 digests.
+HTML is UTF-8, limited to 128 KiB, and uses a responsive document with zero body
+margin. The hero is about 352 × 136 on desktop and shorter on phones. Use CSS
+keyframes, inline styles, system fonts, and visual HTML (`div`, `span`, `p`,
+`br`, `strong`, `em`, `b`, `i`) or inline SVG shapes/text (`svg`, `g`, `path`,
+`circle`, `ellipse`, `rect`, `line`, `polyline`, `polygon`, `text`, `tspan`,
+`title`, `desc`). No scripts, external libraries, links, forms, iframes, images,
+SVG SMIL/foreignObject, meta refresh or other embedded resources. CSS URL
+requests and imports are blocked by CSP; keep all styling self-contained.
+The publisher and server use the same strict DOMPurify allowlist and reject
+unsupported markup rather than publishing a silently changed animation.
+
+Paperclip verifies the digest, validates the HTML, and renders the result in an
+opaque sandboxed iframe with no permissions. A Content Security Policy blocks
+scripts and network resources both inside the card and on direct API visits.
+The browser fetches HTML from its own authenticated instance; it never loads
+the publisher's page in an unsandboxed frame. The frame cannot receive pointer
+or keyboard focus; its accessible description is supplied by `animation.alt`.
+See [iframe sandboxing](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/iframe).
+
+The animation plays automatically without playback controls. The static image
+stays visible while loading and on failure. With reduced motion enabled,
+Paperclip does not request or play the animation. Also include a
+`prefers-reduced-motion` CSS rule in authored documents for standalone previews.
+Animations share the feed's constrained host, three-second server timeout,
+bounded cache, request deduplication and fifteen-minute failure cooldown.
+Dismissal and ID reuse rules are identical for animated and static cards.
+Older Paperclip builds that do not recognize `animation` treat that feed as
+unsupported and quietly show no card.
+
+The complete authoring example is `announcements/examples/animated/`. Preview
+it with the same staging/test-drive workflow below:
+
+```sh
+cp -R announcements/examples/animated .paperclip/announcement-animation-preview
+# Edit HTML; recompute its digest and rename it; update current.json.
+node cli/node_modules/tsx/dist/cli.mjs scripts/publish-announcements.ts .paperclip/announcement-animation-preview --staging animated-preview --dry-run
+node cli/node_modules/tsx/dist/cli.mjs scripts/publish-announcements.ts .paperclip/announcement-animation-preview --staging animated-preview --publish
+```
+
+Point the isolated instance at the printed URL and restart it. Verify movement,
+reduced motion, mobile sizing, and dismissal across reloads. Try a
+missing animation asset: the poster and native controls must remain usable.
+Storybook's Animated, AnimatedDark, AnimatedMobile and MissingAnimation stories,
+and the design guide, provide local examples without changing the remote feed.
+
+## Preview an announcement before publishing
+
+Use a named staging feed. `--staging ` writes
+`announcements/staging//v1/` instead of `announcements/v1/`, so a preview
+cannot overwrite the production manifest. With no source directory it uses
+`announcements/examples/staging/`, including a sample banner. Commands default
+to dry-run unless `--publish` is supplied.
+
+For Paperclip's existing preview host, use the branch preview area that
+CloudFront already has permission to read:
+
+```sh
+aws sso login --profile paperclip-dev
+export PAPERCLIP_PAGE_AWS_PROFILE=paperclip-dev
+export PAPERCLIP_PAGE_BUCKET=paperclipai-runner-e2e-history-078455283791-us-east-1
+export PAPERCLIP_PAGE_BASE_URL=https://d1p6rlowie26tp.cloudfront.net
+export PAPERCLIP_PAGE_DEFAULT_PREFIX=storybook/branches/codex-announcements
+
+# Copy the public fixture into an ignored directory and edit current.json there.
+mkdir -p .paperclip
+cp -R announcements/examples/staging .paperclip/announcement-preview
+node cli/node_modules/tsx/dist/cli.mjs scripts/publish-announcements.ts .paperclip/announcement-preview --staging my-preview --dry-run
+node cli/node_modules/tsx/dist/cli.mjs scripts/publish-announcements.ts .paperclip/announcement-preview --staging my-preview --publish
+```
+
+Choose a unique staging name for your test and use the printed manifest URL.
+The preview host currently uses CloudFront's `Managed-CachingDisabled` policy
+for this branch area: edge TTL is zero even though public responses preserve
+the five-minute manifest and one-year asset cache headers. This is useful for
+preview iteration; it does not verify a production distribution's effective
+cache lifetime. For another host, configure its bucket, base URL and optional
+prefix, then verify its matching cache behavior as described above.
+
+Create a test-drive configuration in this worktree. Put the feed override in
+the **selected instance's `.env`**, not just the invoking shell: test-drive
+deliberately clears inherited `PAPERCLIP_*` variables.
+
+```sh
+mkdir -p .paperclip/announcement-test-drive/instances/default
+# On a new test directory, create this file. On reuse, update these entries
+# while preserving the file's existing keys.
+cat > .paperclip/announcement-test-drive/instances/default/.env <<'EOF'
+PAPERCLIP_ANNOUNCEMENTS_FEED_URL=https://d1p6rlowie26tp.cloudfront.net/storybook/branches/codex-announcements/announcements/staging/my-preview/v1/current.json
+PAPERCLIP_ANNOUNCEMENTS_ENABLED=true
+PAPERCLIP_DB_BACKUP_ENABLED=false
+HEARTBEAT_SCHEDULER_ENABLED=false
+EOF
+
+# A fresh test-drive needs a provider key for its initial CEO. Use your usual
+# provider environment variable; never put a real key into a manifest or commit.
+# Reusing an initialized data directory does not require a bootstrap key.
+node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts test-drive --data-dir .paperclip/announcement-test-drive --no-browser
+```
+
+See [test-drive setup](DEVELOPING.md#one-command-isolated-manual-test-drive) for harness/key options.
+Open the printed local URL. The app chooses a free port starting at 3100 and
+keeps its database under the supplied directory. It creates no tasks or initial
+agent run. After onboarding/company selection, allow three seconds for the card.
+
+Before promoting content, verify:
+
+- The image, copy and both actions fit desktop/mobile and both themes.
+- A dialog or bottom-left toast temporarily hides the card, then restores it.
+- Close or follow a link; reload, switch companies and open another tab/browser.
+ The same account should keep that ID dismissed.
+- Edit copy with the same ID: it stays dismissed. Publish a new ID: it appears
+ on the next eligible visit. Clearing browser storage alone does not reset
+ database dismissals; use a new ID or a fresh isolated data directory.
+- Try the empty fixture and a URL that returns 404. The dashboard remains usable
+ with no announcement and no announcement error popup.
+
+Stop and restart test-drive after changing its feed URL or republishing content
+when you need immediate results. This clears the server's one-hour feed cache
+while retaining dismissal records in the same data directory. Reload or return
+to the app after restart; an uninterrupted active tab does not discover cards.
+For promotion, validate the reviewed content again, configure the production
+host/prefix, and publish without `--staging`. Production's checked-in manifest
+remains empty until a real announcement is ready.
+
+## No announcement and withdrawal
+
+The explicit **none** value is JSON `null`, not the string `"none"`:
+
+```json
+{
+ "schemaVersion": 1,
+ "announcement": null
+}
+```
+
+Publish this manifest to withdraw an announcement while retaining every user's
+dismissed IDs. Restoring an old announcement cannot resurrect it for people who
+dismissed it. The ready-to-publish empty fixture is
+`announcements/examples/none/current.json`:
+
+```sh
+node cli/node_modules/tsx/dist/cli.mjs scripts/publish-announcements.ts announcements/examples/none --staging my-empty-preview --publish
+```
+
+A remote **404** is also a normal empty feed: the board API returns HTTP 200
+with `null`, clears previous content/ETag, and waits fifteen minutes before
+checking upstream again. It produces no announcement warning in server logs or
+popup in the UI. Other unavailable or invalid feeds likewise produce no card
+or UI error popup; unexpected upstream failures can be logged for operators.
+Withdrawal follows the cache/return timing below. Explicit expiration also
+removes a visible card when its deadline arrives.
+
+## Timing and persistence
+
+Show after three seconds when opening or returning to Paperclip, after company
+selection and onboarding. Dialogs and toasts take priority. Phones show it above
+bottom navigation. No automatic timeout, outside-click dismissal or carousel.
+Tab visibility controls the return check: moving focus to the address bar or
+an adjacent app pane leaves the card visible and does not restart its settling
+period. A hidden tab clears the card; becoming visible fetches fresh dismissal
+state before showing anything, even if that lookup takes longer than three
+seconds.
+
+The instance caches the feed for an hour, deduplicates concurrent fetches, and
+uses conditional requests. Failed requests have a fifteen-minute cooldown; no
+card appears for unavailable/invalid/incompatible content. Each request has a
+three-second deadline. Active tabs do not poll for announcements. Publication
+and withdrawal are discovered on a return after cache expiry (normally within
+about 65 minutes for returning users).
+
+Closing or following either link saves a unique `(userId, announcementId)`
+record in the instance DB, shared across browsers and companies. Its first
+write and audit entry commit together; the active company is audit context.
+Viewers can dismiss their own card. No-login instances share `local-board`.
+Separate installations do not share state.
+
+The browser hides immediately, stores pending writes per account, and retries
+on reconnect/return. Failed saves explain that cross-device sync has not
+completed. If browser storage is unavailable, state lasts for this visit. Other
+tabs close through BroadcastChannel/storage events; another browser refreshes
+state on return. Logout clears displayed state and aborts account-bound work.
+A failed state lookup never shows a card.
+
+Board-only APIs: `GET /api/announcements/current`,
+`GET /api/announcements/:id/image`, `GET /api/announcements/:id/animation`, and `POST /api/announcements/:id/dismiss`
+with `{ "companyId": "..." }`. Responses use `private, no-store`. Repeated POSTs
+return 204 without duplicate audits. Pending dismissals remain valid after the
+feed moves to another ID. The instance retains only the IDs of validated
+announcements in a publication registry, so offline retries survive withdrawal
+and restarts. A caller-invented ID returns 404 without creating dismissal or
+audit rows. This registry is not an archive and records no interaction events.
+
+Production ships with an empty manifest. Design guide / Storybook fixtures are
+never used as a production fallback.
diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md
index e5f6031d30..157589b00c 100644
--- a/doc/DEVELOPING.md
+++ b/doc/DEVELOPING.md
@@ -23,6 +23,24 @@ GitHub Actions owns `pnpm-lock.yaml`.
- Pull request CI validates dependency resolution when manifests change.
- Pushes to `master` regenerate `pnpm-lock.yaml` with `pnpm install --lockfile-only --no-frozen-lockfile`, commit it back if needed, and then run verification with `--frozen-lockfile`.
+## Trusted PR Workflow
+
+The PR caller uses `paperclipai/paperclip/.github/workflows/pr-trusted.yml@master`.
+The AWS runner group `paperclip-public-pr` must allow
+`paperclipai/paperclip/.github/workflows/pr-trusted.yml@refs/heads/master`.
+New workflow versions merged into master then receive runner access without a
+separate SHA allowlist update. Dependabot leaves this first-party reference on
+master.
+
+Keep the `.github/**` rule in `.github/CODEOWNERS` and the active master ruleset's
+code-owner review requirement enabled. This covers the caller, the trusted
+workflow, and CODEOWNERS itself. Existing administrator pull-request bypasses
+remain governed by the repository ruleset.
+
+When changing the workflow path or branch, authorize the new reference before
+updating the caller. Retain older authorized SHA references while queued runs or
+supported reruns still use them.
+
## Start Dev
From repo root:
@@ -594,6 +612,16 @@ If the `codex` CLI is not installed or not on `PATH`, `codex_local` agent runs f
Local adapters require their corresponding CLI/session setup on the machine running Paperclip. External adapters are installed through the adapter/plugin flow and should not require hardcoded imports in `server/` or `ui/`.
+## Project Repository Checkouts
+
+Tasks use every distinct repository attached to their project, including repository-only sources with no local folder. Paperclip creates a managed checkout when no local folder is configured. The selected repository remains at the task workspace root. Other project repositories have editable, independent Git checkouts under `.paperclip-repositories/-`. Workspace hints expose each checkout path to the agent.
+
+When an additional repository has a configured local checkout, Paperclip seeds the task copy from its current commit and uncommitted files. Git-ignored files stay out of that copy. Subsequent task edits stay in the task copy. They do not overwrite the configured source folder. Existing task copies retain their work across runs.
+
+Sandbox staging, including Daytona, transfers each repository's Git history and working files. Restore merges files and commits back into each local task checkout independently. Durable sandbox recovery keeps the same repository snapshots. Normal ignore and workspace exclusion rules still apply. A clone failure stops task preparation with an error so the agent does not start with only part of the project.
+
+If a repository is detached or its source configuration changes, its previous task copy is retained under `.paperclip-runtime/detached-repositories/` and excluded from future sandbox transfers. Referenced projects continue to use the separate read-only multi-project workspace behavior.
+
## Config Freshness
Agent, project, environment, secret, skill, and workspace config edits are sampled at the next run boundary. A heartbeat that is already running finishes with the config it started with.
@@ -604,6 +632,8 @@ When effective run config changes, Paperclip may intentionally skip a saved adap
Paperclip applies one process-wide scheduler to expensive host-side workspace Git enumeration, including changed-file browsing, runtime/finalization cleanliness guards, and adapter sandbox-sync snapshots. The scheduler defaults to two active scans and a bounded queue of 32. Identical scans of the same canonical worktree share one subprocess, while successful changed-file listings are cached for 10 seconds. Correctness-sensitive runtime guards bypass the result cache.
+Workspace snapshots list ignored paths with `git ls-files --others --ignored --exclude-standard --directory -z` so ignored directory contents do not require a full status walk. Snapshot failures retain their typed cause instead of becoming a non-Git-folder result. During pre-provider setup, scan timeouts and queue saturation use the existing two automatic failure retries with a 30-second delay. Cancellation, output limits, and other Git errors stop with specific recovery guidance. See `doc/execution-semantics.md` for the ownership and retry-budget contract.
+
The cache intentionally trades up to a few seconds of changed-file freshness for stable server latency. The file browser retains an explicit refresh action, does not start its query while the panel or browser tab is hidden, and presents overloads as retryable failures rather than an empty workspace. A full queue returns `503` with code `workspace_git_scan_saturated`; a scan exceeding its wall-clock limit returns `504` with code `workspace_git_scan_timeout`. Both responses include `Retry-After: 1`.
Sandbox Git sync treats only the selected repository root as a clone source. A selected subfolder uses directory sync within that folder, applies the enclosing repository's ignore rules, and does not transfer parent files or Git history.
diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md
index 6b2dc4d3f3..7c739557ec 100644
--- a/doc/PRODUCT.md
+++ b/doc/PRODUCT.md
@@ -192,3 +192,20 @@ Chat instructions require selecting a suitable project, reusing an existing one
The `create_project` runtime tool uses the normal project API with durable idempotency. `list_projects` and `list_project_repositories` support selection. Multiple `repositoryIds` select authorized catalog entries; multiple HTTPS GitHub `repositoryUrls` register existing repositories absent from the catalog. IDs and URLs may be combined, but cannot accompany an explicit `workspace`. URLs do not create repositories on GitHub or grant credentials. Execution uses normal repository access rules. Repository IDs are revalidated against the authenticated run's responsible user and connection grants. Agents should consider proper available repositories, clarify material ambiguity, and use repository-free projects when appropriate for non-code work.
Confirmed project creation appears as a durable card in the shared task transcript, including selected repository links. Tasks are linked inline. Failed creation never produces a success card. Tool evals cover planning/handoff, project/repository selection, retries, permission and mode denials, and ordinary delegation regressions using the production chat directive.
+
+### In-app announcements
+
+An optional announcement card shares product news with board users on opening
+or returning to Paperclip. Dismissals persist per user across companies and
+browsers within an instance. Operators can disable fetching independently of
+telemetry. See [Announcements](ANNOUNCEMENTS.md).
+
+### Agent chat discovery
+
+With Agent Chat enabled, the Chats sidebar always includes the company's
+earliest-created agent, plus personal starred agents and up to four other recent
+conversations. First use has the same compact rows as returning use. The compose
+icon shares a column with stars and appears on hover or keyboard focus (always on
+touch). It opens a company-wide name/role search, independent of sidebar membership.
+Selecting an agent opens their persistent conversation; it does not reset history
+or create a task until the existing first-write flow requires one.
diff --git a/doc/PUBLISHING.md b/doc/PUBLISHING.md
index 433860153f..26094fdfe0 100644
--- a/doc/PUBLISHING.md
+++ b/doc/PUBLISHING.md
@@ -41,7 +41,9 @@ This script:
3. bundles the CLI entrypoint with esbuild into `cli/dist/index.js`
4. verifies the bundled entrypoint with `node --check`
5. rewrites `cli/package.json` into a publishable npm manifest and stores the dev copy as `cli/package.dev.json`
-6. copies the repo `README.md` into `cli/README.md` for npm metadata
+6. copies the repo `README.md` into `cli/README.md` for npm metadata, rewriting
+ repository-relative image assets to raw GitHub URLs pinned to the source
+ commit
After the release script exits, the dev manifest and temporary files are restored automatically.
diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md
index 3042456a95..1623e9e389 100644
--- a/doc/SPEC-implementation.md
+++ b/doc/SPEC-implementation.md
@@ -1664,3 +1664,17 @@ with bounded continuation and visible recovery. Preserve explicit approvals,
current task ownership, cancellation, dependencies, and newer task state. See
`doc/architecture/native-status-arbitration.md` for finish feedback and the
provenance-checked cleanup of historical automatic completion reviews.
+
+### In-app announcements
+
+A versioned remote JSON manifest supplies one optional board announcement.
+The instance validates/caches content, proxies its raster image, and stores
+user-scoped dismissals. Closing or following an action dismisses the ID; copy
+edits retain it. Writes are board-only, idempotent and transactionally audited
+using an authorized company's context. Viewers may dismiss their own card.
+This is an explicit exception to company-scoped business entities: the
+preference follows one account across companies on the instance. A separate
+instance-level registry retains validated publication IDs, allowing offline
+dismissal retries after withdrawal while rejecting caller-invented IDs. It
+stores no announcement content, account data or interaction events.
+See [Announcements](ANNOUNCEMENTS.md) for API and publishing details.
diff --git a/doc/cloud-build-readiness.md b/doc/cloud-build-readiness.md
index b687197182..724ec2d33e 100644
--- a/doc/cloud-build-readiness.md
+++ b/doc/cloud-build-readiness.md
@@ -9,11 +9,12 @@ The `Cloud readiness` workflow starts for every master push. Its versioned
image, including Sentry resolution and orphan reaping, then publishes the
full-SHA cloud tag. Cloud readiness owns the master trigger so there is one
cloud build per push. Release tags and manual Docker runs retain their callers.
-- The full-SHA image and both exact-source npm packages are visible. The
- packages are `@paperclipai/shared` and `@paperclipai/db` at
- `0.0.0-preview.g`, published through the migrator-only release lane.
- Registry metadata must match the full commit, and the database package must
- pin the matching shared package.
+- The full-SHA image is visible and the exact-source `Cloud migrator artifacts`
+ workflow has succeeded. Readiness verifies the manifest's GitHub attestation
+ against the full SHA, canonical master workflow, and GitHub-hosted runner,
+ then downloads and validates both package archives and the prepared dependency
+ lockfile. The database package pins the matching shared package. New-version
+ npm metadata and tarball propagation are outside this path.
The Cloud workflow builds the image with `USER_UID=1001` and `USER_GID=1001`,
matching the managed runtime. This avoids a startup user remap, which can walk
@@ -56,16 +57,19 @@ before that bot's PR merges. Verification must install and test that commit
without waiting for another merge. The generated lockfile stays in the job's
workspace; these checks do not commit it back to the repository.
-The artifact wait runs for up to 30 minutes and reports what is missing. Only
-an HTTP 404 means publication is pending; authorization errors, upstream outages,
-and identity mismatches fail the job. A failed, cancelled, or skipped prerequisite
+The artifact wait runs for up to 30 minutes and reports what is missing. A
+missing image or an exact-source publisher with no successful run yet means publication
+is pending. An earlier successful push or manual run remains valid after a failed
+retry because publication is immutable. If all matching runs failed, readiness
+fails. An invalid signature, inaccessible or corrupt
+bundle, authorization error, or identity mismatch fails the job. A failed, cancelled, or skipped prerequisite
cannot produce a successful readiness job. Retry the failed publication or build,
then rerun the failed readiness workflow jobs to check the same commit again.
## Consumer contract
`Cloud deployable v1` is a source-and-artifact readiness signal. A deployment
-consumer must still resolve and pin the image digest and npm integrity/lockfile,
+consumer must still resolve and pin the image digest and migrator integrity/lockfile,
validate migration contents and compatibility, and apply its target health gates.
The check creates no release record and deploys no instance. A full-SHA tag by
itself, or a successful migrator dispatch, is not this readiness signal.
@@ -78,9 +82,16 @@ Do not trust a similarly named check from another workflow or a manual branch ru
Order candidates by master ancestry, not job completion time: an older commit
finishing late must not roll a fleet backward. Fail closed on API errors.
-Existing npm canary discovery is unchanged by this producer workflow. Consumers
-can adopt the versioned signal separately after the workflow has landed and
-successfully verified a real master commit.
+Cloud consumers must enable `CLOUD_HARNESS_DIRECT_MIGRATOR_ARTIFACTS` before
+this gate is adopted: readiness no longer promises preview npm availability.
+The automatic npm-only migrator dispatcher has been removed. Manual
+`release.yml` runs with `channel=cloud-migrator`, branch previews, and stable
+releases retain their npm publisher for legacy consumers and rollback.
+
+For rollback, restore the npm dispatcher and gate together before disabling the
+cloud direct-artifact switch. Already-created releases retain their immutable
+archive URLs and lockfiles; keep those objects available. The master producer
+can be retried independently without republishing or overwriting a valid bundle.
## Timing and rollout
@@ -151,9 +162,10 @@ its trusted-publisher identity.
Before enabling the switch, deploy the separate Fleet and restrict its GitHub
runner group to repository ID `1170821064` and these workflows at
-`refs/heads/master`: `cloud-readiness.yml`, `cloud-artifacts.yml`,
+`refs/heads/master`: `cloud-readiness.yml`,
`release-verify.yml`, `runner-chaos-evals.yml`, and `release.yml`. Do not authorize
-PR-controlled workflow versions. PR placement retains its independent pinned
+PR-controlled workflow versions. The direct migrator producer always uses
+GitHub-hosted runners and needs no AWS runner-group authorization. PR placement retains its independent pinned
workflow and six-account author/actor allowlist.
Disable the switch and rerun the whole workflow to restore GitHub-hosted
diff --git a/doc/cloud-ui-snippet.md b/doc/cloud-ui-snippet.md
index e7f0db9ae1..4787f3b9e0 100644
--- a/doc/cloud-ui-snippet.md
+++ b/doc/cloud-ui-snippet.md
@@ -29,6 +29,28 @@ included: clearing the plain variable to blank disables injection even while
a base64 value is still deployed. Everything else about the snippet is
unchanged.
+Base64 does not defeat every firewall. Some decode the value before matching,
+so they reject a base64 snippet whose decoded bytes still contain script
+markup. Deliver a bare script body (below) through one of these.
+
+## Bare script body
+
+Set the value to the script body alone — the JavaScript with no surrounding
+``, which would close the wrapper early. Because the value carries no
+`" }).title).toContain("", "