mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 16:51:28 +02:00
Merge current master and preserve dedicated Dot agent gateway
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
3023aa2f61
514 files changed
+35544
-3846
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([
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1043,7 +1043,9 @@ const categoryBySlug = {
|
||||
sentry: "developer",
|
||||
similarweb: "analytics",
|
||||
stripe: "commerce",
|
||||
superagent: "developer",
|
||||
supabase: "data",
|
||||
telem: "ai",
|
||||
"ticket-tailor": "commerce",
|
||||
ticktick: "productivity",
|
||||
todoist: "productivity",
|
||||
@@ -1143,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 ",
|
||||
@@ -1221,6 +1229,16 @@ const specialMethodsFor = (entry) => {
|
||||
}] } : {}),
|
||||
}),
|
||||
];
|
||||
// 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, {
|
||||
grantKinds: ["user"],
|
||||
defaults: { serverUrl: entry.serverUrl, scopesHint: ["graph:read", "graph:write"] },
|
||||
@@ -1564,6 +1582,73 @@ const specialMethodsFor = (entry) => {
|
||||
}),
|
||||
];
|
||||
}
|
||||
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
|
||||
@@ -1665,7 +1750,7 @@ for (const entry of researchManifest.entries) {
|
||||
schemaVersion: 1,
|
||||
slug: entry.slug,
|
||||
name: entry.name,
|
||||
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"
|
||||
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"],
|
||||
|
||||
@@ -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,14 @@ 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/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",
|
||||
@@ -347,6 +353,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.
|
||||
@@ -541,10 +549,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));
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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