mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
## Thinking Path > - Paperclip manages agents and tasks. > - Runner owns each authenticated run and its provider process. > - Runtime clients need to report successful activity in that run. > - A retained process can outlive a run, so an operation needs current authority. > - This PR captures that authority before the operation and records activity afterward. > - Core can consume the event through the existing run stream. ## Linked Issues or Issue Description Builds on merged #18129 and targets master. The dependent consumer is #18131. Test stability fixes were separated into merged #18128. Earlier combined-head verification remains in [the archive](https://github.com/paperclipai/paperclip/pull/18050#issuecomment-6489369676). **Subsystem affected** Runner activity recording and provider command environments. **Problem or motivation** A retained provider process can complete an operation after its original run ends. Runtime clients need a host-attributed event and a helper that works inside the provider sandbox. **Proposed solution** Capture opaque Runner context before use. Publish a structured notification after success. Runner checks current authority and records `runtime.activity.recorded`. ## What Changed - Add `notification-context` and `record-activity CONTEXT NAMESPACE ACTIVITY_KEY`. Rotate context on warm attachment and process recovery. Accept no caller run ID. - Use one private named pipe. Reuse Runner's bounded JSON-line reader. Serialize concurrent publishers with a read-only lock. A blocking reader queues at most 128 records; the existing control loop handles at most 16 per pass. - Grant Codex access to the exact helper, descriptor, lock and pipe. Preserve other filesystem and network limits. Preserve host discovery values through provider launch boundaries and reject configured-environment overrides. - Update source identities to Pi 34, Copilot 81 and Grok 12. Keep historical identity fixtures and recovery behavior. ## Verification - Targeted release notification and sandbox-policy tests: seven passed. They cover run rotation, recovery, Unicode limits, concurrent writers, malformed and partial records, bounded queues, failed persistence and fixed-path cleanup. - Real CLI integration and pinned Codex 0.160.0 sandbox checks: passed. The sandbox check uses no model, credential or network call. It verifies activity recording while unrelated state reads and parent-directory writes remain denied. - ACPX profile generator check, formatting and diff checks: passed. - Rebased onto master `31b927e30` at head `64d0248a8`. Master used the same profile versions, so the resolution preserves its Pi 33, Copilot 79 and Grok 11 fixtures byte-for-byte and creates new identities. All identity checks pass. Five targeted provider/environment/recovery files pass 168 tests with one existing skip; the Rust Pi admission regression passes. The notification module and Codex sandbox implementation are byte-identical to the tested pipe head `671373fe0`. - Removed the mailbox traversal reported by security review. Its regression replaces the pipe path with a 100-level tree and an unreadable leaf. Shutdown preserves the leaf, performs no traversal and completes promptly, including with a partial record. - An initial targeted test failed because its run-rotation fixture updated config without updating durable state. The corrected fixture passes. Runner also rejects inconsistent run bindings. - Previous head `26abbf62b` passed Runner, build and browser CI. Server shard 11/12 failed in unchanged `attention-service.test.ts`: cleanup/test timeouts were followed by a duplicate `ATQ` fixture key. The final head needs fresh CI and review. - CI on `64d0248a8` completed with 52 successful checks and four intentional skips. Runner Vitest shard 1/2 failed when retained-state copies encountered the live FIFO; the aggregate also failed. Standard review scored 4/5 and found relative pipe paths plus late context publication. Follow-up `5c3f14d32` places IPC in a private canonical temporary directory and publishes context before welcome commands. Its relative-state CLI test, two authenticated reconnect cases, seven notification/policy cases and real Codex sandbox pass. One affected retained-settlement case passes with the matching release binary; the other 18 cases remain for CI. The sandbox denies sibling-directory and lock writes. All build, test and security checks pass on `5c3f14d32`; standard review is 5/5 with no unresolved threads. The requested Apex review is 5/5 on this exact head, with no findings or unresolved threads. - Earlier broad test results in the archive apply to their recorded source revisions. Per the requested workflow, final local checks stay targeted; GitHub CI supplies broad coverage. - After Apex completed, master advanced again and conflicted on Copilot identity. Head `e94a9359d` is rebased on master `8ac5a128e`; it preserves the published Copilot 80 fixture and uses Copilot 81. The notification module, Codex sandbox and durable control-loop code are byte-identical to Apex-reviewed `5c3f14d32`. All 131 targeted installation/compatibility/recovery cases and identity generator checks pass. All 55 checks pass with four intentional skips on the rebased head. Standard review is 5/5 with no unresolved threads. Apex is 5/5 on `e94a9359d`, with zero findings and no unresolved threads. GitHub confirms this head is conflict-free and ready for review. ## Risks Activity remains an optional hint and grants no access. A successful helper exit means publication to the pipe; Runner persists accepted events through its existing durable outbox. Full queues, unavailable helpers or failed writes can drop a hint without failing the operation. Context reads are local and do not wait for the control loop. Publishers have a 500 ms lock deadline and a 2 KiB record limit. IPC lives outside durable state. Idle transport blocks; cleanup removes its fixed files and empty private directory. The host must provide `/usr/bin/mkfifo`. Rebuild matching Runner/provider artifacts for the updated source identities. No dependency, migration, live provider call or deployment is included. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, repository tools and code execution. The exact serving snapshot is not exposed. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
107 lines
6.7 KiB
JavaScript
107 lines
6.7 KiB
JavaScript
#!/usr/bin/env node
|
|
// 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, 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 {
|
|
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');
|
|
for (const key of Object.keys(process.env)) {
|
|
assert.ok(!/(?:API_KEY|TOKEN|SECRET|PASSWORD)$/.test(key), `Unexpected credential variable: ${key}`);
|
|
}
|
|
assert.equal(process.env.PAPERCLIP_RUNNER_BINARY, undefined);
|
|
assert.equal(process.env.NODE_OPTIONS, undefined);
|
|
assert.equal(process.env.NODE_PATH, undefined);
|
|
const server = await realpath(serverDirectory);
|
|
assert.equal(JSON.parse(await readFile(join(server, 'package.json'), 'utf8')).name, '@paperclipai/server');
|
|
const { resolvePaperclipRunnerBinary } = await import(pathToFileURL(join(server, 'dist/services/native-runtime/native-codex-runner.js')));
|
|
const { createCapabilityRunnerdCodexTransport } = await import(pathToFileURL(join(server, 'dist/vendor/paperclip-runner/live/runnerd-codex-transport.js')));
|
|
const { QUALIFIED_ACPX_PROFILES } = await import(pathToFileURL(join(server, 'dist/vendor/paperclip-runner/drivers/acpx/qualified-profiles.js')));
|
|
assert.equal(QUALIFIED_ACPX_PROFILES.pi.agentProfileVersion, 34);
|
|
assert.equal(QUALIFIED_ACPX_PROFILES.pi.commandDigest, 'sha256:ed4b32cc8fbf36309d38b3bd378f41f8e7b9e96a2405d275cc55e89042d94c7f');
|
|
assert.equal(Object.hasOwn(QUALIFIED_ACPX_PROFILES.pi, 'qualificationModel'), false);
|
|
assert.equal(Object.hasOwn(QUALIFIED_ACPX_PROFILES.pi, 'reportedModelId'), false);
|
|
const daemon = resolvePaperclipRunnerBinary();
|
|
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();
|
|
const watchdog = setTimeout(() => { console.error('Pi public-install probe exceeded its cleanup deadline'); process.exit(1); }, 85_000);
|
|
let bundle;
|
|
let failure;
|
|
let settledMs;
|
|
try {
|
|
const workspace = join(root, 'workspace'); await mkdir(workspace);
|
|
bundle = createCapabilityRunnerdCodexTransport({
|
|
provider: 'acpx', acpxAgent: 'pi', piThinkingLevel: 'low', acpxPermissionMode: 'deny-all',
|
|
runnerBinary: daemon, stateDirectory: join(root, 'state'),
|
|
environment: { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8' },
|
|
onEvidence: value => evidence.push(value),
|
|
});
|
|
try {
|
|
await bundle.transport.request('initialize', { clientInfo: { name: 'pi-public-install-verification', version: '1' } });
|
|
await bundle.transport.request('thread/start', {
|
|
cwd: workspace, model: 'openrouter/deepseek/deepseek-v4-flash-0731',
|
|
baseInstructions: 'Credential-free admission probe. No prompt is submitted.',
|
|
permissions: 'paperclip-runner-workspace-read-only', dynamicTools: [],
|
|
});
|
|
} catch (error) { failure = String(error); }
|
|
settledMs = Math.round(performance.now() - started);
|
|
await bundle.transport.close();
|
|
assert.match(failure ?? '', /session\.open failed/);
|
|
assert.match(failure, /retryable=false, classification=session_ensure_failed/);
|
|
assert.doesNotMatch(failure, /timed out/);
|
|
assert.ok(settledMs < 60_000, `Pi admission took ${settledMs} ms`);
|
|
assert.ok(evidence.some(item => item.runnerExited === true && item.runnerExitCode === 0));
|
|
for (const item of evidence) assert.deepEqual(item.childEnvironmentKeys, ['LANG', 'PATH']);
|
|
const state = JSON.parse(await readFile(join(root, 'state/runner/acpx-provider-state.json'), 'utf8'));
|
|
assert.equal(state.descriptor.agent, 'pi');
|
|
assert.equal(state.descriptor.agentRuntimeVersion, '1.0.0');
|
|
assert.equal(state.descriptor.piThinkingLevel, 'low');
|
|
assert.equal(state.activeTurnId, null);
|
|
assert.equal(state.identity, null);
|
|
assert.equal(state.providerExitUnconfirmed, false);
|
|
console.log(JSON.stringify({ schema: 'paperclip.pi.public-npm-install.v1', target: `${process.platform}-${process.arch}`, normalPackagedDaemon: true, exactPiProfile: QUALIFIED_ACPX_PROFILES.pi.agentProfileVersion, runtime: '1.0.0', credentials: 'none', promptCalls: 0, settledMs, cleanRunnerExit: true }));
|
|
} finally {
|
|
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();
|