mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +01:00
## Thinking Path > - Paperclip manages agents and their useful work. > - Status cards and external objects can have future refresh deadlines. > - A stopped tenant needs a durable Cloud alarm to return for those deadlines. > - A local quiet report alone does not prove that handoff or later useful work. > - Acceptance must exercise the normal producer, authorization, claims and alarm owner. > - This PR adds opt-in signed cold-owner acceptance and bounded launcher controls. ## Linked Issues or Issue Description Refs #19001 and #19191, which have merged. This PR targets master and adds acceptance for the existing status and object owners. **What happened?** The existing local coverage did not join useful status generation or built-in GitHub refresh with signed Cloud alarm persistence, physical child retirement and cold reactivation at the original deadline. **Expected behavior** Keep the feature enabled. Complete useful work through its normal owner. Persist the exact authorized alarm before stopping the child. Wake and process the same original deadline without losing identity, claims, retries or useful results. **Steps to reproduce** Use the checked-in Node24 launcher with separate owned loopback App/Cloud test databases, a compiled Cloud checkout and the pinned Node22 executable. Select one status or object bootstrap, cycle or identity case. The command validates fresh evidence and database ownership before starting its guarded worker. **Paperclip version or commit** Current candidate `ea911278b22c855d6722e43afc026bfc5c031405` normally merges fetched master `07a548073ab48f13cb93b2fdc31553a5da88a070`. All eight owned acceptance files are byte-identical to `c08e64fa857d39bdb79352e94d156db7a4dbebf3`; all unrelated paths exactly match master. The guard-argument repair and source-specific historical cycle proof are preserved. ## What Changed - Add one named opt-in case per owner/stage/diagnostic combination. Keep the shared business body and its original deadlines, assertions and cleanup. - Drive actual managed App startup, authenticated producers, persisted claims and the signed Cloud SQL alarm before physical child stop. - Retain the generated five-secret fixture profile across cold boots. Store it in private directories and retain only names, hashes and modes in evidence. - Add optional query timestamps to the existing proxy and harness. Existing callers keep their default behavior. - Add launcher refusal controls and assert its complete child argument list. Both explicit guard preloads and the exact child `NODE_OPTIONS` must match. ## Verification - The current preflight repair has causal proof: the old controls pass all nine cases with an invalid worker preload flag. The new assertions reject that same command in both success controls (two failures/seven passes). Restoring the correct command passes all nine cases. The original refusal, freshness, symlink and byte-preservation assertions remain unchanged. All 33 recorded Node PIDs and three groups close; temporary copies and private directories are removed. These controls stop at the declared spawn stub. They verify the full command contract and do not claim to execute Vitest. - The prior real named status bootstrap passed in 72.942 seconds: one selected case/six explicit skips. It exercised the actual launcher and Vitest fork, useful normal generation, current authorization, settled claims, original 900000ms interval and signed SQL alarm generation/operation-lock acknowledgement before physical child stop. Recorded processes and final SQL sessions closed; both successful databases were normally dropped. - Earlier normal status and GitHub-object cycles qualified their real 900-second and 300-second original deadlines, cold reactivation and useful second results. Their complete original failure and recovery records remain source-specific; no shortened business clock or manufactured completion is used. - On current integrated head `ea911278`, own frozen offline setup and normal shared/SDK, Runner and App declaration prerequisites passed. The status test and its imported closure pass the affected normal test-tree compiler program with original options/references. The actual named normal status bootstrap then passed: one selected case/six skips, 82.202 seconds in Vitest and 85.616 seconds in the launcher. It used the real checked-in launcher, normal App/SDK/process agent and exact compiled Cloud `389878364b42dd129835d263f91882c5774d5123`. The original 900000ms interval and 120000ms alarm lead remain unchanged; useful v1 and signed SQL alarm generation/operation-lock acknowledgement precede physical App stop. All six recorded process IDs and the runtime group were absent; independent final test-database sessions were zero, with no observer during execution. Both fresh success databases were normally dropped. This refreshes the changed consumer seams through handoff and stop; it is not another cold-wake/useful-v2/full-cycle result. Historical cycles remain tied to their measured source. - Fresh exact-head CI and complete edited bot reviews are required before merge. A whole final-tree local build/test or deployed suspension is not claimed. Local provider, process and S3 boundaries are controlled fixtures; no live GitHub provider or Railway/Neon suspension claim follows. ## Risks - These tests use real SQL and wall clocks and are opt-in. Their fixtures must stay isolated and retain failed evidence. - Stable fixture secrets must remain private and must not appear in logs. Cleanup keeps them when owner closure is uncertain. - The acceptance body stays shared to preserve measured evidence. The named registration and command controls make selection failures visible. - This PR does not change production, schema or feature enablement. ## Model Used OpenAI `gpt-6.1-sol` produced the delegated acceptance work. OpenAI Codex, based on GPT-6, performed managing review, source integration and the preflight argument repair. Both used repository tools and code execution. The coordinator's exact backend snapshot and context-window size were 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 - [x] I have checked ROADMAP.md and confirmed this does not duplicate planned core work - [x] I have searched related PRs and linked the existing foundations - [x] I have described the issue with the bug-template fields - [x] I have not referenced internal or instance-local issues or links - [x] My branch name describes the change - [x] I have run the stated focused local tests and preserved their limits - [x] I have added or updated tests - [x] I have documented the opt-in command and evidence requirements - [x] I have documented risks - [ ] All current-head CI gates are green - [ ] Current-head Greptile is 5/5 with no open findings - [x] I will address reviewer findings before merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
126 lines
6.5 KiB
JavaScript
126 lines
6.5 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import test from 'node:test';
|
|
|
|
const root = fileURLToPath(new URL('../../', import.meta.url));
|
|
const launcher = path.join(root, 'scripts/test-status-object-signed-sleep.mjs');
|
|
const boundarySource = `
|
|
const assert = require('node:assert/strict');
|
|
const { EventEmitter } = require('node:events');
|
|
const { appendFileSync } = require('node:fs');
|
|
const { syncBuiltinESMExports } = require('node:module');
|
|
require('node:net').Socket.prototype.connect = function() {
|
|
appendFileSync(process.env.PREFLIGHT_NETWORK_RECEIPT, 'blocked\\n');
|
|
process.exitCode = 98;
|
|
throw new Error('Preflight controls forbid all sockets');
|
|
};
|
|
require('node:child_process').spawn = function(command, args, options) {
|
|
assert.equal(command, process.execPath);
|
|
const path = require('node:path');
|
|
const expectedRoot = process.env.PREFLIGHT_EXPECTED_ROOT;
|
|
const guard = path.join(expectedRoot, 'scripts/fixtures/skill-default-cadence-network-guard.cjs');
|
|
const vitest = path.join(path.dirname(require.resolve('vitest/package.json', {
|
|
paths: [path.join(expectedRoot, 'server')],
|
|
})), 'vitest.mjs');
|
|
assert.deepEqual(args, ['--require', guard, vitest, 'run',
|
|
'server/src/__tests__/status-object-signed-sleep-acceptance.test.ts',
|
|
'--maxWorkers=1', '--no-file-parallelism', '--execArgv=--require=' + guard]);
|
|
assert.equal(options.cwd, expectedRoot);
|
|
assert.equal(options.env.NODE_OPTIONS, '--require=' + guard);
|
|
appendFileSync(process.env.PREFLIGHT_SPAWN_RECEIPT, 'vitest-boundary\\n');
|
|
const child = new EventEmitter();
|
|
child.kill = () => false;
|
|
queueMicrotask(() => child.emit('close', 0, null));
|
|
return child;
|
|
};
|
|
syncBuiltinESMExports();
|
|
`;
|
|
|
|
function fixture(run) {
|
|
const directory = mkdtempSync(path.join(os.tmpdir(), 'status-owner-preflight-'));
|
|
const evidence = path.join(directory, 'evidence'), cloud = path.join(directory, 'cloud');
|
|
mkdirSync(evidence);
|
|
mkdirSync(path.join(cloud, 'dist/sleep/fixtures'), { recursive: true });
|
|
writeFileSync(path.join(cloud, 'dist/sleep/fixtures/default-cadence-bridge.js'), '');
|
|
const preload = path.join(directory, 'boundary.cjs'), log = path.join(directory, 'guard.jsonl');
|
|
const spawnReceipt = path.join(directory, 'spawn.txt'), networkReceipt = path.join(directory, 'network.txt');
|
|
writeFileSync(preload, boundarySource);
|
|
writeFileSync(log, '');
|
|
const env = {
|
|
PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR,
|
|
PREFLIGHT_EXPECTED_ROOT: root, PREFLIGHT_SPAWN_RECEIPT: spawnReceipt, PREFLIGHT_NETWORK_RECEIPT: networkReceipt,
|
|
PAPERCLIP_SKILL_DEFAULT_CADENCE_TEST_DATABASE_URL: 'postgres://paperclip@127.0.0.1:55448/owner_preflight_app_test',
|
|
PAPERCLIP_SKILL_DEFAULT_CADENCE_CLOUD_TEST_DATABASE_URL: 'postgres://paperclip@127.0.0.1:55448/owner_preflight_cloud_test',
|
|
PAPERCLIP_SKILL_DEFAULT_CADENCE_CLOUD_CHECKOUT: cloud, PAPERCLIP_SKILL_DEFAULT_CADENCE_CLOUD_NODE: process.execPath,
|
|
PAPERCLIP_SKILL_DEFAULT_CADENCE_CLOUD_SHA: '0'.repeat(40),
|
|
PAPERCLIP_SKILL_DEFAULT_CADENCE_EVIDENCE: evidence, PAPERCLIP_SKILL_DEFAULT_CADENCE_GUARD_LOG: log,
|
|
};
|
|
const execute = () => {
|
|
const result = spawnSync(process.execPath, ['--require', preload, launcher, '--owner=status', '--stage=identity'], {
|
|
cwd: root, env, encoding: 'utf8', timeout: 10_000,
|
|
});
|
|
console.log(JSON.stringify({ event: 'launcher-preflight-child', pid: result.pid, status: result.status,
|
|
spawnEntered: existsSync(spawnReceipt), socketAttempted: existsSync(networkReceipt),
|
|
guardEvents: existsSync(env.PAPERCLIP_SKILL_DEFAULT_CADENCE_GUARD_LOG)
|
|
? readFileSync(env.PAPERCLIP_SKILL_DEFAULT_CADENCE_GUARD_LOG, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line)) : [] }));
|
|
assert.equal(result.error, undefined);
|
|
assert(!existsSync(networkReceipt), 'No socket may be entered during preflight controls');
|
|
return result;
|
|
};
|
|
try { run({ evidence, directory, log, env, execute, spawnReceipt }); }
|
|
finally { rmSync(directory, { recursive: true, force: true }); }
|
|
}
|
|
|
|
function refused(context, message) {
|
|
const result = context.execute();
|
|
assert.notEqual(result.status, 0);
|
|
assert.match(result.stderr, message);
|
|
assert(!existsSync(context.spawnReceipt), 'Refusal must precede the Vitest spawn boundary');
|
|
assert.equal(readFileSync(context.log, 'utf8'), '', 'Refusal must precede guard installation');
|
|
}
|
|
|
|
test('fresh evidence reaches the actual launcher Vitest boundary without transport', () => fixture(context => {
|
|
const result = context.execute();
|
|
assert.equal(result.status, 0, result.stderr);
|
|
assert.equal(readFileSync(context.spawnReceipt, 'utf8'), 'vitest-boundary\n');
|
|
}));
|
|
|
|
test('fresh evidence may contain only its explicit empty regular guard log', () => fixture(context => {
|
|
const log = path.join(context.evidence, 'guard.jsonl');
|
|
writeFileSync(log, '');
|
|
context.env.PAPERCLIP_SKILL_DEFAULT_CADENCE_GUARD_LOG = log;
|
|
const result = context.execute();
|
|
assert.equal(result.status, 0, result.stderr);
|
|
assert.equal(readFileSync(context.spawnReceipt, 'utf8'), 'vitest-boundary\n');
|
|
}));
|
|
|
|
for (const name of ['status-provider-state.json', 'status-provider-state.json.runs.jsonl',
|
|
'status-provider-state.json.pending', 'status-provider-state.json.api-origin', 'other-evidence.json']) {
|
|
test(`rejects existing ${name} before guard or spawn and preserves its bytes`, () => fixture(context => {
|
|
const file = path.join(context.evidence, name), prior = 'retained evidence\n';
|
|
writeFileSync(file, prior);
|
|
refused(context, /requires a fresh evidence directory/);
|
|
assert.equal(readFileSync(file, 'utf8'), prior);
|
|
}));
|
|
}
|
|
|
|
test('rejects a symlink evidence directory before guard or spawn', () => fixture(context => {
|
|
const link = path.join(context.directory, 'evidence-link');
|
|
symlinkSync(context.evidence, link);
|
|
context.env.PAPERCLIP_SKILL_DEFAULT_CADENCE_EVIDENCE = link;
|
|
refused(context, /evidence directory without a symlink/);
|
|
assert(lstatSync(link).isSymbolicLink());
|
|
}));
|
|
|
|
test('rejects a symlink guard log without writing its target', () => fixture(context => {
|
|
const link = path.join(context.evidence, 'guard-link.jsonl');
|
|
symlinkSync(context.log, link);
|
|
context.env.PAPERCLIP_SKILL_DEFAULT_CADENCE_GUARD_LOG = link;
|
|
refused(context, /fresh absolute guard log/);
|
|
assert(lstatSync(link).isSymbolicLink());
|
|
}));
|