mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
Merge master and reconcile Dot with the released MCP gateway
Reuse scoped browser and device consent, verified client metadata, signed event admissions and warm-standby gates. Preserve the dedicated agent resource and Runner authority. Regenerate the Dot-only migration after the merged gateway history and cover issuer, company, viewer and token isolation. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
1be3f69703
1212 files changed
+142427
-60490
No files matched your search
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
@@ -1018,6 +1104,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 ",
|
||||
@@ -1124,6 +1215,10 @@ 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 },
|
||||
}] } : {}),
|
||||
}),
|
||||
];
|
||||
if (entry.slug === "zep") return [oauthMethodFor(entry, "mcp-oauth", entry.serverUrl, {
|
||||
@@ -1414,6 +1509,61 @@ 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 === "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 +1665,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.", mem0: "Remember preferences, conversations, events, and agent state.", zep: "Retrieve temporal graph memory and authorized business context.", supermemory: "Search and save shared memories, documents, and profiles.", honcho: "Remember conversations and retrieve context about peers." })[entry.slug] ?? (entry.slug === "fireflies"
|
||||
? "Search meeting transcripts, read summaries and action items, and connect meeting-ready routines."
|
||||
: `Connect ${entry.name}'s provider-hosted MCP server.`),
|
||||
categories: [categoryBySlug[entry.slug] ?? "other"],
|
||||
@@ -1653,13 +1803,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 +1867,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 });
|
||||
});
|
||||
@@ -134,6 +134,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;
|
||||
@@ -326,6 +357,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,
|
||||
@@ -335,14 +368,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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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}).`);
|
||||
Reference in new issue
Block a user