mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
Carry current master through Pi public installation
This commit is contained in:
commit
e00258afd9
2195 files changed
+255923
-79198
No files matched your search
@@ -0,0 +1,32 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { cursorConsumerDockerArgs, prepareCursorInstallAccount } from '../cursor-public-install-sandbox.mjs';
|
||||
|
||||
const values = (args, flag) => args.flatMap((value, index) => value === flag ? [args[index + 1]] : []);
|
||||
test('setup and offline admission share a real unprivileged account and persistent home', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cursor-install-account-'));
|
||||
try {
|
||||
const account = prepareCursorInstallAccount(root, 1001, 1001);
|
||||
assert.match(readFileSync(account.passwd, 'utf8'), /paperclip:x:1001:1001:Paperclip verification:\/home\/paperclip:/);
|
||||
assert.equal(statSync(account.home).mode & 0o777, 0o700);
|
||||
for (const download of [true, false]) {
|
||||
const args = cursorConsumerDockerArgs({ account, assets: '/assets', consumer: '/consumer', cache: '/cache', uid: 1001, gid: 1001, command: ['node', '/packages/probe.mjs'], download, readOnlyConsumer: true });
|
||||
assert.deepEqual(values(args, '--user'), ['1001:1001']);
|
||||
assert.deepEqual(values(args, '--network'), [download ? 'bridge' : 'none']);
|
||||
assert.ok(args.includes('--read-only'));
|
||||
assert.ok(values(args, '--env').includes('HOME=/home/paperclip'));
|
||||
assert.ok(values(args, '--mount').includes('type=bind,src=/consumer,dst=/consumer,readonly'));
|
||||
assert.ok(values(args, '--mount').includes(`type=bind,src=${account.passwd},dst=/etc/passwd,readonly`));
|
||||
assert.ok(values(args, '--mount').includes(`type=bind,src=${account.home},dst=/home/paperclip`));
|
||||
}
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('an invalid account is rejected before writing a passwd file', () => {
|
||||
for (const uid of [0, -1, '1001', undefined]) {
|
||||
assert.throws(() => prepareCursorInstallAccount('/does-not-exist', uid, 1001), /unprivileged/);
|
||||
}
|
||||
});
|
||||
@@ -92,17 +92,37 @@ test("a route/authz suite never leaks into the general-server shards", () => {
|
||||
const shard = dryRunJson(["--mode", "general", "--group", "general-server", "--shard-index", "0", "--shard-count", SHARD_COUNT.toString()]);
|
||||
for (const file of shard.selectedGeneralServerSuites) {
|
||||
assert.ok(
|
||||
!/[^/]*(?:route|routes|authz)[^/]*\.test\.ts$/.test(file),
|
||||
!(file.startsWith("server/src/__tests__/") && /[^/]*(?:route|routes|authz)[^/]*\.test\.ts$/.test(file)),
|
||||
`route/authz suite must stay in the serialized lane, not general-server: ${file}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("general shards retain scripts and route-named suites outside the serialized directory", () => {
|
||||
const general = dryRunJson(["--mode", "general", "--group", "general-server", "--shard-index", "0", "--shard-count", "1"]);
|
||||
for (const file of [
|
||||
"server/scripts/verify-runner-vendor-dependencies.test.mjs",
|
||||
"server/src/services/openrouter-models.test.ts",
|
||||
"server/src/routes/setup-token-route.test.ts",
|
||||
]) assert.ok(general.selectedGeneralServerSuites.includes(file), `missing configured server suite: ${file}`);
|
||||
});
|
||||
|
||||
test("shard flags are rejected for the workspaces-b group", () => {
|
||||
const result = dryRun(["--mode", "general", "--group", "general-workspaces-b", "--shard-index", "0", "--shard-count", "3"]);
|
||||
assert.notEqual(result.status, 0, "workspaces-b must not accept shard flags");
|
||||
});
|
||||
|
||||
test("workspace lanes cover every non-server project in the root Vitest configuration", () => {
|
||||
const config = readFileSync(path.join(repoRoot, "vitest.config.ts"), "utf8");
|
||||
const roots = [...config.matchAll(/^\s+"([^"]+)",?\s*$/gm)].map(match => match[1]);
|
||||
assert.ok(roots.includes("server"), "expected the explicit root Vitest project list");
|
||||
const expected = roots.filter(root => root !== "server")
|
||||
.map(root => JSON.parse(readFileSync(path.join(repoRoot, root, "package.json"), "utf8")).name).sort();
|
||||
const actual = ["general-workspaces-a", "general-workspaces-b"]
|
||||
.flatMap(group => dryRunJson(["--mode", "general", "--group", group]).workspaceProjects).sort();
|
||||
assert.deepEqual(actual, expected, "no configured project may be silently omitted or run twice");
|
||||
});
|
||||
|
||||
test("workspaces-a shards map to Vitest native --shard slices over a stable project list", () => {
|
||||
const shards = [0, 1].map((index) =>
|
||||
dryRunJson([
|
||||
@@ -273,6 +293,9 @@ test("the real shard partition is duration-balanced", () => {
|
||||
const chatSuitePath = "server/src/__tests__/chat-channels.integration.test.ts";
|
||||
const nativeRunnerSuitePath =
|
||||
"server/src/services/native-runtime/native-codex-runner.integration.test.ts";
|
||||
const dotRunnerSuitePath = "server/src/__tests__/dot-runner.test.ts";
|
||||
const restartRecoverySuitePath =
|
||||
"server/src/services/native-runtime/native-runner-restart-recovery.integration.test.ts";
|
||||
|
||||
// Mirrors pr-trusted.yml (12 shards, called by pr.yml so GITHUB_WORKFLOW is
|
||||
// "PR"): the chat suite runs in its dedicated lanes and the cargo-dependent
|
||||
@@ -288,7 +311,9 @@ test("12 PR without-chat shards plus the dedicated chat and native-runner lanes
|
||||
const files = shards.flatMap((shard) => shard.selectedGeneralServerSuites);
|
||||
assert.ok(!files.includes(chatSuitePath));
|
||||
assert.ok(!files.includes(nativeRunnerSuitePath));
|
||||
assert.deepEqual([...files, chatSuitePath, nativeRunnerSuitePath].sort(), full.selectedGeneralServerSuites.sort());
|
||||
assert.ok(!files.includes(dotRunnerSuitePath));
|
||||
assert.ok(!files.includes(restartRecoverySuitePath));
|
||||
assert.deepEqual([...files, chatSuitePath, nativeRunnerSuitePath, dotRunnerSuitePath, restartRecoverySuitePath].sort(), full.selectedGeneralServerSuites.sort());
|
||||
assert.equal(new Set(files).size, files.length);
|
||||
const defaultRun = dryRunJson([], prEnv);
|
||||
assert.ok(defaultRun.generalServerSuiteCount === full.generalServerSuiteCount);
|
||||
@@ -307,15 +332,19 @@ for (const [caller, envOverrides] of [["Release", { GITHUB_WORKFLOW: "Release" }
|
||||
const files = shards.flatMap((shard) => shard.selectedGeneralServerSuites);
|
||||
assert.ok(!files.includes(chatSuitePath));
|
||||
assert.ok(files.includes(nativeRunnerSuitePath));
|
||||
assert.ok(files.includes(dotRunnerSuitePath));
|
||||
assert.ok(files.includes(restartRecoverySuitePath));
|
||||
assert.deepEqual([...files, chatSuitePath].sort(), full.selectedGeneralServerSuites.sort());
|
||||
assert.equal(new Set(files).size, files.length);
|
||||
});
|
||||
}
|
||||
|
||||
test("the native-runner lane runs exactly the cargo-dependent vertical-slice suite", () => {
|
||||
test("the native-runner lane runs exactly the cargo-dependent suites", () => {
|
||||
const lane = dryRunJson(["--mode", "general", "--group", "general-server-native-runner"]);
|
||||
assert.deepEqual(lane.selectedGeneralServerSuites, [
|
||||
"server/src/services/native-runtime/native-codex-runner.integration.test.ts",
|
||||
dotRunnerSuitePath,
|
||||
restartRecoverySuitePath,
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -43,6 +43,29 @@ test("allows each current CODEOWNER on a feature branch; reads policy from maste
|
||||
assert.equal(f.calls[0].path, ".github/CODEOWNERS");
|
||||
}
|
||||
});
|
||||
test("owners listed only for release docs may initiate and rerun Storybook", async (t) => {
|
||||
const triggeringActor = process.env.GITHUB_TRIGGERING_ACTOR;
|
||||
t.after(() => { process.env.GITHUB_TRIGGERING_ACTOR = triggeringActor; });
|
||||
const codeowners = `${ownerFile}doc/RELEASING.md @release-owner @release-reviewer\n`;
|
||||
for (const actor of ["cryppadotta", "release-owner", "release-reviewer"]) {
|
||||
for (const rerunner of ["cryppadotta", "release-owner", "release-reviewer"]) {
|
||||
process.env.GITHUB_TRIGGERING_ACTOR = rerunner;
|
||||
const f = fixture({ context: { actor }, codeowners });
|
||||
await authorize(f);
|
||||
assert.equal(f.calls[0].ref, "master");
|
||||
}
|
||||
}
|
||||
});
|
||||
test("removing an owner from CODEOWNERS revokes initiating and rerunning access", async (t) => {
|
||||
const triggeringActor = process.env.GITHUB_TRIGGERING_ACTOR;
|
||||
t.after(() => { process.env.GITHUB_TRIGGERING_ACTOR = triggeringActor; });
|
||||
for (const actor of ["release-owner", "release-reviewer"]) {
|
||||
process.env.GITHUB_TRIGGERING_ACTOR = "cryppadotta";
|
||||
await assert.rejects(authorize(fixture({ context: { actor } })), /Only default-branch CODEOWNERS/);
|
||||
process.env.GITHUB_TRIGGERING_ACTOR = actor;
|
||||
await assert.rejects(authorize(fixture()), /Only default-branch CODEOWNERS/);
|
||||
}
|
||||
});
|
||||
test("rejects non-owner initiators", async () => {
|
||||
await assert.rejects(authorize(fixture({ context: { actor: "contributor" } })), /Only default-branch CODEOWNERS/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { grokConsumerDockerArgs } from './grok-public-install-sandbox.mjs';
|
||||
|
||||
export const CURSOR_INSTALL_ACCOUNT_HOME = '/home/paperclip';
|
||||
|
||||
// The host UID need not exist in the pinned image. Give it a real, isolated
|
||||
// account home that survives the separate installation and offline probe runs.
|
||||
export function prepareCursorInstallAccount(root, uid, gid) {
|
||||
if (!Number.isSafeInteger(uid) || uid <= 0 || !Number.isSafeInteger(gid) || gid <= 0) {
|
||||
throw new Error('Public-install verification requires an unprivileged host user');
|
||||
}
|
||||
const home = join(root, 'account-home');
|
||||
const passwd = join(root, 'account-passwd');
|
||||
mkdirSync(home, { mode: 0o700 });
|
||||
writeFileSync(passwd, `root:x:0:0:root:/root:/usr/sbin/nologin\npaperclip:x:${uid}:${gid}:Paperclip verification:${CURSOR_INSTALL_ACCOUNT_HOME}:/usr/sbin/nologin\n`, { mode: 0o444, flag: 'wx' });
|
||||
return { home, passwd };
|
||||
}
|
||||
|
||||
export function cursorConsumerDockerArgs({ account, readOnlyConsumer = false, ...options }) {
|
||||
const args = grokConsumerDockerArgs({ ...options, temporarySizeMb: 1024, command: [] });
|
||||
const image = args.pop();
|
||||
args[args.indexOf('HOME=/tmp')] = `HOME=${CURSOR_INSTALL_ACCOUNT_HOME}`;
|
||||
if (readOnlyConsumer) {
|
||||
const mount = `type=bind,src=${options.consumer},dst=/consumer`;
|
||||
args[args.indexOf(mount)] = `${mount},readonly`;
|
||||
}
|
||||
return [...args,
|
||||
'--mount', `type=bind,src=${account.passwd},dst=/etc/passwd,readonly`,
|
||||
'--mount', `type=bind,src=${account.home},dst=${CURSOR_INSTALL_ACCOUNT_HOME}`,
|
||||
image, ...options.command];
|
||||
}
|
||||
@@ -119,7 +119,11 @@ if (Object.keys(sortedOptDeps).length > 0) {
|
||||
}
|
||||
|
||||
const output = JSON.stringify(publishPkg, null, 2) + "\n";
|
||||
const outPath = resolve(repoRoot, "cli/package.json");
|
||||
const [outputFlag, outputPath, ...extraArgs] = process.argv.slice(2);
|
||||
if (extraArgs.length || (outputFlag !== undefined && (outputFlag !== "--output" || !outputPath))) {
|
||||
throw new Error("Usage: generate-npm-package-json.mjs [--output <manifest-path>]");
|
||||
}
|
||||
const outPath = outputPath ? resolve(outputPath) : resolve(repoRoot, "cli/package.json");
|
||||
writeFileSync(outPath, output);
|
||||
|
||||
console.log(` ✓ Generated publishable package.json (${Object.keys(sortedDeps).length} deps)`);
|
||||
|
||||
@@ -7,17 +7,18 @@ export const GROK_PUBLIC_INSTALL_LIFECYCLE = [
|
||||
'npm', 'rebuild', '--offline', '--ignore-scripts=false', '--dangerously-allow-all-scripts',
|
||||
];
|
||||
|
||||
export function grokConsumerDockerArgs({ assets, consumer, cache, command, uid, gid, download = false, prerequisite }) {
|
||||
export function grokConsumerDockerArgs({ assets, consumer, cache, command, uid, gid, download = false, prerequisite, temporarySizeMb = 256 }) {
|
||||
if (!Number.isSafeInteger(uid) || uid <= 0 || !Number.isSafeInteger(gid) || gid <= 0) {
|
||||
throw new Error('Public-install verification requires an unprivileged host user');
|
||||
}
|
||||
if (!Number.isSafeInteger(temporarySizeMb) || temporarySizeMb < 256 || temporarySizeMb > 2048) throw new Error('Invalid bounded public-install temporary size');
|
||||
return [
|
||||
'run', '--rm', '--platform', 'linux/amd64',
|
||||
'--user', `${uid}:${gid}`, '--read-only',
|
||||
'--cap-drop', 'ALL', '--security-opt', 'no-new-privileges',
|
||||
'--pids-limit', '256', '--memory', '3g',
|
||||
'--network', download ? 'bridge' : 'none',
|
||||
'--tmpfs', '/tmp:rw,nosuid,nodev,size=256m,mode=1777',
|
||||
'--tmpfs', `/tmp:rw,nosuid,nodev,size=${temporarySizeMb}m,mode=1777`,
|
||||
'--env', 'HOME=/tmp', '--env', 'npm_config_cache=/cache',
|
||||
'--env', 'npm_config_nodedir=/usr/local',
|
||||
'--env', 'npm_config_audit=false', '--env', 'npm_config_fund=false',
|
||||
|
||||
@@ -807,6 +807,91 @@ const apps = [
|
||||
{ requiredResourceFilters: ["team", "project", "environment"] },
|
||||
),
|
||||
],
|
||||
// Enterpret advertises RFC 9728 -> RFC 8414 discovery from its own 401
|
||||
// challenge (issuer https://oauth.enterpret.com, PKCE S256, registration
|
||||
// endpoint present, token_endpoint_auth_method "none"), so `defaults` ships
|
||||
// `serverUrl` only and the broker resolves endpoints at connect time.
|
||||
// Both methods target the official read-only Enterpret MCP. Enterpret Agent's
|
||||
// beta write MCP is a separate service and is outside this connector's scope.
|
||||
// OAuth scope reporting and revocation-cache fixes await provider deployment
|
||||
// and fresh live validation; scope names alone do not prove write capability.
|
||||
// The provider documents no customer-registered OAuth app, so OAuth stays
|
||||
// `dcr` only rather than `["customer", "dcr"]`.
|
||||
[
|
||||
"enterpret",
|
||||
"Enterpret",
|
||||
"Ask questions about your customer feedback and pull verbatim quotes with citations.",
|
||||
"analytics",
|
||||
"enterpret.com",
|
||||
["https://wisdom-api.enterpret.com/*"],
|
||||
[
|
||||
method(
|
||||
"mcp-api-key",
|
||||
"mcp_remote",
|
||||
"api_key",
|
||||
{ serverUrl: "https://wisdom-api.enterpret.com/server/mcp" },
|
||||
"S3",
|
||||
"Generate an auth token in Enterpret under Settings, Enterpret MCP, then paste it below. One token belongs to one Enterpret organization. This is the recommended connection method.",
|
||||
{
|
||||
label: "Use an auth token",
|
||||
grantKinds: ["organization"],
|
||||
whenToUse:
|
||||
"Recommended. Use an organization auth token from Settings → Enterpret MCP. This is the primary, store-ready connection method.",
|
||||
credentialFields: [
|
||||
field(
|
||||
"authorization",
|
||||
"Enterpret auth token",
|
||||
"Paste the token from Settings, Enterpret MCP",
|
||||
),
|
||||
],
|
||||
keyPlacement: {
|
||||
location: "header",
|
||||
name: "Authorization",
|
||||
prefix: "Bearer ",
|
||||
},
|
||||
consoleLinks: {
|
||||
docs: "https://enterpret.support.site/article/enterpret-mcp-server",
|
||||
},
|
||||
warnings: [
|
||||
"Check your auth token's expiry in Enterpret Settings > Enterpret MCP and replace it before it lapses.",
|
||||
"This connection reads customer feedback, including verbatim quotes with speaker attribution.",
|
||||
"run_graph_query starts as Ask first. Cypher is not established as read-only even when Enterpret advertises readOnlyHint.",
|
||||
],
|
||||
},
|
||||
),
|
||||
method(
|
||||
"mcp-oauth",
|
||||
"mcp_remote",
|
||||
"oauth",
|
||||
{
|
||||
serverUrl: "https://wisdom-api.enterpret.com/server/mcp",
|
||||
scopesHint: ["mcp:read"],
|
||||
},
|
||||
"S3",
|
||||
"Sign in to Enterpret in the browser to query the official read-only MCP. Each person connects with their own Enterpret account, and Enterpret attributes their queries individually.",
|
||||
{
|
||||
label: "Sign in with Enterpret",
|
||||
ownershipModes: ["dcr"],
|
||||
grantKinds: ["user"],
|
||||
whenToUse:
|
||||
"Use browser sign-in when each person should query feedback under their own Enterpret account.",
|
||||
consoleLinks: {
|
||||
docs: "https://enterpret.support.site/article/enterpret-mcp-server",
|
||||
},
|
||||
warnings: [
|
||||
"The official Enterpret MCP is read-only. Enterpret previously reported broader OAuth scopes, including mcp:write and email, than Paperclip requested. Enterpret is correcting this scope reporting; it does not establish access to the separate beta Agent MCP.",
|
||||
"After revocation, Enterpret may cache token validity for up to 24 hours. Disconnect this connection to stop Paperclip access immediately. Enterpret is reducing this delay.",
|
||||
"You need an Enterpret account with access to your organization's feedback.",
|
||||
"This connection reads customer feedback, including verbatim quotes with speaker attribution.",
|
||||
],
|
||||
},
|
||||
),
|
||||
],
|
||||
{
|
||||
docsUrl: "https://enterpret.support.site/article/enterpret-mcp-server",
|
||||
redirectConstraints: "https-or-loopback-http",
|
||||
},
|
||||
],
|
||||
[
|
||||
"anthropic",
|
||||
"Anthropic",
|
||||
@@ -945,6 +1030,7 @@ const categoryBySlug = {
|
||||
miro: "productivity",
|
||||
mixpanel: "analytics",
|
||||
netlify: "developer",
|
||||
neon: "data",
|
||||
notion: "content",
|
||||
oreilly: "content",
|
||||
pagerduty: "developer",
|
||||
@@ -957,7 +1043,9 @@ const categoryBySlug = {
|
||||
sentry: "developer",
|
||||
similarweb: "analytics",
|
||||
stripe: "commerce",
|
||||
superagent: "developer",
|
||||
supabase: "data",
|
||||
telem: "ai",
|
||||
"ticket-tailor": "commerce",
|
||||
ticktick: "productivity",
|
||||
todoist: "productivity",
|
||||
@@ -1018,6 +1106,11 @@ const apiKeySpec = {
|
||||
placeholder: "Paste your Kernel API key",
|
||||
},
|
||||
mem0: { name: "Authorization", prefix: "Bearer ", placeholder: "Paste your Mem0 API key" },
|
||||
neon: {
|
||||
name: "Authorization",
|
||||
prefix: "Bearer ",
|
||||
placeholder: "napi_... or neon_project_key_...",
|
||||
},
|
||||
oreilly: {
|
||||
name: "Authorization",
|
||||
prefix: "Bearer ",
|
||||
@@ -1052,6 +1145,12 @@ const apiKeySpec = {
|
||||
prefix: "Bearer ",
|
||||
placeholder: "sbp_...",
|
||||
},
|
||||
superagent: { name: "Authorization", prefix: "Bearer ", placeholder: "sk_live_..." },
|
||||
telem: {
|
||||
name: "Authorization",
|
||||
prefix: "Bearer ",
|
||||
placeholder: "tlm_...",
|
||||
},
|
||||
youcom: {
|
||||
name: "Authorization",
|
||||
prefix: "Bearer ",
|
||||
@@ -1124,6 +1223,20 @@ const specialMethodsFor = (entry) => {
|
||||
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 },
|
||||
...(entry.slug === "honcho" ? { tenantFields: [{
|
||||
key: "workspaceId", label: "Honcho workspace", type: "text", required: true,
|
||||
placeholder: "Workspace ID", validation: { maxLength: 512 },
|
||||
}] } : {}),
|
||||
}),
|
||||
];
|
||||
// Superagent's hosted server advertises protected-resource metadata, but its
|
||||
// authorization server publishes no OAuth metadata, so organization API keys
|
||||
// are the only working credential.
|
||||
if (entry.slug === "superagent") return [
|
||||
apiKeyMethodFor(entry, "mcp-api-key", entry.serverUrl, {
|
||||
whenToUse: "Connect with a Superagent organization API key.",
|
||||
guidanceMd: "Open Superagent Settings → API keys, create a separate key for Paperclip, and paste it below.",
|
||||
consoleLinks: { keys: "https://www.superagent.sh/app/settings#api-keys", docs: entry.docsUrl },
|
||||
}),
|
||||
];
|
||||
if (entry.slug === "zep") return [oauthMethodFor(entry, "mcp-oauth", entry.serverUrl, {
|
||||
@@ -1414,6 +1527,128 @@ const specialMethodsFor = (entry) => {
|
||||
}),
|
||||
];
|
||||
}
|
||||
if (entry.slug === "neon") {
|
||||
// Neon's hosted server narrows itself with documented query options:
|
||||
// `projectId` pins one project and `readonly=true` limits SQL to SELECT
|
||||
// and schema inspection. Its repeatable `category` filter has no
|
||||
// comma-joined form, so catalog narrowing stays with per-action policies.
|
||||
const tenantFields = [
|
||||
{
|
||||
key: "projectId",
|
||||
label: "Pin to project ID",
|
||||
type: "text",
|
||||
advanced: true,
|
||||
placeholder: "Optional Neon project ID",
|
||||
helperMd:
|
||||
"Optional. Restrict this connection to one project. Copy the project ID from Neon Console → Project settings → General.",
|
||||
validation: { pattern: "^[a-z0-9-]+$", maxLength: 64 },
|
||||
transport: { location: "query", name: "projectId" },
|
||||
},
|
||||
{
|
||||
key: "readOnly",
|
||||
label: "Read-only mode",
|
||||
type: "checkbox",
|
||||
defaultValue: false,
|
||||
helperMd:
|
||||
"Enable this to limit SQL to SELECT queries and schema inspection.",
|
||||
transport: {
|
||||
location: "query",
|
||||
name: "readonly",
|
||||
format: "boolean",
|
||||
omitFalse: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
const warning =
|
||||
"Neon recommends its hosted server for development and testing. Review write and destructive actions before execution.";
|
||||
return [
|
||||
oauthMethodFor(entry, "mcp-oauth", entry.serverUrl, {
|
||||
guidanceMd:
|
||||
"Connect Neon in the browser. Open Advanced to pin one project or enable read-only mode. Write tools start enabled and remain governed by Paperclip's action policies.",
|
||||
tenantFields,
|
||||
warnings: [entry.prerequisite, warning],
|
||||
requiredResourceFilters: ["project"],
|
||||
}),
|
||||
apiKeyMethodFor(entry, "mcp-api-key", entry.serverUrl, {
|
||||
guidanceMd:
|
||||
"Use a customer-created Neon API key. Prefer a project-scoped key for one development project; personal and organization keys reach every project they can access. Write tools start enabled and remain governed by Paperclip's action policies.",
|
||||
consoleLinks: {
|
||||
keys: "https://console.neon.tech/app/settings/api-keys",
|
||||
docs: entry.docsUrl,
|
||||
},
|
||||
tenantFields,
|
||||
warnings: [entry.prerequisite, warning],
|
||||
requiredResourceFilters: ["project"],
|
||||
}),
|
||||
];
|
||||
}
|
||||
if (entry.slug === "telem") {
|
||||
// Telem's hosted server takes an API key only. The optional settings are
|
||||
// per-connection request headers that the server reads; each one is left
|
||||
// out of the request when it is empty, so an unset field keeps the
|
||||
// server's default (auto routing off, default tier, all providers).
|
||||
const providerList = (key, label, header, helperMd) => ({
|
||||
key,
|
||||
label,
|
||||
type: "textarea",
|
||||
advanced: true,
|
||||
placeholder: "Optional comma-separated provider names",
|
||||
helperMd,
|
||||
validation: { pattern: "^[A-Za-z0-9_.-]+(,[A-Za-z0-9_.-]+)*$", maxLength: 500 },
|
||||
transport: { location: "header", name: header, format: "csv" },
|
||||
});
|
||||
const tenantFields = [
|
||||
{
|
||||
key: "autoRouting",
|
||||
label: "Auto routing",
|
||||
type: "select",
|
||||
advanced: true,
|
||||
options: [
|
||||
{ value: "off", label: "Off" },
|
||||
{ value: "accuracy", label: "Accuracy" },
|
||||
],
|
||||
helperMd:
|
||||
"Optional. Accuracy lets Telem choose the search providers for each query. Off, or no selection, keeps auto routing off.",
|
||||
transport: { location: "header", name: "X-Telem-Auto-Routing" },
|
||||
},
|
||||
{
|
||||
key: "tier",
|
||||
label: "Tier",
|
||||
type: "select",
|
||||
advanced: true,
|
||||
options: [
|
||||
{ value: "minimalist", label: "Minimalist" },
|
||||
{ value: "default", label: "Default" },
|
||||
{ value: "extended", label: "Extended" },
|
||||
{ value: "max", label: "Max" },
|
||||
],
|
||||
helperMd:
|
||||
"Optional. Sets how much search work Telem does for each query. No selection uses the Default tier.",
|
||||
transport: { location: "header", name: "X-Telem-Tier" },
|
||||
},
|
||||
providerList(
|
||||
"providersInclude",
|
||||
"Providers to include",
|
||||
"X-Telem-Providers-Include",
|
||||
"Optional. Telem searches only these providers. Leave it empty to allow all providers.",
|
||||
),
|
||||
providerList(
|
||||
"providersExclude",
|
||||
"Providers to exclude",
|
||||
"X-Telem-Providers-Exclude",
|
||||
"Optional. Telem does not search these providers.",
|
||||
),
|
||||
];
|
||||
return [
|
||||
apiKeyMethodFor(entry, "mcp-api-key", entry.serverUrl, {
|
||||
guidanceMd:
|
||||
"Create an API key in the Telem console at app.telem.ai. Paste the key below. Open Advanced to set auto routing, the tier, or the providers to include or exclude.",
|
||||
whenToUse: "Connect with a Telem API key.",
|
||||
consoleLinks: { keys: "https://app.telem.ai", docs: entry.docsUrl },
|
||||
tenantFields,
|
||||
}),
|
||||
];
|
||||
}
|
||||
if (entry.slug === "youcom") {
|
||||
// You.com also serves a documented keyless profile at ?profile=free with a
|
||||
// reduced read-only tool set. That is a real user choice: try web search
|
||||
@@ -1515,7 +1750,7 @@ for (const entry of researchManifest.entries) {
|
||||
schemaVersion: 1,
|
||||
slug: entry.slug,
|
||||
name: entry.name,
|
||||
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"
|
||||
description: ({ neon: "Manage Postgres projects and branches, run SQL, and inspect schemas in Neon.", superagent: "Review security findings, start red-team reports, and score content and packages before agents trust them.", 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.", telem: "Search the web and read pages across many search providers with one API key." })[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.`),
|
||||
categories: [categoryBySlug[entry.slug] ?? "other"],
|
||||
@@ -1653,13 +1888,36 @@ const inferState = (slug, state) => {
|
||||
};
|
||||
};
|
||||
// Runtime credentials share the provider catalog, but never expose tool actions.
|
||||
for (const [slug, name, subscription, envKey] of [["anthropic", "Claude", true, "ANTHROPIC_API_KEY"], ["openai", "OpenAI", true, "OPENAI_API_KEY"], ["openrouter", "OpenRouter", false, "OPENROUTER_API_KEY"], ["xai", "Grok", true, "XAI_API_KEY"]]) {
|
||||
let app=apps.find(a=>a.slug===slug);
|
||||
if(!app){app={schemaVersion:1,slug,name,description:`Connect ${name} accounts for your agents.`,categories:["ai"],branding:brandingFor(slug),urlPatterns:[{"openai":"https://api.openai.com/*","openrouter":"https://openrouter.ai/api/*","xai":"https://api.x.ai/*"}[slug]],methods:[]};apps.push(app);}
|
||||
const methods=(subscription?["subscription","api_key"]:["api_key"]).map(authMethod=>({key:`ai-${authMethod}`,label:authMethod==="subscription"?`${name} subscription`:`${name} API key`,purpose:"ai",transport:"runtime_auth",auth:authMethod==="subscription"?"oauth":"api_key",ai:{provider:slug,method:authMethod},grantKinds:["user","organization"],ownershipModes:["customer"],whenToUse:"Authenticate an agent with this account.",guidanceMd:"Use your personal account or an explicitly shared company account.",riskTier:"S3",...(authMethod==="api_key"?{credentialFields:[field("apiKey","API key","Enter API key")],keyPlacement:{location:"env",name:envKey}}:{})}));
|
||||
// Legacy REST entries have no tool execution adapter. Only offer the supported
|
||||
// AI account flow; saved REST connections remain removable through Connections.
|
||||
app.methods = [...methods, ...app.methods.filter(method => method.transport !== "rest_api")];
|
||||
const aiCatalogEntries = [
|
||||
{ slug: "anthropic", name: "Claude", provider: "anthropic", subscription: true, envKey: "ANTHROPIC_API_KEY" },
|
||||
{ slug: "openai", name: "OpenAI", provider: "openai", subscription: true, envKey: "OPENAI_API_KEY", url: "https://api.openai.com/*" },
|
||||
{ slug: "openrouter", name: "OpenRouter", provider: "openrouter", envKey: "OPENROUTER_API_KEY", url: "https://openrouter.ai/api/*" },
|
||||
{ slug: "xai", name: "Grok", provider: "xai", subscription: true, envKey: "XAI_API_KEY", url: "https://api.x.ai/*" },
|
||||
{ slug: "google", name: "Google Gemini", provider: "google", envKey: "GEMINI_API_KEY", url: "https://generativelanguage.googleapis.com/*" },
|
||||
{ slug: "bedrock", name: "Amazon Bedrock", provider: "anthropic", envKey: "AWS_BEARER_TOKEN_BEDROCK", description: "Use Claude through Amazon Bedrock with a Bedrock API key and AWS region." },
|
||||
{ slug: "responses-api", name: "Responses API", provider: "openai", envKey: "OPENAI_API_KEY", description: "Connect any compatible harness to an OpenAI Responses-compatible provider or gateway, including Emissary." },
|
||||
{ slug: "messages-api", name: "Messages API", provider: "anthropic", envKey: "ANTHROPIC_API_KEY", description: "Connect any compatible harness to an Anthropic Messages-compatible provider or gateway." },
|
||||
{ slug: "chat-completions-api", name: "Chat Completions API", provider: "openai", envKey: "OPENAI_API_KEY", description: "Connect any compatible harness to a Chat Completions-compatible provider or gateway." },
|
||||
{ slug: "local", name: "Local endpoint", provider: "openai", envKey: "OPENAI_API_KEY", description: "Use a local model server in the agent’s execution environment." },
|
||||
];
|
||||
for (const { slug, name, provider, subscription, envKey, url, description } of aiCatalogEntries) {
|
||||
let app = apps.find(a => a.slug === slug);
|
||||
if (!app) {
|
||||
app = { schemaVersion: 1, slug, name, description: description ?? `Connect ${name} accounts for your agents.`, categories: ["ai"], branding: brandingFor(slug), urlPatterns: url ? [url] : [], methods: [] };
|
||||
apps.push(app);
|
||||
}
|
||||
app.tags = [...new Set([...(app.tags ?? []), "model-provider"])];
|
||||
const methods = (subscription ? ["subscription", "api_key"] : ["api_key"]).map(authMethod => ({
|
||||
key: `ai-${authMethod}`, label: authMethod === "subscription" ? `${name} subscription` : `${name} API key`,
|
||||
purpose: "ai", transport: "runtime_auth", auth: authMethod === "subscription" ? "oauth" : "api_key",
|
||||
ai: { provider, method: authMethod }, grantKinds: ["user", "organization"], ownershipModes: ["customer"],
|
||||
whenToUse: description ?? "Authenticate an agent with this account.",
|
||||
guidanceMd: "Use your personal account or an explicitly shared company account.", riskTier: "S3",
|
||||
...(authMethod === "api_key" ? { credentialFields: [field("apiKey", "API key", "Enter API key")], keyPlacement: { location: "env", name: envKey } } : {}),
|
||||
}));
|
||||
// Legacy REST entries have no tool execution adapter. Only offer the supported
|
||||
// AI account flow; saved REST connections remain removable through Connections.
|
||||
app.methods = [...methods, ...app.methods.filter(method => method.transport !== "rest_api")];
|
||||
}
|
||||
// Every tool method has a checked-in permission review. Discovery metadata is
|
||||
// evidence for reviewers, never a runtime instruction to request more scopes.
|
||||
@@ -1694,6 +1952,21 @@ for (const app of apps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Reviewed instruction templates are authored in each app's definition. Keep
|
||||
// that optional capability intact when regenerating its transport/auth fields.
|
||||
for (const app of apps) {
|
||||
const definitionPath = path.join(out, `${app.slug}.json`);
|
||||
if (!fs.existsSync(definitionPath)) continue;
|
||||
const { agentInstructions: template } = JSON.parse(fs.readFileSync(definitionPath, "utf8"));
|
||||
if (template === undefined) continue;
|
||||
if (!template || typeof template.id !== "string" || !template.id.trim() || template.id.length > 160
|
||||
|| !Number.isInteger(template.version) || template.version < 1
|
||||
|| typeof template.text !== "string" || !template.text.trim() || template.text.length > 2000) {
|
||||
throw new Error(`${app.slug}: invalid agent instruction template`);
|
||||
}
|
||||
app.agentInstructions = template;
|
||||
}
|
||||
|
||||
const validateApp = (app) => {
|
||||
if (
|
||||
app.schemaVersion !== 1 ||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env node
|
||||
// Credential-free ACP filesystem requests, driven through the real client.
|
||||
import { createInterface } from "node:readline";
|
||||
|
||||
const pending = new Map();
|
||||
let nextId = 100;
|
||||
const send = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);
|
||||
const request = (method, params) => new Promise((resolve) => {
|
||||
const id = nextId++;
|
||||
pending.set(id, resolve);
|
||||
send({ jsonrpc: "2.0", id, method, params });
|
||||
});
|
||||
|
||||
createInterface({ input: process.stdin }).on("line", async (line) => {
|
||||
const message = JSON.parse(line);
|
||||
if (pending.has(message.id)) {
|
||||
pending.get(message.id)(message);
|
||||
pending.delete(message.id);
|
||||
return;
|
||||
}
|
||||
let result;
|
||||
if (message.method === "initialize") {
|
||||
result = {
|
||||
protocolVersion: 1,
|
||||
agentCapabilities: { loadSession: false, sessionCapabilities: { close: {} } },
|
||||
agentInfo: { name: "paperclip-acp-filesystem-fixture", version: "1.0.0" },
|
||||
};
|
||||
} else if (message.method === "session/new") {
|
||||
result = { sessionId: "filesystem-fixture" };
|
||||
} else if (message.method === "session/prompt") {
|
||||
const { operations } = JSON.parse(message.params.prompt.find((entry) => entry.type === "text").text);
|
||||
for (const { method, ...params } of operations) {
|
||||
const response = await request(method, { sessionId: "filesystem-fixture", ...params });
|
||||
process.stderr.write(`FS_RESULT ${JSON.stringify(response)}\n`);
|
||||
}
|
||||
result = { stopReason: "end_turn" };
|
||||
} else if (message.method === "session/cancel") {
|
||||
return;
|
||||
} else {
|
||||
result = {};
|
||||
}
|
||||
if (message.id !== undefined) send({ jsonrpc: "2.0", id: message.id, result });
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
import { createServer } from "node:http";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
/** A loopback-only MCP + OAuth provider; no vendor account or fetch mock needed. */
|
||||
export async function startOAuthRefreshFixture({
|
||||
port = 0,
|
||||
supportsOfflineAccess = true,
|
||||
grantTypes = ["authorization_code", "refresh_token"],
|
||||
challengeScope = true,
|
||||
resourceScopes = ["mcp:read"],
|
||||
accessTokenLifetimeSeconds = 3600,
|
||||
} = {}) {
|
||||
const clients = new Map();
|
||||
const codes = new Map();
|
||||
const accessTokens = new Map();
|
||||
const refreshTokens = new Map();
|
||||
// Diagnostics deliberately contain no credentials, codes, or PKCE verifiers.
|
||||
const events = [];
|
||||
let clockOffset = 0;
|
||||
let origin;
|
||||
const now = () => Date.now() + clockOffset;
|
||||
const issuer = () => `${origin}/identity/acme`;
|
||||
const resource = () => `${origin}/mcp`;
|
||||
const supportedScopes = () => ["mcp:read", "admin:all", ...(supportsOfflineAccess ? ["offline_access"] : [])];
|
||||
const json = (res, status, body) => {
|
||||
res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
const fail = (res, error) => json(res, 400, { error });
|
||||
|
||||
function issueTokens(grant) {
|
||||
const accessToken = `fixture-access-${randomUUID()}`;
|
||||
accessTokens.set(accessToken, { ...grant, expiresAt: now() + accessTokenLifetimeSeconds * 1000 });
|
||||
const refreshToken = grant.offline ? `fixture-refresh-${randomUUID()}` : null;
|
||||
if (refreshToken) refreshTokens.set(refreshToken, grant);
|
||||
return {
|
||||
access_token: accessToken,
|
||||
...(refreshToken ? { refresh_token: refreshToken } : {}),
|
||||
expires_in: accessTokenLifetimeSeconds,
|
||||
token_type: "Bearer",
|
||||
scope: grant.scopes.join(" "),
|
||||
};
|
||||
}
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url, origin);
|
||||
const chunks = [];
|
||||
for await (const chunk of req) chunks.push(chunk);
|
||||
const rawBody = Buffer.concat(chunks).toString("utf8");
|
||||
const form = new URLSearchParams(rawBody);
|
||||
const path = url.pathname;
|
||||
|
||||
if (path === "/.well-known/oauth-protected-resource/mcp") {
|
||||
json(res, 200, { resource: resource(), authorization_servers: [issuer()], scopes_supported: resourceScopes });
|
||||
} else if (path === "/.well-known/oauth-authorization-server/identity/acme") {
|
||||
json(res, 200, {
|
||||
issuer: issuer(),
|
||||
authorization_endpoint: `${issuer()}/authorize`,
|
||||
token_endpoint: `${issuer()}/token`,
|
||||
registration_endpoint: `${issuer()}/register`,
|
||||
scopes_supported: supportedScopes(),
|
||||
grant_types_supported: grantTypes,
|
||||
response_types_supported: ["code"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
token_endpoint_auth_methods_supported: ["none"],
|
||||
});
|
||||
} else if (path === "/identity/acme/register" && req.method === "POST") {
|
||||
const metadata = JSON.parse(rawBody);
|
||||
if (!Array.isArray(metadata.redirect_uris) || metadata.redirect_uris.some((uri) => {
|
||||
const parsed = new URL(uri);
|
||||
return parsed.protocol !== "http:" || !["127.0.0.1", "localhost", "[::1]"].includes(parsed.hostname);
|
||||
})) return fail(res, "invalid_redirect_uri");
|
||||
const requestedGrantTypes = metadata.grant_types ?? ["authorization_code"];
|
||||
if (!Array.isArray(requestedGrantTypes) || requestedGrantTypes.some((type) => !grantTypes.includes(type))) return fail(res, "invalid_client_metadata");
|
||||
metadata.grant_types = requestedGrantTypes;
|
||||
const clientId = `fixture-client-${randomUUID()}`;
|
||||
clients.set(clientId, metadata);
|
||||
events.push({ kind: "registration", grantTypes: metadata.grant_types });
|
||||
json(res, 201, { ...metadata, client_id: clientId });
|
||||
} else if (path === "/identity/acme/authorize") {
|
||||
const params = req.method === "POST" ? form : url.searchParams;
|
||||
const clientId = params.get("client_id");
|
||||
const client = clients.get(clientId);
|
||||
const redirectUri = params.get("redirect_uri");
|
||||
const scopes = (params.get("scope") ?? "").split(/\s+/).filter(Boolean);
|
||||
if (!client || !client.redirect_uris.includes(redirectUri)) return fail(res, "invalid_client");
|
||||
if (params.get("response_type") !== "code" || params.get("code_challenge_method") !== "S256") return fail(res, "invalid_request");
|
||||
if (params.get("resource") !== resource()) return fail(res, "invalid_target");
|
||||
if (scopes.some((scope) => !supportedScopes().includes(scope))) return fail(res, "invalid_scope");
|
||||
if (req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
||||
const escape = (value) => String(value).replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[char]));
|
||||
res.end(`<!doctype html><html><title>Test MCP consent</title><h1>Connect the test MCP server</h1><p>Requested scopes: ${escape(scopes.join(" "))}</p><form method="post">${[...params].map(([key, value]) => `<input type="hidden" name="${escape(key)}" value="${escape(value)}">`).join("")}<button name="decision" value="allow">Allow</button><button name="decision" value="deny">Deny</button></form></html>`);
|
||||
return;
|
||||
}
|
||||
const callback = new URL(redirectUri);
|
||||
callback.searchParams.set("state", params.get("state") ?? "");
|
||||
callback.searchParams.set("iss", issuer());
|
||||
if (params.get("decision") !== "allow") callback.searchParams.set("error", "access_denied");
|
||||
else {
|
||||
const offline = grantTypes.includes("refresh_token") && scopes.includes("offline_access") && params.get("prompt")?.split(/\s+/).includes("consent") && client.grant_types.includes("refresh_token");
|
||||
const code = `fixture-code-${randomUUID()}`;
|
||||
codes.set(code, {
|
||||
clientId, redirectUri, resource: resource(), codeChallenge: params.get("code_challenge"),
|
||||
scopes: offline ? scopes : scopes.filter((scope) => scope !== "offline_access"), offline,
|
||||
});
|
||||
events.push({ kind: "authorization", scopes, prompt: params.get("prompt"), offline: Boolean(offline) });
|
||||
callback.searchParams.set("code", code);
|
||||
}
|
||||
res.writeHead(302, { Location: callback.toString() });
|
||||
res.end();
|
||||
} else if (path === "/identity/acme/token" && req.method === "POST") {
|
||||
const grantType = form.get("grant_type");
|
||||
if (!grantTypes.includes(grantType)) return fail(res, "unsupported_grant_type");
|
||||
let grant;
|
||||
if (grantType === "authorization_code") {
|
||||
const code = form.get("code");
|
||||
grant = codes.get(code);
|
||||
const challenge = createHash("sha256").update(form.get("code_verifier") ?? "").digest("base64url");
|
||||
if (!grant || grant.clientId !== form.get("client_id") || grant.redirectUri !== form.get("redirect_uri") || grant.resource !== form.get("resource") || grant.codeChallenge !== challenge) return fail(res, "invalid_grant");
|
||||
codes.delete(code);
|
||||
} else if (grantType === "refresh_token") {
|
||||
const refreshToken = form.get("refresh_token");
|
||||
grant = refreshTokens.get(refreshToken);
|
||||
if (!grant || grant.clientId !== form.get("client_id") || grant.resource !== form.get("resource")) return fail(res, "invalid_grant");
|
||||
refreshTokens.delete(refreshToken); // Rotation: reuse must fail.
|
||||
} else return fail(res, "unsupported_grant_type");
|
||||
events.push({ kind: "token", grantType, resource: grant.resource, offline: Boolean(grant.offline) });
|
||||
json(res, 200, issueTokens(grant));
|
||||
} else if (path === "/mcp") {
|
||||
const token = accessTokens.get(req.headers.authorization?.replace(/^Bearer /, ""));
|
||||
if (!token || token.expiresAt <= now()) {
|
||||
events.push({ kind: "mcp_unauthorized" });
|
||||
res.writeHead(401, { "WWW-Authenticate": `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp"${challengeScope ? ', scope="mcp:read"' : ""}` });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (!token.scopes.includes("mcp:read")) { json(res, 403, { error: "insufficient_scope" }); return; }
|
||||
if (req.method !== "POST") { res.writeHead(405); res.end(); return; }
|
||||
const rpc = JSON.parse(rawBody);
|
||||
events.push({ kind: "mcp", method: rpc.method });
|
||||
if (rpc.method === "notifications/initialized") { res.writeHead(202); res.end(); return; }
|
||||
let result;
|
||||
if (rpc.method === "initialize") result = { protocolVersion: rpc.params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "offline-access-fixture", version: "1.0.0" } };
|
||||
else if (rpc.method === "tools/list") result = { tools: [{ name: "read_status", description: "Read the test server status", inputSchema: { type: "object", properties: {} }, annotations: { readOnlyHint: true } }] };
|
||||
else if (rpc.method === "tools/call" && rpc.params.name === "read_status") result = { content: [{ type: "text", text: "Authenticated MCP call succeeded" }] };
|
||||
else { json(res, 200, { jsonrpc: "2.0", id: rpc.id, error: { code: -32601, message: "Method not found" } }); return; }
|
||||
json(res, 200, { jsonrpc: "2.0", id: rpc.id, result });
|
||||
} else { json(res, 404, { error: "not_found" }); }
|
||||
} catch {
|
||||
json(res, 400, { error: "invalid_request" });
|
||||
}
|
||||
});
|
||||
server.listen(port, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
origin = `http://127.0.0.1:${server.address().port}`;
|
||||
return {
|
||||
origin, mcpUrl: resource(), issuer: issuer(), events,
|
||||
/** Advance only provider time; callers can separately expire their cached token. */
|
||||
advanceTime(ms) { clockOffset += ms; },
|
||||
async close() {
|
||||
const closed = new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
server.closeAllConnections();
|
||||
await closed;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const fixture = await startOAuthRefreshFixture({ port: Number(process.env.PORT ?? 0), accessTokenLifetimeSeconds: 120 });
|
||||
console.log(JSON.stringify({ event: "ready", mcpUrl: fixture.mcpUrl, accessTokenLifetimeSeconds: 120 }));
|
||||
const stop = async () => { await fixture.close(); process.exit(0); };
|
||||
process.once("SIGINT", stop);
|
||||
process.once("SIGTERM", stop);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import test from "node:test";
|
||||
import { startOAuthRefreshFixture } from "./oauth-refresh-fixture.mjs";
|
||||
|
||||
async function connect(fixture, { offline = true, consent = true, grantTypes = ["authorization_code", "refresh_token"] } = {}) {
|
||||
const redirectUri = "http://127.0.0.1:3100/api/tools/oauth/callback";
|
||||
const registration = await fetch(`${fixture.issuer}/register`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ redirect_uris: [redirectUri], grant_types: grantTypes }),
|
||||
});
|
||||
assert.equal(registration.status, 201);
|
||||
const client = await registration.json();
|
||||
const verifier = "fixture-proof-key-for-tests";
|
||||
const authorization = new URLSearchParams({
|
||||
client_id: client.client_id, redirect_uri: redirectUri, response_type: "code", state: "fixture-state",
|
||||
resource: fixture.mcpUrl, code_challenge_method: "S256",
|
||||
code_challenge: createHash("sha256").update(verifier).digest("base64url"),
|
||||
scope: offline ? "mcp:read offline_access" : "mcp:read", decision: "allow",
|
||||
...(consent ? { prompt: "consent" } : {}),
|
||||
});
|
||||
const response = await fetch(`${fixture.issuer}/authorize`, { method: "POST", body: authorization, redirect: "manual" });
|
||||
assert.equal(response.status, 302);
|
||||
const callback = new URL(response.headers.get("location"));
|
||||
const exchangeBody = new URLSearchParams({
|
||||
grant_type: "authorization_code", client_id: client.client_id, redirect_uri: redirectUri,
|
||||
resource: fixture.mcpUrl, code_verifier: verifier, code: callback.searchParams.get("code"),
|
||||
});
|
||||
const exchange = await fetch(`${fixture.issuer}/token`, { method: "POST", body: exchangeBody });
|
||||
assert.equal(exchange.status, 200);
|
||||
return { tokens: await exchange.json(), clientId: client.client_id };
|
||||
}
|
||||
|
||||
async function call(fixture, accessToken) {
|
||||
return fetch(fixture.mcpUrl, {
|
||||
method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}` },
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "read_status", arguments: {} } }),
|
||||
});
|
||||
}
|
||||
|
||||
for (const options of [{ offline: false }, { consent: false }]) {
|
||||
test(`access-only grant disconnects after expiry: ${JSON.stringify(options)}`, async () => {
|
||||
const fixture = await startOAuthRefreshFixture();
|
||||
try {
|
||||
const { tokens } = await connect(fixture, options);
|
||||
assert.equal(tokens.refresh_token, undefined);
|
||||
assert.equal((await call(fixture, tokens.access_token)).status, 200);
|
||||
fixture.advanceTime(3_601_000);
|
||||
assert.equal((await call(fixture, tokens.access_token)).status, 401);
|
||||
} finally { await fixture.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
test("authorization-code-only provider rejects refresh registration and token requests", async () => {
|
||||
const fixture = await startOAuthRefreshFixture({ grantTypes: ["authorization_code"] });
|
||||
try {
|
||||
const registration = await fetch(`${fixture.issuer}/register`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ redirect_uris: ["http://127.0.0.1:3100/api/tools/oauth/callback"], grant_types: ["authorization_code", "refresh_token"] }),
|
||||
});
|
||||
assert.equal(registration.status, 400);
|
||||
assert.deepEqual(await registration.json(), { error: "invalid_client_metadata" });
|
||||
const { tokens, clientId } = await connect(fixture, { grantTypes: ["authorization_code"] });
|
||||
assert.equal(tokens.refresh_token, undefined);
|
||||
assert.equal((await call(fixture, tokens.access_token)).status, 200);
|
||||
const body = new URLSearchParams({ grant_type: "refresh_token", client_id: clientId, resource: fixture.mcpUrl, refresh_token: "unissued-token" });
|
||||
const refresh = await fetch(`${fixture.issuer}/token`, { method: "POST", body });
|
||||
assert.equal(refresh.status, 400);
|
||||
assert.deepEqual(await refresh.json(), { error: "unsupported_grant_type" });
|
||||
fixture.advanceTime(3_601_000);
|
||||
assert.equal((await call(fixture, tokens.access_token)).status, 401);
|
||||
} finally { await fixture.close(); }
|
||||
});
|
||||
|
||||
test("offline consent permits refresh, requires the same resource, and rejects refresh-token reuse", async () => {
|
||||
const fixture = await startOAuthRefreshFixture();
|
||||
try {
|
||||
const { tokens, clientId } = await connect(fixture);
|
||||
assert.ok(tokens.refresh_token);
|
||||
fixture.advanceTime(3_601_000);
|
||||
assert.equal((await call(fixture, tokens.access_token)).status, 401);
|
||||
const body = new URLSearchParams({ grant_type: "refresh_token", client_id: clientId, resource: `${fixture.origin}/other`, refresh_token: tokens.refresh_token });
|
||||
assert.equal((await fetch(`${fixture.issuer}/token`, { method: "POST", body })).status, 400);
|
||||
body.set("resource", fixture.mcpUrl);
|
||||
const refresh = await fetch(`${fixture.issuer}/token`, { method: "POST", body });
|
||||
assert.equal(refresh.status, 200);
|
||||
const rotated = await refresh.json();
|
||||
assert.notEqual(rotated.refresh_token, tokens.refresh_token);
|
||||
assert.equal((await call(fixture, rotated.access_token)).status, 200);
|
||||
assert.equal((await fetch(`${fixture.issuer}/token`, { method: "POST", body })).status, 400);
|
||||
} finally { await fixture.close(); }
|
||||
});
|
||||
@@ -1,166 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Keep the repository command compatible; the deployable implementation belongs
|
||||
# to the skill so agents do not need a checkout of this repository.
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
scripts/paperclip-issue-update.sh [--issue-id ID] [--status STATUS] [--comment TEXT] [--dry-run]
|
||||
|
||||
Reads a multiline markdown comment from stdin when stdin is piped. This preserves
|
||||
newlines when building the JSON payload for PATCH /api/issues/{issueId}.
|
||||
|
||||
Examples:
|
||||
scripts/paperclip-issue-update.sh --issue-id "$PAPERCLIP_TASK_ID" --status in_progress <<'MD'
|
||||
Investigating formatting
|
||||
|
||||
- Pulled the raw comment body
|
||||
- Comparing it with the run transcript
|
||||
MD
|
||||
|
||||
scripts/paperclip-issue-update.sh --issue-id "$PAPERCLIP_TASK_ID" --status done --dry-run <<'MD'
|
||||
Done
|
||||
|
||||
- Fixed the issue update helper
|
||||
MD
|
||||
EOF
|
||||
}
|
||||
|
||||
require_command() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
printf 'Missing required command: %s\n' "$1" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
issue_id="${PAPERCLIP_TASK_ID:-}"
|
||||
status=""
|
||||
comment_arg=""
|
||||
dry_run=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--issue-id)
|
||||
issue_id="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--status)
|
||||
status="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--comment)
|
||||
comment_arg="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--dry-run)
|
||||
dry_run=1
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown argument: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$issue_id" ]]; then
|
||||
printf 'Missing issue id. Pass --issue-id or set PAPERCLIP_TASK_ID.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
comment=""
|
||||
if [[ -n "$comment_arg" ]]; then
|
||||
comment="$comment_arg"
|
||||
elif [[ ! -t 0 ]]; then
|
||||
comment="$(cat)"
|
||||
fi
|
||||
|
||||
require_command jq
|
||||
|
||||
payload="$(
|
||||
jq -nc \
|
||||
--arg status "$status" \
|
||||
--arg comment "$comment" \
|
||||
'
|
||||
(if $status == "" then {} else {status: $status} end) +
|
||||
(if $comment == "" then {} else {comment: $comment} end)
|
||||
'
|
||||
)"
|
||||
|
||||
if [[ "$dry_run" == "1" ]]; then
|
||||
printf '%s\n' "$payload"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ -z "${PAPERCLIP_API_URL:-}" || -z "${PAPERCLIP_API_KEY:-}" || -z "${PAPERCLIP_RUN_ID:-}" ]]; then
|
||||
printf 'Missing PAPERCLIP_API_URL, PAPERCLIP_API_KEY, or PAPERCLIP_RUN_ID.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A successful PATCH always returns the updated issue JSON. An empty body or a
|
||||
# connection-level failure means the write did NOT land, even when a pipeline
|
||||
# exit code says otherwise, so verify the response instead of inferring success.
|
||||
# Two attempts total: the shared heartbeat policy stops a control-plane write
|
||||
# after two consecutive failures, so the helper must not send a third.
|
||||
max_attempts=2
|
||||
attempt=1
|
||||
while :; do
|
||||
http_code=""
|
||||
body=""
|
||||
set +e
|
||||
response="$(
|
||||
curl -sS -m 30 -X PATCH \
|
||||
"$PAPERCLIP_API_URL/api/issues/$issue_id" \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data-binary "$payload" \
|
||||
-w '\n%{http_code}'
|
||||
)"
|
||||
curl_exit=$?
|
||||
set -e
|
||||
|
||||
if [[ "$curl_exit" -eq 0 ]]; then
|
||||
http_code="${response##*$'\n'}"
|
||||
body="${response%$'\n'*}"
|
||||
fi
|
||||
|
||||
if [[ "$curl_exit" -eq 0 && "$http_code" == 2* ]]; then
|
||||
if [[ -z "$body" ]]; then
|
||||
printf 'Issue update FAILED: HTTP %s with an empty response body. A real update echoes the issue JSON; treat this write as not saved.\n' "$http_code" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$status" ]]; then
|
||||
returned_status="$(jq -r '.status // empty' <<<"$body" 2>/dev/null || true)"
|
||||
if [[ "$returned_status" != "$status" ]]; then
|
||||
printf 'Issue update FAILED: server echoed status %s instead of requested %s.\n' "${returned_status:-<none>}" "$status" >&2
|
||||
printf '%s\n' "$body" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
printf '%s\n' "$body"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 4xx (other than 429) is a definitive rejection; retrying cannot change it.
|
||||
if [[ "$curl_exit" -eq 0 && "$http_code" == 4* && "$http_code" != "429" ]]; then
|
||||
printf 'Issue update rejected (HTTP %s).\n' "$http_code" >&2
|
||||
[[ -n "$body" ]] && printf '%s\n' "$body" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if (( attempt >= max_attempts )); then
|
||||
printf 'Issue update FAILED after %d attempts (curl exit %s, HTTP %s). The status/comment was NOT saved — report this write as failed, do not assume it landed.\n' "$max_attempts" "$curl_exit" "${http_code:-000}" >&2
|
||||
[[ -n "$body" ]] && printf '%s\n' "$body" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'Issue update attempt %d/%d failed (curl exit %s, HTTP %s); retrying...\n' "$attempt" "$max_attempts" "$curl_exit" "${http_code:-000}" >&2
|
||||
sleep $((attempt * 2))
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
paperclip_repo_scripts="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec bash "$paperclip_repo_scripts/../skills/paperclip/scripts/paperclip-issue-update.sh" "$@"
|
||||
@@ -9,6 +9,7 @@ paperclip_instance_id="${PAPERCLIP_INSTANCE_ID:-default}"
|
||||
paperclip_dir="$worktree_cwd/.paperclip"
|
||||
worktree_config_path="$paperclip_dir/config.json"
|
||||
seed_manifest_path="$paperclip_dir/seed-manifest.json"
|
||||
seed_empty_path="$paperclip_dir/seed-empty"
|
||||
|
||||
if [[ ! -d "$base_cwd" ]]; then
|
||||
echo "Base workspace does not exist: $base_cwd" >&2
|
||||
@@ -20,6 +21,11 @@ if [[ ! -d "$worktree_cwd" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -e "$seed_empty_path" ]]; then
|
||||
echo "Worktree instance is explicitly empty; automatic database copying is disabled." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ -e "$seed_manifest_path" ]]; then
|
||||
seed_manifest_state="$(SEED_MANIFEST_PATH="$seed_manifest_path" node <<'EOF'
|
||||
const fs = require("node:fs");
|
||||
|
||||
@@ -19,6 +19,7 @@ const serializedShardDurations = loadShardDurations(
|
||||
const serverRoot = path.join(repoRoot, "server");
|
||||
const serverSrcDir = path.join(repoRoot, "server", "src");
|
||||
const serverTestsDir = path.join(repoRoot, "server", "src", "__tests__");
|
||||
const serverScriptsDir = path.join(repoRoot, "server", "scripts");
|
||||
const nonServerProjects = [
|
||||
"@paperclipai/shared",
|
||||
"@paperclipai/skills-catalog",
|
||||
@@ -26,9 +27,15 @@ const nonServerProjects = [
|
||||
"@paperclipai/adapter-utils",
|
||||
"@paperclipai/adapter-claude-local",
|
||||
"@paperclipai/adapter-codex-local",
|
||||
"@paperclipai/adapter-cursor-cloud",
|
||||
"@paperclipai/adapter-cursor-local",
|
||||
"@paperclipai/adapter-gemini-local",
|
||||
"@paperclipai/adapter-grok-local",
|
||||
"@paperclipai/hermes-paperclip-adapter",
|
||||
"@paperclipai/adapter-kimi-local",
|
||||
"@paperclipai/adapter-openclaw-gateway",
|
||||
"@paperclipai/adapter-opencode-local",
|
||||
"@paperclipai/adapter-pi-local",
|
||||
"@paperclipai/plugin-daytona",
|
||||
"@paperclipai/plugin-sdk",
|
||||
"@paperclipai/create-paperclip-plugin",
|
||||
@@ -72,12 +79,16 @@ const generalServerWithoutChatGroupName = "general-server-without-chat";
|
||||
const generalChatGroupName = "general-chat";
|
||||
const generalServerNativeRunnerGroupName = "general-server-native-runner";
|
||||
const chatSuite = "server/src/__tests__/chat-channels.integration.test.ts";
|
||||
// This suite rebuilds the Runner release binaries with cargo in beforeAll.
|
||||
// The first suite rebuilds the Runner release binaries with cargo in beforeAll.
|
||||
// Inside the PR workflow's plain server shards, which carry no Rust cache,
|
||||
// that build was a ~4m30s cold compile of every third-party crate on each run
|
||||
// (277s of a 291s shard vitest step, actions run 35246999382, 2026-09-17).
|
||||
const nativeRunnerSuite =
|
||||
"server/src/services/native-runtime/native-codex-runner.integration.test.ts";
|
||||
const nativeRunnerSuites = [
|
||||
"server/src/services/native-runtime/native-codex-runner.integration.test.ts",
|
||||
"server/src/__tests__/dot-runner.test.ts",
|
||||
// These process cases otherwise skip in server shards without Runner binaries.
|
||||
"server/src/services/native-runtime/native-runner-restart-recovery.integration.test.ts",
|
||||
];
|
||||
// In the PR workflow (pr.yml, the caller of pr-trusted.yml — reusable
|
||||
// workflows inherit the caller's GITHUB_WORKFLOW), the last Verify Paperclip
|
||||
// Runner vitest shard runs the native-runner group instead, because those
|
||||
@@ -90,7 +101,7 @@ const nativeRunnerSuite =
|
||||
const prWorkflowName = "PR";
|
||||
const nativeRunnerSuiteRunsInRustCachedLane = process.env.GITHUB_WORKFLOW === prWorkflowName;
|
||||
const withoutChatExcludedSuites = nativeRunnerSuiteRunsInRustCachedLane
|
||||
? [chatSuite, nativeRunnerSuite]
|
||||
? [chatSuite, ...nativeRunnerSuites]
|
||||
: [chatSuite];
|
||||
const generalWorkspacesAGroupName = "general-workspaces-a";
|
||||
const generalWorkspacesBGroupName = "general-workspaces-b";
|
||||
@@ -132,6 +143,37 @@ function toServerPath(file) {
|
||||
return path.relative(serverRoot, file).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
// For a multi-line `it.each([...])("name", fn)` call, Vitest 5's `vitest
|
||||
// list --includeTaskLocation` and `vitest run <file>:<line>` disagree about
|
||||
// which line registers the test: `list` reports (and only matches) the line
|
||||
// of the wrapped "name" argument, while `run` only matches the line above
|
||||
// it, where Prettier's formatting puts that call's opening `(`. Separately,
|
||||
// on this file `list` also occasionally invents an entry whose "location"
|
||||
// sits on an ordinary body statement that merely ends in `)(` or starts a
|
||||
// line with a quote, with no `it`/`test` call anywhere nearby — a bogus
|
||||
// artifact of the same collector, not a real test. Both only showed up on
|
||||
// server/src/__tests__/chat-channels.integration.test.ts, a 70,000+ line
|
||||
// fixture; smaller files have not reproduced either issue.
|
||||
//
|
||||
// chatSuiteRunLine classifies a `list`-reported line against the actual
|
||||
// source and returns the line `vitest run` accepts, or null if the line
|
||||
// does not belong to any real `it`/`test` call (a bogus entry to drop).
|
||||
const callAnywherePattern = /(?:^|[^\w$])(?:it|test)(?:\.(?:each|skip|only))?\(/;
|
||||
const inlineCallPattern = /\)\(\s*["'`]/;
|
||||
|
||||
function chatSuiteRunLine(sourceLines, line) {
|
||||
const text = (sourceLines[line - 1] ?? "").trim();
|
||||
if (callAnywherePattern.test(text) || text.endsWith(")(") || inlineCallPattern.test(text)) {
|
||||
return line;
|
||||
}
|
||||
const previous = (sourceLines[line - 2] ?? "").trim();
|
||||
const isNameArgument = text.startsWith('"') || text.startsWith("'") || text.startsWith("`");
|
||||
if (isNameArgument && previous.endsWith(")(")) {
|
||||
return line - 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isRouteOrAuthzTest(file) {
|
||||
if (routeTestPattern.test(file)) {
|
||||
return true;
|
||||
@@ -314,6 +356,8 @@ function runVitest(args, label, testShard = null) {
|
||||
const env = {
|
||||
...process.env,
|
||||
NODE_ENV: "test",
|
||||
PAPERCLIP_TEST_HOST_HOME: process.env.PAPERCLIP_TEST_HOST_HOME
|
||||
?? (process.env.PAPERCLIP_HOME?.trim() || path.join(os.homedir(), ".paperclip")),
|
||||
PAPERCLIP_HOME: path.join(testRoot, "h"),
|
||||
// Config discovery otherwise prefers the checkout's .paperclip/config.json
|
||||
// over PAPERCLIP_HOME, importing preview scheduling policy into unit tests.
|
||||
@@ -324,6 +368,8 @@ function runVitest(args, label, testShard = null) {
|
||||
mkdirSync(env.PAPERCLIP_HOME, { recursive: true });
|
||||
mkdirSync(env.TMPDIR, { recursive: true });
|
||||
if (testShard) {
|
||||
const file = path.resolve(repoRoot, chatSuite);
|
||||
const sourceLines = readFileSync(file, "utf8").split("\n");
|
||||
const collect = (filters, name) => {
|
||||
const output = path.join(testRoot, `${name}.json`);
|
||||
const result = spawnSync("pnpm", ["exec", "vitest", "list", ...sourceOnlyVitestArgs,
|
||||
@@ -333,14 +379,28 @@ function runVitest(args, label, testShard = null) {
|
||||
if (result.error || result.status !== 0) fail(`Vitest collection failed: ${result.error?.message ?? result.status}`);
|
||||
return JSON.parse(readFileSync(output, "utf8"));
|
||||
};
|
||||
const collected = collect(args, "all");
|
||||
const file = path.resolve(repoRoot, chatSuite);
|
||||
const allCollected = collect(args, "all");
|
||||
const collected = [];
|
||||
const bogusCollected = [];
|
||||
for (const test of allCollected) {
|
||||
(chatSuiteRunLine(sourceLines, test.location.line) === null ? bogusCollected : collected).push(test);
|
||||
}
|
||||
if (bogusCollected.length > 0) {
|
||||
console.warn(
|
||||
`[test:run] dropping ${bogusCollected.length} bogus Vitest collection entr${bogusCollected.length === 1 ? "y" : "ies"} with no matching it()/test() call: ${bogusCollected.map((test) => `${test.location.line}:${JSON.stringify(test.name)}`).join(", ")}`,
|
||||
);
|
||||
}
|
||||
const selected = partitionTestLines(collected, testShard.count, file)[testShard.index];
|
||||
const filters = selected.lines.map((line) => `${chatSuite}:${line}`);
|
||||
args = [...args.filter((arg) => arg !== chatSuite), ...filters];
|
||||
assertSelectedTests(selected.tests, collect(args, "selected"), file);
|
||||
// Validate shard membership with the same `list`-reported lines that
|
||||
// produced `selected`, then switch to the `run`-compatible lines only for
|
||||
// the filters handed to the real `vitest run` below (see
|
||||
// chatSuiteRunLine).
|
||||
const validationFilters = selected.lines.map((line) => `${chatSuite}:${line}`);
|
||||
const validationArgs = [...args.filter((arg) => arg !== chatSuite), ...validationFilters];
|
||||
assertSelectedTests(selected.tests, collect(validationArgs, "selected"), file);
|
||||
console.log(`[test:run] chat shard ${testShard.index + 1}/${testShard.count}: ${selected.tests.length}/${collected.length} tests, ${selected.lines.length} source lines; exact filter coverage verified`);
|
||||
args.push("--allowOnly=false");
|
||||
const runFilters = selected.lines.map((line) => `${chatSuite}:${chatSuiteRunLine(sourceLines, line)}`);
|
||||
args = [...args.filter((arg) => arg !== chatSuite), ...runFilters, "--allowOnly=false"];
|
||||
}
|
||||
const result = spawnSync("pnpm", ["exec", "vitest", "run", ...sourceOnlyVitestArgs, ...args], {
|
||||
cwd: repoRoot,
|
||||
@@ -383,7 +443,7 @@ function runGeneralGroup(routeTests, groupName, shardIndex = null, shardCount =
|
||||
}
|
||||
if (groupName === generalServerNativeRunnerGroupName) {
|
||||
runVitest(
|
||||
["--project", "@paperclipai/server", ...serializedServerVitestArgs, nativeRunnerSuite],
|
||||
["--project", "@paperclipai/server", ...serializedServerVitestArgs, ...nativeRunnerSuites],
|
||||
"native runner vertical-slice suite",
|
||||
);
|
||||
return;
|
||||
@@ -492,10 +552,15 @@ const routeTests = walk(serverTestsDir)
|
||||
// config pins maxWorkers to 1, so the only way to parallelize is across jobs.
|
||||
// Suites are partitioned by recorded duration (scripts/general-server-shard.mjs)
|
||||
// rather than round-robin, so one slow suite cluster can't stretch a single shard.
|
||||
const generalServerTestFiles = walk(serverSrcDir)
|
||||
const serializedRepoPaths = new Set(routeTests.map(test => test.repoPath));
|
||||
const generalServerTestFiles = [
|
||||
...walk(serverSrcDir).filter(file => file.endsWith(".test.ts")),
|
||||
...walk(serverScriptsDir).filter(file => file.endsWith(".test.mjs")),
|
||||
]
|
||||
.map((file) => toRepoPath(file))
|
||||
.filter((repoPath) => repoPath.endsWith(".test.ts"))
|
||||
.filter((repoPath) => !isRouteOrAuthzTest(repoPath))
|
||||
// Only exclude suites actually assigned to the serialized lane. A name
|
||||
// such as services/openrouter-models.test.ts is not a serialized route.
|
||||
.filter((repoPath) => !serializedRepoPaths.has(repoPath))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const options = parseCliOptions(process.argv.slice(2));
|
||||
@@ -517,7 +582,7 @@ if (options.dryRun) {
|
||||
generalServerSuiteCount: generalServerTestFiles.length,
|
||||
selectedGeneralServerSuites:
|
||||
options.mode === generalModeName && options.group === generalServerNativeRunnerGroupName
|
||||
? [nativeRunnerSuite]
|
||||
? nativeRunnerSuites
|
||||
: options.mode === generalModeName &&
|
||||
[generalServerGroupName, generalServerWithoutChatGroupName].includes(options.group) &&
|
||||
options.shardCount !== null
|
||||
|
||||
@@ -100,7 +100,7 @@ try {
|
||||
if (!request.reservationId || request.maxCostUsd !== 0.5 || !["gpt-5.6-luna", "claude-sonnet-5", ...OPENROUTER_MODELS].includes(request.model)) throw new Error("Paid attempt requires ledger reservation and qualified model");
|
||||
if (isOpenRouter) {
|
||||
providerVersion = execFileSync(resolve("packages/paperclip-runner/node_modules/opencode-ai/bin/opencode.exe"), ["--version"], { encoding: "utf8" }).trim();
|
||||
if (providerVersion !== "1.18.32") throw new Error("OpenCode profile requires version 1.18.32");
|
||||
if (providerVersion !== "1.18.34") throw new Error("OpenCode profile requires version 1.18.34");
|
||||
}
|
||||
const providerEnvironment = isOpenRouter ? openRouterEnvironment() : request.model === "claude-sonnet-5" ? (() => {
|
||||
const token = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
|
||||
@@ -18,10 +18,10 @@ export function storybookAgentAvatarAssets() {
|
||||
})();
|
||||
server.middlewares.use((req, res, next) => {
|
||||
const pathname = new URL(req.url ?? "/", "http://storybook.local").pathname;
|
||||
const match = /^\/agent-avatar-images\/cap-v1\/([^/]+)\/([^/]+)-(\d+)-([12])\.png$/.exec(pathname);
|
||||
const match = /^\/agent-avatar-images\/cap-v1\/([^/]+)\/([^/]+)-(\d+)-([12])(-paperclip-dark-v1)?\.png$/.exec(pathname);
|
||||
if (!match) return next();
|
||||
void (async () => {
|
||||
const [, palette, pose, sizeText, scaleText] = match;
|
||||
const [, palette, pose, sizeText, scaleText, darkBackground] = match;
|
||||
const { pool, AGENT_PALETTE_IDS, AGENT_AVATAR_SIZES, CHARACTER_STATES, appearanceForPalette } = await renderer();
|
||||
const size = Number(sizeText);
|
||||
const scale = Number(scaleText);
|
||||
@@ -33,7 +33,7 @@ export function storybookAgentAvatarAssets() {
|
||||
}
|
||||
if (!images.has(pathname)) {
|
||||
const appearance = appearanceForPalette(muted ? AGENT_PALETTE_IDS[0] : palette);
|
||||
images.set(pathname, pool.render({ appearance, size, scale, pose, muted }).catch((error) => {
|
||||
images.set(pathname, pool.render({ appearance, size, scale, pose, muted, background: darkBackground ? "paperclip-dark" : "transparent" }).catch((error) => {
|
||||
images.delete(pathname);
|
||||
throw error;
|
||||
}));
|
||||
@@ -60,6 +60,8 @@ export function storybookAgentAvatarAssets() {
|
||||
(muted ? [AGENT_PALETTE_IDS[0]] : AGENT_PALETTE_IDS).flatMap(palette =>
|
||||
CHARACTER_STATES.flatMap(pose => AGENT_AVATAR_SIZES.flatMap(size =>
|
||||
[1, 2].map(scale => ({ appearance: appearanceForPalette(palette), size, scale, pose, muted }))))));
|
||||
// Slack exports use this one opaque preset; ordinary UI avatars stay clear.
|
||||
requests.push(...AGENT_PALETTE_IDS.map(palette => ({ appearance: appearanceForPalette(palette), size: 512, scale: 1, pose: "rest", muted: false, background: "paperclip-dark" })));
|
||||
const pool = createAgentAvatarPool(2);
|
||||
const images = [];
|
||||
let next = 0;
|
||||
@@ -68,8 +70,8 @@ export function storybookAgentAvatarAssets() {
|
||||
while (next < requests.length) {
|
||||
const request = requests[next++];
|
||||
const source = await pool.render(request);
|
||||
const { appearance, size, scale, pose, muted } = request;
|
||||
const fileName = agentAvatarUrl(appearance, size, scale, pose, muted).slice(2);
|
||||
const { appearance, size, scale, pose, muted, background } = request;
|
||||
const fileName = agentAvatarUrl(appearance, size, scale, pose, muted, background).slice(2);
|
||||
this.emitFile({ type: "asset", fileName, source });
|
||||
images.push({ path: fileName, sha256: createHash("sha256").update(source).digest("hex"), pixels: size * scale });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Opt-in real Codex qualification; no model turn or live account data.
|
||||
// Run from the repository root:
|
||||
// node --import ./server/node_modules/tsx/dist/loader.mjs scripts/tests/native-cleanup-paginated-codex.mjs
|
||||
// PAPERCLIP_TEST_CODEX_BINARY may select the exact installed Codex 0.156.0 binary.
|
||||
// PAPERCLIP_TEST_CODEX_BINARY may select the exact installed Codex 0.160.0 binary.
|
||||
// Fresh synthetic fixture directories are retained for inspection; never reuse live homes.
|
||||
import { spawn, execFileSync } from "node:child_process";
|
||||
import {
|
||||
@@ -26,10 +26,10 @@ if (
|
||||
execFileSync(codexBinary, ["--version"], {
|
||||
encoding: "utf8",
|
||||
timeout: 10000,
|
||||
}).trim() !== "codex-cli 0.156.0"
|
||||
}).trim() !== "codex-cli 0.160.0"
|
||||
) {
|
||||
throw new Error(
|
||||
"This opt-in qualification requires Codex CLI 0.156.0. Requalify deliberately before changing the pin.",
|
||||
"This opt-in qualification requires Codex CLI 0.160.0. Requalify deliberately before changing the pin.",
|
||||
);
|
||||
}
|
||||
const home = await mkdtemp(join(tmpdir(), "paperclip-paginated-probe-"));
|
||||
@@ -132,7 +132,7 @@ const lines =
|
||||
timestamp,
|
||||
cwd: home,
|
||||
originator: "codex",
|
||||
cli_version: "0.156.0",
|
||||
cli_version: "0.160.0",
|
||||
source: "cli",
|
||||
model_provider: "openai",
|
||||
selected_capability_roots: [],
|
||||
|
||||
@@ -50,7 +50,7 @@ if (args.length === 0 || (args.length === 1 && args[0] === "--help")) {
|
||||
// Never silently qualify a debug fallback or a caller-selected alternate runner.
|
||||
assert.match(
|
||||
runnerBinary,
|
||||
/\/dist\/bin\/paperclip-runnerd$/u,
|
||||
/\/dist\/bin\/(?:darwin-(?:arm64|x64)\/|linux-x64\/)?paperclip-runnerd$/u,
|
||||
"normal_staged_runner_required",
|
||||
);
|
||||
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||
@@ -88,7 +88,7 @@ if (args.length === 0 || (args.length === 1 && args[0] === "--help")) {
|
||||
}).trim();
|
||||
assert.equal(
|
||||
version,
|
||||
"codex-cli 0.156.0",
|
||||
"codex-cli 0.160.0",
|
||||
"qualified_codex_version_required",
|
||||
);
|
||||
const ledger = join(fixture, "provider-process-starts.txt");
|
||||
@@ -309,7 +309,7 @@ if (args.length === 0 || (args.length === 1 && args[0] === "--help")) {
|
||||
kind: "codex",
|
||||
provider: "codex",
|
||||
driver: "codex_app_server",
|
||||
providerVersion: "0.156.0",
|
||||
providerVersion: "0.160.0",
|
||||
command: shim,
|
||||
args: [
|
||||
"--cd",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env node
|
||||
// Refresh only Arcade's public support claim; never update Composio verification dates.
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
const source = "https://docs.arcade.dev/en/resources/integrations";
|
||||
const args = process.argv.slice(2);
|
||||
const verifiedAt = args[args.indexOf("--verified-at") + 1];
|
||||
if (!args.includes("--verified-at") || !/^\d{4}-\d{2}-\d{2}$/.test(verifiedAt)) {
|
||||
throw new Error("Supply --verified-at YYYY-MM-DD after reviewing the official public Arcade catalog.");
|
||||
}
|
||||
const input = args.includes("--input") ? args[args.indexOf("--input") + 1] : null;
|
||||
let html;
|
||||
if (input) html = await readFile(input, "utf8");
|
||||
else {
|
||||
const response = await fetch(source);
|
||||
if (!response.ok) throw new Error(`Arcade catalog request failed (${response.status})`);
|
||||
html = await response.text();
|
||||
}
|
||||
const records = [...html.matchAll(/self\.__next_f\.push\(\[1,("(?:\\.|[^"\\])*?")\]\)/g)]
|
||||
.map((match) => JSON.parse(match[1])).join("");
|
||||
const start = records.indexOf('"toolkits":[');
|
||||
if (start < 0) throw new Error("Arcade catalog payload was not found; preserve the previous snapshot.");
|
||||
const rest = records.slice(start + '"toolkits":'.length);
|
||||
let end = 0, depth = 0, quoted = false, escaped = false;
|
||||
for (; end < rest.length; end++) {
|
||||
const char = rest[end];
|
||||
if (escaped) { escaped = false; continue; }
|
||||
if (quoted && char === "\\") { escaped = true; continue; }
|
||||
if (char === '"') { quoted = !quoted; continue; }
|
||||
if (!quoted && char === "[") depth++;
|
||||
if (!quoted && char === "]" && --depth === 0) { end++; break; }
|
||||
}
|
||||
const toolkits = JSON.parse(rest.slice(0, end));
|
||||
const apps = toolkits.filter((app) => app.isComingSoon === false && app.isHidden === false).map((app) => {
|
||||
const toolkit = app.relativeDocsLink.split("/").at(-1);
|
||||
const slug = toolkit.replaceAll("_", "-");
|
||||
const logoUrl = new URL(app.publicIconUrl, source);
|
||||
const docsUrl = new URL(app.hasPage ? app.docsLink : source);
|
||||
if (!/^[a-z0-9-]+$/.test(slug) || !app.label || typeof app.id !== "string"
|
||||
|| logoUrl.protocol !== "https:" || !["docs.arcade.dev", "design-system.arcade.dev"].includes(logoUrl.hostname)
|
||||
|| docsUrl.origin !== "https://docs.arcade.dev") throw new Error(`Invalid public Arcade entry: ${app.id}`);
|
||||
return { slug, toolkit, name: app.label, logoUrl: logoUrl.href, docsUrl: docsUrl.href, aliases: [app.id, toolkit], category: app.category };
|
||||
}).sort((a, b) => a.slug.localeCompare(b.slug, "en"));
|
||||
if (apps.length < 100 || new Set(apps.map((app) => app.slug)).size !== apps.length) {
|
||||
throw new Error("Arcade catalog is incomplete or contains duplicate slugs; preserve the previous snapshot.");
|
||||
}
|
||||
const target = new URL("../packages/shared/src/arcade-app-catalog.json", import.meta.url);
|
||||
await writeFile(target, `${JSON.stringify({ source, verifiedAt, apps }, null, 2)}\n`);
|
||||
console.log(`Indexed ${apps.length} public Arcade apps (${verifiedAt}).`);
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env node
|
||||
// Public npm graph + explicit setup only. No credentials, inference, publication,
|
||||
// checkout mount, candidate overrides, or native Cursor assets in the tarballs.
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { materializePublishManifest, prepareBundledPackage } from './prepare-bundled-package.mjs';
|
||||
import { GROK_PUBLIC_INSTALL_LIFECYCLE, GROK_PUBLIC_INSTALL_IMAGE } from './grok-public-install-sandbox.mjs';
|
||||
import { cursorConsumerDockerArgs, prepareCursorInstallAccount } from './cursor-public-install-sandbox.mjs';
|
||||
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const root = mkdtempSync(join(tmpdir(), 'paperclip-cursor-public-install-'));
|
||||
console.log(`Verification evidence: ${root}`);
|
||||
const env = { ...process.env, NODE_PATH: '', PAPERCLIP_RELEASE_REUSE_UI_DIST: '1', npm_config_ignore_scripts: 'false', npm_config_audit: 'false', npm_config_fund: 'false' };
|
||||
for (const key of Object.keys(env)) if (/^(OPENAI|ANTHROPIC|OPENROUTER|DAYTONA|XAI|GROK|CURSOR|COPILOT|GITHUB|GH)(_|$)/.test(key) || /CANDIDATE/.test(key)) delete env[key];
|
||||
const run = (command, args, cwd = root) => execFileSync(command, args, { cwd, env, stdio: 'pipe', maxBuffer: 64 * 1024 * 1024 });
|
||||
const sourceRevision = run('git', ['rev-parse', 'HEAD'], repo).toString().trim();
|
||||
const releaseBinaries = JSON.parse(readFileSync(join(repo, 'server/dist/vendor/paperclip-runner/bin/release-manifest.json'), 'utf8'));
|
||||
assert.equal(releaseBinaries.schema, 'paperclip.runner.release-binaries.v1');
|
||||
assert.match(releaseBinaries.sourceRevision, /^[a-f0-9]{40}$/);
|
||||
run('git', ['merge-base', '--is-ancestor', releaseBinaries.sourceRevision, sourceRevision], repo);
|
||||
// Controller and TypeScript admission may follow a frozen daemon. Keep its
|
||||
// exact provenance and require the Rust sources and protocol to remain identical.
|
||||
run('git', ['diff', '--quiet', releaseBinaries.sourceRevision, sourceRevision, '--', 'packages/paperclip-runner/runner', 'packages/paperclip-runner/protocol'], repo);
|
||||
assert.deepEqual(Object.keys(releaseBinaries.platforms).sort(), ['darwin-arm64', 'darwin-x64', 'linux-x64']);
|
||||
assert.equal(releaseBinaries.remoteProviderPack?.target, 'linux-x64', 'Release assembly must include the expected ordinary Daytona image pack');
|
||||
const releaseVersion = `0.0.0-cursor-verify.${sourceRevision.slice(0, 12)}`;
|
||||
const listing = run(process.execPath, [join(repo, 'scripts/release-package-map.mjs'), 'list'], repo).toString().trim().split('\n').map(line => line.split('\t'));
|
||||
const packages = new Map(listing.map(([dir, name]) => [name, {dir, manifest: JSON.parse(readFileSync(join(repo, dir, 'package.json')))}]));
|
||||
const cliManifestPath = join(repo, 'cli/package.json'); const original = readFileSync(cliManifestPath);
|
||||
const generatedManifest = join(root, 'cli-manifest.json');
|
||||
run(process.execPath, [join(repo, 'scripts/generate-npm-package-json.mjs'), '--output', generatedManifest], repo);
|
||||
const publishedCli = JSON.parse(readFileSync(generatedManifest));
|
||||
publishedCli.dependencies['@paperclipai/server'] = releaseVersion;
|
||||
packages.set('paperclipai', { dir: 'cli', manifest: publishedCli });
|
||||
const needed = new Set();
|
||||
function visit(name) {
|
||||
if (needed.has(name)) return;
|
||||
const entry = packages.get(name); assert.ok(entry, `Missing public package ${name}`); needed.add(name);
|
||||
for (const [dependency, version] of Object.entries({...entry.manifest.dependencies, ...entry.manifest.optionalDependencies})) if (version.startsWith('workspace:') || dependency === '@paperclipai/server' && name === 'paperclipai') visit(dependency);
|
||||
}
|
||||
visit('paperclipai');
|
||||
// Run after the candidate build, including its required standalone adapters.
|
||||
for (const name of needed) assert.ok(existsSync(join(repo, packages.get(name).dir, 'dist')), `Missing built public package ${name}`);
|
||||
run('bash', [join(repo, 'scripts/prepare-server-ui-dist.sh')], repo);
|
||||
const assets = join(root, 'assets'); const consumer = join(root, 'consumer'); const cache = join(root, 'cache');
|
||||
for (const path of [assets, consumer, cache]) mkdirSync(path, { mode: 0o755 });
|
||||
writeFileSync(join(consumer, 'package.json'), JSON.stringify({ private: true, type: 'module' }));
|
||||
for (const [index, name] of [...needed].entries()) {
|
||||
const { dir, manifest } = packages.get(name); const staged = join(root, `source-${index}`); const target = join(root, `package-${index}`); mkdirSync(staged);
|
||||
for (const file of manifest.files ?? ['dist']) {
|
||||
const skills = file === 'skills' && ['server', 'packages/adapters/claude-local', 'packages/adapters/codex-local'].includes(dir);
|
||||
if (file === 'skills' && !skills && !existsSync(join(repo,dir,file))) continue;
|
||||
cpSync(skills ? join(repo,'skills') : join(repo,dir,file), join(staged,file), { recursive: true });
|
||||
}
|
||||
const releaseManifest = { ...manifest, version: releaseVersion };
|
||||
writeFileSync(join(staged,'package.json'), JSON.stringify(releaseManifest));
|
||||
if ((manifest.bundleDependencies ?? manifest.bundledDependencies ?? []).length) prepareBundledPackage(staged,target);
|
||||
else { cpSync(staged,target,{recursive:true}); writeFileSync(join(target,'package.json'),JSON.stringify(materializePublishManifest(releaseManifest))); }
|
||||
run('npm',['pack','--ignore-scripts','--pack-destination',assets],target);
|
||||
console.log(`Packed ${name}`);
|
||||
}
|
||||
const sentinelSource = join(root,'lifecycle-sentinel'); mkdirSync(sentinelSource);
|
||||
writeFileSync(join(sentinelSource,'package.json'), JSON.stringify({name:'paperclip-verification-lifecycle-sentinel',version:'1.0.0',private:true,scripts:{postinstall:'node -e "require(\'node:fs\').writeFileSync(\'lifecycle-ran\', \'ok\')"'}}));
|
||||
run('npm',['pack','--ignore-scripts','--pack-destination',assets],sentinelSource);
|
||||
for (const file of readdirSync(assets)) chmodSync(join(assets,file),0o644);
|
||||
const account = prepareCursorInstallAccount(root, process.getuid(), process.getgid());
|
||||
const isolated = (command, options={}) => run('docker',cursorConsumerDockerArgs({assets,consumer,cache,account,uid:process.getuid(),gid:process.getgid(),command,...options}));
|
||||
isolated(['npm','install','--ignore-scripts','--omit=dev',...readdirSync(assets).filter(f=>f.endsWith('.tgz')).map(f=>`/packages/${f}`)],{download:true});
|
||||
const sentinel = join(consumer,'node_modules/paperclip-verification-lifecycle-sentinel/lifecycle-ran');
|
||||
assert.equal(existsSync(sentinel),false);
|
||||
const lock = readFileSync(join(consumer,'package-lock.json'),'utf8');
|
||||
// Ordinary npm hooks may fetch platform dependency assets (e.g. esbuild).
|
||||
// Cursor itself must still remain absent until its explicit setup command.
|
||||
isolated(GROK_PUBLIC_INSTALL_LIFECYCLE, { download: true });
|
||||
assert.equal(readFileSync(sentinel,'utf8'),'ok'); assert.equal(readFileSync(join(consumer,'package-lock.json'),'utf8'),lock);
|
||||
const installedRunner = join(consumer,'node_modules/@paperclipai/server/dist/vendor/paperclip-runner');
|
||||
assert.equal(existsSync(join(installedRunner,'provider-assets/cursor')),false,'npm must never download Cursor');
|
||||
assert.equal(existsSync(join(account.home,'.paperclip/runtimes/cursor')),false,'npm must not provision the account runtime cache');
|
||||
const setupOutput = isolated(['node','node_modules/paperclipai/dist/index.js','runtime','setup','cursor'],{download:true,readOnlyConsumer:true}).toString();
|
||||
console.log(setupOutput.trim());
|
||||
assert.match(setupOutput,/Verified Cursor 2026\.09\.26-dd393fe \(linux-x64\), paperclip-cursor-usage-v4/);
|
||||
const probe = `import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { userInfo } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { defaultCapabilityRunnerdBinary } from '/consumer/node_modules/@paperclipai/server/dist/vendor/paperclip-runner/live/runnerd-codex-transport.js';
|
||||
import { resolvePaperclipRunnerBinary } from '/consumer/node_modules/@paperclipai/server/dist/services/native-runtime/native-codex-runner.js';
|
||||
import { requireServerAdapter } from '/consumer/node_modules/@paperclipai/server/dist/adapters/registry.js';
|
||||
import { runnerBinaryTarget } from '/consumer/node_modules/@paperclipai/server/dist/vendor/paperclip-runner/live/runner-binary.js';
|
||||
import { bundledRemoteRunnerBinary, bundledRemoteProviderPackManifestPath } from '/consumer/node_modules/@paperclipai/server/dist/vendor/paperclip-runner/live/bundled-remote-provider-pack.js';
|
||||
import { verifyAcpxProfileInstallation } from '/consumer/node_modules/@paperclipai/server/dist/vendor/paperclip-runner/drivers/acpx/profile-installation.js';
|
||||
import { resolveQualifiedAcpxProfile } from '/consumer/node_modules/@paperclipai/server/dist/vendor/paperclip-runner/drivers/acpx/qualified-profiles.js';
|
||||
import { CURSOR_DISTRIBUTION_PINS } from '/consumer/node_modules/@paperclipai/server/dist/vendor/paperclip-runner/drivers/acpx/generated-profiles.js';
|
||||
const profile = resolveQualifiedAcpxProfile('cursor', 'gpt-5.6-luna[context=272k,reasoning=medium,fast=false]');
|
||||
const installation = await verifyAcpxProfileInstallation(profile);
|
||||
assert.equal(userInfo().homedir, '/home/paperclip');
|
||||
assert.equal(installation.agentServerPackageJsonPath,join(userInfo().homedir,'.paperclip/runtimes/cursor/linux-x64',CURSOR_DISTRIBUTION_PINS['linux-x64'].closureSha256,'.paperclip-cursor-closure.json'));
|
||||
assert.equal(installation.agentRuntimePackageJsonPath,null);
|
||||
assert.equal(installation.commandDigest,profile.commandDigest);
|
||||
await (await installation.openCommand()).close();
|
||||
const readiness = await requireServerAdapter('paperclip_runner').testEnvironment({ companyId: 'installation-probe', adapterType: 'paperclip_runner', config: { provider: 'acpx', acpxAgent: 'cursor', model: profile.reportedModelId } });
|
||||
assert.equal(readiness.status, 'pass', JSON.stringify(readiness));
|
||||
assert.equal(readiness.checks[0]?.code, 'acpx_runtime_ready');
|
||||
const binaryRoot = '/consumer/node_modules/@paperclipai/server/dist/vendor/paperclip-runner/bin';
|
||||
const manifest = JSON.parse(readFileSync(binaryRoot + '/release-manifest.json', 'utf8'));
|
||||
assert.equal(manifest.schema, 'paperclip.runner.release-binaries.v1');
|
||||
assert.deepEqual(Object.keys(manifest.platforms).sort(), ['darwin-arm64', 'darwin-x64', 'linux-x64']);
|
||||
for (const [target, artifact] of Object.entries(manifest.platforms)) {
|
||||
assert.equal(artifact.path, target + '/paperclip-runnerd');
|
||||
const bytes = readFileSync(binaryRoot + '/' + artifact.path);
|
||||
assert.equal(runnerBinaryTarget(bytes), target);
|
||||
assert.equal('sha256:' + createHash('sha256').update(bytes).digest('hex'), artifact.sha256);
|
||||
}
|
||||
const binary = defaultCapabilityRunnerdBinary();
|
||||
assert.equal(binary, binaryRoot + '/linux-x64/paperclip-runnerd');
|
||||
assert.equal(resolvePaperclipRunnerBinary(), binary, 'The installed server must use the same verified platform daemon');
|
||||
assert.equal(bundledRemoteRunnerBinary(), binary);
|
||||
const remotePack = JSON.parse(readFileSync(bundledRemoteProviderPackManifestPath(), 'utf8'));
|
||||
assert.equal(remotePack.digest, manifest.remoteProviderPack.digest);
|
||||
assert.equal(remotePack.payload.providers.cursor.qualification, 'qualified');
|
||||
const metadata = JSON.parse(execFileSync(binary, ['--build-metadata'], { encoding: 'utf8' }));
|
||||
assert.equal(metadata.schema, 'paperclip-runner/runnerd-build-metadata/v1');
|
||||
assert.equal(metadata.binaryContractVersion, 2);
|
||||
console.log('All three packaged daemon identities verified; the ordinary installed resolver launched Linux x64');
|
||||
console.log('Installed Cursor closure and command lease verified without credentials or candidate overrides');`;
|
||||
writeFileSync(join(assets,'probe.mjs'),probe,{mode:0o644});
|
||||
console.log(isolated(['node','/packages/probe.mjs'],{readOnlyConsumer:true}).toString().trim());
|
||||
assert.equal(existsSync(join(installedRunner,'provider-assets/cursor')),false,'explicit setup must leave the read-only npm package unchanged');
|
||||
for (const name of needed) assert.equal(JSON.parse(readFileSync(join(consumer,'node_modules',name,'package.json'))).version,releaseVersion);
|
||||
const report = {schema:'paperclip.cursor.public-npm-install.v1',sourceRevision,releaseVersion,consumerImage:GROK_PUBLIC_INSTALL_IMAGE,packageCount:needed.size,sourceCliManifestPreserved:readFileSync(cliManifestPath).equals(original),lifecycleScriptsEnabled:true,lifecycleSentinelVerified:true,lifecycleNetwork:'bridge',consumerLockPreserved:true,npmProvisionedCursor:false,publicSetupCommand:true,readOnlyConsumer:true,accountCacheVerified:true,pinnedClosureVerified:true,packagedDaemonTargetsVerified:['darwin-arm64','darwin-x64','linux-x64'],releaseBinaries,ordinaryInstalledDaemonLaunched:true,providerCalls:0};
|
||||
writeFileSync(join(root,'report.json'),JSON.stringify(report,null,2)+'\n'); console.log(JSON.stringify(report));
|
||||
Reference in new issue
Block a user