mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
feat(runner): qualify native Codex platforms and cache hosted builds
Extract the tested runtime and installed-package prerequisite without changing agent creation defaults or experimental gates. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
7d9b93aee2
commit
ddcd746eb6
40 files changed
+3474
-565
No files matched your search
@@ -1,16 +1,64 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { isWorkspacePackage, parseWorkspaceEntries } from '../build-standalone-public-packages.mjs';
|
||||
import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertInstalledCodexPlatformProvenance, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedCodexProbeSource } from '../grok-public-install-sandbox.mjs';
|
||||
import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertInstalledCodexPlatformProvenance, assertInstalledNativeConsumerContinuity, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedCodexProbeSource, installedDaemonProbeSource, macNodeHeadersDirectory, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource } from '../grok-public-install-sandbox.mjs';
|
||||
import { verifyPiPackagedDaemon } from '../pi-public-install-probe.mjs';
|
||||
import { runnerBinaryTarget } from '../../packages/paperclip-runner/src/live/runner-binary.ts';
|
||||
|
||||
const paths = { assets: '/private/staging/assets', consumer: '/private/staging/consumer', cache: '/private/staging/cache', uid: 1001, gid: 1001 };
|
||||
const values = (args, flag) => args.flatMap((value, index) => value === flag ? [args[index + 1]] : []);
|
||||
|
||||
test('native host startup preserves the consumer lock and both executable byte identities', () => {
|
||||
const before = { lockSha256: 'a'.repeat(64), runnerSha256: 'b'.repeat(64), codexCommandSha256: 'c'.repeat(64), codexNativeExecutableSha256: 'e'.repeat(64) };
|
||||
assert.doesNotThrow(() => assertInstalledNativeConsumerContinuity(before, { ...before }));
|
||||
for (const field of Object.keys(before)) {
|
||||
assert.throws(() => assertInstalledNativeConsumerContinuity(before, { ...before, [field]: 'd'.repeat(64) }), new RegExp(field));
|
||||
assert.throws(() => assertInstalledNativeConsumerContinuity({ ...before, [field]: '' }, { ...before, [field]: '' }), new RegExp(field));
|
||||
}
|
||||
const source = readFileSync(new URL('../verify-grok-npm-install.mjs', import.meta.url), 'utf8');
|
||||
const arm = source.split('async function verifyLinuxArmConsumer')[1].split("if (process.argv[2] === '--linux-arm64-consumer')")[0];
|
||||
assert.match(arm, /const hostEnv = \{ PATH: .*HOME: home, TMPDIR: hostTmp, LC_ALL: 'C', TZ: 'UTC' \}/);
|
||||
assert.match(arm, /execFileSync\(process.execPath, \[file\], \{ cwd: consumer, env: hostEnv/);
|
||||
assert.ok(arm.indexOf('isolated(GROK_PUBLIC_INSTALL_LIFECYCLE)') < arm.indexOf("hostProbe('host-startup-probe.mjs'"));
|
||||
assert.ok(arm.indexOf('const before =') < arm.indexOf("hostProbe('host-startup-probe.mjs'"));
|
||||
assert.ok(arm.indexOf("hostProbe('host-startup-probe.mjs'") < arm.indexOf('const after ='));
|
||||
assert.match(arm, /assertInstalledNativeConsumerContinuity\(before, after\)/);
|
||||
assert.doesNotMatch(arm, /externallySandboxed|externalSandbox|sudo|sysctl|--privileged|seccomp=unconfined|unshare/);
|
||||
});
|
||||
|
||||
test('Mac offline lifecycle uses only the matching executing Node SDK', () => {
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), 'mac-node-headers-test-')));
|
||||
try {
|
||||
const prefix = join(root, 'sdk'), executable = join(prefix, 'bin/node'), include = join(prefix, 'include/node');
|
||||
mkdirSync(join(prefix, 'bin'), { recursive: true }); mkdirSync(include, { recursive: true });
|
||||
writeFileSync(executable, 'fixture Node executable');
|
||||
writeFileSync(join(include, 'node.h'), '#include "node_version.h"\n');
|
||||
const header = '#define NODE_MAJOR_VERSION 24\n#define NODE_MINOR_VERSION 20\n#define NODE_PATCH_VERSION 0\n';
|
||||
writeFileSync(join(include, 'node_version.h'), header);
|
||||
assert.equal(macNodeHeadersDirectory(executable, '24.20.0'), prefix);
|
||||
assert.throws(() => macNodeHeadersDirectory(executable, '24.19.0'), /must match the executing Node/);
|
||||
assert.throws(() => macNodeHeadersDirectory(executable, '24.20.0-custom'), /exact release version/);
|
||||
writeFileSync(join(include, 'node_version.h'), header + '#define NODE_PATCH_VERSION 0\n');
|
||||
assert.throws(() => macNodeHeadersDirectory(executable, '24.20.0'), /declare one patch version/);
|
||||
writeFileSync(join(include, 'node_version.h'), header);
|
||||
rmSync(join(include, 'node.h'));
|
||||
assert.throws(() => macNodeHeadersDirectory(executable, '24.20.0'), /ENOENT/);
|
||||
writeFileSync(join(root, 'outside-node.h'), '#include "node_version.h"\n');
|
||||
symlinkSync(join(root, 'outside-node.h'), join(include, 'node.h'));
|
||||
assert.throws(() => macNodeHeadersDirectory(executable, '24.20.0'), /inside the executing Node prefix/);
|
||||
const source = readFileSync(new URL('../verify-grok-npm-install.mjs', import.meta.url), 'utf8');
|
||||
assert.ok(source.includes('macNodeHeadersDirectory(process.execPath, process.versions.node)'));
|
||||
assert.ok(source.includes('consumer, { ...env, npm_config_nodedir: nodeHeaders }'), 'SDK is bound only to the deferred lifecycle environment');
|
||||
assert.equal(source.split('npm_config_nodedir:').length, 2);
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('lifecycle execution has no network, host credentials, checkout, or elevated privileges', () => {
|
||||
const args = grokConsumerDockerArgs({ ...paths, command: GROK_PUBLIC_INSTALL_LIFECYCLE });
|
||||
assert.deepEqual(values(args, '--network'), ['none']);
|
||||
@@ -85,7 +133,9 @@ test('verification never elevates PR-controlled provisioning or cleanup on the h
|
||||
|
||||
test('the image and Grok downloads start before staging and join without weakening the npm checks', () => {
|
||||
const source = readFileSync(new URL('../verify-grok-npm-install.mjs', import.meta.url), 'utf8');
|
||||
const position = fragment => { const index = source.indexOf(fragment); assert.notEqual(index, -1, `verifier must contain ${fragment}`); return index; };
|
||||
// The optional Mac consumer owns separate scratch and no Linux prefetches.
|
||||
const linuxSource = source.slice(source.indexOf("assert.equal(process.platform, 'linux'"));
|
||||
const position = fragment => { const index = linuxSource.indexOf(fragment); assert.notEqual(index, -1, `verifier must contain ${fragment}`); return index; };
|
||||
// Exactly the pinned consumer image is pulled, for the platform the probes run on.
|
||||
const imagePull = position("prefetch('image', 'docker', ['pull', '--platform', 'linux/amd64', GROK_PUBLIC_INSTALL_IMAGE])");
|
||||
// Grok is provisioned into the prefetch directory, never straight into `native/grok`.
|
||||
@@ -109,7 +159,7 @@ test('the image and Grok downloads start before staging and join without weakeni
|
||||
// cleanup waits for them before removing the directory they write into.
|
||||
assert.ok(source.includes("import { startPrefetch, stopPrefetches } from './prefetch-child.mjs'"));
|
||||
assert.ok(position('await stopPrefetches(prefetches)') < position('rmSync(root, { recursive: true, force: true })'));
|
||||
assert.equal(source.match(/rmSync\(root/g).length, 1);
|
||||
assert.equal(linuxSource.match(/rmSync\(root/g).length, 1);
|
||||
});
|
||||
|
||||
test('the bundled-dependency installs run together while the other packages are copied, and pack in order', () => {
|
||||
@@ -137,7 +187,8 @@ test('the bundled-dependency installs run together while the other packages are
|
||||
const checks = position("for (const tarball of tarballs) assertNoBundledCodexPayloads(run('tar', ['-tzf', tarball])");
|
||||
const copied = position('for (const tarball of tarballs) {');
|
||||
assert.ok(bundledSources < start && start < registered && registered < otherSources && otherSources < join && join < packs && packs < reordered && reordered < checks && checks < copied);
|
||||
assert.equal(source.match(/run\('npm', \['pack'/g).length, 1, 'only the lifecycle sentinel is packed in-process');
|
||||
const packagingLane = source.slice(position("} else {\nassert.equal(process.platform, 'linux'"));
|
||||
assert.equal(packagingLane.match(/run\('npm', \['pack'/g).length, 1, 'only the lifecycle sentinel is packed in-process in the packaging lane');
|
||||
assert.ok(source.includes("destination: join(root, `pack-${index}`)"), 'each pack writes into its own directory');
|
||||
// The timing line reports the split staging phase, each install, and the
|
||||
// pack step with what the packs would have cost one after another.
|
||||
@@ -201,6 +252,14 @@ test('the installed Codex probe exercises the public export and rejects incomple
|
||||
const platformPath = `node_modules/@openai/codex-${target}`;
|
||||
const platform = join(consumer, platformPath); mkdirSync(platform, { recursive: true });
|
||||
const nativeVendor = join(platform, 'vendor'); mkdirSync(nativeVendor);
|
||||
const triple = { 'linux-x64': 'x86_64-unknown-linux-musl', 'linux-arm64': 'aarch64-unknown-linux-musl',
|
||||
'darwin-x64': 'x86_64-apple-darwin', 'darwin-arm64': 'aarch64-apple-darwin', 'win32-x64': 'x86_64-pc-windows-msvc', 'win32-arm64': 'aarch64-pc-windows-msvc' }[target];
|
||||
const nativeBytes = Buffer.alloc(64);
|
||||
if (process.platform === 'darwin') { nativeBytes.writeUInt32LE(0xfeedfacf); nativeBytes.writeUInt32LE(process.arch === 'arm64' ? 0x0100000c : 0x01000007, 4); }
|
||||
else if (process.platform === 'win32') { nativeBytes.set([0x4d, 0x5a]); nativeBytes.writeUInt32LE(40, 60); nativeBytes.writeUInt32LE(0x00004550, 40); nativeBytes.writeUInt16LE(process.arch === 'arm64' ? 0xaa64 : 0x8664, 44); }
|
||||
else { nativeBytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); nativeBytes.writeUInt16LE(process.arch === 'arm64' ? 183 : 62, 18); }
|
||||
const nativeFile = join(nativeVendor, triple, 'bin', process.platform === 'win32' ? 'codex.exe' : 'codex');
|
||||
mkdirSync(join(nativeVendor, triple, 'bin'), { recursive: true }); writeFileSync(nativeFile, nativeBytes, { mode: 0o755 });
|
||||
const manifest = { name: '@openai/codex', version: `0.160.0-${target}`, os: [process.platform], cpu: [process.arch] };
|
||||
writeFileSync(join(platform, 'package.json'), JSON.stringify(manifest));
|
||||
const entry = { name: manifest.name, version: manifest.version,
|
||||
@@ -209,6 +268,8 @@ test('the installed Codex probe exercises the public export and rejects incomple
|
||||
writeFileSync(join(consumer, 'package-lock.json'), JSON.stringify(lock));
|
||||
const server = join(consumer, 'node_modules/@paperclipai/server');
|
||||
const vendor = join(server, 'dist/vendor/paperclip-runner'), drivers = join(vendor, 'drivers/acpx'); mkdirSync(drivers, { recursive: true });
|
||||
mkdirSync(join(vendor, 'live'));
|
||||
writeFileSync(join(vendor, 'live/runner-binary.js'), `export const runnerBinaryTarget = ${runnerBinaryTarget.toString()};`);
|
||||
writeFileSync(join(server, 'package.json'), JSON.stringify({ name: '@paperclipai/server', type: 'module' }));
|
||||
const integrity = join(drivers, 'installation-integrity.js');
|
||||
const security = join(vendor, 'drivers/codex/codex-security-config.js'); mkdirSync(join(vendor, 'drivers/codex'));
|
||||
@@ -233,6 +294,24 @@ test('the installed Codex probe exercises the public export and rejects incomple
|
||||
assert.equal(JSON.parse(result.stdout).pinnedCodexCommandVerified, true);
|
||||
assert.equal(JSON.parse(result.stdout).codexPlatformPackageSource, 'official npm');
|
||||
assert.equal(JSON.parse(result.stdout).codexConsumerPlatform, target);
|
||||
assert.equal(JSON.parse(result.stdout).codexCommandSha256, createHash('sha256').update(readFileSync(command)).digest('hex'));
|
||||
assert.equal(JSON.parse(result.stdout).codexNativeExecutableSha256, createHash('sha256').update(nativeBytes).digest('hex'));
|
||||
const original = JSON.parse(result.stdout);
|
||||
nativeBytes[63] = 1; writeFileSync(nativeFile, nativeBytes);
|
||||
result = run(); assert.equal(result.status, 0, result.stderr);
|
||||
const changed = JSON.parse(result.stdout);
|
||||
assert.equal(changed.codexCommandSha256, original.codexCommandSha256, 'native mutation does not change the npm launcher');
|
||||
assert.notEqual(changed.codexNativeExecutableSha256, original.codexNativeExecutableSha256);
|
||||
assert.throws(() => assertInstalledNativeConsumerContinuity({ lockSha256: 'a'.repeat(64), runnerSha256: 'b'.repeat(64), ...original },
|
||||
{ lockSha256: 'a'.repeat(64), runnerSha256: 'b'.repeat(64), ...changed }), /codexNativeExecutableSha256/);
|
||||
nativeBytes[63] = 0; writeFileSync(nativeFile, nativeBytes);
|
||||
rmSync(nativeFile);
|
||||
result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /one native executable/);
|
||||
const foreignNative = join(root, 'foreign-codex'); writeFileSync(foreignNative, nativeBytes, { mode: 0o755 });
|
||||
symlinkSync(foreignNative, nativeFile);
|
||||
result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /bounded regular file in its selected platform package/);
|
||||
rmSync(nativeFile); writeFileSync(nativeFile, nativeBytes, { mode: 0o755 });
|
||||
result = run(); assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(JSON.parse(result.stdout).codexQualifiedInstallationVerified, true);
|
||||
assert.equal(JSON.parse(result.stdout).codexCommandLeaseVerified, true);
|
||||
assert.equal(JSON.parse(result.stdout).codexNativeSandboxResourcesVerified, true);
|
||||
@@ -253,6 +332,20 @@ test('the installed Codex probe exercises the public export and rejects incomple
|
||||
writeFileSync(legacyCommand, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
writeFileSync(index, `export const resolvePinnedCodexCommand = () => ${JSON.stringify(legacyCommand)};`);
|
||||
result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /Selected Codex platform version must match the qualified pin/);
|
||||
// Native Codex uses protocol compatibility, not the ACPX version allowlist.
|
||||
// Keep exact official-npm host provenance and the selected resource boundary.
|
||||
mkdirSync(join(legacy, 'vendor'));
|
||||
const legacyNative = join(legacy, 'vendor', triple, 'codex', process.platform === 'win32' ? 'codex.exe' : 'codex');
|
||||
mkdirSync(join(legacy, 'vendor', triple, 'codex'), { recursive: true }); writeFileSync(legacyNative, nativeBytes, { mode: 0o755 });
|
||||
writeFileSync(legacyCommand, '#!/bin/sh\nprintf "codex-cli 0.156.1\\n"\n', { mode: 0o755 });
|
||||
writeFileSync(security, securitySource.replaceAll(JSON.stringify(nativeVendor), JSON.stringify(join(legacy, 'vendor'))));
|
||||
writeFileSync(probe, installedCodexProbeSource(index, consumer, undefined, { nativeCodex: true }));
|
||||
result = run(); assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(JSON.parse(result.stdout).installedCodexVersion, 'codex-cli 0.156.1');
|
||||
assert.equal(JSON.parse(result.stdout).codexQualifiedInstallationVerified, undefined);
|
||||
writeFileSync(probe, installedCodexProbeSource(index, consumer, '0.160.0'));
|
||||
writeFileSync(security, securitySource);
|
||||
|
||||
writeFileSync(index, `import { writeFileSync } from 'node:fs'; export const resolvePinnedCodexCommand = () => {
|
||||
writeFileSync(${JSON.stringify(join(platform, 'package.json'))}, ${JSON.stringify(JSON.stringify({ ...manifest, altered: true }))});
|
||||
return ${JSON.stringify(command)}; };`);
|
||||
@@ -308,6 +401,7 @@ test('actual tar member checks allow Codex JavaScript but reject nested platform
|
||||
assert.doesNotThrow(() => assertNoBundledCodexPayloads(javascript));
|
||||
for (const payload of [
|
||||
'package/node_modules/@openai/codex-linux-x64/package.json',
|
||||
'package/node_modules/@openai/codex-linux-arm64/package.json',
|
||||
'package/node_modules/bridge/node_modules/@openai/codex-darwin-arm64/package.json',
|
||||
'package/node_modules/@openai/codex-win32-x64/package.json',
|
||||
'package/node_modules/@openai/codex/vendor/x86_64-unknown-linux-musl/codex/codex',
|
||||
@@ -320,24 +414,129 @@ test('actual tar member checks allow Codex JavaScript but reject nested platform
|
||||
});
|
||||
|
||||
test('consumer provenance rejects bundled, foreign, inconsistent and non-official Codex payloads', () => {
|
||||
const path = 'node_modules/@openai/codex-linux-x64';
|
||||
const manifest = { name: '@openai/codex', version: '0.160.0-linux-x64', os: ['linux'], cpu: ['x64'] };
|
||||
for (const target of ['linux-x64', 'linux-arm64']) {
|
||||
const path = `node_modules/@openai/codex-${target}`;
|
||||
const manifest = { name: '@openai/codex', version: `0.160.0-${target}`, os: ['linux'], cpu: [target.slice('linux-'.length)] };
|
||||
const entry = { version: manifest.version, resolved: `https://registry.npmjs.org/@openai/codex/-/codex-${manifest.version}.tgz`,
|
||||
integrity: `sha512-${Buffer.alloc(64, 1).toString('base64')}` };
|
||||
const check = (value = entry, installed = [{ path, manifest }]) => assertInstalledCodexPlatformProvenance({ packages: { [path]: value } }, installed, 'linux-x64');
|
||||
const check = (value = entry, installed = [{ path, manifest }]) => assertInstalledCodexPlatformProvenance({ packages: { [path]: value } }, installed, target);
|
||||
assert.equal(check().codexConsumerHostOnly, true);
|
||||
const legacyManifest = { ...manifest, version: '0.156.1-linux-x64' };
|
||||
const legacyManifest = { ...manifest, version: `0.156.1-${target}` };
|
||||
assert.equal(check({ ...entry, version: legacyManifest.version,
|
||||
resolved: 'https://registry.npmjs.org/@openai/codex/-/codex-0.156.1-linux-x64.tgz' }, [{ path, manifest: legacyManifest }]).codexConsumerHostOnly, true);
|
||||
assert.throws(() => check(entry, [{ path, manifest: { ...manifest, version: '^0.160.0-linux-x64' } }]), /exact host version/);
|
||||
resolved: `https://registry.npmjs.org/@openai/codex/-/codex-0.156.1-${target}.tgz` }, [{ path, manifest: legacyManifest }]).codexConsumerHostOnly, true);
|
||||
assert.throws(() => check(entry, [{ path, manifest: { ...manifest, version: `^0.160.0-${target}` } }]), /exact host version/);
|
||||
for (const change of [
|
||||
{ inBundle: true }, { link: true }, { version: '0.156.1-linux-x64' },
|
||||
{ resolved: 'https://example.com/@openai/codex/-/codex-0.160.0-linux-x64.tgz' },
|
||||
{ resolved: 'https://registry.npmjs.org/@openai/codex/-/codex-0.156.1-linux-x64.tgz' },
|
||||
{ resolved: 'https://user@registry.npmjs.org/@openai/codex/-/codex-0.160.0-linux-x64.tgz' },
|
||||
{ inBundle: true }, { link: true }, { version: `0.156.1-${target}` },
|
||||
{ resolved: `https://example.com/@openai/codex/-/codex-0.160.0-${target}.tgz` },
|
||||
{ resolved: `https://registry.npmjs.org/@openai/codex/-/codex-0.156.1-${target}.tgz` },
|
||||
{ resolved: `https://user@registry.npmjs.org/@openai/codex/-/codex-0.160.0-${target}.tgz` },
|
||||
{ integrity: undefined }, { integrity: 'sha512-AA==' },
|
||||
]) assert.throws(() => check({ ...entry, ...change }));
|
||||
assert.throws(() => check(entry, []), /must install its host/);
|
||||
assert.throws(() => check(entry, [{ path: 'node_modules/@openai/codex-darwin-arm64', manifest }]), /only the host/);
|
||||
assert.throws(() => check(entry, [{ path, manifest: { ...manifest, name: 'untrusted' } }]), /identity/);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('hosted Mac lifecycle denies OS networking and writes outside owned scratch', () => {
|
||||
const profile = macPublicInstallLifecycleProfile('/Users/runner/work/_temp/owned-check');
|
||||
assert.match(profile, /\(deny network\*\)/);
|
||||
assert.match(profile, /\(deny file-write\*/);
|
||||
assert.match(profile, /subpath "\/Users\/runner\/work\/_temp\/owned-check"/);
|
||||
assert.throws(() => macPublicInstallLifecycleProfile('relative'), /absolute/);
|
||||
assert.throws(() => macPublicInstallLifecycleProfile('/tmp/owned\0bad'), /absolute/);
|
||||
const source = readFileSync(new URL('../verify-grok-npm-install.mjs', import.meta.url), 'utf8');
|
||||
assert.match(source, /process.env.GITHUB_ACTIONS, 'true'/);
|
||||
assert.match(source, /networkProbe, 'allowed'/);
|
||||
assert.match(source, /networkProbe, 'denied'/);
|
||||
assert.match(source, /error.code,'EPERM'/);
|
||||
assert.match(source, /'rebuild', '--offline', '--ignore-scripts=false', '--dangerously-allow-all-scripts'/);
|
||||
assert.match(source, /readFileSync\(sentinel, 'utf8'\), 'ok'/);
|
||||
assert.match(source, /readFileSync\(join\(consumer, 'package-lock.json'\), 'utf8'\), lock/);
|
||||
assert.match(source, /finally \{ rmSync\(root, \{ recursive: true, force: true \}\); \}/);
|
||||
});
|
||||
|
||||
test('installed daemon proof uses the production host resolver and complete source-bound manifest', () => {
|
||||
const source = installedDaemonProbeSource('/consumer/server/dist/vendor/paperclip-runner', '/consumer', 'a'.repeat(40));
|
||||
assert.match(source, /defaultCapabilityRunnerdBinary\(\)/);
|
||||
assert.match(source, /Object.keys\(manifest.platforms\).sort\(\), targets.sort\(\)/);
|
||||
assert.match(source, /manifest.sourceRevision, "a{40}"/);
|
||||
assert.match(source, /runnerBinaryTarget\(bytes\), target/);
|
||||
assert.match(source, /createHash\('sha256'\)/);
|
||||
assert.match(source, /metadata.binaryContractVersion, expected.contracts.runnerdArtifact/);
|
||||
assert.match(source, /'linux-arm64'/);
|
||||
assert.doesNotMatch(source, /PAPERCLIP_RUNNERD_BINARY|runner\/target\/debug|execFileSync\('paperclip-runnerd'/);
|
||||
});
|
||||
|
||||
test('native ARM clean consumers keep the existing container and installed-runtime boundaries', () => {
|
||||
const args = grokConsumerDockerArgs({ ...paths, platform: 'linux/arm64', command: GROK_PUBLIC_INSTALL_LIFECYCLE });
|
||||
assert.deepEqual(values(args, '--platform'), ['linux/arm64']);
|
||||
assert.deepEqual(values(args, '--network'), ['none']);
|
||||
assert.deepEqual(values(args, '--user'), ['1001:1001']);
|
||||
assert.ok(args.includes('--read-only'));
|
||||
assert.deepEqual(values(args, '--cap-drop'), ['ALL']);
|
||||
assert.deepEqual(values(args, '--security-opt'), ['no-new-privileges']);
|
||||
assert.ok(values(args, '--mount').some(value => value.endsWith('dst=/packages,readonly')));
|
||||
assert.throws(() => grokConsumerDockerArgs({ ...paths, platform: 'linux/arm/v7', command: [] }), /supported native Linux platform/);
|
||||
const source = readFileSync(new URL('../verify-grok-npm-install.mjs', import.meta.url), 'utf8');
|
||||
const arm = source.split('async function verifyLinuxArmConsumer(')[1].split("if (process.argv[2] === '--linux-arm64-consumer')")[0];
|
||||
assert.match(arm, /process.platform \+ '-' \+ process.arch, 'linux-arm64'/);
|
||||
assert.match(arm, /const platform = 'linux\/arm64'/);
|
||||
assert.match(arm, /isolated\(GROK_PUBLIC_INSTALL_LIFECYCLE\)/);
|
||||
assert.match(arm, /installedCodexProbeSource\(installed \+ '\/index.js', '\/consumer', undefined, \{ nativeCodex: true \}\)/);
|
||||
assert.match(arm, /tests\/runner-e2e\/installed-cli.ts/);
|
||||
assert.match(arm, /process.versions.node.split\('\.'\)\[0\], '24'/);
|
||||
assert.match(arm, /macInstalledStartupProbeSource\(consumer, hostServer, daemon.sha256, join\(assets, 'installed-cli.ts'\)\)/);
|
||||
assert.match(arm, /startupEnvironment: 'native host, same installed graph, isolated empty homes'/);
|
||||
assert.doesNotMatch(arm, /stage-runner|runner\/target|PAPERCLIP_RUNNER_BINARY|prerequisite:/);
|
||||
});
|
||||
|
||||
|
||||
test('Mac startup wiring normalizes only validated manifest digests and uses server-scoped tooling', () => {
|
||||
const source = macInstalledStartupProbeSource('/owned/consumer', '/owned/consumer/server', 'sha256:' + 'a'.repeat(64), '/source/tests/runner-e2e/installed-cli.ts');
|
||||
assert.match(source, /expectedRunnerSha256:"a{64}"/);
|
||||
assert.doesNotMatch(source, /expectedRunnerSha256:"sha256:/);
|
||||
for (const digest of ['a'.repeat(64), 'sha256:bad', 'sha512:' + 'a'.repeat(64), 'sha256:' + 'A'.repeat(64)]) {
|
||||
assert.throws(() => macInstalledStartupProbeSource('/owned/consumer', '/owned/consumer/server', digest, '/source/helper.ts'), /verified SHA-256/);
|
||||
}
|
||||
const verifier = readFileSync(new URL('../verify-grok-npm-install.mjs', import.meta.url), 'utf8');
|
||||
assert.match(verifier, /\['--import', 'tsx', startupFile\], join\(repo, 'server'\)/);
|
||||
const closedEnvironment = verifier.match(/const env = \{ PATH: `\$\{tools\}[\s\S]*?\};/)[0];
|
||||
assert.doesNotMatch(closedEnvironment, /NODE_PATH|NODE_OPTIONS|\.\.\.process.env/);
|
||||
});
|
||||
|
||||
test('Pi admission verifies assembled host daemon identity and preserves the flat source-package control', async () => {
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), 'pi-installed-daemon-'))), server = join(root, 'server');
|
||||
const output = join(server, 'dist/vendor/paperclip-runner'), target = 'linux-x64', source = 'a'.repeat(40);
|
||||
const binary = join(output, 'bin', target, 'paperclip-runnerd'), manifestPath = join(output, 'bin/release-manifest.json');
|
||||
const bytes = Buffer.alloc(64); bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); bytes.writeUInt16LE(62, 18);
|
||||
const sha256 = value => `sha256:${createHash('sha256').update(value).digest('hex')}`;
|
||||
const manifest = { schema: 'paperclip.runner.release-binaries.v1', sourceRevision: source,
|
||||
platforms: { [target]: { path: `${target}/paperclip-runnerd`, sha256: sha256(bytes) } } };
|
||||
const check = () => verifyPiPackagedDaemon(server, binary, target, runnerBinaryTarget);
|
||||
try {
|
||||
mkdirSync(join(output, 'bin', target), { recursive: true });
|
||||
writeFileSync(binary, bytes); writeFileSync(join(server, 'dist/build-info.json'), JSON.stringify({ commit: source }));
|
||||
writeFileSync(manifestPath, JSON.stringify(manifest));
|
||||
await check();
|
||||
for (const changed of [null, { ...manifest, schema: 'untrusted' }, { ...manifest, sourceRevision: 'b'.repeat(40) },
|
||||
{ ...manifest, platforms: { [target]: { ...manifest.platforms[target], path: '../outside' } } },
|
||||
{ ...manifest, platforms: { [target]: { ...manifest.platforms[target], sha256: 'sha256:' + '0'.repeat(64) } } }]) {
|
||||
writeFileSync(manifestPath, JSON.stringify(changed)); await assert.rejects(check());
|
||||
}
|
||||
const wrongHeader = Buffer.from(bytes); wrongHeader.writeUInt16LE(183, 18);
|
||||
writeFileSync(binary, wrongHeader);
|
||||
writeFileSync(manifestPath, JSON.stringify({ ...manifest, platforms: { [target]: { ...manifest.platforms[target], sha256: sha256(wrongHeader) } } }));
|
||||
await assert.rejects(check(), /linux-arm64/);
|
||||
writeFileSync(binary, bytes); writeFileSync(manifestPath, JSON.stringify(manifest));
|
||||
const outside = join(root, 'outside'); writeFileSync(outside, bytes);
|
||||
rmSync(binary); symlinkSync(outside, binary); await assert.rejects(check());
|
||||
rmSync(binary); writeFileSync(binary, bytes); rmSync(manifestPath);
|
||||
const flat = join(output, 'bin/paperclip-runnerd'); writeFileSync(flat, bytes);
|
||||
await verifyPiPackagedDaemon(server, flat, target, runnerBinaryTarget);
|
||||
await assert.rejects(check(), /strictly equal/);
|
||||
writeFileSync(manifestPath, 'null');
|
||||
await assert.rejects(verifyPiPackagedDaemon(server, flat, target, runnerBinaryTarget), /manifest must be an object/);
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
import { createServer } from "node:http";
|
||||
@@ -81,14 +83,14 @@ test("chaos verification isolates callers that verify the same source commit", (
|
||||
|
||||
test("canary reuses exact-source proof while stable keeps full verification", () => {
|
||||
const releaseWorkflow = readWorkflow("release.yml");
|
||||
const canary = releaseWorkflow.split(" verify_canary:\n")[1].split("\n publish_canary:")[0];
|
||||
const canary = releaseWorkflow.match(/ verify_canary:\n[\s\S]*?(?=\n [a-z_]+:)/)[0];
|
||||
assert.match(canary, /github\.repository == 'paperclipai\/paperclip' && github\.event_name == 'push' && github\.ref == 'refs\/heads\/master'/);
|
||||
assert.match(canary, /actions: read/);
|
||||
assert.match(canary, /ref: \$\{\{ github\.sha \}\}/);
|
||||
assert.match(canary, /SOURCE_SHA: \$\{\{ github\.sha \}\}/);
|
||||
assert.match(canary, /run: node scripts\/cloud-source-verification\.mjs "\$SOURCE_SHA"/);
|
||||
assert.doesNotMatch(canary, /release-verify\.yml|continue-on-error|always\(\)/);
|
||||
assert.match(releaseWorkflow, /publish_canary:\n\s+if: github\.event_name == 'push'\n\s+needs: verify_canary/);
|
||||
assert.match(releaseWorkflow, /publish_canary:\n\s+if: .*github\.event_name == 'push'.*\n\s+needs: \[verify_canary, release_runner_source, release_runner_binaries\]/);
|
||||
// The stable lane is gated on the stable channel since the nightly lane
|
||||
// was added; a `needs:` line (for example a preflight job) may sit between
|
||||
// the gate and the delegation.
|
||||
@@ -742,6 +744,399 @@ test("direct protocol concurrency override only lowers the configured ceiling",
|
||||
}
|
||||
});
|
||||
|
||||
test("release daemon production uses exact-source native hosts and no publisher or provider permissions", () => {
|
||||
const workflow = readWorkflow("release-verify.yml");
|
||||
const producer = workflow.split(" runner_binaries:\n")[1];
|
||||
const targets = workflow.split(" runner_binary_targets:\n")[1].split(" runner_binaries:\n")[0];
|
||||
for (const [target, host] of [["linux-arm64", "ubuntu-24.04-arm"], ["darwin-arm64", "macos-15"], ["darwin-x64", "macos-15-intel"]]) {
|
||||
assert.ok(targets.includes(`'${target}': '${host}'`));
|
||||
}
|
||||
assert.match(producer, /matrix: \$\{\{ fromJSON\(needs.runner_binary_targets.outputs.matrix\) \}\}/);
|
||||
assert.match(targets, /Unknown exact-source daemon platform contract/);
|
||||
assert.match(targets, /RELEASE_RUNNER_TARGETS/);
|
||||
assert.doesNotMatch(targets, /secrets\.|id-token: write|contents: write|npm publish|environment:/);
|
||||
assert.match(producer, /persist-credentials: false/);
|
||||
assert.match(producer, /contents: read/);
|
||||
assert.doesNotMatch(producer, /secrets\.|id-token: write|contents: write|npm publish|environment:/);
|
||||
assert.match(producer, /\[\[ "\$SOURCE_SHA" =~ \^\[a-f0-9\]\{40\}\$ \]\]/);
|
||||
assert.match(producer, /git rev-parse HEAD/);
|
||||
assert.match(producer, /process.platform \+ "-" \+ process.arch/);
|
||||
assert.match(producer, /\['native', \['build:binary'\]/);
|
||||
const runner = JSON.parse(readFileSync(path.join(repoRoot, 'packages/paperclip-runner/package.json'), 'utf8'));
|
||||
assert.match(runner.scripts['build:binary'], /cargo build --release --manifest-path runner\/Cargo.toml --locked/);
|
||||
assert.match(runner.scripts['build:binary'], /&& node scripts\/stage-runner-binary.mjs$/);
|
||||
assert.match(producer, /stage-release-runner-binaries.mjs record/);
|
||||
for (const job of ["runner_chaos_evals", "typecheck", "general_tests", "serialized_tests", "runner_workflow_evals", "verify_paperclip_runner", "build"]) {
|
||||
assert.ok(workflow.includes(`${job}:\n if: \${{ !inputs.runner_binaries_only }}`));
|
||||
}
|
||||
const release = readWorkflow("release.yml");
|
||||
for (const job of ["publish_canary", "publish_nightly", "publish_beta", "preview_stable", "publish_stable"]) {
|
||||
const block = release.split(` ${job}:\n`)[1].split(/\n [a-z_]+:\n/)[0];
|
||||
assert.match(block, /needs: .*release_runner_binaries/);
|
||||
assert.match(block, /pattern: \$\{\{ needs.release_runner_binaries.outputs.runner_artifact_prefix \}\}-\*/);
|
||||
assert.match(block, /PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR/);
|
||||
}
|
||||
const script = readFileSync(path.join(repoRoot, 'scripts/release.sh'), 'utf8');
|
||||
assert.ok(script.indexOf('assemble-daemons "$PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR"') < script.indexOf('scripts/build-standalone-public-packages.mjs'));
|
||||
assert.match(script, /cp -R .*paperclip-runner\/dist\/bin\/\." .*server\/dist\/vendor\/paperclip-runner\/bin\//);
|
||||
assert.match(script, /elif \[ "\$dry_run" = false \]; then[\s\S]*?release_fail "Public releases require/);
|
||||
});
|
||||
|
||||
function nativeProducer() {
|
||||
return readWorkflow('release-verify.yml').split(' runner_binaries:\n')[1];
|
||||
}
|
||||
|
||||
test('native dependency caches are target-bound and unmerged master callers cannot save them', () => {
|
||||
const producer = nativeProducer();
|
||||
const cache = producer.split(' - name: Cache native Rust dependencies\n')[1].split('\n - ')[0];
|
||||
assert.match(cache, /Swatinem\/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6/);
|
||||
assert.match(cache, /workspaces: \$\{\{ steps.native_rust_workspace.outputs.path \}\} -> target/);
|
||||
assert.match(cache, /shared-key: release-native-v2-\$\{\{ matrix.host \}\}-\$\{\{ matrix.target \}\}-\$\{\{ hashFiles\('packages\/paperclip-runner\/protocol\/\*\*', 'packages\/paperclip-runner\/src\/\*\*', 'packages\/paperclip-runner\/\*.json'\) \}\}/);
|
||||
for (const option of ['cache-workspace-crates', 'cache-bin', 'cache-on-failure']) {
|
||||
assert.match(cache, new RegExp(option + ': false'));
|
||||
}
|
||||
const expression = cache.match(/save-if: \$\{\{ (.+) \}\}/)?.[1];
|
||||
assert.ok(expression, 'cache writes need a separate source trust gate');
|
||||
const canSave = new Function('github', 'inputs', `return (${expression});`);
|
||||
const github = { repository: 'paperclipai/paperclip', repository_id: '1170821064', event_name: 'push', ref: 'refs/heads/master', sha: 'a'.repeat(40) };
|
||||
assert.equal(canSave(github, { ref: github.sha }), true);
|
||||
for (const [overrides, input] of [
|
||||
[{}, 'b'.repeat(40)], [{}, ''],
|
||||
[{ event_name: 'workflow_dispatch' }, github.sha],
|
||||
[{ event_name: 'workflow_dispatch' }, 'b'.repeat(40)],
|
||||
[{ event_name: 'pull_request', ref: 'refs/pull/123/merge' }, github.sha],
|
||||
[{ ref: 'refs/heads/topic' }, github.sha],
|
||||
[{ repository: 'fork/paperclip' }, github.sha],
|
||||
[{ repository_id: '1' }, github.sha],
|
||||
]) assert.equal(canSave({ ...github, ...overrides }, { ref: input }), false);
|
||||
assert.match(producer, /pinned="\$HOME\/paperclip-runner-rust"/);
|
||||
assert.match(producer, /ln -s "\$GITHUB_WORKSPACE\/packages\/paperclip-runner\/runner" "\$pinned"/);
|
||||
assert.match(producer, /echo "RUSTUP_TOOLCHAIN=\$toolchain" >> "\$GITHUB_ENV"/);
|
||||
assert.doesNotMatch(producer, /cache-hit|continue-on-error/);
|
||||
assert.ok(producer.indexOf('Bind immutable producer source and target') < producer.indexOf('Cache native Rust dependencies'));
|
||||
assert.ok(producer.indexOf('Build TypeScript contract and host daemon concurrently') < producer.indexOf('Record actual daemon metadata and immutable bytes'));
|
||||
});
|
||||
|
||||
function nativePreparationScript() {
|
||||
const step = nativeProducer().split(' - name: Build TypeScript contract and host daemon concurrently\n')[1].split('\n - ')[0];
|
||||
const script = step.match(/node --input-type=module <<'NODE'\n([\s\S]*?)\n NODE/)?.[1];
|
||||
assert.ok(script);
|
||||
return script.replace(/^ {10}/gm, '');
|
||||
}
|
||||
|
||||
const fixtureProcessGone = pid => {
|
||||
try { process.kill(pid, 0); } catch (error) { return error.code === 'ESRCH'; }
|
||||
try { return /^\d+ \(.*\) Z /.test(readFileSync(`/proc/${pid}/stat`, 'utf8')); } catch { return false; }
|
||||
};
|
||||
const waitForFixture = async (predicate, label) => {
|
||||
for (let attempt = 0; attempt < 200; attempt++) {
|
||||
if (predicate()) return;
|
||||
await new Promise(resolve => setTimeout(resolve, 25));
|
||||
}
|
||||
assert.fail(label);
|
||||
};
|
||||
|
||||
// Execute the workflow's actual coordinator with two tiny local Node commands.
|
||||
// Both must cross the start barrier before either can finish; no pnpm, Cargo,
|
||||
// provider, registry, or Docker build runs in these fixtures.
|
||||
async function withNativePreparationFixture(mode, check) {
|
||||
const root = realpathSync(mkdtempSync(path.join(tmpdir(), 'native-preparation-')));
|
||||
let controller;
|
||||
try {
|
||||
for (const directory of ['scripts', 'tools', 'packages/paperclip-runner', 'logs']) {
|
||||
mkdirSync(path.join(root, directory), { recursive: true });
|
||||
}
|
||||
for (const helper of ['prefetch-child.mjs', 'prefetch-supervisor.mjs']) {
|
||||
copyFileSync(path.join(repoRoot, 'scripts', helper), path.join(root, 'scripts', helper));
|
||||
}
|
||||
writeFileSync(path.join(root, 'prepare.mjs'), nativePreparationScript());
|
||||
writeFileSync(path.join(root, 'tools/pnpm'), `#!${process.execPath}
|
||||
const { spawn } = require('node:child_process');
|
||||
const { existsSync, writeFileSync } = require('node:fs');
|
||||
const { join } = require('node:path');
|
||||
const root = process.env.FIXTURE_ROOT;
|
||||
const name = process.argv.includes('build:binary') ? 'native' : 'typescript';
|
||||
writeFileSync(join(root, name + '.started'), JSON.stringify({ pid: process.pid, cwd: process.cwd(), args: process.argv.slice(2) }));
|
||||
const wait = setInterval(() => {
|
||||
if (!existsSync(join(root, 'native.started')) || !existsSync(join(root, 'typescript.started'))) return;
|
||||
clearInterval(wait);
|
||||
if (process.env.FIXTURE_MODE === 'success') {
|
||||
setTimeout(() => { writeFileSync(join(root, name + '.finished'), 'joined'); console.log(name + ' complete'); }, 150);
|
||||
return;
|
||||
}
|
||||
const child = spawn(process.execPath, ['-e', \
|
||||
"const fs=require('node:fs'); fs.writeFileSync(process.argv[1], String(process.pid)); setInterval(() => fs.writeFileSync(process.argv[2], String(Date.now())), 25)",\
|
||||
join(root, name + '.descendant'), join(root, name + '.writing')], { stdio: 'inherit' });
|
||||
child.on('exit', () => process.exit(0));
|
||||
const barrier = setInterval(() => {
|
||||
if (!existsSync(join(root, 'native.descendant')) || !existsSync(join(root, 'typescript.descendant'))) return;
|
||||
clearInterval(barrier);
|
||||
if (process.env.FIXTURE_MODE === name) { console.error(name + ' failed'); process.exit(23); }
|
||||
}, 10);
|
||||
}, 10);
|
||||
`, { mode: 0o755 });
|
||||
controller = spawn(process.execPath, ['prepare.mjs'], { cwd: root, env: {
|
||||
...process.env, PATH: `${path.join(root, 'tools')}:${process.env.PATH}`, RUNNER_TEMP: path.join(root, 'logs'), FIXTURE_ROOT: root, FIXTURE_MODE: mode,
|
||||
}, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stdout = '', stderr = '';
|
||||
controller.stdout.on('data', chunk => { stdout += chunk; });
|
||||
controller.stderr.on('data', chunk => { stderr += chunk; });
|
||||
const completed = new Promise((resolve, reject) => {
|
||||
controller.once('error', reject);
|
||||
controller.once('close', (status, signal) => resolve({ status, signal, stdout, stderr }));
|
||||
});
|
||||
await check({ root, controller, completed });
|
||||
} finally {
|
||||
if (controller?.exitCode === null && controller.signalCode === null) controller.kill('SIGKILL');
|
||||
for (const name of ['native', 'typescript']) {
|
||||
for (const filename of [name + '.started', name + '.descendant']) {
|
||||
const file = path.join(root, filename);
|
||||
if (!existsSync(file)) continue;
|
||||
const data = readFileSync(file, 'utf8');
|
||||
const pid = filename.endsWith('.started') ? JSON.parse(data).pid : Number(data);
|
||||
if (!fixtureProcessGone(pid)) {
|
||||
try { process.kill(pid, 'SIGKILL'); } catch (error) { if (error.code !== 'ESRCH') throw error; }
|
||||
}
|
||||
}
|
||||
}
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('native preparation overlaps independent outputs and joins both before recording', async () => {
|
||||
await withNativePreparationFixture('success', async ({ root, completed }) => {
|
||||
const result = await completed;
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
for (const name of ['native', 'typescript']) assert.equal(readFileSync(path.join(root, name + '.finished'), 'utf8'), 'joined');
|
||||
assert.equal(JSON.parse(readFileSync(path.join(root, 'native.started'), 'utf8')).cwd, path.join(root, 'packages/paperclip-runner'));
|
||||
assert.deepEqual(JSON.parse(readFileSync(path.join(root, 'native.started'), 'utf8')).args, ['build:binary']);
|
||||
assert.deepEqual(JSON.parse(readFileSync(path.join(root, 'typescript.started'), 'utf8')).args, ['--filter', '@paperclipai/paperclip-runner', 'build:typescript']);
|
||||
const results = result.stdout.split('\n').filter(line => line.startsWith('{')).map(line => JSON.parse(line));
|
||||
assert.deepEqual(results.map(item => [item.build, item.status, item.exitCode]), [['typescript', 'fulfilled', 0], ['native', 'fulfilled', 0]]);
|
||||
});
|
||||
});
|
||||
|
||||
for (const name of ['typescript', 'native']) {
|
||||
test(`${name} preparation failure fails the join and retires both owned process trees`, async () => {
|
||||
await withNativePreparationFixture(name, async ({ root, completed }) => {
|
||||
const result = await completed;
|
||||
assert.equal(result.status, 1, result.stderr);
|
||||
assert.match(result.stderr, new RegExp(name + ' prefetch failed \\(exit 23\\)'));
|
||||
const results = result.stdout.split('\n').filter(line => line.startsWith('{')).map(line => JSON.parse(line));
|
||||
assert.equal(results.length, 2);
|
||||
assert.ok(results.every(item => item.status === 'rejected'));
|
||||
for (const build of ['native', 'typescript']) {
|
||||
const pid = Number(readFileSync(path.join(root, build + '.descendant'), 'utf8'));
|
||||
await waitForFixture(() => fixtureProcessGone(pid), build + ' descendant outlived failed join');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('canceling native preparation retires both supervisors and their descendants', { skip: process.platform === 'win32' }, async () => {
|
||||
await withNativePreparationFixture('cancel', async ({ root, controller, completed }) => {
|
||||
await waitForFixture(() => ['native', 'typescript'].every(name => existsSync(path.join(root, name + '.descendant'))), 'both native build trees must start');
|
||||
controller.kill('SIGTERM');
|
||||
const result = await completed;
|
||||
assert.equal(result.signal, 'SIGTERM');
|
||||
for (const name of ['native', 'typescript']) {
|
||||
const pid = Number(readFileSync(path.join(root, name + '.descendant'), 'utf8'));
|
||||
await waitForFixture(() => fixtureProcessGone(pid), name + ' descendant outlived cancellation');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("premerge native qualification consumes the same Linux-packed npm bytes without publishing", () => {
|
||||
const smoke = readWorkflow("release-smoke.yml");
|
||||
assert.match(smoke, /smoke_service:\n\s+if: inputs.qualification_source_sha == ''/);
|
||||
assert.match(smoke, /smoke:\n\s+if: inputs.qualification_source_sha == ''/);
|
||||
const qualification = smoke.split(" qualification_daemons:\n")[1];
|
||||
assert.doesNotMatch(qualification, /secrets\.|npm publish|id-token: write|contents: write|continue-on-error/);
|
||||
assert.match(qualification, /runner_binaries_only: true/);
|
||||
assert.match(qualification, /PAPERCLIP_RUNNER_PUBLIC_PACKS_DIR/);
|
||||
assert.match(qualification, /if: \$\{\{ !cancelled\(\) && steps.pack_identity.outputs.available == 'true' \}\}/);
|
||||
assert.match(qualification, /name: \$\{\{ inputs.artifact_name \}\}-public-packages/);
|
||||
assert.match(qualification, /--mac-consumer/);
|
||||
assert.match(qualification, /--linux-arm64-consumer/);
|
||||
assert.match(qualification, /fromJSON\(needs.qualification_daemons.outputs.native_consumer_matrix\)/);
|
||||
assert.match(qualification, /pnpm install --ignore-scripts --no-frozen-lockfile/);
|
||||
});
|
||||
|
||||
test("native ARM qualification prepares its scoped distro sandbox before the existing consumer", () => {
|
||||
const smoke = readWorkflow("release-smoke.yml");
|
||||
const native = smoke.split(" qualification_native:\n")[1];
|
||||
const preparation = native.split(" - name: Prepare Linux ARM sandbox prerequisites\n")[1].split("\n - ")[0];
|
||||
assert.match(preparation, /if: \$\{\{ matrix\.target == 'linux-arm64' \}\}/);
|
||||
assert.match(preparation, /timeout-minutes: 5/);
|
||||
assert.match(preparation, /bash \.qualification-tooling\/scripts\/prepare-linux-arm64-qualification\.sh/);
|
||||
const tooling = native.split(" - name: Checkout immutable ARM qualification tooling\n")[1].split("\n - ")[0];
|
||||
assert.match(tooling, /if: \$\{\{ matrix\.target == 'linux-arm64' \}\}/);
|
||||
assert.match(tooling, /repository: \$\{\{ job\.workflow_repository \}\}/);
|
||||
assert.match(tooling, /ref: \$\{\{ job\.workflow_sha \}\}/);
|
||||
assert.match(tooling, /sparse-checkout: scripts\/prepare-linux-arm64-qualification\.sh/);
|
||||
assert.match(tooling, /sparse-checkout-cone-mode: false/);
|
||||
assert.match(tooling, /persist-credentials: false/);
|
||||
assert.doesNotMatch(tooling + preparation, /github\.workflow_sha|github\.sha|ref: (?:master|main)|ref: \$\{\{ inputs\.qualification_source_sha/);
|
||||
assert.ok(native.indexOf("Checkout immutable ARM qualification tooling") < native.indexOf("Prepare Linux ARM sandbox prerequisites"));
|
||||
assert.ok(native.indexOf("Prepare Linux ARM sandbox prerequisites") < native.indexOf("Verify host identity and actual clean npm consumer"));
|
||||
assert.match(native, /path: \|\n\s+\$\{\{ runner\.temp \}\}\/native-public-install\.json\n\s+\$\{\{ runner\.temp \}\}\/paperclip-arm-bwrap-prerequisite\//);
|
||||
assert.doesNotMatch(smoke.split(" qualification_daemons:\n")[0], /prepare-linux-arm64-qualification/,
|
||||
"ordinary service and Docker smoke keep their existing setup");
|
||||
});
|
||||
|
||||
test("ARM qualification binds separate product and reusable-workflow revisions even when the product lacks the helper", { skip: process.platform === "win32" }, () => {
|
||||
const native = readWorkflow("release-smoke.yml").split(" qualification_native:\n")[1];
|
||||
const step = native.split(" - name: Prepare Linux ARM sandbox prerequisites\n")[1].split("\n - ")[0];
|
||||
const script = step.split(" run: |\n")[1].split("\n").map(line => line.startsWith(" ") ? line.slice(10) : line).join("\n");
|
||||
const root = mkdtempSync(path.join(tmpdir(), "arm-separate-tooling-"));
|
||||
try {
|
||||
const tools = path.join(root, "tools"); mkdirSync(tools);
|
||||
const tooling = path.join(root, ".qualification-tooling", "scripts"); mkdirSync(tooling, { recursive: true });
|
||||
const helper = 'printf "%s\\n" "$TOOLING_SHA" > "$FIXTURE_PREPARED"\n';
|
||||
writeFileSync(path.join(tooling, "prepare-linux-arm64-qualification.sh"), helper);
|
||||
writeFileSync(path.join(tools, "git"), '#!/bin/bash\nif [[ "$1" == -C ]]; then printf "%s\\n" "$FIXTURE_TOOLING_HEAD"; else printf "%s\\n" "$FIXTURE_PRODUCT_HEAD"; fi\n', { mode: 0o755 });
|
||||
const productSha = "a".repeat(40), toolingSha = "b".repeat(40);
|
||||
for (const [productHead, toolingHead, expectedStatus] of [[productSha, toolingSha, 0], ["c".repeat(40), toolingSha, 1], [productSha, "c".repeat(40), 1], [productSha, "invalid", 1]]) {
|
||||
const report = path.join(root, "report"); mkdirSync(report);
|
||||
const prepared = path.join(root, "prepared");
|
||||
const result = spawnSync("/bin/bash", ["-c", script], { cwd: root, encoding: "utf8", env: {
|
||||
PATH: `${tools}:${path.dirname(process.execPath)}:/usr/bin:/bin`, RUNNER_TEMP: report,
|
||||
SOURCE_SHA: productSha, TOOLING_SHA: toolingSha, TOOLING_REPOSITORY: "paperclipai/paperclip",
|
||||
TOOLING_WORKFLOW_REF: "paperclipai/paperclip/.github/workflows/release-smoke.yml@refs/heads/qualification",
|
||||
TOOLING_WORKFLOW_PATH: ".github/workflows/release-smoke.yml",
|
||||
FIXTURE_PRODUCT_HEAD: productHead, FIXTURE_TOOLING_HEAD: toolingHead, FIXTURE_PREPARED: prepared,
|
||||
// Reusable calls retain the caller context; it must not select tooling.
|
||||
GITHUB_REPOSITORY: "different/caller", GITHUB_SHA: "d".repeat(40),
|
||||
} });
|
||||
assert.equal(result.status, expectedStatus, result.stderr);
|
||||
assert.equal(existsSync(path.join(root, "scripts", "prepare-linux-arm64-qualification.sh")), false);
|
||||
assert.equal(existsSync(prepared), expectedStatus === 0);
|
||||
if (expectedStatus === 0) {
|
||||
assert.equal(readFileSync(prepared, "utf8").trim(), toolingSha);
|
||||
const identity = JSON.parse(readFileSync(path.join(report, "paperclip-arm-bwrap-prerequisite", "qualification-identity.json"), "utf8"));
|
||||
assert.equal(identity.productSourceSha, productSha);
|
||||
assert.equal(identity.toolingSourceSha, toolingSha);
|
||||
assert.equal(identity.toolingRepository, "paperclipai/paperclip");
|
||||
assert.equal(identity.toolingWorkflowPath, ".github/workflows/release-smoke.yml");
|
||||
assert.equal(identity.helperSha256, createHash("sha256").update(helper).digest("hex"));
|
||||
}
|
||||
rmSync(report, { recursive: true, force: true });
|
||||
rmSync(prepared, { force: true });
|
||||
}
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("Linux ARM prerequisite setup rejects other hosts before invoking tools and preserves scoped security", { skip: process.platform === "win32" }, () => {
|
||||
const helper = path.join(repoRoot, "scripts/prepare-linux-arm64-qualification.sh");
|
||||
const source = readFileSync(helper, "utf8");
|
||||
const root = mkdtempSync(path.join(tmpdir(), "arm-prerequisite-guard-"));
|
||||
try {
|
||||
const tools = path.join(root, "tools"); mkdirSync(tools);
|
||||
const sentinel = path.join(root, "invoked");
|
||||
for (const command of ["dpkg", "uname", "mkdir", "mktemp", "sysctl", "sudo"]) {
|
||||
writeFileSync(path.join(tools, command), '#!/bin/bash\nprintf "%s\\n" "$0" >> "$PREREQUISITE_SENTINEL"\nexit 85\n', { mode: 0o755 });
|
||||
}
|
||||
for (const [actions, runnerOs] of [["", "Linux"], ["false", "Linux"], ["true", "macOS"], ["true", "Windows"]]) {
|
||||
const result = spawnSync("/bin/bash", [helper], { encoding: "utf8", env: {
|
||||
PATH: tools, GITHUB_ACTIONS: actions, RUNNER_OS: runnerOs, RUNNER_TEMP: root, PREREQUISITE_SENTINEL: sentinel,
|
||||
} });
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /requires a GitHub Actions Linux runner/);
|
||||
assert.equal(existsSync(sentinel), false, "host guard must precede every external tool");
|
||||
assert.equal(existsSync(path.join(root, "paperclip-arm-bwrap-prerequisite")), false);
|
||||
}
|
||||
assert.ok(source.indexOf('"${GITHUB_ACTIONS:-}" != true') < source.indexOf(". /etc/os-release"));
|
||||
assert.match(source, /-o Acquire::AllowInsecureRepositories=false/);
|
||||
assert.match(source, /download "\$package_name=\$package_version"/);
|
||||
assert.match(source, /printf '%s %s\\n' "\$package_sha256" "\$package_file" \| sha256sum -c -/);
|
||||
assert.match(source, /dpkg-deb --fsys-tarfile "\$package_file"/);
|
||||
assert.match(source, /tar -xO \.\/usr\/bin\/bwrap/);
|
||||
assert.match(source, /11d39094f044f0cda0febb3ad517b830301da6b2ce929664af09ee9e4dd264f9/);
|
||||
assert.ok(source.indexOf('test ! -L "$target"') < source.indexOf('sudo install -o root -g root -m 0644 "$stock_profile" "$target"'));
|
||||
assert.match(source, /timeout 15s sudo "\$parser" -r "\$target"/);
|
||||
for (const setting of ["userns", "apparmor", "clone"]) assert.ok(source.includes(`test "$after_${setting}" = "$before_${setting}"`));
|
||||
const aptCommands = source.replace(/\\\n\s*/g, " ").split("\n").filter(line => /\bapt-get\b/.test(line));
|
||||
assert.equal(aptCommands.length, 2, "only signed-index update and download may invoke APT");
|
||||
assert.match(aptCommands[0], /\bupdate\b/);
|
||||
assert.match(aptCommands[1], /\bdownload\b/);
|
||||
for (const command of aptCommands) assert.doesNotMatch(command, /\b(?:install|upgrade|remove|purge)\b/);
|
||||
assert.doesNotMatch(source, /apt-get[^\n]* install|\bdpkg(?:-deb)?\s+(?:-i|--install|--unpack)|sysctl\s+(?:-w|--system)|\baa-disable\b|\bsetcap\b|systemctl|--no-sandbox/);
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("exact-source daemon matrices preserve historical three-target sources and add native ARM only for new sources", () => {
|
||||
const workflow = readWorkflow("release-verify.yml");
|
||||
const step = workflow.split(' - name: Preserve the immutable source\'s platform contract')[1].split('\n runner_binaries:')[0];
|
||||
const script = step.split(' run: |\n')[1].split('\n').map(line => line.startsWith(' ') ? line.slice(10) : line).join('\n');
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'release-target-contract-'));
|
||||
try {
|
||||
const tools = path.join(root, 'tools'); mkdirSync(tools);
|
||||
writeFileSync(path.join(tools, 'git'), '#!/bin/sh\nprintf "%s\\n" "$FIXTURE_HEAD"\n', { mode: 0o755 });
|
||||
const source = path.join(root, 'packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs');
|
||||
mkdirSync(path.dirname(source), { recursive: true });
|
||||
const output = path.join(root, 'outputs');
|
||||
const historical = ['darwin-arm64', 'darwin-x64', 'linux-x64'];
|
||||
for (const targets of [historical, [...historical, 'linux-arm64']]) {
|
||||
writeFileSync(source, `export const RELEASE_RUNNER_TARGETS = ${JSON.stringify(targets)};`);
|
||||
writeFileSync(output, '');
|
||||
const result = spawnSync('bash', ['-euo', 'pipefail', '-c', script], { cwd: root, encoding: 'utf8', env: {
|
||||
PATH: `${tools}:${path.dirname(process.execPath)}:/usr/bin:/bin`, SOURCE_SHA: 'a'.repeat(40), FIXTURE_HEAD: 'a'.repeat(40), GITHUB_OUTPUT: output,
|
||||
} });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const data = Object.fromEntries(readFileSync(output, 'utf8').trim().split('\n').map(line => {
|
||||
const index = line.indexOf('='); return [line.slice(0, index), JSON.parse(line.slice(index + 1))];
|
||||
}));
|
||||
assert.deepEqual(data.matrix.include.map(row => row.target), targets);
|
||||
assert.deepEqual(data.native_consumers.include.map(row => row.target), targets.filter(target => target !== 'linux-x64'));
|
||||
assert.ok(data.matrix.include.every(row => row.target !== 'linux-arm64' || row.host === 'ubuntu-24.04-arm'));
|
||||
assert.equal(data.matrix.include.find(row => row.target === 'linux-x64').host, targets.length === 4 ? 'ubuntu-24.04' : 'ubuntu-latest',
|
||||
'new GNU libc baseline stays pinned while historical qualification keeps its producer mode');
|
||||
}
|
||||
writeFileSync(source, 'export const RELEASE_RUNNER_TARGETS = ["linux-arm64"];'); writeFileSync(output, '');
|
||||
const result = spawnSync('bash', ['-euo', 'pipefail', '-c', script], { cwd: root, encoding: 'utf8', env: {
|
||||
PATH: `${tools}:${path.dirname(process.execPath)}:/usr/bin:/bin`, SOURCE_SHA: 'a'.repeat(40), FIXTURE_HEAD: 'a'.repeat(40), GITHUB_OUTPUT: output,
|
||||
} });
|
||||
assert.equal(result.status, 1); assert.match(result.stderr, /Unknown exact-source daemon platform contract/);
|
||||
assert.equal(readFileSync(output, 'utf8'), '');
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
|
||||
test("historical promotion sources keep their packaging path while new sources require daemon assembly", () => {
|
||||
const workflow = readWorkflow("release.yml");
|
||||
const selector = workflow.split(' - name: Read the exact source packaging contract')[1].split('\n release_runner_binaries:')[0];
|
||||
const script = selector.split(' run: |\n')[1].split('\n').map(line => line.startsWith(' ') ? line.slice(10) : line).join('\n');
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'release-source-contract-'));
|
||||
try {
|
||||
const tools = path.join(root, 'tools'); mkdirSync(tools);
|
||||
writeFileSync(path.join(tools, 'git'), '#!/bin/sh\ncase "$1" in\nrev-parse) printf "%s\\n" "$FIXTURE_HEAD";;\nshow) cat "$FIXTURE_SOURCE";;\n*) exit 3;;\nesac\n', { mode: 0o755 });
|
||||
const source = path.join(root, 'release.sh'), output = path.join(root, 'outputs');
|
||||
for (const [contents, required] of [['pnpm build\n', false], ['assemble-daemons "$PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR" "$SOURCE_SHA"\n', true]]) {
|
||||
writeFileSync(source, contents); writeFileSync(output, '');
|
||||
const result = spawnSync('bash', ['-euo', 'pipefail', '-c', script], { encoding: 'utf8', env: {
|
||||
PATH: `${tools}:/usr/bin:/bin`, SOURCE_SHA: 'a'.repeat(40), FIXTURE_HEAD: 'a'.repeat(40), FIXTURE_SOURCE: source, GITHUB_OUTPUT: output,
|
||||
} });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(readFileSync(output, 'utf8'), new RegExp(`required=${required}`));
|
||||
}
|
||||
const result = spawnSync('bash', ['-euo', 'pipefail', '-c', script], { encoding: 'utf8', env: {
|
||||
PATH: `${tools}:/usr/bin:/bin`, SOURCE_SHA: 'a'.repeat(40), FIXTURE_HEAD: 'b'.repeat(40), FIXTURE_SOURCE: source, GITHUB_OUTPUT: output,
|
||||
} });
|
||||
assert.equal(result.status, 1, 'Wrong checkout cannot opt out of mandatory assembly');
|
||||
const unreadable = spawnSync('bash', ['-euo', 'pipefail', '-c', script], { encoding: 'utf8', env: {
|
||||
PATH: `${tools}:/usr/bin:/bin`, SOURCE_SHA: 'a'.repeat(40), FIXTURE_HEAD: 'a'.repeat(40), FIXTURE_SOURCE: path.join(root, 'absent'), GITHUB_OUTPUT: output,
|
||||
} });
|
||||
assert.equal(unreadable.status, 1, 'A failed immutable source read must not masquerade as a legacy source');
|
||||
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
for (const job of ['publish_canary','publish_nightly','publish_beta','preview_stable','publish_stable']) {
|
||||
const block = workflow.split(` ${job}:\n`)[1].split(/\n [a-z_]+:\n/)[0];
|
||||
assert.match(block, /needs.release_runner_source.result == 'success'/);
|
||||
assert.match(block, /needs.release_runner_source.outputs.required == 'false' \|\| needs.release_runner_binaries.result == 'success'/);
|
||||
assert.match(block, /Download exact-source release daemon receipts\n\s+if: needs.release_runner_source.outputs.required == 'true'/);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
test("canary startup refreshes an incomplete dependency publication, then onboards once", async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readdirSync, statSync } from "node:fs";
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { runnerBinaryTarget } from "../packages/paperclip-runner/src/live/runner-binary.ts";
|
||||
|
||||
export function resolveNativeRunnerRequirement({ exitCode, stdout }) {
|
||||
if (exitCode !== 0) {
|
||||
@@ -38,20 +39,41 @@ function newestMtimeMs(target) {
|
||||
return newest;
|
||||
}
|
||||
|
||||
export function resolveNativeRunnerDevBinary({
|
||||
repoRoot,
|
||||
platform = process.platform,
|
||||
architecture = process.arch,
|
||||
}) {
|
||||
const executable = platform === "win32" ? "paperclip-runnerd.exe" : "paperclip-runnerd";
|
||||
const packageRoot = path.join(repoRoot, "packages", "paperclip-runner");
|
||||
const target = path.join(packageRoot, "dist", "bin", `${platform}-${architecture}`, executable);
|
||||
const flat = path.join(packageRoot, "dist", "bin", executable);
|
||||
const targetStat = statSync(target, { throwIfNoEntry: false });
|
||||
const flatStat = statSync(flat, { throwIfNoEntry: false });
|
||||
// Release targets remain authoritative unless a newer source build matches
|
||||
// this host. In particular, a build-host flat artifact cannot mask a remotely
|
||||
// staged Mac target or force a local Cargo build.
|
||||
if (flatStat?.isFile() && (!targetStat?.isFile() || flatStat.mtimeMs > targetStat.mtimeMs)
|
||||
&& runnerBinaryTarget(readFileSync(flat)) === `${platform}-${architecture}`) {
|
||||
return { binary: flat, sourceBuild: true };
|
||||
}
|
||||
return targetStat?.isFile() ? { binary: target, sourceBuild: false } : null;
|
||||
}
|
||||
|
||||
export function paperclipRunnerBinaryNeedsBuild({
|
||||
repoRoot,
|
||||
nativeRunnerRequired,
|
||||
configuredBinary,
|
||||
platform = process.platform,
|
||||
architecture = process.arch,
|
||||
}) {
|
||||
if (!nativeRunnerRequired) return false;
|
||||
if (configuredBinary?.trim()) return false;
|
||||
|
||||
const executable = platform === "win32" ? "paperclip-runnerd.exe" : "paperclip-runnerd";
|
||||
const selected = resolveNativeRunnerDevBinary({ repoRoot, platform, architecture });
|
||||
if (!selected) return true;
|
||||
const binaryStat = statSync(selected.binary);
|
||||
const packageRoot = path.join(repoRoot, "packages", "paperclip-runner");
|
||||
const stagedBinary = path.join(packageRoot, "dist", "bin", executable);
|
||||
const binaryStat = statSync(stagedBinary, { throwIfNoEntry: false });
|
||||
if (!binaryStat?.isFile()) return true;
|
||||
|
||||
const runnerRoot = path.join(packageRoot, "runner");
|
||||
const buildInputs = [
|
||||
|
||||
+25
-17
@@ -8,6 +8,7 @@ import { stdin, stdout } from "node:process";
|
||||
import { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "./dev-runner-output.ts";
|
||||
import {
|
||||
paperclipRunnerBinaryNeedsBuild,
|
||||
resolveNativeRunnerDevBinary,
|
||||
resolveNativeRunnerRequirement,
|
||||
} from "./dev-runner-native-binary.mjs";
|
||||
import { applyDevRunnerOptions } from "./dev-runner-options.ts";
|
||||
@@ -171,6 +172,9 @@ const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
PAPERCLIP_UI_DEV_MIDDLEWARE: explicitUiDevMiddleware ?? (serveBuiltUiForManagedRuntime ? "false" : "true"),
|
||||
};
|
||||
// An automatic source-build override must not hide subsequent Rust edits from
|
||||
// the watcher. Keep the operator's original choice separate from server env.
|
||||
const configuredRunnerBinary = env.PAPERCLIP_RUNNER_BINARY;
|
||||
|
||||
if (mode === "dev") {
|
||||
env.PAPERCLIP_DEV_SERVER_STATUS_FILE = devServerStatusFilePath;
|
||||
@@ -573,28 +577,32 @@ async function buildPaperclipRunner() {
|
||||
process.exit(typescriptResult.code);
|
||||
}
|
||||
|
||||
const nativeRunnerRequired = await getNativeRunnerRequired();
|
||||
if (
|
||||
!paperclipRunnerBinaryNeedsBuild({
|
||||
paperclipRunnerBinaryNeedsBuild({
|
||||
repoRoot,
|
||||
nativeRunnerRequired: await getNativeRunnerRequired(),
|
||||
configuredBinary: env.PAPERCLIP_RUNNER_BINARY,
|
||||
nativeRunnerRequired,
|
||||
configuredBinary: configuredRunnerBinary,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
console.log("[paperclip] building paperclip runner native binary...");
|
||||
const binaryResult = await runPnpm(
|
||||
["--filter", "@paperclipai/paperclip-runner", "build:binary"],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
if (binaryResult.signal) {
|
||||
exitForSignal(binaryResult.signal);
|
||||
return;
|
||||
}
|
||||
if (binaryResult.code !== 0) {
|
||||
console.error("[paperclip] paperclip runner native binary build failed");
|
||||
process.exit(binaryResult.code);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[paperclip] building paperclip runner native binary...");
|
||||
const binaryResult = await runPnpm(
|
||||
["--filter", "@paperclipai/paperclip-runner", "build:binary"],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
if (binaryResult.signal) {
|
||||
exitForSignal(binaryResult.signal);
|
||||
return;
|
||||
}
|
||||
if (binaryResult.code !== 0) {
|
||||
console.error("[paperclip] paperclip runner native binary build failed");
|
||||
process.exit(binaryResult.code);
|
||||
if (!configuredRunnerBinary?.trim()) {
|
||||
const selected = nativeRunnerRequired ? resolveNativeRunnerDevBinary({ repoRoot }) : null;
|
||||
if (selected?.sourceBuild) env.PAPERCLIP_RUNNER_BINARY = selected.binary;
|
||||
else delete env.PAPERCLIP_RUNNER_BINARY;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, realpathSync, statSync } from 'node:fs';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
|
||||
// Keep public-package lifecycle code off the verification host. Resolve and
|
||||
// cache the public npm graph without scripts, then execute it offline.
|
||||
@@ -9,6 +11,31 @@ export const GROK_PUBLIC_INSTALL_LIFECYCLE = [
|
||||
'npm', 'rebuild', '--offline', '--ignore-scripts=false', '--dangerously-allow-all-scripts',
|
||||
];
|
||||
|
||||
// setup-node provides its matching SDK alongside the executing Node binary.
|
||||
// Deferred lifecycle scripts may read that SDK, but may never download headers.
|
||||
export function macNodeHeadersDirectory(nodeExecutable, nodeVersion) {
|
||||
assert.match(nodeVersion, /^\d+\.\d+\.\d+$/, 'Executing Node must have an exact release version');
|
||||
const executable = realpathSync(nodeExecutable);
|
||||
assert.equal(basename(executable), 'node', 'Node SDK must belong to the executing Node binary');
|
||||
assert.ok(statSync(executable).isFile(), 'Executing Node must be a regular file');
|
||||
const prefix = realpathSync(join(dirname(executable), '..'));
|
||||
const include = join(prefix, 'include/node');
|
||||
for (const name of ['node.h', 'node_version.h']) {
|
||||
const header = join(include, name);
|
||||
assert.equal(realpathSync(header), header, 'Node SDK headers must remain inside the executing Node prefix');
|
||||
assert.ok(statSync(header).isFile(), 'Node SDK headers must be regular files');
|
||||
}
|
||||
assert.match(readFileSync(join(include, 'node.h'), 'utf8'), /#\s*include\s+"node_version\.h"/, 'Node SDK must include its version header');
|
||||
const versionHeader = readFileSync(join(include, 'node_version.h'), 'utf8');
|
||||
const versions = ['MAJOR', 'MINOR', 'PATCH'].map(part => {
|
||||
const matches = [...versionHeader.matchAll(new RegExp('^#\\s*define\\s+NODE_' + part + '_VERSION\\s+(\\d+)\\s*$', 'gm'))];
|
||||
assert.equal(matches.length, 1, 'Node SDK must declare one ' + part.toLowerCase() + ' version');
|
||||
return matches[0][1];
|
||||
});
|
||||
assert.equal(versions.join('.'), nodeVersion, 'Node SDK version must match the executing Node');
|
||||
return prefix;
|
||||
}
|
||||
|
||||
// Inspect the actual npm archive, independently of the staging helper.
|
||||
export function assertNoBundledCodexPayloads(entries) {
|
||||
assert.ok(Array.isArray(entries) && entries.length > 0, 'Packed package must have tar members');
|
||||
@@ -60,10 +87,11 @@ export function assertInstalledCodexPlatformProvenance(lock, installed, target)
|
||||
|
||||
// Execute this in the existing offline public consumer, never in the checkout.
|
||||
// The public index and returned command must both come from the installed graph.
|
||||
export function installedCodexProbeSource(indexPath, consumerRoot, expectedVersion) {
|
||||
export function installedCodexProbeSource(indexPath, consumerRoot, expectedVersion, { nativeCodex = false } = {}) {
|
||||
return `
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createRequire } from 'node:module';
|
||||
import { accessSync, constants, existsSync, lstatSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
||||
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||
@@ -118,11 +146,11 @@ export function installedCodexProbeSource(indexPath, consumerRoot, expectedVersi
|
||||
assert.ok(selectedPlatform, 'Codex wrapper must resolve an official host platform package from its installed graph');
|
||||
const selectedMetadata = JSON.parse(readFileSync(selectedPlatformManifest, 'utf8'));
|
||||
assert.deepEqual(selectedMetadata, selectedPlatform.manifest, 'Selected Codex platform metadata must match its scanned provenance');
|
||||
assert.equal(selectedMetadata.version, ${JSON.stringify(expectedVersion)} + '-' + process.platform + '-' + process.arch,
|
||||
'Selected Codex platform version must match the qualified pin');
|
||||
${nativeCodex ? '' : `assert.equal(selectedMetadata.version, ${JSON.stringify(expectedVersion)} + '-' + process.platform + '-' + process.arch,
|
||||
'Selected Codex platform version must match the qualified pin');`}
|
||||
const version = execFileSync(command, ['--version'], { timeout: 30_000, maxBuffer: 128 * 1024, encoding: 'utf8',
|
||||
env: { PATH: '/usr/local/bin:/usr/bin:/bin', HOME: '/tmp', NODE_PATH: '' } }).trim();
|
||||
assert.equal(version, 'codex-cli ' + ${JSON.stringify(expectedVersion)}, 'Installed Codex version must match the qualified pin');
|
||||
env: { PATH: process.env.PATH, HOME: process.env.HOME, NODE_PATH: '' } }).trim();
|
||||
${nativeCodex ? "assert.match(version, /^codex-cli /, 'Installed official Codex command must execute');" : `assert.equal(version, 'codex-cli ' + ${JSON.stringify(expectedVersion)}, 'Installed Codex version must match the qualified pin');`}
|
||||
const vendor = dirname(index);
|
||||
const integrityPath = join(vendor, 'drivers/acpx/installation-integrity.js');
|
||||
const profilesPath = join(vendor, 'drivers/acpx/qualified-profiles.js');
|
||||
@@ -132,26 +160,116 @@ export function installedCodexProbeSource(indexPath, consumerRoot, expectedVersi
|
||||
const platformRoot = realpathSync(dirname(selectedPlatformManifest));
|
||||
const nativeVendor = realpathSync(join(platformRoot, 'vendor'));
|
||||
assert.ok(contained(nativeVendor) && nativeVendor.startsWith(platformRoot + sep), 'Codex vendor resources must remain inside their installed platform package');
|
||||
const triple = { 'linux-x64': 'x86_64-unknown-linux-musl', 'linux-arm64': 'aarch64-unknown-linux-musl',
|
||||
'darwin-x64': 'x86_64-apple-darwin', 'darwin-arm64': 'aarch64-apple-darwin',
|
||||
'win32-x64': 'x86_64-pc-windows-msvc', 'win32-arm64': 'aarch64-pc-windows-msvc' }[process.platform + '-' + process.arch];
|
||||
assert.ok(triple, 'Codex native payload requires a known host target');
|
||||
const nativeName = process.platform === 'win32' ? 'codex.exe' : 'codex';
|
||||
const nativeCandidates = ['bin', 'codex'].map(directory => join(nativeVendor, triple, directory, nativeName)).filter(existsSync);
|
||||
assert.equal(nativeCandidates.length, 1, 'Selected official Codex platform must contain one native executable');
|
||||
const nativeExecutable = realpathSync(nativeCandidates[0]);
|
||||
const nativeStat = lstatSync(nativeCandidates[0]);
|
||||
assert.ok(nativeExecutable.startsWith(nativeVendor + sep) && nativeStat.isFile() && !nativeStat.isSymbolicLink()
|
||||
&& nativeStat.size >= 20 && nativeStat.size <= 512 * 1024 * 1024, 'Codex native executable must be a bounded regular file in its selected platform package');
|
||||
accessSync(nativeExecutable, constants.X_OK);
|
||||
const nativeBytes = readFileSync(nativeExecutable);
|
||||
const { runnerBinaryTarget } = await import(pathToFileURL(join(vendor, 'live/runner-binary.js')).href);
|
||||
assert.equal(runnerBinaryTarget(nativeBytes), process.platform + '-' + process.arch, 'Codex native executable must match the actual host architecture');
|
||||
const sandboxSource = { HOME: '/private-provider-home', PATH: '/usr/local/bin:/usr/bin:/bin' };
|
||||
const readRoots = codexExecutableReadOnlyRoots(sandboxSource, command);
|
||||
assert.ok(readRoots.includes(nativeVendor), 'Selected native Codex vendor directory must be readable inside its sandbox');
|
||||
assert.ok(!readRoots.includes(platformRoot) && !readRoots.includes(root) && !readRoots.includes(sandboxSource.HOME), 'Codex sandbox must not grant enclosing npm or credential-home access');
|
||||
const sandboxArgs = createIsolatedCodexAppServerArgs(sandboxSource, readRoots).join('\\n');
|
||||
assert.ok(sandboxArgs.includes(JSON.stringify(nativeVendor) + '="read"'), 'Codex sandbox configuration must retain the selected native executable resources');
|
||||
const { createAcpxPackageJsonResolver, verifyQualifiedAcpxInstallation } = await import(pathToFileURL(integrityPath).href);
|
||||
${nativeCodex ? '' : `const { createAcpxPackageJsonResolver, verifyQualifiedAcpxInstallation } = await import(pathToFileURL(integrityPath).href);
|
||||
const { resolveQualifiedAcpxProfile } = await import(pathToFileURL(profilesPath).href);
|
||||
const serverRoot = resolve(vendor, '../../..');
|
||||
assert.ok(contained(serverRoot), 'Codex package authority must be the installed server');
|
||||
const installation = await verifyQualifiedAcpxInstallation(resolveQualifiedAcpxProfile('codex', 'gpt-5.4'),
|
||||
createAcpxPackageJsonResolver(serverRoot, join(serverRoot, 'package.json')));
|
||||
const lease = await installation.openCommand();
|
||||
await lease.close();
|
||||
console.log(JSON.stringify({ pinnedCodexCommandVerified: true, pinnedCodexVersion: ${JSON.stringify(expectedVersion)},
|
||||
...provenance, codexSelectedPlatformPath: selectedPlatform.path, codexSelectedPlatformVersion: selectedMetadata.version, codexNativeSandboxResourcesVerified: true, codexQualifiedInstallationVerified: true, codexCommandLeaseVerified: true, providerCalls: 0 }));
|
||||
await lease.close();`}
|
||||
console.log(JSON.stringify({ pinnedCodexCommandVerified: true, installedCodexVersion: version,
|
||||
codexCommandSha256: createHash('sha256').update(readFileSync(command)).digest('hex'),
|
||||
codexNativeExecutableSha256: createHash('sha256').update(nativeBytes).digest('hex'),
|
||||
codexNativeExecutablePath: relative(root, nativeExecutable).split(sep).join('/'),
|
||||
...provenance, codexSelectedPlatformPath: selectedPlatform.path, codexSelectedPlatformVersion: selectedMetadata.version,
|
||||
codexNativeSandboxResourcesVerified: true,
|
||||
${nativeCodex ? '' : `pinnedCodexVersion: ${JSON.stringify(expectedVersion)}, codexQualifiedInstallationVerified: true, codexCommandLeaseVerified: true,`}
|
||||
providerCalls: 0 }));
|
||||
`;
|
||||
}
|
||||
|
||||
export function grokConsumerDockerArgs({ assets, consumer, cache, command, uid, gid, download = false, prerequisite, temporarySizeMiB = 256, temporaryExecutable = false }) {
|
||||
/** Installed bytes and consumer resolution must survive host protocol startup. */
|
||||
export function assertInstalledNativeConsumerContinuity(before, after) {
|
||||
for (const field of ['lockSha256', 'runnerSha256', 'codexCommandSha256', 'codexNativeExecutableSha256']) {
|
||||
if (!/^[a-f0-9]{64}$/.test(before[field]) || before[field] !== after[field]) {
|
||||
throw new Error(`Installed native host startup changed ${field}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The installed consumer must select its packaged host daemon, never PATH. */
|
||||
export function installedDaemonProbeSource(outputRoot, consumerRoot, sourceRevision) {
|
||||
return `
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync, realpathSync } from 'node:fs';
|
||||
import { isAbsolute, join, relative, sep } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
const root = realpathSync(${JSON.stringify(consumerRoot)}), output = realpathSync(${JSON.stringify(outputRoot)});
|
||||
const inside = path => { const value = relative(root, realpathSync(path)); return value !== '' && value !== '..' && !value.startsWith('..' + sep) && !isAbsolute(value); };
|
||||
assert.ok(inside(output));
|
||||
const { defaultCapabilityRunnerdBinary } = await import(pathToFileURL(join(output, 'live/runnerd-codex-transport.js')).href);
|
||||
const { runnerBinaryTarget } = await import(pathToFileURL(join(output, 'live/runner-binary.js')).href);
|
||||
const { parsePaperclipRunnerdBuildMetadata } = await import(pathToFileURL(join(output, 'evals/runnerd-artifact.js')).href);
|
||||
const { PAPERCLIP_RUNNER_BUILD_METADATA: expected } = await import(pathToFileURL(join(output, 'evals/build-metadata.js')).href);
|
||||
const manifest = JSON.parse(readFileSync(join(output, 'bin/release-manifest.json'), 'utf8'));
|
||||
const targets = ['darwin-arm64', 'darwin-x64', 'linux-x64', 'linux-arm64'];
|
||||
assert.equal(manifest.schema, 'paperclip.runner.release-binaries.v1');
|
||||
assert.equal(manifest.sourceRevision, ${JSON.stringify(sourceRevision)});
|
||||
assert.deepEqual(Object.keys(manifest.platforms).sort(), targets.sort());
|
||||
for (const target of targets) {
|
||||
const record = manifest.platforms[target];
|
||||
assert.equal(record.path, target + '/paperclip-runnerd');
|
||||
const file = join(output, 'bin', record.path); assert.ok(inside(file));
|
||||
const bytes = readFileSync(file);
|
||||
assert.equal(runnerBinaryTarget(bytes), target);
|
||||
assert.equal('sha256:' + createHash('sha256').update(bytes).digest('hex'), record.sha256);
|
||||
}
|
||||
const target = process.platform + '-' + process.arch, selected = defaultCapabilityRunnerdBinary();
|
||||
assert.ok(inside(selected));
|
||||
assert.equal(realpathSync(selected), realpathSync(join(output, 'bin', manifest.platforms[target].path)), 'Production must select the packaged host daemon');
|
||||
const metadata = parsePaperclipRunnerdBuildMetadata(JSON.parse(execFileSync(selected, ['--build-metadata'], {
|
||||
timeout: 15000, maxBuffer: 64 * 1024, encoding: 'utf8', env: { PATH: process.env.PATH, HOME: process.env.HOME, NODE_PATH: '' },
|
||||
})));
|
||||
assert.equal(metadata.binaryContractVersion, expected.contracts.runnerdArtifact);
|
||||
assert.equal(metadata.nativeExecutionVersion, expected.contracts.nativeExecution);
|
||||
assert.equal(metadata.harnessDriverVersion, expected.contracts.harnessDriver);
|
||||
assert.equal(metadata.prp.name, expected.prp.name);
|
||||
assert.ok(metadata.prp.minimumVersion <= expected.prp.maximumVersion && metadata.prp.maximumVersion >= expected.prp.minimumVersion);
|
||||
console.log(JSON.stringify({ installedRunnerDaemonVerified: true, target, sourceRevision: manifest.sourceRevision,
|
||||
sha256: manifest.platforms[target].sha256, buildMetadata: metadata, providerCalls: 0 }));
|
||||
`;
|
||||
}
|
||||
|
||||
// Hosted, credential-free Mac consumers defer only npm lifecycle execution.
|
||||
// Offline npm flags are not an OS network boundary; sandbox-exec supplies it.
|
||||
export function macPublicInstallLifecycleProfile(ownedRoot) {
|
||||
if (!ownedRoot.startsWith('/') || ownedRoot.includes('\0')) throw new Error('Mac lifecycle requires an absolute owned root');
|
||||
return `(version 1)\n(allow default)\n(deny network*)\n(deny file-write* (require-all (require-not (subpath ${JSON.stringify(ownedRoot)})) (require-not (literal "/dev/null"))))\n`;
|
||||
}
|
||||
|
||||
export function macInstalledStartupProbeSource(consumerRoot, serverRoot, runnerDigest, helperPath) {
|
||||
if (!/^sha256:[a-f0-9]{64}$/.test(runnerDigest)) throw new Error('Installed Mac startup requires the verified SHA-256 manifest identity');
|
||||
return `import { probeInstalledCodexStartup } from ${JSON.stringify(helperPath)};
|
||||
console.log(JSON.stringify(await probeInstalledCodexStartup({consumerRoot:${JSON.stringify(consumerRoot)},
|
||||
serverRoot:${JSON.stringify(serverRoot)},expectedRunnerSha256:${JSON.stringify(runnerDigest.slice(7))}})));`;
|
||||
}
|
||||
|
||||
export function grokConsumerDockerArgs({ assets, consumer, cache, command, uid, gid, download = false, prerequisite, temporarySizeMiB = 256, temporaryExecutable = false, platform = 'linux/amd64' }) {
|
||||
if (!['linux/amd64', 'linux/arm64'].includes(platform)) throw new Error('Public-install verification requires a supported native Linux platform');
|
||||
if (!Number.isSafeInteger(uid) || uid <= 0 || !Number.isSafeInteger(gid) || gid <= 0) {
|
||||
throw new Error('Public-install verification requires an unprivileged host user');
|
||||
}
|
||||
@@ -162,7 +280,7 @@ export function grokConsumerDockerArgs({ assets, consumer, cache, command, uid,
|
||||
throw new Error('Executable runtime snapshots require an offline verification sandbox');
|
||||
}
|
||||
return [
|
||||
'run', '--rm', '--platform', 'linux/amd64',
|
||||
'run', '--rm', '--platform', platform,
|
||||
'--user', `${uid}:${gid}`, '--read-only',
|
||||
'--cap-drop', 'ALL', '--security-opt', 'no-new-privileges',
|
||||
'--pids-limit', '256', '--memory', '3g',
|
||||
|
||||
@@ -2,10 +2,44 @@
|
||||
// Run in a credential-free public npm consumer after `paperclipai runtime setup pi`.
|
||||
// Use the server's normal packaged daemon resolver; never submit a prompt.
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, mkdtemp, readFile, realpath, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
export async function verifyPiPackagedDaemon(server, daemon, target, runnerBinaryTarget) {
|
||||
assert.match(target, /^(?:linux|darwin)-(?:x64|arm64)$/);
|
||||
const output = join(server, 'dist/vendor/paperclip-runner'), manifestPath = join(output, 'bin/release-manifest.json');
|
||||
let manifest;
|
||||
try { manifest = JSON.parse(await readFile(manifestPath, 'utf8')); }
|
||||
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
||||
let expectedPath = join(output, 'bin/paperclip-runnerd');
|
||||
if (manifest !== undefined) {
|
||||
assert.ok(manifest && typeof manifest === 'object' && !Array.isArray(manifest), 'Release manifest must be an object');
|
||||
assert.equal(await realpath(manifestPath), manifestPath, 'Release manifest must remain inside the installed package');
|
||||
assert.equal(manifest.schema, 'paperclip.runner.release-binaries.v1');
|
||||
assert.match(manifest.sourceRevision, /^[a-f0-9]{40}$/);
|
||||
const build = JSON.parse(await readFile(join(server, 'dist/build-info.json'), 'utf8'));
|
||||
assert.equal(manifest.sourceRevision, build.commit, 'Packaged daemon source must match the installed server build');
|
||||
const artifact = manifest.platforms?.[target];
|
||||
assert.equal(artifact?.path, `${target}/paperclip-runnerd`, 'Packaged daemon must use the known host target path');
|
||||
assert.match(artifact.sha256, /^sha256:[a-f0-9]{64}$/);
|
||||
expectedPath = join(output, 'bin', artifact.path);
|
||||
assert.equal(await realpath(daemon), expectedPath, 'Production must select the installed host daemon');
|
||||
assert.equal(await realpath(expectedPath), expectedPath, 'Packaged daemon cannot escape through a symlink');
|
||||
const bytes = await readFile(expectedPath);
|
||||
assert.equal(`sha256:${createHash('sha256').update(bytes).digest('hex')}`, artifact.sha256);
|
||||
assert.equal(runnerBinaryTarget(bytes), target);
|
||||
} else {
|
||||
// Existing host-only source packages have no multi-target release manifest.
|
||||
// They must still use their exact normal flat daemon, never an override.
|
||||
assert.equal(await realpath(daemon), expectedPath);
|
||||
assert.equal(runnerBinaryTarget(await readFile(expectedPath)), target);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
|
||||
const [serverDirectory, ...extra] = process.argv.slice(2);
|
||||
assert.ok(serverDirectory && extra.length === 0, 'Usage: pi-public-install-probe.mjs INSTALLED_SERVER_ROOT');
|
||||
@@ -25,7 +59,8 @@ assert.equal(QUALIFIED_ACPX_PROFILES.pi.commandDigest, 'sha256:e92078bee3c23bec4
|
||||
assert.equal(Object.hasOwn(QUALIFIED_ACPX_PROFILES.pi, 'qualificationModel'), false);
|
||||
assert.equal(Object.hasOwn(QUALIFIED_ACPX_PROFILES.pi, 'reportedModelId'), false);
|
||||
const daemon = resolvePaperclipRunnerBinary();
|
||||
assert.equal(await realpath(daemon), join(server, 'dist/vendor/paperclip-runner/bin/paperclip-runnerd'));
|
||||
const { runnerBinaryTarget } = await import(pathToFileURL(join(server, 'dist/vendor/paperclip-runner/live/runner-binary.js')));
|
||||
await verifyPiPackagedDaemon(server, daemon, `${process.platform}-${process.arch}`, runnerBinaryTarget);
|
||||
const root = await mkdtemp(join(tmpdir(), 'pi-public-install-probe-'));
|
||||
const evidence = [];
|
||||
const started = performance.now();
|
||||
@@ -69,3 +104,5 @@ try {
|
||||
try { await bundle?.transport.close(); }
|
||||
finally { clearTimeout(watchdog); await rm(root, { recursive: true, force: true }); }
|
||||
}
|
||||
}
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
|
||||
Executable
+142
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env bash
|
||||
# Prepare the disposable GitHub ARM qualification host using stock distro files.
|
||||
# Extraction skips package maintainer scripts and their global policy changes.
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${GITHUB_ACTIONS:-}" != true || "${RUNNER_OS:-}" != Linux ]]; then
|
||||
printf 'Linux ARM qualification preparation requires a GitHub Actions Linux runner.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
. /etc/os-release
|
||||
test "$ID" = ubuntu
|
||||
test "$VERSION_ID" = 24.04
|
||||
test "$(dpkg --print-architecture)" = arm64
|
||||
test "$(uname -m)" = aarch64
|
||||
prerequisite_report_dir=${RUNNER_TEMP:?}/paperclip-arm-bwrap-prerequisite
|
||||
mkdir -p "$prerequisite_report_dir"
|
||||
prerequisite_download_dir=$(mktemp -d "${RUNNER_TEMP}/paperclip-arm-stock-packages.XXXXXX")
|
||||
before_userns=$(sysctl -n kernel.apparmor_restrict_unprivileged_userns)
|
||||
before_apparmor=$(cat /sys/module/apparmor/parameters/enabled)
|
||||
before_clone=$(sysctl -n kernel.unprivileged_userns_clone)
|
||||
test "$before_userns" = 1
|
||||
test "$before_apparmor" = Y
|
||||
test "$before_clone" = 1
|
||||
printf 'restriction=%s apparmor=%s clone=%s\n' \
|
||||
"$before_userns" "$before_apparmor" "$before_clone" | \
|
||||
tee "$prerequisite_report_dir/before.txt"
|
||||
|
||||
parser=$(readlink -f "$(command -v apparmor_parser)")
|
||||
test "$parser" = /usr/sbin/apparmor_parser
|
||||
test "$(stat -c '%u' "$parser")" = 0
|
||||
dpkg-query -W -f='${Package} ${Version} ${Architecture}\n' \
|
||||
apparmor libc6 libcap2 libselinux1 | \
|
||||
tee "$prerequisite_report_dir/existing-package-versions.txt"
|
||||
dpkg --compare-versions "$(dpkg-query -W -f='${Version}' libc6)" ge 2.38
|
||||
dpkg --compare-versions "$(dpkg-query -W -f='${Version}' libcap2)" ge 1:2.10
|
||||
dpkg --compare-versions "$(dpkg-query -W -f='${Version}' libselinux1)" ge 3.1~
|
||||
|
||||
timeout 120s sudo apt-get \
|
||||
-o Acquire::AllowInsecureRepositories=false \
|
||||
-o APT::Get::AllowUnauthenticated=false update | \
|
||||
tee "$prerequisite_report_dir/apt-update.txt"
|
||||
apt-cache policy bubblewrap apparmor-profiles | \
|
||||
tee "$prerequisite_report_dir/apt-policy.txt"
|
||||
for package_name in bubblewrap apparmor-profiles; do
|
||||
package_version=$(apt-cache policy "$package_name" | awk '/Candidate:/ {print $2}')
|
||||
test -n "$package_version"
|
||||
test "$package_version" != '(none)'
|
||||
apt-cache show "$package_name=$package_version" > \
|
||||
"$prerequisite_report_dir/$package_name-index.txt"
|
||||
package_sha256=$(awk '/^SHA256:/ {print $2}' \
|
||||
"$prerequisite_report_dir/$package_name-index.txt")
|
||||
test "${#package_sha256}" = 64
|
||||
printf '%s\n' "$package_sha256" | grep -E '^[0-9a-f]{64}$' >/dev/null
|
||||
(
|
||||
cd "$prerequisite_download_dir"
|
||||
timeout 60s apt-get -o APT::Get::AllowUnauthenticated=false \
|
||||
download "$package_name=$package_version"
|
||||
)
|
||||
package_files=("$prerequisite_download_dir"/"$package_name"_*.deb)
|
||||
test "${#package_files[@]}" = 1
|
||||
package_file=${package_files[0]}
|
||||
test -f "$package_file"
|
||||
printf '%s %s\n' "$package_sha256" "$package_file" | sha256sum -c -
|
||||
test "$(dpkg-deb -f "$package_file" Package)" = "$package_name"
|
||||
test "$(dpkg-deb -f "$package_file" Version)" = "$package_version"
|
||||
dpkg-deb -f "$package_file" Package Version Architecture | \
|
||||
tee "$prerequisite_report_dir/$package_name-downloaded-version.txt"
|
||||
if test "$package_name" = bubblewrap; then
|
||||
test "$(dpkg-deb -f "$package_file" Architecture)" = arm64
|
||||
dpkg-deb --fsys-tarfile "$package_file" | \
|
||||
tar -xO ./usr/bin/bwrap > "$prerequisite_download_dir/bwrap"
|
||||
else
|
||||
test "$(dpkg-deb -f "$package_file" Architecture)" = all
|
||||
dpkg-deb --fsys-tarfile "$package_file" | \
|
||||
tar -xO ./usr/share/apparmor/extra-profiles/bwrap-userns-restrict > \
|
||||
"$prerequisite_download_dir/bwrap-userns-restrict"
|
||||
fi
|
||||
done
|
||||
|
||||
stock_binary=$prerequisite_download_dir/bwrap
|
||||
stock_profile=$prerequisite_download_dir/bwrap-userns-restrict
|
||||
printf '11d39094f044f0cda0febb3ad517b830301da6b2ce929664af09ee9e4dd264f9 %s\n' \
|
||||
"$stock_profile" | sha256sum -c -
|
||||
cat "$stock_profile" | tee "$prerequisite_report_dir/stock-profile.txt"
|
||||
test "$(od -An -tx1 -N4 "$stock_binary" | tr -d ' \n')" = 7f454c46
|
||||
readelf -h "$stock_binary" | tee "$prerequisite_report_dir/bwrap-elf-header.txt"
|
||||
grep -E 'Machine:.*AArch64' "$prerequisite_report_dir/bwrap-elf-header.txt"
|
||||
|
||||
# Reject every target symlink, including dangling links, before privileged copy.
|
||||
test ! -L /usr/bin/bwrap
|
||||
if test -e /usr/bin/bwrap; then
|
||||
test -f /usr/bin/bwrap
|
||||
cmp "$stock_binary" /usr/bin/bwrap
|
||||
else
|
||||
sudo install -o root -g root -m 0755 "$stock_binary" /usr/bin/bwrap
|
||||
fi
|
||||
test "$(readlink -f "$(command -v bwrap)")" = /usr/bin/bwrap
|
||||
test "$(stat -c '%u:%g:%a' /usr/bin/bwrap)" = 0:0:755
|
||||
command -v getcap >/dev/null
|
||||
test -z "$(getcap /usr/bin/bwrap)"
|
||||
sha256sum "$stock_binary" /usr/bin/bwrap | \
|
||||
tee "$prerequisite_report_dir/bwrap-sha256.txt"
|
||||
timeout 5s /usr/bin/bwrap --version | tee "$prerequisite_report_dir/bwrap-version.txt"
|
||||
help=$(timeout 5s /usr/bin/bwrap --help)
|
||||
printf '%s\n' "$help" > "$prerequisite_report_dir/bwrap-help.txt"
|
||||
printf '%s\n' "$help" | grep -F -- --as-pid-1 >/dev/null
|
||||
printf '%s\n' "$help" | grep -F -- --perms >/dev/null
|
||||
|
||||
target=/etc/apparmor.d/bwrap-userns-restrict
|
||||
test ! -L "$target"
|
||||
test ! -L /etc/apparmor.d/local/bwrap-userns-restrict
|
||||
test ! -L /etc/apparmor.d/local/unpriv_bwrap
|
||||
test ! -s /etc/apparmor.d/local/bwrap-userns-restrict
|
||||
test ! -s /etc/apparmor.d/local/unpriv_bwrap
|
||||
if test -e "$target"; then
|
||||
test -f "$target"
|
||||
cmp "$stock_profile" "$target"
|
||||
else
|
||||
sudo install -o root -g root -m 0644 "$stock_profile" "$target"
|
||||
fi
|
||||
timeout 15s sudo "$parser" -r "$target"
|
||||
sha256sum "$stock_profile" "$target" | \
|
||||
tee "$prerequisite_report_dir/profile-sha256.txt"
|
||||
loaded_profiles=$(sudo cat /sys/kernel/security/apparmor/profiles)
|
||||
for required_profile in 'bwrap (enforce)' 'unpriv_bwrap (enforce)'; do
|
||||
printf '%s\n' "$loaded_profiles" | grep -Fx "$required_profile" | \
|
||||
tee -a "$prerequisite_report_dir/loaded-profiles.txt"
|
||||
done
|
||||
after_userns=$(sysctl -n kernel.apparmor_restrict_unprivileged_userns)
|
||||
after_apparmor=$(cat /sys/module/apparmor/parameters/enabled)
|
||||
after_clone=$(sysctl -n kernel.unprivileged_userns_clone)
|
||||
test "$after_userns" = "$before_userns"
|
||||
test "$after_apparmor" = "$before_apparmor"
|
||||
test "$after_clone" = "$before_clone"
|
||||
printf 'restriction=%s apparmor=%s clone=%s\n' \
|
||||
"$after_userns" "$after_apparmor" "$after_clone" | \
|
||||
tee "$prerequisite_report_dir/after.txt"
|
||||
timeout 5s /usr/bin/bwrap --die-with-parent --unshare-user --unshare-net \
|
||||
--ro-bind / / --cap-drop ALL /bin/true
|
||||
printf 'stock files materialized without dpkg registration or maintainer scripts; namespace probe passed; no provider processes or turns\n' | \
|
||||
tee "$prerequisite_report_dir/namespace-probe.txt"
|
||||
@@ -284,6 +284,16 @@ release_info ""
|
||||
release_info "==> Step 2/7: Building workspace artifacts..."
|
||||
cd "$REPO_ROOT"
|
||||
pnpm build
|
||||
# Hosted producers supply data-only, hash/source/architecture-bound daemon
|
||||
# receipts. Assemble after the build (which stages its host binary), before
|
||||
# public packages are packed. The server vendors this same compiled runner.
|
||||
if [ -n "${PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR:-}" ]; then
|
||||
node "$REPO_ROOT/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs" \
|
||||
assemble-daemons "$PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR" "$(git rev-parse HEAD)"
|
||||
cp -R "$REPO_ROOT/packages/paperclip-runner/dist/bin/." "$REPO_ROOT/server/dist/vendor/paperclip-runner/bin/"
|
||||
elif [ "$dry_run" = false ]; then
|
||||
release_fail "Public releases require the four hosted runner daemon artifacts; use the supported Release workflow."
|
||||
fi
|
||||
node "$REPO_ROOT/scripts/build-standalone-public-packages.mjs"
|
||||
bash "$REPO_ROOT/scripts/prepare-server-ui-dist.sh"
|
||||
for pkg_dir in server packages/adapters/claude-local packages/adapters/codex-local; do
|
||||
|
||||
@@ -4,17 +4,200 @@
|
||||
// explicit and separate from npm installation, and is removed in finally.
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createServer } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { stripTypeScriptTypes } from 'node:module';
|
||||
import { basename, dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { packConcurrency, packStagedPackages, packTimings } from './pack-staged-packages.mjs';
|
||||
import { materializePublishManifest } from './prepare-bundled-package.mjs';
|
||||
import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedCodexProbeSource } from './grok-public-install-sandbox.mjs';
|
||||
import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertInstalledNativeConsumerContinuity, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedCodexProbeSource, installedDaemonProbeSource, macNodeHeadersDirectory, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource } from './grok-public-install-sandbox.mjs';
|
||||
import { startPrefetch, stopPrefetches } from './prefetch-child.mjs';
|
||||
import { retainRunnerQualificationPackages } from './retain-runner-qualification-packages.mjs';
|
||||
import { bundledInstallTimings, startBundledInstalls } from './stage-bundled-packages.mjs';
|
||||
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
async function verifyMacConsumer(packageDirectory, sourceRevision, receiptPath) {
|
||||
assert.equal(process.env.GITHUB_ACTIONS, 'true', 'Mac clean consumers run only on disposable hosted CI');
|
||||
assert.equal(process.platform, 'darwin');
|
||||
assert.ok(['arm64', 'x64'].includes(process.arch));
|
||||
assert.match(sourceRevision, /^[a-f0-9]{40}$/);
|
||||
const packages = realpathSync(packageDirectory);
|
||||
const manifest = JSON.parse(readFileSync(join(packages, 'manifest.json'), 'utf8'));
|
||||
assert.equal(manifest.schema, 'paperclip.runner.public-packages/v1');
|
||||
assert.equal(manifest.sourceRevision, sourceRevision);
|
||||
assert.ok(manifest.archives.some(entry => entry.name === '@paperclipai/server'));
|
||||
assert.ok(manifest.archives.some(entry => entry.name === 'paperclipai'));
|
||||
assert.equal(new Set(manifest.archives.map(entry => entry.name)).size, manifest.archives.length);
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), 'paperclip-mac-public-install-')));
|
||||
try {
|
||||
const consumer = join(root, 'consumer'), home = join(root, 'home'), tools = join(root, 'tools');
|
||||
for (const directory of [consumer, home, tools, join(root, 'tmp'), join(root, 'cache')]) mkdirSync(directory);
|
||||
const npmCli = realpathSync(execFileSync('/usr/bin/which', ['npm'], { encoding: 'utf8' }).trim());
|
||||
symlinkSync(process.execPath, join(tools, 'node'));
|
||||
// npm subprocesses get a Node-only owned tool directory; no ambient harness
|
||||
// command can hide missing installed dependencies.
|
||||
const env = { PATH: `${tools}:/usr/bin:/bin:/usr/sbin:/sbin`, HOME: home, TMPDIR: join(root, 'tmp'),
|
||||
npm_config_cache: join(root, 'cache'), npm_config_audit: 'false', npm_config_fund: 'false' };
|
||||
const run = (command, args, cwd = consumer, runEnv = env) => execFileSync(command, args, { cwd, env: runEnv,
|
||||
timeout: 10 * 60_000, maxBuffer: 32 * 1024 * 1024, encoding: 'utf8' });
|
||||
const tarballs = manifest.archives.map(entry => {
|
||||
assert.match(entry.file, /^[A-Za-z0-9._-]+\.tgz$/); assert.match(entry.sha256, /^[a-f0-9]{64}$/);
|
||||
const path = join(packages, entry.file);
|
||||
assert.equal(realpathSync(path), path);
|
||||
assert.equal(createHash('sha256').update(readFileSync(path)).digest('hex'), entry.sha256);
|
||||
assertNoBundledCodexPayloads(run('/usr/bin/tar', ['-tzf', path]).trim().split('\n'));
|
||||
return path;
|
||||
});
|
||||
writeFileSync(join(consumer, 'package.json'), JSON.stringify({ private: true, type: 'module' }));
|
||||
const sentinelSource = join(root, 'lifecycle-sentinel'); mkdirSync(sentinelSource);
|
||||
writeFileSync(join(sentinelSource, 'package.json'), JSON.stringify({ name: 'paperclip-verification-lifecycle-sentinel', version: '1.0.0',
|
||||
scripts: { postinstall: 'node -e "require(\'node:fs\').writeFileSync(\'lifecycle-ran\', \'ok\')"' } }));
|
||||
run(process.execPath, [npmCli, 'pack', '--ignore-scripts', '--pack-destination', root], sentinelSource);
|
||||
const sentinelTar = join(root, readdirSync(root).find(name => name.endsWith('.tgz')));
|
||||
run(process.execPath, [npmCli, 'install', '--ignore-scripts', '--omit=dev', ...tarballs, sentinelTar]);
|
||||
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');
|
||||
const profile = join(root, 'lifecycle.sb'); writeFileSync(profile, macPublicInstallLifecycleProfile(root));
|
||||
const listener = createServer(socket => socket.end());
|
||||
await new Promise((resolve, reject) => { listener.once('error', reject); listener.listen(0, '127.0.0.1', resolve); });
|
||||
try {
|
||||
const port = listener.address().port;
|
||||
const networkProbe = `import assert from 'node:assert/strict'; import net from 'node:net';
|
||||
const socket = net.connect({host:'127.0.0.1',port:${port}}); socket.setTimeout(3000,()=>process.exit(2));
|
||||
socket.once('connect',()=>{socket.destroy(); ${"process.argv[1] === 'denied' ? process.exit(3) : process.exit(0)"};});
|
||||
socket.once('error',error=>{assert.equal(process.argv[1],'denied'); assert.equal(error.code,'EPERM');});`;
|
||||
run(process.execPath, ['--input-type=module', '-e', networkProbe, 'allowed']);
|
||||
run('/usr/bin/sandbox-exec', ['-f', profile, process.execPath, '--input-type=module', '-e', networkProbe, 'denied']);
|
||||
} finally { await new Promise(resolve => listener.close(resolve)); }
|
||||
const nodeHeaders = macNodeHeadersDirectory(process.execPath, process.versions.node);
|
||||
run('/usr/bin/sandbox-exec', ['-f', profile, process.execPath, npmCli, 'rebuild', '--offline', '--ignore-scripts=false', '--dangerously-allow-all-scripts', '--foreground-scripts'],
|
||||
consumer, { ...env, npm_config_nodedir: nodeHeaders });
|
||||
assert.equal(readFileSync(sentinel, 'utf8'), 'ok');
|
||||
assert.equal(readFileSync(join(consumer, 'package-lock.json'), 'utf8'), lock);
|
||||
for (const entry of manifest.archives) {
|
||||
assert.equal(JSON.parse(readFileSync(join(consumer, 'node_modules', entry.name, 'package.json'))).version, manifest.releaseVersion);
|
||||
}
|
||||
const server = join(consumer, 'node_modules/@paperclipai/server'), installed = join(server, 'dist/vendor/paperclip-runner');
|
||||
const probe = (name, source) => { const file = join(root, name); writeFileSync(file, source); return JSON.parse(run(process.execPath, [file]).trim()); };
|
||||
const daemon = probe('daemon-probe.mjs', installedDaemonProbeSource(installed, consumer, sourceRevision));
|
||||
const codex = probe('codex-probe.mjs', installedCodexProbeSource(join(installed, 'index.js'), consumer, undefined, { nativeCodex: true }));
|
||||
const startupScript = macInstalledStartupProbeSource(consumer, server, daemon.sha256, join(repo, 'tests/runner-e2e/installed-cli.ts'));
|
||||
const startupFile = join(root, 'startup-probe.mts'); writeFileSync(startupFile, startupScript);
|
||||
const startup = JSON.parse(run(process.execPath, ['--import', 'tsx', startupFile], join(repo, 'server')).trim());
|
||||
const receipt = { schema: 'paperclip.runner.mac-public-install.v1', sourceRevision, platform: process.platform, architecture: process.arch,
|
||||
cleanNpmInstall: true, packageCount: manifest.archives.length, codexPackedPayloadsOmitted: true,
|
||||
consumerLockPreserved: true, lifecycleSentinelVerified: true, lifecycleNodeHeadersVerified: true, lifecycleNodeVersion: process.versions.node,
|
||||
lifecycleNetwork: 'sandbox-exec denied (loopback negative control)',
|
||||
...daemon, ...codex, startup, providerCalls: 0 };
|
||||
writeFileSync(receiptPath, JSON.stringify(receipt, null, 2) + '\n');
|
||||
console.log(JSON.stringify(receipt));
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
}
|
||||
async function verifyLinuxArmConsumer(packageDirectory, sourceRevision, receiptPath) {
|
||||
assert.equal(process.env.GITHUB_ACTIONS, 'true', 'Native clean consumers run only on disposable hosted CI');
|
||||
assert.equal(process.platform + '-' + process.arch, 'linux-arm64', 'ARM qualification requires a native ARM host');
|
||||
assert.equal(process.versions.node.split('.')[0], '24', 'ARM host startup requires the qualified Node24 runtime');
|
||||
assert.match(sourceRevision, /^[a-f0-9]{40}$/);
|
||||
const packages = realpathSync(packageDirectory);
|
||||
const manifest = JSON.parse(readFileSync(join(packages, 'manifest.json'), 'utf8'));
|
||||
assert.equal(manifest.schema, 'paperclip.runner.public-packages/v1');
|
||||
assert.equal(manifest.sourceRevision, sourceRevision);
|
||||
assert.ok(manifest.archives.some(entry => entry.name === '@paperclipai/server'));
|
||||
assert.ok(manifest.archives.some(entry => entry.name === 'paperclipai'));
|
||||
assert.equal(new Set(manifest.archives.map(entry => entry.name)).size, manifest.archives.length);
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), 'paperclip-arm-public-install-')));
|
||||
try {
|
||||
const assets = join(root, 'assets'), consumer = join(root, 'consumer'), cache = join(root, 'cache');
|
||||
for (const directory of [assets, consumer, cache]) mkdirSync(directory);
|
||||
const run = (command, args, cwd = root) => execFileSync(command, args, { cwd,
|
||||
env: { PATH: process.env.PATH, HOME: root, TMPDIR: root }, timeout: 10 * 60_000,
|
||||
maxBuffer: 32 * 1024 * 1024, encoding: 'utf8' });
|
||||
const platform = 'linux/arm64';
|
||||
run('docker', ['pull', '--platform', platform, GROK_PUBLIC_INSTALL_IMAGE]);
|
||||
const isolated = (command, options = {}) => run('docker', grokConsumerDockerArgs({ assets, consumer, cache,
|
||||
uid: process.getuid(), gid: process.getgid(), platform, command, ...options }));
|
||||
for (const entry of manifest.archives) {
|
||||
assert.match(entry.file, /^[A-Za-z0-9._-]+\.tgz$/); assert.match(entry.sha256, /^[a-f0-9]{64}$/);
|
||||
const tarball = join(packages, entry.file);
|
||||
assert.equal(realpathSync(tarball), tarball);
|
||||
assert.equal(createHash('sha256').update(readFileSync(tarball)).digest('hex'), entry.sha256);
|
||||
assertNoBundledCodexPayloads(run('tar', ['-tzf', tarball]).trim().split('\n'));
|
||||
cpSync(tarball, join(assets, entry.file)); chmodSync(join(assets, entry.file), 0o644);
|
||||
}
|
||||
writeFileSync(join(consumer, 'package.json'), JSON.stringify({ private: true, type: 'module' }));
|
||||
const sentinelSource = join(root, 'lifecycle-sentinel'); mkdirSync(sentinelSource);
|
||||
writeFileSync(join(sentinelSource, 'package.json'), JSON.stringify({ name: 'paperclip-verification-lifecycle-sentinel', version: '1.0.0',
|
||||
scripts: { postinstall: 'node -e "require(\'node:fs\').writeFileSync(\'lifecycle-ran\', \'ok\')"' } }));
|
||||
run('npm', ['pack', '--ignore-scripts', '--pack-destination', assets], sentinelSource);
|
||||
isolated(['npm', 'install', '--ignore-scripts', '--omit=dev', ...readdirSync(assets).filter(file => file.endsWith('.tgz')).map(file => `/packages/${file}`)], { 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');
|
||||
isolated(GROK_PUBLIC_INSTALL_LIFECYCLE);
|
||||
assert.equal(readFileSync(sentinel, 'utf8'), 'ok');
|
||||
assert.equal(readFileSync(join(consumer, 'package-lock.json'), 'utf8'), lock);
|
||||
for (const entry of manifest.archives) {
|
||||
assert.equal(JSON.parse(readFileSync(join(consumer, 'node_modules', entry.name, 'package.json'))).version, manifest.releaseVersion);
|
||||
}
|
||||
const server = '/consumer/node_modules/@paperclipai/server', installed = server + '/dist/vendor/paperclip-runner';
|
||||
const probe = (name, source, options = {}) => { writeFileSync(join(assets, name), source); return JSON.parse(isolated(['node', '/packages/' + name], options).trim()); };
|
||||
const daemon = probe('daemon-probe.mjs', installedDaemonProbeSource(installed, '/consumer', sourceRevision));
|
||||
assert.equal(daemon.target, 'linux-arm64');
|
||||
const codex = probe('codex-probe.mjs', installedCodexProbeSource(installed + '/index.js', '/consumer', undefined, { nativeCodex: true }));
|
||||
// Reuse the existing zero-turn hook as read-only verifier source. All
|
||||
// production imports still resolve from the installed consumer graph.
|
||||
cpSync(join(repo, 'tests/runner-e2e/installed-cli.ts'), join(assets, 'installed-cli.ts'));
|
||||
writeFileSync(join(assets, 'types.js'), stripTypeScriptTypes(readFileSync(join(repo, 'tests/runner-e2e/types.ts'), 'utf8')));
|
||||
// The restricted install container deliberately forbids nested namespaces.
|
||||
// Keep its lifecycle/no-network proof, then test the exact same bind-mounted
|
||||
// graph on the native host using Codex's normal read-only namespace policy.
|
||||
const home = join(root, 'host-home'), hostTmp = join(root, 'host-tmp');
|
||||
for (const directory of [home, hostTmp]) mkdirSync(directory, { mode: 0o700 });
|
||||
const hostEnv = { PATH: `${dirname(process.execPath)}:/usr/bin:/bin:/usr/sbin:/sbin`, HOME: home, TMPDIR: hostTmp, LC_ALL: 'C', TZ: 'UTC' };
|
||||
const hostProbe = (name, source) => {
|
||||
const file = join(assets, name); writeFileSync(file, source);
|
||||
return JSON.parse(execFileSync(process.execPath, [file], { cwd: consumer, env: hostEnv,
|
||||
timeout: 120_000, maxBuffer: 1024 * 1024, encoding: 'utf8' }).trim());
|
||||
};
|
||||
const hostServer = join(consumer, 'node_modules/@paperclipai/server'), hostInstalled = join(hostServer, 'dist/vendor/paperclip-runner');
|
||||
const hostDaemon = () => hostProbe('host-daemon-probe.mjs', installedDaemonProbeSource(hostInstalled, consumer, sourceRevision));
|
||||
const hostCodex = () => hostProbe('host-codex-probe.mjs', installedCodexProbeSource(join(hostInstalled, 'index.js'), consumer, undefined, { nativeCodex: true }));
|
||||
const digest = value => createHash('sha256').update(value).digest('hex');
|
||||
const beforeCodex = hostCodex();
|
||||
const before = { lockSha256: digest(lock), runnerSha256: hostDaemon().sha256.slice(7), codexCommandSha256: beforeCodex.codexCommandSha256, codexNativeExecutableSha256: beforeCodex.codexNativeExecutableSha256 };
|
||||
assert.equal(before.runnerSha256, daemon.sha256.slice(7), 'Docker and host must select the same packaged daemon bytes');
|
||||
assert.equal(before.codexCommandSha256, codex.codexCommandSha256, 'Docker and host must select the same official Codex command bytes');
|
||||
assert.equal(before.codexNativeExecutableSha256, codex.codexNativeExecutableSha256, 'Docker and host must select the same official native Codex payload bytes');
|
||||
const hostHeader = process.report.getReport().header;
|
||||
assert.match(hostHeader.glibcVersionRuntime || '', /^\d+\.\d+$/, 'Native host startup must report its actual GNU libc ABI');
|
||||
const startup = hostProbe('host-startup-probe.mjs', macInstalledStartupProbeSource(consumer, hostServer, daemon.sha256, join(assets, 'installed-cli.ts')));
|
||||
const afterCodex = hostCodex();
|
||||
const after = { lockSha256: digest(readFileSync(join(consumer, 'package-lock.json'))), runnerSha256: hostDaemon().sha256.slice(7), codexCommandSha256: afterCodex.codexCommandSha256, codexNativeExecutableSha256: afterCodex.codexNativeExecutableSha256 };
|
||||
assertInstalledNativeConsumerContinuity(before, after);
|
||||
assert.equal(startup.runnerSha256, before.runnerSha256);
|
||||
assert.equal(startup.codexSha256, before.codexCommandSha256);
|
||||
const receipt = { schema: 'paperclip.runner.linux-arm64-public-install.v1', sourceRevision, platform: process.platform, architecture: process.arch,
|
||||
cleanNpmInstall: true, packageCount: manifest.archives.length, codexPackedPayloadsOmitted: true,
|
||||
consumerImage: GROK_PUBLIC_INSTALL_IMAGE, consumerImagePlatform: platform,
|
||||
consumerLockPreserved: true, lifecycleSentinelVerified: true, lifecycleNetwork: 'none',
|
||||
startupEnvironment: 'native host, same installed graph, isolated empty homes',
|
||||
startupHost: { nodeVersion: process.versions.node, nodeModulesAbi: process.versions.modules, glibcVersionRuntime: hostHeader.glibcVersionRuntime, kernelRelease: hostHeader.osRelease },
|
||||
installedIdentityBefore: before, installedIdentityAfter: after,
|
||||
...daemon, ...codex, startup, providerCalls: 0 };
|
||||
writeFileSync(receiptPath, JSON.stringify(receipt, null, 2) + '\n');
|
||||
console.log(JSON.stringify(receipt));
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
}
|
||||
if (process.argv[2] === '--linux-arm64-consumer') {
|
||||
assert.equal(process.argv.length, 6, 'Usage: --linux-arm64-consumer PACKAGE_DIRECTORY SOURCE_SHA RECEIPT_PATH');
|
||||
await verifyLinuxArmConsumer(process.argv[3], process.argv[4], process.argv[5]);
|
||||
} else if (process.argv[2] === '--mac-consumer') {
|
||||
assert.equal(process.argv.length, 6, 'Usage: --mac-consumer PACKAGE_DIRECTORY SOURCE_SHA RECEIPT_PATH');
|
||||
await verifyMacConsumer(process.argv[3], process.argv[4], process.argv[5]);
|
||||
} else {
|
||||
assert.equal(process.platform, 'linux', 'Run this verification on disposable EC2 Linux, not a developer host');
|
||||
const root = mkdtempSync(join(tmpdir(), 'paperclip-grok-public-install-'));
|
||||
const prerequisite = join(root, 'native/grok');
|
||||
@@ -146,6 +329,24 @@ try {
|
||||
timing.stage_pack_check_ms = Math.round(performance.now() - checksStarted);
|
||||
timing.stage_pack_ms = Math.round(performance.now() - packStarted);
|
||||
Object.assign(timing, packTimings(packs, concurrency));
|
||||
|
||||
// Preserve the exact Linux-produced public tarballs for independent Mac
|
||||
// consumers even if a later Linux runtime probe fails. This is a packaging
|
||||
// receipt, never an installation or provider-admission pass.
|
||||
if (process.env.PAPERCLIP_RUNNER_PUBLIC_PACKS_DIR) {
|
||||
const output = resolve(process.env.PAPERCLIP_RUNNER_PUBLIC_PACKS_DIR);
|
||||
assert.equal(JSON.parse(readFileSync(join(repo, 'server/dist/build-info.json'))).commit, sourceRevision);
|
||||
assert.ok(!existsSync(output), 'Never replace retained public packages');
|
||||
mkdirSync(output, { recursive: true });
|
||||
const archives = [...needed].map((name, index) => {
|
||||
const file = basename(tarballs[index]); cpSync(tarballs[index], join(output, file));
|
||||
return { name, file, sha256: createHash('sha256').update(readFileSync(tarballs[index])).digest('hex') };
|
||||
});
|
||||
writeFileSync(join(output, 'manifest.json'), JSON.stringify({
|
||||
schema: 'paperclip.runner.public-packages/v1', sourceRevision, releaseVersion,
|
||||
publicInstallationVerified: false, providerCalls: 0, archives,
|
||||
}, null, 2) + '\n');
|
||||
}
|
||||
const assets = join(root, 'assets'); mkdirSync(assets, { mode: 0o755 });
|
||||
const consumer = join(root, 'consumer'); mkdirSync(consumer);
|
||||
const cache = join(root, 'cache'); mkdirSync(cache);
|
||||
@@ -259,3 +460,4 @@ try {
|
||||
await stopPrefetches(prefetches);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user