Merge origin/master into codex/grok-native-runner

Preserve Grok credential staging alongside the new remote Codex assigned-MCP credential boundary. Seven focused native-runtime checks pass.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-24 13:58:13 -07:00
commit 5a176ea301
178 files changed
+8994 -1999

No files matched your search

+21 -134
View File
@@ -1,144 +1,31 @@
import test from "node:test";
import assert from "node:assert/strict";
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 });
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://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 } });
if (url.includes("/blobs/")) return json({ config: { Labels: { "org.opencontainers.image.revision": wrongImage ? "c".repeat(40) : sha } } });
throw new Error(`Unexpected request: ${url}`);
};
}
const noSignature = async () => {}; // Signature enforcement is exercised separately below.
const workflow = readFileSync(new URL("../../workflows/cloud-readiness.yml", import.meta.url), "utf8");
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, 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("migrator"); missing.add("image"); }
if (clock === 30) missing.delete("image");
},
});
assert.equal(clock, 30); assert.equal(signatures, 1);
assert.deepEqual(result, { version: 1, sha, packageVersion: version });
});
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), 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/);
});
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");
test("retirement preserves exact-source verification without issuing legacy deployment readiness", () => {
assert.match(workflow, /push:\s*\n\s*branches: \[master\]/);
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, /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/);
assert.equal(existsSync(new URL("../../workflows/cloud-artifacts.yml", import.meta.url)), false);
const proof = workflow.split(" source_verified:")[1];
assert.ok(proof);
assert.match(proof, /name: Cloud source verified v1/);
assert.match(proof, /needs: \[verify\]/);
assert.match(proof, /if: github.repository == 'paperclipai\/paperclip' && github.ref == 'refs\/heads\/master'/);
assert.doesNotMatch(proof, /^\s*(?:if:.*always\(|continue-on-error:)/m);
assert.doesNotMatch(workflow, /Cloud deployable v1|docker-cloud.yml|cloud-readiness.mjs|^ (image|artifacts|ready):/m);
assert.doesNotMatch(workflow, /packages: write|secrets: inherit|id-token: write|actions: write|checks: write|uses: .*@v\d\b/);
assert.equal(existsSync(new URL("../../workflows/docker-cloud.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);
}
test("standard image provenance and independent exact-source migrators remain available", () => {
const docker = readFileSync(new URL("../../workflows/docker.yml", import.meta.url), "utf8");
assert.match(docker, /target: production/);
assert.match(docker, /type=raw,value=sha-\$\{\{ github.sha \}\}/);
assert.match(docker, /run: node scripts\/standard-image-contract.mjs --resolve "\$GITHUB_SHA"/);
assert.match(docker, /subject-digest: \$\{\{ steps.standard.outputs.digest \}\}/);
const migrator = readFileSync(new URL("../../workflows/cloud-migrator-artifacts.yml", import.meta.url), "utf8");
assert.match(migrator, /push:\s*\n\s*branches: \[master\]/);
assert.match(migrator, /uses: actions\/attest@/);
assert.doesNotMatch(docker, /build-and-push-cloud|docker-cloud.yml|canary-cloud/);
});
@@ -1,35 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
const workflow = readFileSync(new URL("../../workflows/docker-cloud.yml", import.meta.url), "utf8");
// Exercise the workflow's actual boolean expression. Its string comparisons and
// boolean operators have the same results in JS for these canonical contexts.
const expression = workflow.match(/^ runs-on: \$\{\{ (.+) \}\}$/m)?.[1];
assert.ok(expression, "cloud routing must remain an explicit job expression");
const timeoutExpression = workflow.match(/^ timeout-minutes: \$\{\{ (.+) \}\}$/m)?.[1];
assert.ok(timeoutExpression, "AWS jobs must finish before the Fleet instance lifetime");
const fleet = "runs-on/fleet=paperclip-cloud-build-x64/env=public-ci";
const base = { repository: "paperclipai/paperclip", repository_id: "1170821064", ref: "refs/heads/master", event_name: "push" };
for (const { name, github = {}, enabled = "true", expected = "ubuntu-latest" } of [
{ name: "canonical master push", expected: fleet },
{ name: "manual master build", github: { event_name: "workflow_dispatch" }, expected: fleet },
{ name: "disabled switch", enabled: "false" },
{ name: "missing switch", enabled: "" },
{ name: "invalid switch", enabled: "yes" },
{ name: "fork", github: { repository: "someone/paperclip", repository_id: "123" } },
{ name: "wrong repository identity", github: { repository_id: "123" } },
{ name: "pull request", github: { event_name: "pull_request", ref: "refs/pull/123/merge" } },
{ name: "privileged PR event", github: { event_name: "pull_request_target" } },
{ name: "release tag", github: { ref: "refs/tags/v2026.911.0" } },
{ name: "branch push", github: { ref: "refs/heads/feature" } },
{ name: "manual branch build", github: { event_name: "workflow_dispatch", ref: "refs/heads/feature" } },
{ name: "workflow completion event", github: { event_name: "workflow_run" } },
]) {
test(`cloud runner routing: ${name}`, () => {
const context = { github: { ...base, ...github }, vars: { AWS_CLOUD_BUILDS_ENABLED: enabled } };
assert.equal(runInNewContext(expression, context), expected);
assert.equal(runInNewContext(timeoutExpression, context), expected === fleet ? 40 : 60);
});
}
@@ -0,0 +1,56 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
const workflow = readFileSync(new URL("../../workflows/docker.yml", import.meta.url), "utf8");
const job = workflow.split(" promote_canary_channel:\n")[1];
const script = job.split(" run: |\n")[1].split("\n").map(line => line.replace(/^ {10}/, "")).join("\n");
const sha = "a".repeat(40);
test("canary promotion waits for the standard manifest and keeps its serialized channel", () => {
assert.match(job, /needs: \[merge-and-push\]/);
assert.match(job, /group: docker-canary-channel-promotion/);
assert.match(job, /cancel-in-progress: false/);
});
for (const [name, commitPresent, imagePresent] of [
["promotes the current npm canary without a cloud image", true, true],
["waits when the standard image is missing", true, false],
["waits when the npm canary tag has not resolved", false, false],
]) {
test(name, () => {
const dir = mkdtempSync(path.join(tmpdir(), "standard-canary-promotion-"));
const log = path.join(dir, "calls.jsonl");
const fixture = `#!${process.execPath}
const fs = require("node:fs");
const command = require("node:path").basename(process.argv[1]);
const args = process.argv.slice(2);
fs.appendFileSync(process.env.TEST_CALLS, JSON.stringify({ command, args }) + "\\n");
if (command === "curl") process.stdout.write(JSON.stringify({ canary: "2026.922.0-canary.1" }));
else if (command === "gh") { if (process.env.COMMIT_PRESENT !== "true") process.exit(1); process.stdout.write(process.env.TEST_SHA); }
else if (args.slice(0, 3).join(" ") === "buildx imagetools inspect") process.exit(process.env.IMAGE_PRESENT === "true" ? 0 : 1);
else if (args.slice(0, 3).join(" ") !== "buildx imagetools create") process.exit(99);
`;
try {
for (const command of ["curl", "gh", "docker"]) writeFileSync(path.join(dir, command), fixture, { mode: 0o755 });
const result = spawnSync("bash", ["-e", "-o", "pipefail", "-c", script], {
encoding: "utf8", env: {
...process.env, PATH: `${dir}${path.delimiter}${process.env.PATH}`,
IMAGE: "ghcr.io/paperclipai/paperclip", GITHUB_REPOSITORY: "paperclipai/paperclip",
TEST_CALLS: log, TEST_SHA: sha, COMMIT_PRESENT: String(commitPresent), IMAGE_PRESENT: String(imagePresent),
},
});
assert.equal(result.status, 0, result.stderr);
const calls = readFileSync(log, "utf8").trim().split("\n").map(line => JSON.parse(line));
assert.ok(calls.find(call => call.command === "gh").args.some(arg => arg.includes("canary%2Fv2026.922.0-canary.1")));
const docker = calls.filter(call => call.command === "docker").map(call => call.args);
assert.deepEqual(docker, [
...(commitPresent ? [["buildx", "imagetools", "inspect", "ghcr.io/paperclipai/paperclip:sha-aaaaaaa"]] : []),
...(commitPresent && imagePresent ? [["buildx", "imagetools", "create", "-t", "ghcr.io/paperclipai/paperclip:canary", "ghcr.io/paperclipai/paperclip:sha-aaaaaaa"]] : []),
]);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
}
@@ -1,65 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
const workflow = readFileSync(new URL("../../workflows/docker-cloud.yml", import.meta.url), "utf8");
const step = workflow.split(" - name: Free runner disk")[1].split(" - name: Login to GitHub Container Registry")[0];
const script = step.split(" run: |\n")[1].split("\n").map((line) => line.replace(/^ {10}/, "")).join("\n");
const threshold = 64 * 1024 * 1024;
for (const { name, dockerFree, workspaceFree, dfStatus = "0", infoStatus = "0", cleanup } of [
{ name: "ample free space", dockerFree: threshold + 1, workspaceFree: threshold + 1, cleanup: false },
{ name: "exactly the headroom threshold", dockerFree: threshold, workspaceFree: threshold, cleanup: false },
{ name: "Docker filesystem below threshold", dockerFree: threshold - 1, workspaceFree: threshold + 1, cleanup: true },
{ name: "workspace filesystem below threshold", dockerFree: threshold + 1, workspaceFree: threshold - 1, cleanup: true },
{ name: "invalid Docker measurement", dockerFree: "unknown", workspaceFree: threshold + 1, cleanup: true },
{ name: "invalid workspace measurement", dockerFree: threshold + 1, workspaceFree: "unknown", cleanup: true },
{ name: "failed df command", dockerFree: threshold + 1, workspaceFree: threshold + 1, dfStatus: "1", cleanup: true },
{ name: "failed Docker inspection", dockerFree: threshold + 1, workspaceFree: threshold + 1, infoStatus: "1", cleanup: true },
]) {
test(`cloud disk cleanup: ${name}`, () => {
const dir = mkdtempSync(path.join(tmpdir(), "cloud-disk-test-"));
const log = path.join(dir, "commands.log");
// Every mutating command is a recording fixture; no real SDKs, caches,
// images, or directories are deleted when the workflow shell executes.
const fixture = `#!/bin/bash
printf '%s %s\\n' "\${0##*/}" "$*" >> "$COMMAND_LOG"
case "\${0##*/}" in
df)
printf 'Filesystem 1024-blocks Used Available Capacity Mounted on\\n'
if [ "$1" = '-Pk' ]; then
printf '/dev/docker 200000000 1 %s 1%% /docker\\n' "$DOCKER_FREE"
printf '/dev/workspace 200000000 1 %s 1%% /workspace\\n' "$WORKSPACE_FREE"
exit "$DF_STATUS"
fi
;;
docker)
if [ "$1" = 'info' ]; then
printf '/docker-data\\n'
exit "$INFO_STATUS"
fi
;;
esac
`;
try {
for (const command of ["df", "docker", "pnpm", "sudo"]) {
writeFileSync(path.join(dir, command), fixture, { mode: 0o755 });
}
const result = spawnSync("bash", ["-c", script], {
encoding: "utf8",
env: { ...process.env, PATH: `${dir}:${process.env.PATH}`, GITHUB_WORKSPACE: "/workspace", COMMAND_LOG: log,
DOCKER_FREE: String(dockerFree), WORKSPACE_FREE: String(workspaceFree), DF_STATUS: dfStatus, INFO_STATUS: infoStatus },
});
assert.equal(result.status, 0, result.stderr);
const commands = readFileSync(log, "utf8");
if (infoStatus === "0") assert.match(commands, /df -Pk \/docker-data \/workspace/);
assert.equal(commands.includes("pnpm store prune"), cleanup);
assert.equal(commands.includes("sudo rm -rf /usr/share/dotnet"), cleanup);
assert.equal(commands.includes("docker system prune -af"), cleanup);
assert.equal(result.stdout.includes("skipping cleanup"), !cleanup);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
}
@@ -6,7 +6,6 @@ const workflows = [
'.github/workflows/refresh-lockfile.yml',
'.github/workflows/pr-trusted.yml',
'.github/workflows/docker.yml',
'.github/workflows/docker-cloud.yml',
];
test('lockfile repair workflows resolve dependencies instead of updating metadata only', async () => {
@@ -87,9 +87,7 @@ test("Cloud readiness bookkeeping never waits for the AWS verification fleet", (
const workflow = readFileSync(new URL("../../workflows/cloud-readiness.yml", import.meta.url), "utf8");
const bodies = new Map();
for (const [name, needs] of [
["artifacts", null],
["source_verified", "[verify]"],
["ready", "[verify, image, artifacts]"],
]) {
const body = workflow.match(new RegExp(`^ ${name}:\\n([\\s\\S]*?)(?=^ [a-z_]+:|(?![\\s\\S]))`, "m"))?.[1];
assert.ok(body, `missing ${name} job`);
@@ -100,8 +98,6 @@ test("Cloud readiness bookkeeping never waits for the AWS verification fleet", (
assert.match(body, /^ +SOURCE_SHA: \$\{\{ github.sha \}\}$/m);
assert.equal(body.match(/^ needs: (.+)$/m)?.[1] ?? null, needs, `${name} prerequisites`);
}
assert.match(bodies.get("artifacts"), /^ run: node scripts\/cloud-readiness.mjs "\$SOURCE_SHA"$/m);
assert.match(bodies.get("source_verified"), /^ run: node --test scripts\/cloud-source-verification.test.mjs$/m);
assert.match(bodies.get("source_verified"), /echo "Cloud source verified v1: \$SOURCE_SHA"/);
assert.match(bodies.get("ready"), /echo "Cloud deployable v1: \$SOURCE_SHA"/);
});
-50
View File
@@ -14,13 +14,6 @@ concurrency:
cancel-in-progress: false
jobs:
image:
if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master'
permissions:
contents: read
packages: write
uses: ./.github/workflows/docker-cloud.yml
verify:
if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master'
permissions:
@@ -29,29 +22,6 @@ jobs:
with:
ref: ${{ github.sha }}
artifacts:
if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master'
name: Wait for exact-source cloud artifacts
# Bookkeeping must not wait for the AWS builders it observes.
runs-on: ubuntu-latest
timeout-minutes: 35
permissions:
contents: read
actions: read
attestations: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
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"
source_verified:
# npm canary publication reuses this exact-source verification proof.
# Keep it independent of image/migrator availability, and fail closed when
@@ -79,23 +49,3 @@ jobs:
SOURCE_SHA: ${{ github.sha }}
run: |
echo "Cloud source verified v1: $SOURCE_SHA" >> "$GITHUB_STEP_SUMMARY"
ready:
# Versioned consumer contract. Never add always() or continue-on-error:
# failed, cancelled, or skipped prerequisites must not report readiness.
name: Cloud deployable v1
needs: [verify, image, artifacts]
if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master'
# Bookkeeping must not wait for the AWS builders it observes.
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Record cloud readiness
env:
SOURCE_SHA: ${{ github.sha }}
run: |
{
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"
-290
View File
@@ -1,290 +0,0 @@
name: Docker cloud
on:
workflow_dispatch:
workflow_call:
permissions: {}
# Independent SHAs can build immediately on separate runners.
# Repeated requests for the same source serialize without cancelling a build.
# No mutable canary channel is promoted here; docker.yml owns that operation.
concurrency:
group: docker-cloud-${{ github.sha }}
cancel-in-progress: false
jobs:
build-and-push-cloud:
# Only canonical master builds can consume the release Fleet. The runner
# group must also allow this workflow only at refs/heads/master.
# Keep an operator switch for a full-run retry on GitHub-hosted runners.
runs-on: ${{ vars.AWS_CLOUD_BUILDS_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-cloud-build-x64/env=public-ci' || 'ubuntu-latest' }}
# Fleet instances expire after 45 minutes, including bootstrap and cleanup.
timeout-minutes: ${{ vars.AWS_CLOUD_BUILDS_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && 40 || 60 }}
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
# Full history and tags so `git describe` below can compute the
# release version to stamp into the image.
fetch-depth: 0
# `.git` is dockerignored, so a running image cannot derive its own
# version and otherwise reports the source package.json placeholder in
# analytics and the debug panel. Compute it here from the pristine
# checkout (real CalVer drift from the nearest release tag) and pass it
# into the build. Empty when no release tag is reachable — the server
# then keeps its existing fallbacks.
- name: Compute build version
id: build-version
run: |
set -euo pipefail
case "${GITHUB_REF}" in
refs/tags/nightly/v*)
# Lane tags carry the exact published version; stamp it verbatim
# instead of describing drift from the nearest stable tag.
version="${GITHUB_REF#refs/tags/nightly/v}"
;;
refs/tags/beta/v*)
version="${GITHUB_REF#refs/tags/beta/v}"
;;
*)
version="$(git describe --tags --match 'v*' --long --dirty 2>/dev/null || true)"
;;
esac
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "Stamping build version: ${version:-<none>}"
# ISO week stamp for the Dockerfile's tool layer: the layer caches
# across commits and re-pulls the @latest CLI tools when the week rolls
# over, instead of on every build.
- name: Compute tool cache epoch
id: tools-epoch
run: echo "epoch=$(date -u +%G-W%V)" >> "$GITHUB_OUTPUT"
- name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
with:
version: 9.15.4
run_install: false
# No dependency cache here: this workflow publishes release images, and
# restoring a shared Actions cache into the build inputs would let a
# poisoned cache entry reach the published artifact.
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
- name: Refresh lockfile for Docker build context
run: |
set -euo pipefail
pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile
changed="$(git status --porcelain)"
if [ -z "$changed" ]; then
echo "Lockfile already matches package metadata."
exit 0
fi
if printf '%s\n' "$changed" | grep -Fvq ' pnpm-lock.yaml'; then
echo "Unexpected files changed during lockfile refresh:"
echo "$changed"
exit 1
fi
echo "Using refreshed pnpm-lock.yaml in the Docker build context."
- name: Free runner disk
run: |
set -euo pipefail
echo "Disk before cleanup:"
df -h
# A measured hosted cloud build started with 86 GB available.
# Keep ample headroom for BuildKit and image verification, but
# avoid minutes deleting SDKs when neither filesystem needs space.
minimum_free_kib=$((64 * 1024 * 1024))
if docker_root="$(docker info --format '{{.DockerRootDir}}')" \
&& available_kib="$(df -Pk "$docker_root" "$GITHUB_WORKSPACE" | awk 'NR > 1 { rows++; if ($4 !~ /^[0-9]+$/) invalid = 1; if (min == "" || $4 < min) min = $4 } END { if (invalid || rows != 2) exit 1; print min }')" \
&& [[ "$available_kib" =~ ^[0-9]+$ ]] \
&& (( available_kib >= minimum_free_kib )); then
echo "At least 64 GiB is available for Docker and the workspace; skipping cleanup."
exit 0
fi
pnpm store prune || true
sudo apt-get clean || true
sudo rm -rf \
/usr/share/dotnet \
/usr/share/swift \
/usr/local/lib/android \
/usr/local/share/boost \
/usr/local/share/powershell \
/opt/ghc \
/opt/hostedtoolcache/CodeQL \
/opt/hostedtoolcache/PyPy \
/opt/hostedtoolcache/Ruby || true
docker system prune -af || true
echo "Disk after cleanup:"
df -h
- name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
# Mixing several historical manifests missed otherwise reusable native
# layers on fresh builders. Import the nearest available complete cache.
- name: Select cloud cache ancestry
id: cloud-cache
env:
CACHE_IMAGE: ghcr.io/${{ github.repository }}
run: node scripts/select-cloud-cache.mjs
# Deployment tooling reads these labels from the registry to verify an
# image's schema expectations against a migrator before deploying it,
# without pulling the image. The server refuses to start when the
# database is missing bundled migrations, so orchestrators need a cheap
# way to check image/migrator compatibility up front.
- name: Compute schema migration labels
id: schema
run: |
set -euo pipefail
last=$(ls packages/db/src/migrations/*.sql | sed 's|.*/||' | LC_ALL=C sort | tail -1)
count=$(ls packages/db/src/migrations/*.sql | wc -l | tr -d ' ')
echo "last=${last}" >> "$GITHUB_OUTPUT"
echo "count=${count}" >> "$GITHUB_OUTPUT"
# Published under the same lane tag set as the self-hosted image, with a
# `-cloud` suffix (nightly-cloud, latest-cloud, <version>-cloud,
# sha-<short>-cloud). `:canary-cloud` follows the same retag-step
# ownership rule as `:canary` above.
- name: Docker meta (cloud)
id: meta-cloud
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
with:
images: ghcr.io/${{ github.repository }}
flavor: |
suffix=-cloud,onlatest=true
tags: |
type=raw,value=nightly,enable=${{ startsWith(github.ref, 'refs/tags/nightly/v') }}
type=raw,value=beta,enable=${{ startsWith(github.ref, 'refs/tags/beta/v') }}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=sha
labels: |
io.github.paperclipai.schema.last-migration=${{ steps.schema.outputs.last }}
io.github.paperclipai.schema.migration-count=${{ steps.schema.outputs.count }}
- name: Build and push (cloud)
id: build-cloud
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
target: cloud
# Space-separated sandbox-provider directory names to build into
# the variant; add here when managed deployments need another.
# CLOUD_BUNDLED_SERVER_DEPS names the optional peer packages the
# variant installs from server/package.json's declared version;
# add another name there when a managed tenant needs it.
build-args: |
USER_UID=1001
USER_GID=1001
CLOUD_BUNDLED_PLUGINS=daytona
CLOUD_BUNDLED_SERVER_DEPS=@sentry/node
PAPERCLIP_BUILD_VERSION=${{ steps.build-version.outputs.version }}
PAPERCLIP_BUILD_COMMIT=${{ github.sha }}
CLI_TOOLS_CACHE_EPOCH=${{ steps.tools-epoch.outputs.epoch }}
# amd64 only, unlike the self-hosted image above: the cloud variant
# is consumed exclusively by managed-deployment hosts, which run
# amd64. The QEMU-emulated arm64 half dominated this job's wall
# clock, and dropping it roughly halves time-to-deployable-image.
platforms: linux/amd64
push: true
# Same-SHA builds serialize above; different SHAs never share a
# writable cache ref. Registry layers are content-addressed and
# shared even when cache manifests have separate tags.
cache-from: ${{ steps.cloud-cache.outputs.source }}
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache-cloud-${{ github.sha }},mode=max
tags: ${{ steps.meta-cloud.outputs.tags }}
labels: ${{ steps.meta-cloud.outputs.labels }}
# The cloud target installs @sentry/node at the version
# server/package.json declares, into a directory the server's own
# module resolution walks. Verify the image this job just pushed, not
# a local build, so a build-cache or layer-ordering regression is
# caught before any tenant runs the image.
- name: Verify the pushed image resolves the declared Sentry version
env:
IMAGE: ghcr.io/${{ github.repository }}@${{ steps.build-cloud.outputs.digest }}
run: |
set -euo pipefail
expected="$(node -e "process.stdout.write(require('./server/package.json').peerDependencies['@sentry/node'])")"
test -n "$expected"
installed="$(docker run --rm --pull always \
-v "$PWD/scripts/assert-cloud-image-sentry.mjs:/app/server/.ci-sentry-probe.mjs:ro" \
--entrypoint node "$IMAGE" /app/server/.ci-sentry-probe.mjs)"
echo "Declared optional peer version: $expected"
echo "Installed in the pushed image: $installed"
if [ "$installed" != "$expected" ]; then
echo "ERROR: the pushed image resolves @sentry/node@$installed, expected @sentry/node@$expected" >&2
exit 1
fi
echo "The pushed image resolves the declared @sentry/node version."
# Managed hosts run node as 1001:1001. Bake that identity into the image
# so usermod does not walk the mounted home on every container start.
# Check before the entrypoint can repair a wrongly built identity.
- name: Verify cloud runtime user
env:
IMAGE: ghcr.io/${{ github.repository }}@${{ steps.build-cloud.outputs.digest }}
run: |
set -euo pipefail
docker run --rm --entrypoint sh "$IMAGE" -ec '
test "$(id -u node)" = 1001
test "$(id -g node)" = 1001
test "$USER_UID" = 1001
test "$USER_GID" = 1001
'
docker run --rm -e USER_UID=1001 -e USER_GID=1001 "$IMAGE" sh -ec '
test "$(id -u)" = 1001
test "$(id -g)" = 1001
test -w "$PAPERCLIP_HOME"
'
# Verify the independently published cloud image without waiting for
# the self-hosted manifest job. The Sentry check already pulled it.
- name: Verify cloud PID 1 reaps orphaned processes
env:
IMAGE: ghcr.io/${{ github.repository }}@${{ steps.build-cloud.outputs.digest }}
run: docker run --rm -i "$IMAGE" sh -s < scripts/assert-orphan-reaping.sh
# Cloud's commit resolver and preview-artifact planner use the full SHA.
# Publish that address only after checking this build's exact digest.
# Retagging reuses the registry manifest and does not rebuild the image.
- name: Publish verified full-SHA cloud tag
env:
IMAGE: ghcr.io/${{ github.repository }}@${{ steps.build-cloud.outputs.digest }}
FULL_SHA_TAG: ghcr.io/${{ github.repository }}:sha-${{ github.sha }}-cloud
run: |
set -euo pipefail
revision="$(docker image inspect "$IMAGE" --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}')"
platform="$(docker image inspect "$IMAGE" --format '{{ .Os }}/{{ .Architecture }}')"
test "$revision" = "$GITHUB_SHA"
test "$platform" = linux/amd64
docker buildx imagetools create --prefer-index=false --tag "$FULL_SHA_TAG" "$IMAGE"
+6 -18
View File
@@ -53,9 +53,8 @@ jobs:
# ten master commits sampled that day never produced an image at all.
#
# Each platform now builds on a runner of its own architecture and pushes by
# digest; `merge` assembles the manifest list. arm64 is kept rather than
# dropped (the cloud variant below dropped it and is amd64-only) because
# this is the self-hosted image, and ARM hosts consume it.
# digest; `merge` assembles the manifest list. Both architectures remain
# available to self-hosted installations and downstream image composers.
build-and-push:
strategy:
# Independent legs: one architecture failing should still publish
@@ -353,7 +352,7 @@ jobs:
# until the cgroup pid limit is exhausted and every fork() in the
# container fails. Run against the pushed manifest rather than a local
# build: the legs push by digest, so nothing is loaded into this
# runner's daemon. The independent cloud workflow checks its own image.
# runner's daemon.
- name: Verify PID 1 reaps orphaned processes
env:
# Through the environment, not interpolated into the script body, so
@@ -387,17 +386,7 @@ jobs:
subject-digest: ${{ steps.standard.outputs.digest }}
push-to-registry: true
# Master cloud builds start independently in docker-cloud.yml. Tag builds
# and manual Docker dispatches call the same implementation, preserving the
# release tags and the canary promotion dependency below.
build-and-push-cloud:
if: github.event_name != 'push' || github.ref != 'refs/heads/master'
uses: ./.github/workflows/docker-cloud.yml
permissions:
contents: read
packages: write
# Moves the mutable `:canary` / `:canary-cloud` channel tags. Kept OUT
# Moves the mutable `:canary` channel tag. Kept OUT
# of the build jobs and serialized in its own lane, and — the load-
# bearing property — CONVERGENT rather than self-interested: a
# promotion does not promote "its own" canary, it retags the channel
@@ -418,7 +407,7 @@ jobs:
# merge-and-push, not build-and-push: the per-arch legs push untagged
# digests, and the production `sha-*` tags this promotion retags only
# exist once the manifest merge has named them.
needs: [merge-and-push, build-and-push-cloud]
needs: [merge-and-push]
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
@@ -447,10 +436,9 @@ jobs:
exit 0
fi
short="$(printf '%s' "$sha" | cut -c1-7)"
if ! docker buildx imagetools inspect "$IMAGE:sha-${short}-cloud" >/dev/null 2>&1; then
if ! docker buildx imagetools inspect "$IMAGE:sha-${short}" >/dev/null 2>&1; then
echo "images for ${current} (sha-${short}) not published yet; its own promotion converges the channel"
exit 0
fi
docker buildx imagetools create -t "$IMAGE:canary" "$IMAGE:sha-${short}"
docker buildx imagetools create -t "$IMAGE:canary-cloud" "$IMAGE:sha-${short}-cloud"
echo "channel tags moved to canary ${current} (sha-${short})"
+1 -1
View File
@@ -417,7 +417,7 @@ jobs:
# explicitly, exactly like the nightly and beta lanes: the run keys
# its concurrency off the tag ref, so no master push can supersede
# it, and docker.yml's `type=sha` mapping publishes the
# sha-<short> and sha-<short>-cloud images either way.
# standard sha-<short> images either way.
- name: Build Docker images for the canary tag
env:
GH_TOKEN: ${{ github.token }}
@@ -22,6 +22,14 @@ on:
description: "Optional lower concurrency (at least 2, no higher than the configured campaign limit)"
type: string
required: false
grok_authentication:
description: "Explicit Grok credential source; subscription uses protected GROK_AUTH_JSON"
type: choice
options:
- api_key
- subscription
default: api_key
required: false
max_infrastructure_retries:
description: "Automatic retries only for explicitly retryable infrastructure failures (0-3)"
type: number
@@ -256,6 +264,7 @@ jobs:
MAX_PARALLEL_LIMIT: ${{ needs.authorize.outputs.max_parallel_limit }}
REQUESTED_MAX_PARALLEL: ${{ inputs.max_parallel }}
ROSTERS: ${{ inputs.rosters || 'all' }}
GROK_AUTHENTICATION: ${{ inputs.grok_authentication || 'api_key' }}
run: |
set -euo pipefail
if ! [[ "$MAX_PARALLEL" =~ ^[1-9][0-9]*$ ]] || [ "$MAX_PARALLEL" -lt 2 ] || [ "$MAX_PARALLEL" -gt "$MAX_PARALLEL_LIMIT" ]; then
@@ -272,6 +281,7 @@ jobs:
node packages/paperclip-runner/scripts/runner-protocol-eval-campaign.mjs catalog \
--evals-root .paperclip-evals \
--rosters "$ROSTERS" \
--grok-authentication "$GROK_AUTHENTICATION" \
--campaign-id "gha-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \
--max-parallel "$MAX_PARALLEL" \
--output runner-protocol-eval-catalog.json
@@ -542,6 +552,7 @@ jobs:
ANTHROPIC_API_KEY: ${{ matrix.credentialName == 'ANTHROPIC_API_KEY' && secrets.ANTHROPIC_API_KEY || '' }}
OPENROUTER_API_KEY: ${{ matrix.credentialName == 'OPENROUTER_API_KEY' && secrets.OPENROUTER_API_KEY || '' }}
XAI_API_KEY: ${{ matrix.credentialName == 'XAI_API_KEY' && secrets.XAI_API_KEY || '' }}
PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET: ${{ matrix.credentialName == 'PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET' && secrets.GROK_AUTH_JSON || '' }}
PAPERCLIP_CLAUDE_MANAGED_PROFILE_ID: ${{ vars.PAPERCLIP_CLAUDE_MANAGED_PROFILE_ID }}
PAPERCLIP_CLAUDE_MANAGED_AGENT_ID: ${{ vars.PAPERCLIP_CLAUDE_MANAGED_AGENT_ID }}
PAPERCLIP_CLAUDE_MANAGED_AGENT_VERSION: ${{ vars.PAPERCLIP_CLAUDE_MANAGED_AGENT_VERSION }}
@@ -582,19 +593,36 @@ jobs:
--runner-package runner-protocol-build/extracted/paperclip-runner.tgz \
--runnerd runner-protocol-build/extracted/paperclip-runnerd \
--runs-root cell-output/runs \
--summary-path cell-output/roster-summary.json \
--max-infrastructure-retries "$MAX_INFRASTRUCTURE_RETRIES" \
--run-id "gha-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${CELL_ID}"
status=$?
set -e
CELL_EXIT_CODE="$status" node --input-type=module <<'NODE'
import { writeFileSync } from "node:fs";
import { readFileSync, writeFileSync } from "node:fs";
let authenticationMode;
let authenticationEvidenceFailure;
if (["XAI_API_KEY", "PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET"].includes(process.env.CREDENTIAL_NAME)) {
const expected = process.env.CREDENTIAL_NAME === "XAI_API_KEY" ? "api_key" : "subscription";
try {
const observed = JSON.parse(readFileSync("cell-output/roster-summary.json", "utf8")).authenticationMode;
// Never copy arbitrary provider output into cell metadata or errors.
if (["api_key", "subscription"].includes(observed)) authenticationMode = observed;
if (authenticationMode !== expected) authenticationEvidenceFailure = "grok_authentication_evidence_mismatch";
} catch {
authenticationEvidenceFailure = "grok_authentication_evidence_unreadable";
}
}
writeFileSync("cell-output/cell.json", `${JSON.stringify({
schema: "paperclip.runner-protocol-eval.cell/v1",
cellId: process.env.CELL_ID,
rosterFile: process.env.ROSTER_FILE,
caseId: process.env.CASE_ID,
...(authenticationMode ? { authenticationMode } : {}),
...(authenticationEvidenceFailure ? { authenticationEvidenceFailure } : {}),
exitCode: Number(process.env.CELL_EXIT_CODE),
}, null, 2)}\n`, { mode: 0o600 });
if (authenticationEvidenceFailure) throw new Error(authenticationEvidenceFailure);
NODE
exit "$status"
+2
View File
@@ -690,6 +690,8 @@ When an additional repository has a configured local checkout, Paperclip seeds t
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.
Staging preserves relative symlink targets in secondary repositories, including skill links such as `.claude/skills/demo -> ../../skills/demo`. It does not rewrite them to host temporary paths or copy their target contents in place of the link. Daytona still rejects outbound archives with absolute or escaping link targets before extraction.
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
+19 -30
View File
@@ -32,40 +32,29 @@ docker build -t paperclip-local \
--build-arg USER_UID=$(id -u) --build-arg USER_GID=$(id -g) .
```
## Cloud image addresses
## Standard images and downstream composition
The Docker workflow publishes the managed deployment image for Linux AMD64.
`Cloud readiness` starts `Docker cloud` on each master push independently of the
multi-platform self-hosted build. Different commits use separate concurrency groups and existing
GitHub-hosted runners, so an older production or cloud build does not hold the
new commit in a workflow queue. Available GitHub runner capacity still applies.
Release tags and manual `Docker` dispatches call the same cloud build workflow.
The Docker workflow publishes the standard `production` target for Linux AMD64
and ARM64. Canonical master pushes also publish
`ghcr.io/paperclipai/paperclip:sha-<FULL_SHA>` and a GitHub/Sigstore attestation
for its immutable multi-platform digest. Downstream services can compose their
own images from this public base without rebuilding Core.
Each commit exports to its own `buildcache-cloud-<FULL_SHA>` registry tag.
Builds import the current commit and nine first-parent ancestors, plus the
legacy `buildcache-cloud` fallback. This preserves reusable layers without
letting concurrent builds overwrite one shared cache manifest. Retain recent
cache tags if registry cleanup is configured; deleting them makes builds colder.
The legacy recurring public `-cloud` publisher is retired. Master pushes,
release tags, and manual `Docker` dispatches no longer build that variant.
Existing `-cloud` tags and digests remain in the registry for rollback; their
release-channel aliases no longer advance. This change deletes no images,
cache tags, or migrator artifacts.
Cloud CI skips SDK and cache cleanup when both the Docker data filesystem and
the checkout filesystem have at least 64 GiB available. Below that conservative
headroom threshold, or when the measurement fails, it retains the existing
cleanup. The threshold selects the fast path; it is not a new minimum disk
requirement for local builds or smaller runners.
The `cloud` Dockerfile target remains available for explicit
[preview builds](preview-release-artifacts.md). Those requests still publish a
full-SHA `-cloud` tag when needed. They do not advance a release channel or
replace downstream private composition.
After the pushed image passes its Sentry and orphan-reaping checks, the workflow verifies its
commit label and platform and adds `ghcr.io/paperclipai/paperclip:sha-<full-commit-sha>-cloud`.
This address lets commit-based deployment tooling reuse the normal build.
Existing short-SHA and release tags remain available.
The full-SHA tag identifies the source commit. It does not certify that source
tests passed or that a compatible database migrator is available. Deployment
tooling must still check those prerequisites and pin the resolved image digest;
a rebuild of the same source can update the tag's digest.
The separate [cloud readiness check](cloud-build-readiness.md) combines source
verification, successful cloud image checks, and exact-source migrator
availability. It runs outside the full npm release's concurrency queue.
A published image alone does not prove source tests or migration compatibility.
Downstream deployment tooling must verify [source proof](cloud-build-readiness.md),
the standard image attestation, the exact-source migrator, and its own composed
image before rollout. Resolve immutable digests instead of deploying mutable tags.
## One-liner (build + run)
+2 -2
View File
@@ -19,7 +19,7 @@ The release captain's checklist for every lane. The mechanics live in
- [ ] the release smoke suite passed against the exact candidate canary
before anything published
- [ ] `npm view paperclipai@nightly version` shows the new `-nightly.N`
- [ ] `nightly/v*` tag pushed; `:nightly` and `:nightly-cloud` images built
- [ ] `nightly/v*` tag pushed; `:nightly` image built
- [ ] on a tag-push rejection (workflows-permission error), follow the
recovery commands in the job summary
@@ -34,7 +34,7 @@ Happy path:
- [ ] dispatch `release.yml` with `channel: beta`
- [ ] approve the `npm-beta` environment gate
- [ ] `npm view paperclipai@beta version` shows the new `-beta.N`
- [ ] `beta/v*` tag pushed; `:beta` and `:beta-cloud` images built
- [ ] `beta/v*` tag pushed; `:beta` image built
- [ ] post-publish smoke (`smoke_beta`) is green
- [ ] `draft_stable_notes` pushed `release-notes/v<beta-version>`; open the
notes PR from the job-summary link
+7
View File
@@ -1003,6 +1003,13 @@ stack id to the Cloud tenant portfolio endpoint. Client-supplied user ids are
never forwarded. Successful responses are cached briefly per user; self-hosted
instances return `404`.
On Cloud-managed instances, the Members page offers **Invite people** to the
current stack's owner/admin. It opens that stack's Cloud People settings with a
full-page navigation. The destination uses the configured Cloud origin and the
current stack from the authenticated portfolio. The action stays hidden until
that role is known, or when the portfolio request fails. Hiding the tenant-local
Invites tab does not hide this Cloud action.
## 10.2 Goals
- `GET /companies/:companyId/goals`
+53 -96
View File
@@ -1,38 +1,25 @@
# Cloud build readiness
The `Cloud readiness` workflow starts for every master push. Its versioned
`Cloud deployable v1` job succeeds only after all three prerequisites succeed:
The `Cloud readiness` workflow starts for every master push and retains the
versioned `Cloud source verified v1` job. It calls the full `Release Verify`
workflow for that exact commit, including typecheck, builds, general and
serialized tests, and Runner verification. The source proof depends on every
source check and fails closed if verification fails, is cancelled, or is skipped.
- The existing `Release Verify` workflow checks that exact commit, including
typecheck, builds, general and serialized tests, and Runner verification.
- The reusable `Docker cloud` workflow builds and verifies its Linux AMD64
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 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 recurring public `-cloud` publisher and its `Cloud deployable v1` gate are
retired. The workflow no longer builds a legacy image or waits for one.
Keep its filename and source-proof job name stable: npm canary publication and
downstream image composers consume that exact contract.
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
the mounted home and delay health checks. Before publishing the full-SHA tag,
the workflow checks the baked identity without running the entrypoint, then
checks the normal entrypoint's effective user and writable home. Volume ownership
repair still runs when needed. The Dockerfile defaults remain `1000:1000` for
self-hosted builds, and runtime identity overrides remain supported. The first
build with the new identity must rebuild layers that depend on the base image;
later builds can reuse those layers.
Standard images still publish independently through `docker.yml`. The
`cloud-migrator-artifacts.yml` workflow still publishes signed exact-source
migrators independently. A downstream composer must verify those artifacts,
build and test its own image, and record separate deployment readiness.
Verification and image building run concurrently, outside the full npm release's
concurrency group. Different commits have independent groups. The npm canary
release reuses `Cloud source verified v1` for the exact master push instead of
starting a second copy of `Release Verify`. This source-only job depends on every
source check but does not wait for Docker or migrator publication. npm canary
publication remains possible when source verification passes and an image build
fails. Stable releases and candidate-branch betas still run full verification.
Verification runs outside the full npm release's concurrency group. Different
commits have independent groups. The npm canary release reuses the source proof
for its exact master push instead of starting another `Release Verify` run.
Stable releases and candidate-branch betas still run full verification.
The canary consumer requires the expected workflow ID and path, upstream source
repository, master push event, full SHA, and a successful job in the latest run
@@ -57,41 +44,36 @@ 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. 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 and retirement boundary
## Consumer contract
Accept `Cloud source verified v1` only from the latest attempt of the canonical
`cloud-readiness.yml` master push for the expected repository identity and full
source SHA. Check the job itself and reject failed, skipped, cancelled, or
ambiguous proof. This signal verifies source only. It creates no release record,
certifies no composed image, and deploys no instance.
`Cloud deployable v1` is a source-and-artifact readiness signal. A deployment
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.
Downstream deployment consumers must separately verify the standard image's
immutable digest and attestation, the exact-source migrator's signature and
integrity, migration compatibility, their own image composition, and target
health. Order automatic candidates by master ancestry, not completion time.
For automatic selection, accept only a successful job named exactly
`Cloud deployable v1` in the latest attempt of a successful
`.github/workflows/cloud-readiness.yml` run in `paperclipai/paperclip`, with
event `push`, head branch `master`, and the expected full head SHA and repository.
Do not trust a similarly named check from another workflow or a manual branch run.
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.
Merge this retirement only after all active automatic deployment consumers use
the standard-image composition contract. A consumer still selecting
`Cloud deployable v1` will stop advancing at the last legacy-ready commit.
Do not rename the source proof to the old readiness name or weaken a consumer
check to hide that dependency.
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.
Existing release records, immutable image digests, migrator artifacts, and
registry tags are retained for rollback. No registry deletion or live deployment
is part of this change. Explicit `release.yml` preview requests still use the
legacy `cloud` Dockerfile target for a specified source commit. They do not
restart recurring legacy publication. Keep that compatibility path until its
operator consumers migrate separately.
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.
The old `nightly-cloud`, `beta-cloud`, `latest-cloud`, and `canary-cloud` aliases
stop advancing. Self-hosted standard release aliases continue unchanged. A
rollback to an already published image needs no rebuild; restoring recurring
legacy publication would require reverting the publisher retirement.
## Timing and rollout
@@ -107,7 +89,8 @@ exact commit. Keep readiness and deployment as separate milestones:
| --- | --- | --- |
| Merge | Merged PR timestamp and full merge commit SHA | Merge |
| Image available | Successful full-SHA image publication and verification | Merge |
| Cloud deployable | Successful `Cloud deployable v1` job in the accepted push run and attempt | Merge |
| Source verified | Successful `Cloud source verified v1` job in the accepted push run and attempt | Merge |
| Composed image ready | Downstream composition verification and publication succeed | Merge |
| Canary healthy | Deployment consumer's canary health gate confirms the target commit | Merge |
| Fleet complete | Campaign succeeds for all eligible targets at that commit | Merge |
@@ -118,7 +101,7 @@ does not measure automatic merge-to-deploy latency. A preparation-only run
resolves artifacts without deploying a target and must not be counted as a
successful deployment.
Record queue time and the image, source-verification, and artifact-wait durations
Record queue time and the image, source-verification, migrator, and composition durations
separately. The slowest prerequisite determines readiness; shortening an already
faster prerequisite may have no effect on the total. After readiness, measure
consumer discovery delay, artifact resolution, canary health, and fleet rollout.
@@ -132,23 +115,14 @@ excluded or sleeping targets, retries, and failures with the fleet result. Recor
runner queue conditions and cache state; one warm or cold run is a sample, not a
latency guarantee.
Land full-SHA image publication, independent cloud builds, and migrator-only
publication before enabling this workflow. Until those producers are present,
the artifact wait cannot succeed. A manual dispatch on master can verify the
wiring, but automatic consumers should use push runs. Source verification and
registry checks can be rerun without deploying or changing mutable npm channels.
When reverting this workflow, restore the master push trigger in
`docker-cloud.yml` in the same change so master images continue to build.
## Reserved AWS verification capacity
`AWS_POST_MERGE_CI_ENABLED=true` routes cloud source verification, artifact
waiting, readiness signals, and exact-master migrator preparation to the
`AWS_POST_MERGE_CI_ENABLED=true` routes cloud source verification and
exact-master migrator preparation to the
`paperclip-post-merge` runner group. The separate Fleet label is
`runs-on/fleet=paperclip-post-merge-x64/env=public-ci`. Its 36 reserved slots use
the same four-vCPU, 16-GiB machines as approved PR jobs. PR capacity is reduced
to 64; image capacity stays at eight. The total ceiling remains 108 runners.
to 64; the separately provisioned image capacity is unchanged by this retirement.
This keeps PR bursts from consuming every post-merge verification slot.
Every selector checks the canonical repository name and ID, master ref, and a
@@ -172,29 +146,12 @@ Disable the switch and rerun the whole workflow to restore GitHub-hosted
placement. Assigned jobs keep their original runners. Readiness requirements,
source checks, and npm integrity checks are unchanged.
## AWS cloud build routing
`AWS_CLOUD_BUILDS_ENABLED=true` routes the Docker cloud job to the
`paperclip-cloud-build-x64` RunsOn Fleet for canonical `paperclipai/paperclip`
master pushes and manual master runs. Forks, pull requests, and release tags
retain GitHub-hosted runners. The separate `AWS_CI_ENABLED` and
`AWS_CI_TRUSTED_USER_IDS` variables control PR routing.
The cloud Fleet uses a separate runner group, `paperclip-cloud-build`, restricted
to this repository and `.github/workflows/docker-cloud.yml@refs/heads/master`.
Provision that group and Fleet before enabling the variable. The cloud runners
need at least 64 GiB free for Docker and the workspace; the initial configuration
uses 120 GiB disks with the existing 4-vCPU, 16-GiB machine size. AWS jobs have
a 40-minute workflow timeout so they finish before the 45-minute instance
lifetime; GitHub-hosted jobs retain their 60-minute timeout. Keep the registry
cache and all pushed-image verification steps enabled.
To roll back routing, set `AWS_CLOUD_BUILDS_ENABLED=false`, then rerun the cloud
workflow. Changing the variable does not migrate an already assigned job.
Check the Actions job's runner name and runner group to verify placement. Record
queue time, image verification completion, and `Cloud deployable v1` separately;
source verification and the migrator still run on GitHub-hosted runners.
## Retired AWS cloud build routing
`AWS_CLOUD_BUILDS_ENABLED` and the `paperclip-cloud-build` runner group no longer
route a public image job after this retirement. This source change does not
delete runner groups, Fleets, credentials, registry images, or cache tags. Review
shared infrastructure ownership separately before removing those resources.
### Typecheck Rust dependency cache
+9
View File
@@ -99,6 +99,15 @@ Sending `environmentId: null` tests a change back to the instance default.
## Runtime isolation
Codex ACP terminal failures with category `limit` and explicit usage-exhaustion
wording enter provider-quota recovery. A supported reset clock uses the existing
Codex parser; when none is available, recovery uses its existing quota backoff.
Context, turn, rate, storage-capacity and configured-budget limits retain their
existing handling. The adapter inspects bounded provider text only in memory
and retains recovery labels and a parsed timestamp, without copying the text to
run results or logs. A historical generic terminal-limit message alone does not
establish quota exhaustion.
`prepareManagedAiRuntime` is shared by runs, environment tests, and adoption.
Claude ACP validates working directories on the selected execution target. A
sandbox directory does not need to exist on the Paperclip server. When the agent
+373
View File
@@ -0,0 +1,373 @@
# Experimental memory connectors
Enable **Settings → Experimental → Memory connectors**, then open **Connectors**
and choose Mem0, Zep, Supermemory, Cognee, or Honcho. The flag defaults to off.
It hides catalog setup and rejects new curated setup and initial OAuth-start
requests on the server. Existing connections keep running and can reconnect or
rotate credentials without re-enabling the toggle.
Generic custom MCP connections retain their existing behavior.
These are ordinary company-scoped tool connections. There is no separate memory
page, automatic conversation upload, prompt injection, or background memory sync.
Agents explicitly invoke the provider's tools through the existing gateway, and
operators manage access and actions on the regular Permissions screen.
## Provider contracts
Official documentation and public endpoint discovery checked September 24, 2026.
| Provider | Transport and authentication | Setup and scope |
| --- | --- | --- |
| [Mem0](https://docs.mem0.ai/platform/mem0-mcp) | Remote MCP, `https://mcp.mem0.ai/mcp/`, Bearer API key | Create a key in the Mem0 dashboard. The provider key controls project access; user/agent/session selectors are tool arguments. |
| [Zep](https://help.getzep.com/memory-mcp-server) | Remote MCP, `https://api.getzep.com/mcp`, OAuth | Configure Memory MCP and the identity provider in Zep first. A project administrator assigns MCP seats and shared graphs. Sign in with the assigned work identity. An ordinary Zep API key is not the credential for this endpoint. |
| [Supermemory](https://supermemory.ai/docs/supermemory-mcp/mcp) | Remote MCP, `https://mcp.supermemory.ai/mcp`, OAuth | Sign in and select the workspace, read/write access, and optional container tags offered by Supermemory. Developer API keys are separate from hosted MCP sign-in. |
| [Cognee](https://docs.cognee.ai/cognee-cloud/connections/cloud-mcp) | Bundled Cloud API bridge, Cloud API key | Copy the tenant API Base URL and key from Cognee Cloud → API Keys. Requires an active Cloud workspace. The bundled bridge works in public deployments without a local MCP runtime host. |
| [Honcho](https://honcho.dev/docs/v3/guides/integrations/mcp) | Remote MCP, `https://mcp.honcho.dev`, Bearer API key | Create an organization and API key in the Honcho dashboard. Workspace, peer, and session selectors remain explicit provider tool arguments. |
Zep and Supermemory use user grants, the existing PKCE OAuth broker, and automatic
client registration/discovery. Zep advertises its authorization server at
`https://api.getzep.com/v1/oauth`, with `graph:read graph:write` scopes.
Supermemory advertises `https://api.supermemory.ai/api/auth`, with
`openid profile email offline_access`. Neither requires Paperclip ID or a new
Paperclip-hosted credential service.
Cognee does not advertise a hosted MCP endpoint. Paperclip bundles a narrow
Cloud API bridge for `remember`, `recall`, and `forget`, matching the reviewed
remote-mode contract from `cognee-mcp` 0.5.5. The existing approved template ID
and credential paths remain compatible, but no process or package manager runs:
there is no runtime registry resolution, transitive dependency installation, or
credential-bearing subprocess or local runtime slot. Provider failures do not
consume local restart budgets. The bridge uses the gateway's guarded HTTP
client and bounded response reader. It sends credentials only to the validated
HTTPS tenant origin under `*.aws.cognee.ai`; redirects, credentials in URLs,
ports, paths, query strings, and fragments are rejected.
Setup validates Cloud access with the datasets endpoint. Scheduled health
checks validate the URL and vault references without probing the Cloud API, so
a transient provider timeout cannot hide working tools. Actual calls still
report provider failures. Calls without an explicit timeout receive 60 seconds
for retrieval synthesis. Indexing is asynchronous, so an immediate recall after
remember may return HTTP 409 until the dataset is ready.
## Credential and governance boundaries
API keys and OAuth tokens use the instance vault and existing grant lifecycle.
Cognee's tenant URL is displayed as text during entry but is also vaulted, with
an `env.COGNEE_BASE_URL` reference. No credential value belongs in connection
config, manifests, logs, fixtures, or Storybook. Personal credentials stay on
personal grants; organization credentials stay on the organization grant.
The local stdio gateway projects only the approved template's environment keys.
Provider resource selectors are not new Paperclip-enforced tenant filters. The
provider's key, OAuth consent, and ACLs determine its accessible data. Configure
agent access and action policies accordingly; do not claim that a user ID,
workspace name, dataset, or space argument by itself enforces isolation.
The catalog classifies memory retrieval as read, storage/update as write, and
forget/delete/reset as destructive, overriding misleading read-only hints.
Unknown actions are treated as writes. Supermemory's `add_memory` can either
save or forget, so the whole action is destructive. Cognee exposes only the
reviewed `remember`, `recall`, and `forget` schemas from version 0.5.5, not the
package's broader administration tools.
| Reviewed family | Examples | Risk |
| --- | --- | --- |
| Retrieval | Mem0 `search_memories`, `get_memories`, `list_events`; Supermemory `search_memory`, `get_profile`; Cognee `recall` | Read |
| Store/update | Mem0 `add_memory`, `update_memory`; Zep add-memory tools; Cognee `remember`; Honcho create/update tools | Write |
| Delete/forget | Mem0 `delete_memory`, `delete_all_memories`, `delete_entities`; Supermemory `add_memory`; Cognee `forget` | Destructive |
The existing default policy remains **Allowed** for active actions, including
writes and deletion. Operators can narrow access or set **Ask first**. Connection
revocation, company and grant checks, action profiles, runtime access, catalog
quarantine (when enabled), and audit continue through the shared substrate.
## Review and validation
Storybook: **Apps → Connections → Memory**. Includes all five production setup
flows, the experimental toggle, disabled setup, waiting for sign-in, rejected
credentials/retry, and a narrow Cognee layout. The fixtures explicitly simulate
authentication/discovery and never call providers or store secrets.
Deterministic tests cover the off-by-default contract, cached gallery filtering,
direct setup links, server-side gates before secret creation, provider methods,
environment credential paths, risk classification, pinned Cognee schemas, URL
restrictions, and encrypted credential handling. Full provider proof is tracked
separately below; mocked tests and Storybook are not live proof.
The [sanitized live tool inventory](memory-tool-inventory.json) records every
discovered name, schema hash, parameter name, and risk classification for the
five connected providers (82 tools total).
Live observations from the isolated `codex/memory-connectors` checkout:
- Mem0: API key created; live setup discovered 11 tools. Search succeeded through
Paperclip. An `add_memory` call with synthetic notebook text waited for **Ask
first**, then executed after **Allow once**, with both decisions in the audit log.
- Supermemory: Google sign-in and developer API key creation completed. Hosted
MCP OAuth connected separately with read-only consent restricted to the test
tag and one test agent. Search succeeded; a query for an unconsented tag
returned the expected provider denial. Live discovery returned 16 tools.
- Cognee: API key created and Cloud access verified. The official pinned MCP
client stored synthetic notebook text in a dedicated test dataset and recalled
it after indexing completed. Personal setup exposes the three reviewed tools.
Paperclip gateway recall returned the expected blue notebook fact (10.6s).
A nonexistent test dataset returned a tool error, correctly recorded as failure.
- Zep: Google signup and the isolated `Memory connector test` project are ready.
Its project API key is saved in `~/.secrets` (mode 0600), separately from the
hosted MCP OAuth grant. Enabled Google Workspace MCP with writes allowed and
automatic user creation disabled; created only the matching test user using
the documented API. Paperclip dynamic registration and the explicitly approved
`graph:read` / `graph:write` grant completed. Live discovery returned 12 tools.
The Codex browser's automated form submission stalled; a fresh flow and the
user's final consent click completed authorization.
- Honcho: initial organization creation failed repeatedly, including after a
fresh Google login. A subsequent retry succeeded and issued the API key,
saved in `~/.secrets` (mode 0600). Personal connection setup discovered 40 tools.
No payment method was added. The real agent test exposed and verified the
personal remote credential-path correction described below.
All five providers now have authenticated managed-tool E2E evidence. Supermemory
now also has verified scoped writes and semantic recall, as recorded below. Discovery does
not establish that every individual tool or advanced provider feature was tested.
No real memories were uploaded for testing. The synthetic Cognee dataset is
`paperclip_memory_connector_smoke_20260924`; the Mem0 test user and Supermemory
consent tag are `paperclip-memory-smoke-20260924`.
### Real agent tasks
Follow-up acceptance testing creates tasks in the browser and assigns a real
`codex_local` agent in an isolated company. These tests use managed connector
tools, not provider credentials in the agent environment. Task-scoped connector
audit records verify the returned provider results independently of the agent's
completion comment.
The first task exposed a Codex delivery defect: generated MCP config used
`headers`, which Codex ignores, instead of `http_headers`. The adapter now writes
the supported field; a regression assertion checks the complete header setting.
After restarting the test server, the resumed agent received working managed
tools and retrieved the approved Mem0 memory.
- **MEM-1 / Mem0:** browser-created task requested a synthetic write, paused for
**Ask first**, and resumed after the board clicked **Approve & run**. The write
executed once and `search_memories` retrieved the same memory ID and exact
silver compass fact under user `paperclip-memory-task-e2e-20260924`.
- **MEM-2 / Cognee:** the task called `remember` and `recall` through the managed
stdio gateway. The provider returned the amber telescope fact from dedicated
dataset `paperclip_memory_task_e2e_20260924` on the first recall.
- **MEM-3 / Supermemory:** read-only search succeeded within the consented tag
with an honest zero-result response; one out-of-scope search returned the
provider's explicit 403 denial. This does not test memory writes under
read-only consent. The task exposed a gateway reporting defect: provider
`isError: true` was recorded and returned as success. Failed provider calls now
produce failed invocation/audit records and an MCP `isError: true` response.
A second browser-triggered agent run confirmed both the successful scoped
search and the corrected error response; audit records show `call_failed`,
`outcome: failure`, and `reasonCode: tool_error` for the denied search.
- **MEM-4 / Zep:** run `959d37a2-5d06-4f8b-bbfd-b4d751949fc8` called
`add_memory` and `search_graph` through the managed connector. The first
episode search returned the exact golden astrolabe fact and matching episode
`9133fe20-3bf0-46e4-bb48-d58c697d141d`. The episode was not yet processed;
raw episode retrieval passed, while derived graph processing is not claimed.
- **MEM-5 / Honcho:** the first authenticated run exposed a gateway bug: a
personal API-key path `credentials.authorization` was incorrectly prefixed a
second time during vault resolution. Personal remote grants now use their
declared path, with regression coverage for API keys, custom headers, and
OAuth. After restart, run `f8498aa1-5c84-48f5-8a94-3b603ea30e9a` made seven
successful managed calls: list/create workspace, create peer/session, add the
peer, add a message, and retrieve messages. Dedicated workspace
`paperclip-memory-e2e-20260924`, peer `synthetic-test-explorer`, session
`synthetic-observatory-session`, message `q3nOttElad3g_JACmizQd` returned the
exact violet sundial fact. No approval or provider errors occurred on the
successful run. Its initial discovery still reflected the earlier failed
health check; successful calls restored connection health.
All tools default to **Allowed**, including writes and destructive actions.
The temporary Mem0 **Ask first** test override was removed after the approval
test. Effective agent access was checked again: Mem0 11/11, Cognee 3/3,
Supermemory 16/16, Zep 12/12, and Honcho 40/40 allowed, with zero ask-first or
off actions. Provider OAuth consent remains a separate boundary from Paperclip
tool permissions.
### Daytona sandbox verification (September 24, 2026)
Browser-created **MEM-6** runs the same three connected providers through a
native `paperclip_runner` Codex agent in a real Daytona Linux x86_64 sandbox.
The acceptance task uses only synthetic data and managed tools: a Mem0 copper
lantern fact, a dedicated Cognee dataset, and Supermemory searches inside and
outside the existing read-only consent. This first run predates Zep and Honcho
account setup; their subsequent local proof appears above. This is a manual
Product E2E attempt, not a full eval campaign.
The immutable sandbox image is
`ghcr.io/paperclipai/paperclip-daytona-runner@sha256:b782947dc9738038570308686858dfb37fd731aba2f82944b6bb665a419e2f24`.
The remote runner binary was extracted from that image (SHA-256
`5067194e4a4eff0946e312b162e78a46184dae49c29f5699be81fec6cfd0b9d7`).
The first attempt failed before provider startup because a macOS host needs
`PAPERCLIP_RUNNER_REMOTE_BINARY_PATH` pointing to a Linux binary. After configuring
it, PRP authenticated through Daytona provider ingress and the agent executed in
`/home/daytona/paperclip-workspace` as the `daytona` user.
That retry did **not** pass connector acceptance: its assigned MCP URL pointed to
the local host's loopback address, which means the sandbox itself when used
remotely. No memory provider calls ran. Catalog readiness from
`connections_search` does not prove remote gateway connectivity. Native assigned
MCP traffic uses a direct HTTP connection, separate from the PRP control channel;
the remote Codex path now relays assigned tools through the existing authenticated
PRP dynamic-tool channel. The control plane retains the short-lived gateway token
and invokes the same gateway service for discovery and each call. Provider
credentials, policies, approval checks, and audit records stay server-owned.
Every call also verifies the native run still owns its task; planning/ask modes
expose only read tools. Other provider/local paths retain their existing HTTP MCP
delivery. No tunnel or public board/API exposure is needed for remote Codex.
Provider threads retain dynamic-tool declarations, so the remote session contract
also rotates when introducing the relay. An intermediate retry discovered all
30 tools server-side but resumed an old provider catalog; it made no provider
calls and is not counted as a pass.
The first successful browser-triggered run `16c288bc-27c3-4010-b8f8-e3d24d007c59` completed
**MEM-6** successfully in 1m 36s with the default configured `gpt-5.6-sol` model.
Task-scoped gateway audit records independently confirm six calls:
- Mem0 `add_memory` stored the exact copper lantern fact with `infer=false`;
`search_memories` returned the same ID (`ba30eea2-f68b-4873-acbe-a59dae98ed75`)
under user `paperclip-memory-daytona-e2e-20260924`.
- Cognee `remember` accepted the dedicated
`paperclip_memory_daytona_e2e_20260924` dataset, and `recall` returned the fact
on the first attempt. Its stdio provider process remains on the control plane;
the Daytona agent calls it through PRP.
- Supermemory `search_memory` succeeded within the existing consented tag with
zero results. Exactly one unconsented search returned the expected 403 scope
restriction and was audited as `call_failed` / `tool_error`.
No approval prompts, public tunnel, direct provider API fallback, or provider
credentials in the agent environment were used. All assigned tools remain
Allowed. This proves the connected Mem0, Cognee, and Supermemory journeys on
remote native Codex; it does not establish live Zep/Honcho access or Supermemory
writes beyond its existing read-only consent. Failed setup attempts are retained
in the task history. The disposable environment is removed after verification.
After Zep and Honcho onboarding completed, **MEM-6** ran again in a fresh
Daytona sandbox: `b41a45a5-6040-429d-82f3-f048f45205a8`, 2m 4s. The browser
showed Done, and task-scoped gateway audit independently confirmed **10
successful provider calls across all five providers**:
- Mem0 retrieved the existing copper lantern memory and matching ID.
- Cognee performed two read-only recalls; the second returned the exact stored
sentence from the dedicated dataset.
- Supermemory completed a search within the existing consented tag (zero results).
- Zep stored the jade sextant fact once and retrieved its exact text through
episode search, with matching UUID `8ef23967-bc98-410e-9e06-03669da5d7dd`.
- Honcho created session `daytona-memory-final-20260924` in the existing synthetic
workspace, added the test peer, stored the ivory compass fact, and retrieved
the exact message `e7g4wGXfWR5_rdb4bk6zN`.
The agent confirmed Linux x86_64, user `daytona`, and working directory
`/home/daytona/paperclip-workspace`. No unexpected approval or connector error
occurred. The control plane relayed all managed provider calls through the native
runner channel, with no provider keys in the agent environment or public tunnel.
All five connections finished healthy. The original local test-agent configuration
was restored, the disposable environment removed, and Daytona confirmed sandbox
`682ca187-765e-4b61-9038-747d2649e76a` no longer exists. Provider test memories
remain available for inspection. Supermemory writes and advanced derived-memory
features remain outside this test's coverage.
### Storybook walkthrough checks
The Memory group includes 21 stories covering the catalog, experimental switch,
all five setup flows, credential forms, OAuth waiting/failure, completion, and
narrow layouts. Zep and Supermemory now fetch identity fixtures after mount;
preseeded query-cache data previously left their “Which humans” step waiting
forever. Browser walkthroughs reach both providers' ready screens with all tools
allowed. Credential play functions wait for the access button to become enabled;
browser checks also confirmed rejected Mem0 credentials and narrow Cognee inputs.
These are simulated provider journeys and do not replace live account tests.
### Automated checks
- `pnpm -r typecheck` and `pnpm build` passed. The final server changes also
passed `tsc --noEmit`.
- Connector service, memory governance, and instance-setting regressions: 399
tests passed with embedded PostgreSQL enabled.
- Focused shared/UI contract and setup tests: 281 passed.
- Storybook production build, token gates, brand asset validation, and
`git diff --check` passed. Browser review covered both themes, the 320px
Storybook viewport, disabled setup, the toggle, OAuth waiting, and rejected-key
retry. Storybook authentication remains simulated.
- The full repository suite finished: 686 files passed, six failed, four skipped;
13,308 tests passed, 11 failed, 84 skipped. It reported failures in Slack
callback batching, concurrent workspace port allocation, and deferred heartbeat
comment batching. Initial connector count/error and instance-setting snapshot
failures were corrected and their full targeted suites passed. That run also
loaded memory-normalization code before the final implementation was saved;
the final targeted memory suite passed. The full suite is not claimed green.
- Codex managed-home regression suite: 53 passed after the live-task fix;
adapter typecheck and build passed.
- Gateway acceptance/service regression suites: 106 passed after the personal
remote credential-path fix; final server typecheck and build passed.
- Native relay/session/authority regressions: 534 passed, followed by 36 final
relay/authority checks covering readable tool names and live task-mode changes.
Final server typecheck and build passed. Relay checks cover configured gateway
reuse, gateway errors, revocation, partial bindings, credential isolation, and
planning/ask restrictions.
### Supermemory write completion (September 24)
The original read-only grant was replaced through the browser with a new
read/write OAuth grant restricted to `paperclip-memory-smoke-20260924` and the
same test agent. The old local connection was removed. `who_am_i` independently
returned scoped `permission: write`; all 16 Paperclip tools remain Allowed.
Local run `fb67a991-2960-47e5-8f79-94282c65d2ec` saved the exact synthetic fact
“The Supermemory test navigator keeps a turquoise compass in a maple cabinet.”
Document `zUk5wpBu79rCMWu9Yubd9j` initially stayed queued, so the task correctly
did not claim immediate recall. Supermemory's browser console later displayed
Done and the exact stored content.
Daytona run `bab3abd3-617e-4598-b0de-213d5c26f075` performed a second write:
“The Daytona Supermemory test pilot keeps a coral sextant in a walnut drawer.”
Document `C5HY2CWANHZZa3TckJUDYN` reached Done. Managed `get_document` returned
its exact content; `search_memory` returned both this fact and the earlier
local fact at 93% relevance to their respective queries. The task audit records
10 successful calls, including the write, document reads, and semantic searches.
No provider credentials entered the sandbox. The native PRP relay handled all
calls without a public tunnel. The local agent configuration was restored, and
sandbox `6a9723dd-972e-4933-b227-6b8e7e2133ae` was deleted and verified absent.
### Bundled Cognee bridge acceptance (September 24)
The final implementation was retested after replacing the external MCP client
with the bundled Cloud bridge. Local managed write run
`16e7820c-2a94-41d9-8d7b-abe6524edf01` stored the bronze-telescope fact in
`paperclip_memory_bundled_bridge_20260924`; the immediate recall did not yet
prove indexing completion. Follow-up run `8e12cad1-f2f4-4471-a790-d589ffa050b7`
returned “He keeps a bronze telescope in a birch observatory.”
Final Daytona run `a56f9f97-3fb3-498c-abf6-974ddc7d5d30` independently recalled
that fact, then stored “The Daytona Cognee test cartographer keeps a silver
globe in a cedar study.” in `paperclip_memory_daytona_bridge_20260924`.
The provider returned dataset `9518ab46-e777-5f38-994b-9ceabfff4856`, then graph
recall returned the silver globe and cedar study. Every call used the native
runner relay and reported `transport: cognee_cloud`, `spawnedLocalProcess: false`.
The run succeeded. No provider credentials entered the sandbox. The original
local agent configuration was restored, and sandbox
`3b980bc0-6bcd-4cfe-9b17-b518c1aed64a` was deleted and verified absent.
The gateway regression suite passed all 71 tests, including immediate recovery
from a provider error in an authenticated public deployment without a trusted
local runtime host. The regression asserts that no runtime slot is created.
The bundled bridge's 25 contract and validation tests also passed.
## Branding provenance
Official artwork downloaded September 24, 2026; no invented marks or colors:
- Zep: `https://www.getzep.com/apple-touch-icon.png` (official gradient mark).
- Supermemory: `https://supermemory.ai/apple-touch-icon.png?v=2` (official mark on
its supplied white background).
- Cognee: `https://www.cognee.ai/favicon.ico`, converted losslessly to PNG.
- Honcho: `https://honcho.dev/Honcho_Pixel-05.svg`.
- Mem0 reuses the existing catalog artwork.
The supplied square artwork fits the shared gray connector frame and works in
both themes. Registry changes are in `ui/public/brands/apps/manifest.json`;
regenerate definitions with `node scripts/ingest-app-definitions.mjs --definitions-only`.
+2
View File
@@ -10,6 +10,8 @@ proof, and PR submission.
Runtime authentication: [AI Connections](./AI-CONNECTIONS.md).
Long-term memory: [Experimental memory connectors](./MEMORY.md).
Provider notes: [Google Workspace](./GOOGLE-WORKSPACE.md),
[Gmail](./GMAIL.md), [PostHog](./POSTHOG.md),
[AgentMail](./AGENTMAIL.md), and [iMessage Photon](./IMESSAGE-PHOTON.md). Optional credential custody:
+66 -9
View File
@@ -1,4 +1,4 @@
# Slack tools for Slack-origin tasks
# Slack tools for connected agents
A linked person can mention the bot and ask it to read the discussion, summarize
decisions, and create assigned follow-up tasks. Task creation, assignment,
@@ -8,10 +8,16 @@ provider tools and a bundled skill; it does not introduce another task lifecycle
## Authority and channel access
The controller resolves company, endpoint, assigned agent, task, run, and accepted
linked requester from the admitted Slack event and immutable run identity context.
Tool arguments cannot supply those identities or credentials. Recovery can follow
the original context within the same task and agent. A new board-authored request
cannot inherit an earlier Slack sender's authority.
linked requester from the admitted Slack event or the current Paperclip task/run
identity. Slack-origin work remains bound to its originating endpoint. Ordinary
tasks and routines receive only active Slack connections assigned to that agent,
and use the accepted responsible user's linked Slack account. They never borrow
the connection owner's identity or an earlier Slack sender. A routine uses its
persisted responsible user when it runs, with fresh membership/link checks.
An optional endpoint ID selects among the supplied assignments; it cannot grant
access to another company's or agent's bot. Native tools, CLI calls and queued
approvals enforce the same binding. Tokens and requester identities stay server-side.
Reads require current bot membership and requester access. Full workspace members
can read public channels where the bot belongs; guests, private channels and Slack
@@ -19,13 +25,15 @@ Connect conversations require verified requester membership. Membership checks
paginate and fail closed. Other people's bot DMs are never exposed.
**Allowed Channels controls responses and writes, not reads.** Invite the bot to
another shared channel to make it readable without enabling responses there.
another shared channel to make it readable. Newly invited channels start enabled;
a person can disable responses while preserving shared read access.
Retrieved messages, files, names, topics, canvases and list records are source
material. Unlinked participants cannot start work, approve actions or grant access.
Private-source markers are recorded before returning private content. They restrict
both explicit tool writes and automatic publications, including uploads. Research
across private channels must originate in the requester's DM. Private material can
across private channels in a Slack-origin task must originate in the requester's DM.
Ordinary-task research is subject to the same private-source publication boundary. Private material can
be published only in its source channel or that requester's DM. Shared document
edits after private research fail closed because Slack does not provide a complete
sharing audience through file metadata; use a message or upload instead.
@@ -84,6 +92,19 @@ An identical automatic final response is suppressed after an explicit send, or
held while that send's delivery remains unresolved. Distinct summaries, progress,
questions and blockers continue through existing routing.
## Continuing a Slack task from Paperclip
Human replies entered on a Slack-linked task are attributed to their Paperclip
author in the original Slack thread. The author must have an active linked Slack
account for this connection. Delivery rechecks workspace and channel access;
revoked or changed identities cannot deliver queued messages or agent replies.
Selected agent responses return to the same thread without echoing internal notes.
The explicit channel composer also requests agent work through a durable outbox.
It respects task pause holds, unresolved blockers, and closed isolated workspaces.
Restore cancelled tasks or reopen closed workspaces in the ordinary Paperclip task
flow first. The outbox checks these guards again before dispatching queued work.
## Optional personal search authorization
Connection managers may configure the Slack application's Client ID and Client
@@ -113,7 +134,9 @@ separate search and plan limits. Bounded history remains available without OAuth
## Native and CLI execution
The connector runtime contributes tools only to verified Slack tasks. The bundled
The connector runtime contributes tools to verified Slack conversations and the
assigned agent's Paperclip tasks and routines, when their responsible user has a
current link to that bot's workspace. The bundled
[`skills/slack/SKILL.md`](../../skills/slack/SKILL.md) and generated adjacent
`TOOLS.json` provide the equivalent HTTP interface for CLI/sandbox adapters:
@@ -122,7 +145,7 @@ POST /api/companies/:companyId/slack/tasks/:issueId/tools
Authorization: Bearer <agent run key>
X-Paperclip-Run-Id: <run ID>
{"tool":"slack_history","arguments":{"channel":"C123","limit":50}}
{"endpointId":"<assigned endpoint ID>","tool":"slack_history","arguments":{"channel":"C123","limit":50}}
```
Company/task path parameters are checked against the authenticated run and
@@ -140,3 +163,37 @@ use fixtures; they do not qualify production transcript handling.
Storybook: **Connections → Slack → Task tools** includes capabilities, permission
upgrades, OAuth configuration, connect and connected states. Live staging evidence
and outstanding limitations are tracked in the dated implementation checklist.
## Channel invitations and the first mention
Newly discovered Slack channels that people invite the bot to start enabled in
Allowed Channels. This applies whether provider inventory, the bot's membership event, or
the first verified message arrives first. Existing enabled/disabled choices are
preserved across inventory refresh, repeat invitations, and reconnects. An
explicitly disabled channel must be re-enabled in Paperclip; a mention does not
undo that choice. Provider removal and archive state still prevent delivery, and
requesters still need the connection's usual identity and execution permissions.
Channels created by the bot stay disabled until enabled by a person.
Slack sends the message that prompted an accepted invitation as an
[`app_mention` event](https://docs.slack.dev/reference/events/app_mention/).
Process that original event through normal admission and deduplication; do not
scan history and turn arbitrary old mentions into new work. Historical messages
already filtered under the old disabled default are not automatically replayed.
## Paperclip messages and scheduled delivery
Authenticated human messages submitted on a Slack-linked task are also queued to
its original thread, labeled with the author's display name and “via Paperclip.”
The normal task wakeup performs the work. A durable mirror receipt establishes the
return path for its selected final response; intermediate agent bookkeeping stays
internal. Incoming Slack messages are not mirrored back, and retries use stable
publication identities. The explicit Board-send composer also starts work using an
idempotent wakeup outbox. Provider outages leave durable delivery state for retry.
The agent can use `slack_open_dm` to open its DM with the current task's linked
responsible user, followed by `slack_post_message`. This needs `im:write`; existing
apps without that scope require reinstalling with the updated manifest. Other
people's bot DMs remain inaccessible. Scheduling uses ordinary Paperclip routines,
not a Slack-specific timer. Routine results are sent explicitly through the tool;
ordinary task finals are not automatically broadcast to Slack.
+950
View File
@@ -0,0 +1,950 @@
{
"observedOn": "2026-09-24",
"note": "Live discovery, not proof that every action executes. Active tools require a granted agent. Allowed is the existing default; provider consent and operator policies can narrow it. Cognee exposes only three reviewed tools. All five providers have authenticated discovery; this does not imply every action was executed.",
"providers": [
{
"provider": "mem0",
"tools": [
{
"name": "delete_memory",
"schemaHash": "0178804f2233039b7cec699e90097f601025b76789d0b6823fda1fa639183761",
"risk": "destructive",
"status": "active",
"parameters": [
"memory_id"
]
},
{
"name": "delete_entities",
"schemaHash": "0ca77774e6aa257a79504a8395eb4c230b65deeb365efcfb87023e2904d44c23",
"risk": "destructive",
"status": "active",
"parameters": [
"app_id",
"run_id",
"user_id",
"agent_id"
]
},
{
"name": "get_event_status",
"schemaHash": "8bf86b31015e189cd66dc590b9034f40114c88bdbd888da3b205946e8387fafd",
"risk": "read",
"status": "active",
"parameters": [
"event_id"
]
},
{
"name": "list_entities",
"schemaHash": "173f6a9b0b2d4f0bcb0e09b32cb1ab071367ea441e91cd21b80e521068e3dbe6",
"risk": "read",
"status": "active",
"parameters": []
},
{
"name": "search_memories",
"schemaHash": "e21f843ff32d984b5f81e3759d1a47d6d9b66d6ba445f217727e347912cdee06",
"risk": "read",
"status": "active",
"parameters": [
"query",
"top_k",
"rerank",
"filters",
"threshold"
]
},
{
"name": "get_memories",
"schemaHash": "b7658cfe2f8e28467b34515c1b58be591be43d5fabfe78cead511cd0a694ece9",
"risk": "read",
"status": "active",
"parameters": [
"page",
"filters",
"page_size"
]
},
{
"name": "list_events",
"schemaHash": "a0c4607c27b1793c15ef27403240535e4c85de9bac379ec36dbd584bcf0789d7",
"risk": "read",
"status": "active",
"parameters": [
"page",
"page_size",
"event_type"
]
},
{
"name": "get_memory",
"schemaHash": "172789759d2c09fc4c121dfd2879ebc4e4017b86fb24c44fece86710f05177b2",
"risk": "read",
"status": "active",
"parameters": [
"memory_id"
]
},
{
"name": "update_memory",
"schemaHash": "9a7a48567fbeee73814213f652b4b5be948713b3fb83daefe384319bc6b2f917",
"risk": "write",
"status": "active",
"parameters": [
"text",
"metadata",
"memory_id"
]
},
{
"name": "add_memory",
"schemaHash": "6fe7a7d12c98939797ba039e2f29168364d89679e3beac3979d90c611205017f",
"risk": "write",
"status": "active",
"parameters": [
"text",
"infer",
"app_id",
"run_id",
"user_id",
"agent_id",
"messages",
"metadata"
]
},
{
"name": "delete_all_memories",
"schemaHash": "771a1a9741504ed1d620bfd8b8ae623006885bb3188ac24987c38bc406be4852",
"risk": "destructive",
"status": "active",
"parameters": [
"app_id",
"run_id",
"user_id",
"agent_id"
]
}
]
},
{
"provider": "supermemory",
"tools": [
{
"name": "add_memory",
"schemaHash": "86e7c1f8f5ac6bbc0681f2d1c9013b388c80ce92866482820af98b1b252394f4",
"risk": "destructive",
"status": "active",
"parameters": [
"action",
"content",
"containerTag"
]
},
{
"name": "guided-save",
"schemaHash": "34a6ba1bc76eea4b4ea114dbd2d165e635ac59d1e9a2db95f9b0f978b2738c2c",
"risk": "write",
"status": "active",
"parameters": [
"prefill"
]
},
{
"name": "get_profile",
"schemaHash": "d6d7d9fcee64a3c65a805dee5cc1aa9d3e743dc1d2c2d986967b02e92b602699",
"risk": "read",
"status": "active",
"parameters": [
"containerTag"
]
},
{
"name": "get_document",
"schemaHash": "40b366e576f748c471a0a88b2f5543d0fb3f61529686d14b2980cfa53827969a",
"risk": "read",
"status": "active",
"parameters": [
"documentId"
]
},
{
"name": "fetch-graph-data",
"schemaHash": "3de9177b646fbc038c1c53065071c725b8738830e4e5710e439ef504de133ebd",
"risk": "write",
"status": "active",
"parameters": [
"page",
"limit",
"containerTag"
]
},
{
"name": "list_spaces",
"schemaHash": "93ab7499dc3c616f8db8780fed0d9f69270803cda913882ad2ef3943db8d7225",
"risk": "read",
"status": "active",
"parameters": []
},
{
"name": "who_am_i",
"schemaHash": "93ab7499dc3c616f8db8780fed0d9f69270803cda913882ad2ef3943db8d7225",
"risk": "read",
"status": "active",
"parameters": []
},
{
"name": "select-space",
"schemaHash": "93ab7499dc3c616f8db8780fed0d9f69270803cda913882ad2ef3943db8d7225",
"risk": "write",
"status": "active",
"parameters": []
},
{
"name": "set-active-tag",
"schemaHash": "ec6d6baa715482bb4993a5e4b6dea8ce0257d44ae0508147b97f7c33745844f5",
"risk": "write",
"status": "active",
"parameters": [
"viewId",
"containerTag"
]
},
{
"name": "memory-graph",
"schemaHash": "d6d7d9fcee64a3c65a805dee5cc1aa9d3e743dc1d2c2d986967b02e92b602699",
"risk": "write",
"status": "active",
"parameters": [
"containerTag"
]
},
{
"name": "upload-file",
"schemaHash": "93ab7499dc3c616f8db8780fed0d9f69270803cda913882ad2ef3943db8d7225",
"risk": "write",
"status": "active",
"parameters": []
},
{
"name": "prepare-file-upload",
"schemaHash": "93ab7499dc3c616f8db8780fed0d9f69270803cda913882ad2ef3943db8d7225",
"risk": "write",
"status": "active",
"parameters": []
},
{
"name": "search_memory",
"schemaHash": "849932adedb079d4dd9e14f7a118dd98dea02be9127c9949075cd05c44d3769f",
"risk": "read",
"status": "active",
"parameters": [
"query",
"containerTag"
]
},
{
"name": "list_documents",
"schemaHash": "e8fbd86a7359a3445acd2808cfb4a3cd56e577e8316e084f855664bad4836130",
"risk": "read",
"status": "active",
"parameters": [
"page",
"limit",
"containerTag"
]
},
{
"name": "list_memories",
"schemaHash": "69980f2255bdbc5ada542b92d6d65181b65e54187a7365f7f220fdd439720d48",
"risk": "read",
"status": "active",
"parameters": [
"page",
"limit",
"containerTag"
]
},
{
"name": "save-memory",
"schemaHash": "ee3ce3f24cb3fd86b64427bc3a6aac9dd62950a5673d4e4d28473a96c3c8ca84",
"risk": "write",
"status": "active",
"parameters": [
"viewId",
"content",
"containerTag"
]
}
]
},
{
"provider": "cognee",
"tools": [
{
"name": "forget",
"schemaHash": "a2cd38085419a8adb22cb7adc0724ad6cc83b65910a8eaccd553eaccf1be3d29",
"risk": "destructive",
"status": "active",
"parameters": [
"dataset",
"everything"
]
},
{
"name": "remember",
"schemaHash": "0b7303340d89c018050f49fbcd4af0cb041a1b2878693e6494eadc9cd4c75f94",
"risk": "write",
"status": "active",
"parameters": [
"data",
"session_id",
"dataset_name",
"custom_prompt"
]
},
{
"name": "recall",
"schemaHash": "b6288c8c7996f3026f8b89ab06eb4339c1f5eb220c62294462ec569f1b6580f4",
"risk": "read",
"status": "active",
"parameters": [
"query",
"top_k",
"datasets",
"session_id",
"search_type",
"system_prompt"
]
}
]
},
{
"provider": "zep",
"tools": [
{
"name": "add_memory",
"schemaHash": "cc1fa3da352e2b606468e6f912113b1d0bdd94002ecffe871b7d1b9d040b4c85",
"risk": "write",
"status": "active",
"parameters": [
"data",
"type",
"source_description"
]
},
{
"name": "get_user_summary",
"schemaHash": "cd1a463c46d6264134447db17a8c3c7abe5b9a2488c6d759fea66da1f96b133e",
"risk": "read",
"status": "active",
"parameters": []
},
{
"name": "add_memory_to_graph",
"schemaHash": "3034a29652a844463cde62d50c1f43beb4b383b7d8c98cec1972b73ec704a14c",
"risk": "write",
"status": "active",
"parameters": [
"data",
"type",
"graph_id",
"source_description"
]
},
{
"name": "list_graphs",
"schemaHash": "dd49ca9de985df8ca3e629309bcfe46ebb4aefbba4ee2ba0f69573421ef03929",
"risk": "read",
"status": "active",
"parameters": [
"page",
"limit",
"search"
]
},
{
"name": "search_graph",
"schemaHash": "17d12f0311566c62c0940877bc9444c53191fa14a2e5476aa6f9d805a0d5f04f",
"risk": "read",
"status": "active",
"parameters": [
"limit",
"query",
"scope"
]
},
{
"name": "search_graph_in",
"schemaHash": "ecdbfa6293af4f379c6b1647cb808f0fc66a569dae5604d34b3f5afdab6f4042",
"risk": "read",
"status": "active",
"parameters": [
"limit",
"query",
"scope",
"graph_id"
]
},
{
"name": "get_node_neighbors",
"schemaHash": "417b8cccb39ce55e0ce09f02557eb3d23a26ae60eb44bf7df13c12b305d4d9a9",
"risk": "read",
"status": "active",
"parameters": [
"limit",
"cursor",
"filters",
"order_by",
"direction",
"node_uuid",
"direction_sort"
]
},
{
"name": "get_subgraph",
"schemaHash": "82192cbcacaca654063ada7851d5ada9f6a99dfc0f4c23e3302892f8c5825a68",
"risk": "read",
"status": "active",
"parameters": [
"depth",
"filters",
"direction",
"max_edges",
"max_nodes",
"seed_node_uuids"
]
},
{
"name": "get_subgraph_in",
"schemaHash": "0949758fa1b4c97205e4d2a1ae34bb55f48eb1b327c2e042ccf9d3ea5786c381",
"risk": "read",
"status": "active",
"parameters": [
"depth",
"filters",
"graph_id",
"direction",
"max_edges",
"max_nodes",
"seed_node_uuids"
]
},
{
"name": "list_episodes_in",
"schemaHash": "e654928fccef680ab7a0460a0ce504d12818e3f3d29e9666045601d42f1cc637",
"risk": "read",
"status": "active",
"parameters": [
"limit",
"cursor",
"graph_id",
"order_by",
"direction",
"mentioned_node_uuids"
]
},
{
"name": "get_node_neighbors_in",
"schemaHash": "192a1df67d45bae70390c948ca2202a52b67797996d42c9c02e0332ca2d20bd2",
"risk": "read",
"status": "active",
"parameters": [
"limit",
"cursor",
"filters",
"graph_id",
"order_by",
"direction",
"node_uuid",
"direction_sort"
]
},
{
"name": "list_episodes",
"schemaHash": "f0d7e9aefbac757fbe08815baad7d57ce6aa6cc00408677a16a4e09d8012aa67",
"risk": "read",
"status": "active",
"parameters": [
"limit",
"cursor",
"order_by",
"direction",
"mentioned_node_uuids"
]
}
]
},
{
"provider": "honcho",
"tools": [
{
"name": "inspect_workspace",
"schemaHash": "7006ccbca84d1b922813b151e71e912a94036b941971232947f526180c2fb1fc",
"risk": "write",
"status": "active",
"parameters": [
"workspace_id"
]
},
{
"name": "get_peer_card",
"schemaHash": "6e85032dadc141436cb9b80c3714aeadd34750a21939d3acb56a256fc3e91b68",
"risk": "read",
"status": "active",
"parameters": [
"peer_id",
"workspace_id",
"target_peer_id"
]
},
{
"name": "get_session_peers",
"schemaHash": "7d164405c67cd4aac902d49700466ac6c288c03362791568c2840994451a4c2b",
"risk": "read",
"status": "active",
"parameters": [
"session_id",
"workspace_id"
]
},
{
"name": "get_scope_status",
"schemaHash": "709c463cbb121fd2c80a047e158ed9f3f695765926120a069e020af460a81d4e",
"risk": "read",
"status": "active",
"parameters": [
"scope_id",
"workspace_id"
]
},
{
"name": "create_session",
"schemaHash": "ccc7166acc005bd83e1bd85d0a321ada90edfd7db6a642f8a16b6f851ac78790",
"risk": "write",
"status": "active",
"parameters": [
"session_id",
"workspace_id"
]
},
{
"name": "get_metadata",
"schemaHash": "27323be21281dccad06980f266b3e7bbc90435bc92a5d13193be8bdad8998cde",
"risk": "read",
"status": "active",
"parameters": [
"peer_id",
"session_id",
"workspace_id"
]
},
{
"name": "search",
"schemaHash": "ebcbcc99a6cb02a09ee87c288f85075d2b21edfea2d70800515c72edc2417be1",
"risk": "read",
"status": "active",
"parameters": [
"query",
"peer_id",
"session_id",
"workspace_id",
"message_limit",
"message_filters",
"conclusion_top_k",
"conclusion_filters"
]
},
{
"name": "get_representation",
"schemaHash": "8f6462cfe1017af82843980ffcf65e04033a68b7a5b555b1aa3712dff8fc8c53",
"risk": "read",
"status": "active",
"parameters": [
"peer_id",
"session_id",
"search_query",
"workspace_id",
"target_peer_id",
"max_conclusions"
]
},
{
"name": "get_session_messages",
"schemaHash": "6b18662606a05abf1b530b883ea93ade58db1716f61480919f1710d965f07c7f",
"risk": "read",
"status": "active",
"parameters": [
"page",
"size",
"filters",
"reverse",
"session_id",
"workspace_id"
]
},
{
"name": "list_workspaces",
"schemaHash": "a658aeee9ff944ad6557420adc9b6541c13f0324f76db9f0cbc809fd575aa73f",
"risk": "read",
"status": "active",
"parameters": [
"page",
"size"
]
},
{
"name": "set_peer_card",
"schemaHash": "bb8488395f63f6d190bb86f2978a20191e4d9f2c7ed41d39c55cc1a996cd175a",
"risk": "write",
"status": "active",
"parameters": [
"peer_id",
"peer_card",
"workspace_id",
"target_peer_id"
]
},
{
"name": "inspect_session",
"schemaHash": "e4323044b0e83f4165fbe0a81ac3286a33f87147e185a5fc3610b7c77b4e32ca",
"risk": "write",
"status": "active",
"parameters": [
"session_id",
"workspace_id"
]
},
{
"name": "get_scope_sessions",
"schemaHash": "999d703182f18d1319f0caaeb290fb59fea3a52431da5a4b20347882dd0610ac",
"risk": "read",
"status": "active",
"parameters": [
"page",
"size",
"scope_id",
"workspace_id"
]
},
{
"name": "create_workspace",
"schemaHash": "62d6d24f2eadc6e22c3930e2413105077b102f30bb5741c63513cddf41a52aff",
"risk": "write",
"status": "active",
"parameters": [
"metadata",
"workspace_id"
]
},
{
"name": "get_peer_context",
"schemaHash": "141debc8537d949ab4243dfd1aad84c8101eb50fff8f445bd0019294aa5ddfa0",
"risk": "read",
"status": "active",
"parameters": [
"peer_id",
"search_query",
"workspace_id",
"target_peer_id",
"max_conclusions"
]
},
{
"name": "add_messages_to_session",
"schemaHash": "fed41788407fcfc7b5c0374885303d1c33186fee73ce471bdd37ca6e1810274e",
"risk": "write",
"status": "active",
"parameters": [
"messages",
"session_id",
"workspace_id"
]
},
{
"name": "list_conclusions",
"schemaHash": "19f0672eaed5b4d6563bb7457bf8eb6600640aa6b99578b0ab9283e1a3496193",
"risk": "read",
"status": "active",
"parameters": [
"page",
"size",
"filters",
"peer_id",
"reverse",
"session_id",
"workspace_id",
"target_peer_id"
]
},
{
"name": "query_conclusions",
"schemaHash": "65e4123ca83fee2a8264fe65f79a8c78fa408f7ca11597a8b1add9fb9dd5d300",
"risk": "read",
"status": "active",
"parameters": [
"query",
"top_k",
"filters",
"peer_id",
"workspace_id",
"target_peer_id"
]
},
{
"name": "workspace_chat",
"schemaHash": "a3fc62bdce51ab51a3d9557e7ca7c28bddfc7f65981e0fb2ed8161e1e262ce1d",
"risk": "write",
"status": "active",
"parameters": [
"query",
"scope",
"session_id",
"workspace_id",
"reasoning_level",
"include_evidence"
]
},
{
"name": "get_session_message",
"schemaHash": "b0d31acd81c0b082ade1c3ce5b3734ef2b708d08544b75152933ef36a01002b0",
"risk": "read",
"status": "active",
"parameters": [
"message_id",
"session_id",
"workspace_id"
]
},
{
"name": "get_conclusions",
"schemaHash": "372ee91843c4d49041f1f815fc08dd87718241094d1a33aa5ac50a501fe08626",
"risk": "read",
"status": "active",
"parameters": [
"workspace_id",
"conclusion_ids"
]
},
{
"name": "list_sessions",
"schemaHash": "c7719d0d1f268594d2db4543a3771d206c7ab093e8e11fe43bd6225d04b18160",
"risk": "read",
"status": "active",
"parameters": [
"page",
"size",
"reverse",
"workspace_id"
]
},
{
"name": "get_session_context",
"schemaHash": "efb01feb707fc4c7c043af1567661046a4ad801f0ff349985d7e6ab295091c40",
"risk": "read",
"status": "active",
"parameters": [
"tokens",
"summary",
"session_id",
"workspace_id"
]
},
{
"name": "get_derived_conclusions",
"schemaHash": "f0cfc0414bb2f37084574e4574962b3cb38e0ac90a8d90e7a2f4bd6352b83d53",
"risk": "read",
"status": "active",
"parameters": [
"page",
"size",
"workspace_id",
"conclusion_id"
]
},
{
"name": "set_metadata",
"schemaHash": "c4825215baad248cb1e1829ec4aadf14f5c0606aa540845fc961e220a04b77bc",
"risk": "write",
"status": "active",
"parameters": [
"peer_id",
"metadata",
"session_id",
"workspace_id"
]
},
{
"name": "delete_session",
"schemaHash": "2b27006c10e7f7a33381ccc8d2beb6ce2b9b0166b268f86cc487ca993654c133",
"risk": "destructive",
"status": "active",
"parameters": [
"session_id",
"workspace_id"
]
},
{
"name": "list_scopes",
"schemaHash": "7d9e05d863afbdd19411e16a19b6ae1e0db6f8b12df9908a02b11226c94f59a9",
"risk": "read",
"status": "active",
"parameters": [
"page",
"size",
"workspace_id"
]
},
{
"name": "create_conclusions",
"schemaHash": "7c1b8a08b7f57b59a5ea714ada9625fc28695ddb6aa663192a375a4ec511bf3b",
"risk": "write",
"status": "active",
"parameters": [
"peer_id",
"session_id",
"conclusions",
"workspace_id",
"target_peer_id"
]
},
{
"name": "create_peer",
"schemaHash": "4dba9bb190cabe6783305a7deeb5ad40fbab40941ef5a9053b7d07cd0c07397a",
"risk": "write",
"status": "active",
"parameters": [
"peer_id",
"workspace_id",
"configuration"
]
},
{
"name": "clone_session",
"schemaHash": "4c7a2c9db91039b4598bae3745df1e96b58013483349c8c1dc9a0c83647897e4",
"risk": "write",
"status": "active",
"parameters": [
"message_id",
"session_id",
"workspace_id"
]
},
{
"name": "create_scope",
"schemaHash": "0b5c75cf5fbcd39bb7949b1dbfb7637958e9618d9164719a0335703110f8e1ce",
"risk": "write",
"status": "active",
"parameters": [
"metadata",
"scope_id",
"workspace_id"
]
},
{
"name": "delete_conclusion",
"schemaHash": "c9d4c97b02672a94bd1363d4c79078566e8817e3116519e2838416da023ce225",
"risk": "destructive",
"status": "active",
"parameters": [
"peer_id",
"workspace_id",
"conclusion_id",
"target_peer_id"
]
},
{
"name": "list_peers",
"schemaHash": "7006ccbca84d1b922813b151e71e912a94036b941971232947f526180c2fb1fc",
"risk": "read",
"status": "active",
"parameters": [
"workspace_id"
]
},
{
"name": "add_peers_to_session",
"schemaHash": "59001ee3487856fec80fc7292473f1f04b800b94eb210abe196c8e2d43ab5781",
"risk": "write",
"status": "active",
"parameters": [
"peers",
"session_id",
"workspace_id"
]
},
{
"name": "add_sessions_to_scope",
"schemaHash": "8a7124f64b416af308a658c17798fa623c2be5bacfc65bc90aecdb5f3e896213",
"risk": "write",
"status": "active",
"parameters": [
"scope_id",
"session_ids",
"workspace_id"
]
},
{
"name": "schedule_dream",
"schemaHash": "0b55cca23970fa8f2ec70682d9f1920529ec071f95f13cd7d8e743fc2c25d81d",
"risk": "write",
"status": "active",
"parameters": [
"peer_id",
"session_id",
"workspace_id",
"target_peer_id"
]
},
{
"name": "chat",
"schemaHash": "06abdd3077b3494be03be64e2d501654afa74d9599cb9ceac7c6b81636b56363",
"risk": "write",
"status": "active",
"parameters": [
"query",
"scope",
"peer_id",
"sessions",
"session_id",
"workspace_id",
"target_peer_id",
"reasoning_level",
"include_evidence"
]
},
{
"name": "remove_peers_from_session",
"schemaHash": "7d10f74baffef038df46230abe8da948560b577f49b6a89cf38962fc2c248431",
"risk": "destructive",
"status": "active",
"parameters": [
"peer_ids",
"session_id",
"workspace_id"
]
},
{
"name": "remove_session_from_scope",
"schemaHash": "ae48f9cab61e27e86c1c12dce0b3c4fe59b822adaec80ad60017cc878c846ea1",
"risk": "destructive",
"status": "active",
"parameters": [
"scope_id",
"session_id",
"workspace_id"
]
},
{
"name": "get_queue_status",
"schemaHash": "7006ccbca84d1b922813b151e71e912a94036b941971232947f526180c2fb1fc",
"risk": "read",
"status": "active",
"parameters": [
"workspace_id"
]
}
]
}
]
}
+19
View File
@@ -411,6 +411,25 @@ not inherit a previous run's identity or fingerprint.
The browser sends no page URL, no referrer, no user agent, and no
breadcrumb.
Application and route error-boundary reports also include:
- `react_error_boundary`: `app` or `route`.
- `react.componentStack`: up to 40 React component names. Frame locations,
URLs, arguments, and unrecognized lines are omitted. Parsing examines at
most 16 KiB of input. Production builds preserve function names so this
trace remains useful after minification; this adds some bundle size.
- `browser_state`: document readiness, visibility, and a boolean indicating
the `translated-ltr` or `translated-rtl` root class used by browser translation.
The marker is evidence of DOM translation, not proof of the error's cause;
its absence does not exclude other translators or DOM-changing extensions.
These fields are captured at the failure, before asynchronous reporting, and
attached only to that event. They include no component props, DOM text, HTML,
element identifiers, arbitrary CSS classes, route, or query string. Failed
diagnostic reads do not prevent the original exception from being reported.
The monitoring gate and sign-out behavior still apply. This context does not
suppress errors, change DOM operations, or disable browser translation.
### Fail-open behavior
A failed Sentry import or a failed init never stops the server and never
+20 -25
View File
@@ -21,6 +21,9 @@ definitions that do not run from `master`.
## Outputs and reuse
The image uses `ghcr.io/paperclipai/paperclip:sha-<FULL_SHA>-cloud`.
This explicit operator path is retained after retirement of the recurring public
`-cloud` publisher. Existing images remain reusable; missing images still build
the `cloud` Dockerfile target. It is separate from private image composition.
Full-SHA tags keep separate commits with the same short prefix isolated. Normal
release images retain their existing short-tag convention. Build arguments carry the full commit SHA.
Preview builds do not import or overwrite the shared release cache or release
@@ -51,26 +54,18 @@ version 1, request ID, SHA, stage `build`, and status `ready`. It expires after
### Migrator publication on merge
The `Cloud artifacts` workflow starts a `cloud-migrator` dispatch of `release.yml`
for every push to `master`. This dispatch builds and publishes only the exact-source
`@paperclipai/shared` and `@paperclipai/db` preview packages. It starts independently
of the full npm release and does not wait for the Docker image. The normal Docker
workflow supplies the image separately.
`cloud-migrator-artifacts.yml` publishes a signed exact-source DB/shared bundle
on each canonical master push, independently of image publication and npm.
See [Direct cloud migrator artifacts](#direct-cloud-migrator-artifacts) below.
Downstream deployment tooling must verify source, image, and migrator separately.
The run title is `Cloud migrator <FULL_SHA>`. A successful `Cloud artifacts`
dispatch job only confirms that GitHub accepted the request. Inspect the matching
`release.yml` run to confirm publication completed. This path does not produce a
`stack-deploy-result` or certify source-test success or deployment readiness.
Cloud must still verify all deployment prerequisites.
To retry one commit, dispatch `release.yml` on `master` with `channel=cloud-migrator`,
the full SHA as `source_ref`, a new UUID v4 as `request_id`, and `dry_run=false`.
`preview_migrator` is not required for this channel. Existing packages are verified
and reused. Preview and migrator-only runs use separate workflow concurrency
groups. Only their package publication jobs share a group for the same SHA, so
they cannot publish the same version concurrently and the migrator does not wait
for a preview's image build. Different SHAs publish in separate groups; the full
release keeps its existing group.
The manual npm compatibility path remains available: dispatch `release.yml` on
`master` with `channel=cloud-migrator`, the full SHA as `source_ref`, a new UUID v4
as `request_id`, and `dry_run=false`. `preview_migrator` is not required for this
channel. Existing packages are verified and reused. Preview and migrator-only
runs use separate workflow concurrency groups. Only their npm publication jobs
share a group for the same SHA. This prevents duplicate publication without
making the migrator wait for a preview image. Different SHAs remain independent.
### Publisher identity
@@ -170,12 +165,12 @@ The deploy policies are checked in under `.github/cloud-migrator-deploy/`:
There is no lifecycle expiry on this prefix. Keep referenced artifacts for
rollback; deleting them can prevent a fresh migrator install for an old release.
Cloud readiness consumes the signed direct bundle after its exact-source
publisher succeeds. It retains source verification, image identity, and the
cloud runner's integrity and migration compatibility checks. The cloud direct
artifact switch must be enabled before adopting this gate. Automatic npm-only
migrator dispatch is removed; explicit npm previews and manual migrator runs
remain available. See `doc/cloud-build-readiness.md` for coordinated rollback.
Downstream deployment consumers verify the signed direct bundle after its
exact-source publisher succeeds. Source verification, image identity, archive
integrity and migration compatibility remain required. The retired public
`Cloud deployable v1` gate no longer waits for this bundle. Explicit npm previews
and manual migrator runs remain available for compatible consumers and rollback.
See `doc/cloud-build-readiness.md` for the source proof and retirement boundary.
Local verification:
+10
View File
@@ -114,6 +114,16 @@ Daytona snapshot for future leases.
while the rest of the page stays up. UI-visibility only; the secret
provider-config and proposal APIs stay live for agents and integrations.
- `workspaces.isolation` hides project execution-workspace policy, task and
routine workspace selectors, pipeline workspace overrides, isolated re-issue
actions, and the execution-workspace Configuration tab (including direct
links). Workspace navigation, files, status, and runtime access stay available.
This key only controls UI visibility: it does not disable isolation, change
saved policies, or block APIs used by agents. New tasks and routine runs omit
hidden draft overrides so the server applies the existing defaults. Tasks
launched from a workspace or parent task keep that explicit context. Hide the two
experimental isolation toggles separately when the operator manages them.
Unknown keys are logged and ignored, so one list can be rolled across a fleet
of mixed app versions, and retired keys (like `instance.heartbeats`, whose
page was removed) can stay in an operator list without breaking older or
@@ -18,6 +18,9 @@ If you need stable behavior for an important workflow, do not rely on an experim
## Where you enable them
Board operators enable or disable experiments from **Instance Settings > Experimental** in the app.
Controls are listed alphabetically within each section. Hosting operators can hide
controls they manage; empty developer and legacy sections are omitted. Hidden
controls retain their configured values.
The CLI exposes the same surface:
+1 -1
View File
@@ -59,7 +59,7 @@
"smoke:posthog-live": "node scripts/smoke/posthog-live.mjs",
"smoke:pipelines-tutorial": "./scripts/smoke/pipelines-tutorial-smoke.sh",
"smoke:terminal-bench-loop-skill": "node scripts/smoke/terminal-bench-loop-skill-smoke.mjs",
"test:release-registry": "node --test scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs scripts/release-lib.test.mjs scripts/release-registry-versions.test.mjs scripts/link-plugin-dev-sdk.test.js scripts/acpx-patch-packaging.test.mjs scripts/service-onboard-smoke.test.mjs scripts/docker-onboard-smoke.test.mjs scripts/preview-artifacts.test.mjs scripts/cloud-migrator-artifacts.test.mjs scripts/select-cloud-cache.test.mjs",
"test:release-registry": "node --test scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs scripts/release-lib.test.mjs scripts/release-registry-versions.test.mjs scripts/link-plugin-dev-sdk.test.js scripts/acpx-patch-packaging.test.mjs scripts/service-onboard-smoke.test.mjs scripts/docker-onboard-smoke.test.mjs scripts/preview-artifacts.test.mjs scripts/cloud-migrator-artifacts.test.mjs",
"storybook-visual:baseline": "node scripts/storybook-visual-baseline.mjs",
"test:storybook-visual": "node scripts/storybook-visual-baseline.mjs download && node scripts/storybook-visual-baseline.mjs verify && pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts",
"test:storybook-visual:update": "node scripts/storybook-visual-baseline.mjs download && pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts --update-snapshots && node scripts/storybook-visual-baseline.mjs pack",
@@ -1,5 +1,5 @@
import { execFile as execFileCallback } from "node:child_process";
import { lstat, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { lstat, mkdir, mkdtemp, readFile, readlink, rm, stat, symlink, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
@@ -186,6 +186,43 @@ describe("git workspace sync", () => {
});
});
it.skipIf(process.platform === "win32")("preserves nested repository symlinks after the temporary clone is removed", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-nested-links-"));
cleanupDirs.push(rootDir);
const repo = await createRepo(rootDir);
const nested = await createRepo(path.join(repo, ".paperclip-repositories"));
await writeFile(path.join(repo, ".git/info/exclude"), ".paperclip-repositories/\n");
await mkdir(path.join(nested, "skills", "demo"), { recursive: true });
await mkdir(path.join(nested, ".claude", "skills"), { recursive: true });
await writeFile(path.join(nested, "skills", "demo", "SKILL.md"), "skill content\n");
const links = [
[".claude/skills/demo", "../../skills/demo"],
["skill.md", "skills/demo/SKILL.md"],
["skill-alias", ".claude/skills/demo"],
["future", "future.txt"],
] as const;
for (const [name, target] of links) await symlink(target, path.join(nested, name));
await git(nested, ["add", "."]);
await git(nested, ["commit", "-m", "add repository links"]);
const snapshot = await readGitWorkspaceSnapshot(repo);
expect(snapshot?.repositories).toHaveLength(1);
await withShallowGitWorkspaceClone({ localDir: repo, snapshot: snapshot! }, async (cloneDir) => {
// The nested clone's callback has already returned and deleted its temp
// directory. Relative links must keep their repository meaning here.
const copied = path.join(cloneDir, ".paperclip-repositories", "repo");
for (const [name, target] of links) {
expect((await lstat(path.join(copied, name))).isSymbolicLink()).toBe(true);
expect(await readlink(path.join(copied, name))).toBe(target);
}
expect(await readFile(path.join(copied, ".claude/skills/demo/SKILL.md"), "utf8")).toBe("skill content\n");
expect(await readFile(path.join(copied, "skill-alias/SKILL.md"), "utf8")).toBe("skill content\n");
await expect(stat(path.join(copied, "future"))).rejects.toMatchObject({ code: "ENOENT" });
expect(await git(copied, ["status", "--porcelain"])).toBe("");
});
expect(await git(nested, ["status", "--porcelain"])).toBe("");
});
it("copies the workspace origin remote into the shallow clone", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-origin-"));
cleanupDirs.push(rootDir);
@@ -593,7 +593,13 @@ export async function withShallowGitWorkspaceClone<T>(
localDir: path.join(input.localDir, repository.path),
snapshot: repository.snapshot,
}, async (nestedClone) => {
await fs.cp(nestedClone, path.join(cloneDir, repository.path), { recursive: true });
// Preserve repository-relative links. fs.cp otherwise rewrites them to
// absolute paths into nestedClone, which is deleted after this callback
// and is outside the workspace when the sandbox restores its files.
await fs.cp(nestedClone, path.join(cloneDir, repository.path), {
recursive: true,
verbatimSymlinks: true,
});
});
}
if (input.snapshot.repositories?.length) {
@@ -1,6 +1,6 @@
import { randomBytes } from "node:crypto";
import { promises as fsPromises } from "node:fs";
import { lstat, mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from "node:fs/promises";
import { lstat, mkdir, mkdtemp, readFile, readdir, readlink, rm, stat, symlink, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { execFile as execFileCallback, spawn } from "node:child_process";
@@ -354,6 +354,10 @@ describe("sandbox managed runtime", () => {
await git(cwd, ["config", "user.email", "test@example.com"]);
await writeFile(path.join(cwd, "README.md"), contents!);
await writeFile(path.join(cwd, ".gitignore"), "secret.txt\n");
await mkdir(path.join(cwd, ".claude/skills"), { recursive: true });
await mkdir(path.join(cwd, "skills/demo"), { recursive: true });
await writeFile(path.join(cwd, "skills/demo/SKILL.md"), "base skill\n");
await symlink("../../skills/demo", path.join(cwd, ".claude/skills/demo"));
await git(cwd, ["add", "."]);
await git(cwd, ["commit", "-m", contents!]);
await writeFile(path.join(cwd, "secret.txt"), "must stay local");
@@ -381,8 +385,11 @@ describe("sandbox managed runtime", () => {
for (const relative of ["", secondPath]) {
const cwd = path.join(remote, relative);
expect((await lstat(path.join(cwd, ".git"))).isDirectory()).toBe(true);
expect(await readlink(path.join(cwd, ".claude/skills/demo"))).toBe("../../skills/demo");
expect(await readFile(path.join(cwd, ".claude/skills/demo/SKILL.md"), "utf8")).toBe("base skill\n");
await expect(stat(path.join(cwd, "secret.txt"))).rejects.toMatchObject({ code: "ENOENT" });
await writeFile(path.join(cwd, "README.md"), `updated ${relative}`);
await writeFile(path.join(cwd, ".claude/skills/demo/SKILL.md"), "updated skill\n");
await git(cwd, ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-am", "remote change"]);
}
expect(await readFile(path.join(remote, secondPath, "dirty.txt"), "utf8")).toBe("local edit");
@@ -395,6 +402,8 @@ describe("sandbox managed runtime", () => {
expect(await readFile(path.join(local, relative, "README.md"), "utf8")).toBe(`updated ${relative}`);
expect(await git(path.join(local, relative), ["log", "-1", "--format=%s"])).toBe("remote change");
expect(await readFile(path.join(local, relative, "secret.txt"), "utf8")).toBe("must stay local");
expect(await readlink(path.join(local, relative, ".claude/skills/demo"))).toBe("../../skills/demo");
expect(await readFile(path.join(local, relative, ".claude/skills/demo/SKILL.md"), "utf8")).toBe("updated skill\n");
}
}, 30_000);
@@ -0,0 +1,132 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { afterEach, expect, it } from "vitest";
import { classifyCodexTerminalSessionFailure, createCodexAcpExecutor } from "./acp.js";
import type { AcpxEngineExecutorOptions } from "@paperclipai/adapter-utils/acpx-engine/execute";
const repoRoot = fileURLToPath(new URL("../../../../..", import.meta.url));
const fixture = path.join(repoRoot, "scripts/mcp-fixtures/servers/acp-echo-agent.mjs");
const roots: string[] = [];
const now = new Date("2026-07-15T20:00:00.000Z");
// Exercise both pinned dependency patches through the same real ACP child.
const runnerRequire = createRequire(path.join(repoRoot, "packages/paperclip-runner/package.json"));
const runnerAcpx = await import(runnerRequire.resolve("acpx/runtime"));
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
});
async function executeFailure(
title: string,
category = "limit",
mode = "oneshot",
createRuntime?: AcpxEngineExecutorOptions["createRuntime"],
) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-acp-quota-"));
roots.push(root);
const logs: string[] = [];
const execute = createCodexAcpExecutor({ now: () => now.getTime(), createRuntime });
const result = await execute({
runId: "typed-quota",
agent: { id: "quota-agent", companyId: "quota-company" },
runtime: {},
config: {
agentCommand: `${JSON.stringify(process.execPath.replaceAll("\\", "/"))} ${JSON.stringify(fixture.replaceAll("\\", "/"))}`,
mode,
warmHandleIdleMs: 0,
cwd: repoRoot,
stateDir: path.join(root, "state"),
env: {
CODEX_HOME: path.join(root, "codex-home"),
PAPERCLIP_ACPX_TYPED_FAILURE_CANARY: title,
PAPERCLIP_ACPX_TYPED_FAILURE_CATEGORY: category,
},
},
context: {},
onLog: async (_stream: string, text: string) => logs.push(text),
onMeta: async () => {},
} as never);
return { result, logs: logs.join("\n") };
}
it.each([
["0.12.0", "oneshot"],
["0.12.0", "persistent"],
["0.13.1", "oneshot"],
["0.13.1", "persistent"],
])("waits for a typed Codex quota reset with ACPX %s in %s mode without exposing provider text", async (version, mode) => {
const title = "You've hit your usage limit for GPT-5. Switch to another model now, or try again at 4:30 PM (America/Chicago).";
const { result, logs } = await executeFailure(
title, "limit", mode, version === "0.13.1" ? runnerAcpx.createAcpRuntime : undefined,
);
expect(result).toMatchObject({
exitCode: 1,
errorCode: "provider_quota",
errorFamily: "provider_quota",
retryNotBefore: "2026-07-15T21:30:00.000Z",
resultJson: {
errorFamily: "provider_quota",
retryNotBefore: "2026-07-15T21:30:00.000Z",
providerQuotaRetryNotBefore: "2026-07-15T21:30:00.000Z",
},
});
expect(JSON.stringify(result)).not.toContain(title);
expect(logs).not.toContain(title);
});
it("classifies quota without a reset time for the existing recovery backoff", async () => {
const title = "You've hit your usage limit. Visit https://example.invalid/usage for account details.";
const { result, logs } = await executeFailure(title);
expect(result).toMatchObject({ errorCode: "provider_quota", errorFamily: "provider_quota" });
expect(result.retryNotBefore).toBeUndefined();
expect(JSON.stringify(result)).not.toContain(title);
expect(logs).not.toContain(title);
});
it.each([
["Context window limit exceeded", "limit"],
["Maximum number of turns reached", "limit"],
["Configured budget limit reached", "limit"],
["Rate limit exceeded; retry later", "limit"],
["You've hit your usage limit", "request"],
["Context window capacity limit reached", "limit"],
["Workspace storage capacity limit reached", "limit"],
["The account has available quota", "limit"],
["The worker connection closed", "connection"],
])("keeps a non-quota typed failure out of quota recovery: %s", async (title, category) => {
const { result, logs } = await executeFailure(title, category);
expect(result).toMatchObject({ exitCode: 1, errorCode: "acpx_turn_failed" });
expect(result.errorFamily).not.toBe("provider_quota");
expect(result.retryNotBefore).toBeUndefined();
expect(JSON.stringify(result)).not.toContain(title);
expect(logs).not.toContain(title);
});
it("does not infer quota from the historical generic terminal-limit error", () => {
expect(classifyCodexTerminalSessionFailure({
category: "limit",
title: "ACP agent reported a terminal limit failure.",
}, now)).toBeNull();
});
it("reads usage exhaustion and its reset clock from terminal details", () => {
expect(classifyCodexTerminalSessionFailure({
category: "limit",
title: "Codex could not complete the turn",
details: "You've hit your usage limit for GPT-5. Switch to another model now, or try again at 4:30 PM (America/Chicago).",
}, now)).toEqual({
errorCode: "provider_quota",
errorFamily: "provider_quota",
retryNotBefore: "2026-07-15T21:30:00.000Z",
});
});
it.each(["Usage limit reached", "Usage limit exceeded", "You’ve hit your usage limit"])(
"classifies explicit usage exhaustion without a reset clock: %s", (title) => {
expect(classifyCodexTerminalSessionFailure({ category: "limit", title }, now))
.toEqual({ errorCode: "provider_quota", errorFamily: "provider_quota" });
},
);
@@ -29,6 +29,8 @@ import type {
AcpxEngineExecutorOptions,
AcpxRemoteManagedHomeContext,
AcpxRemoteManagedHomeResult,
AcpxTerminalFailureClassification,
AcpxTerminalSessionFailure,
} from "@paperclipai/adapter-utils/acpx-engine/execute";
import {
asNumber,
@@ -38,7 +40,7 @@ import {
} from "@paperclipai/adapter-utils/server-utils";
import { createWorkspaceRestoreTeardown } from "@paperclipai/adapter-utils/workspace-restore-teardown";
import { normalizeCodexModel } from "../index.js";
import { classifyCodexAuthRefreshFailure } from "./parse.js";
import { classifyCodexAuthRefreshFailure, extractCodexRetryNotBefore } from "./parse.js";
import { copyBackCodexAuth } from "./codex-auth-copyback.js";
import { buildCodexAuthInboundProvision } from "./codex-auth-merge-scripts.js";
import {
@@ -257,10 +259,31 @@ async function prepareCodexRemoteManagedHome(
};
}
export function classifyCodexTerminalSessionFailure(
failure: AcpxTerminalSessionFailure,
now: Date,
): AcpxTerminalFailureClassification | null {
// ACP's `limit` also covers context, turn, rate and configured budget limits.
// Require explicit usage exhaustion; the CLI's broader capacity matcher would
// also match a context/storage capacity limit and defer the wrong failure.
if (failure.category !== "limit") return null;
const surface = { errorMessage: [failure.title, failure.details].filter(Boolean).join("\n") };
if (!/\b(?:you(?:'|’)ve hit your usage limit|usage limit (?:reached|exceeded))\b/i.test(surface.errorMessage)) {
return null;
}
const retryNotBefore = extractCodexRetryNotBefore(surface, now)?.toISOString();
return {
errorCode: "provider_quota",
errorFamily: "provider_quota",
...(retryNotBefore ? { retryNotBefore } : {}),
};
}
function withCodexAcpDefaults(options: CodexAcpExecutorOptions): AcpxEngineExecutorOptions {
return {
resolveBillingIdentity: resolveCodexAcpBillingIdentity,
prepareRemoteManagedHome: prepareCodexRemoteManagedHome,
classifyTerminalSessionFailure: classifyCodexTerminalSessionFailure,
...options,
adapterType: "codex_local",
moduleDir,
@@ -1023,7 +1023,8 @@ describe("evaluateCodexCredentialReadiness", () => {
const alpha = await fs.readFile(path.join(alphaHome, "config.toml"), "utf8");
const zero = await fs.readFile(path.join(zeroHome, "config.toml"), "utf8");
expect(alpha).toContain('[mcp_servers."alpha"]');
expect(alpha).toContain('Authorization = "Bearer alpha-token"');
expect(alpha).toContain('http_headers = { Authorization = "Bearer alpha-token" }');
expect(alpha).not.toMatch(/^headers\s*=/m);
expect(zero).not.toContain("mcp_servers.");
expect(zero).not.toContain("stale-token");
expect(alphaHome).not.toBe(zeroHome);
@@ -316,7 +316,7 @@ function buildManagedMcpBlock(input: {
"",
`[mcp_servers.${tomlString(managedName)}]`,
`url = ${tomlString(url)}`,
`headers = { Authorization = ${tomlString(`Bearer ${gateway.bearerToken}`)} }`,
`http_headers = { Authorization = ${tomlString(`Bearer ${gateway.bearerToken}`)} }`,
);
});
lines.push(MANAGED_MCP_BLOCK_END);
@@ -119,6 +119,20 @@ from the default branch and provide:
marked disabled;
- `max_infrastructure_retries`: zero through three, applied only when an
attempt explicitly reports a retryable infrastructure failure.
- `grok_authentication`: `api_key` (the compatibility default) or `subscription`.
Subscription requires an explicitly selected Grok roster and the owner-approved
`GROK_AUTH_JSON` secret in the protected `runner-e2e-paid` environment. API mode
uses only `XAI_API_KEY`. A missing selected credential fails; it never changes
authentication mode or falls back to another credential.
For subscription qualification, pass `-f grok_authentication=subscription` when
dispatching the default-branch workflow. The immutable catalog, retained cell,
campaign roster, and result record carry `authenticationMode`. The cell reads it
from the pinned eval program's actual roster summary, and aggregation rejects
missing or mismatched authentication evidence. Select an eval revision that
supports Grok subscription admission and records this summary field. Keep API
and subscription campaigns separate when interpreting results. Remove temporary
subscription test secrets after the authorized qualification completes.
The authorization job resolves the Paperclip branch to a commit and verifies
the supplied eval commit before any checkout. A short-lived bot token generated
@@ -1471,6 +1471,14 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
"method": "turn/started",
"params": {"turn": {"id": provider_turn_id}}
}))?;
if args.iter().any(|value| value == "--account-notifications") {
send(json!({"method":"account/updated", "params":{
"authMode":"chatgpt", "planType":"pro"
}}))?;
send(json!({"method":"account/login/completed", "params":{
"loginId":"fixture-login", "success":true, "error":null
}}))?;
}
if args.iter().any(|value| value == "--helper-tool-requests") {
if !args.iter().any(|value| value == "--foreign-helper-tool") {
send(json!({"method":"item/completed", "params":{
@@ -3383,8 +3383,18 @@ fn classify_notification_thread(
"Codex notification has malformed turn identity",
));
}
// This connection-level notification carries no task authority. Codex can
// emit it while loading skills during the first turn.
// Account and skill updates describe the provider connection, not a task.
// Codex can emit them during startup or credential refresh without thread
// or turn IDs. Never let them acquire execution authority or expose their
// account payload as task output.
if matches!(method, "account/updated" | "account/login/completed") {
if contains_provider_work_binding(params) {
return Err(LocalRunnerError::invalid(
"Codex account notification contains execution identity",
));
}
return Ok(NotificationThread::UnrelatedInformation);
}
if method == "skills/changed" && !contains_provider_work_binding(params) {
return Ok(NotificationThread::UnrelatedInformation);
}
@@ -4986,6 +4996,38 @@ mod notification_identity_tests {
}
}
#[test]
fn account_notifications_are_connection_information_without_execution_authority() {
for (method, params) in [
(
"account/updated",
json!({"authMode":"chatgpt", "planType":"pro"}),
),
(
"account/login/completed",
json!({"loginId":null, "success":true, "error":null}),
),
] {
assert_eq!(
classify_notification_thread(method, "root", &BTreeSet::new(), &params).unwrap(),
NotificationThread::UnrelatedInformation
);
for invalid in [
json!({"threadId":"other"}),
json!({"turnId":"unbound"}),
json!({"itemId":"unbound"}),
json!({"nested":{"request":{"id":"unbound"}}}),
json!({"threadId":7}),
json!({"threadId":"root", "thread":{"id":"other"}}),
] {
assert!(
classify_notification_thread(method, "root", &BTreeSet::new(), &invalid)
.is_err()
);
}
}
}
#[test]
fn child_mcp_startup_before_lineage_has_no_execution_authority() {
let params =
@@ -588,6 +588,54 @@ fn codex_transport_buffers_notifications_while_waiting_for_responses() {
fs::remove_dir_all(directory).expect("remove Codex integration-test directory");
}
#[test]
fn codex_account_updates_do_not_interrupt_turns_or_publish_account_details() {
let directory = temporary_directory("account-notifications");
let config = provider_config(&directory, &["--account-notifications"]);
let mut provider = CodexProvider::start(&config, None).expect("start fake Codex provider");
for _ in 0..2 {
provider
.start_turn("Are you there?", &config.cwd)
.expect("start provider turn");
let mut completed = false;
let mut account_notices = 0;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while std::time::Instant::now() < deadline {
match provider.poll().expect("poll provider event") {
Some(CodexProviderEvent::ProtocolFailure { diagnostic }) => {
panic!("account notification interrupted the turn: {diagnostic}");
}
Some(CodexProviderEvent::Notification { method, params }) => {
if params["providerMethod"]
.as_str()
.is_some_and(|method| method.starts_with("account/"))
{
account_notices += 1;
assert_eq!(method, "warning");
assert_eq!(params["classification"], "unrelated_information");
assert!(!params.to_string().contains("fixture-login"));
assert!(params.get("authMode").is_none());
assert!(params.get("planType").is_none());
}
if method == "turn/completed" {
completed = true;
break;
}
}
_ => {}
}
std::thread::sleep(std::time::Duration::from_millis(1));
}
assert!(
completed,
"the real provider boundary must deliver the terminal"
);
assert_eq!(account_notices, 2);
}
provider.shutdown().expect("stop provider");
fs::remove_dir_all(directory).expect("remove account notification test directory");
}
#[test]
fn codex_goal_autostart_binds_the_provider_turn_authority() {
let directory = temporary_directory("goal-autostart");
@@ -54,7 +54,14 @@ function inside(root, candidate, label) {
return resolve(candidate);
}
export function credentialForConfig(config) {
function validateGrokAuthenticationMode(mode) {
if (mode !== "api_key" && mode !== "subscription") {
throw new Error("Grok authentication mode must be api_key or subscription");
}
return mode;
}
export function credentialForConfig(config, grokAuthenticationMode = "api_key") {
if (config.provider === "opencode") return "OPENROUTER_API_KEY";
if (config.provider === "claude_managed") return "ANTHROPIC_API_KEY";
if (config.provider === "aws_agentcore") return "AWS_AGENTCORE_OIDC";
@@ -65,7 +72,11 @@ export function credentialForConfig(config) {
if (config.acpxAgent === "grok") return "XAI_API_KEY";
if (config.acpxAgent === "pi") return "OPENROUTER_API_KEY";
if (config.acpxAgent === "claude") return "ANTHROPIC_API_KEY";
if (config.acpxAgent === "grok") return "XAI_API_KEY";
if (config.acpxAgent === "grok") {
return validateGrokAuthenticationMode(grokAuthenticationMode) === "subscription"
? "PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET"
: "XAI_API_KEY";
}
if (config.acpxAgent === "codex") return "OPENAI_API_KEY";
}
throw new Error(
@@ -118,8 +129,10 @@ export async function buildProtocolEvalCatalog({
campaignId,
source = {},
maxParallel = 100,
grokAuthenticationMode = "api_key",
}) {
safeId(campaignId, "campaign ID");
validateGrokAuthenticationMode(grokAuthenticationMode);
if (
!Number.isSafeInteger(maxParallel) ||
maxParallel < 2 ||
@@ -160,7 +173,7 @@ export async function buildProtocolEvalCatalog({
`Config for ${rosterId}`,
);
const config = await loadObject(configPath);
const credentialName = credentialForConfig(config);
const credentialName = credentialForConfig(config, grokAuthenticationMode);
const cases = roster.cases.map((caseId) => safeId(caseId, "case ID"));
if (new Set(cases).size !== cases.length) {
throw new Error(`Live roster ${rosterId} repeats a case`);
@@ -173,6 +186,9 @@ export async function buildProtocolEvalCatalog({
provider: String(config.provider ?? "codex"),
driver: String(config.driver ?? "codex_app_server"),
credentialName,
...(config.provider === "acpx" && config.acpxAgent === "grok"
? { authenticationMode: grokAuthenticationMode }
: {}),
cases,
});
}
@@ -186,6 +202,9 @@ export async function buildProtocolEvalCatalog({
}
}
if (rosters.length === 0) throw new Error("No live rosters were selected");
if (grokAuthenticationMode === "subscription" && !rosters.some((roster) => roster.authenticationMode === "subscription")) {
throw new Error("Subscription selection requires an explicit Grok roster");
}
const cells = rosters.flatMap((roster) =>
roster.cases.map((caseId) => ({
@@ -197,6 +216,7 @@ export async function buildProtocolEvalCatalog({
provider: roster.provider,
driver: roster.driver,
credentialName: roster.credentialName,
...(roster.authenticationMode ? { authenticationMode: roster.authenticationMode } : {}),
})),
);
const shards = [[], []];
@@ -210,7 +230,7 @@ export async function buildProtocolEvalCatalog({
schema: "paperclip.runner-protocol-eval.catalog/v1",
campaignId,
source,
selection: { kind: requested === null ? "maintained_full" : "subset", rosters: rosterSelection },
selection: { kind: requested === null ? "maintained_full" : "subset", rosters: rosterSelection, grokAuthenticationMode },
rosters,
cells,
matrices: shards.map((include) => ({ include })),
@@ -400,6 +420,10 @@ export async function aggregateProtocolEvalCampaign({
) {
throw new Error(`Downloaded cell metadata drifted for ${cellId}`);
}
if (expected.authenticationMode !== undefined &&
(status.authenticationMode !== expected.authenticationMode || status.authenticationEvidenceFailure !== undefined)) {
throw new Error(`Invalid Grok authentication evidence for ${cellId}; retained cell metadata and attempts require inspection`);
}
const attemptRoot = resolve(dirname(statusPath), "runs");
const attemptIds = [];
const attemptMetadata = await lstat(attemptRoot).catch(() => null);
@@ -474,6 +498,7 @@ export async function aggregateProtocolEvalCampaign({
model: cell.model,
provider: cell.provider,
driver: cell.driver,
...(cell.authenticationMode ? { authenticationMode: cell.authenticationMode } : {}),
attemptIds,
finalAttemptId,
disposition: score.disposition,
@@ -514,6 +539,7 @@ export async function aggregateProtocolEvalCampaign({
model: roster.model,
provider: roster.provider,
driver: roster.driver,
...(roster.authenticationMode ? { authenticationMode: roster.authenticationMode } : {}),
selected: roster.cases.length,
passed: results.filter(
(result) => result.rosterId === roster.rosterId && result.passed,
@@ -700,6 +726,7 @@ async function main() {
rosterSelection: argument(args, "--rosters", "all"),
campaignId: argument(args, "--campaign-id", `local-${Date.now()}`),
maxParallel: Number(argument(args, "--max-parallel", "100")),
grokAuthenticationMode: argument(args, "--grok-authentication", "api_key"),
source: {
paperclipSha: process.env.PAPERCLIP_PROTOCOL_EVAL_SOURCE_SHA ?? null,
evalsSha: process.env.PAPERCLIP_PROTOCOL_EVALS_SHA ?? null,
@@ -104,6 +104,71 @@ test("maps every qualified driver to one explicit credential boundary", () => {
);
});
async function grokFixture() {
const value = await fixture();
value.config = {
schema: "paperclip-runner/eval-config/v1", id: "live-grok",
provider: "acpx", driver: "acpx_runtime", acpxAgent: "grok", model: "grok-4.7",
};
value.roster.model = value.config.model;
await writeFile(join(value.program, "configs/live-opencode-model.json"), JSON.stringify(value.config));
await writeFile(join(value.program, "rosters/live-opencode-model.json"), JSON.stringify(value.roster));
return value;
}
test("selects exactly one Grok credential and preserves authentication provenance", async () => {
const { root } = await grokFixture();
for (const [mode, credential] of [
["api_key", "XAI_API_KEY"],
["subscription", "PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET"],
]) {
const catalog = await buildProtocolEvalCatalog({ evalsRoot: root, campaignId: "gha-42-1", grokAuthenticationMode: mode });
assert.equal(catalog.selection.grokAuthenticationMode, mode);
assert.equal(catalog.cells[0].credentialName, credential);
assert.equal(catalog.cells[0].authenticationMode, mode);
assert.equal(catalog.rosters[0].authenticationMode, mode);
assert.equal(catalog.matrices[0].include[0].credentialName, credential);
}
for (const mode of ["", "auto", "subscription,api_key", null]) {
await assert.rejects(buildProtocolEvalCatalog({ evalsRoot: root, campaignId: "gha-42-1", grokAuthenticationMode: mode }), /authentication mode/);
}
const other = await fixture();
await assert.rejects(buildProtocolEvalCatalog({ evalsRoot: other.root, campaignId: "gha-42-1", grokAuthenticationMode: "subscription" }), /requires an explicit Grok roster/);
});
test("subscription evidence cannot be substituted with an API or missing authentication record", async () => {
const { root, config, evalCase } = await grokFixture();
const catalog = await buildProtocolEvalCatalog({ evalsRoot: root, campaignId: "gha-42-1", grokAuthenticationMode: "subscription" });
const catalogPath = join(root, "catalog.json");
await writeFile(catalogPath, JSON.stringify(catalog));
const cell = catalog.cells[0];
const download = join(root, "downloads/cell");
const attemptId = "grok-attempt-01";
const attempt = join(download, "runs", attemptId);
await mkdir(attempt, { recursive: true });
for (const [file, value] of Object.entries({
"artifact.json": { attemptId, usage: null },
"score.json": { attemptId, caseId: evalCase.id, passed: true, disposition: "passed" },
"case.json": evalCase, "config.json": config,
})) await writeFile(join(attempt, file), JSON.stringify(value));
const aggregate = () => aggregateProtocolEvalCampaign({
catalogPath, downloadsRoot: join(root, "downloads"), evalsRoot: root,
runsOut: join(root, "merged"), campaignOut: join(root, "campaign.json"), source: {},
});
const status = { cellId: cell.cellId, rosterFile: cell.rosterFile, caseId: cell.caseId, exitCode: 0 };
await writeFile(join(download, "cell.json"), JSON.stringify({ ...status, authenticationMode: "subscription" }));
const passed = await aggregate();
assert.equal(passed.totals.passed, 1);
assert.equal(passed.results[0].authenticationMode, "subscription");
assert.equal(passed.rosters[0].authenticationMode, "subscription");
await writeFile(join(download, "cell.json"), JSON.stringify({ ...status, authenticationMode: "subscription", authenticationEvidenceFailure: "grok_authentication_evidence_unreadable" }));
await assert.rejects(aggregate(), /Invalid Grok authentication evidence/);
for (const authenticationMode of [undefined, "api_key", "auto"]) {
await writeFile(join(download, "cell.json"), JSON.stringify({ ...status, authenticationMode }));
await assert.rejects(aggregate(), /Invalid Grok authentication evidence/);
}
});
test("catalogs roster plus case cells and emits bounded balanced shards", async () => {
const { root } = await fixture();
const catalog = await buildProtocolEvalCatalog({
@@ -1,5 +1,7 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { spawnSync } from "node:child_process";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
import test from "node:test";
@@ -13,6 +15,21 @@ const trustedPrWorkflowPath = resolve(
".github/workflows/pr-trusted.yml",
);
test("Grok subscription credentials require explicit catalog selection and observed auth evidence", async () => {
const workflow = await readFile(workflowPath, "utf8");
const paid = workflow.slice(workflow.indexOf(" steps: &direct_eval_steps"), workflow.indexOf(" eval_shard_1:"));
assert.match(workflow, /grok_authentication:\n[\s\S]*?type: choice\n[\s\S]*?default: api_key/u);
assert.match(workflow, /--grok-authentication "\$GROK_AUTHENTICATION"/u);
assert.ok(paid.includes("PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET: ${{ matrix.credentialName == 'PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET' && secrets.GROK_AUTH_JSON || '' }}"));
assert.ok(paid.includes("XAI_API_KEY: ${{ matrix.credentialName == 'XAI_API_KEY' && secrets.XAI_API_KEY || '' }}"));
assert.match(paid, /--summary-path cell-output\/roster-summary\.json/u);
assert.match(paid, /JSON\.parse\(readFileSync\("cell-output\/roster-summary\.json", "utf8"\)\)\.authenticationMode/u);
assert.match(paid, /authenticationMode !== expected/u);
for (const job of [workflow.slice(0, workflow.indexOf(" eval_shard_0:")), workflow.slice(workflow.indexOf(" report:"))]) {
assert.doesNotMatch(job, /secrets\.GROK_AUTH_JSON/u);
}
});
test("direct live eval workflow keeps paid execution behind stable actor authorization", async () => {
const workflow = await readFile(workflowPath, "utf8");
assert.match(workflow, /^\s{2}authorize:/mu);
@@ -228,3 +245,41 @@ test("trusted catalog, direct eval, and report orchestration stay on workflow re
assert.doesNotMatch(section, /ref: \$\{\{ needs\.authorize\.outputs\.target_sha \}\}/u, `${job} must not execute target orchestration code`);
}
});
test("authentication failures retain cell metadata without leaking malformed summary content", async () => {
const workflow = await readFile(workflowPath, "utf8");
const script = workflow.match(/CELL_EXIT_CODE="\$status" node --input-type=module <<'NODE'\n([\s\S]*?) NODE/u)?.[1];
assert.ok(script);
const root = await mkdtemp(resolve(tmpdir(), "grok-cell-evidence-"));
try {
await mkdir(resolve(root, "cell-output"));
const summary = resolve(root, "cell-output/roster-summary.json");
for (const [content, expectedFailure, expectedMode] of [
[undefined, "grok_authentication_evidence_unreadable", undefined],
["{SENSITIVE_SENTINEL", "grok_authentication_evidence_unreadable", undefined],
["null", "grok_authentication_evidence_unreadable", undefined],
['{"authenticationMode":"SENSITIVE_SENTINEL"}', "grok_authentication_evidence_mismatch", undefined],
['{"authenticationMode":"api_key"}', "grok_authentication_evidence_mismatch", "api_key"],
['{"authenticationMode":"subscription"}', undefined, "subscription"],
]) {
if (content === undefined) await rm(summary, { force: true });
else await writeFile(summary, content);
const result = spawnSync(process.execPath, ["--input-type=module", "-e", script], {
cwd: root, encoding: "utf8",
env: { CREDENTIAL_NAME: "PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET", CELL_ID: "cell-1", CASE_ID: "context", ROSTER_FILE: "grok.json", CELL_EXIT_CODE: "7" },
});
assert.equal(result.status, expectedFailure ? 1 : 0);
const retained = await readFile(resolve(root, "cell-output/cell.json"), "utf8");
const metadata = JSON.parse(retained);
assert.equal(metadata.cellId, "cell-1");
assert.equal(metadata.caseId, "context");
assert.equal(metadata.exitCode, 7);
assert.equal(metadata.authenticationEvidenceFailure, expectedFailure);
assert.equal(metadata.authenticationMode, expectedMode);
assert.doesNotMatch(retained + result.stdout + result.stderr, /SENSITIVE_SENTINEL/u);
}
} finally {
await rm(root, { recursive: true, force: true });
}
});
@@ -20,7 +20,10 @@ import type {
VerifiedAcpxInstallation,
} from "./installation-integrity.js";
import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js";
import { prepareAcpxRuntimeSandbox } from "./runtime-sandbox.js";
import {
prepareAcpxRuntimeSandbox,
type AcpxRuntimeSandbox,
} from "./runtime-sandbox.js";
import {
AcpxRuntimeHost,
type AcpxRuntimeHostDependencies,
@@ -30,6 +33,15 @@ import {
} from "./runtime-host.js";
const temporaryDirectories: string[] = [];
// A published skill snapshot is sealed read-only by `protectStagedTree`
// (runtime-context-materializer.ts:125, :133). Only
// `releaseMaterializedNativeRuntimeSkills` restores write permission before
// removal. `hostFixture` records every sandbox's skills home here as soon as
// the sandbox exists, before the materialize step that seals it and before
// any later step in the same open() call can fail or stall past this file's
// per-test timeout. `afterEach` releases every recorded home first, so a
// forced-open directory removal never has to unlink inside a sealed tree.
const materializedSkillsHomes: string[] = [];
const admissionControllers: AbortController[] = [];
const pendingAdmissionOpenings = new Set<Promise<void>>();
const pendingAdmissionCleanups = new Set<Promise<void>>();
@@ -167,6 +179,14 @@ afterEach(async () => {
}
await Promise.all([...pendingAdmissionOpenings]);
await Promise.all([...pendingAdmissionCleanups]);
// Release every sealed skills tree before the plain `rm` below. `rm` does
// not restore write permission, so a tree still sealed at this point would
// otherwise fail with EACCES and hide the real test failure.
await Promise.all(
materializedSkillsHomes
.splice(0)
.map((skillsHome) => releaseMaterializedNativeRuntimeSkills(skillsHome)),
);
await Promise.all(
temporaryDirectories
.splice(0)
@@ -281,7 +301,12 @@ describe("ACPX runtime host", () => {
const providerStartTurn = vi.fn(() => runtimeTurn());
let assigned = true;
let expectedReference = "ASSIGNED_SKILL_MARKER";
// Prepare the real sandbox once. Each reopen still exercises the host's
// real skill refresh, including changed references and removed assignments,
// without repeating unrelated durable directory and file writes. Sandbox
// preparation itself also has dedicated runtime-sandbox coverage.
const dependencies = fixture.dependencies({
reuseSandbox: true,
openRuntime: async (options) => {
skillsHome = join(options.launchEnvironment.CLAUDE_CONFIG_DIR!, "skills");
expect(await readdir(skillsHome)).toEqual(assigned ? ["assigned"] : []);
@@ -1411,6 +1436,16 @@ describe("ACPX runtime host", () => {
agentRuntimePackageJsonPath: null,
openCommand,
}),
// This test builds its own dependency object instead of
// `fixture.dependencies()`, so it must record the skills home
// itself. Command admission starts after the sandbox exists and
// Claude's skills are already materialized and sealed, and abort
// can land right there — before this test's own cleanup runs.
prepareSandbox: async (sandboxInput) => {
const sandbox = await prepareAcpxRuntimeSandbox(sandboxInput);
materializedSkillsHomes.push(join(sandbox.agentHomeDirectory, "skills"));
return sandbox;
},
openRuntime,
retainAdmissionCleanup: trackAdmissionCleanup,
reportRetainedCleanupFailure: vi.fn(),
@@ -1724,8 +1759,15 @@ async function hostFixture() {
input: Pick<AcpxRuntimeHostDependencies, "openRuntime"> &
Partial<
Pick<AcpxRuntimeHostDependencies, "reportRetainedCleanupFailure">
>,
> & {
// Opt-in only. When true, `prepareSandbox` runs the real
// preparation once, then returns that same sandbox for every
// later open in the test. Every other test omits this flag, so
// the file still proves that a reopen re-prepares the sandbox.
reuseSandbox?: boolean;
},
): AcpxRuntimeHostDependencies {
let reusedSandbox: AcpxRuntimeSandbox | null = null;
return {
verifyInstallation: async (profile) =>
({
@@ -1735,6 +1777,19 @@ async function hostFixture() {
openCommand: async () => command,
}) satisfies VerifiedAcpxInstallation,
openRuntime: input.openRuntime,
// Record the skills home the instant the sandbox exists, ahead of
// the materialize call that seals it. A test that overrides
// `prepareSandbox` for a non-Claude agent replaces this wrapper, but
// those agents never materialize skills, so nothing is lost.
prepareSandbox: async (sandboxInput) => {
if (input.reuseSandbox && reusedSandbox) return reusedSandbox;
const sandbox = await prepareAcpxRuntimeSandbox(sandboxInput);
if (sandboxInput.agent === "claude") {
materializedSkillsHomes.push(join(sandbox.agentHomeDirectory, "skills"));
}
if (input.reuseSandbox) reusedSandbox = sandbox;
return sandbox;
},
retainAdmissionCleanup: trackAdmissionCleanup,
reportRetainedCleanupFailure:
input.reportRetainedCleanupFailure ?? vi.fn(),
@@ -4652,18 +4652,18 @@ describe("daytona native file-sync hooks", () => {
await expect(fs.stat(path.join(hostRoot, "escape.txt"))).rejects.toThrow();
});
it("syncOut refuses a sandbox-authored tarball carrying a symlink whose target escapes the extraction dir", async () => {
it.each(["../../outside.txt", "/tmp/paperclip-git-workspace-example/skills/demo"])("syncOut refuses a sandbox-authored tarball with escaping symlink target %s", async (linkTarget) => {
const hostRoot = await makeHostDir();
const restored = path.join(hostRoot, "restored");
const sandbox = createMockSandbox();
sandbox.fs.downloadFiles.mockImplementation(async (requests: Array<{ source: string; destination?: string }>) => {
return Promise.all(
requests.map(async (req) => {
// Craft a tar whose sole member is a symlink pointing above the tree.
// Craft a tar whose sole member is a symlink pointing outside the tree.
const staging = await fs.mkdtemp(path.join(os.tmpdir(), "daytona-evil-"));
tempDirs.push(staging);
await fs.mkdir(path.join(staging, "sub"), { recursive: true });
await fs.symlink("../../outside.txt", path.join(staging, "sub", "evil"));
await fs.symlink(linkTarget, path.join(staging, "sub", "evil"));
execFileSync("tar", ["-cf", req.destination!, "-C", path.join(staging, "sub"), "evil"]);
return { source: req.source, result: req.destination };
}),
@@ -4741,6 +4741,8 @@ describe("daytona native file-sync hooks", () => {
await fs.writeFile(path.join(source, "secret"), "top-secret");
await fs.chmod(path.join(source, "secret"), 0o600);
await fs.symlink("nested/data.txt", path.join(source, "shortcut"));
await fs.mkdir(path.join(source, ".claude", "skills"), { recursive: true });
await fs.symlink("../../nested", path.join(source, ".claude", "skills", "demo"));
// Simulate the sandbox filesystem with a host-side directory the mock tar
// commands operate on, so the round-trip exercises real tar create/extract.
@@ -4798,6 +4800,8 @@ describe("daytona native file-sync hooks", () => {
const linkStat = await fs.lstat(path.join(restored, "shortcut"));
expect(linkStat.isSymbolicLink()).toBe(true);
expect(await fs.readlink(path.join(restored, "shortcut"))).toBe("nested/data.txt");
expect(await fs.readlink(path.join(restored, ".claude", "skills", "demo"))).toBe("../../nested");
expect(await fs.readFile(path.join(restored, ".claude", "skills", "demo", "data.txt"), "utf8")).toBe("hello world");
});
// -------------------------------------------------------------------------
@@ -20,58 +20,62 @@ import a18 from "./app-definitions/api-key-generic.json" with { type: "json" };
import a19 from "./app-definitions/sentry.json" with { type: "json" };
import a20 from "./app-definitions/vercel.json" with { type: "json" };
import a21 from "./app-definitions/anthropic.json" with { type: "json" };
import a22 from "./app-definitions/jira.json" with { type: "json" };
import a23 from "./app-definitions/airtable.json" with { type: "json" };
import a24 from "./app-definitions/beehiiv.json" with { type: "json" };
import a25 from "./app-definitions/bitly.json" with { type: "json" };
import a26 from "./app-definitions/candid.json" with { type: "json" };
import a27 from "./app-definitions/cloudflare.json" with { type: "json" };
import a28 from "./app-definitions/cloudinary.json" with { type: "json" };
import a29 from "./app-definitions/coda.json" with { type: "json" };
import a30 from "./app-definitions/hugging-face.json" with { type: "json" };
import a31 from "./app-definitions/kernel.json" with { type: "json" };
import a32 from "./app-definitions/local-falcon.json" with { type: "json" };
import a33 from "./app-definitions/make.json" with { type: "json" };
import a34 from "./app-definitions/manufact.json" with { type: "json" };
import a35 from "./app-definitions/miro.json" with { type: "json" };
import a36 from "./app-definitions/netlify.json" with { type: "json" };
import a37 from "./app-definitions/oreilly.json" with { type: "json" };
import a38 from "./app-definitions/planetscale.json" with { type: "json" };
import a39 from "./app-definitions/resend.json" with { type: "json" };
import a40 from "./app-definitions/ticktick.json" with { type: "json" };
import a41 from "./app-definitions/todoist.json" with { type: "json" };
import a42 from "./app-definitions/webflow.json" with { type: "json" };
import a43 from "./app-definitions/wix.json" with { type: "json" };
import a44 from "./app-definitions/brex.json" with { type: "json" };
import a45 from "./app-definitions/clickhouse.json" with { type: "json" };
import a46 from "./app-definitions/egnyte.json" with { type: "json" };
import a47 from "./app-definitions/embat.json" with { type: "json" };
import a48 from "./app-definitions/mixpanel.json" with { type: "json" };
import a49 from "./app-definitions/postman.json" with { type: "json" };
import a50 from "./app-definitions/razorpay.json" with { type: "json" };
import a51 from "./app-definitions/sanity.json" with { type: "json" };
import a52 from "./app-definitions/stripe.json" with { type: "json" };
import a53 from "./app-definitions/supabase.json" with { type: "json" };
import a54 from "./app-definitions/ticket-tailor.json" with { type: "json" };
import a55 from "./app-definitions/asana.json" with { type: "json" };
import a56 from "./app-definitions/box.json" with { type: "json" };
import a57 from "./app-definitions/mem0.json" with { type: "json" };
import a58 from "./app-definitions/pagerduty.json" with { type: "json" };
import a59 from "./app-definitions/similarweb.json" with { type: "json" };
import a60 from "./app-definitions/xero.json" with { type: "json" };
import a61 from "./app-definitions/youcom.json" with { type: "json" };
import a62 from "./app-definitions/fireflies.json" with { type: "json" };
import a63 from "./app-definitions/gmail.json" with { type: "json" };
import a64 from "./app-definitions/google-drive.json" with { type: "json" };
import a65 from "./app-definitions/google-docs.json" with { type: "json" };
import a66 from "./app-definitions/google-sheets.json" with { type: "json" };
import a67 from "./app-definitions/google-slides.json" with { type: "json" };
import a68 from "./app-definitions/google-calendar.json" with { type: "json" };
import a69 from "./app-definitions/google-chat.json" with { type: "json" };
import a70 from "./app-definitions/google-people.json" with { type: "json" };
import a71 from "./app-definitions/google-workspace-search.json" with { type: "json" };
import a72 from "./app-definitions/openai.json" with { type: "json" };
import a73 from "./app-definitions/openrouter.json" with { type: "json" };
import a74 from "./app-definitions/xai.json" with { type: "json" };
import a22 from "./app-definitions/cognee.json" with { type: "json" };
import a23 from "./app-definitions/jira.json" with { type: "json" };
import a24 from "./app-definitions/airtable.json" with { type: "json" };
import a25 from "./app-definitions/beehiiv.json" with { type: "json" };
import a26 from "./app-definitions/bitly.json" with { type: "json" };
import a27 from "./app-definitions/candid.json" with { type: "json" };
import a28 from "./app-definitions/cloudflare.json" with { type: "json" };
import a29 from "./app-definitions/cloudinary.json" with { type: "json" };
import a30 from "./app-definitions/coda.json" with { type: "json" };
import a31 from "./app-definitions/hugging-face.json" with { type: "json" };
import a32 from "./app-definitions/kernel.json" with { type: "json" };
import a33 from "./app-definitions/local-falcon.json" with { type: "json" };
import a34 from "./app-definitions/make.json" with { type: "json" };
import a35 from "./app-definitions/manufact.json" with { type: "json" };
import a36 from "./app-definitions/miro.json" with { type: "json" };
import a37 from "./app-definitions/netlify.json" with { type: "json" };
import a38 from "./app-definitions/oreilly.json" with { type: "json" };
import a39 from "./app-definitions/planetscale.json" with { type: "json" };
import a40 from "./app-definitions/resend.json" with { type: "json" };
import a41 from "./app-definitions/ticktick.json" with { type: "json" };
import a42 from "./app-definitions/todoist.json" with { type: "json" };
import a43 from "./app-definitions/webflow.json" with { type: "json" };
import a44 from "./app-definitions/wix.json" with { type: "json" };
import a45 from "./app-definitions/brex.json" with { type: "json" };
import a46 from "./app-definitions/clickhouse.json" with { type: "json" };
import a47 from "./app-definitions/egnyte.json" with { type: "json" };
import a48 from "./app-definitions/embat.json" with { type: "json" };
import a49 from "./app-definitions/mixpanel.json" with { type: "json" };
import a50 from "./app-definitions/postman.json" with { type: "json" };
import a51 from "./app-definitions/razorpay.json" with { type: "json" };
import a52 from "./app-definitions/sanity.json" with { type: "json" };
import a53 from "./app-definitions/stripe.json" with { type: "json" };
import a54 from "./app-definitions/supabase.json" with { type: "json" };
import a55 from "./app-definitions/ticket-tailor.json" with { type: "json" };
import a56 from "./app-definitions/asana.json" with { type: "json" };
import a57 from "./app-definitions/box.json" with { type: "json" };
import a58 from "./app-definitions/mem0.json" with { type: "json" };
import a59 from "./app-definitions/pagerduty.json" with { type: "json" };
import a60 from "./app-definitions/similarweb.json" with { type: "json" };
import a61 from "./app-definitions/xero.json" with { type: "json" };
import a62 from "./app-definitions/youcom.json" with { type: "json" };
import a63 from "./app-definitions/fireflies.json" with { type: "json" };
import a64 from "./app-definitions/zep.json" with { type: "json" };
import a65 from "./app-definitions/supermemory.json" with { type: "json" };
import a66 from "./app-definitions/honcho.json" with { type: "json" };
import a67 from "./app-definitions/gmail.json" with { type: "json" };
import a68 from "./app-definitions/google-drive.json" with { type: "json" };
import a69 from "./app-definitions/google-docs.json" with { type: "json" };
import a70 from "./app-definitions/google-sheets.json" with { type: "json" };
import a71 from "./app-definitions/google-slides.json" with { type: "json" };
import a72 from "./app-definitions/google-calendar.json" with { type: "json" };
import a73 from "./app-definitions/google-chat.json" with { type: "json" };
import a74 from "./app-definitions/google-people.json" with { type: "json" };
import a75 from "./app-definitions/google-workspace-search.json" with { type: "json" };
import a76 from "./app-definitions/openai.json" with { type: "json" };
import a77 from "./app-definitions/openrouter.json" with { type: "json" };
import a78 from "./app-definitions/xai.json" with { type: "json" };
import type { AppDefinition } from "./types/app-definition.js";
export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64,a65,a66,a67,a68,a69,a70,a71,a72,a73,a74] as AppDefinition[];
export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64,a65,a66,a67,a68,a69,a70,a71,a72,a73,a74,a75,a76,a77,a78] as AppDefinition[];
+5 -5
View File
@@ -274,7 +274,7 @@ describe("AppDefinition catalog", () => {
"google-workspace-search",
]),
);
expect(SELF_SERVE_MCP_CANDIDATES).toHaveLength(45);
expect(SELF_SERVE_MCP_CANDIDATES).toHaveLength(48);
expect(BLOCKED_MCP_PROVIDERS.map((entry) => entry.slug)).toEqual([
"g2",
"vercel",
@@ -427,15 +427,15 @@ describe("AppDefinition catalog", () => {
expect(channel("slack")?.guidanceMd).toContain("reactions");
expect(channel("slack")?.guidanceMd).toContain("direct messages");
});
it("keeps a complete, unique, dated evidence ledger for all 48 researched MCP providers", () => {
it("keeps a complete, unique, dated evidence ledger for all 51 researched MCP providers", () => {
// Ledger-wide date reflects the last full re-verification (2026-08-26);
// later provider additions carry their own research evidence, but
// bumping the shared date would overstate freshness for the other providers.
expect(SELF_SERVE_MCP_RESEARCH.verifiedAt).toBe("2026-08-26");
expect(SELF_SERVE_MCP_RESEARCH.entries).toHaveLength(48);
expect(SELF_SERVE_MCP_RESEARCH.entries).toHaveLength(51);
expect(
new Set(SELF_SERVE_MCP_RESEARCH.entries.map((entry) => entry.slug)),
).toHaveProperty("size", 48);
).toHaveProperty("size", 51);
for (const entry of SELF_SERVE_MCP_RESEARCH.entries) {
expect(new URL(entry.docsUrl).protocol).toBe("https:");
expect(new URL(entry.serverUrl).protocol).toBe("https:");
@@ -719,7 +719,7 @@ describe("AppDefinition catalog", () => {
"ticktick",
"xero",
]);
expect(APP_STORE_DEFINITIONS).toHaveLength(52);
expect(APP_STORE_DEFINITIONS).toHaveLength(56);
const connectableSlugs = new Set(
CONNECTABLE_APP_DEFINITIONS.map((entry) => entry.slug),
);
+3 -1
View File
@@ -6,6 +6,7 @@ import type { ToolConnectionOwnership } from "./types/tool-access.js";
export const CONNECTABLE_APP_SLUGS = new Set([
"anthropic", "openai", "openrouter", "xai",
"agentmail",
"cognee",
...SELF_SERVE_MCP_CANDIDATES.map((entry) => entry.slug),
"zapier",
"arcade",
@@ -215,7 +216,8 @@ export function appAcceptsCustomerOAuthClient(app: AppDefinition | null | undefi
return Boolean(app && getAvailableConnectionMethods(app).some(connectionMethodAcceptsCustomerOAuthClient));
}
export function credentialConfigPath(field: FieldDef): string {
export function credentialConfigPath(field: FieldDef, method?: ConnectionMethodDef | null): string {
if (method?.transport === "local_stdio" && method.keyPlacement?.location === "env") return `env.${field.key}`;
return `credentials.${field.key}`;
}
@@ -0,0 +1,71 @@
{
"schemaVersion": 1,
"slug": "cognee",
"name": "Cognee",
"description": "Build and recall shared graph memory from documents and conversations.",
"categories": [
"ai"
],
"branding": {
"logoUrl": "/brands/apps/cognee.png"
},
"urlPatterns": [
"https://*.aws.cognee.ai/*"
],
"docsUrl": "https://docs.cognee.ai/cognee-cloud/connections/cloud-mcp",
"setupPrerequisite": {
"title": "Cognee Cloud and a local runtime",
"description": "Use your Cognee Cloud tenant API URL and key. Paperclip's runtime host needs uv installed to run the official Cognee MCP client. Public deployments require a trusted MCP runtime host.",
"actionLabel": "Open Cognee API keys",
"actionUrl": "https://platform.cognee.ai/api-keys"
},
"methods": [
{
"key": "cloud-local",
"transport": "local_stdio",
"auth": "api_key",
"ownershipModes": [
"customer"
],
"whenToUse": "Connect your Cloud tenant through the official Cognee MCP client.",
"defaults": {
"templateKey": "paperclip.cognee-cloud"
},
"guidanceMd": "Copy the API Base URL and create an API key on Cognee's API Keys page. Use a Cloud workspace with an active subscription. Paperclip uses its bundled Cloud client; no extra runtime installation is required.",
"riskTier": "S3",
"label": "Connect Cognee Cloud",
"credentialFields": [
{
"key": "COGNEE_BASE_URL",
"label": "API Base URL",
"type": "text",
"required": true,
"secret": false,
"placeholder": "https://your-tenant.aws.cognee.ai",
"validation": {
"pattern": "^https://[a-zA-Z0-9-]+\\.aws\\.cognee\\.ai/?$",
"maxLength": 255
},
"helperMd": "Copy API Base URL from Cognee's API Keys page."
},
{
"key": "COGNEE_API_KEY",
"label": "Cognee API key",
"type": "password",
"required": true,
"placeholder": "Paste your Cognee API key",
"secret": true,
"helperMd": "Create a key in Cognee → API Keys. The key is shown once."
}
],
"keyPlacement": {
"location": "env",
"name": "COGNEE_API_KEY"
},
"consoleLinks": {
"keys": "https://platform.cognee.ai/api-keys",
"docs": "https://docs.cognee.ai/cognee-cloud/connections/cloud-mcp"
}
}
]
}
@@ -0,0 +1,56 @@
{
"schemaVersion": 1,
"slug": "honcho",
"name": "Honcho",
"description": "Remember conversations and retrieve context about peers.",
"categories": [
"ai"
],
"featured": false,
"branding": {
"logoUrl": "/brands/apps/honcho.svg"
},
"urlPatterns": [
"https://mcp.honcho.dev/*"
],
"docsUrl": "https://honcho.dev/docs/v3/guides/integrations/mcp",
"methods": [
{
"key": "mcp-api-key",
"transport": "mcp_remote",
"auth": "api_key",
"ownershipModes": [
"customer"
],
"whenToUse": "Use a restricted customer-owned key when browser sign-in is not suitable.",
"defaults": {
"serverUrl": "https://mcp.honcho.dev"
},
"guidanceMd": "Open the Honcho dashboard, create an API key for the account agents should use, and paste it below.",
"riskTier": "S3",
"label": "Use an API key",
"credentialFields": [
{
"key": "authorization",
"label": "Honcho API key",
"type": "password",
"required": true,
"placeholder": "Paste your Honcho API key",
"secret": true
}
],
"keyPlacement": {
"location": "header",
"name": "Authorization",
"prefix": "Bearer "
},
"consoleLinks": {
"keys": "https://app.honcho.dev",
"docs": "https://honcho.dev/docs/v3/guides/integrations/mcp"
},
"warnings": [
"An API key from the Honcho dashboard. Memory is stored in your Honcho account; select workspace and peer identifiers when calling tools."
]
}
]
}
@@ -2,7 +2,7 @@
"schemaVersion": 1,
"slug": "mem0",
"name": "Mem0",
"description": "Connect Mem0's provider-hosted MCP server.",
"description": "Remember preferences, conversations, events, and agent state.",
"categories": [
"ai"
],
@@ -26,7 +26,7 @@
"defaults": {
"serverUrl": "https://mcp.mem0.ai/mcp/"
},
"guidanceMd": "Use a customer-created Mem0 key. A Mem0 API key; the live server currently requires the slash-normalized endpoint.",
"guidanceMd": "Open the Mem0 dashboard, create an API key for the account agents should use, and paste it below.",
"riskTier": "S3",
"label": "Use an API key",
"credentialFields": [
@@ -35,7 +35,7 @@
"label": "Mem0 API key",
"type": "password",
"required": true,
"placeholder": "m0sk_...",
"placeholder": "Paste your Mem0 API key",
"secret": true
}
],
@@ -45,7 +45,7 @@
"prefix": "Bearer "
},
"consoleLinks": {
"keys": "https://docs.mem0.ai/platform/mem0-mcp",
"keys": "https://app.mem0.ai/dashboard/api-keys",
"docs": "https://docs.mem0.ai/platform/mem0-mcp"
},
"warnings": [
@@ -0,0 +1,50 @@
{
"schemaVersion": 1,
"slug": "supermemory",
"name": "Supermemory",
"description": "Search and save shared memories, documents, and profiles.",
"categories": [
"ai"
],
"featured": false,
"branding": {
"logoUrl": "/brands/apps/supermemory.png"
},
"urlPatterns": [
"https://mcp.supermemory.ai/*"
],
"docsUrl": "https://supermemory.ai/docs/supermemory-mcp/mcp",
"redirectConstraints": "https-or-loopback-http",
"methods": [
{
"key": "mcp-oauth",
"transport": "mcp_remote",
"auth": "oauth",
"ownershipModes": [
"dcr"
],
"whenToUse": "Use browser sign-in for the provider-hosted MCP server.",
"defaults": {
"serverUrl": "https://mcp.supermemory.ai/mcp",
"scopesHint": [
"openid",
"profile",
"email",
"offline_access"
]
},
"guidanceMd": "Sign in to Supermemory, then choose a workspace, read or write access, and optional tags. Use a separate space for unrelated work.",
"riskTier": "S3",
"label": "Sign in with Supermemory",
"consoleLinks": {
"docs": "https://supermemory.ai/docs/supermemory-mcp/mcp"
},
"warnings": [
"Sign in to Supermemory and select the spaces this connection may access. The hosted MCP uses OAuth, not a developer API key."
],
"grantKinds": [
"user"
]
}
]
}
@@ -0,0 +1,49 @@
{
"schemaVersion": 1,
"slug": "zep",
"name": "Zep",
"description": "Retrieve temporal graph memory and authorized business context.",
"categories": [
"ai"
],
"featured": false,
"branding": {
"logoUrl": "/brands/apps/zep.png"
},
"urlPatterns": [
"https://api.getzep.com/*"
],
"docsUrl": "https://help.getzep.com/memory-mcp-server",
"redirectConstraints": "https-or-loopback-http",
"methods": [
{
"key": "mcp-oauth",
"transport": "mcp_remote",
"auth": "oauth",
"ownershipModes": [
"dcr"
],
"whenToUse": "Use browser sign-in for the provider-hosted MCP server.",
"defaults": {
"serverUrl": "https://api.getzep.com/mcp",
"scopesHint": [
"graph:read",
"graph:write"
]
},
"guidanceMd": "Sign in with the work identity configured for your Zep project's Memory MCP server. Zep restricts access to that identity's memory and authorized shared graphs.",
"riskTier": "S3",
"label": "Sign in with Zep",
"consoleLinks": {
"settings": "https://app.getzep.com",
"docs": "https://help.getzep.com/memory-mcp-server"
},
"warnings": [
"A Zep project with Memory MCP enabled, available MCP seats, and Google Workspace or enterprise OIDC configured by its administrator. Project API keys do not authenticate Memory MCP."
],
"grantKinds": [
"user"
]
}
]
}
+7
View File
@@ -116,6 +116,13 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
cloudDefault: false,
selfHostedDefault: false,
},
enableMemoryConnectors: {
title: "Memory connectors",
description: "Show experimental Mem0, Zep, Supermemory, Cognee, and Honcho setup. Existing connections keep running when hidden.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableMcpAggregators: {
title: "MCP aggregators",
description:
+4
View File
@@ -2680,6 +2680,7 @@ export {
HIDEABLE_GENERAL_SECTIONS,
HIDEABLE_INSTANCE_PAGES,
HIDEABLE_SETTING_KEYS,
HIDEABLE_WORKSPACE_SECTIONS,
SETTINGS_OPERATOR_MANAGED_ERROR_CODE,
UI_ONLY_GENERAL_SECTIONS,
experimentalSettingKey,
@@ -2695,6 +2696,7 @@ export {
type HideableGeneralSection,
type HideableInstancePage,
type HideableSettingKey,
type HideableWorkspaceSection,
type ParsedHiddenSettings,
} from "./settings-visibility.js";
export {
@@ -2776,3 +2778,5 @@ export { REMOTE_MCP_CONNECTOR_METHODS, isRemoteMcpConnectorId, isRemoteMcpConnec
export { isRetiredComposioConnection, RETIRED_COMPOSIO_MESSAGE } from "./retired-composio.js";
export * from "./slack-tools.js";
export { MEMORY_CONNECTOR_IDS, isMemoryConnectorId, type MemoryConnectorId } from "./memory-connectors.js";
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { MEMORY_CONNECTOR_IDS, isMemoryConnectorId } from "./memory-connectors.js";
import { getConnectableAppDefinition, credentialConfigPath } from "./app-definitions.js";
import { INSTANCE_FEATURE_CATALOG } from "./feature-catalog.js";
import { instanceExperimentalSettingsSchema, patchInstanceExperimentalSettingsSchema } from "./validators/instance.js";
describe("experimental memory connectors", () => {
it("defaults off and preserves patch semantics", () => {
expect(instanceExperimentalSettingsSchema.parse({}).enableMemoryConnectors).toBe(false);
expect(patchInstanceExperimentalSettingsSchema.parse({})).not.toHaveProperty("enableMemoryConnectors");
expect(patchInstanceExperimentalSettingsSchema.parse({ enableMemoryConnectors: true })).toEqual({ enableMemoryConnectors: true });
expect(INSTANCE_FEATURE_CATALOG.enableMemoryConnectors).toMatchObject({ tier: "managed", selfHostedDefault: false, cloudDefault: false });
expect(isMemoryConnectorId("notion")).toBe(false);
});
it.each(MEMORY_CONNECTOR_IDS)("has a supported, branded %s setup method", (slug) => {
const app = getConnectableAppDefinition(slug)!;
expect(app).toBeDefined();
expect(app.branding.logoUrl).toMatch(new RegExp(`/brands/apps/${slug}\\.`));
expect(app.methods).toHaveLength(1);
expect(app.methods[0]!.transport).toBe(slug === "cognee" ? "local_stdio" : "mcp_remote");
});
it("uses provider-documented auth and endpoint paths", () => {
const method = (slug: string) => getConnectableAppDefinition(slug)!.methods[0]!;
expect(method("mem0")).toMatchObject({ auth: "api_key", defaults: { serverUrl: "https://mcp.mem0.ai/mcp/" }, keyPlacement: { location: "header", name: "Authorization", prefix: "Bearer " } });
expect(method("honcho")).toMatchObject({ auth: "api_key", defaults: { serverUrl: "https://mcp.honcho.dev" }, keyPlacement: { location: "header", name: "Authorization", prefix: "Bearer " } });
expect(method("zep")).toMatchObject({ auth: "oauth", grantKinds: ["user"], ownershipModes: ["dcr"], defaults: { serverUrl: "https://api.getzep.com/mcp", scopesHint: ["graph:read", "graph:write"] } });
expect(method("supermemory")).toMatchObject({ auth: "oauth", grantKinds: ["user"], defaults: { serverUrl: "https://mcp.supermemory.ai/mcp" } });
const cognee = method("cognee");
expect(cognee.defaults).toEqual({ templateKey: "paperclip.cognee-cloud" });
expect(cognee.credentialFields!.map(field => credentialConfigPath(field, cognee))).toEqual(["env.COGNEE_BASE_URL", "env.COGNEE_API_KEY"]);
expect(credentialConfigPath(method("mem0").credentialFields![0]!, method("mem0"))).toBe("credentials.authorization");
});
});
+6
View File
@@ -0,0 +1,6 @@
/** Setup rollout only: hiding the catalog does not revoke existing connections. */
export const MEMORY_CONNECTOR_IDS = ["mem0", "zep", "supermemory", "cognee", "honcho"] as const;
export type MemoryConnectorId = (typeof MEMORY_CONNECTOR_IDS)[number];
export function isMemoryConnectorId(value: unknown): value is MemoryConnectorId {
return typeof value === "string" && (MEMORY_CONNECTOR_IDS as readonly string[]).includes(value);
}
@@ -53,6 +53,9 @@
{"slug": "fireflies", "name": "Fireflies", "wave": 4, "status": "self_serve", "docsUrl": "https://docs.fireflies.ai/getting-started/mcp-configuration", "serverUrl": "https://api.fireflies.ai/mcp", "authMode": "dcr_or_api_key", "prerequisite": "A Fireflies account with access to the meetings you want to use. API keys are available in Settings → Developer Settings.", "riskTier": "S3"},
{ "slug": "g2", "name": "G2", "wave": "blocked", "status": "blocked", "docsUrl": "https://documentation.g2.com/docs/g2-mcp-server", "serverUrl": "https://mcp.g2.com/mcp", "authMode": "provider_approval", "prerequisite": "G2 must enable cross-application token introspection before an independently registered client can work.", "riskTier": "S3" },
{ "slug": "vercel", "name": "Vercel", "wave": "blocked", "status": "blocked", "docsUrl": "https://vercel.com/docs/agent-resources/vercel-mcp", "serverUrl": "https://mcp.vercel.com", "authMode": "provider_approval", "prerequisite": "Vercel currently reviews and approves MCP clients.", "riskTier": "S3" },
{ "slug": "zomato", "name": "Zomato", "wave": "blocked", "status": "blocked", "docsUrl": "https://github.com/Zomato/mcp-server-manifest", "serverUrl": "https://mcp-server.zomato.com/mcp", "authMode": "provider_approval", "prerequisite": "Zomato currently limits third-party clients and requires redirect-URI allowlisting.", "riskTier": "S3" }
{ "slug": "zomato", "name": "Zomato", "wave": "blocked", "status": "blocked", "docsUrl": "https://github.com/Zomato/mcp-server-manifest", "serverUrl": "https://mcp-server.zomato.com/mcp", "authMode": "provider_approval", "prerequisite": "Zomato currently limits third-party clients and requires redirect-URI allowlisting.", "riskTier": "S3" },
{"slug": "zep", "name": "Zep", "wave": 3, "status": "self_serve", "docsUrl": "https://help.getzep.com/memory-mcp-server", "serverUrl": "https://api.getzep.com/mcp", "authMode": "dcr_cimd", "prerequisite": "A Zep project with Memory MCP enabled, available MCP seats, and Google Workspace or enterprise OIDC configured by its administrator. Project API keys do not authenticate Memory MCP.", "riskTier": "S3"},
{"slug": "supermemory", "name": "Supermemory", "wave": 3, "status": "self_serve", "docsUrl": "https://supermemory.ai/docs/supermemory-mcp/mcp", "serverUrl": "https://mcp.supermemory.ai/mcp", "authMode": "dcr", "prerequisite": "Sign in to Supermemory and select the spaces this connection may access. The hosted MCP uses OAuth, not a developer API key.", "riskTier": "S3"},
{"slug": "honcho", "name": "Honcho", "wave": 3, "status": "self_serve", "docsUrl": "https://honcho.dev/docs/v3/guides/integrations/mcp", "serverUrl": "https://mcp.honcho.dev", "authMode": "api_key", "prerequisite": "An API key from the Honcho dashboard. Memory is stored in your Honcho account; select workspace and peer identifiers when calling tools.", "riskTier": "S3"}
]
}
@@ -49,6 +49,11 @@ describe("hideable setting keys", () => {
});
describe("parseHiddenSettingsList", () => {
it("accepts workspace controls independently of experimental flags", () => {
expect(parseHiddenSettingsList("workspaces.isolation")).toEqual({ hidden: ["workspaces.isolation"], unknown: [] });
expect(hidesExperimentalSetting(new Set(["workspaces.isolation"]), "enableIsolatedWorkspaces")).toBe(false);
});
it("returns nothing hidden for undefined or empty input", () => {
expect(parseHiddenSettingsList(undefined)).toEqual({ hidden: [], unknown: [] });
expect(parseHiddenSettingsList(" , ,")).toEqual({ hidden: [], unknown: [] });
@@ -99,7 +99,12 @@ export function experimentalSettingKey(key: InstanceFeatureKey): HideableExperim
return `instance.experimental.${key}`;
}
/** Workspace policy editors and selectors. UI-only; execution and APIs stay active. */
export const HIDEABLE_WORKSPACE_SECTIONS = ["workspaces.isolation"] as const;
export type HideableWorkspaceSection = (typeof HIDEABLE_WORKSPACE_SECTIONS)[number];
export type HideableSettingKey =
| HideableWorkspaceSection
| HideableInstancePage
| HideableCompanyPage
| HideableCompanySection
@@ -108,6 +113,7 @@ export type HideableSettingKey =
/** Every key `PAPERCLIP_HIDDEN_SETTINGS` accepts. */
export const HIDEABLE_SETTING_KEYS: readonly HideableSettingKey[] = [
...HIDEABLE_WORKSPACE_SECTIONS,
...HIDEABLE_INSTANCE_PAGES,
...HIDEABLE_COMPANY_PAGES,
...HIDEABLE_COMPANY_SECTIONS,
+38 -4
View File
@@ -7,7 +7,12 @@ export const slackSearchConfigSchema = z
})
.strict();
const channel = z.string().regex(/^[CGD][A-Z0-9]+$/).describe("Slack channel ID (for example C012AB3CD), not a channel name or URL. Use the assigned source channel or slack_channels results.");
const channel = z
.string()
.regex(/^[CGD][A-Z0-9]+$/)
.describe(
"Slack channel ID (for example C012AB3CD), not a channel name or URL. Use the assigned source channel or slack_channels results.",
);
const user = z.string().regex(/^[UW][A-Z0-9]+$/);
const timestamp = z.string().regex(/^\d+\.\d+$/);
const text = z.string().min(1).max(12000);
@@ -15,7 +20,14 @@ const cursor = z.string().max(2048).optional();
const page = { cursor, limit: z.number().int().min(1).max(100).optional() };
const channelPage = { channel, ...page };
const message = { channel, ts: timestamp, thread_ts: timestamp.optional() };
const write = { idempotencyKey: z.string().uuid().describe("A UUID, such as 9c0dc094-41b6-4d84-a2f1-1df331774489. Do not use a descriptive string or a register_deliverable key. Reuse this UUID only for the same operation.") };
const write = {
idempotencyKey: z
.string()
.uuid()
.describe(
"A UUID, such as 9c0dc094-41b6-4d84-a2f1-1df331774489. Do not use a descriptive string or a register_deliverable key. Reuse this UUID only for the same operation.",
),
};
const file = z.string().regex(/^F[A-Z0-9]+$/);
const document = {
channel,
@@ -33,7 +45,18 @@ function tool<N extends string, S extends z.ZodRawShape>(
description: string,
shape: S,
) {
const schema = z.object(shape).strict();
const schema = z
.object({
...shape,
endpointId: z
.string()
.uuid()
.optional()
.describe(
"Optional assigned Slack connection ID. Use the supplied resource ID when more than one bot is available; it never grants access to another agent's bot.",
),
})
.strict();
return {
name: `slack_${name}` as const,
method,
@@ -46,8 +69,17 @@ function tool<N extends string, S extends z.ZodRawShape>(
}
/** Reviewed allowlist. Scopes separated by | are alternatives, not cumulative requirements.
* No model-supplied method, token, endpoint, identity, workspace or upload URL. */
* No model-supplied method, token, identity, workspace or upload URL.
* The optional endpoint selector is authorized against the assigned agent on every call. */
export const SLACK_TOOLS = [
tool(
"open_dm",
"conversations.open",
["im:write"],
"write",
"Open or resume this bot's DM with the current task's linked requester. Returns a channel ID for slack_post_message. Never opens another person's DM.",
write,
),
tool(
"delivery",
"chat.getPermalink",
@@ -418,11 +450,13 @@ export type SlackToolName = (typeof SLACK_TOOLS)[number]["name"];
export const slackToolCallSchema = z
.object({
tool: z.string().min(1).max(100),
endpointId: z.string().uuid().optional(),
arguments: z.record(z.string(), z.unknown()),
})
.strict();
export const SLACK_BOT_TOOL_SCOPES = [
"im:write",
"emoji:read",
"pins:read",
"pins:write",
+11 -1
View File
@@ -116,11 +116,21 @@ export class TelemetryClient {
* backend event schema.
*/
track<K extends TelemetryEventName>(eventName: K, ...args: TrackArgs<K>): void {
if (!Object.hasOwn(PAPERCLIP_EVENTS, eventName)) return;
if (!this.isRegisteredEventName(eventName)) return;
const [dimensions] = args;
this.enqueue(eventName, dimensions);
}
/**
* Whether `track` would enqueue this first-party event name rather than drop
* it as unregistered. Lets a caller with an expensive payload (e.g. one that
* needs database reads) skip building dimensions for a proposed event the
* client would discard anyway.
*/
isRegisteredEventName(eventName: string): boolean {
return Object.hasOwn(PAPERCLIP_EVENTS, eventName);
}
/**
* Tracks plugin telemetry bridge events whose names are built dynamically
* from third-party plugin input. The backend accepts only explicitly
+69
View File
@@ -153,6 +153,75 @@ export function trackAgentTaskRun(
});
}
export function trackConnectionCreated(
client: TelemetryClient,
dims: {
connector_key: RawDimension<"custom">;
transport: RawDimension<"mcp_remote" | "rest_api" | "local_stdio">;
auth_kind: RawDimension<"oauth" | "api_key" | "none">;
setup_flow: RawDimension<"gallery" | "api" | "example">;
status: RawDimension<"draft" | "active" | "disabled" | "archived">;
enabled: boolean;
},
): void {
client.track(
// @ts-expect-error -- proposed-telemetry(https://github.com/paperclipai/paperclip/issues/13578): measure which catalog connectors installations create connections for
"connection.created",
dims,
);
}
export function trackConnectionUpdated(
client: TelemetryClient,
dims: {
connector_key: RawDimension<"custom">;
transport: RawDimension<"mcp_remote" | "rest_api" | "local_stdio">;
auth_kind: RawDimension<"oauth" | "api_key" | "none">;
change_source: RawDimension<
| "api"
| "gallery"
| "oauth_callback"
| "credential_refresh"
| "archive"
| "example"
>;
previous_status: RawDimension<"draft" | "active" | "disabled" | "archived">;
status: RawDimension<"draft" | "active" | "disabled" | "archived">;
previous_enabled: boolean;
enabled: boolean;
},
): void {
client.track(
// @ts-expect-error -- proposed-telemetry(https://github.com/paperclipai/paperclip/issues/13578): measure connector lifecycle transitions (configured, paused, archived) after creation
"connection.updated",
dims,
);
}
export function trackConnectionInvoked(
client: TelemetryClient,
dims: {
connector_key: RawDimension<"custom">;
transport: RawDimension<"mcp_remote" | "rest_api" | "local_stdio">;
status: RawDimension<
| "succeeded"
| "failed"
| "denied"
| "cancelled"
| "timed_out"
| "rate_limited"
>;
origin: RawDimension<"setup_test" | "agent" | "user" | "system" | "plugin">;
duration_seconds?: number;
},
): void {
client.track(
// @ts-expect-error -- proposed-telemetry(https://github.com/paperclipai/paperclip/issues/13578): measure whether connected connectors are successfully used and where invocations fail; records completed invocation attempts and their terminal status, never invocation starts
"connection.invoked",
dims,
);
}
export function trackErrorHandlerCrash(
client: TelemetryClient,
dims: { errorCode: string },
+3
View File
@@ -18,6 +18,9 @@ export {
trackErrorHandlerCrash,
trackInteractionCreated,
trackInteractionResolved,
trackConnectionCreated,
trackConnectionUpdated,
trackConnectionInvoked,
} from "./events.js";
export type {
TelemetryConfig,
@@ -0,0 +1,124 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { TelemetryClient } from "./client.js";
import { resolveTelemetryConfig } from "./config.js";
import {
trackConnectionCreated,
trackConnectionUpdated,
trackConnectionInvoked,
} from "./events.js";
import type { TelemetryState } from "./types.js";
const TEST_STATE: TelemetryState = {
installId: "test-install",
salt: "test-salt",
createdAt: "2026-01-01T00:00:00Z",
firstSeenVersion: "0.0.0",
};
function makeClient(config?: { enabled?: boolean }) {
const stateFactory = vi.fn(() => TEST_STATE);
return {
client: new TelemetryClient(
{ enabled: config?.enabled ?? true, endpoint: "http://localhost:9999/ingest" },
stateFactory,
"0.0.0-test",
() => 0.5,
),
stateFactory,
};
}
function trackAllProposedConnectorEvents(client: TelemetryClient) {
trackConnectionCreated(client, {
connector_key: "github",
transport: "mcp_remote",
auth_kind: "oauth",
setup_flow: "gallery",
status: "active",
enabled: true,
});
trackConnectionUpdated(client, {
connector_key: "github",
transport: "mcp_remote",
auth_kind: "oauth",
change_source: "api",
previous_status: "draft",
status: "active",
previous_enabled: false,
enabled: true,
});
trackConnectionInvoked(client, {
connector_key: "github",
transport: "mcp_remote",
status: "succeeded",
origin: "agent",
duration_seconds: 3,
});
}
describe("proposed connector events against the real TelemetryClient", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
it("cannot queue or send: state stays untouched and nothing hits the wire", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true }));
const { client, stateFactory } = makeClient();
trackAllProposedConnectorEvents(client);
await client.flush();
expect(stateFactory).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
});
it("differential control: a registered event through the same client does send", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true }));
const { client } = makeClient();
trackAllProposedConnectorEvents(client);
client.track("project.created", {});
await client.flush();
expect(fetch).toHaveBeenCalledTimes(1);
const body = JSON.parse(
String((vi.mocked(fetch).mock.calls[0]?.[1] as RequestInit).body),
);
expect(body.events).toEqual([
expect.objectContaining({ name: "project.created" }),
]);
});
it("reports the three connector event names as unregistered", () => {
const { client } = makeClient();
expect(client.isRegisteredEventName("connection.created")).toBe(false);
expect(client.isRegisteredEventName("connection.updated")).toBe(false);
expect(client.isRegisteredEventName("connection.invoked")).toBe(false);
expect(client.isRegisteredEventName("project.created")).toBe(true);
});
it("a disabled client drops even registered events", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true }));
const { client, stateFactory } = makeClient({ enabled: false });
client.track("project.created", {});
await client.flush();
expect(stateFactory).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
});
it("environment suppression resolves telemetry off under CI and opt-out flags", () => {
vi.stubEnv("PAPERCLIP_TELEMETRY_DISABLED", "1");
expect(resolveTelemetryConfig().enabled).toBe(false);
vi.unstubAllEnvs();
vi.stubEnv("DO_NOT_TRACK", "1");
expect(resolveTelemetryConfig().enabled).toBe(false);
vi.unstubAllEnvs();
vi.stubEnv("GITHUB_ACTIONS", "true");
expect(resolveTelemetryConfig().enabled).toBe(false);
});
});
+2
View File
@@ -73,6 +73,8 @@ export interface InstanceExperimentalSettings {
enableChatConnectors: boolean;
/** Exposes MCP aggregator setup; existing connections keep running when hidden. */
enableMcpAggregators: boolean;
/** Show experimental memory connection setup. Existing connections remain usable. */
enableMemoryConnectors: boolean;
enablePipelines: boolean;
enableCases: boolean;
enableAgentChat: boolean;
@@ -53,6 +53,7 @@ export const instanceExperimentalSettingsSchema = z.object({
enableApps: z.boolean().default(true),
enableChatConnectors: z.boolean().default(false),
enableMcpAggregators: z.boolean().default(false),
enableMemoryConnectors: z.boolean().default(false),
enablePipelines: z.boolean().default(false),
enableCases: z.boolean().default(false),
enableAgentChat: z.boolean().default(false),
+128 -125
View File
@@ -5,8 +5,8 @@ settings:
excludeLinksFromLockfile: false
overrides:
'@agentclientprotocol/codex-acp@1.6.2>@openai/codex': 0.153.4
'@agentclientprotocol/claude-agent-acp@0.73.0>@anthropic-ai/claude-agent-sdk': 0.3.263
'@agentclientprotocol/codex-acp@1.6.2>@openai/codex': 0.156.0
'@agentclientprotocol/claude-agent-acp@0.73.0>@anthropic-ai/claude-agent-sdk': 0.3.280
rollup: '>=4.59.0'
react: ^19.2.8
react-dom: ^19.2.8
@@ -19,7 +19,7 @@ patchedDependencies:
hash: riqgguivxjxydoydnzpedjy5au
path: patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch
'@agentclientprotocol/codex-acp@1.6.2':
hash: juyg45ifkipb4jskg7lpym2lj4
hash: rt7c5otjh4is5hljadvoq7qcem
path: patches/@agentclientprotocol__codex-acp@1.6.2.patch
'@chat-adapter/discord@4.39.0':
hash: nywlhls7dd4npegrgax7jqdbxa
@@ -199,7 +199,7 @@ importers:
dependencies:
'@agentclientprotocol/codex-acp':
specifier: ^1.6.2
version: 1.6.2(patch_hash=juyg45ifkipb4jskg7lpym2lj4)
version: 1.6.2(patch_hash=rt7c5otjh4is5hljadvoq7qcem)
'@paperclipai/adapter-utils':
specifier: workspace:*
version: link:../../adapter-utils
@@ -502,10 +502,10 @@ importers:
version: 0.73.0(patch_hash=riqgguivxjxydoydnzpedjy5au)(@anthropic-ai/sdk@0.121.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))
'@agentclientprotocol/codex-acp':
specifier: 1.6.2
version: 1.6.2(patch_hash=juyg45ifkipb4jskg7lpym2lj4)
version: 1.6.2(patch_hash=rt7c5otjh4is5hljadvoq7qcem)
'@openai/codex':
specifier: 0.153.4
version: 0.153.4
specifier: 0.156.0
version: 0.156.0
acpx:
specifier: 0.13.1
version: 0.13.1(patch_hash=ikfco27aeue26z63kex2z5d5ce)
@@ -516,8 +516,8 @@ importers:
specifier: ^3.1.1
version: 3.1.1
opencode-ai:
specifier: 1.18.29
version: 1.18.29
specifier: 1.18.32
version: 1.18.32
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.18)(react@19.2.8)
@@ -1218,6 +1218,9 @@ importers:
specifier: 13.6.32
version: 13.6.32
devDependencies:
'@paperclipai/plugin-sdk':
specifier: workspace:*
version: link:../packages/plugins/sdk
'@sentry/browser':
specifier: 10.71.0
version: 10.71.0
@@ -1288,48 +1291,48 @@ packages:
'@antfu/install-pkg@1.1.0':
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
'@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.263':
resolution: {integrity: sha512-H4eLd4Tkx3rJkt739CHb+9AcaKiiOpibU4tYsmma47mV+2zAPjUyFxpuE2N57VSmpAgbLQxu44du0TFN+UH5dg==}
'@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.280':
resolution: {integrity: sha512-Yws14X5g5hgDtF565Tld/+dILAhH9QZpundjKMyO3dFasq9miXZlhU0NwXqwTO2xP0jDjJ54zduIiJwkcfcvGw==}
cpu: [arm64]
os: [darwin]
'@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.263':
resolution: {integrity: sha512-jKwmfkem1s/TcK7u83cJf2zLHMz846irV4vqYGUlo74uX2qfX57R4UsrCMlcTUMou/U9vQyNCc/Kk0s+zSxYRg==}
'@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.280':
resolution: {integrity: sha512-B1eLx/oZ5RL1c3a13nQ4cRUksWyhGcVNS5vbCHnOVu4Pz+dH0moZ+iY8VaE6FvikVAQwFEW6VDU00b6m3tYdsQ==}
cpu: [x64]
os: [darwin]
'@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.263':
resolution: {integrity: sha512-TiOmi9mqYvu3uGgM+0eQrUQzgXb7pgTzqahtRyEjpJrNigddGoANHcbNKL7P7y4etJpyIuYXGuVh5SLJzySYVA==}
'@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.280':
resolution: {integrity: sha512-uDh+Ggjb36l+jcVgaYxSziGHmBSSR3clqvGlQ0Bgc1v5dLF4igwqZ6ZPW/hS9dArG27fislIOetH1waEgIr+GA==}
cpu: [arm64]
os: [linux]
'@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.263':
resolution: {integrity: sha512-2KPY1wu1hdjpVsw+Tg3i50uhxhhetO1fB24wjUJAQJ8YPSogqO32UbtYPJ6I3F17lBNHb/Mq3nRYGAuSU5yjtg==}
'@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.280':
resolution: {integrity: sha512-6s96OIvBHT2817hCFLIZ421JBpQnvrmZucpjlWsyCODKmwGsSxiEhlWqK2h5Q7lwS6F7bzeAhqjGvC7FzDl9Rw==}
cpu: [arm64]
os: [linux]
'@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.263':
resolution: {integrity: sha512-PWctt/XjebBxtI37i2xx2OMxSscPMnKmxU82MWdtIvoAyJ40U5yfqpm92fm45R02eLn9j4LQpvNocVTcRAHv+g==}
'@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.280':
resolution: {integrity: sha512-IVbIgvi5C0sdLg/p9hpiVvAQ/ikvTKwOOEy5C1aT7w8XLqBgfpJbzzVuk06df1Q0HlILUmf63EKPmq48lbxtqA==}
cpu: [x64]
os: [linux]
'@anthropic-ai/claude-agent-sdk-linux-x64@0.3.263':
resolution: {integrity: sha512-un7HJzTT+DSQLgM33emQ0qHAwIY3CXIwe8JXoD9PaT7pdLepK4Ffa4r5j84pHTVS5uoGqxcf196tBgqb5/Z7FA==}
'@anthropic-ai/claude-agent-sdk-linux-x64@0.3.280':
resolution: {integrity: sha512-vpPyxYLy+zNc7LwQDWsZf0GZtTYa0PJLxzuv2vK9iV2dG+hrbRNTl35NSKKRR9rMj6RpKz0i2NLyVQ+D4mREgw==}
cpu: [x64]
os: [linux]
'@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.263':
resolution: {integrity: sha512-n8owOwNSSi7/KpONb/ut+uXRjBIp9N6EjwADiB+YygPwGRtpWP+PR/qFsGdTVBE5cpPwhgKVAf27xEZEfNvcSQ==}
'@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.280':
resolution: {integrity: sha512-heTflHwciJF+Hrf7v0J53QBzD+9mxN9gt2VVmEJAN0+ejbPcOcqnGGZHvyc9aZUuGh5INN0ECaiL4Fn1X2vXQQ==}
cpu: [arm64]
os: [win32]
'@anthropic-ai/claude-agent-sdk-win32-x64@0.3.263':
resolution: {integrity: sha512-EwqzsOLxIJTX6RIXtQ21ekOKmYBNfwbtGtqaPqdWZGzpMbHaie8kGsM630h9RFaoxeytS/jbg8B3fPCdUKfhPw==}
'@anthropic-ai/claude-agent-sdk-win32-x64@0.3.280':
resolution: {integrity: sha512-N+Y1zJb19nx0oPTvzkl/E3ng9AtWGW+K4hySmSHCgYSsgIwfs1YzDj/F5eHDMrxgklTuzFp76TvL3S75z5ge+g==}
cpu: [x64]
os: [win32]
'@anthropic-ai/claude-agent-sdk@0.3.263':
resolution: {integrity: sha512-0QWoHgWWlSmgXfEqZRbVYRoXT9p4tx/yKaZ4eLJ8l1MR65SIvMGR+cd5ZXUpGYZpj9RzbYiYU9LoFg90mdIjmw==}
'@anthropic-ai/claude-agent-sdk@0.3.280':
resolution: {integrity: sha512-aIQSTKcCJcOgi125GAGQaNZUYgxbEBQwV2Ac+Utp0+gGUjIEWjTqz4B8kXr9XjkcCTYiRUMTDsXPAUfIcBDjnw==}
engines: {node: '>=18.0.0'}
peerDependencies:
'@anthropic-ai/sdk': '>=0.93.0'
@@ -3181,43 +3184,43 @@ packages:
'@octokit/types@18.0.0':
resolution: {integrity: sha512-l6bAF43PNxkJp6g+W4PjoUSSkxHomXw2nOum5CTftJz1NlV3vu93NImgOYtLf6CbBUb5j+fiuzW0PPQ5JTSvZA==}
'@openai/codex@0.153.4':
resolution: {integrity: sha512-wbHDmit7S/YvBGVX1DQmk13xtWblZ2cApeJ/pB7xDZ10Cna+DZc5ij7f0F4OxdsXN4FW1oLT48OpogUI1+8Y2w==}
'@openai/codex@0.156.0':
resolution: {integrity: sha512-47dfpl2e8F47baSQTE+QaWzF/kKoOeR9YQ2fIBdRGzZZP2/QZ6D0uwLY2ZamS8ypov4UFtg92jZh8m+w65bsGg==}
engines: {node: '>=16'}
hasBin: true
'@openai/codex@0.153.4-darwin-arm64':
resolution: {integrity: sha512-B1qhN3fa1ay0R0wGziXqgwSkB5icpYChNKHhtBHff/0UtSTC7z+l8aTtvMlGjH3E8HEvY3+njIJelM9CAAoVWg==}
'@openai/codex@0.156.0-darwin-arm64':
resolution: {integrity: sha512-43/9bryLjRA+g7z1AGeFS8AQiChmG52nIhzMkxjNAXcxEBMVsA88YGBosk0ndThjgTGB42vK82BQV3DR6OBsGQ==}
engines: {node: '>=16'}
cpu: [arm64]
os: [darwin]
'@openai/codex@0.153.4-darwin-x64':
resolution: {integrity: sha512-vnSbbPzfoDZmmyzsxswsDDXQ06IVFBzkQU7/hroB3ji93Ok2utcsq8Psfk2tjF5r9mEx8RWFJhzuTGHG26/NDA==}
'@openai/codex@0.156.0-darwin-x64':
resolution: {integrity: sha512-fD6Nxn7rxinrE6JRxN1liMH3rIV2j9HjcszwMprgs3Ka2p6rR60CmvN03RsWOT4vCcxnA7Evhpnm+bCTNVhH+g==}
engines: {node: '>=16'}
cpu: [x64]
os: [darwin]
'@openai/codex@0.153.4-linux-arm64':
resolution: {integrity: sha512-QKdjYLYV4hXIuUQDP3P6F4NXuWFoKo9WUoV4nAREIx55kiUyi8UsYdsVobkeXir5n/maEQgYMCKLHVma4rNPiw==}
'@openai/codex@0.156.0-linux-arm64':
resolution: {integrity: sha512-IfUrjxm8RAdzPgqlPhUXTJ4mGKKbuTKsNixiXoiRzNkd9kNLj/fUmOQYCcWu+3Dk77b4OH1itnoIRAcFimNzNQ==}
engines: {node: '>=16'}
cpu: [arm64]
os: [linux]
'@openai/codex@0.153.4-linux-x64':
resolution: {integrity: sha512-x1EcwBlY3AObM1VTUHNM2AzAJQsyreGdagpF+qFiYi/Oa30VBktvvG0C6tLtCzqW6hjZNWkGZQWmeVk7MuJKWg==}
'@openai/codex@0.156.0-linux-x64':
resolution: {integrity: sha512-/PX399ISB715skgBBOtOX5aqLxmPQhYC7wDasZnAJhRrtzt3qgs6kjSnimN9T8OFKv4LDAp+uJdN896tQYaTrA==}
engines: {node: '>=16'}
cpu: [x64]
os: [linux]
'@openai/codex@0.153.4-win32-arm64':
resolution: {integrity: sha512-/FBh42976ltF1kxDoPQBg1Q6+hwChRU5/sm5dfeC8kFVQMvOCGoGeY5d8rRZGVJE8XojlXo74VQb0sHowcfgBw==}
'@openai/codex@0.156.0-win32-arm64':
resolution: {integrity: sha512-kDs2Sz+oDAcAm6n5C1TkN6qy7ZNWZiDPM7Uj0Z913I+SFLOYTkF/OB2SWYFG73jF/gKLrkIpWZFdIvFUaMBWfw==}
engines: {node: '>=16'}
cpu: [arm64]
os: [win32]
'@openai/codex@0.153.4-win32-x64':
resolution: {integrity: sha512-lMkB43kJZH0VFr+hoXc11qqR7QtQIbkr07ALgj4urKL1osNyUyuy1iXd3Vzz2iCYvBUCSw7I0l/W1cEPGx9euQ==}
'@openai/codex@0.156.0-win32-x64':
resolution: {integrity: sha512-9y+shxtHJn7yrmZqF4gs9oK1Tte9Heshkda8TrTiAf3bPn24cgOq+avZ+Mad54I3M2R8AFeqt0hT/aS6s5cbAQ==}
engines: {node: '>=16'}
cpu: [x64]
os: [win32]
@@ -7651,69 +7654,69 @@ packages:
resolution: {integrity: sha512-NzwMUB6C1D0+Kd+9iMS/H4k+Ck3cTX6Ckyfr/gAGlmvSE1LUQZnEZvWBi4PYmMwH/S5SMeTXnE+9uAz8uF+pWw==}
engines: {node: '>=20'}
opencode-ai@1.18.29:
resolution: {integrity: sha512-syIDVwlrYTgTOXzZe9SkInJWethbq6l3SNC762UeXyO0a9V0wGfd+U4yACvppwNBnhIsl0j2QPYYCyLpNaSomg==}
opencode-ai@1.18.32:
resolution: {integrity: sha512-SCrZWdq44y/EoH2+fE4HLcXS+DzpVqHPzmXk3p2RrufYy8LWvpfhRhKtijb5ktvx8r94ScqToqwHxR0BwS65OQ==}
cpu: [arm64, x64]
os: [darwin, linux, win32]
hasBin: true
opencode-darwin-arm64@1.18.29:
resolution: {integrity: sha512-EU0qma5GPJXcDp5rENvedSfqJjqxatQW/Qzjs71pR2YdbrSh7YmBwtvnIb2/KIvfQr0DErX4ST14sbBQI2mQdg==}
opencode-darwin-arm64@1.18.32:
resolution: {integrity: sha512-sDsHk/A6FNqeN5NuAiII6AAhi4pJIFcIY0S2yjG9gToIIVaqrQRaHQ6g0kpoVqTiMci8tvaND6tWD4eDT3ahFw==}
cpu: [arm64]
os: [darwin]
opencode-darwin-x64-baseline@1.18.29:
resolution: {integrity: sha512-LJAQWZd4Tixo/IYCM3qYDk+h+OydkcZY6ywL99K67acJzfiAmfzqDKUi7WuQkMrlTCSPoOxkyz3yTPd0GzVeXw==}
opencode-darwin-x64-baseline@1.18.32:
resolution: {integrity: sha512-xXcVF+98Dk5bm3HXFu7R0xjc6zZSbC5LI7AcTWsnwFZIzOmYMjtCg0MwhEs/FPeMY6aGpl0n6IOS4WlQRXAAcw==}
cpu: [x64]
os: [darwin]
opencode-darwin-x64@1.18.29:
resolution: {integrity: sha512-Soyw5WI5kRI3Wbk5eeuSkMw4x+ScXi3SqzITMsi6rnVX0hsAbFCOmKOieYcd8BKJ1aUTrrG0Vv556CFzekk5Bw==}
opencode-darwin-x64@1.18.32:
resolution: {integrity: sha512-ZO/4GTJV/q2jlZ8apN1OfgTdD4m9bkdgLsEWYYDK6O8eTljIFgwhRMrJw1ozucU/D6lLQsA3CX/sS56UtlsTjQ==}
cpu: [x64]
os: [darwin]
opencode-linux-arm64-musl@1.18.29:
resolution: {integrity: sha512-9lNhNvW3FdwbI+BDy/y+0bwIQkbz36u/RVQoZH2tYvWjHkOaf8D+mSuDKbKcFqpLFIbbQona01oYSQZzncZIcQ==}
opencode-linux-arm64-musl@1.18.32:
resolution: {integrity: sha512-G0fHTfilpeJu5VyvBp0H2fl9KnHayePYEibMW1vC0455hQDny+VkFMRKpu6yLYzGbAfjeTqKtRkejrQJD4mLUQ==}
cpu: [arm64]
os: [linux]
opencode-linux-arm64@1.18.29:
resolution: {integrity: sha512-Lr7XXik5wPJZBV5RW+aR6Uogf1n5GogpB565m+D/jiO/vRXr9LhvCpixgAr1tiwuH1ZfMsqOOlbFQz5jgH3Tqg==}
opencode-linux-arm64@1.18.32:
resolution: {integrity: sha512-SDMw716oYxxJ9CWDO5roCpziw98ANwPZSz6L8evUOHkFCq6OU31xZGQwv/T1ROJnoPNJKWODmm1vzS6s6uEgUA==}
cpu: [arm64]
os: [linux]
opencode-linux-x64-baseline-musl@1.18.29:
resolution: {integrity: sha512-A60ew2Xu0gXPwjPYbXoYtkzpUYaLKpgp8dVU5RJpGhT2NBMWBaEyY8O/pcZJFvhPOoEFP9IYmcF2k8Pj4mjDfA==}
opencode-linux-x64-baseline-musl@1.18.32:
resolution: {integrity: sha512-HGa9YBUBdc+qAyC9U8zXUht5qbTufOABOfSwmhukmzl9uMtuGoUovCzilwxytH0mTr/prUT/LAqWcXXoPXjRoA==}
cpu: [x64]
os: [linux]
opencode-linux-x64-baseline@1.18.29:
resolution: {integrity: sha512-AnPvoVjeLC6qBYSeDwmj8fIVeJxspkrh1xAPow5NMSdH7IZZd0auiI2PHIW+0/3B+xfar1Zx1a6yvDd4Wd74Wg==}
opencode-linux-x64-baseline@1.18.32:
resolution: {integrity: sha512-XXO4htNdJEoj935NGMYNGjD1M8ZiJPo2u2cQzIyeBjinoClI7iXOHBPw+Twm/ZuzFHKtmI2594bt0GWIwTetkw==}
cpu: [x64]
os: [linux]
opencode-linux-x64-musl@1.18.29:
resolution: {integrity: sha512-bGAZ9NFzNOzrXVN9oczd1H+vzLH1aGyYg1ZZNtuZiszxKr8+vTjLnNcjzGJIuc6jtjvlWbfp8l+S5fxCVuQo2A==}
opencode-linux-x64-musl@1.18.32:
resolution: {integrity: sha512-ktS7x5Ms+sVMCAb2oRpPKGk10isRjHkwBz6ordNOlx0epx6JQPsTydzJmpLBSdsiFAkfmZoBiYayKDXXU+rwXA==}
cpu: [x64]
os: [linux]
opencode-linux-x64@1.18.29:
resolution: {integrity: sha512-X8/wS/8mzL7Ko0zYYF6RzKax39KkxXDRoimhmzXuo0gPrZX4DQjqBNpPAByBwUjFapk73ZGSVsjDGvoNapBa1Q==}
opencode-linux-x64@1.18.32:
resolution: {integrity: sha512-CIatvoyi8V5a56xyw6ZUnsKxB9wqYIZqfNeqUNWhBBY4aENAm907LP0xXAwL6tR0cYbqNl2+Dvx8mOqXIxbDeQ==}
cpu: [x64]
os: [linux]
opencode-windows-arm64@1.18.29:
resolution: {integrity: sha512-LwDli4EeZIGNnq8vJ9/uLIvsxAY0zLi6+54KsmOk8E1w7121axXpJ/5TpCg4LvmsNXwRKenKJgWzqoDBhy/2mw==}
opencode-windows-arm64@1.18.32:
resolution: {integrity: sha512-AFXHn/kYnbsR/5N7Bm5ysv1vjDkQMaJQxPxJeF1OgGoQV48yBXPLIzhTijbt6IsipNBQxdb6xC0yANefpDSD9Q==}
cpu: [arm64]
os: [win32]
opencode-windows-x64-baseline@1.18.29:
resolution: {integrity: sha512-rUqqp8zbHq5bc3yWPymQxCHgS2Fax07GoEq/KX1R+FE4XNDgL6R+Q1PFqqVDneLd3l0h5qPb+qqsiiWGGEORlA==}
opencode-windows-x64-baseline@1.18.32:
resolution: {integrity: sha512-pXhJumlv2/g/ubgbfW5VtCnLEfdXw2RZO0j1uVhYVtCCYNjioJUPeLT1oMRF06VbkCo1UuLRfwEFqeFXD59N+A==}
cpu: [x64]
os: [win32]
opencode-windows-x64@1.18.29:
resolution: {integrity: sha512-xtWiZNMiwFtBl7q9yMG+XK/BTYGfgFoHLDx4ruWVYCoxjV0NYwjJi6rB9B3xIVbTclzu81YLKNFcT3kNBEG9Yw==}
opencode-windows-x64@1.18.32:
resolution: {integrity: sha512-9uy0owugtP9nkuylzN9+O9zWUIxt4X+KYwsREVk4G27PCfxccj+xIT7yxO9hCVtrF+DVgiJ59xDpiANE9UZrrQ==}
cpu: [x64]
os: [win32]
@@ -9018,16 +9021,16 @@ snapshots:
'@agentclientprotocol/claude-agent-acp@0.73.0(patch_hash=riqgguivxjxydoydnzpedjy5au)(@anthropic-ai/sdk@0.121.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))':
dependencies:
'@agentclientprotocol/sdk': 1.4.0(zod@4.4.3)
'@anthropic-ai/claude-agent-sdk': 0.3.263(@anthropic-ai/sdk@0.121.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3)
'@anthropic-ai/claude-agent-sdk': 0.3.280(@anthropic-ai/sdk@0.121.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3)
zod: 4.4.3
transitivePeerDependencies:
- '@anthropic-ai/sdk'
- '@modelcontextprotocol/sdk'
'@agentclientprotocol/codex-acp@1.6.2(patch_hash=juyg45ifkipb4jskg7lpym2lj4)':
'@agentclientprotocol/codex-acp@1.6.2(patch_hash=rt7c5otjh4is5hljadvoq7qcem)':
dependencies:
'@agentclientprotocol/sdk': 1.4.0(zod@4.4.3)
'@openai/codex': 0.153.4
'@openai/codex': 0.156.0
diff: 9.0.0
open: 11.0.1
vscode-jsonrpc: 9.0.1
@@ -9046,44 +9049,44 @@ snapshots:
package-manager-detector: 1.8.0
tinyexec: 1.3.1
'@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.263':
'@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.280':
optional: true
'@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.263':
'@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.280':
optional: true
'@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.263':
'@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.280':
optional: true
'@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.263':
'@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.280':
optional: true
'@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.263':
'@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.280':
optional: true
'@anthropic-ai/claude-agent-sdk-linux-x64@0.3.263':
'@anthropic-ai/claude-agent-sdk-linux-x64@0.3.280':
optional: true
'@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.263':
'@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.280':
optional: true
'@anthropic-ai/claude-agent-sdk-win32-x64@0.3.263':
'@anthropic-ai/claude-agent-sdk-win32-x64@0.3.280':
optional: true
'@anthropic-ai/claude-agent-sdk@0.3.263(@anthropic-ai/sdk@0.121.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3)':
'@anthropic-ai/claude-agent-sdk@0.3.280(@anthropic-ai/sdk@0.121.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3)':
dependencies:
'@anthropic-ai/sdk': 0.121.0(zod@4.4.3)
'@modelcontextprotocol/sdk': 1.30.0(zod@4.4.3)
zod: 4.4.3
optionalDependencies:
'@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.263
'@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.263
'@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.263
'@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.263
'@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.263
'@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.263
'@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.263
'@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.263
'@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.280
'@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.280
'@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.280
'@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.280
'@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.280
'@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.280
'@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.280
'@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.280
'@anthropic-ai/sdk@0.121.0(zod@4.4.3)':
dependencies:
@@ -11183,31 +11186,31 @@ snapshots:
dependencies:
'@octokit/openapi-types': 29.0.1
'@openai/codex@0.153.4':
'@openai/codex@0.156.0':
optionalDependencies:
'@openai/codex-darwin-arm64': '@openai/codex@0.153.4-darwin-arm64'
'@openai/codex-darwin-x64': '@openai/codex@0.153.4-darwin-x64'
'@openai/codex-linux-arm64': '@openai/codex@0.153.4-linux-arm64'
'@openai/codex-linux-x64': '@openai/codex@0.153.4-linux-x64'
'@openai/codex-win32-arm64': '@openai/codex@0.153.4-win32-arm64'
'@openai/codex-win32-x64': '@openai/codex@0.153.4-win32-x64'
'@openai/codex-darwin-arm64': '@openai/codex@0.156.0-darwin-arm64'
'@openai/codex-darwin-x64': '@openai/codex@0.156.0-darwin-x64'
'@openai/codex-linux-arm64': '@openai/codex@0.156.0-linux-arm64'
'@openai/codex-linux-x64': '@openai/codex@0.156.0-linux-x64'
'@openai/codex-win32-arm64': '@openai/codex@0.156.0-win32-arm64'
'@openai/codex-win32-x64': '@openai/codex@0.156.0-win32-x64'
'@openai/codex@0.153.4-darwin-arm64':
'@openai/codex@0.156.0-darwin-arm64':
optional: true
'@openai/codex@0.153.4-darwin-x64':
'@openai/codex@0.156.0-darwin-x64':
optional: true
'@openai/codex@0.153.4-linux-arm64':
'@openai/codex@0.156.0-linux-arm64':
optional: true
'@openai/codex@0.153.4-linux-x64':
'@openai/codex@0.156.0-linux-x64':
optional: true
'@openai/codex@0.153.4-win32-arm64':
'@openai/codex@0.156.0-win32-arm64':
optional: true
'@openai/codex@0.153.4-win32-x64':
'@openai/codex@0.156.0-win32-x64':
optional: true
'@opentelemetry/api@1.9.1': {}
@@ -15712,55 +15715,55 @@ snapshots:
powershell-utils: 0.2.0
wsl-utils: 1.0.0
opencode-ai@1.18.29:
opencode-ai@1.18.32:
optionalDependencies:
opencode-darwin-arm64: 1.18.29
opencode-darwin-x64: 1.18.29
opencode-darwin-x64-baseline: 1.18.29
opencode-linux-arm64: 1.18.29
opencode-linux-arm64-musl: 1.18.29
opencode-linux-x64: 1.18.29
opencode-linux-x64-baseline: 1.18.29
opencode-linux-x64-baseline-musl: 1.18.29
opencode-linux-x64-musl: 1.18.29
opencode-windows-arm64: 1.18.29
opencode-windows-x64: 1.18.29
opencode-windows-x64-baseline: 1.18.29
opencode-darwin-arm64: 1.18.32
opencode-darwin-x64: 1.18.32
opencode-darwin-x64-baseline: 1.18.32
opencode-linux-arm64: 1.18.32
opencode-linux-arm64-musl: 1.18.32
opencode-linux-x64: 1.18.32
opencode-linux-x64-baseline: 1.18.32
opencode-linux-x64-baseline-musl: 1.18.32
opencode-linux-x64-musl: 1.18.32
opencode-windows-arm64: 1.18.32
opencode-windows-x64: 1.18.32
opencode-windows-x64-baseline: 1.18.32
opencode-darwin-arm64@1.18.29:
opencode-darwin-arm64@1.18.32:
optional: true
opencode-darwin-x64-baseline@1.18.29:
opencode-darwin-x64-baseline@1.18.32:
optional: true
opencode-darwin-x64@1.18.29:
opencode-darwin-x64@1.18.32:
optional: true
opencode-linux-arm64-musl@1.18.29:
opencode-linux-arm64-musl@1.18.32:
optional: true
opencode-linux-arm64@1.18.29:
opencode-linux-arm64@1.18.32:
optional: true
opencode-linux-x64-baseline-musl@1.18.29:
opencode-linux-x64-baseline-musl@1.18.32:
optional: true
opencode-linux-x64-baseline@1.18.29:
opencode-linux-x64-baseline@1.18.32:
optional: true
opencode-linux-x64-musl@1.18.29:
opencode-linux-x64-musl@1.18.32:
optional: true
opencode-linux-x64@1.18.29:
opencode-linux-x64@1.18.32:
optional: true
opencode-windows-arm64@1.18.29:
opencode-windows-arm64@1.18.32:
optional: true
opencode-windows-x64-baseline@1.18.29:
opencode-windows-x64-baseline@1.18.32:
optional: true
opencode-windows-x64@1.18.29:
opencode-windows-x64@1.18.32:
optional: true
os-paths@4.4.0: {}
@@ -63,13 +63,13 @@ test("canary reuses exact-source proof while stable keeps full verification", ()
test("source proof requires every source check and does not wait on image publication", () => {
const readiness = readWorkflow("cloud-readiness.yml");
const proof = readiness.split(" source_verified:\n")[1].split("\n ready:")[0];
const proof = readiness.split(" source_verified:\n")[1];
assert.match(proof, /name: Cloud source verified v1/);
assert.match(proof, /needs: \[verify\]/);
assert.match(proof, /node --test scripts\/cloud-source-verification.test.mjs/);
assert.match(proof, /SOURCE_SHA: \$\{\{ github\.sha \}\}/);
assert.doesNotMatch(proof, /always\(\)|continue-on-error|needs:.*(?:image|artifacts)/);
assert.match(readiness.split(" ready:\n")[1], /needs: \[verify, image, artifacts\]/);
assert.doesNotMatch(readiness, /^ (?:image|artifacts|ready):/m);
});
test("onboard smoke container binds beyond loopback so the mapped port is reachable", () => {
@@ -433,12 +433,13 @@ test("Runner eval workflows pin actions and gate paid live execution", () => {
});
test("direct Grok qualification installs the pinned binary and scopes its API key", () => {
test("direct Grok qualification installs the pinned binary and scopes the selected credential", () => {
const workflow = readWorkflow("runner-protocol-live-evals.yml");
assert.ok(workflow.includes("XAI_API_KEY: ${{ matrix.credentialName == 'XAI_API_KEY' && secrets.XAI_API_KEY || '' }}"));
assert.ok(workflow.includes("if [ -f packages/grok-acp/install.mjs ]; then"));
assert.ok(workflow.indexOf("node packages/grok-acp/install.mjs") < workflow.indexOf("pnpm --filter @paperclipai/paperclip-runner deploy --prod"));
assert.ok(!workflow.includes("secrets.GROK_AUTH_JSON"));
assert.ok(workflow.includes("PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET: ${{ matrix.credentialName == 'PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET' && secrets.GROK_AUTH_JSON || '' }}"));
assert.equal((workflow.match(/secrets\.GROK_AUTH_JSON/gu) ?? []).length, 1);
});
test("direct protocol concurrency override only lowers the configured ceiling", () => {
-102
View File
@@ -1,102 +0,0 @@
#!/usr/bin/env node
import { pathToFileURL } from "node:url";
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { imageExists, versionFor } from "./preview-artifacts.mjs";
import { verifyPublished } from "./cloud-migrator-artifacts.mjs";
const repository = "paperclipai/paperclip";
const workflow = ".github/workflows/cloud-migrator-artifacts.yml";
export async function migratorPublished(sha, fetchImpl, token) {
let pending = false;
const failures = [];
for (let page = 1; page <= 10; page++) {
const response = await fetchImpl(`https://api.github.com/repos/${repository}/actions/workflows/cloud-migrator-artifacts.yml/runs?branch=master&head_sha=${sha}&per_page=100&page=${page}`, {
headers: { Accept: "application/vnd.github+json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
redirect: "error", signal: AbortSignal.timeout(30_000),
});
if (!response.ok) throw new Error(`Migrator producer lookup failed: HTTP ${response.status}`);
const body = await response.json();
if (!Array.isArray(body.workflow_runs) || !Number.isSafeInteger(body.total_count) || body.total_count < 0 ||
(page === 1 && (body.total_count === 0) !== (body.workflow_runs.length === 0))) throw new Error("Invalid migrator producer response.");
if (body.total_count === 0) return false;
for (const run of body.workflow_runs) {
if (run.head_sha !== sha || run.head_branch !== "master" || run.path !== workflow ||
run.head_repository?.id !== 1170821064 || run.head_repository.full_name !== repository ||
!["push", "workflow_dispatch"].includes(run.event)) throw new Error("Migrator producer identity mismatch.");
// Publication is immutable. A later failed manual run must not hide a
// successful exact-source publisher; the signed bundle is checked next.
if (run.status === "completed" && run.conclusion === "success") return true;
if (run.status !== "completed") pending = true;
else failures.push(`${run.id}: ${run.conclusion}`);
}
if (page * 100 >= body.total_count) {
if (pending) return false;
throw new Error(`Migrator producers failed: ${failures.join(", ")}.`);
}
}
throw new Error("Too many migrator producer runs to establish publication.");
}
export function verifyManifestProvenance(bytes, sha, { exec = execFileSync } = {}) {
versionFor(sha);
const scratch = mkdtempSync(path.join(os.tmpdir(), "cloud-readiness-attestation-"));
try {
const file = path.join(scratch, "manifest.json");
writeFileSync(file, bytes);
exec("gh", ["attestation", "verify", file, "--repo", repository,
"--source-digest", sha, "--source-ref", "refs/heads/master",
"--cert-identity", `https://github.com/${repository}/${workflow}@refs/heads/master`,
"--deny-self-hosted-runners"], { stdio: "inherit", timeout: 60_000 });
} finally { rmSync(scratch, { recursive: true, force: true }); }
}
/** Read-only availability gate. Deployment still resolves and pins artifacts. */
export async function waitForCloudArtifacts(sha, {
fetchImpl = fetch,
token = process.env.GH_TOKEN,
verifyProvenance = verifyManifestProvenance,
now = () => performance.now(),
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
timeoutMs = 30 * 60_000,
intervalMs = 20_000,
log = console.log,
} = {}) {
const version = versionFor(sha);
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || !Number.isFinite(intervalMs) || intervalMs <= 0) {
throw new Error("Cloud readiness requires positive finite timeout and poll interval.");
}
const deadline = now() + timeoutMs;
let previous;
let missing = ["image", "migrator"];
while (now() < deadline) {
// Recheck the image and exact-source publisher on the successful poll.
// Only a missing/pending producer waits; failed publication fails closed.
const results = await Promise.all([
imageExists(sha, fetchImpl),
migratorPublished(sha, fetchImpl, token),
]);
missing = ["image", "migrator"].filter((_, index) => !results[index]);
if (missing.length === 0) {
// Verify the exact signed bytes and all pinned downloads after the
// publisher succeeds. An inaccessible or corrupt artifact cannot pass.
await verifyPublished(sha, fetchImpl, { verifyProvenance });
log(`Cloud artifacts available for ${sha}: verified image and exact-source migrator ${version}.`);
return { version: 1, sha, packageVersion: version };
}
const state = missing.join(", ");
if (state !== previous) log(`Waiting for cloud artifacts for ${sha}: ${state}.`);
previous = state;
const remaining = deadline - now();
if (remaining > 0) await sleep(Math.min(intervalMs, remaining));
}
throw new Error(`Cloud artifacts timed out for ${sha}; missing: ${missing.join(", ")}.`);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try { await waitForCloudArtifacts(process.argv[2]); }
catch (error) { console.error(error.message); process.exitCode = 1; }
}
+48 -3
View File
@@ -920,6 +920,9 @@ const categoryBySlug = {
make: "productivity",
manufact: "productivity",
mem0: "ai",
zep: "ai",
supermemory: "ai",
honcho: "ai",
miro: "productivity",
mixpanel: "analytics",
netlify: "developer",
@@ -995,7 +998,7 @@ const apiKeySpec = {
prefix: null,
placeholder: "Paste your Kernel API key",
},
mem0: { name: "Authorization", prefix: "Bearer ", placeholder: "m0sk_..." },
mem0: { name: "Authorization", prefix: "Bearer ", placeholder: "Paste your Mem0 API key" },
oreilly: {
name: "Authorization",
prefix: "Bearer ",
@@ -1073,6 +1076,23 @@ const apiKeyMethodFor = (
);
};
const specialMethodsFor = (entry) => {
if (entry.slug === "mem0" || entry.slug === "honcho") return [
apiKeyMethodFor(entry, "mcp-api-key", entry.serverUrl, {
guidanceMd: `Open the ${entry.name} dashboard, create an API key for the account agents should use, and paste it below.`,
consoleLinks: { keys: entry.slug === "mem0" ? "https://app.mem0.ai/dashboard/api-keys" : "https://app.honcho.dev", docs: entry.docsUrl },
}),
];
if (entry.slug === "zep") return [oauthMethodFor(entry, "mcp-oauth", entry.serverUrl, {
grantKinds: ["user"],
defaults: { serverUrl: entry.serverUrl, scopesHint: ["graph:read", "graph:write"] },
guidanceMd: "Sign in with the work identity configured for your Zep project's Memory MCP server. Zep restricts access to that identity's memory and authorized shared graphs.",
consoleLinks: { settings: "https://app.getzep.com", docs: entry.docsUrl },
})];
if (entry.slug === "supermemory") return [oauthMethodFor(entry, "mcp-oauth", entry.serverUrl, {
grantKinds: ["user"],
defaults: { serverUrl: entry.serverUrl, scopesHint: ["openid", "profile", "email", "offline_access"] },
guidanceMd: "Sign in to Supermemory, then choose a workspace, read or write access, and optional tags. Use a separate space for unrelated work.",
})];
if (entry.slug === "fireflies")
return [
oauthMethodFor(entry, "mcp-oauth", entry.serverUrl, {
@@ -1379,6 +1399,31 @@ const specialMethodsFor = (entry) => {
return null;
};
// Cognee Cloud publishes a local MCP client, not a hosted remote MCP endpoint.
// This approved, pinned template uses the ordinary vault and stdio gateway.
apps.push({
schemaVersion: 1, slug: "cognee", name: "Cognee",
description: "Build and recall shared graph memory from documents and conversations.",
categories: ["ai"], branding: brandingFor("cognee"),
urlPatterns: ["https://*.aws.cognee.ai/*"],
docsUrl: "https://docs.cognee.ai/cognee-cloud/connections/cloud-mcp",
setupPrerequisite: {
title: "Cognee Cloud and a local runtime",
description: "Use your Cognee Cloud tenant API URL and key. Paperclip's runtime host needs uv installed to run the official Cognee MCP client. Public deployments require a trusted MCP runtime host.",
actionLabel: "Open Cognee API keys", actionUrl: "https://platform.cognee.ai/api-keys",
},
methods: [method("cloud-local", "local_stdio", "api_key", { templateKey: "paperclip.cognee-cloud" }, "S3",
"Copy the API Base URL and create an API key on Cognee's API Keys page. Use a Cloud workspace with an active subscription. Paperclip uses its bundled Cloud client; no extra runtime installation is required.", {
label: "Connect Cognee Cloud", whenToUse: "Connect your Cloud tenant through the official Cognee MCP client.",
credentialFields: [
{ key: "COGNEE_BASE_URL", label: "API Base URL", type: "text", required: true, secret: false, placeholder: "https://your-tenant.aws.cognee.ai", validation: { pattern: "^https://[a-zA-Z0-9-]+\\.aws\\.cognee\\.ai/?$", maxLength: 255 }, helperMd: "Copy API Base URL from Cognee's API Keys page." },
{ ...field("COGNEE_API_KEY", "Cognee API key", "Paste your Cognee API key"), helperMd: "Create a key in Cognee → API Keys. The key is shown once." },
],
keyPlacement: { location: "env", name: "COGNEE_API_KEY" },
consoleLinks: { keys: "https://platform.cognee.ai/api-keys", docs: "https://docs.cognee.ai/cognee-cloud/connections/cloud-mcp" },
})],
});
for (const entry of researchManifest.entries) {
const existing = apps.find((app) => app.slug === entry.slug);
if (entry.status === "blocked") {
@@ -1426,9 +1471,9 @@ for (const entry of researchManifest.entries) {
schemaVersion: 1,
slug: entry.slug,
name: entry.name,
description: entry.slug === "fireflies"
description: ({ mem0: "Remember preferences, conversations, events, and agent state.", zep: "Retrieve temporal graph memory and authorized business context.", supermemory: "Search and save shared memories, documents, and profiles.", honcho: "Remember conversations and retrieve context about peers." })[entry.slug] ?? (entry.slug === "fireflies"
? "Search meeting transcripts, read summaries and action items, and connect meeting-ready routines."
: `Connect ${entry.name}'s provider-hosted MCP server.`,
: `Connect ${entry.name}'s provider-hosted MCP server.`),
categories: [categoryBySlug[entry.slug] ?? "other"],
featured: entry.slug === "jira",
branding: brandingFor(entry.slug),
-134
View File
@@ -4,9 +4,7 @@ import { planArtifacts } from "./preview-artifacts.mjs";
import { readFileSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { gzipSync } from "node:zlib";
import { execFileSync, spawnSync } from "node:child_process";
import { previewManifest, assertMetadata, validateRequest, versionFor, tarManifest, packageExists, imageExists, publishPreview, publishImage } from "./preview-artifacts.mjs";
const sha = "a".repeat(40);
@@ -222,135 +220,3 @@ test("commits sharing a short prefix use separate full-SHA image addresses", asy
await imageExists(other, fetchImpl);
assert.deepEqual(urls.filter((url) => url.includes("/manifests/")), [sha, other].map((commit) => `https://ghcr.io/v2/paperclipai/paperclip/manifests/sha-${commit}-cloud`));
});
test("cloud builds start per commit and preserve tag promotion dependencies", () => {
const docker = readFileSync(new URL("../.github/workflows/docker.yml", import.meta.url), "utf8");
const cloud = readFileSync(new URL("../.github/workflows/docker-cloud.yml", import.meta.url), "utf8");
const readiness = readFileSync(new URL("../.github/workflows/cloud-readiness.yml", import.meta.url), "utf8");
assert.match(readiness, /branches: \[master\]/);
assert.match(readiness, /uses: \.\/\.github\/workflows\/docker-cloud.yml/);
assert.doesNotMatch(cloud, /^ push:/m);
assert.match(cloud, /workflow_call:/);
assert.match(cloud, /group: docker-cloud-\$\{\{ github.sha \}\}/);
assert.match(cloud, /cancel-in-progress: false/);
assert.doesNotMatch(cloud, /uses: .*@v\d\b/);
assert.match(cloud, /cache-to: type=registry,ref=ghcr.io\/\$\{\{ github.repository \}\}:buildcache-cloud-\$\{\{ github.sha \}\},mode=max/);
const caller = docker.split(" build-and-push-cloud:")[1].split(" promote_canary_channel:")[0];
assert.match(caller, /if: github.event_name != 'push' \|\| github.ref != 'refs\/heads\/master'/);
assert.match(caller, /uses: .\/.github\/workflows\/docker-cloud.yml/);
assert.match(docker.split(" promote_canary_channel:")[1], /needs: \[merge-and-push, build-and-push-cloud\]/);
const reaping = cloud.indexOf(" - name: Verify cloud PID 1 reaps orphaned processes");
assert.ok(reaping > cloud.indexOf(" - name: Verify the pushed image resolves the declared Sentry version"));
assert.ok(reaping < cloud.indexOf(" - name: Publish verified full-SHA cloud tag"));
});
test("cloud builds bake the managed runtime identity and verify it before publication", () => {
const workflow = readFileSync(new URL("../.github/workflows/docker-cloud.yml", import.meta.url), "utf8");
const build = workflow.split(" - name: Build and push (cloud)")[1].split(" - name:")[0];
assert.match(build, /build-args: \|\n\s+USER_UID=1001\n\s+USER_GID=1001\n/);
const verify = workflow.indexOf(" - name: Verify cloud runtime user");
assert.ok(verify > workflow.indexOf(" - name: Verify the pushed image resolves the declared Sentry version"));
assert.ok(verify < workflow.indexOf(" - name: Publish verified full-SHA cloud tag"));
const step = workflow.slice(verify).split("\n - name:")[0];
assert.match(step, /IMAGE: ghcr.io\/\$\{\{ github.repository \}\}@\$\{\{ steps.build-cloud.outputs.digest \}\}/);
assert.doesNotMatch(step, /continue-on-error:|if:/);
assert.ok(step.indexOf('--entrypoint sh "$IMAGE"') < step.indexOf('-e USER_UID=1001 -e USER_GID=1001'));
for (const flag of ["u", "g"]) {
assert.ok(step.includes(`test "$(id -${flag} node)" = 1001`));
assert.ok(step.includes(`test "$(id -${flag})" = 1001`));
}
assert.ok(step.includes('test -w "$PAPERCLIP_HOME"'));
});
test("cloud cache imports are bounded, follow master ancestry, and retain the legacy fallback", () => {
const workflow = readFileSync(new URL("../.github/workflows/docker-cloud.yml", import.meta.url), "utf8");
const selector = workflow.indexOf(" - name: Select cloud cache ancestry");
assert.ok(selector > workflow.indexOf(" - name: Login to GitHub Container Registry"));
assert.ok(selector > workflow.indexOf(" - name: Set up Docker Buildx"));
assert.ok(selector < workflow.indexOf(" - name: Build and push (cloud)"));
assert.match(workflow, /run: node scripts\/select-cloud-cache.mjs/);
assert.match(workflow, /cache-from: \$\{\{ steps.cloud-cache.outputs.source \}\}/);
const script = fileURLToPath(new URL("./select-cloud-cache.mjs", import.meta.url));
const dir = mkdtempSync(path.join(tmpdir(), "cloud-cache-test-"));
const output = path.join(dir, "output");
const env = { ...process.env, GIT_AUTHOR_NAME: "Test", GIT_AUTHOR_EMAIL: "test@example.test", GIT_COMMITTER_NAME: "Test", GIT_COMMITTER_EMAIL: "test@example.test" };
const git = (...args) => execFileSync("git", ["-c", "core.hooksPath=/dev/null", "-c", "commit.gpgsign=false", ...args], { cwd: dir, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
try {
git("init", "--initial-branch=master");
const commits = [];
for (let i = 0; i < 12; i++) {
git("commit", "--allow-empty", "-m", `main ${i}`);
commits.unshift(git("rev-parse", "HEAD"));
}
git("checkout", "-b", "topic", "HEAD~1");
git("commit", "--allow-empty", "-m", "topic");
git("checkout", "master");
git("merge", "--no-ff", "topic", "-m", "merge topic");
commits.unshift(git("rev-parse", "HEAD"));
const available = `ghcr.io/paperclipai/paperclip:buildcache-cloud-${commits[2]}`;
const inspections = path.join(dir, "inspections");
writeFileSync(path.join(dir, "docker"), `#!/usr/bin/env node
const fs = require("node:fs");
fs.appendFileSync(process.env.CACHE_INSPECTIONS, process.argv.at(-1) + "\\n");
if (process.argv.at(-1) !== process.env.AVAILABLE_CACHE) {
process.stderr.write("manifest unknown");
process.exit(1);
}
`, { mode: 0o755 });
const result = spawnSync(process.execPath, [script], {
cwd: dir, encoding: "utf8", env: {
...env, PATH: `${dir}${path.delimiter}${env.PATH}`, CACHE_IMAGE: "ghcr.io/paperclipai/paperclip",
GITHUB_OUTPUT: output, AVAILABLE_CACHE: available, CACHE_INSPECTIONS: inspections,
},
});
assert.equal(result.status, 0, result.stderr);
assert.equal(readFileSync(output, "utf8"), `source=type=registry,ref=${available}\n`);
assert.deepEqual(readFileSync(inspections, "utf8").trim().split("\n"), commits.slice(0, 3).map((commit) => `ghcr.io/paperclipai/paperclip:buildcache-cloud-${commit}`));
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test("normal cloud builds publish the checked digest only when source and platform match", () => {
const workflow = readFileSync(new URL("../.github/workflows/docker-cloud.yml", import.meta.url), "utf8");
const cloud = workflow.split(" build-and-push-cloud:")[1];
const verify = cloud.indexOf(" - name: Verify the pushed image resolves the declared Sentry version");
const publish = cloud.indexOf(" - name: Publish verified full-SHA cloud tag");
assert.ok(verify >= 0 && publish > verify);
const verification = cloud.slice(verify, publish);
assert.match(verification, /IMAGE: ghcr.io\/\$\{\{ github.repository \}\}@\$\{\{ steps.build-cloud.outputs.digest \}\}/);
assert.doesNotMatch(verification, /continue-on-error:|if: always\(/);
const step = cloud.slice(publish).split(/\n(?: #| - name:)/)[0];
assert.doesNotMatch(step, /continue-on-error:|if:/);
assert.match(step, /FULL_SHA_TAG: ghcr.io\/\$\{\{ github.repository \}\}:sha-\$\{\{ github.sha \}\}-cloud/);
const script = step.split(" run: |\n")[1].split("\n").map((line) => line.replace(/^ {10}/, "")).join("\n");
const dir = mkdtempSync(path.join(tmpdir(), "cloud-tag-test-"));
const image = `ghcr.io/paperclipai/paperclip@sha256:${"b".repeat(64)}`;
const tag = `ghcr.io/paperclipai/paperclip:sha-${sha}-cloud`;
try {
writeFileSync(path.join(dir, "docker"), `#!/bin/sh
case "$1 $2" in
'image inspect')
case "$5" in
*revision*) printf '%s\\n' "$TEST_REVISION" ;;
*) printf '%s\\n' "$TEST_PLATFORM" ;;
esac ;;
'buildx imagetools') printf '%s\\n' "$@" > "$TEST_CALLS" ;;
*) exit 99 ;;
esac
`, { mode: 0o755 });
for (const [revision, platform, succeeds] of [[sha, "linux/amd64", true], ["c".repeat(40), "linux/amd64", false], [sha, "linux/arm64", false]]) {
const calls = path.join(dir, "calls");
rmSync(calls, { force: true });
const result = spawnSync("bash", ["-c", script], { encoding: "utf8", env: {
...process.env, PATH: `${dir}${path.delimiter}${process.env.PATH}`, GITHUB_SHA: sha,
IMAGE: image, FULL_SHA_TAG: tag, TEST_REVISION: revision, TEST_PLATFORM: platform, TEST_CALLS: calls,
} });
if (succeeds) {
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(readFileSync(calls, "utf8").trim().split("\n"), ["buildx", "imagetools", "create", "--prefer-index=false", "--tag", tag, image]);
} else {
assert.notEqual(result.status, 0);
assert.throws(() => readFileSync(calls), { code: "ENOENT" });
}
}
} finally { rmSync(dir, { recursive: true, force: true }); }
});
-66
View File
@@ -1,66 +0,0 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { appendFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
export function cloudCacheCandidates(image, commits) {
if (!/^ghcr\.io\/[a-z0-9._-]+\/[a-z0-9._-]+$/.test(image ?? "")) {
throw new Error("Expected a GHCR owner/repository cache image.");
}
if (!Array.isArray(commits) || commits.length === 0 || commits.some((sha) => !/^[a-f0-9]{40}$/.test(sha))) {
throw new Error("Cloud cache ancestry requires full commit SHAs.");
}
return [
...[...new Set(commits)].slice(0, 10).map((sha) => `${image}:buildcache-cloud-${sha}`),
`${image}:buildcache-cloud`,
];
}
export async function selectCloudCache(image, commits, {
exists = registryCacheExists,
log = console.log,
} = {}) {
for (const ref of cloudCacheCandidates(image, commits)) {
try {
if (!await exists(ref)) continue;
log(`Using cloud cache: ${ref}`);
return `type=registry,ref=${ref}`;
} catch {
// Cache availability must not turn an otherwise valid build into a
// failure. A later ancestor may still be available during a rollout.
log(`Could not inspect cloud cache ${ref}; trying the next ancestor.`);
}
}
log("No cloud cache is available; this build will populate one.");
return "";
}
function registryCacheExists(ref) {
try {
// Use the preceding Docker login, including for private registry caches.
// Inspect metadata only: no layer download and no image execution.
execFileSync("docker", ["buildx", "imagetools", "inspect", "--raw", ref], {
timeout: 10_000,
maxBuffer: 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
return true;
} catch (error) {
if (/manifest unknown|not found|NAME_UNKNOWN/i.test(String(error.stderr ?? ""))) return false;
throw error;
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
if (!process.env.GITHUB_OUTPUT) throw new Error("GITHUB_OUTPUT is required.");
const commits = execFileSync("git", ["rev-list", "--first-parent", "--max-count=10", "HEAD"], {
encoding: "utf8",
}).trim().split("\n");
const source = await selectCloudCache(process.env.CACHE_IMAGE, commits, { exists: registryCacheExists });
appendFileSync(process.env.GITHUB_OUTPUT, `source=${source}\n`);
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
-65
View File
@@ -1,65 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { cloudCacheCandidates, selectCloudCache } from "./select-cloud-cache.mjs";
const image = "ghcr.io/paperclipai/paperclip";
const commits = ["a".repeat(40), "b".repeat(40), "c".repeat(40)];
const candidates = cloudCacheCandidates(image, commits);
test("a same-SHA rerun imports only its existing cache", async () => {
const inspected = [];
const source = await selectCloudCache(image, commits, {
exists: async (ref) => { inspected.push(ref); return true; }, log() {},
});
assert.equal(source, `type=registry,ref=${candidates[0]}`);
assert.deepEqual(inspected, candidates.slice(0, 1));
});
test("a new merge imports only its nearest available ancestor", async () => {
const inspected = [];
const source = await selectCloudCache(image, commits, {
exists: async (ref) => { inspected.push(ref); return ref === candidates[1]; }, log() {},
});
assert.equal(source, `type=registry,ref=${candidates[1]}`);
assert.deepEqual(inspected, candidates.slice(0, 2));
assert.equal(source.includes("\n"), false);
});
test("a still-building parent falls back to an older completed cache", async () => {
assert.equal(await selectCloudCache(image, commits, {
exists: async (ref) => ref === candidates[2], log() {},
}), `type=registry,ref=${candidates[2]}`);
});
test("the legacy cache is used only if no SHA cache exists", async () => {
const inspected = [];
assert.equal(await selectCloudCache(image, commits, {
exists: async (ref) => { inspected.push(ref); return ref === candidates.at(-1); }, log() {},
}), `type=registry,ref=${candidates.at(-1)}`);
assert.deepEqual(inspected, candidates);
});
test("missing caches permit a cold build", async () => {
assert.equal(await selectCloudCache(image, commits, { exists: async () => false, log() {} }), "");
});
test("a failed lookup can fall back without failing image publication", async () => {
const messages = [];
assert.equal(await selectCloudCache(image, commits, {
exists: async (ref) => {
if (ref === candidates[0]) throw new Error("registry temporarily unavailable");
return true;
},
log: (message) => messages.push(message),
}), `type=registry,ref=${candidates[1]}`);
assert.match(messages[0], /Could not inspect cloud cache/);
});
test("ancestry is bounded, deduplicated, and rejects output injection", () => {
const many = Array.from({ length: 20 }, (_, i) => i.toString(16).padStart(40, "0"));
assert.equal(cloudCacheCandidates(image, many).length, 11);
assert.deepEqual(cloudCacheCandidates(image, [commits[0], commits[0]]), [candidates[0], candidates.at(-1)]);
assert.throws(() => cloudCacheCandidates(`${image}\nsource=untrusted`, commits));
assert.throws(() => cloudCacheCandidates(image, ["master"]));
assert.throws(() => cloudCacheCandidates(image, []));
});
@@ -40,6 +40,7 @@ import {
inArray,
isNotNull,
like,
notInArray,
or,
sql,
} from "drizzle-orm";
@@ -749,8 +750,9 @@ function uniqueDiscordApplicationId() {
}
function fakeSlackFetch(botId = `U-BOT-${randomUUID()}`) {
return (input: string | URL | Request) => {
return (input: string | URL | Request, init?: RequestInit) => {
const url = String(input);
const args = new URLSearchParams(String(init?.body ?? new URL(url).search));
if (url === "https://slack.com/api/auth.test") {
return Promise.resolve(
new Response(
@@ -784,7 +786,7 @@ function fakeSlackFetch(botId = `U-BOT-${randomUUID()}`) {
);
}
if (url.startsWith("https://slack.com/api/conversations.info")) {
const channelId = new URL(url).searchParams.get("channel");
const channelId = args.get("channel");
return Promise.resolve(
new Response(
JSON.stringify({
@@ -800,6 +802,9 @@ function fakeSlackFetch(botId = `U-BOT-${randomUUID()}`) {
),
);
}
if (url === "https://slack.com/api/users.info") {
return Promise.resolve(Response.json({ ok: true, user: { id: args.get("user"), team_id: "T-PAPERCLIP" } }));
}
if (url === "https://slack.com/api/agents.sessions.setStatus") {
return Promise.resolve(Response.json({ ok: true }));
}
@@ -1043,19 +1048,26 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
try {
await Promise.all([...fixtureServices].map((service) => service.shutdown()));
} finally {
if (fixtureCompanies.size > 0) {
await db.update(chatEndpoints).set({ status: "paused" })
.where(and(inArray(chatEndpoints.companyId, [...fixtureCompanies]), eq(chatEndpoints.status, "active")));
// The milestone scanner also considers paused endpoints while their
// conversations are active. Retire those bindings after assertions.
await db.update(chatConversations).set({ state: "completed" })
.where(and(inArray(chatConversations.companyId, [...fixtureCompanies]), inArray(chatConversations.state, ["active", "waiting"])));
}
await retireFixtureState([...fixtureCompanies]);
fixtureServices.clear();
fixtureCompanies.clear();
}
});
async function retireFixtureState(companyIds: string[]) {
if (companyIds.length === 0) return;
// Verifying endpoints can also own receipts. Once a receipt is 60s old,
// another test's global sweep can reclaim it. Retire every live endpoint,
// then release synthetic lease owners after their services have stopped.
await db.update(chatEndpoints).set({ status: "paused" })
.where(and(inArray(chatEndpoints.companyId, companyIds), notInArray(chatEndpoints.status, ["archived", "revoked"])));
await db.delete(chatEndpointLeases)
.where(inArray(chatEndpointLeases.companyId, companyIds));
// The milestone scanner includes paused endpoints with active bindings.
await db.update(chatConversations).set({ state: "completed" })
.where(and(inArray(chatConversations.companyId, companyIds), inArray(chatConversations.state, ["active", "waiting"])));
}
async function seedCompany() {
const companyId = randomUUID();
fixtureCompanies.add(companyId);
@@ -1549,7 +1561,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
async function configuredSlackEndpoint(
fixture: Awaited<ReturnType<typeof seedCompany>>,
overrides: { allowUnlinkedPeople?: boolean } & Partial<
overrides: { allowUnlinkedPeople?: boolean; linkedBoardUser?: boolean } & Partial<
Pick<
ChatChannelServiceOptions,
| "credentialMutationLeaseRenewalIntervalMs"
@@ -1604,6 +1616,10 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
},
"owner-user",
);
if (overrides.linkedBoardUser) {
const [principal] = await db.insert(chatExternalPrincipals).values({ companyId: fixture.companyId, provider: "slack", providerAccountId: "T-PAPERCLIP", externalId: "UBOARD" }).returning();
await db.insert(chatIdentityLinks).values({ companyId: fixture.companyId, endpointId: endpoint.id, principalId: principal.id, paperclipUserId: "owner-user", status: "linked", confirmedAt: new Date() });
}
await recordSlackUrlVerification(context.service, endpoint.publicId);
await context.service.configure(
endpoint.id,
@@ -1715,6 +1731,64 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
};
}
it("retires a stale verifying receipt before another fixture runs recovery", async () => {
const fixture = await seedCompany();
const context = await configuredSlackEndpoint(fixture);
const thread = makeThread({ channelId: "C-FIXTURE-RETIREMENT", id: "slack:C-FIXTURE-RETIREMENT:7001.1" });
await deliverMessage({
callbacks: context.callbacks,
endpointId: context.endpoint.id,
thread: thread.thread,
message: makeMessage({ id: "7001.1", text: "@maya check fixture recovery", mentioned: true }),
trigger: "mention",
});
await context.service.shutdown();
const [receipt] = await db.select().from(chatActions).where(and(
eq(chatActions.endpointId, context.endpoint.id),
eq(chatActions.kind, "receipt_reaction"),
));
expect(receipt).toBeDefined();
await db.update(chatActions).set({ status: "processing", updatedAt: new Date(Date.now() - 61_000) })
.where(eq(chatActions.id, receipt!.id));
await db.insert(chatEndpointLeases).values({
companyId: fixture.companyId,
endpointId: context.endpoint.id,
leaseKey: "credentials",
token: "retired-fixture-owner",
expiresAt: new Date(Date.now() + 90_000),
});
const other = await seedCompany();
const next = createService();
const otherEndpoint = await next.service.create(other.companyId, {
provider: "slack", assignedAgentId: other.assignedAgentId,
}, "owner-user");
const [otherLease] = await db.insert(chatEndpointLeases).values({
companyId: other.companyId,
endpointId: otherEndpoint.id,
leaseKey: "credentials",
token: "other-fixture-owner",
expiresAt: new Date(Date.now() + 90_000),
}).returning();
let recovery: Promise<number> | undefined;
try {
await retireFixtureState([fixture.companyId]);
recovery = next.service.processPendingDeliveries();
await expect.poll(async () => db.select({ status: chatActions.status }).from(chatActions)
.where(eq(chatActions.id, receipt!.id)), { timeout: 5_000 })
.toEqual([{ status: "cancelled" }]);
await recovery;
expect(next.runtime.endpoints.size).toBe(0);
expect(await db.select().from(chatEndpointLeases).where(eq(chatEndpointLeases.id, otherLease!.id)))
.toEqual([otherLease]);
} finally {
// Release synthetic owners even when a regression blocks recovery.
await db.delete(chatEndpointLeases).where(inArray(chatEndpointLeases.companyId, [fixture.companyId, other.companyId]));
await recovery;
await next.service.shutdown();
}
});
async function configuredTeamsEndpoint(
fixture: Awaited<ReturnType<typeof seedCompany>>,
overrides: Partial<
@@ -3936,6 +4010,282 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
await request(app).patch(`/api/chat-endpoints/${github.id}`).send({ communicationInstructions: "Not enabled" }).expect(422);
});
it("mirrors Paperclip messages with user attribution and returns only the selected Slack reply", async () => {
await instanceSettingsService(db).updateExperimental({ enableChatConnectors: true });
const fixture = await seedCompany();
const { callbacks, endpoint, runtime, service, wakeup } = await configuredSlackEndpoint(fixture);
const channel = makeThread({ channelId: "CBOARD", id: "slack:CBOARD:8000.1", name: "board" });
await deliverMessage({ callbacks, endpointId: endpoint.id, thread: channel.thread,
message: makeMessage({ id: "8000.1", text: "@maya start here", mentioned: true }), trigger: "mention" });
await db.update(chatEndpoints).set({ status: "active" }).where(eq(chatEndpoints.id, endpoint.id));
const [conversation] = await service.listConversations(endpoint.id);
runtime.endpoints.get(endpoint.id)!.posts.length = 0;
wakeup.mockClear();
const [boardPrincipal] = await db.insert(chatExternalPrincipals).values({ companyId: fixture.companyId, provider: "slack", providerAccountId: "T-PAPERCLIP", externalId: "UBOARD" }).returning();
await db.insert(chatIdentityLinks).values({ companyId: fixture.companyId, endpointId: endpoint.id, principalId: boardPrincipal.id, paperclipUserId: "owner-user", status: "linked", confirmedAt: new Date() });
const clientRequestId = randomUUID();
const comment = await issueService(db).addComment(conversation.issueId, "Please make the plan", { userId: "owner-user" },
{ authorType: "user", mirrorToSlack: true, clientRequestId });
expect((await issueService(db).addComment(conversation.issueId, "Please make the plan", { userId: "owner-user" },
{ authorType: "user", mirrorToSlack: true, clientRequestId })).id).toBe(comment.id);
await service.processPendingPublications();
expect(runtime.endpoints.get(endpoint.id)!.posts).toEqual([
{ threadId: channel.thread.id, text: "**Owner User (via Paperclip)**\n\nPlease make the plan" },
]);
const runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId: fixture.companyId, agentId: fixture.assignedAgentId,
status: "running", responsibleUserId: "owner-user", contextSnapshot: { issueId: conversation.issueId, source: "issue.comment", wakeCommentId: comment.id } });
await initializeRunIdentity(db, { companyId: fixture.companyId, runId, issueId: conversation.issueId, responsibleUserId: "owner-user", messageIds: [comment.id], cause: "user_message" });
const { resolveSlackTaskAuthority } = await import("../services/connectors/slack-authority.js");
await expect(resolveSlackTaskAuthority(db, { companyId: fixture.companyId, agentId: fixture.assignedAgentId, runId, issueId: conversation.issueId, endpointId: endpoint.id })).resolves.toMatchObject({
slackUserId: "UBOARD", conversation: { id: conversation.id }, deliveryId: null,
});
await issueService(db).addComment(conversation.issueId, "Internal bookkeeping", { agentId: fixture.assignedAgentId, runId }, { authorizationReason: "internal_agent_write" });
await addSelectedChatFinal({ companyId: fixture.companyId, issueId: conversation.issueId, agentId: fixture.assignedAgentId, runId, body: "The plan is ready." });
await service.processPendingPublications();
await service.processPendingPublications();
expect(runtime.endpoints.get(endpoint.id)!.posts.map(post => post.text)).toEqual([
"**Owner User (via Paperclip)**\n\nPlease make the plan", "The plan is ready.",
]);
// The explicit Board composer uses the same mirror and a durable wakeup.
const key = randomUUID();
await service.publishBoardMessage(endpoint.id, conversation.id, "Now assign the follow-ups", key, "owner-user");
await service.publishBoardMessage(endpoint.id, conversation.id, "Now assign the follow-ups", key, "owner-user");
expect(wakeup).toHaveBeenCalledTimes(1);
expect(wakeup).toHaveBeenCalledWith(fixture.assignedAgentId, expect.objectContaining({ requestedByActorId: "owner-user", contextSnapshot: expect.objectContaining({ source: "issue.comment", taskKey: (await issueService(db).getById(conversation.issueId))!.identifier, wakeCommentId: expect.any(String) }) }));
expect(runtime.endpoints.get(endpoint.id)!.posts.filter(post => post.text.includes("Now assign the follow-ups"))).toHaveLength(1);
const ordinary = await issueService(db).addComment(conversation.issueId, "Private system note", { agentId: fixture.assignedAgentId }, { authorType: "agent" });
expect(await db.select().from(chatPublications).where(eq(chatPublications.commentId, ordinary.id))).toEqual([]);
// A scheduler outage preserves the Board request for the delivery worker.
wakeup.mockRejectedValueOnce(new Error("temporary scheduler failure"));
await service.publishBoardMessage(endpoint.id, conversation.id, "Retry this work", randomUUID(), "owner-user");
expect(await db.select().from(chatActions).where(and(eq(chatActions.endpointId, endpoint.id), eq(chatActions.kind, "slack_board_message"), eq(chatActions.status, "received")))).toHaveLength(1);
await service.processPendingDeliveries();
await service.processPendingPublications();
expect(await db.select().from(chatActions).where(and(eq(chatActions.endpointId, endpoint.id), eq(chatActions.kind, "slack_board_message"), eq(chatActions.status, "received")))).toHaveLength(0);
expect(runtime.endpoints.get(endpoint.id)!.posts.filter(post => post.text.includes("Retry this work"))).toHaveLength(1);
});
it.each(["revoked", "relinked", "private-membership", "later-cancelled"] as const)("rechecks %s authority before queued Slack Board messages and replies", async (change) => {
await instanceSettingsService(db).updateExperimental({ enableChatConnectors: true });
const fixture = await seedCompany();
let canRead = true;
const fallback = fakeSlackFetch();
const fetchImpl = (async (input, init) => {
const url = String(input);
const args = new URLSearchParams(String(init?.body ?? new URL(url).search));
if (url.includes("conversations.info")) return Response.json({ ok: true, channel: { id: args.get("channel"), is_member: true, is_private: true } });
if (url.includes("conversations.members")) return Response.json({ ok: true, members: canRead ? ["UBOARD"] : [] });
return fallback(input, init);
}) as typeof fetch;
const { callbacks, endpoint, runtime, service } = await configuredSlackEndpoint(fixture, { fetch: fetchImpl });
const channel = makeThread({ channelId: "CBOARD", id: "slack:CBOARD:8000.1", name: "board" });
await deliverMessage({ callbacks, endpointId: endpoint.id, thread: channel.thread,
message: makeMessage({ id: "8000.1", text: "@maya start here", mentioned: true }), trigger: "mention" });
await db.update(chatEndpoints).set({ status: "active" }).where(eq(chatEndpoints.id, endpoint.id));
const [conversation] = await service.listConversations(endpoint.id);
runtime.endpoints.get(endpoint.id)!.posts.length = 0;
await expect(issueService(db).addComment(conversation.issueId, "Unlinked Board message", { userId: "owner-user" },
{ authorType: "user", mirrorToSlack: true })).rejects.toMatchObject({ status: 403 });
const [principal] = await db.insert(chatExternalPrincipals).values({ companyId: fixture.companyId, provider: "slack", providerAccountId: "T-PAPERCLIP", externalId: "UBOARD" }).returning();
const [link] = await db.insert(chatIdentityLinks).values({ companyId: fixture.companyId, endpointId: endpoint.id, principalId: principal.id, paperclipUserId: "owner-user", status: "linked", confirmedAt: new Date() }).returning();
const comment = await issueService(db).addComment(conversation.issueId, "Queued before access changed", { userId: "owner-user" }, { authorType: "user", mirrorToSlack: true });
const runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId: fixture.companyId, agentId: fixture.assignedAgentId, status: "running", responsibleUserId: "owner-user", contextSnapshot: { issueId: conversation.issueId, source: "issue.comment", wakeCommentId: comment.id } });
await initializeRunIdentity(db, { companyId: fixture.companyId, runId, issueId: conversation.issueId, responsibleUserId: "owner-user", messageIds: [comment.id], cause: "user_message" });
await addSelectedChatFinal({ companyId: fixture.companyId, issueId: conversation.issueId, agentId: fixture.assignedAgentId, runId, body: "Queued final before access changed" });
expect(await db.select().from(chatPublications).where(eq(chatPublications.endpointId, endpoint.id))).toHaveLength(2);
if (change === "later-cancelled") {
const later = await issueService(db).addComment(conversation.issueId, "A different, cancelled request", { userId: "owner-user" }, { authorType: "user", mirrorToSlack: true });
await db.update(chatActions).set({ status: "cancelled" }).where(and(eq(chatActions.endpointId, endpoint.id), eq(sql<string>`${chatActions.payload}->>'commentId'`, later.id)));
await db.update(chatPublications).set({ state: "cancelled" }).where(eq(chatPublications.commentId, later.id));
} else if (change === "private-membership") canRead = false;
else {
await db.update(chatIdentityLinks).set({ status: "revoked", revokedAt: new Date() }).where(eq(chatIdentityLinks.id, link.id));
if (change === "relinked") {
const [other] = await db.insert(chatExternalPrincipals).values({ companyId: fixture.companyId, provider: "slack", providerAccountId: "T-PAPERCLIP", externalId: "UOTHER" }).returning();
await db.insert(chatIdentityLinks).values({ companyId: fixture.companyId, endpointId: endpoint.id, principalId: other.id, paperclipUserId: "owner-user", status: "linked", confirmedAt: new Date() });
}
const { slackBoardReplyBindings } = await import("../services/slack-board-messages.js");
expect(await slackBoardReplyBindings(db, { companyId: fixture.companyId, issueId: conversation.issueId, agentId: fixture.assignedAgentId, userId: "owner-user", commentIds: [comment.id] })).toEqual([]);
}
await service.processPendingPublications();
if (change === "later-cancelled") {
expect(runtime.endpoints.get(endpoint.id)!.posts.map(post => post.text)).toEqual([
"**Owner User (via Paperclip)**\n\nQueued before access changed", "Queued final before access changed",
]);
} else {
expect(runtime.endpoints.get(endpoint.id)!.posts).toEqual([]);
expect((await db.select().from(chatPublications).where(eq(chatPublications.endpointId, endpoint.id))).every(row => row.state === "cancelled")).toBe(true);
}
});
it.each(["cancelled", "paused", "blocked", "closed-workspace"] as const)("does not bypass %s task guards from the Slack Board composer or its outbox", async (guard) => {
const { issueTreeHolds, issueRelations, executionWorkspaces, projects } = await import("@paperclipai/db");
const fixture = await seedCompany();
const { callbacks, endpoint, service, wakeup, runtime } = await configuredSlackEndpoint(fixture);
const channel = makeThread({ channelId: "CGUARD", id: "slack:CGUARD:8100.1", name: "guard" });
await deliverMessage({ callbacks, endpointId: endpoint.id, thread: channel.thread,
message: makeMessage({ id: "8100.1", text: "@maya start here", mentioned: true }), trigger: "mention" });
await db.update(chatEndpoints).set({ status: "active" }).where(eq(chatEndpoints.id, endpoint.id));
const [conversation] = await service.listConversations(endpoint.id);
const [principal] = await db.insert(chatExternalPrincipals).values({ companyId: fixture.companyId, provider: "slack", providerAccountId: "T-PAPERCLIP", externalId: "UBOARD" }).returning();
await db.insert(chatIdentityLinks).values({ companyId: fixture.companyId, endpointId: endpoint.id, principalId: principal.id, paperclipUserId: "owner-user", status: "linked", confirmedAt: new Date() });
runtime.endpoints.get(endpoint.id)!.posts.length = 0;
wakeup.mockClear();
wakeup.mockRejectedValueOnce(new Error("scheduler unavailable"));
await service.publishBoardMessage(endpoint.id, conversation.id, "Queued work", randomUUID(), "owner-user");
expect(wakeup).toHaveBeenCalledTimes(1);
expect(runtime.endpoints.get(endpoint.id)!.posts).toEqual([]);
if (guard === "cancelled") await db.update(issues).set({ status: "cancelled" }).where(eq(issues.id, conversation.issueId));
if (guard === "paused") await db.insert(issueTreeHolds).values({ companyId: fixture.companyId, rootIssueId: conversation.issueId, mode: "pause", status: "active" });
if (guard === "blocked") {
const [blocker] = await db.insert(issues).values({ companyId: fixture.companyId, title: "Unresolved prerequisite", status: "todo" }).returning();
await db.insert(issueRelations).values({ companyId: fixture.companyId, issueId: blocker.id, relatedIssueId: conversation.issueId, type: "blocks" });
}
if (guard === "closed-workspace") {
const [project] = await db.insert(projects).values({ companyId: fixture.companyId, name: "Workspace guard" }).returning();
const [workspace] = await db.insert(executionWorkspaces).values({ companyId: fixture.companyId, projectId: project.id, mode: "isolated_workspace", strategyType: "git_worktree", name: "Closed", status: "archived", closedAt: new Date() }).returning();
await db.update(issues).set({ executionWorkspaceId: workspace.id }).where(eq(issues.id, conversation.issueId));
}
const before = await db.select().from(issueComments).where(eq(issueComments.issueId, conversation.issueId));
await expect(service.publishBoardMessage(endpoint.id, conversation.id, "Must not bypass guard", randomUUID(), "owner-user")).rejects.toMatchObject({ status: 409 });
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, conversation.issueId))).toHaveLength(before.length);
wakeup.mockClear();
await service.processPendingDeliveries();
expect(wakeup).not.toHaveBeenCalled();
await service.processPendingPublications();
expect(runtime.endpoints.get(endpoint.id)!.posts).toEqual([]);
expect((await db.select().from(chatPublications).where(eq(chatPublications.endpointId, endpoint.id))).every(row => row.state === "cancelled")).toBe(true);
expect(await db.select().from(chatActions).where(and(eq(chatActions.endpointId, endpoint.id), eq(chatActions.kind, "slack_board_message")))).toEqual([expect.objectContaining({ status: "cancelled" })]);
});
it.each(["concurrent", "lost-response"] as const)("applies the Slack Board resume once during %s dispatch", async (mode) => {
const fixture = await seedCompany();
const { callbacks, endpoint, service, wakeup, runtime } = await configuredSlackEndpoint(fixture, { linkedBoardUser: true });
const channel = makeThread({ channelId: "CONCE", id: "slack:CONCE:8200.1", name: "once" });
await deliverMessage({ callbacks, endpointId: endpoint.id, thread: channel.thread,
message: makeMessage({ id: "8200.1", text: "@maya start here", mentioned: true }), trigger: "mention" });
await db.update(chatEndpoints).set({ status: "active" }).where(eq(chatEndpoints.id, endpoint.id));
const [conversation] = await service.listConversations(endpoint.id);
await db.update(issues).set({ status: "done" }).where(eq(issues.id, conversation.issueId));
runtime.endpoints.get(endpoint.id)!.posts.length = 0;
wakeup.mockClear();
let release!: () => void;
let reached!: () => void;
const blocked = new Promise<void>(resolve => { release = resolve; });
const entered = new Promise<void>(resolve => { reached = resolve; });
wakeup.mockImplementationOnce(async () => {
// Model a scheduler which accepts the request and finishes before its
// HTTP response reaches this outbox worker.
await db.update(issues).set({ status: "done" }).where(eq(issues.id, conversation.issueId));
reached();
if (mode === "lost-response") throw new Error("scheduler response lost");
await blocked;
return null;
});
const sending = service.publishBoardMessage(endpoint.id, conversation.id, "One resume", randomUUID(), "owner-user");
await entered;
try {
if (mode === "lost-response") await sending;
await service.processPendingDeliveries();
expect((await issueService(db).getById(conversation.issueId))!.status).toBe("done");
expect(wakeup).toHaveBeenCalledTimes(mode === "concurrent" ? 1 : 2);
const resumes = await db.select().from(activityLog).where(and(eq(activityLog.companyId, fixture.companyId), eq(activityLog.entityId, conversation.issueId), eq(sql<string>`${activityLog.details}->>'source'`, "slack_board_message")));
expect(resumes).toHaveLength(1);
} finally {
release();
await sending;
}
await service.processPendingPublications();
expect(runtime.endpoints.get(endpoint.id)!.posts.filter(post => post.text.includes("One resume"))).toHaveLength(1);
});
it("dispatches and publishes a queued Slack Board send with a one-connection database pool", async () => {
const fixture = await seedCompany();
const { callbacks, endpoint, service, wakeup, runtime } = await configuredSlackEndpoint(fixture, { linkedBoardUser: true });
const channel = makeThread({ channelId: "CPOOL", id: "slack:CPOOL:8300.1", name: "pool" });
await deliverMessage({ callbacks, endpointId: endpoint.id, thread: channel.thread,
message: makeMessage({ id: "8300.1", text: "@maya start here", mentioned: true }), trigger: "mention" });
await db.update(chatEndpoints).set({ status: "active" }).where(eq(chatEndpoints.id, endpoint.id));
const [conversation] = await service.listConversations(endpoint.id);
runtime.endpoints.get(endpoint.id)!.posts.length = 0;
wakeup.mockRejectedValueOnce(new Error("scheduler unavailable"));
await service.publishBoardMessage(endpoint.id, conversation.id, "Small pool work", randomUUID(), "owner-user");
const smallDb = createDb(externalTestDatabaseUrl ?? tempDb!.connectionString, { maxConnections: 1 });
const smallWake = vi.fn(async () => ({ accepted: true }));
const worker = chatChannelService(smallDb, {
fetch: fakeSlackFetch() as typeof fetch,
heartbeat: { wakeup: smallWake },
publicBaseUrl: "https://paperclip.example",
runtime: runtime as unknown as ChatSdkRuntime,
});
fixtureServices.add(worker);
await worker.processPendingDeliveries();
await worker.processPendingPublications();
expect(smallWake).toHaveBeenCalledTimes(1);
expect(runtime.endpoints.get(endpoint.id)!.posts.map(post => post.text)).toEqual(["**Owner User (via Paperclip)**\n\nSmall pool work"]);
}, 15_000);
it("provides assigned Slack tools to routine tasks and can DM only their linked responsible user", async () => {
const { resolveConnectorAssignments, executeConnectorTool } = await import("../services/connector-runtime.js");
const { resolveSlackTaskAuthority } = await import("../services/connectors/slack-authority.js");
await instanceSettingsService(db).updateExperimental({ enableChatConnectors: true });
const fixture = await seedCompany();
const { endpoint, service } = await configuredSlackEndpoint(fixture, { allowUnlinkedPeople: false });
await db.update(chatEndpoints).set({ status: "active" }).where(eq(chatEndpoints.id, endpoint.id));
const [principal] = await db.insert(chatExternalPrincipals).values({ companyId: fixture.companyId, provider: "slack", providerAccountId: "T-PAPERCLIP", externalId: "UROUTINE" }).returning();
await db.insert(chatIdentityLinks).values({ companyId: fixture.companyId, endpointId: endpoint.id, principalId: principal.id, paperclipUserId: "owner-user", status: "linked", confirmedAt: new Date() });
const [issue] = await db.insert(issues).values({ companyId: fixture.companyId, title: "10am daily briefing", status: "todo", assigneeAgentId: fixture.assignedAgentId, responsibleUserId: "owner-user" }).returning();
const runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId: fixture.companyId, agentId: fixture.assignedAgentId, status: "running", responsibleUserId: "owner-user", contextSnapshot: { issueId: issue.id, source: "routine" } });
await initializeRunIdentity(db, { companyId: fixture.companyId, runId, issueId: issue.id, responsibleUserId: "owner-user", cause: "routine" });
const binding = { companyId: fixture.companyId, agentId: fixture.assignedAgentId, runId, issueId: issue.id, endpointId: endpoint.id };
const assignments = await resolveConnectorAssignments(db, binding);
expect(assignments.map(item => item.key)).toEqual(["slack"]);
expect(assignments[0].resources[0]).toMatchObject({ id: endpoint.id, metadata: { requesterId: "UROUTINE" } });
expect(assignments[0].tools.some(tool => tool.name === "slack_open_dm")).toBe(true);
await expect(resolveSlackTaskAuthority(db, binding)).resolves.toMatchObject({ conversation: null, userId: "owner-user", slackUserId: "UROUTINE" });
const upstream = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const method = String(input).split("/").at(-1);
const args = Object.fromEntries(new URLSearchParams(String(init?.body ?? "")));
if (method === "users.info") return Response.json({ ok: true, user: { id: "UROUTINE", team_id: "T-PAPERCLIP" } });
if (method === "conversations.open") {
expect(args.users).toBe("UROUTINE");
return Response.json({ ok: true, channel: { id: "DROUTINE" } });
}
if (method === "conversations.info") return Response.json({ ok: true, channel: args.channel?.startsWith("C")
? { id: args.channel, is_private: args.channel === "CPRIVATE", is_member: true }
: { id: args.channel, is_im: true, user: args.channel === "DOTHER" ? "UOTHER" : "UROUTINE" } });
if (method === "conversations.members") return Response.json({ ok: true, members: ["UROUTINE"] });
if (method === "conversations.history") return Response.json({ ok: true, messages: [{ ts: "8100.0", user: "UROUTINE", text: "Private planning context" }], has_more: false });
if (method === "chat.postMessage") return Response.json({ ok: true, channel: args.channel, ts: "8100.1" });
throw new Error(`Unexpected Slack operation ${method}`);
});
vi.stubGlobal("fetch", upstream);
onTestFinished(() => vi.unstubAllGlobals());
const openArgs = { idempotencyKey: randomUUID() };
await expect(executeConnectorTool(db, binding, "slack_open_dm", openArgs)).resolves.toMatchObject({ status: "completed" });
await expect(executeConnectorTool(db, binding, "slack_post_message", { channel: "DROUTINE", text: "Here is your day.", idempotencyKey: randomUUID() })).resolves.toMatchObject({ status: "completed" });
await expect(executeConnectorTool(db, binding, "slack_post_message", { channel: "DOTHER", text: "Wrong person", idempotencyKey: randomUUID() })).rejects.toThrow();
expect(upstream.mock.calls.filter(call => String(call[0]).endsWith("chat.postMessage"))).toHaveLength(1);
await expect(resolveSlackTaskAuthority(db, { ...binding, endpointId: randomUUID() })).rejects.toThrow();
await expect(resolveSlackTaskAuthority(db, { ...binding, agentId: fixture.replacementAgentId })).rejects.toThrow();
await expect(executeConnectorTool(db, { ...binding, endpointId: undefined }, "slack_open_dm", { endpointId: randomUUID(), idempotencyKey: randomUUID() })).rejects.toThrow();
// Native-style arguments select the same verified assignment as the CLI route.
await expect(executeConnectorTool(db, { ...binding, endpointId: undefined }, "slack_open_dm", { endpointId: endpoint.id, idempotencyKey: randomUUID() })).resolves.toMatchObject({ status: "completed" });
await db.insert(chatEndpointResources).values({ companyId: fixture.companyId, endpointId: endpoint.id, type: "channel", providerResourceId: "CPUBLIC", label: "Public", enabled: true, availability: "available" });
await expect(executeConnectorTool(db, binding, "slack_history", { channel: "CPRIVATE" })).resolves.toMatchObject({ status: "completed" });
await expect(executeConnectorTool(db, binding, "slack_post_message", { channel: "CPUBLIC", text: "Private research", idempotencyKey: randomUUID() })).rejects.toThrow();
await expect(executeConnectorTool(db, binding, "slack_post_message", { channel: "DROUTINE", text: "Your private briefing", idempotencyKey: randomUUID() })).resolves.toMatchObject({ status: "completed" });
await service.update(endpoint.id, { allowDirectMessages: false }, "owner-user");
await expect(executeConnectorTool(db, binding, "slack_open_dm", { idempotencyKey: randomUUID() })).rejects.toThrow("Direct messages are disabled");
await service.update(endpoint.id, { allowDirectMessages: true }, "owner-user");
await service.revokeLink(endpoint.id, principal.id, "owner-user");
await expect(resolveConnectorAssignments(db, binding)).resolves.toEqual([]);
await expect(executeConnectorTool(db, binding, "slack_post_message", { channel: "DROUTINE", text: "Revoked", idempotencyKey: randomUUID() })).rejects.toThrow();
});
it("binds Slack tools to the admitted linked request, recovers its provenance and revokes retained access", async () => {
const { resolveConnectorAssignments, executeConnectorTool, applyConnectorSkills, prepareConnectorSkillDelivery } = await import("../services/connector-runtime.js");
const { executeSlackTool } = await import("../services/connectors/slack.js");
@@ -4097,6 +4447,16 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
const [invocation] = await db.select().from(toolInvocations).where(eq(toolInvocations.id, approval.invocationId));
expect(invocation, invocation.errorMessage ?? undefined).toMatchObject({ status: "succeeded", approvalState: "approved" });
expect(postFetch.mock.calls.filter(call => String(call[0]).endsWith("/conversations.create"))).toHaveLength(1);
expect(await service.listResources(endpoint.id)).toEqual(expect.arrayContaining([
expect.objectContaining({ providerResourceId: "CNEWCHANNEL", enabled: false }),
]));
await service.handleWebhook(endpoint.publicId, "slack", new Request("https://paperclip.example/slack", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ event_id: "Ev-created-channel", event: {
type: "member_joined_channel", user: (await service.get(endpoint.id)).botExternalId,
channel: "CNEWCHANNEL", channel_type: "C", event_ts: "7300.1",
} }),
}));
expect(await db.select().from(chatEndpointResources).where(and(eq(chatEndpointResources.endpointId, endpoint.id), eq(chatEndpointResources.providerResourceId, "CNEWCHANNEL"), eq(chatEndpointResources.enabled, true)))).toEqual([]);
// The governed result must return to its exact originating Slack thread,
// even though approval delivery has a different wake key from normal input.
@@ -12938,7 +13298,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
it("reconciles Slack membership during configure, resume, and reconnect", async () => {
const fixture = await seedCompany();
let channels = [
{ id: "C-ONE", name: "one", is_member: true, is_archived: false },
{ id: "C-ONE", name: "one", is_member: true, is_archived: false, creator: "U-HUMAN" },
];
const providerFetch = vi.fn(async (input: string | URL | Request) => {
const url = String(input);
@@ -12993,17 +13353,26 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
expect.objectContaining({
providerResourceId: "C-ONE",
availability: "available",
enabled: false,
enabled: true,
}),
]);
const [firstResource] = await service.listResources(endpoint.id);
await service.replaceResources(endpoint.id, [{ id: firstResource!.id, enabled: false }]);
await service.configure(endpoint.id, { action: "reconnect", credentials: {
botToken: "xoxb-reconcile", signingSecret: "reconcile-signing-secret",
} }, "owner-user");
expect((await service.listResources(endpoint.id))[0]).toMatchObject({
id: firstResource!.id, enabled: false, availability: "available",
});
await db
.update(chatEndpoints)
.set({ status: "active", setup: { step: "complete" } })
.where(eq(chatEndpoints.id, endpoint.id));
await service.configure(endpoint.id, { action: "pause" }, "owner-user");
channels = [
{ id: "C-TWO", name: "two", is_member: true, is_archived: false },
{ id: "C-TWO", name: "two", is_member: true, is_archived: false, creator: "U-HUMAN" },
];
await service.configure(endpoint.id, { action: "resume" }, "owner-user");
expect(
@@ -13021,7 +13390,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
.set({ status: "attention" })
.where(eq(chatEndpoints.id, endpoint.id));
channels = [
{ id: "C-THREE", name: "three", is_member: true, is_archived: false },
{ id: "C-THREE", name: "three", is_member: true, is_archived: false, creator: "U-RECONCILE" },
];
await service.configure(
endpoint.id,
@@ -13044,6 +13413,8 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
{ id: "C-THREE", availability: "available" },
{ id: "C-TWO", availability: "unavailable" },
]);
expect((await service.listResources(endpoint.id)).find(resource => resource.providerResourceId === "C-THREE"))
.toMatchObject({ enabled: false });
});
it("rejects reconnect credentials for a different Slack bot without rotating secrets", async () => {
@@ -13131,6 +13502,146 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
});
});
it.each(["membership-first", "mention-first"])(
"accepts the Slack invitation mention exactly once (%s)",
async (order) => {
const fixture = await seedCompany();
const { callbacks, endpoint, service, wakeup } =
await configuredSlackEndpoint(fixture);
await db
.update(chatEndpoints)
.set({ status: "active" })
.where(eq(chatEndpoints.id, endpoint.id));
const current = await service.get(endpoint.id);
const channel = makeThread({
channelId: "C-INVITED",
id: "slack:C-INVITED:100.1",
name: "invited",
});
const mention = () =>
deliverMessage({
callbacks,
endpointId: endpoint.id,
thread: channel.thread,
message: makeMessage({
id: "100.1",
text: "@maya summarize the recent papercuts",
mentioned: true,
}),
trigger: "mention",
});
const joined = () =>
service.handleWebhook(
endpoint.publicId,
"slack",
new Request("https://paperclip.example/slack", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
event_id: "Ev-invited",
event: {
type: "member_joined_channel",
event_ts: "101.0",
user: current.botExternalId,
channel: "C-INVITED",
channel_type: "C",
},
}),
}),
);
if (order === "membership-first") {
await joined();
await mention();
} else {
await mention();
await joined();
}
// Slack may retry either event. Neither changes the resource choice or starts duplicate work.
await joined();
await mention();
const resources = await service.listResources(endpoint.id);
expect(resources).toHaveLength(1);
expect(resources[0]).toMatchObject({
providerResourceId: "C-INVITED",
availability: "available",
enabled: true,
});
expect(await service.listConversations(endpoint.id)).toHaveLength(1);
expect(
await db
.select()
.from(issues)
.where(eq(issues.companyId, fixture.companyId)),
).toHaveLength(1);
expect(wakeup).toHaveBeenCalledTimes(1);
},
);
it.each(["verifying", "active"] as const)(
"preserves an explicitly disabled Slack channel after invitation and mentions (%s)",
async (status) => {
const fixture = await seedCompany();
const { callbacks, endpoint, service, wakeup } =
await configuredSlackEndpoint(fixture);
if (status === "active")
await db
.update(chatEndpoints)
.set({ status })
.where(eq(chatEndpoints.id, endpoint.id));
const current = await service.get(endpoint.id);
let eventSequence = 100;
const membership = (type: string) =>
service.handleWebhook(
endpoint.publicId,
"slack",
new Request("https://paperclip.example/slack", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
event_id: `Ev-disabled-${eventSequence}`,
event: {
type,
event_ts: String(eventSequence++),
user: current.botExternalId,
channel: "C-DISABLED",
channel_type: "C",
},
}),
}),
);
await membership("member_joined_channel");
const [resource] = await service.listResources(endpoint.id);
expect(resource!.enabled).toBe(true);
await service.replaceResources(endpoint.id, [
{ id: resource!.id, enabled: false },
]);
await membership("member_left_channel");
await membership("member_joined_channel");
const channel = makeThread({
channelId: "C-DISABLED",
id: "slack:C-DISABLED:200.1",
name: "disabled",
});
await deliverMessage({
callbacks,
endpointId: endpoint.id,
thread: channel.thread,
message: makeMessage({
id: "200.1",
text: "@maya do not override the opt-out",
mentioned: true,
}),
trigger: "mention",
});
expect((await service.listResources(endpoint.id))[0]).toMatchObject({
enabled: false,
availability: "available",
});
expect(await service.listConversations(endpoint.id)).toHaveLength(0);
expect(wakeup).not.toHaveBeenCalled();
},
);
it("persists Slack membership, uninstall, and same-bot reinstall lifecycle before acknowledging", async () => {
const fixture = await seedCompany();
const { callbacks, endpoint, runtime, service } =
@@ -13164,7 +13675,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
expect(resource).toMatchObject({
providerResourceId: "C-LIFECYCLE",
availability: "available",
enabled: false,
enabled: true,
});
await vi.waitFor(async () => {
await expect(service.listResources(endpoint.id)).resolves.toEqual([
@@ -22598,7 +23109,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
await service.shutdown();
});
it("deduplicates inbound events, keeps one task per thread, and requires enablement for newly discovered channels", async () => {
it("deduplicates inbound events, keeps one task per thread, and enables newly discovered Slack channels", async () => {
const fixture = await seedCompany();
const { callbacks, endpoint, runtime, service, wakeup } =
await configuredSlackEndpoint(fixture);
@@ -22747,7 +23258,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
})),
).toEqual([
{ label: "engineering", enabled: true },
{ label: "finance", enabled: false },
{ label: "finance", enabled: true },
]);
deliveries = await db
.select()
@@ -22756,13 +23267,13 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
expect(
deliveries.find((delivery) => delivery.providerEventId.includes("2000.1"))
?.state,
).toBe("filtered");
).toBe("processed");
expect(
await db
.select()
.from(issues)
.where(eq(issues.companyId, fixture.companyId)),
).toHaveLength(1);
).toHaveLength(2);
const finance = resources.find((resource) => resource.label === "finance");
if (!finance) throw new Error("Expected discovered finance resource");
@@ -40658,6 +41169,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
const storage = createStorageService();
const { callbacks, endpoint, runtime, service } =
await configuredSlackEndpoint(fixture, {
linkedBoardUser: true,
storage: storage.storage,
});
const channel = makeThread({
@@ -40746,7 +41258,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
expect(publications).toEqual([
expect.objectContaining({
state: "published",
payload: { text: "The selected PNG should follow." },
payload: { text: "**Owner User (via Paperclip)**\n\nThe selected PNG should follow." },
}),
expect.objectContaining({
id: failed.id,
@@ -40764,7 +41276,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
expect(providerRuntime.posts).toEqual([
{
threadId: channel.thread.id,
text: "The selected PNG should follow.",
text: "**Owner User (via Paperclip)**\n\nThe selected PNG should follow.",
},
{
threadId: channel.thread.id,
@@ -41296,6 +41808,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
const storage = createStorageService();
const { callbacks, endpoint, runtime, service } =
await configuredSlackEndpoint(fixture, {
linkedBoardUser: true,
storage: storage.storage,
});
const channel = makeThread({
@@ -41375,7 +41888,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
expect(publications).toEqual([
expect.objectContaining({
state: "published",
payload: { text: "Visible board update" },
payload: { text: "**Owner User (via Paperclip)**\n\nVisible board update" },
}),
expect.objectContaining({
id: first.id,
@@ -41390,7 +41903,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
.where(eq(issueAttachments.id, attachment.id)),
).resolves.toEqual([{ issueCommentId: comments[0].id }]);
expect(runtime.endpoints.get(endpoint.id)?.posts).toEqual([
{ threadId: channel.thread.id, text: "Visible board update" },
{ threadId: channel.thread.id, text: "**Owner User (via Paperclip)**\n\nVisible board update" },
{
threadId: channel.thread.id,
text: "",
@@ -41459,6 +41972,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
const company = await seedCompany();
const storage = createStorageService();
const context = await configuredSlackEndpoint(company, {
linkedBoardUser: true,
storage: storage.storage,
});
const channel = makeThread({
@@ -41656,7 +42170,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
).toEqual([
{
threadId: "slack:C-BOARD-REJECTION:4410.1",
text: "Corrected separately keyed send",
text: "**Owner User (via Paperclip)**\n\nCorrected separately keyed send",
},
]);
} finally {
@@ -42581,7 +43095,8 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
const storage = createStorageService();
const { callbacks, endpoint, runtime, service } =
provider === "slack"
? await configuredSlackEndpoint(fixture, { storage: storage.storage })
? await configuredSlackEndpoint(fixture, {
linkedBoardUser: true, storage: storage.storage })
: await configuredDiscordEndpoint(fixture, {
storage: storage.storage,
});
@@ -42686,7 +43201,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
).toEqual([[], ...attachmentIds.map((id) => [id])]);
expect(initialBatch[2]!.id).toBe(blocked.id);
expect(initialRuntime.posts).toEqual([
{ threadId: channel.thread.id, text },
{ threadId: channel.thread.id, text: provider === "slack" ? `**Owner User (via Paperclip)**\n\n${text}` : text },
{
threadId: channel.thread.id,
text: provider === "slack" ? "" : "Shared selected-1.txt.",
@@ -43565,6 +44080,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
const storage = createStorageService();
const { callbacks, endpoint, runtime, service } =
await configuredSlackEndpoint(fixture, {
linkedBoardUser: true,
storage: storage.storage,
});
try {
@@ -43686,7 +44202,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
expect(providerRuntime.posts).toEqual([
{
threadId: damagedChannel.thread.id,
text: "Damaged attachment send",
text: "**Owner User (via Paperclip)**\n\nDamaged attachment send",
},
]);
@@ -43705,11 +44221,11 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
expect(providerRuntime.posts).toEqual([
{
threadId: damagedChannel.thread.id,
text: "Damaged attachment send",
text: "**Owner User (via Paperclip)**\n\nDamaged attachment send",
},
{
threadId: healthyChannel.thread.id,
text: "Healthy attachment send",
text: "**Owner User (via Paperclip)**\n\nHealthy attachment send",
},
{
threadId: healthyChannel.thread.id,
@@ -43767,7 +44283,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
it("keeps missing attachment storage retryable but fails invalid metadata before provider transport", async () => {
const fixture = await seedCompany();
const { callbacks, endpoint, runtime, service } =
await configuredSlackEndpoint(fixture);
await configuredSlackEndpoint(fixture, { linkedBoardUser: true });
try {
const channel = makeThread({
channelId: "C-BOARD-MISSING-STORAGE",
@@ -43823,7 +44339,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
expect(providerRuntime.posts).toEqual([
{
threadId: channel.thread.id,
text: "Missing storage attachment send",
text: "**Owner User (via Paperclip)**\n\nMissing storage attachment send",
},
]);
@@ -43870,7 +44386,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
it("scopes board-send idempotency to one external conversation", async () => {
const fixture = await seedCompany();
const { callbacks, endpoint, service } =
await configuredSlackEndpoint(fixture);
await configuredSlackEndpoint(fixture, { linkedBoardUser: true });
const firstChannel = makeThread({
channelId: "C-BOARD-IDEMPOTENCY-ONE",
id: "slack:C-BOARD-IDEMPOTENCY-ONE:4401.1",
@@ -59397,6 +59913,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
});
const { callbacks, endpoint, runtime, service, wakeup } =
await configuredSlackEndpoint(fixture, {
linkedBoardUser: true,
reactionLinkPreflightBarrier: async () => {
reachedPreflight();
await preflightRelease;
@@ -59485,13 +60002,18 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
state: "processed",
}),
]);
expect(wakeup).toHaveBeenCalledTimes(wakeupCount);
// Sending the Board message wakes the agent once; the reaction adds no wake.
expect(wakeup).toHaveBeenCalledTimes(wakeupCount + 1);
expect(wakeup.mock.calls.at(-1)?.[1]).toMatchObject({
contextSnapshot: { source: "issue.comment" },
requestedByActorId: "owner-user",
});
});
it("durably replays a reaction that arrives before its outbound link without waking the task", async () => {
const fixture = await seedCompany();
const { callbacks, endpoint, runtime, service, wakeup } =
await configuredSlackEndpoint(fixture);
await configuredSlackEndpoint(fixture, { linkedBoardUser: true });
const channel = makeThread({
channelId: "C-EARLY-REACTION",
id: "slack:C-EARLY-REACTION:7130.1",
@@ -59886,6 +60408,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
const fixture = await seedCompany();
const { callbacks, endpoint, runtime, service, wakeup } =
await configuredSlackEndpoint(fixture, {
linkedBoardUser: true,
reactionReplayEndpointLockBarrier: async () => {
if (!recoveryArmed) return;
reactionEntered = true;
@@ -60211,6 +60734,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
});
const { callbacks, endpoint, runtime, service, wakeup } =
await configuredSlackEndpoint(fixture, {
linkedBoardUser: true,
reactionReplayEndpointLockBarrier: async () => {
endpointLocked();
await contention;
@@ -60374,7 +60898,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
it("filters a late duplicate instead of bypassing the bounded reaction-link lifetime", async () => {
const fixture = await seedCompany();
const { callbacks, endpoint, runtime, service } =
await configuredSlackEndpoint(fixture);
await configuredSlackEndpoint(fixture, { linkedBoardUser: true });
const channel = makeThread({
channelId: "C-EXPIRED-REACTION",
id: "slack:C-EXPIRED-REACTION:7140.1",
@@ -60473,6 +60997,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
let advanceReplayClock = false;
const { callbacks, endpoint, runtime, service } =
await configuredSlackEndpoint(fixture, {
linkedBoardUser: true,
reactionReplayEndpointLockBarrier: async () => {
if (advanceReplayClock) {
vi.setSystemTime(new Date(baseTime.getTime() + 2_000));
@@ -60580,7 +61105,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
it("filters a staged reaction when destination reach is revoked before replay", async () => {
const fixture = await seedCompany();
const { callbacks, endpoint, runtime, service, wakeup } =
await configuredSlackEndpoint(fixture);
await configuredSlackEndpoint(fixture, { linkedBoardUser: true });
const channel = makeThread({
channelId: "C-REVOKED-EARLY-REACTION",
id: "slack:C-REVOKED-EARLY-REACTION:7145.1",
@@ -60704,7 +61229,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
it("replays a pre-link reaction into the completed DM generation that sent the message", async () => {
const fixture = await seedCompany();
const { callbacks, endpoint, runtime, service, wakeup } =
await configuredSlackEndpoint(fixture);
await configuredSlackEndpoint(fixture, { linkedBoardUser: true });
const channelId = "D-EARLY-REACTION";
const dm = makeThread({
channelId,
@@ -69992,6 +70517,10 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
? await configuredGitHubEndpoint(fixture)
: await configuredTeamsEndpoint(fixture);
try {
if (provider === "slack") {
const [principal] = await db.insert(chatExternalPrincipals).values({ companyId: fixture.companyId, provider: "slack", providerAccountId: "T-PAPERCLIP", externalId: "UBOARD" }).returning();
await db.insert(chatIdentityLinks).values({ companyId: fixture.companyId, endpointId: context.endpoint.id, principalId: principal.id, paperclipUserId: "owner-user", status: "linked", confirmedAt: new Date() });
}
const teamsChannel = `teams:${Buffer.from("19:long-publication@thread.tacv2").toString("base64url")}:${Buffer.from("https://smba.trafficmanager.net/amer/").toString("base64url")}`;
if (provider === "microsoft-teams") {
await db.insert(chatEndpointResources).values({
@@ -70093,20 +70622,21 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
for (let drain = 0; drain < 20; drain++)
await service.processPendingPublications();
expect(publication?.commentId).toBeTruthy();
const expectedText = provider === "slack" && source === "new" ? `**Owner User (via Paperclip)**\n\n${body}` : body;
const rows = await orderedBatch(publication!.commentId!);
expect(rows.every((row) => row.state === "published")).toBe(true);
expect(rows.map((row) => row.payload.text).join("") === body).toBe(
expect(rows.map((row) => row.payload.text).join("") === expectedText).toBe(
true,
);
expect(
transport.posts.map((post) => post.text).join("") === body,
).toBe(true);
expect(transport.posts.map((post) => post.text)).toEqual(
rows.map((row) => renderPublicationTransportText(row.payload)),
);
expect(
transport.posts.every((post) =>
nativePublicationTextFits(provider, post.text),
),
).toBe(true);
expect(transport.posts.at(-1)?.text).toContain(marker);
expect(rows.map(row => row.payload.text).join("").endsWith(marker)).toBe(true);
const before = transport.posts.length;
if (comment)
await service.publishComment(
@@ -70142,7 +70672,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
"@here ".repeat(1_000) +
"END-OF-RICH-BOARD";
expect(body.length).toBeLessThanOrEqual(100_000);
const safe = projectSafeChatPublicationText(body);
const safe = projectSafeChatPublicationText(provider === "slack" ? `**Owner User (via Paperclip)**\n\n${body}` : body);
const publication = await service.publishBoardMessage(
endpoint.id,
conversation.id,
@@ -70233,6 +70763,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
let restarted: ChatChannelService | undefined;
try {
const body = "a".repeat(99_990) + "FINAL-TAIL";
const expectedText = provider === "slack" ? `**Owner User (via Paperclip)**\n\n${body}` : body;
let attempts = 0;
transport.postHook = async () => {
if (++attempts === 2)
@@ -70281,11 +70812,10 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
expect(final[1]!.attempts).toBe(2);
expect(final.slice(2).every((row) => row.attempts === 1)).toBe(true);
const resumed = fresh.runtime.endpoints.get(endpoint.id)!.posts;
expect(
[...transport.posts, ...resumed]
.map((post) => post.text)
.join("") === body,
).toBe(true);
expect(final.map(row => row.payload.text).join("")).toBe(expectedText);
expect([...transport.posts, ...resumed].map(post => post.text)).toEqual(
final.map(row => renderPublicationTransportText(row.payload)),
);
expect(
(
await restarted.getPublicationBatchStatus(
@@ -5,22 +5,18 @@ import { describe, expect, it } from "vitest";
import { BUNDLED_PLUGIN_CATALOG } from "../services/bundled-plugins.js";
/**
* Drift guard for the cloud image variant (Dockerfile `cloud` target).
* Drift guard for the explicit preview image (Dockerfile `cloud` target).
*
* The cloud image builds the sandbox-provider plugins named in the
* The preview image builds the sandbox-provider plugins named in the
* CLOUD_BUNDLED_PLUGINS build arg so managed instances can auto-install
* them from the bundled catalog at boot. That contract spans three places
* that nothing else ties together: the Dockerfile ARG default, the docker
* workflow's build-arg, and BUNDLED_PLUGIN_CATALOG. A rename or removal in
* any one of them would otherwise surface only when the image build fails
* on master — or worse, as a silent "bundle not present" skip at instance
* boot.
* them from the bundled catalog at boot. The Dockerfile default and
* BUNDLED_PLUGIN_CATALOG must agree even after the recurring public cloud
* publisher is retired. Explicit previews still use this build target.
*/
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
const dockerfile = readFileSync(path.join(repoRoot, "Dockerfile"), "utf8");
const workflow = readFileSync(path.join(repoRoot, ".github", "workflows", "docker.yml"), "utf8");
const cloudWorkflow = readFileSync(path.join(repoRoot, ".github", "workflows", "docker-cloud.yml"), "utf8");
function parseList(source: string, pattern: RegExp, label: string): string[] {
const match = source.match(pattern);
@@ -35,18 +31,9 @@ const dockerfileDefault = parseList(
/^ARG CLOUD_BUNDLED_PLUGINS="([^"]*)"/m,
"Dockerfile",
);
const workflowArg = parseList(
cloudWorkflow,
/^\s*CLOUD_BUNDLED_PLUGINS=(.*)$/m,
"docker workflow",
);
describe("cloud image bundled plugins", () => {
it("keeps the Dockerfile default and the workflow build-arg in sync", () => {
expect(workflowArg).toEqual(dockerfileDefault);
});
it.each([...new Set([...dockerfileDefault, ...workflowArg])])(
it.each(dockerfileDefault)(
"plugin %s is buildable and resolvable by the auto-installer",
(name) => {
const dir = path.join(repoRoot, "packages", "plugins", "sandbox-providers", name);
@@ -77,37 +64,6 @@ describe("cloud image bundled plugins", () => {
expect(workflow).toMatch(/^\s*target: production$/m);
});
it("publishes the cloud image in its own job with no needs coupling", () => {
const caller = workflow.split(" build-and-push-cloud:")[1]?.split(" promote_canary_channel:")[0];
expect(caller, "tag and manual builds must call the cloud workflow").toContain("uses: ./.github/workflows/docker-cloud.yml");
expect(caller, "the reusable caller must also remain independent of production").not.toMatch(/^\s*needs:/m);
// The reusable cloud workflow owns its job and SHA concurrency group.
// Production publication must not gate, delay, or skip the cloud build.
const jobsSection = cloudWorkflow.slice(cloudWorkflow.indexOf("\njobs:\n"));
const headers = [...jobsSection.matchAll(/^ {2}([\w-]+):[^\n]*$/gm)];
expect(
headers.length,
"docker-cloud.yml must declare a cloud build job under jobs:",
).toBeGreaterThanOrEqual(1);
// Locate the job block that carries the cloud build (target: cloud) and
// assert it declares no `needs:` — coupling it to another job would
// reintroduce the shared failure the split job exists to remove.
const cloudHeaderIdx = headers.findIndex((header, i) => {
const start = header.index ?? 0;
const end = headers[i + 1]?.index ?? jobsSection.length;
return jobsSection.slice(start, end).includes("target: cloud");
});
expect(cloudHeaderIdx, "one job must build the cloud target").toBeGreaterThanOrEqual(0);
const start = headers[cloudHeaderIdx].index ?? 0;
const end = headers[cloudHeaderIdx + 1]?.index ?? jobsSection.length;
const cloudJobBlock = jobsSection.slice(start, end);
expect(
cloudJobBlock,
"the cloud job must not couple to another job via needs:",
).not.toMatch(/^\s*needs:/m);
});
it("throttles the docker workflow with cancel-in-progress: false", () => {
// Concurrency is declared at the workflow (top) level so a single group
// spans the whole run, and cancel-in-progress is false so an in-flight
@@ -14,12 +14,12 @@ import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
/**
* Drift guard for the cloud image variant's bundled Sentry server package
* Drift guard for the explicit preview image's bundled Sentry server package
* (Dockerfile `cloud` target).
*
* The self-hosted image, built from the `production` target, keeps
* `@sentry/node` as a true optional peer dependency: the operator installs
* it themselves. The hosted (cloud) image installs the packages the
* it themselves. The explicit preview image installs the packages the
* `CLOUD_BUNDLED_SERVER_DEPS` build argument names, so a managed tenant
* gets server error reports with no separate install step. The stage
* reads each package's version from the `peerDependencies` block of
@@ -30,14 +30,13 @@ import { describe, expect, it } from "vitest";
* workflow carry no literal version pin (they read the version from
* `server/package.json` at build time instead); the `cloud-server-deps`
* stage declares the `CLOUD_BUNDLED_SERVER_DEPS` build argument with a
* default that names `@sentry/node`; the docker workflow passes that same
* argument to the cloud build; and no committed manifest re-declares the
* default that names `@sentry/node`; and no committed manifest re-declares the
* version.
*/
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
const dockerfile = readFileSync(path.join(repoRoot, "Dockerfile"), "utf8");
const workflow = readFileSync(path.join(repoRoot, ".github", "workflows", "docker-cloud.yml"), "utf8");
const workflow = readFileSync(path.join(repoRoot, ".github", "workflows", "release.yml"), "utf8");
const serverPackageJson = JSON.parse(
readFileSync(path.join(repoRoot, "server", "package.json"), "utf8"),
) as { peerDependencies?: Record<string, string> };
@@ -164,10 +163,6 @@ describe("cloud image Sentry install", () => {
).toContain("@sentry/node");
});
it("passes CLOUD_BUNDLED_SERVER_DEPS to the cloud build in the docker workflow", () => {
expect(workflow).toMatch(/^\s*CLOUD_BUNDLED_SERVER_DEPS=@sentry\/node\s*$/m);
});
it("declares no committed manifest that re-states the version", () => {
expect(
existsSync(path.join(repoRoot, "docker", "cloud-server-deps")),
@@ -21,7 +21,7 @@ import { describe, expect, it } from "vitest";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
const dockerfile = readFileSync(path.join(repoRoot, "Dockerfile"), "utf8");
const workflow = readFileSync(path.join(repoRoot, ".github", "workflows", "docker.yml"), "utf8");
const cloudWorkflow = readFileSync(path.join(repoRoot, ".github", "workflows", "docker-cloud.yml"), "utf8");
const previewWorkflow = readFileSync(path.join(repoRoot, ".github", "workflows", "release.yml"), "utf8");
/**
* Return the text of the Dockerfile stage that starts at the named target.
@@ -68,12 +68,11 @@ describe("docker build-stamp wiring", () => {
).toBeLessThan(serverBuildIdx);
});
it("passes PAPERCLIP_BUILD_COMMIT as a build-arg for both image targets", () => {
const argLines = [...`${workflow}\n${cloudWorkflow}`.matchAll(/^\s*PAPERCLIP_BUILD_COMMIT=.*$/gm)];
expect(
argLines.length,
"the docker workflow must pass PAPERCLIP_BUILD_COMMIT for the production and cloud builds",
).toBeGreaterThanOrEqual(2);
it("passes PAPERCLIP_BUILD_COMMIT as a build-arg for standard and explicit preview builds", () => {
for (const [name, source] of [["standard", workflow], ["preview", previewWorkflow]]) {
expect(source, `${name} must pass the source commit into the image build`)
.toMatch(/^\s*PAPERCLIP_BUILD_COMMIT=\$\{\{ (?:github.sha|inputs.source_ref) \}\}$/m);
}
});
});
@@ -888,6 +888,12 @@ describe("resolveHeartbeatRunResponse", () => {
);
expect(isExternalChatPresentationContext({ source: "tool_action_review" })).toBe(false);
expect(isExternalChatPresentationContext({ source: "tool_action_review" }, true)).toBe(true);
expect(isExternalChatPresentationContext({ source: "issue.comment" })).toBe(false);
expect(isExternalChatPresentationContext({ source: "issue.comment", externalChatContinuation: true })).toBe(false);
expect(isExternalChatPresentationContext({ source: "issue.comment" }, true)).toBe(true);
expect(isExternalChatPresentationContext({ source: "issue.comment.reopen" }, true)).toBe(true);
expect(isExternalChatPresentationContext({ source: "issue.update" })).toBe(false);
expect(isExternalChatPresentationContext({ source: "issue.update" }, true)).toBe(true);
expect(isExternalChatPresentationContext(null)).toBe(false);
});
});
@@ -44,6 +44,7 @@ describe("instance settings service", () => {
enableAgentChat: false,
enableChatConnectors: false,
enableMcpAggregators: false,
enableMemoryConnectors: false,
enableConferenceRoomChat: false,
enableClassicTaskInterface: false,
enableExternalObjects: false,
@@ -1425,6 +1425,8 @@ describe.sequential("issue comment reopen routes", () => {
attachmentIds: undefined,
authorType: "user",
authorizationReason: "allow_board_actor",
clientRequestId: undefined,
mirrorToSlack: true,
presentation: {
kind: "system_notice",
tone: "warning",
@@ -324,9 +324,10 @@ describe("issue update comment wakeups", () => {
}));
});
it("includes the new comment in assignment wakes from issue updates", async () => {
const existing = makeIssue();
it.each([null, "active"] as const)("includes the new comment and shared session key in assignment wakes (external: %s)", async (externalConversationState) => {
const existing = makeIssue({ externalConversationState });
const updated = makeIssue({
externalConversationState,
assigneeAgentId: ASSIGNEE_AGENT_ID,
assigneeUserId: null,
});
@@ -370,6 +371,7 @@ describe("issue update comment wakeups", () => {
taskId: existing.id,
commentId: "comment-1",
wakeCommentId: "comment-1",
...(externalConversationState ? { taskKey: existing.identifier } : {}),
source: "issue.update",
}),
}),
@@ -596,8 +598,9 @@ describe("issue update comment wakeups", () => {
);
});
it("wakes the assignee on top-level board issue comments", async () => {
it.each([null, "active"] as const)("wakes the assignee on top-level board comments with external conversation %s", async (externalConversationState) => {
const existing = makeIssue({
externalConversationState,
assigneeAgentId: ASSIGNEE_AGENT_ID,
assigneeUserId: null,
status: "in_progress",
@@ -618,8 +621,9 @@ describe("issue update comment wakeups", () => {
});
expect(res.status).toBe(201);
if (externalConversationState) expect(mockRunnerGoalService.projection).not.toHaveBeenCalled();
expect(mockIssueService.addComment).toHaveBeenCalledWith(existing.id, "please handle this top-level thread comment", expect.anything(),
expect.objectContaining({ clientRequestId: "66666666-6666-4666-8666-666666666666" }), expect.anything());
expect.objectContaining({ clientRequestId: "66666666-6666-4666-8666-666666666666", mirrorToSlack: true }), expect.anything());
await vi.waitFor(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1));
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
ASSIGNEE_AGENT_ID,
@@ -636,6 +640,7 @@ describe("issue update comment wakeups", () => {
taskId: existing.id,
commentId: "comment-3",
wakeCommentId: "comment-3",
...(externalConversationState ? { taskKey: existing.identifier } : {}),
wakeReason: "issue_commented",
source: "issue.comment",
}),
@@ -643,6 +648,20 @@ describe("issue update comment wakeups", () => {
);
});
it("retains the Slack session key when reopening strips the read-only conversation projection", async () => {
const existing = makeIssue({ externalConversationState: "active", status: "done", assigneeAgentId: ASSIGNEE_AGENT_ID });
const updated = makeIssue({ status: "todo", assigneeAgentId: ASSIGNEE_AGENT_ID });
mockIssueService.getById.mockResolvedValue(existing);
mockIssueService.update.mockResolvedValue(updated);
mockIssueService.addComment.mockResolvedValue({ id: "comment-reopen-slack", issueId: existing.id, companyId: existing.companyId, body: "Continue from Paperclip" });
const res = await request(await createApp()).post(`/api/issues/${existing.id}/comments`).send({ body: "Continue from Paperclip", reopen: true });
expect(res.status).toBe(201);
await vi.waitFor(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1));
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(ASSIGNEE_AGENT_ID, expect.objectContaining({
contextSnapshot: expect.objectContaining({ source: "issue.comment.reopen", taskKey: existing.identifier, wakeCommentId: "comment-reopen-slack" }),
}));
});
it("does not wake the assignee for its own run-authenticated top-level comment", async () => {
const existing = makeIssue({
assigneeAgentId: ASSIGNEE_AGENT_ID,
@@ -0,0 +1,72 @@
import { describe, expect, it, vi } from "vitest";
import { classifyRisk } from "../services/tool-access.js";
import { COGNEE_STDIO_TEMPLATE, cogneeCloudUrl, callCogneeCloud } from "../services/cognee-connection.js";
describe("memory tool governance", () => {
it.each([
["mem0", "add_memory", "write"], ["mem0", "search_memories", "read"],
["mem0", "delete_entities", "destructive"], ["zep", "add_memory_to_graph", "write"],
["supermemory", "add_memory", "destructive"], ["supermemory", "select-space", "write"],
["supermemory", "get_profile", "read"], ["cognee", "remember", "write"],
["cognee", "recall", "read"], ["cognee", "forget", "destructive"],
["honcho", "create_peer", "write"], ["honcho", "future_unknown_action", "write"],
])("classifies %s %s even with a misleading read hint", (provider, name, expected) => {
expect(classifyRisk({ name, annotations: { readOnlyHint: true } }, provider)).toBe(expected);
});
it("keeps the approved Cognee bridge free of runtime package resolution", () => {
expect(COGNEE_STDIO_TEMPLATE.command).toBeNull();
expect(COGNEE_STDIO_TEMPLATE.args).toEqual([]);
expect(COGNEE_STDIO_TEMPLATE.envKeys).toEqual(["COGNEE_BASE_URL", "COGNEE_API_KEY"]);
expect(COGNEE_STDIO_TEMPLATE.tools.map(t => t.name)).toEqual(["remember", "recall", "forget"]);
});
it.each(["http://localhost", "https://evil.test", "https://tenant.aws.cognee.ai@evil.test", "https://tenant.aws.cognee.ai/other", "https://tenant.aws.cognee.ai?key=secret", "https://tenant.aws.cognee.ai:8443"])("rejects unreviewed Cognee URL %s", (url) => {
expect(() => cogneeCloudUrl(url)).toThrow();
});
it("accepts the tenant API origin", () => expect(cogneeCloudUrl("https://tenant.aws.cognee.ai").origin).toBe("https://tenant.aws.cognee.ai"));
it("writes text as a file to the tenant-only Cloud endpoint", async () => {
const request = vi.fn().mockResolvedValue({ status: "completed" });
const result = await callCogneeCloud({ baseUrl: "https://fixture.aws.cognee.ai", apiKey: "fixture-key",
tool: "remember", parameters: { data: "synthetic memory", dataset_name: "fixture" }, request,
signal: AbortSignal.timeout(1000) });
const [url, init] = request.mock.calls[0]!;
expect(url.href).toBe("https://fixture.aws.cognee.ai/api/v1/remember");
expect(init).toMatchObject({ method: "POST", redirect: "manual", headers: expect.objectContaining({ "x-api-key": "fixture-key" }) });
expect(Buffer.isBuffer(init.body)).toBe(true);
const form = await new Response(init.body, { headers: init.headers }).formData();
expect(form.get("datasetName")).toBe("fixture");
expect(await (form.get("data") as File).text()).toBe("synthetic memory");
expect(result).toMatchObject({ isError: false, structuredContent: { result: { status: "completed" } } });
});
it("keeps session writes on the Cloud session endpoint", async () => {
const request = vi.fn().mockResolvedValue({ status: "completed" });
await callCogneeCloud({ baseUrl: "https://fixture.aws.cognee.ai", apiKey: "key", tool: "remember",
parameters: { data: "text", session_id: "session", dataset_name: "fixture" }, request, signal: AbortSignal.timeout(1000) });
expect(request.mock.calls[0]![0].pathname).toBe("/api/v1/remember/entry");
expect(JSON.parse(request.mock.calls[0]![1].body)).toMatchObject({
entry: { type: "qa", answer: "text" }, session_id: "session", dataset_name: "fixture" });
});
it("preserves scoped recall arguments and does not fetch other datasets", async () => {
const request = vi.fn().mockResolvedValue(["memory"]);
await callCogneeCloud({ baseUrl: "https://fixture.aws.cognee.ai", apiKey: "key", tool: "recall",
parameters: { query: "find", datasets: "one, two", search_type: "chunks", top_k: 3 }, request, signal: AbortSignal.timeout(1000) });
expect(request).toHaveBeenCalledTimes(1);
expect(request.mock.calls[0]![0].pathname).toBe("/api/v1/recall");
expect(JSON.parse(request.mock.calls[0]![1].body)).toEqual({ query: "find", datasets: ["one", "two"], search_type: "CHUNKS", top_k: 3 });
});
it("matches the official client's unscoped dataset discovery", async () => {
const request = vi.fn().mockResolvedValueOnce([{ name: "one" }]).mockResolvedValueOnce(["memory"]);
await callCogneeCloud({ baseUrl: "https://fixture.aws.cognee.ai", apiKey: "key", tool: "recall",
parameters: { query: "find" }, request, signal: AbortSignal.timeout(1000) });
expect(request.mock.calls[0]![0].pathname).toBe("/api/v1/datasets/");
expect(JSON.parse(request.mock.calls[1]![1].body).datasets).toEqual(["one"]);
});
it("requires an explicit delete target and never falls back after a provider failure", async () => {
const request = vi.fn().mockRejectedValue(new Error("Cloud unavailable"));
const input = { baseUrl: "https://fixture.aws.cognee.ai", apiKey: "key", tool: "forget", parameters: {}, request, signal: AbortSignal.timeout(1000) };
await expect(callCogneeCloud(input)).rejects.toThrow("Specify a dataset");
expect(request).not.toHaveBeenCalled();
await expect(callCogneeCloud({ ...input, parameters: { dataset: "fixture" } })).rejects.toThrow("Cloud unavailable");
expect(request).toHaveBeenCalledTimes(1);
expect(JSON.parse(request.mock.calls[0]![1].body)).toEqual({ dataset: "fixture", everything: false });
});
});
@@ -0,0 +1,493 @@
import { randomUUID } from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
companies,
companyMemberships,
companySecretBindings,
companySecretVersions,
companySecrets,
connectionGrants,
createDb,
secretAccessEvents,
toolApplications,
toolCatalogEntries,
toolConnectionInstalls,
toolConnections,
toolProfileBindings,
toolProfileEntries,
toolProfiles,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
const track = vi.fn();
const isRegisteredEventName = vi.fn(() => true);
vi.mock("../telemetry.js", () => ({
getTelemetryClient: () => ({ track, isRegisteredEventName }),
}));
const { toolAccessService } = await import("../services/tool-access.js");
const { secretService } = await import("../services/secrets.js");
const { instanceSettingsService } = await import("../services/instance-settings.js");
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported
? describe
: describe.skip;
type Db = ReturnType<typeof createDb>;
function createdEvents() {
return track.mock.calls.filter(([name]) => name === "connection.created");
}
function updatedEvents() {
return track.mock.calls.filter(([name]) => name === "connection.updated");
}
async function createCompany(db: Db) {
const company = await db
.insert(companies)
.values({
name: `Lifecycle telemetry ${randomUUID()}`,
issuePrefix: `LT${randomUUID().slice(0, 6).toUpperCase()}`,
})
.returning()
.then((rows) => rows[0]!);
await db.insert(companyMemberships).values({
companyId: company.id,
principalType: "user",
principalId: actor.actorId,
status: "active",
membershipRole: "admin",
});
return company;
}
const actor = {
actorType: "user" as const,
actorId: "lifecycle-telemetry-user",
actorSource: "local_implicit" as const,
};
const mcpTool = (name: string) => ({
name,
description: name,
inputSchema: { type: "object", properties: {} },
annotations: { readOnlyHint: true },
});
/**
* Minimal remote MCP server: answers initialize, the initialized notification,
* and tools/list. Mirrors the fixture the remote-connector lifecycle tests use
* so the direct Composio MCP path here is the real gallery path, not a stub.
*/
function remoteMcpFixture() {
const requests: { url: string; headers: Headers }[] = [];
const remoteHttpRequest = async (url: string, init: RequestInit) => {
const body = JSON.parse(String(init.body)) as { id: number; method: string };
requests.push({ url, headers: new Headers(init.headers) });
if (body.method === "initialize") {
return Response.json(
{
id: body.id,
jsonrpc: "2.0",
result: { protocolVersion: "2025-06-18", capabilities: {} },
},
{ headers: { "Mcp-Session-Id": "session" } },
);
}
if (body.method === "notifications/initialized")
return new Response(null, { status: 202 });
return Response.json({
id: body.id,
jsonrpc: "2.0",
result: { tools: [mcpTool("read"), mcpTool("ask")] },
});
};
return { requests, remoteHttpRequest };
}
/**
* Retained legacy broker records (#13758): a `rest_api` parent keyed to the
* catalog slug and a `provider: composio` child. Master keeps such rows only
* for inspection and explicit removal; they cannot execute or reconnect.
*/
async function insertRetiredComposioRecords(db: Db, companyId: string) {
const [application] = await db
.insert(toolApplications)
.values({ companyId, name: "Composio", type: "rest_api", status: "active" })
.returning();
const [parent] = await db
.insert(toolConnections)
.values({
companyId,
applicationId: application!.id,
name: "Composio (legacy broker)",
uid: `composio/${randomUUID()}`,
transport: "rest_api",
authKind: "api_key",
status: "active",
enabled: true,
config: { sourceTemplateKey: "composio" },
transportConfig: { sourceTemplateKey: "composio" },
})
.returning();
const [child] = await db
.insert(toolConnections)
.values({
companyId,
applicationId: application!.id,
name: "GitHub (via Composio)",
uid: `composio/github/${randomUUID()}`,
transport: "mcp_remote",
authKind: "none",
status: "active",
enabled: true,
config: {
provider: "composio",
parentConnectionId: parent!.id,
toolkitSlug: "github",
connectedAccountId: "account-github",
},
transportConfig: {},
})
.returning();
return { parent: parent!, child: child! };
}
describeEmbeddedPostgres("connector lifecycle telemetry (tool-access)", () => {
let db!: Db;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null =
null;
let keyDir: string | null = null;
beforeAll(async () => {
keyDir = await mkdtemp(join(tmpdir(), "lifecycle-telemetry-secrets-"));
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY_FILE", join(keyDir, "key"));
tempDb = await startEmbeddedPostgresTestDatabase(
"paperclip-lifecycle-telemetry-",
);
db = createDb(tempDb.connectionString);
await instanceSettingsService(db).updateExperimental({
enableMcpAggregators: true,
});
}, 30_000);
afterEach(async () => {
track.mockClear();
await db.delete(activityLog);
await db.delete(toolProfileEntries);
await db.delete(toolProfileBindings);
await db.delete(toolProfiles);
await db.delete(toolCatalogEntries);
await db.delete(connectionGrants);
await db.delete(toolConnectionInstalls);
await db.delete(toolConnections);
await db.delete(toolApplications);
await db.delete(secretAccessEvents);
await db.delete(companySecretBindings);
await db.delete(companySecretVersions);
await db.delete(companySecrets);
await db.delete(companyMemberships);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
vi.unstubAllEnvs();
if (keyDir) await rm(keyDir, { recursive: true, force: true });
});
function service(options: Parameters<typeof toolAccessService>[1] = {}) {
return toolAccessService(db, {
remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }],
...options,
});
}
it("createConnection emits one created event with the catalog key from the committed row", async () => {
const company = await createCompany(db);
const svc = service();
await svc.createConnection(company.id, {
name: "GitHub fixture",
transport: "mcp_remote",
config: { url: "https://fixture.example/mcp", sourceTemplateKey: "github" },
enabled: true,
status: "active",
});
const events = createdEvents();
expect(events).toHaveLength(1);
expect(events[0]?.[1]).toMatchObject({
connector_key: "github",
transport: "mcp_remote",
setup_flow: "api",
status: "active",
enabled: true,
});
}, 30_000);
it("updateConnection emits committed transitions only; metadata saves stay silent", async () => {
const company = await createCompany(db);
const svc = service();
const connection = await svc.createConnection(company.id, {
name: "Custom fixture",
transport: "mcp_remote",
config: { url: "https://fixture.example/mcp" },
enabled: true,
status: "active",
});
track.mockClear();
await svc.updateConnection(connection.id, { enabled: false });
let events = updatedEvents();
expect(events).toHaveLength(1);
expect(events[0]?.[1]).toMatchObject({
connector_key: "custom",
change_source: "api",
previous_enabled: true,
enabled: false,
previous_status: "active",
status: "active",
});
track.mockClear();
await svc.updateConnection(connection.id, { name: "Renamed fixture" });
expect(updatedEvents()).toHaveLength(0);
}, 30_000);
it("direct Composio MCP setup from the gallery emits catalog identity only, never the session URL", async () => {
const company = await createCompany(db);
const remote = remoteMcpFixture();
const svc = service({
deploymentMode: "local_trusted",
deploymentExposure: "private",
remoteHttpRequest: remote.remoteHttpRequest,
});
// The same server path the Apps gallery and inline task cards call
// (POST /companies/:companyId/tools/apps/connect): Composio Connect with an
// externally configured MCP session URL.
const connected = await svc.connectGalleryApp(
company.id,
{
galleryKey: "composio",
connectionMethodKey: "mcp",
link: "https://mcp.composio.dev/session/fixture?token=fixture-secret",
authMode: "none",
},
actor,
);
expect(remote.requests.length).toBeGreaterThan(0);
const created = createdEvents();
expect(created).toHaveLength(1);
expect(created[0]?.[1]).toMatchObject({
connector_key: "composio",
transport: "mcp_remote",
auth_kind: "none",
setup_flow: "gallery",
status: connected.connection.status,
enabled: connected.connection.enabled,
});
expect(JSON.stringify(track.mock.calls)).not.toContain("fixture-secret");
expect(JSON.stringify(track.mock.calls)).not.toContain("mcp.composio.dev");
track.mockClear();
const finished = await svc.finishGalleryAppConnection(
company.id,
connected.connectionId,
{
enabledCatalogEntryIds: connected.catalog.map((entry) => entry.id),
askFirstCatalogEntryIds: [],
access: "all_agents",
},
actor,
);
// Connect commits a draft; the finish step is the committed draft -> active
// transition, so it is the one `gallery` update this setup reports.
expect(connected.connection).toMatchObject({ status: "draft", enabled: false });
expect(finished.connection).toMatchObject({ status: "active", enabled: true });
const updated = updatedEvents();
expect(updated).toHaveLength(1);
expect(updated[0]?.[1]).toMatchObject({
connector_key: "composio",
transport: "mcp_remote",
change_source: "gallery",
previous_status: "draft",
status: "active",
previous_enabled: false,
enabled: true,
});
expect(createdEvents()).toHaveLength(0);
}, 30_000);
it("pausing and resuming a direct Composio MCP connection reports api transitions with the catalog key", async () => {
const company = await createCompany(db);
const remote = remoteMcpFixture();
const svc = service({
deploymentMode: "local_trusted",
deploymentExposure: "private",
remoteHttpRequest: remote.remoteHttpRequest,
});
const connected = await svc.connectGalleryApp(
company.id,
{
galleryKey: "composio",
connectionMethodKey: "mcp",
link: "https://mcp.composio.dev/session/fixture?token=fixture-secret",
authMode: "none",
},
actor,
);
await svc.finishGalleryAppConnection(
company.id,
connected.connectionId,
{
enabledCatalogEntryIds: connected.catalog.map((entry) => entry.id),
askFirstCatalogEntryIds: [],
access: "all_agents",
},
actor,
);
track.mockClear();
await svc.updateConnection(connected.connectionId, { enabled: false });
await svc.updateConnection(connected.connectionId, { enabled: true });
const updated = updatedEvents();
expect(updated).toHaveLength(2);
expect(updated.map(([, dims]) => dims)).toEqual([
expect.objectContaining({
connector_key: "composio",
transport: "mcp_remote",
change_source: "api",
previous_enabled: true,
enabled: false,
}),
expect.objectContaining({
connector_key: "composio",
change_source: "api",
previous_enabled: false,
enabled: true,
}),
]);
// No per-app child rows exist for direct MCP, so nothing else cascades.
expect(createdEvents()).toHaveLength(0);
}, 30_000);
it("finalizing OAuth access reports the committed draft -> active transition as a gallery step", async () => {
// Direct MCP aggregator OAuth (Composio Connect via DCR) from the Apps
// gallery or an inline task card: the callback stores a personal grant,
// then finalizeOAuthAccess — POST .../apps/:id/finalize-oauth-access, also
// called by connection-intent completion — activates the draft row itself
// before handing off to the finish step.
const company = await createCompany(db);
const svc = service();
const [application] = await db
.insert(toolApplications)
.values({ companyId: company.id, name: "Composio", type: "mcp_remote", status: "active" })
.returning();
const [connection] = await db
.insert(toolConnections)
.values({
companyId: company.id,
applicationId: application!.id,
name: "Composio",
uid: `composio/${randomUUID()}`,
transport: "mcp_remote",
authKind: "oauth",
credentialPolicy: "per_user",
status: "draft",
enabled: false,
config: {
sourceTemplateKey: "composio",
connectionMethodKey: "mcp",
url: "https://connect.composio.dev/mcp",
},
transportConfig: { url: "https://connect.composio.dev/mcp" },
})
.returning();
const accessToken = await secretService(db).create(company.id, {
name: `OAuth access token ${randomUUID().slice(0, 8)}`,
key: `tool_app.${randomUUID()}.oauth_access_token`,
provider: "local_encrypted",
value: "fixture-access-token",
});
await db.insert(connectionGrants).values({
companyId: company.id,
connectionId: connection!.id,
kind: "user",
subjectUserId: actor.actorId,
status: "active",
credentialSecretRefs: [
{
secretId: accessToken.id,
versionSelector: "latest",
configPath: "oauth.access_token",
required: true,
label: "OAuth access token",
},
],
});
track.mockClear();
const finished = await svc.finalizeOAuthAccess(
company.id,
connection!.id,
{ grantKind: "user" },
actor,
);
expect(finished.connection).toMatchObject({ status: "active", enabled: true });
const updated = updatedEvents();
// Exactly one transition: finalize's own write. The finish step it calls
// afterwards re-reads an already-active row and stays silent.
expect(updated).toHaveLength(1);
expect(updated[0]?.[1]).toMatchObject({
connector_key: "composio",
transport: "mcp_remote",
auth_kind: "oauth",
change_source: "gallery",
previous_status: "draft",
status: "active",
previous_enabled: false,
enabled: true,
});
expect(createdEvents()).toHaveLength(0);
expect(JSON.stringify(track.mock.calls)).not.toContain("fixture-access-token");
}, 30_000);
it("retained legacy broker records emit only their explicit removal, distinguishable by transport", async () => {
const company = await createCompany(db);
const { parent, child } = await insertRetiredComposioRecords(db, company.id);
const svc = service();
track.mockClear();
await svc.archiveConnection(parent.id);
await svc.archiveConnection(child.id);
const updated = updatedEvents();
expect(updated).toHaveLength(2);
expect(updated[0]?.[1]).toMatchObject({
// Legacy parent: catalog slug survives, but `rest_api` marks it as the
// retired broker rather than a direct MCP connection.
connector_key: "composio",
transport: "rest_api",
change_source: "archive",
status: "archived",
});
expect(updated[1]?.[1]).toMatchObject({
// Legacy child: no catalog key; the raw toolkit slug never leaves.
connector_key: "custom",
transport: "mcp_remote",
change_source: "archive",
status: "archived",
});
expect(JSON.stringify(track.mock.calls)).not.toContain("github");
expect(JSON.stringify(track.mock.calls)).not.toContain("account-github");
expect(createdEvents()).toHaveLength(0);
}, 30_000);
});
@@ -811,14 +811,14 @@ describeEmbeddedPostgres("tool access service", () => {
process.env.PAPERCLIP_TOOL_ACCESS_TEST_DATABASE_URL?.trim();
if (externalDatabaseUrl) {
db = createDb(externalDatabaseUrl);
await instanceSettingsService(db).updateExperimental({ enableMcpAggregators: true });
await instanceSettingsService(db).updateExperimental({ enableMcpAggregators: true, enableMemoryConnectors: true });
return;
}
tempDb = await startEmbeddedPostgresTestDatabase(
"paperclip-tool-access-service-",
);
db = createDb(tempDb.connectionString);
await instanceSettingsService(db).updateExperimental({ enableMcpAggregators: true });
await instanceSettingsService(db).updateExperimental({ enableMcpAggregators: true, enableMemoryConnectors: true });
}, 20_000);
afterEach(async () => {
@@ -5092,7 +5092,7 @@ describeEmbeddedPostgres("tool access service", () => {
"youcom",
]),
);
expect(res.body.apps).toHaveLength(52);
expect(res.body.apps).toHaveLength(56);
expect(
res.body.apps.find((app: { slug: string }) => app.slug === "gmail")
.ownershipAvailability,
@@ -11589,6 +11589,149 @@ describeEmbeddedPostgres("tool access service", () => {
);
});
it.each(["mem0", "zep", "supermemory", "cognee", "honcho"])("rejects %s setup before creating credentials when memory connectors are disabled", async (provider) => {
const company = await createCompany(db);
const service = createTestToolAccessService(db);
await instanceSettingsService(db).updateExperimental({ enableMemoryConnectors: false });
try {
const response = await request(createRouteApp(db)).get(`/api/companies/${company.id}/tools/gallery`).expect(200);
expect(response.body.apps.some((app: { slug: string }) => app.slug === provider)).toBe(false);
await expect(service.connectGalleryApp(company.id, { galleryKey: provider })).rejects.toMatchObject({ status: 403, details: { code: "memory_connectors_disabled" } });
expect(await db.select().from(toolConnections).where(eq(toolConnections.companyId, company.id))).toHaveLength(0);
expect(await db.select().from(companySecrets).where(eq(companySecrets.companyId, company.id))).toHaveLength(0);
} finally {
await instanceSettingsService(db).updateExperimental({ enableMemoryConnectors: true });
}
});
it("rotates an existing Mem0 key while new memory setup is disabled", async () => {
const company = await createCompany(db);
const service = createTestToolAccessService(db);
mockToolsList([{ name: "search_memories", annotations: { readOnlyHint: true } }]);
const connected = await service.connectGalleryApp(company.id, {
galleryKey: "mem0", credentialValues: { "credentials.authorization": "old-key" },
});
await db.update(toolConnections).set({ status: "active" }).where(eq(toolConnections.id, connected.connectionId));
await instanceSettingsService(db).updateExperimental({ enableMemoryConnectors: false });
try {
const result = await service.reconnectGalleryApp(connected.connectionId, company.id,
{ credentialValues: { "credentials.authorization": "new-key" } });
expect(result.connection.id).toBe(connected.connectionId);
expect(result.connection.healthStatus).toBe("ok");
} finally {
await instanceSettingsService(db).updateExperimental({ enableMemoryConnectors: true });
}
});
it("keeps active Cognee tools available during a transient Cloud probe outage", async () => {
const company = await createCompany(db);
const service = createTestToolAccessService(db);
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("[]", { status: 200 }));
const connected = await service.connectGalleryApp(company.id, {
galleryKey: "cognee", credentialValues: { "env.COGNEE_BASE_URL": "https://fixture.aws.cognee.ai", "env.COGNEE_API_KEY": "key" },
});
await db.update(toolConnections).set({ status: "active" }).where(eq(toolConnections.id, connected.connectionId));
fetchMock.mockReset().mockRejectedValue(new Error("temporary timeout"));
const result = await service.checkHealth(connected.connectionId);
expect(result.connection.healthStatus).toBe("ok");
expect(fetchMock).not.toHaveBeenCalled();
const catalog = await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, connected.connectionId));
expect(catalog).toHaveLength(3);
});
it("creates a user-owned value when reconnect restores a missing personal credential field", async () => {
const company = await createCompany(db);
const service = createTestToolAccessService(db);
mockToolsList([{ name: "search_memories", annotations: { readOnlyHint: true } }]);
const actor = { actorType: "user" as const, actorId: "personal-reconnect-owner" };
const connected = await service.connectGalleryApp(company.id, {
galleryKey: "mem0", grantKind: "user", credentialValues: { "credentials.authorization": "old-key" },
}, actor);
const { grants } = await service.listConnectionGrants(connected.connectionId, company.id);
await db.update(connectionGrants).set({ credentialSecretRefs: [] }).where(eq(connectionGrants.id, grants[0]!.id));
await service.reconnectGalleryApp(connected.connectionId, company.id,
{ credentialValues: { "credentials.authorization": "restored-key" } }, actor);
const after = await service.listConnectionGrants(connected.connectionId, company.id);
const ref = after.grants[0]!.credentialSecretRefs[0]!;
const [secret] = await db.select().from(companySecrets).where(eq(companySecrets.id, ref.secretId));
expect(secret).toMatchObject({ scope: "user", ownerUserId: actor.actorId });
const resolved = await secretService(db).resolveUserSecretValue(company.id, {
definitionId: secret.userSecretDefinitionId!, responsibleUserId: actor.actorId,
}, { consumerType: "tool_connection", consumerId: connected.connectionId, configPath: ref.configPath,
actorType: "system", actorId: null, responsibleUserId: actor.actorId });
expect(resolved?.value).toBe("restored-key");
expect((await service.getConnection(connected.connectionId, company.id)).credentialSecretRefs).toEqual([]);
});
it("keeps rejected Mem0 API keys on the key-entry path rather than switching to OAuth", async () => {
const company = await createCompany(db);
const service = createTestToolAccessService(db);
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("unauthorized", { status: 401, headers: { "www-authenticate": "Bearer" } }));
await expect(service.connectGalleryApp(company.id, {
galleryKey: "mem0", credentialValues: { "credentials.authorization": "invalid-key" },
})).rejects.toMatchObject({ status: 422, details: { code: "memory_api_key_rejected" } });
expect(await db.select().from(companySecrets).where(eq(companySecrets.companyId, company.id))).toHaveLength(0);
});
it("vaults Cognee environment credentials and verifies Cloud access before exposing its reviewed tools", async () => {
const company = await createCompany(db);
const service = createTestToolAccessService(db);
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("[]", { status: 200 }));
const key = "cognee-private-fixture-key";
const connected = await service.connectGalleryApp(company.id, {
galleryKey: "cognee",
credentialValues: { "env.COGNEE_BASE_URL": "https://fixture.aws.cognee.ai", "env.COGNEE_API_KEY": key },
});
expect(fetchMock).toHaveBeenCalledWith("https://fixture.aws.cognee.ai/api/v1/datasets/", expect.objectContaining({
method: "GET", headers: { "X-Api-Key": key }, redirect: "manual",
}));
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId));
expect(connection.config).toMatchObject({ templateId: "paperclip.cognee-cloud" });
expect(connection.credentialSecretRefs.map(ref => ref.configPath).sort()).toEqual(["env.COGNEE_API_KEY", "env.COGNEE_BASE_URL"]);
expect(JSON.stringify(connected)).not.toContain(key);
expect(JSON.stringify(connection)).not.toContain(key);
const catalog = await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, connected.connectionId));
expect(catalog.map(entry => [entry.toolName, entry.riskLevel]).sort()).toEqual([["forget", "destructive"], ["recall", "read"], ["remember", "write"]]);
});
it("resolves personal Cognee credentials through their owner and durable declarations", async () => {
const company = await createCompany(db);
const service = createTestToolAccessService(db);
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("[]", { status: 200 }));
const connected = await service.connectGalleryApp(company.id, {
galleryKey: "cognee", grantKind: "user",
credentialValues: { "env.COGNEE_BASE_URL": "https://fixture.aws.cognee.ai", "env.COGNEE_API_KEY": "personal-cognee-key" },
}, { actorType: "user", actorId: "carol" });
const { grants } = await service.listConnectionGrants(connected.connectionId, company.id);
expect(grants).toHaveLength(1);
expect(connected.connection.credentialSecretRefs).toEqual([]);
const vault = secretService(db);
for (const ref of grants[0]!.credentialSecretRefs) {
const [secret] = await db.select().from(companySecrets).where(eq(companySecrets.id, ref.secretId));
expect(secret).toMatchObject({ scope: "user", ownerUserId: "carol" });
const resolved = await vault.resolveUserSecretValue(company.id, {
definitionId: secret.userSecretDefinitionId, responsibleUserId: "carol",
}, { consumerType: "tool_connection", consumerId: connected.connectionId, configPath: ref.configPath,
actorType: "system", actorId: null, responsibleUserId: "carol" });
expect(resolved?.value).toBe(ref.configPath === "env.COGNEE_API_KEY" ? "personal-cognee-key" : "https://fixture.aws.cognee.ai");
await expect(vault.resolveUserSecretValue(company.id, {
definitionId: secret.userSecretDefinitionId, responsibleUserId: "another-user",
})).rejects.toMatchObject({ status: 422, details: { code: "user_secret_missing" } });
}
});
it.each(["organization", "user"] as const)("cleans up a rejected %s Cognee connection and its vault records", async (grantKind) => {
const company = await createCompany(db);
const service = createTestToolAccessService(db);
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("unauthorized", { status: 401 }));
await expect(service.connectGalleryApp(company.id, {
galleryKey: "cognee", grantKind, credentialValues: { "env.COGNEE_BASE_URL": "https://fixture.aws.cognee.ai", "env.COGNEE_API_KEY": "invalid-key" },
}, { actorType: "user", actorId: "carol" })).rejects.toMatchObject({ status: 422, details: { code: "cognee_access_unverified" } });
expect(await db.select().from(userSecretDefinitions).where(eq(userSecretDefinitions.companyId, company.id))).toHaveLength(0);
expect(await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.companyId, company.id))).toHaveLength(0);
expect(await db.select().from(companySecrets).where(eq(companySecrets.companyId, company.id))).toHaveLength(0);
});
it("retains an encrypted API key when the same draft method resumes", async () => {
const company = await createCompany(db);
const service = createTestToolAccessService(db);
@@ -0,0 +1,571 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
agents,
companies,
companyMemberships,
connectionGrants,
createDb,
heartbeatRuns,
issues,
projects,
toolAccessAuditEvents,
toolActionRequests,
toolApplications,
toolCallEvents,
toolCatalogEntries,
toolConnections,
toolGatewayRateLimitCounters,
toolGatewaySessions,
toolInvocations,
toolPolicies,
toolProfileBindings,
toolProfiles,
toolRuntimeSlots,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
// Proposal wrappers drop unregistered names inside the real client, so the
// integration assertions use a client double that reports the proposed event
// as registered. `track` observes exactly what the emitter would hand the
// real client after schema adoption.
const track = vi.fn();
const isRegisteredEventName = vi.fn(() => true);
vi.mock("../telemetry.js", () => ({
getTelemetryClient: () => ({ track, isRegisteredEventName }),
}));
// Deterministic reproduction of "post-execution bookkeeping failure": the
// gateway's success path awaits writeAudit -> logActivity after the succeeded
// save; failing that call lands in the catch that overwrites the row to
// failed.
const failLogActivityForAction = { current: null as string | null };
vi.mock("../services/activity-log.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../services/activity-log.js")>();
return {
...actual,
logActivity: vi.fn(
async (...args: Parameters<typeof actual.logActivity>) => {
const input = args[1] as { action?: string };
if (
failLogActivityForAction.current &&
input?.action === failLogActivityForAction.current
) {
throw new Error(`forced activity write failure: ${input.action}`);
}
return actual.logActivity(...args);
},
),
};
});
const { createToolGatewayService } = await import(
"../services/tool-gateway.js"
);
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported
? describe
: describe.skip;
type Db = ReturnType<typeof createDb>;
const SIGNING_SECRET = "connector-telemetry-signing-secret";
function mcpSuccessResponse(init: RequestInit): Response {
const body = JSON.parse(String(init.body ?? "{}"));
return new Response(
JSON.stringify({
jsonrpc: "2.0",
id: body?.id ?? "fixture",
result: { content: [{ type: "text", text: "ok" }] },
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
async function createCompany(db: Db) {
return db
.insert(companies)
.values({
name: `Connector telemetry ${randomUUID()}`,
issuePrefix: `CT${randomUUID().slice(0, 6).toUpperCase()}`,
})
.returning()
.then((rows) => rows[0]!);
}
async function createAgent(db: Db, companyId: string) {
return db
.insert(agents)
.values({
companyId,
name: `Agent ${randomUUID()}`,
role: "engineer",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
})
.returning()
.then((rows) => rows[0]!);
}
async function createIssueAndRun(db: Db, companyId: string, agentId: string) {
const project = await db
.insert(projects)
.values({ companyId, name: `Project ${randomUUID()}` })
.returning()
.then((rows) => rows[0]!);
const issue = await db
.insert(issues)
.values({
companyId,
projectId: project.id,
title: `Telemetry issue ${randomUUID()}`,
status: "in_progress",
assigneeAgentId: agentId,
})
.returning()
.then((rows) => rows[0]!);
const run = await db
.insert(heartbeatRuns)
.values({
companyId,
agentId,
invocationSource: "assignment",
status: "running",
contextSnapshot: { issueId: issue.id, projectId: project.id },
})
.returning()
.then((rows) => rows[0]!);
return { issue, run };
}
async function allowAllToolsForAgent(db: Db, companyId: string, agentId: string) {
const profile = await db
.insert(toolProfiles)
.values({
companyId,
profileKey: `telemetry-all-${randomUUID()}`,
name: `Telemetry profile ${randomUUID()}`,
defaultAction: "allow",
})
.returning()
.then((rows) => rows[0]!);
await db.insert(toolProfileBindings).values({
companyId,
profileId: profile.id,
targetType: "agent",
targetId: agentId,
});
}
async function createRemoteMcpTool(db: Db, companyId: string) {
const [application] = await db
.insert(toolApplications)
.values({
companyId,
applicationKey: `telemetry-app-${randomUUID().slice(0, 8)}`,
name: `Remote app ${randomUUID()}`,
type: "mcp_http",
status: "active",
})
.returning();
const config = {
url: "https://mcp.telemetry.test/mcp",
sourceTemplateKey: "github",
};
const [connection] = await db
.insert(toolConnections)
.values({
companyId,
applicationId: application!.id,
name: `Remote connection ${randomUUID()}`,
uid: `test/${randomUUID()}`,
transport: "mcp_remote",
status: "active",
enabled: true,
healthStatus: "ok",
config,
transportConfig: config,
credentialRefs: [],
credentialSecretRefs: [],
})
.returning();
await db.insert(connectionGrants).values({
companyId,
connectionId: connection!.id,
kind: "organization",
credentialSecretRefs: [],
status: "active",
isDefault: true,
});
const [catalogEntry] = await db
.insert(toolCatalogEntries)
.values({
companyId,
applicationId: application!.id,
connectionId: connection!.id,
entryKind: "tool",
name: `kv_set-${randomUUID()}`,
toolName: "kv_set",
title: "KV Set",
description: "Set a value",
inputSchema: {
type: "object",
properties: { key: { type: "string" }, value: { type: "string" } },
required: ["key", "value"],
additionalProperties: false,
},
annotations: { readOnlyHint: false },
riskLevel: "write",
isReadOnly: false,
isWrite: true,
isDestructive: false,
status: "active",
versionHash: randomUUID(),
})
.returning();
return { application: application!, connection: connection!, catalogEntry: catalogEntry! };
}
async function settledEvents(expectedCount: number) {
await vi.waitFor(() => {
expect(
track.mock.calls.filter(
([name]) => name === "connection.invoked",
).length,
).toBeGreaterThanOrEqual(expectedCount);
});
// The emitter runs as detached background work; give any surplus emission a
// chance to land before asserting the exact count.
await new Promise((resolve) => setTimeout(resolve, 150));
return track.mock.calls.filter(
([name]) => name === "connection.invoked",
);
}
describeEmbeddedPostgres("gateway connector invocation telemetry", () => {
let db!: Db;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null =
null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase(
"paperclip-connector-telemetry-",
);
db = createDb(tempDb.connectionString);
}, 30_000);
afterEach(async () => {
track.mockClear();
failLogActivityForAction.current = null;
await db.delete(activityLog);
await db.delete(toolCallEvents);
await db.delete(toolRuntimeSlots);
await db.delete(toolGatewaySessions);
await db.delete(toolGatewayRateLimitCounters);
await db.delete(toolActionRequests);
await db.delete(toolInvocations);
await db.delete(toolAccessAuditEvents);
await db.delete(toolPolicies);
await db.delete(toolProfileBindings);
await db.delete(toolProfiles);
await db.delete(toolCatalogEntries);
await db.delete(connectionGrants);
await db.delete(toolConnections);
await db.delete(toolApplications);
await db.delete(heartbeatRuns);
await db.delete(issues);
await db.delete(projects);
await db.delete(agents);
await db.delete(companyMemberships);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function fixture(options: Parameters<typeof createToolGatewayService>[1] = {}) {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
const { run } = await createIssueAndRun(db, company.id, agent.id);
const remoteTool = await createRemoteMcpTool(db, company.id);
await allowAllToolsForAgent(db, company.id, agent.id);
const gateway = createToolGatewayService(db, {
toolActionSigningSecret: SIGNING_SECRET,
remoteHttpRequest: async (_url, init) => mcpSuccessResponse(init),
...options,
});
const session = await gateway.createSession({
companyId: company.id,
agentId: agent.id,
runId: run.id,
});
const tool = (await gateway.listToolsForSession(session.token)).find(
(candidate) => candidate.connectionId === remoteTool.connection.id,
);
expect(tool).toBeDefined();
return { company, agent, run, remoteTool, gateway, session, tool: tool! };
}
it("emits exactly one succeeded event for a successful agent execution", async () => {
const f = await fixture();
await expect(
f.gateway.executeTool({
sessionToken: f.session.token,
tool: f.tool.name,
parameters: { key: "a", value: "1" },
}),
).resolves.toMatchObject({ status: "completed" });
const events = await settledEvents(1);
expect(events).toHaveLength(1);
expect(events[0]?.[1]).toMatchObject({
connector_key: "github",
transport: "mcp_remote",
status: "succeeded",
origin: "agent",
});
}, 30_000);
it("emits a single failed event when post-success audit work throws (no contradictory pair)", async () => {
const f = await fixture();
failLogActivityForAction.current = "tool_gateway.call_completed";
await expect(
f.gateway.executeTool({
sessionToken: f.session.token,
tool: f.tool.name,
parameters: { key: "a", value: "1" },
}),
).rejects.toThrow(/forced activity write failure/);
const [invocation] = await db.select().from(toolInvocations);
expect(invocation?.status).toBe("failed");
const events = await settledEvents(1);
expect(events).toHaveLength(1);
expect(events[0]?.[1]).toMatchObject({ status: "failed", origin: "agent" });
expect(
events.some(([, dims]) => (dims as { status: string }).status === "succeeded"),
).toBe(false);
}, 30_000);
it("emits one failed event when the remote execution itself fails", async () => {
const f = await fixture({
remoteHttpRequest: async () =>
new Response("upstream exploded", { status: 500 }),
});
await expect(
f.gateway.executeTool({
sessionToken: f.session.token,
tool: f.tool.name,
parameters: { key: "a", value: "1" },
}),
).rejects.toThrow();
const events = await settledEvents(1);
expect(events).toHaveLength(1);
expect(events[0]?.[1]).toMatchObject({ status: "failed" });
}, 30_000);
it("emits one denied event for a policy block and one rate_limited event past the limit", async () => {
const f = await fixture();
await db.insert(toolPolicies).values({
companyId: f.company.id,
name: "Block telemetry tool",
policyType: "block",
selectors: { connectionId: f.remoteTool.connection.id },
priority: 1,
});
await expect(
f.gateway.executeTool({
sessionToken: f.session.token,
tool: f.tool.name,
parameters: { key: "a", value: "1" },
}),
).rejects.toMatchObject({ reasonCode: expect.any(String) });
let events = await settledEvents(1);
expect(events).toHaveLength(1);
expect(events[0]?.[1]).toMatchObject({ status: "denied", origin: "agent" });
track.mockClear();
await db.delete(toolPolicies);
await db.insert(toolPolicies).values({
companyId: f.company.id,
name: "One call only",
policyType: "rate_limit",
selectors: { connectionId: f.remoteTool.connection.id },
config: { limit: 1, windowSeconds: 60 },
priority: 1,
});
await expect(
f.gateway.executeTool({
sessionToken: f.session.token,
tool: f.tool.name,
parameters: { key: "rate", value: "first" },
}),
).resolves.toMatchObject({ status: "completed" });
await expect(
f.gateway.executeTool({
sessionToken: f.session.token,
tool: f.tool.name,
parameters: { key: "rate", value: "second" },
}),
).rejects.toMatchObject({ status: 429 });
events = await settledEvents(2);
expect(events).toHaveLength(2);
expect(events.map(([, dims]) => (dims as { status: string }).status).sort()).toEqual(
["rate_limited", "succeeded"],
);
}, 30_000);
it("approval flow: pending emits nothing, decline emits denied, approve emits succeeded", async () => {
const f = await fixture();
await db.insert(toolPolicies).values({
companyId: f.company.id,
name: "Review telemetry tool",
policyType: "require_approval",
selectors: { connectionId: f.remoteTool.connection.id },
priority: 1,
});
await f.gateway
.executeTool({
sessionToken: f.session.token,
tool: f.tool.name,
parameters: { key: "a", value: "declined" },
})
.then(
() => {
throw new Error("expected approval_required");
},
(error) => expect(error).toMatchObject({ reasonCode: "approval_required" }),
);
// Awaiting approval is in-flight: no completion event may exist yet. The
// approval card itself emits the registered interaction.created event, so
// filter to the proposed connector event.
await new Promise((resolve) => setTimeout(resolve, 150));
expect(
track.mock.calls.filter(([name]) => name === "connection.invoked"),
).toHaveLength(0);
const [pendingRequest] = await db.select().from(toolActionRequests);
await f.gateway.declineActionRequest({
companyId: f.company.id,
actionRequestId: pendingRequest!.id,
actor: { userId: "board" },
});
let events = await settledEvents(1);
expect(events).toHaveLength(1);
expect(events[0]?.[1]).toMatchObject({ status: "denied", origin: "agent" });
track.mockClear();
await f.gateway
.executeTool({
sessionToken: f.session.token,
tool: f.tool.name,
parameters: { key: "a", value: "approved" },
})
.then(
() => {
throw new Error("expected approval_required");
},
(error) => expect(error).toMatchObject({ reasonCode: "approval_required" }),
);
const approvable = await db
.select()
.from(toolActionRequests)
.where(eq(toolActionRequests.status, "pending"))
.then((rows) => rows[0]!);
await f.gateway.approveActionRequest({
companyId: f.company.id,
actionRequestId: approvable.id,
actor: { userId: "board" },
});
events = await settledEvents(1);
expect(events).toHaveLength(1);
expect(events[0]?.[1]).toMatchObject({ status: "succeeded", origin: "agent" });
}, 40_000);
it("labels connection-test executions as setup_test", async () => {
const f = await fixture();
await db.insert(companyMemberships).values({
companyId: f.company.id,
principalType: "user",
principalId: "board-user",
status: "active",
membershipRole: "member",
});
const testCall = await f.gateway.executeTestCall({
companyId: f.company.id,
connectionId: f.remoteTool.connection.id,
agentId: f.agent.id,
userId: "board-user",
toolName: f.tool.name,
parameters: { key: "t", value: "1" },
});
expect(testCall).toMatchObject({ decision: "allowed" });
expect(testCall).not.toHaveProperty("error");
const events = await settledEvents(1);
expect(events).toHaveLength(1);
expect(events[0]?.[1]).toMatchObject({
status: "succeeded",
origin: "setup_test",
});
}, 30_000);
it("an idempotent replay returns the stored result without a second event", async () => {
const f = await fixture();
const idempotencyKey = `telemetry-replay-${randomUUID()}`;
await expect(
f.gateway.executeTool({
sessionToken: f.session.token,
tool: f.tool.name,
parameters: { key: "a", value: "1" },
idempotencyKey,
}),
).resolves.toMatchObject({ status: "completed" });
await settledEvents(1);
await expect(
f.gateway.executeTool({
sessionToken: f.session.token,
tool: f.tool.name,
parameters: { key: "a", value: "1" },
idempotencyKey,
}),
).resolves.toMatchObject({ status: "replayed" });
const events = await settledEvents(1);
expect(events).toHaveLength(1);
}, 30_000);
it("telemetry sink failures never fail the tool call", async () => {
const f = await fixture();
track.mockImplementation(() => {
throw new Error("sink offline");
});
await expect(
f.gateway.executeTool({
sessionToken: f.session.token,
tool: f.tool.name,
parameters: { key: "a", value: "1" },
}),
).resolves.toMatchObject({ status: "completed" });
const [invocation] = await db.select().from(toolInvocations);
expect(invocation?.status).toBe("succeeded");
track.mockReset();
}, 30_000);
});
@@ -1439,16 +1439,14 @@ describeEmbeddedPostgres("tool gateway service", () => {
const tool = (await gateway.listToolsForSession(session.token))
.find((candidate) => candidate.providerType === "mcp_remote_http");
const result = await gateway.executeTool({
await expect(gateway.executeTool({
sessionToken: session.token,
tool: tool!.name,
parameters: {},
})).rejects.toMatchObject({
reasonCode: "tool_error",
message: expect.stringContaining("enroll the signed-in Workspace account and this OAuth client's Google Cloud project"),
});
expect(result.status).toBe("completed");
expect((result.result as { content?: string }).content).toContain(
"enroll the signed-in Workspace account and this OAuth client's Google Cloud project",
);
});
it("injects Vercel tokens at dispatch and refreshes exactly once after an upstream 401", async () => {
+152
View File
@@ -54,6 +54,7 @@ import {
} from "../services/tool-content-guards.js";
import { createToolGatewayService, ToolGatewayHttpError } from "../services/tool-gateway.js";
import { secretService } from "../services/secrets.js";
import * as cogneeBridge from "../services/cognee-connection.js";
import { createKvDemoHttpServer, type KvDemoHttpServer } from "../../../packages/kv-demo-mcp-server/src/http.js";
import {
getEmbeddedPostgresTestSupport,
@@ -582,6 +583,72 @@ describeEmbeddedPostgres("tool gateway acceptance", () => {
await tempDb?.cleanup();
});
it("preserves provider tool errors in MCP responses and failed invocation audits", async () => {
const company = await createCompany(db);
const remote = await startFakeRemoteMcpServer(({ body }) => ({
body: {
jsonrpc: "2.0",
id: body?.id,
result: { content: [{ type: "text", text: "Search denied: outside consented tag" }], isError: true },
},
}));
try {
const { application, connection, catalogEntry } = await createRemoteMcpTool(db, company.id, {
url: remote.url, toolName: "search_memory", riskLevel: "read",
});
await db.update(toolCatalogEntries).set({
inputSchema: { type: "object", properties: {}, additionalProperties: false },
}).where(eq(toolCatalogEntries.id, catalogEntry.id));
const toolName = expectedConnectedToolName({
applicationKey: application.applicationKey,
connectionId: connection.id,
toolName: catalogEntry.toolName,
});
const [profile] = await db.insert(toolProfiles).values({
companyId: company.id, profileKey: `error-${randomUUID()}`,
name: "Provider error test", defaultAction: "deny",
}).returning();
await db.insert(toolProfileEntries).values({
companyId: company.id, profileId: profile.id,
selectorType: "tool_name", effect: "include", toolName,
});
const gateway = createTestToolGatewayService(db);
const named = await gateway.createNamedGateway({
companyId: company.id, body: { name: "Provider error test", profileId: profile.id },
});
const token = await gateway.createNamedGatewayToken({
companyId: company.id, gatewayId: named.id, body: { name: "Test" },
});
const response = await request(createGatewayRouteApp(db, gateway))
.post(named.endpointPath)
.set("authorization", `Bearer ${token.token}`)
.send({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: toolName, arguments: {} } })
.expect(200);
expect(response.body.result).toEqual({
content: [{ type: "text", text: "Search denied: outside consented tag" }], isError: true,
});
expect(await db.select().from(toolInvocations).where(eq(toolInvocations.companyId, company.id)))
.toEqual([expect.objectContaining({ status: "failed", errorCode: "tool_error" })]);
const events = await db.select().from(toolCallEvents).where(eq(toolCallEvents.companyId, company.id));
expect(events).toContainEqual(expect.objectContaining({ eventType: "call_failed", outcome: "failure", reasonCode: "tool_error" }));
expect(events.some((event) => event.eventType === "call_completed")).toBe(false);
const agent = await createAgent(db, company.id);
await allowAllToolsForAgent(db, company.id, agent.id);
await db.insert(companyMemberships).values({ companyId: company.id, principalType: "user",
principalId: "test-user", status: "active", membershipRole: "member" });
const testCall = await gateway.executeTestCall({ companyId: company.id, connectionId: connection.id,
agentId: agent.id, userId: "test-user", toolName, parameters: {} });
expect(testCall).toMatchObject({ decision: "allowed", error: { reasonCode: "tool_error" } });
expect(await db.select().from(toolInvocations).where(eq(toolInvocations.id, testCall.invocationId)))
.toEqual([expect.objectContaining({ status: "failed", errorCode: "tool_error" })]);
const testEvents = await db.select().from(toolCallEvents).where(eq(toolCallEvents.invocationId, testCall.invocationId));
expect(testEvents.some(event => event.eventType === "call_failed")).toBe(true);
expect(testEvents.some(event => event.eventType === "call_completed")).toBe(false);
} finally {
await remote.close();
}
});
it("exposes a named gateway with scoped bearer-token auth and revocation", async () => {
const company = await createCompany(db);
const remote = await startFakeRemoteMcpServer(async ({ body }) => ({
@@ -1659,6 +1726,54 @@ rl.on("line", (line) => {
}
});
it.each(["credentials.authorization", "headers.X-Api-Key", "oauth.access_token"])(
"resolves a personal remote credential using its declared %s path",
async (configPath) => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
const { run } = await createIssueAndRun(db, company.id, agent.id);
await createActiveMember(db, company.id, "alice");
await db.update(heartbeatRuns).set({ responsibleUserId: "alice" }).where(eq(heartbeatRuns.id, run.id));
const secrets = secretService(db);
const definition = await secrets.createUserSecretDefinition(company.id, {
name: "Personal remote token",
key: `personal_remote_${randomUUID().replace(/-/g, "")}`,
provider: "local_encrypted",
});
const secret = await secrets.createCurrentUserSecretValue(company.id, "alice", {
definitionId: definition.id, value: "personal-remote-test-token",
});
const remote = await createRemoteMcpTool(db, company.id);
await db.update(toolConnections).set({
credentialPolicy: "per_user",
credentialRefs: [{ name: configPath, secretId: secret.id, placement: "header", key: "Authorization", prefix: "Bearer " }],
}).where(eq(toolConnections.id, remote.connection.id));
await secrets.syncUserSecretDeclarationsForTarget(company.id,
{ targetType: "tool_connection", targetId: remote.connection.id },
[{ definitionKey: definition.key, configPath, envKey: "Authorization", versionSelector: "latest", required: true }],
{ replaceAll: true });
await db.insert(connectionGrants).values({
companyId: company.id, connectionId: remote.connection.id, kind: "user", subjectUserId: "alice",
credentialSecretRefs: [{ secretId: secret.id, configPath, versionSelector: "latest" }],
status: "active", isDefault: false,
});
await allowAllToolsForAgent(db, company.id, agent.id);
const headers: string[] = [];
const gateway = createTestToolGatewayService(db, { remoteHttpRequest: async (_url, init) => {
headers.push(new Headers(init.headers).get("authorization") ?? "");
const body = JSON.parse(String(init.body)) as { id: string };
return new Response(JSON.stringify({ jsonrpc: "2.0", id: body.id,
result: { content: [{ type: "text", text: "retrieved synthetic memory" }] } }),
{ headers: { "content-type": "application/json" } });
} });
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
const tool = (await gateway.listToolsForSession(session.token)).find(t => t.connectionId === remote.connection.id)!;
expect((await gateway.executeTool({ sessionToken: session.token, tool: tool.name,
parameters: { key: "test", value: "synthetic" } })).status).toBe("completed");
expect(headers).toEqual(["Bearer personal-remote-test-token"]);
},
);
it("passes only the selected grant identity to local stdio MCP processes", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
@@ -5089,6 +5204,43 @@ rl.on("line", (line) => {
});
});
it("calls bundled Cognee in public mode and recovers from provider errors without runtime backoff", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
const { run } = await createIssueAndRun(db, company.id, agent.id);
const refs = await Promise.all(Object.entries({
COGNEE_BASE_URL: "https://fixture.aws.cognee.ai",
COGNEE_API_KEY: "fixture-key",
}).map(async ([key, value]) => {
const secret = await secretService(db).create(company.id, {
name: key, key: `${key}_${randomUUID().replace(/-/g, "")}`,
provider: "local_encrypted", value,
});
return { secretId: secret.id, versionSelector: "latest", configPath: `env.${key}`, required: true };
}));
const local = await createLocalStdioMcpTool(db, company.id, {
applicationKey: "cognee", toolName: "recall",
connectionConfig: { templateId: "paperclip.cognee-cloud" }, credentialSecretRefs: refs,
});
await allowAllToolsForAgent(db, company.id, agent.id);
const bridge = vi.spyOn(cogneeBridge, "callCogneeCloud")
.mockRejectedValueOnce(new Error("Cloud temporarily unavailable"))
.mockResolvedValue({ content: [{ type: "text", text: "recalled synthetic memory" }],
structuredContent: { result: "recalled synthetic memory" }, isError: false });
const gateway = createTestToolGatewayService(db, {
deploymentMode: "authenticated", deploymentExposure: "public", trustedLocalStdioRuntimeHost: null,
});
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
const tool = (await gateway.listToolsForSession(session.token)).find(t => t.connectionId === local.connection.id)!;
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name,
parameters: { message: "synthetic" } })).rejects.toThrow("Cloud temporarily unavailable");
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name,
parameters: { message: "synthetic" } })).resolves.toMatchObject({ status: "completed" });
expect(bridge).toHaveBeenCalledTimes(2);
expect(await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.connectionId, local.connection.id))).toHaveLength(0);
bridge.mockRestore();
});
it("fails closed for hosted public local stdio unless a trusted runtime host is configured", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
+2
View File
@@ -5,6 +5,7 @@ import { projectToolRoutes } from "./routes/project-tools.js";
import { emailChannelService } from "./services/email-channels.js";
import { emailRoutes, emailWebhookRoutes } from "./routes/email.js";
import { toolActionDeliveryService } from "./services/tool-action-delivery.js";
import { registerAssignedMcpGateway } from "./services/native-runtime/assigned-mcp-tools.js";
import express, { Router, type Request as ExpressRequest } from "express";
import {
createServer as createHttpServer,
@@ -843,6 +844,7 @@ export async function createApp(
}));
api.use(slackToolRoutes(db, opts.authPublicBaseUrl));
app.locals.toolGateway = toolGateway;
registerAssignedMcpGateway(db, toolGateway);
app.locals.toolActionDeliveries = toolActionDeliveries;
app.use(mcpGatewayProtocolRoutes(toolGateway));
api.use(aiConnectionRoutes(db, { deploymentMode: opts.deploymentMode, deploymentExposure: opts.deploymentExposure, trustedLocalStdioRuntimeHost }));
+16
View File
@@ -13679,6 +13679,7 @@ export function issueRoutes(
{
attachmentIds: commentAttachmentIds,
clientRequestId: actor.actorType === "user" ? commentClientRequestId : undefined,
mirrorToSlack: actor.actorType === "user",
authorizationReason: issueMutationAuthorizationReason,
sourceTrust: attachmentCommentSourceTrust,
},
@@ -14284,6 +14285,7 @@ export function issueRoutes(
{
authorizationReason: issueMutationAuthorizationReason,
clientRequestId: actor.actorType === "user" ? commentClientRequestId : undefined,
mirrorToSlack: actor.actorType === "user",
sourceTrust: await sourceTrustForActorWrite(issue, actor),
},
);
@@ -14291,6 +14293,7 @@ export function issueRoutes(
await externalObjectsSvc.syncCommentSafely(comment.id);
if (
issue.assigneeAgentId &&
!issue.externalConversationState &&
!(
actor.actorType === "agent" &&
actor.actorId === issue.assigneeAgentId
@@ -14491,6 +14494,11 @@ export function issueRoutes(
typeof wakeup.payload.issueId === "string"
? wakeup.payload.issueId
: issue.id;
// Provider turns use the task identifier as their session key. Board
// messages must resume that same session instead of creating a UUID-keyed fork.
if (wakeIssueId === issue.id && issue.externalConversationState && issue.identifier) {
wakeup.contextSnapshot = { ...wakeup.contextSnapshot, taskKey: issue.identifier };
}
wakeups.set(`${agentId}:${wakeIssueId}`, { agentId, wakeup });
};
const addDependencyResolvedWakeup = async (input: {
@@ -17635,6 +17643,7 @@ export function issueRoutes(
metadata: req.body.metadata ?? null,
attachmentIds: req.body.attachmentIds,
clientRequestId: actor.actorType === "user" ? req.body.clientRequestId : undefined,
mirrorToSlack: actor.actorType === "user",
sourceTrust,
};
let txResult: {
@@ -17747,6 +17756,7 @@ export function issueRoutes(
metadata: req.body.metadata ?? null,
attachmentIds: req.body.attachmentIds,
clientRequestId: actor.actorType === "user" ? req.body.clientRequestId : undefined,
mirrorToSlack: actor.actorType === "user",
authorizationReason: commentAuthorizationReason,
sourceTrust: await sourceTrustForActorWrite(currentIssue, actor),
};
@@ -17772,6 +17782,9 @@ export function issueRoutes(
await externalObjectsSvc.syncCommentSafely(comment.id);
if (
currentIssue.assigneeAgentId &&
// Slack-linked messages need the normal attributed wake boundary so
// the accepted requester and return-thread receipt travel with the run.
!currentIssue.externalConversationState &&
!(
actor.actorType === "agent" &&
actor.actorId === currentIssue.assigneeAgentId
@@ -17955,6 +17968,9 @@ export function issueRoutes(
: currentIssue.id;
const key = `${agentId}:${wakeIssueId}`;
if (wakeups.has(key)) return;
if (wakeIssueId === currentIssue.id && issue.externalConversationState && currentIssue.identifier) {
wakeup.contextSnapshot = { ...wakeup.contextSnapshot, taskKey: currentIssue.identifier };
}
wakeups.set(key, { agentId, wakeup });
};
const addDependencyResolvedWakeup = async (input: {
+1
View File
@@ -157,6 +157,7 @@ export function slackToolRoutes(db: Db, publicBaseUrl?: string) {
issueId,
runId: req.actor.runId,
agentId: req.actor.agentId,
endpointId: call.endpointId,
},
call.tool,
call.arguments,
+3 -2
View File
@@ -5,6 +5,7 @@ import { and, eq, or } from "drizzle-orm";
import {
APP_STORE_DEFINITIONS,
isRemoteMcpConnectorId,
isMemoryConnectorId,
GITHUB_CONNECTOR_PROFILES,
GOOGLE_WORKSPACE_CONNECTOR_PROFILES,
isAgentStatusAssignableToWork,
@@ -815,7 +816,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
? await options.paperclipCloudConnector.getCapabilities()
: [];
const vercelConnect = vercelConnectIntegrationStatus();
const { enableMcpAggregators } = await instanceSettingsService(db).getExperimental();
const { enableMcpAggregators, enableMemoryConnectors } = await instanceSettingsService(db).getExperimental();
res.json({
capabilities: await describeConnectionCreateCapabilities(req, companyId),
credentialSources: {
@@ -831,7 +832,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
: "Vercel Connect setup is disabled on this Paperclip instance.",
},
},
apps: APP_STORE_DEFINITIONS.filter((app) => enableMcpAggregators || !isRemoteMcpConnectorId(app.slug)).map((app) =>
apps: APP_STORE_DEFINITIONS.filter((app) => (enableMcpAggregators || !isRemoteMcpConnectorId(app.slug)) && (enableMemoryConnectors || !isMemoryConnectorId(app.slug))).map((app) =>
appWithPaperclipCloudConnectorAvailability(app, advertisedProfiles)
),
});
+10
View File
@@ -204,6 +204,16 @@ async function handleMcpGatewayProtocol(
} catch (err) {
if (err instanceof ToolGatewayHttpError) {
const id = (req.body as { id?: unknown } | undefined)?.id ?? null;
// Provider tool failures are MCP tool results, not successful calls or
// protocol errors. The service has already recorded the failed invocation.
if (req.body?.method === "tools/call" && err.reasonCode === "tool_error") {
res.json({
jsonrpc: "2.0",
id,
result: { content: [{ type: "text", text: err.message }], isError: true },
});
return;
}
res.status(err.status).json({
jsonrpc: "2.0",
id,
+171 -11
View File
@@ -1,3 +1,7 @@
import { withSlackBoardLease } from "./slack-board-lease.js";
import { mirrorSlackBoardComment, slackBoardReplyBindings } from "./slack-board-messages.js";
import { authorizeSlackBoardPublication } from "./slack-board-authority.js";
import { assertSlackBoardWorkAllowed } from "./slack-board-resume.js";
import { slackExplicitPublicationDuplicate } from "./connectors/slack-publication.js";
import { rememberVerifiedSlackSearchEvent, slackSearchActionToken } from "./connectors/slack-search-context.js";
import { slackAuthorizationRevision } from "./connectors/slack-revision.js";
@@ -6711,11 +6715,12 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
async function resolveCredentialRefs(
endpoint: EndpointRow,
refs: ToolCredentialSecretRef[],
database: DbOrTransaction = db,
): Promise<Record<string, string>> {
const values: Record<string, string> = {};
for (const ref of refs) {
const key = ref.configPath.replace(/^credentials\./, "");
values[key] = await secrets.resolveSecretValue(
values[key] = await (database === db ? secrets : secretService(database as Db)).resolveSecretValue(
endpoint.companyId,
ref.secretId,
ref.versionSelector ?? "latest",
@@ -6733,8 +6738,9 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
async function resolveCredentials(
endpoint: EndpointRow,
database: DbOrTransaction = db,
): Promise<Record<string, string>> {
const connection = await db
const connection = await database
.select({ refs: toolConnections.credentialSecretRefs, config: toolConnections.config })
.from(toolConnections)
.where(
@@ -6745,7 +6751,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
)
.then((rows) => rows[0] ?? null);
if (!connection) throw notFound("Chat connection not found");
const values = await resolveCredentialRefs(endpoint, connection.refs);
const values = await resolveCredentialRefs(endpoint, connection.refs, database);
if (endpoint.provider === "imessage-photon") {
const configuration = photonChannelConfigurationSchema.parse(connection.config.photon);
return { ...values, ...configuration, lineId: configuration.allocation === "shared" ? photonSharedScope(configuration.projectId) : configuration.lineId };
@@ -7344,7 +7350,12 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
label: item.label,
providerUrl: item.providerUrl ?? null,
availability: "available",
enabled: false,
// Slack inventory contains only channels the bot has joined. Apply the
// invitation default on insert; conflict updates preserve operator choices.
enabled:
endpoint.provider === "slack" &&
item.type === "channel" &&
item.metadata?.creator !== endpoint.botExternalId,
metadata: item.metadata ?? {},
})
.onConflictDoUpdate({
@@ -10544,7 +10555,12 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
providerResourceId,
label: resourceLabel.label,
availability: "available",
enabled: thread.isDM || enabledBySetupActivation,
// Slack can deliver the invitation's app_mention before the membership
// callback. Give either arrival order the same new-channel default.
enabled:
thread.isDM ||
enabledBySetupActivation ||
(endpoint.provider === "slack" && type === "channel"),
})
.onConflictDoUpdate({
target: [
@@ -15423,6 +15439,9 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
message.raw,
);
const mayEnableSetupDestination =
// Slack channels start enabled. Never use first-test activation to
// override an existing channel the operator explicitly disabled.
endpoint.provider !== "slack" &&
endpoint.provider !== "imessage-photon" &&
!thread.isDM &&
endpoint.status === "verifying" &&
@@ -24246,7 +24265,11 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
label: migratedLabel,
providerUrl: effect.providerUrl ?? null,
availability: effect.availability,
enabled: migratedEnabled,
enabled:
migratedEnabled ||
(currentEndpoint.provider === "slack" &&
effect.resourceType === "channel" &&
effect.availability === "available"),
metadata: effect.metadata ?? {},
})
.onConflictDoUpdate({
@@ -27673,6 +27696,80 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
* authenticated attachment downloads through the endpoint runtime; unsafe
* or no-longer-available files are omitted without losing the text turn.
*/
async function processPendingSlackBoardMessages(limit = 25) {
const pending = await db.select({ id: chatActions.id, companyId: chatActions.companyId, endpointId: chatActions.endpointId }).from(chatActions)
.innerJoin(chatEndpoints, and(eq(chatEndpoints.id, chatActions.endpointId), eq(chatEndpoints.companyId, chatActions.companyId)))
.where(and(eq(chatActions.kind, "slack_board_message"), eq(chatActions.status, "received"), notInArray(chatEndpoints.status, ["paused", "attention"])))
.orderBy(asc(chatActions.createdAt)).limit(limit);
for (const candidate of pending) {
await withSlackBoardLease(db, { ...candidate, actionId: candidate.id }, fetchImpl, async (lease) => {
const [action] = await db.select().from(chatActions).where(and(eq(chatActions.id, candidate.id), eq(chatActions.status, "received")));
if (!action) return;
const issueId = String(action.payload.issueId);
const userId = String(action.payload.userId);
const agentId = String(action.payload.agentId);
const commentId = String(action.payload.commentId);
async function cancelWork(message: string) {
await lease.commit(async (tx) => {
await tx.update(chatActions).set({ status: "cancelled", result: { code: "slack_board_work_not_authorized", message }, updatedAt: new Date() }).where(eq(chatActions.id, action.id));
await tx.update(chatPublications).set({ state: "cancelled", redactedError: message, nextAttemptAt: null, updatedAt: new Date() }).where(and(
eq(chatPublications.companyId, action.companyId), eq(chatPublications.endpointId, action.endpointId),
eq(chatPublications.conversationId, action.conversationId!), eq(chatPublications.commentId, commentId),
inArray(chatPublications.state, ["pending", "retry"]),
));
});
}
const currentEndpoint = await endpointRecord(action.endpointId);
if (currentEndpoint && ["paused", "attention"].includes(currentEndpoint.endpoint.status)) return;
const bindings = await slackBoardReplyBindings(db, { companyId: action.companyId, issueId, userId, agentId, commentIds: [commentId] });
if (!bindings.some(binding => binding.endpointId === action.endpointId && binding.conversationId === action.conversationId)) {
await cancelWork("The Slack message author no longer has access to this conversation");
return;
}
try {
const [publication] = await db.select().from(chatPublications).where(and(eq(chatPublications.companyId, action.companyId), eq(chatPublications.endpointId, action.endpointId), eq(chatPublications.commentId, commentId))).limit(1);
const [conversation] = await db.select().from(chatConversations).where(and(eq(chatConversations.companyId, action.companyId), eq(chatConversations.id, action.conversationId!)));
const credentials = currentEndpoint ? await resolveCredentials(currentEndpoint.endpoint) : null;
if (!publication || !conversation || !currentEndpoint || !await authorizeSlackBoardPublication(db, currentEndpoint.endpoint, conversation, publication, credentials?.botToken ?? "", lease.fetch)) {
throw forbidden("The Slack message author no longer has access to this conversation");
}
// Persist the resume and its marker together before waking. If a
// scheduler response is lost, retry cannot reopen work a second time.
const issue = await lease.commit(async (tx) => {
const [current] = await tx.select().from(issues).where(and(eq(issues.id, issueId), eq(issues.companyId, action.companyId), eq(issues.assigneeAgentId, agentId))).for("update");
if (!current) throw forbidden("The task is no longer assigned to the Slack agent");
await assertSlackBoardWorkAllowed(tx as unknown as Db, current);
if (action.result?.resumeApplied !== true) {
if (["done", "blocked"].includes(current.status)) {
await issueService(tx as unknown as Db).update(current.id, { status: "todo", actorUserId: userId });
await logActivity(tx as unknown as Db, { companyId: current.companyId, actorType: "user", actorId: userId, action: "issue.updated", entityType: "issue", entityId: current.id, details: { status: "todo", source: "slack_board_message" } });
}
await tx.update(chatActions).set({ result: { resumeApplied: true }, updatedAt: new Date() }).where(eq(chatActions.id, action.id));
}
return current;
});
await lease.commit(async () => {});
await options.heartbeat.wakeup(agentId, {
source: "automation", triggerDetail: "system", reason: "issue_commented",
idempotencyKey: `slack-board-comment:${action.id}`, allowRunCoalescing: false,
requestedByActorType: "user", requestedByActorId: userId,
payload: { issueId, commentId, resumeIntent: true, followUpRequested: true },
contextSnapshot: { issueId, taskId: issueId, taskKey: issue.identifier ?? issueId, wakeCommentId: commentId, source: "issue.comment", resumeIntent: true, followUpRequested: true },
});
await lease.commit(async (tx) => {
await tx.update(chatActions).set({ status: "processed", updatedAt: new Date() }).where(eq(chatActions.id, action.id));
});
} catch (error) {
if (error instanceof HttpError && [400, 403, 404, 409].includes(error.status)) {
await cancelWork(error.message);
return;
}
logger.warn({ actionId: action.id, error: error instanceof Error ? error.message : "Wakeup failed" }, "Slack Board message wakeup will retry");
}
});
}
}
async function processPendingDeliveries(limit = 25, onlyDeliveryId?: string) {
await settleRejectedInboundWakeups(onlyDeliveryId);
// Provider-visible effects that are not backed by a task publication use
@@ -27682,6 +27779,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
const actionRecovery = onlyDeliveryId
? null
: Promise.allSettled([
processPendingSlackBoardMessages(limit),
processPendingGitHubWebhookIngress(limit),
processPendingProviderEffects(limit),
processPendingReceiptReactions(limit),
@@ -30591,6 +30689,15 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
});
return { rejection: rejectionDetails(invalidIds) };
}
if (emailBoundary?.endpoint.provider === "slack") {
const [issue] = await tx.select().from(issues).where(and(eq(issues.id, conversation.issueId), eq(issues.companyId, conversation.companyId)));
if (!issue) throw notFound("Task not found");
await assertSlackBoardWorkAllowed(tx as unknown as Db, issue);
await mirrorSlackBoardComment(tx, comment, { publicationKey: idempotencyKey, wakeAgent: true, endpointId, conversationId, attachmentIds });
const [created] = await tx.select().from(chatPublications).where(and(eq(chatPublications.companyId, conversation.companyId), eq(chatPublications.idempotencyKey, idempotencyKey)));
if (!created) throw conflict("This Slack task is no longer assigned to the connected agent");
return created;
}
const attachedFiles = attachmentIds.length
? await tx
.select({
@@ -30690,6 +30797,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
publication.rejection,
);
}
if (emailBoundary?.endpoint.provider === "slack") await processPendingSlackBoardMessages();
await processPendingPublications();
const batch = publication.commentId
? await db
@@ -33829,6 +33937,10 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
return null;
}
let authorizationActionId: string | null = null;
if (endpoint.provider === "slack") {
const credentials = await resolveCredentials(endpoint, tx);
if (!await authorizeSlackBoardPublication(tx, endpoint, conversation, input.publication, credentials.botToken ?? "", fetchImpl)) return null;
}
if (
input.publication.idempotencyKey.startsWith("wake:") &&
!(await authorizeInboundWakePublication(tx, input.publication))
@@ -37826,6 +37938,9 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
.from(chatPublications)
.where(
and(
// An explicit Board send promises both delivery and work. Keep
// its text/files pending until the durable wake has been accepted.
sql`not exists (select 1 from chat_actions a where a.company_id = ${chatPublications.companyId} and a.endpoint_id = ${chatPublications.endpointId} and a.conversation_id = ${chatPublications.conversationId} and a.kind = 'slack_board_message' and a.status = 'received' and a.payload->>'commentId' = ${chatPublications.commentId}::text)`,
or(
and(
sql`not exists (select 1 from chat_endpoints e where e.id = ${chatPublications.endpointId} and e.publication_mode = 'explicit')`,
@@ -38166,7 +38281,52 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
? sql`${chatActions.payload}->>'commentId' = ${identity.sourceMessageId}`
: eq(chatActions.id, run.wakeupRequestId ?? "00000000-0000-0000-0000-000000000000"),
)).limit(1);
if (!action || action.payload.requestedByActorType !== "user" || action.payload.requestedByActorId !== run.responsibleUserId)
if (!action) {
// Ordinary tasks and routines use their accepted responsible user, never
// an earlier Slack sender or the connection owner's credentials.
const [message] = identity.sourceMessageId ? await tx.select().from(issueComments)
.where(and(eq(issueComments.companyId, binding.companyId), eq(issueComments.issueId, binding.issueId),
eq(issueComments.id, identity.sourceMessageId), isNull(issueComments.deletedAt))) : [];
if (identity.sourceMessageId && (!message || message.authorType !== "user" || message.authorUserId !== run.responsibleUserId))
throw forbidden("Slack tools require the current task requester's identity");
// An external participant may not acquire unrelated bot connections by
// falling back to ordinary-task access after source admission fails.
if (identity.sourceMessageId) {
const [external] = await tx.select({ id: chatMessageLinks.id }).from(chatMessageLinks)
.where(and(eq(chatMessageLinks.companyId, binding.companyId), eq(chatMessageLinks.commentId, identity.sourceMessageId), eq(chatMessageLinks.direction, "inbound"))).limit(1);
if (external) throw forbidden("External messages require their originating Slack connection");
}
const endpoints = await tx.select().from(chatEndpoints).where(and(
eq(chatEndpoints.companyId, binding.companyId), eq(chatEndpoints.provider, "slack"),
eq(chatEndpoints.assignedAgentId, binding.agentId), eq(chatEndpoints.status, "active"),
...(binding.endpointId ? [eq(chatEndpoints.id, binding.endpointId)] : []),
)).for("no key update");
if (endpoints.length !== 1) throw forbidden("Select an active Slack connection assigned to this agent");
const endpoint = endpoints[0]!;
const [issue] = await tx.select().from(issues).where(and(eq(issues.companyId, binding.companyId), eq(issues.id, binding.issueId), eq(issues.assigneeAgentId, binding.agentId)));
if (!issue) throw forbidden("Slack tools require a task assigned to this agent");
const [member] = await tx.select().from(companyMemberships).where(and(
eq(companyMemberships.companyId, binding.companyId), eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, run.responsibleUserId!), eq(companyMemberships.status, "active"), ne(companyMemberships.membershipRole, "viewer"),
));
if (!member) throw forbidden("The responsible user must be an active company operator");
const links = await tx.select({ principal: chatExternalPrincipals }).from(chatIdentityLinks)
.innerJoin(chatExternalPrincipals, and(eq(chatExternalPrincipals.id, chatIdentityLinks.principalId), eq(chatExternalPrincipals.companyId, binding.companyId)))
.where(and(eq(chatIdentityLinks.companyId, binding.companyId), eq(chatIdentityLinks.endpointId, endpoint.id),
eq(chatIdentityLinks.paperclipUserId, run.responsibleUserId!), eq(chatIdentityLinks.status, "linked"),
eq(chatExternalPrincipals.provider, "slack"), eq(chatExternalPrincipals.providerAccountId, endpoint.providerAccountId!), eq(chatExternalPrincipals.isBot, false)));
if (links.length !== 1) throw forbidden("Link your Slack account to this connection before using it from tasks or routines");
// A Board message changes who is directing the work, not the privacy
// of context already present in its linked Slack conversation.
const conversations = await tx.select().from(chatConversations).where(and(
eq(chatConversations.companyId, binding.companyId), eq(chatConversations.endpointId, endpoint.id),
eq(chatConversations.issueId, binding.issueId),
)).limit(2);
if (conversations.length > 1) throw forbidden("The task's Slack conversation boundary is ambiguous");
return { endpoint, issue, conversation: conversations[0] ?? null, delivery: null, principalId: links[0]!.principal.id, slackUserId: links[0]!.principal.externalId };
}
if (action.payload.requestedByActorType !== "user" || action.payload.requestedByActorId !== run.responsibleUserId ||
(binding.endpointId && action.endpointId !== binding.endpointId))
throw forbidden("Slack tools require a verified linked Slack request");
// This standalone resolver does not own the scheduler's issue lock. Use
// the ingress lock order (endpoint, then issue) so a normal webhook or
@@ -38191,18 +38351,18 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
const credentials = await resolveCredentials(resolved.endpoint);
if (!credentials.botToken) throw forbidden("Slack bot credential is unavailable");
return {
endpoint: resolved.endpoint, conversation: resolved.conversation,
endpoint: resolved.endpoint, issueId: binding.issueId, conversation: resolved.conversation,
principalId: resolved.principalId, slackUserId: resolved.slackUserId,
userId: run.responsibleUserId, deliveryId: resolved.delivery.id,
userId: run.responsibleUserId, deliveryId: resolved.delivery?.id ?? null,
identityContextId: identity.context?.id ?? null,
workMode: resolved.issue.workMode,
revision: createHash("sha256").update(JSON.stringify([
resolved.endpoint.status, resolved.endpoint.allowDirectMessages, resolved.conversation.sessionGeneration,
resolved.endpoint.status, resolved.endpoint.allowDirectMessages, resolved.conversation?.sessionGeneration ?? null,
run.responsibleUserId, credentials.botToken,
await slackAuthorizationRevision(db, binding.companyId, resolved.endpoint.id, resolved.endpoint.connectionId, binding.agentId, run.responsibleUserId),
])).digest("hex"),
botToken: credentials.botToken,
searchActionToken: slackSearchActionToken(db, resolved.endpoint.id, resolved.endpoint.providerAccountId!, resolved.slackUserId, String((resolved.delivery.normalizedEvent.message as Record<string, unknown> | undefined)?.providerMessageId ?? "")),
searchActionToken: resolved.delivery ? slackSearchActionToken(db, resolved.endpoint.id, resolved.endpoint.providerAccountId!, resolved.slackUserId, String((resolved.delivery.normalizedEvent.message as Record<string, unknown> | undefined)?.providerMessageId ?? "")) : null,
};
});
@@ -81,6 +81,7 @@ export async function listSlackBotChannels(input: {
channels?: Array<{
id?: string;
name?: string;
creator?: string;
is_member?: boolean;
is_private?: boolean;
is_archived?: boolean;
@@ -99,6 +100,7 @@ export async function listSlackBotChannels(input: {
type: "channel",
label: channel.name ? `#${channel.name}` : channel.id,
metadata: {
...(channel.creator ? { creator: channel.creator } : {}),
private: channel.is_private === true,
...(channel.context_team_id
? { contextTeamId: channel.context_team_id }
+115
View File
@@ -0,0 +1,115 @@
import { createHash } from "node:crypto";
/** Reviewed Cognee Cloud contract from cognee-mcp 0.5.5. Executed by the bundled bridge below. */
export const COGNEE_STDIO_TEMPLATE = {
name: "Cognee Cloud",
command: null,
args: [] as string[],
envKeys: ["COGNEE_BASE_URL", "COGNEE_API_KEY"],
tools: [
{
name: "remember", description: "Store text in a Cognee dataset or session.",
annotations: { readOnlyHint: false },
inputSchema: { type: "object", properties: {
data: { type: "string" }, dataset_name: { type: "string" },
session_id: { type: "string" }, custom_prompt: { type: "string" },
}, required: ["data"], additionalProperties: false },
},
{
name: "recall", description: "Search Cognee memory, optionally within named datasets or a session.",
annotations: { readOnlyHint: true },
inputSchema: { type: "object", properties: {
query: { type: "string" }, search_type: { type: "string" }, datasets: { type: "string" },
session_id: { type: "string" }, system_prompt: { type: "string" }, top_k: { type: "integer", minimum: 1, default: 15 },
}, required: ["query"], additionalProperties: false },
},
{
name: "forget", description: "Permanently delete a Cognee dataset, or all owned memory when everything is true.",
annotations: { destructiveHint: true },
inputSchema: { type: "object", properties: {
dataset: { type: "string" }, everything: { type: "boolean", default: false },
}, additionalProperties: false },
},
],
};
export function cogneeCloudUrl(value: string): URL {
const url = new URL(value);
if (url.protocol !== "https:" || !/^[a-z0-9-]+\.aws\.cognee\.ai$/i.test(url.hostname)
|| url.port || url.username || url.password || url.search || url.hash || url.pathname !== "/") {
throw new Error("Copy the tenant API Base URL from Cognee's API Keys page.");
}
return url;
}
/**
* Bundled bridge for the three reviewed Cloud operations. No package manager,
* subprocess, runtime downloads, or inherited host environment is involved.
* The gateway supplies its DNS/redirect-guarded, response-bounded HTTP client.
*/
export async function callCogneeCloud(input: {
baseUrl: string;
apiKey: string;
tool: string;
parameters: Record<string, unknown>;
request: (url: URL, init: RequestInit) => Promise<unknown>;
signal: AbortSignal;
}) {
const base = cogneeCloudUrl(input.baseUrl);
if (!input.apiKey) throw new Error("Reconnect Cognee to restore its Cloud API key.");
const tenant = /^tenant-([0-9a-f-]{36})\./i.exec(base.hostname)?.[1];
const headers: Record<string, string> = { "X-Api-Key": input.apiKey };
if (tenant) headers["X-Tenant-Id"] = tenant;
const args = input.parameters;
const post = async (path: string, body: Record<string, unknown> | FormData) => {
const url = new URL(path, base);
const request = new Request(url, { method: "POST",
headers: body instanceof FormData ? headers : { ...headers, "Content-Type": "application/json" },
body: body instanceof FormData ? body : JSON.stringify(body) });
// The guarded client pins DNS through node:http and accepts byte buffers,
// not fetch's FormData. Serialize with the matching multipart boundary.
return input.request(url, { method: "POST", signal: input.signal,
headers: Object.fromEntries(request.headers), body: Buffer.from(await request.arrayBuffer()), redirect: "manual" });
};
let result: unknown;
if (input.tool === "remember") {
const dataset = args.dataset_name || "paperclip_memory";
if (args.session_id) {
if (args.custom_prompt) throw new Error("custom_prompt is not supported with session_id in Cognee Cloud.");
result = await post("/api/v1/remember/entry", {
entry: { type: "qa", question: "", answer: args.data, context: "" },
dataset_name: dataset, session_id: args.session_id,
});
} else {
const form = new FormData();
form.set("data", new Blob([String(args.data)], { type: "text/plain" }), `memory_${createHash("sha256").update(String(args.data)).digest("hex")}.txt`);
form.set("datasetName", String(dataset));
if (args.custom_prompt) form.set("custom_prompt", String(args.custom_prompt));
result = await post("/api/v1/remember", form);
}
} else if (input.tool === "recall") {
let datasets = typeof args.datasets === "string" ? args.datasets.split(",").map(v => v.trim()).filter(Boolean) : [];
if (!datasets.length && !args.session_id) {
const available = await input.request(new URL("/api/v1/datasets/", base), {
method: "GET", headers, signal: input.signal, redirect: "manual",
});
if (!Array.isArray(available)) throw new Error("Cognee returned an invalid dataset list.");
datasets = available.flatMap(value => value && typeof value.name === "string" ? [value.name] : []);
}
result = await post("/api/v1/recall", {
query: args.query, top_k: args.top_k ?? 15,
search_type: typeof args.search_type === "string" ? args.search_type.toUpperCase() : null,
...(datasets.length ? { datasets } : {}),
...(args.session_id ? { session_id: args.session_id } : {}),
...(args.system_prompt ? { system_prompt: args.system_prompt } : {}),
});
} else if (input.tool === "forget") {
if (!args.dataset && args.everything !== true) throw new Error("Specify a dataset or set everything to true.");
result = await post("/api/v1/forget", {
everything: args.everything === true, ...(args.dataset ? { dataset: args.dataset } : {}),
});
} else {
throw new Error("Unreviewed Cognee action.");
}
return { content: [{ type: "text", text: JSON.stringify(result) }], structuredContent: { result }, isError: false };
}
+1
View File
@@ -25,6 +25,7 @@ type ToolBinding = AgentBinding & {
runId: string;
issueId: string;
workMode?: string;
endpointId?: string;
};
type Resource = {
id: string;
@@ -0,0 +1,269 @@
import { toolConnections, toolInvocations, type Db } from "@paperclipai/db";
import { beforeEach, describe, expect, it, vi } from "vitest";
const track = vi.fn();
const isRegisteredEventName = vi.fn(() => true);
let telemetryClient: {
track: typeof track;
isRegisteredEventName: typeof isRegisteredEventName;
} | null = { track, isRegisteredEventName };
vi.mock("../telemetry.js", () => ({
getTelemetryClient: () => telemetryClient,
}));
const {
connectorKeyForConnection,
emitConnectionCreated,
emitConnectionUpdated,
emitConnectionInvoked,
invocationOrigin,
} = await import("./connector-telemetry.js");
type ToolConnectionRow = typeof toolConnections.$inferSelect;
type ToolInvocationRow = typeof toolInvocations.$inferSelect;
function connectionRow(overrides: Record<string, unknown> = {}): ToolConnectionRow {
return {
id: "conn-1",
connectionPurpose: "tool",
transport: "mcp_remote",
authKind: "oauth",
status: "active",
enabled: true,
config: { sourceTemplateKey: "github" },
...overrides,
} as unknown as ToolConnectionRow;
}
function invocationRow(overrides: Record<string, unknown> = {}): ToolInvocationRow {
return {
id: "inv-1",
connectionId: "conn-1",
status: "succeeded",
actorType: "agent",
runId: "run-1",
issueId: "issue-1",
gatewayId: "gw-1",
startedAt: new Date("2026-09-17T00:00:00.000Z"),
completedAt: new Date("2026-09-17T00:00:07.400Z"),
...overrides,
} as unknown as ToolInvocationRow;
}
function fakeDb(
invocation: ToolInvocationRow | null,
connection: ToolConnectionRow | null,
): Db & { selectCalls: () => number } {
let selectCalls = 0;
return {
selectCalls: () => selectCalls,
select: () => {
selectCalls += 1;
return {
from: () => ({
innerJoin: () => ({
where: () =>
Promise.resolve(
// Mirror the emitter's single joined read: an invocation with
// no (or a deleted) connection joins to zero rows.
invocation && connection && invocation.connectionId
? [{ invocation, connection }]
: [],
),
}),
}),
};
},
} as unknown as Db & { selectCalls: () => number };
}
beforeEach(() => {
track.mockReset();
isRegisteredEventName.mockReset();
isRegisteredEventName.mockReturnValue(true);
telemetryClient = { track, isRegisteredEventName };
});
describe("connectorKeyForConnection", () => {
it("keeps a sourceTemplateKey that resolves in the shared catalog", () => {
expect(connectorKeyForConnection(connectionRow())).toBe("github");
});
it("reports custom for keys outside the catalog", () => {
expect(
connectorKeyForConnection(connectionRow({ config: { sourceTemplateKey: "my-internal-server" } })),
).toBe("custom");
});
it("reports custom when the key is missing or not a string", () => {
expect(connectorKeyForConnection(connectionRow({ config: {} }))).toBe("custom");
expect(
connectorKeyForConnection(connectionRow({ config: { sourceTemplateKey: 42 } })),
).toBe("custom");
});
});
describe("invocationOrigin", () => {
it("labels user-driven connection tests without run context as setup_test", () => {
expect(
invocationOrigin(
invocationRow({ actorType: "user", runId: null, issueId: null, gatewayId: null }),
),
).toBe("setup_test");
});
it("keeps the actor type for run-attached invocations", () => {
expect(invocationOrigin(invocationRow())).toBe("agent");
expect(invocationOrigin(invocationRow({ actorType: "user" }))).toBe("user");
});
});
describe("emitConnectionCreated", () => {
it("emits catalog identity and lifecycle state for tool connections", () => {
emitConnectionCreated(connectionRow(), "gallery");
expect(track).toHaveBeenCalledWith("connection.created", {
connector_key: "github",
transport: "mcp_remote",
auth_kind: "oauth",
setup_flow: "gallery",
status: "active",
enabled: true,
});
});
it("never emits for channel or AI connections", () => {
emitConnectionCreated(connectionRow({ connectionPurpose: "channel" }), "api");
expect(track).not.toHaveBeenCalled();
});
it("is a no-op without an initialized telemetry client", () => {
telemetryClient = null;
expect(() => emitConnectionCreated(connectionRow(), "gallery")).not.toThrow();
});
it("swallows client failures instead of failing the setup path", () => {
track.mockImplementation(() => {
throw new Error("sink offline");
});
expect(() => emitConnectionCreated(connectionRow(), "gallery")).not.toThrow();
});
});
describe("emitConnectionUpdated", () => {
it("stays silent when the committed write left status and enabled unchanged", () => {
emitConnectionUpdated(
connectionRow(),
{ status: "active", enabled: true },
"api",
);
expect(track).not.toHaveBeenCalled();
});
it("emits the transition when lifecycle status changes", () => {
emitConnectionUpdated(
connectionRow(),
{ status: "draft", enabled: true },
"oauth_callback",
);
expect(track).toHaveBeenCalledWith("connection.updated", {
connector_key: "github",
transport: "mcp_remote",
auth_kind: "oauth",
change_source: "oauth_callback",
previous_status: "draft",
status: "active",
previous_enabled: true,
enabled: true,
});
});
it("emits when only the enabled flag flips", () => {
emitConnectionUpdated(
connectionRow({ enabled: false }),
{ status: "active", enabled: true },
"api",
);
expect(track).toHaveBeenCalledTimes(1);
expect(track.mock.calls[0]?.[1]).toMatchObject({
previous_enabled: true,
enabled: false,
});
});
it("never emits for non-tool connections even on lifecycle changes", () => {
emitConnectionUpdated(
connectionRow({ connectionPurpose: "channel" }),
{ status: "draft", enabled: true },
"api",
);
expect(track).not.toHaveBeenCalled();
});
});
describe("emitConnectionInvoked", () => {
it("emits terminal outcome, origin, and duration from the committed row", async () => {
await emitConnectionInvoked(fakeDb(invocationRow(), connectionRow()), "inv-1");
expect(track).toHaveBeenCalledWith("connection.invoked", {
connector_key: "github",
transport: "mcp_remote",
status: "succeeded",
origin: "agent",
duration_seconds: 7,
});
});
it("omits duration when the invocation never recorded a start time", async () => {
await emitConnectionInvoked(
fakeDb(invocationRow({ startedAt: null, status: "denied" }), connectionRow()),
"inv-1",
);
expect(track.mock.calls[0]?.[1]).not.toHaveProperty("duration_seconds");
expect(track.mock.calls[0]?.[1]).toMatchObject({ status: "denied" });
});
it("ignores in-flight statuses so approval waits never count as failures", async () => {
await emitConnectionInvoked(
fakeDb(invocationRow({ status: "awaiting_approval" }), connectionRow()),
"inv-1",
);
expect(track).not.toHaveBeenCalled();
});
it("ignores invocations without a connection or with a non-tool connection", async () => {
await emitConnectionInvoked(
fakeDb(invocationRow({ connectionId: null }), connectionRow()),
"inv-1",
);
await emitConnectionInvoked(
fakeDb(invocationRow(), connectionRow({ connectionPurpose: "channel" })),
"inv-1",
);
expect(track).not.toHaveBeenCalled();
});
it("resolves without emitting when the invocation row is missing", async () => {
await expect(
emitConnectionInvoked(fakeDb(null, null), "inv-missing"),
).resolves.toBeUndefined();
expect(track).not.toHaveBeenCalled();
});
it("reads nothing from the database while the event name is unregistered", async () => {
isRegisteredEventName.mockReturnValue(false);
const db = fakeDb(invocationRow(), connectionRow());
await emitConnectionInvoked(db, "inv-1");
expect(db.selectCalls()).toBe(0);
expect(track).not.toHaveBeenCalled();
expect(isRegisteredEventName).toHaveBeenCalledWith(
"connection.invoked",
);
});
it("loads the invocation and connection with a single read once registered", async () => {
const db = fakeDb(invocationRow(), connectionRow());
await emitConnectionInvoked(db, "inv-1");
expect(db.selectCalls()).toBe(1);
expect(track).toHaveBeenCalledTimes(1);
});
});
+226
View File
@@ -0,0 +1,226 @@
import { eq } from "drizzle-orm";
import { toolConnections, toolInvocations, type Db } from "@paperclipai/db";
import { getConnectableAppDefinition } from "@paperclipai/shared";
import {
trackConnectionCreated,
trackConnectionUpdated,
trackConnectionInvoked,
} from "@paperclipai/shared/telemetry";
import { logger } from "../middleware/logger.js";
import { getTelemetryClient } from "../telemetry.js";
type ToolConnectionRow = typeof toolConnections.$inferSelect;
type ToolInvocationRow = typeof toolInvocations.$inferSelect;
export type ConnectorSetupFlow = "gallery" | "api" | "example";
export type ConnectorChangeSource =
| "api"
| "gallery"
| "oauth_callback"
| "credential_refresh"
| "archive"
| "example";
/**
* Terminal `tool_invocations.status` values. `pending`, `authorized`,
* `awaiting_approval`, and `executing` are in-flight and never emit: pending
* approval is not a failure. `cancelled` is declared in the schema enum but no
* current code path writes it to an invocation (signing-path cancellations
* land on the action request and fail the invocation); it stays here so a
* future writer is counted without a telemetry change.
*/
const TERMINAL_INVOCATION_STATUSES: ReadonlySet<string> = new Set([
"succeeded",
"failed",
"denied",
"cancelled",
"timed_out",
"rate_limited",
]);
/**
* Connector identity for telemetry is the reviewed first-party catalog slug
* only: `config.sourceTemplateKey` validated against the shared app-definitions
* catalog. Anything else — custom MCP servers, user-named connections, keys
* that no longer resolve to a catalog entry — reports the literal `custom` so
* no user- or provider-controlled string leaves the process.
*/
export function connectorKeyForConnection(
connection: Pick<ToolConnectionRow, "config">,
): string {
const raw = connection.config?.sourceTemplateKey;
const key = typeof raw === "string" ? raw : null;
return key && getConnectableAppDefinition(key) ? key : "custom";
}
/**
* Setup-test invocations (Apps → Test tab) are separated from agent usage on
* durable invocation columns, mirroring the gateway's own test-origin
* predicate, so the split survives reloads and approval-driven completion.
*/
export function invocationOrigin(
invocation: Pick<
ToolInvocationRow,
"actorType" | "runId" | "issueId" | "gatewayId" | "connectionId"
>,
): string {
const isTestOrigin =
invocation.actorType === "user" &&
invocation.runId === null &&
invocation.issueId === null &&
invocation.gatewayId === null &&
invocation.connectionId !== null;
return isTestOrigin ? "setup_test" : invocation.actorType;
}
function isToolPurpose(connection: Pick<ToolConnectionRow, "connectionPurpose">): boolean {
return connection.connectionPurpose === "tool";
}
/**
* Emits one proposed `connection.created` event for a tool-purpose
* connection row that a caller already committed. Channel and AI connections
* never emit. This function never throws; a telemetry failure must never fail
* a connection setup path.
*/
export function emitConnectionCreated(
connection: ToolConnectionRow,
setupFlow: ConnectorSetupFlow,
): void {
try {
const client = getTelemetryClient();
if (!client) return;
if (!isToolPurpose(connection)) return;
trackConnectionCreated(client, {
connector_key: connectorKeyForConnection(connection),
transport: connection.transport,
auth_kind: connection.authKind,
setup_flow: setupFlow,
status: connection.status,
enabled: connection.enabled,
});
} catch (err) {
logger.warn(
{ err, connectionId: connection.id },
"failed to emit connection.created telemetry",
);
}
}
/**
* Emits one proposed `connection.updated` event when a committed
* write changed a tool-purpose connection's persisted lifecycle state
* (`status` or `enabled`). Metadata-only saves, health polls, credential
* rotation, and catalog refreshes do not change either field and therefore
* never emit. This function never throws.
*/
export function emitConnectionUpdated(
connection: ToolConnectionRow,
previous: Pick<ToolConnectionRow, "status" | "enabled">,
changeSource: ConnectorChangeSource,
): void {
try {
const client = getTelemetryClient();
if (!client) return;
if (!isToolPurpose(connection)) return;
if (
previous.status === connection.status &&
previous.enabled === connection.enabled
) {
return;
}
trackConnectionUpdated(client, {
connector_key: connectorKeyForConnection(connection),
transport: connection.transport,
auth_kind: connection.authKind,
change_source: changeSource,
previous_status: previous.status,
status: connection.status,
previous_enabled: previous.enabled,
enabled: connection.enabled,
});
} catch (err) {
logger.warn(
{ err, connectionId: connection.id },
"failed to emit connection.updated telemetry",
);
}
}
/**
* Emits one proposed `connection.invoked` event for an invocation
* a caller already wrote to a terminal status. Despite the name, this is a
* completion event: it records finished invocation attempts and their
* terminal status, never invocation starts. Never await it: like
* `agent.task_run`, this is best-effort background work and must not delay
* the caller's own response or lifecycle writes.
*
* Call placement inside the gateway is deliberate and asymmetric. Failure
* paths call it right after their terminal save. Success paths call it only
* after the post-save bookkeeping (action-request settlement, tool-call
* event, audit) has completed, because a bookkeeping failure lands in a catch
* that overwrites the row to `failed` and emits there — emitting the success
* beforehand would let one execution report both outcomes.
*
* Self-guards, so callers do not have to re-check anything: non-terminal
* statuses, invocations without a tool-purpose connection, and an
* unregistered (still-proposed) event name all return without emitting. The
* registration gate keeps the completion path free of telemetry reads until
* schema adoption: the runtime client drops unregistered names in `track`,
* so there is no point loading rows for an event that cannot be queued.
*
* Delivery is best-effort per execution, not exactly-once. Known residual
* duplications, accepted for the proposal stage: an outer safety-net catch
* can re-save `failed` and emit again when a failure path's own bookkeeping
* throws after its emit (test-invocation approval flow), and a crash-side
* replay that re-writes a terminal status emits again. Deduplication state
* is out of scope for this proposal.
*/
export async function emitConnectionInvoked(
db: Db,
invocationId: string,
): Promise<void> {
try {
const client = getTelemetryClient();
if (!client) return;
if (!client.isRegisteredEventName("connection.invoked")) return;
const joined = await db
.select({ invocation: toolInvocations, connection: toolConnections })
.from(toolInvocations)
.innerJoin(
toolConnections,
eq(toolInvocations.connectionId, toolConnections.id),
)
.where(eq(toolInvocations.id, invocationId))
.then((rows) => rows[0] ?? null);
if (!joined) return;
const { invocation, connection } = joined;
if (!TERMINAL_INVOCATION_STATUSES.has(invocation.status)) return;
if (!isToolPurpose(connection)) return;
const startedAtMs = invocation.startedAt
? new Date(invocation.startedAt).getTime()
: null;
const completedAtMs = invocation.completedAt
? new Date(invocation.completedAt).getTime()
: null;
const durationSeconds =
startedAtMs !== null && completedAtMs !== null
? Math.max(0, Math.round((completedAtMs - startedAtMs) / 1000))
: undefined;
trackConnectionInvoked(client, {
connector_key: connectorKeyForConnection(connection),
transport: connection.transport,
status: invocation.status,
origin: invocationOrigin(invocation),
...(durationSeconds === undefined ? {} : { duration_seconds: durationSeconds }),
});
} catch (err) {
logger.warn(
{ err, invocationId },
"failed to emit connection.invoked telemetry",
);
}
}
+15 -9
View File
@@ -11,7 +11,7 @@ import {
type Api = ReturnType<typeof slackClient>;
export async function authorizeSlackChannel(
authority: SlackTaskAuthority,
authority: Pick<SlackTaskAuthority, "endpoint" | "slackUserId">,
api: Api,
channelId: string,
) {
@@ -24,8 +24,13 @@ export async function authorizeSlackChannel(
// Membership can disappear between listing and inspection. Slack also
// hides inaccessible conversations with channel_not_found. Treat only
// these definite access failures as denials; outages must still surface.
if (error instanceof HttpError && error.status === 422 &&
["slack_channel_not_found", "slack_not_in_channel"].includes(String(object(error.details).code))) {
if (
error instanceof HttpError &&
error.status === 422 &&
["slack_channel_not_found", "slack_not_in_channel"].includes(
String(object(error.details).code),
)
) {
throw forbidden("This Slack channel is no longer accessible to the bot");
}
throw error;
@@ -129,7 +134,7 @@ export async function authorizeSlackWrite(
db,
authority.endpoint.companyId,
authority.endpoint.id,
authority.conversation.issueId,
authority.issueId,
channelId,
channel.is_im === true ? authority.slackUserId : null,
))
@@ -153,6 +158,7 @@ export async function recordSlackReadBoundary(
)
return;
if (
authority.conversation &&
!authority.conversation.isDirectMessage &&
channel.id !==
authority.conversation.externalConversationId.replace(/^slack:/, "")
@@ -165,16 +171,16 @@ export async function recordSlackReadBoundary(
.values({
companyId: authority.endpoint.companyId,
endpointId: authority.endpoint.id,
conversationId: authority.conversation.id,
conversationId: authority.conversation?.id ?? null,
principalId: authority.principalId,
kind: "slack_private_source",
status: "processed",
providerActionId: `slack-private:${authority.conversation.issueId}:${channel.id}:${authority.slackUserId}`,
providerActionId: `slack-private:${authority.issueId}:${channel.id}:${authority.slackUserId}`,
payload: {
issueId: authority.conversation.issueId,
issueId: authority.issueId,
channelId: channel.id,
requesterId: authority.slackUserId,
allowedDmChannel: authority.conversation.isDirectMessage
allowedDmChannel: authority.conversation?.isDirectMessage
? authority.conversation.externalConversationId.replace(/^slack:/, "")
: null,
},
@@ -216,7 +222,7 @@ export async function recordSlackOriginBoundary(
authority: SlackTaskAuthority,
api: Api,
) {
if (authority.conversation.isDirectMessage) return;
if (!authority.conversation || authority.conversation.isDirectMessage) return;
const origin = await authorizeSlackChannel(
authority,
api,
@@ -4,7 +4,7 @@ import {
toolInvocations,
toolActionRequests,
type Db,
type chatEndpoints,
chatEndpoints,
type chatConversations,
} from "@paperclipai/db";
import { and, eq, inArray } from "drizzle-orm";
@@ -17,17 +17,20 @@ export interface SlackTaskBinding {
runId: string;
issueId: string;
workMode?: string;
/** Selects a server-verified connection assigned to this agent. */
endpointId?: string;
/** Controller-only operation snapshot; never part of a tool argument schema. */
identityContextId?: string | null;
approvedInvocationId?: string;
}
export interface SlackTaskAuthority {
endpoint: typeof chatEndpoints.$inferSelect;
conversation: typeof chatConversations.$inferSelect;
issueId: string;
conversation: typeof chatConversations.$inferSelect | null;
userId: string;
slackUserId: string;
principalId: string;
deliveryId: string;
deliveryId: string | null;
identityContextId: string | null;
revision: string;
workMode: string;
@@ -177,3 +180,24 @@ export async function slackRunOrigin(db: Db, binding: SlackTaskBinding) {
}
return { ...captured, sourceMessageId: origin?.messageId ?? null };
}
/** Enumerate only this agent's connections; each still needs live run/user authority. */
export async function slackEndpointCandidates(
db: Db,
binding: Pick<SlackTaskBinding, "companyId" | "agentId" | "endpointId">,
) {
return db
.select({ id: chatEndpoints.id })
.from(chatEndpoints)
.where(
and(
eq(chatEndpoints.companyId, binding.companyId),
eq(chatEndpoints.assignedAgentId, binding.agentId),
eq(chatEndpoints.provider, "slack"),
inArray(chatEndpoints.status, ["active", "verifying"]),
...(binding.endpointId
? [eq(chatEndpoints.id, binding.endpointId)]
: []),
),
);
}
@@ -15,7 +15,10 @@ import type {
ToolGatewayDescriptor,
ToolGatewaySession,
} from "../tool-gateway.js";
import { resolveSlackTaskAuthority } from "./slack-authority.js";
import {
resolveSlackTaskAuthority,
slackEndpointCandidates,
} from "./slack-authority.js";
export async function syncSlackBotTools(
tx: Parameters<Parameters<Db["transaction"]>[0]>[0],
endpoint: typeof chatEndpoints.$inferSelect,
@@ -169,7 +172,7 @@ export async function syncSlackBotTools(
.onConflictDoNothing();
}
export async function slackToolsForSession(
async function slackToolsForEndpoint(
db: Db,
session: Pick<
ToolGatewaySession,
@@ -180,11 +183,13 @@ export async function slackToolsForSession(
| "identityContextId"
| "approvedSlackInvocationId"
>,
endpointId: string,
): Promise<ToolGatewayDescriptor[]> {
if (!session.agentId || !session.runId || !session.issueId) return [];
let authority;
try {
authority = await resolveSlackTaskAuthority(db, {
endpointId,
companyId: session.companyId,
agentId: session.agentId,
runId: session.runId,
@@ -239,3 +244,18 @@ export async function slackToolsForSession(
providerMetadata: { endpointId: authority.endpoint.id },
}));
}
export async function slackToolsForSession(
db: Db,
session: Parameters<typeof slackToolsForEndpoint>[1],
): Promise<ToolGatewayDescriptor[]> {
if (!session.agentId || !session.runId || !session.issueId) return [];
const tools: ToolGatewayDescriptor[] = [];
for (const endpoint of await slackEndpointCandidates(db, {
companyId: session.companyId,
agentId: session.agentId,
})) {
tools.push(...(await slackToolsForEndpoint(db, session, endpoint.id)));
}
return tools;
}
@@ -27,7 +27,7 @@ export async function authorizeSlackDocument(
eq(chatActions.endpointId, authority.endpoint.id),
eq(chatActions.kind, "slack_tool_write"),
eq(chatActions.status, "processed"),
sql`${chatActions.payload}->'binding'->>'issueId' = ${authority.conversation.issueId}`,
sql`${chatActions.payload}->'binding'->>'issueId' = ${authority.issueId}`,
sql`${chatActions.payload}->>'userId' = ${authority.userId}`,
sql`${chatActions.payload}->'args'->>'channel' = ${args.channel}`,
sql`(${chatActions.result}->>'canvasId' = ${args.file} or ${chatActions.result}->>'listId' = ${args.file})`,
@@ -55,7 +55,7 @@ export async function authorizeSlackDocument(
eq(chatActions.companyId, authority.endpoint.companyId),
eq(chatActions.endpointId, authority.endpoint.id),
eq(chatActions.kind, "slack_private_source"),
sql`${chatActions.payload}->>'issueId' = ${authority.conversation.issueId}`,
sql`${chatActions.payload}->>'issueId' = ${authority.issueId}`,
),
)
.limit(1);
Loaded 100 of 178 files, more files were not shown because too many files have changed in this diff. Show more