mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
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:
commit
5a176ea301
178 files changed
+8994
-1999
No files matched your search
@@ -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"/);
|
||||
});
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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})"
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
@@ -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(), ¶ms).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[];
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
Generated
+128
-125
@@ -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", () => {
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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 }); }
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user