mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-12 18:06:11 +02:00
Fix public Cursor asset ownership and add runtime-loss qualification
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
e2b89b6302
commit
37ba674378
16 files changed
+395
-12
No files matched your search
@@ -68,3 +68,38 @@ GitHub Actions ownership of lockfile commits. Its SHA-256 is
|
||||
`70af8ab3d7051c85fc1a55c11e9afe8887d9711232e3c6e97666006562217e5f`;
|
||||
retain these exact resolved bytes with candidate artifacts and pass their digest
|
||||
to the immutable image build.
|
||||
|
||||
## Qualification preparation and demonstrated repairs
|
||||
|
||||
Candidate `9ba53fced5de6dabd1e931b7438d9dd118e45f0f` passed local
|
||||
completion, semantic question continuation, and file-edit/validation Product E2E
|
||||
cells with the explicit Luna model, serial execution, no retries, and successful
|
||||
cleanup. Their original campaign identities remain unchanged. Measured per-run
|
||||
dollar usage is unavailable, not zero. The existing Cursor account-cycle cap is
|
||||
counted once against the reconciled campaign envelope.
|
||||
|
||||
Fresh ordinary provider packs were built for macOS ARM64 and x64; the Linux x64
|
||||
Daytona image also built successfully. These are preparation artifacts, not a
|
||||
completed release certification.
|
||||
|
||||
Public-package inspection found that setup derived the standalone runner layout
|
||||
when embedded in the server's vendored layout. The provisioner now selects its
|
||||
contained public asset root explicitly and rejects an unbundled source invocation.
|
||||
Three containment checks pass. `node scripts/verify-cursor-npm-install.mjs`
|
||||
packages the actual public CLI/server dependency graph and verifies installation,
|
||||
enabled npm hooks, explicit setup, and the installed Cursor execution closure in
|
||||
an isolated consumer. It retains evidence under its printed temporary directory
|
||||
and makes no model calls. npm hooks may fetch normal platform dependencies;
|
||||
Cursor provisioning must remain absent until the public setup command runs.
|
||||
|
||||
The new explicit-only `native-provider-loss` Product E2E suite covers the missing
|
||||
transport-loss gate locally and on Daytona. It loses only the observed per-turn
|
||||
run owner while a native mutation is awaiting permission, then requires a visible
|
||||
failed run, an unanswerable stale approval, an open task, no automatic replay,
|
||||
no changed target, and retirement of the owned process tree. The remote fault uses
|
||||
a Linux pidfd bound to the retained start ticks and boot ID.
|
||||
|
||||
The initial full test run retained resource/startup failures. Targeted reruns
|
||||
passed 43 boundary/file-handoff checks, 196 real-runner checks, and 1,742 of 1,744
|
||||
remaining server checks. The two remaining assertions compare macOS `/var` aliases
|
||||
against canonical `/private/var` paths; no unrelated test repair is ported.
|
||||
@@ -0,0 +1,12 @@
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/** Both public server vendoring and the standalone runner own their assets. */
|
||||
export function cursorProvisionerPackageRoot(moduleUrl) {
|
||||
const url = new URL(moduleUrl);
|
||||
if (url.protocol !== "file:" || url.search || url.hash) throw new Error("Cursor setup requires a published provisioner");
|
||||
const path = fileURLToPath(url);
|
||||
if (/\/dist\/vendor\/paperclip-runner\/cli\/provision-cursor\.(?:cjs|js)$/.test(path)) return resolve(dirname(path), "..");
|
||||
if (/\/dist\/cli\/provision-cursor\.(?:cjs|js)$/.test(path)) return resolve(dirname(path), "../..");
|
||||
throw new Error("Cursor setup requires a published provisioner");
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { cursorProvisionerPackageRoot } from "./cursor-provisioner-layout.mjs";
|
||||
|
||||
test("public server setup owns the same asset root as its vendored runtime", () => {
|
||||
assert.equal(cursorProvisionerPackageRoot("file:///consumer/node_modules/@paperclipai/server/dist/vendor/paperclip-runner/cli/provision-cursor.cjs"), "/consumer/node_modules/@paperclipai/server/dist/vendor/paperclip-runner");
|
||||
});
|
||||
test("standalone runner setup owns package assets", () => {
|
||||
assert.equal(cursorProvisionerPackageRoot("file:///consumer/node_modules/@paperclipai/paperclip-runner/dist/cli/provision-cursor.cjs"), "/consumer/node_modules/@paperclipai/paperclip-runner");
|
||||
});
|
||||
test("unbundled and foreign layouts cannot provision outside an owned package", () => {
|
||||
for (const url of ["file:///repo/packages/paperclip-runner/scripts/provision-cursor.mjs", "file:///tmp/provision-cursor.cjs", "https://example.com/dist/cli/provision-cursor.js", "file:///consumer/dist/cli/provision-cursor.js?redirect=1"]) assert.throws(() => cursorProvisionerPackageRoot(url), /published provisioner/);
|
||||
});
|
||||
@@ -1,13 +1,13 @@
|
||||
#!/usr/bin/env node
|
||||
import { lstat } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { join } from "node:path";
|
||||
import { cursorProvisionerPackageRoot } from "./cursor-provisioner-layout.mjs";
|
||||
import { cursorDistribution, materializePinnedCursorDistribution, verifyCursorDistribution } from "./materialize-cursor-distribution.mjs";
|
||||
|
||||
/** Explicit public installation; never invoked by an npm lifecycle hook. */
|
||||
export async function provisionCursorRuntime() {
|
||||
const distribution = await cursorDistribution();
|
||||
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const packageRoot = cursorProvisionerPackageRoot(import.meta.url);
|
||||
const destination = join(packageRoot, "provider-assets", "cursor", `${distribution.platform}-${distribution.architecture}`);
|
||||
const installed = await lstat(destination).catch(error => {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
|
||||
@@ -119,7 +119,11 @@ if (Object.keys(sortedOptDeps).length > 0) {
|
||||
}
|
||||
|
||||
const output = JSON.stringify(publishPkg, null, 2) + "\n";
|
||||
const outPath = resolve(repoRoot, "cli/package.json");
|
||||
const [outputFlag, outputPath, ...extraArgs] = process.argv.slice(2);
|
||||
if (extraArgs.length || (outputFlag !== undefined && (outputFlag !== "--output" || !outputPath))) {
|
||||
throw new Error("Usage: generate-npm-package-json.mjs [--output <manifest-path>]");
|
||||
}
|
||||
const outPath = outputPath ? resolve(outputPath) : resolve(repoRoot, "cli/package.json");
|
||||
writeFileSync(outPath, output);
|
||||
|
||||
console.log(` ✓ Generated publishable package.json (${Object.keys(sortedDeps).length} deps)`);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env node
|
||||
// Public npm graph + explicit setup only. No credentials, inference, publication,
|
||||
// checkout mount, candidate overrides, or native Cursor assets in the tarballs.
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { materializePublishManifest, prepareBundledPackage } from './prepare-bundled-package.mjs';
|
||||
import { grokConsumerDockerArgs, GROK_PUBLIC_INSTALL_LIFECYCLE, GROK_PUBLIC_INSTALL_IMAGE } from './grok-public-install-sandbox.mjs';
|
||||
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const root = mkdtempSync(join(tmpdir(), 'paperclip-cursor-public-install-'));
|
||||
console.log(`Verification evidence: ${root}`);
|
||||
const env = { ...process.env, NODE_PATH: '', PAPERCLIP_RELEASE_REUSE_UI_DIST: '1', npm_config_ignore_scripts: 'false', npm_config_audit: 'false', npm_config_fund: 'false' };
|
||||
for (const key of Object.keys(env)) if (/^(OPENAI|ANTHROPIC|OPENROUTER|DAYTONA|XAI|GROK|CURSOR|COPILOT|GITHUB|GH)(_|$)/.test(key) || /CANDIDATE/.test(key)) delete env[key];
|
||||
const run = (command, args, cwd = root) => execFileSync(command, args, { cwd, env, stdio: 'pipe', maxBuffer: 64 * 1024 * 1024 });
|
||||
const sourceRevision = run('git', ['rev-parse', 'HEAD'], repo).toString().trim();
|
||||
const releaseVersion = `0.0.0-cursor-verify.${sourceRevision.slice(0, 12)}`;
|
||||
const listing = run(process.execPath, [join(repo, 'scripts/release-package-map.mjs'), 'list'], repo).toString().trim().split('\n').map(line => line.split('\t'));
|
||||
const packages = new Map(listing.map(([dir, name]) => [name, {dir, manifest: JSON.parse(readFileSync(join(repo, dir, 'package.json')))}]));
|
||||
const cliManifestPath = join(repo, 'cli/package.json'); const original = readFileSync(cliManifestPath);
|
||||
const generatedManifest = join(root, 'cli-manifest.json');
|
||||
run(process.execPath, [join(repo, 'scripts/generate-npm-package-json.mjs'), '--output', generatedManifest], repo);
|
||||
const publishedCli = JSON.parse(readFileSync(generatedManifest));
|
||||
publishedCli.dependencies['@paperclipai/server'] = releaseVersion;
|
||||
packages.set('paperclipai', { dir: 'cli', manifest: publishedCli });
|
||||
const needed = new Set();
|
||||
function visit(name) {
|
||||
if (needed.has(name)) return;
|
||||
const entry = packages.get(name); assert.ok(entry, `Missing public package ${name}`); needed.add(name);
|
||||
for (const [dependency, version] of Object.entries({...entry.manifest.dependencies, ...entry.manifest.optionalDependencies})) if (version.startsWith('workspace:') || dependency === '@paperclipai/server' && name === 'paperclipai') visit(dependency);
|
||||
}
|
||||
visit('paperclipai');
|
||||
// Run after the candidate build, including its required standalone adapters.
|
||||
for (const name of needed) assert.ok(existsSync(join(repo, packages.get(name).dir, 'dist')), `Missing built public package ${name}`);
|
||||
run('bash', [join(repo, 'scripts/prepare-server-ui-dist.sh')], repo);
|
||||
const assets = join(root, 'assets'); const consumer = join(root, 'consumer'); const cache = join(root, 'cache');
|
||||
for (const path of [assets, consumer, cache]) mkdirSync(path, { mode: 0o755 });
|
||||
writeFileSync(join(consumer, 'package.json'), JSON.stringify({ private: true, type: 'module' }));
|
||||
for (const [index, name] of [...needed].entries()) {
|
||||
const { dir, manifest } = packages.get(name); const staged = join(root, `source-${index}`); const target = join(root, `package-${index}`); mkdirSync(staged);
|
||||
for (const file of manifest.files ?? ['dist']) {
|
||||
const skills = file === 'skills' && ['server', 'packages/adapters/claude-local', 'packages/adapters/codex-local'].includes(dir);
|
||||
if (file === 'skills' && !skills && !existsSync(join(repo,dir,file))) continue;
|
||||
cpSync(skills ? join(repo,'skills') : join(repo,dir,file), join(staged,file), { recursive: true });
|
||||
}
|
||||
const releaseManifest = { ...manifest, version: releaseVersion };
|
||||
writeFileSync(join(staged,'package.json'), JSON.stringify(releaseManifest));
|
||||
if ((manifest.bundleDependencies ?? manifest.bundledDependencies ?? []).length) prepareBundledPackage(staged,target);
|
||||
else { cpSync(staged,target,{recursive:true}); writeFileSync(join(target,'package.json'),JSON.stringify(materializePublishManifest(releaseManifest))); }
|
||||
run('npm',['pack','--ignore-scripts','--pack-destination',assets],target);
|
||||
console.log(`Packed ${name}`);
|
||||
}
|
||||
const sentinelSource = join(root,'lifecycle-sentinel'); mkdirSync(sentinelSource);
|
||||
writeFileSync(join(sentinelSource,'package.json'), JSON.stringify({name:'paperclip-verification-lifecycle-sentinel',version:'1.0.0',private:true,scripts:{postinstall:'node -e "require(\'node:fs\').writeFileSync(\'lifecycle-ran\', \'ok\')"'}}));
|
||||
run('npm',['pack','--ignore-scripts','--pack-destination',assets],sentinelSource);
|
||||
for (const file of readdirSync(assets)) chmodSync(join(assets,file),0o644);
|
||||
const isolated = (command, options={}) => run('docker',grokConsumerDockerArgs({assets,consumer,cache,uid:process.getuid(),gid:process.getgid(),command,...options}));
|
||||
isolated(['npm','install','--ignore-scripts','--omit=dev',...readdirSync(assets).filter(f=>f.endsWith('.tgz')).map(f=>`/packages/${f}`)],{download:true});
|
||||
const sentinel = join(consumer,'node_modules/paperclip-verification-lifecycle-sentinel/lifecycle-ran');
|
||||
assert.equal(existsSync(sentinel),false);
|
||||
const lock = readFileSync(join(consumer,'package-lock.json'),'utf8');
|
||||
// Ordinary npm hooks may fetch platform dependency assets (e.g. esbuild).
|
||||
// Cursor itself must still remain absent until its explicit setup command.
|
||||
isolated(GROK_PUBLIC_INSTALL_LIFECYCLE, { download: true });
|
||||
assert.equal(readFileSync(sentinel,'utf8'),'ok'); assert.equal(readFileSync(join(consumer,'package-lock.json'),'utf8'),lock);
|
||||
const installedRunner = join(consumer,'node_modules/@paperclipai/server/dist/vendor/paperclip-runner');
|
||||
assert.equal(existsSync(join(installedRunner,'provider-assets/cursor')),false,'npm must never download Cursor');
|
||||
const setupOutput = isolated(['node','node_modules/paperclipai/dist/index.js','runtime','setup','cursor'],{download:true}).toString();
|
||||
console.log(setupOutput.trim());
|
||||
assert.match(setupOutput,/Verified Cursor 2026\.09\.26-dd393fe \(linux-x64\), paperclip-cursor-usage-v4/);
|
||||
const probe = `import assert from 'node:assert/strict';
|
||||
import { verifyQualifiedAcpxInstallation } from '/consumer/node_modules/@paperclipai/server/dist/vendor/paperclip-runner/drivers/acpx/installation-integrity.js';
|
||||
import { resolveQualifiedAcpxProfile } from '/consumer/node_modules/@paperclipai/server/dist/vendor/paperclip-runner/drivers/acpx/qualified-profiles.js';
|
||||
const profile = resolveQualifiedAcpxProfile('cursor', 'gpt-5.6-luna[context=272k,reasoning=medium,fast=false]');
|
||||
const installation = await verifyQualifiedAcpxInstallation(profile, () => { throw new Error('Cursor must not resolve an npm package'); });
|
||||
assert.equal(installation.agentServerPackageJsonPath,null); assert.equal(installation.agentRuntimePackageJsonPath,null);
|
||||
await (await installation.openCommand()).close();
|
||||
console.log('Installed Cursor closure and command lease verified without credentials or candidate overrides');`;
|
||||
writeFileSync(join(assets,'probe.mjs'),probe,{mode:0o644});
|
||||
console.log(isolated(['node','/packages/probe.mjs']).toString().trim());
|
||||
for (const name of needed) assert.equal(JSON.parse(readFileSync(join(consumer,'node_modules',name,'package.json'))).version,releaseVersion);
|
||||
const report = {schema:'paperclip.cursor.public-npm-install.v1',sourceRevision,releaseVersion,consumerImage:GROK_PUBLIC_INSTALL_IMAGE,packageCount:needed.size,sourceCliManifestPreserved:readFileSync(cliManifestPath).equals(original),lifecycleScriptsEnabled:true,lifecycleSentinelVerified:true,lifecycleNetwork:'bridge',consumerLockPreserved:true,npmProvisionedCursor:false,publicSetupCommand:true,pinnedClosureVerified:true,providerCalls:0};
|
||||
writeFileSync(join(root,'report.json'),JSON.stringify(report,null,2)+'\n'); console.log(JSON.stringify(report));
|
||||
@@ -136,10 +136,10 @@ describe("runner E2E catalog", () => {
|
||||
expect(localIntegrityTasks).toHaveLength(2);
|
||||
expect(openRouterBreadthTasks).toHaveLength(3);
|
||||
expect(runnerSuites.map((suite) => suite.expectedMatrixSize)).toEqual([
|
||||
6, 8, 2, 2, 30, 3, 16, 16, 2, 6, 8, 46, 23, 50, 6, 20, 26, 52, 28, 18, 2, 6, 6, 12, 10, 48, 16, 10, 2, 1, 1,
|
||||
6, 8, 2, 2, 2, 30, 3, 16, 16, 2, 6, 8, 46, 23, 50, 6, 20, 26, 52, 28, 18, 2, 6, 6, 12, 10, 48, 16, 10, 2, 1, 1,
|
||||
]);
|
||||
expect(validateRunnerCatalog()).toHaveLength(482);
|
||||
expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(482);
|
||||
expect(validateRunnerCatalog()).toHaveLength(484);
|
||||
expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(484);
|
||||
expect(
|
||||
runnerMatrix.filter((entry) => entry.suite.id === "core-compatibility"),
|
||||
).toHaveLength(48);
|
||||
|
||||
@@ -1104,6 +1104,22 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [
|
||||
environments: runnerEnvironments, tasks: nativeActiveStopTasks, expectedMatrixSize: 2,
|
||||
definitionMetadata: { version: 4, qualification: "pending", scheduling: "explicit-only", evidence: "paperclip.e2e.native-active-stop-settlement.v2", pendingObservation: "retained-api-before-caller-uuid-stop", normalCompletionAccepted: false, permissionPolicy: "approve-reads", lifecycle: "per_turn", remoteEvidence: "paperclip.e2e.native-active-stop-remote-retirement.v1", remoteObservationCoverage: "continuous-through-owned-process-retirement", filesystemAfterRemoteRetirementObserved: false, localObservationCoverage: "four-phases-through-cleanup", providerDeath: "not-covered" },
|
||||
},
|
||||
{
|
||||
id: "native-provider-loss", label: "Lose a runtime with an unanswered native permission", manualOnly: true,
|
||||
description: "Lose the owned Cursor runtime while a native mutation remains unanswered; require a visible failed run, closed unanswerable input, stale-answer refusal and independent retirement with no effects or replay.",
|
||||
groups: ["native"], profiles: extendedHarnessProfiles.filter(profile => profile.qualificationCandidate === "cursor"),
|
||||
environments: runnerEnvironments,
|
||||
tasks: [{
|
||||
id: "pending-permission-provider-loss", label: "Owned runtime loss with pending permission", groups: [], workMode: "standard", flow: "native_provider_loss",
|
||||
expectedRunCount: 1, attemptTimeoutMs: { local: 300_000, daytona: 300_000 }, turnTimeoutMs: 120_000,
|
||||
expectedTerminalState: { issue: "in_progress", run: "failed" },
|
||||
buildTitle: nonce => `Native provider loss ${nonce}`,
|
||||
buildVisibleMarker: nonce => `NATIVE-PROVIDER-LOSS-${nonce}`,
|
||||
buildPrompt: () => "Attempt the supplied exact native shell operation once and leave its permission callback unanswered. Do not retry, use another tool or path, answer your own permission, call paperclip_finish, or claim completion. The qualification fixture will deliberately lose the isolated runtime before any approval.",
|
||||
buildMatchers: () => [],
|
||||
}], expectedMatrixSize: 2,
|
||||
definitionMetadata: { version: 1, qualification: "pending", scheduling: "explicit-only", fault: "observed-per-turn-run-root-loss", remoteFaultAuthority: "pidfd-start-ticks-boot-id", replayAllowed: false },
|
||||
},
|
||||
{
|
||||
id: "rich-acp-warm-continuity", label: "Rich ACP warm continuity", manualOnly: true,
|
||||
description: "Three browser-driven turns with stable native session, runner process and workspace identity for Cursor.",
|
||||
|
||||
@@ -76,6 +76,18 @@ function processTable(): ProcessIdentity[] {
|
||||
return output.split("\n").flatMap(line => { const m = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/u.exec(line); return m ? [{ pid: Number(m[1]), parent: Number(m[2]), start: m[3]! }] : []; });
|
||||
}
|
||||
export interface RunProcessAuthority { pid: number; groupId: number; startedAt: string; runId: string }
|
||||
/** Closed fault injection for the already observed, isolated per-turn run.
|
||||
* The caller cannot select a signal, executable, or an unobserved process.
|
||||
*/
|
||||
export function injectObservedRunLoss(authority: RunProcessAuthority, journal: readonly ProcessIdentity[]) {
|
||||
const retained = journal.find(row => row.pid === authority.pid);
|
||||
const current = processTable().find(row => row.pid === authority.pid);
|
||||
if (!retained || !current || retained.start !== current.start || authority.pid === process.pid) throw new Error("Provider-loss run ownership changed");
|
||||
const argv = execFileSync("/bin/ps", ["-p", String(current.pid), "-o", "command="], { encoding: "utf8", timeout: 3000, maxBuffer: 65536 });
|
||||
if (!isPerTurnRunProcess(authority, current, argv) || !processTable().some(row => row.pid === current.pid && row.start === current.start)) throw new Error("Provider-loss run is not the retained per-turn owner");
|
||||
process.kill(current.pid, "SIGKILL");
|
||||
return { root: retained, signal: "SIGKILL", observedMonotonicNs: process.hrtime.bigint().toString() };
|
||||
}
|
||||
export function isPerTurnRunProcess(authority: RunProcessAuthority, observed: ProcessIdentity, command: string): boolean {
|
||||
if (!/^[a-zA-Z0-9_-]{1,128}$/u.test(authority.runId) || authority.pid !== observed.pid || authority.groupId !== observed.pid) return false;
|
||||
const started = Date.parse(authority.startedAt), actual = Date.parse(observed.start);
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { pollUntil, type RunnerApi } from "./api.js";
|
||||
import { collectRunEvents } from "./run-observations.js";
|
||||
import { createTaskThroughUi } from "./user-actions.js";
|
||||
import { createDeniedTargetFixture, exists, observeRunProcesses, injectObservedRunLoss } from "./native-local-fixtures.js";
|
||||
import { assertNativeRemoteRetirement, nativeRemoteDeniedSample, prepareNativeRemoteAction, type NativeRemoteBootstrap, type NativeRemoteFixture, type NativeRemoteSnapshot } from "./native-remote-evidence.js";
|
||||
import { cursorDeniedCommand } from "./cursor-native-evidence.js";
|
||||
import { readActiveStopCaller, observeActiveStopPending, type ActiveStopPending, type ActiveStopScope } from "./native-active-stop-evidence.js";
|
||||
import type { BootstrapReadProof } from "./native-bootstrap-read-proof.js";
|
||||
import { bootstrapReadExecutionId } from "./native-bootstrap-read-proof.js";
|
||||
import type { LiveFixtureValues } from "./live-fixtures.js";
|
||||
import type { MatrixExecution } from "./types.js";
|
||||
type Row = Record<string, any>;
|
||||
type Check = { id: string; passed: boolean; detail: string };
|
||||
|
||||
/** Transport loss must never turn an unanswered native mutation into success. */
|
||||
export function assertProviderLossSettlement(run: Row, issue: Row, events: readonly Row[], pending: ActiveStopPending) {
|
||||
const fail = (ok: boolean, reason: string) => { if (!ok) throw new Error(`Native provider loss: ${reason}`); };
|
||||
fail(run.id === pending.scope.runId && run.companyId === pending.scope.companyId && run.nativeIssueId === pending.scope.issueId
|
||||
&& run.status === "failed" && run.runtimeMode === "native" && typeof run.error === "string" && run.error.length > 0
|
||||
&& issue.id === pending.scope.issueId && issue.companyId === pending.scope.companyId && issue.status === "in_progress"
|
||||
&& run.resultJson?.nativeCancellation == null, "failure must leave the task open without claiming Stop or success");
|
||||
const frames = events.flatMap(row => row.payload?.prpEvent ? [row.payload.prpEvent] : []);
|
||||
const closures = frames.filter(frame => ["runtime_request.resolved", "runtime_request.cancelled", "runtime_request.expired"].includes(frame.eventType)
|
||||
&& frame.payload?.requestId === pending.requestId);
|
||||
fail(closures.length === 1 && ["runtime_request.cancelled", "runtime_request.expired"].includes(closures[0].eventType)
|
||||
&& closures[0].payload.turnId === pending.turnId && closures[0].payload.requestKind === "permission_approval"
|
||||
&& closures[0].payload.action === undefined && closures[0].payload.response === undefined && closures[0].payload.replayAllowed !== true,
|
||||
"undeliverable permission must close without acceptance or replay");
|
||||
fail(!frames.some(frame => frame.eventType === "turn.completed" || frame.eventType === "completion.accepted"
|
||||
|| frame.eventType === "tool.execution.completed" && frame.payload?.executionId === bootstrapReadExecutionId(pending.toolCallId)
|
||||
&& frame.payload?.status === "completed"), "no completion or completed mutation after loss");
|
||||
return { schema: "paperclip.e2e.native-provider-loss.v1", requestId: pending.requestId, turnId: pending.turnId,
|
||||
requestClosure: closures[0].eventType, taskStillOpen: true, failedRun: true, replayAllowed: false };
|
||||
}
|
||||
|
||||
export async function runNativeProviderLossFlow(input: {
|
||||
page: Page; api: RunnerApi; fixtures: LiveFixtureValues; execution: MatrixExecution; nonce: string; workspacePath: string; deadlineAt: number;
|
||||
remoteBootstrap?: NativeRemoteBootstrap;
|
||||
registerCleanupAssertion(callback: () => Promise<Check[]>): void;
|
||||
registerBeforeEnvironmentTeardownAssertion(callback: () => Promise<Check[]>): void;
|
||||
observe(issue: Row, runs: Row[]): void;
|
||||
capture(id: string, label: string, file: string): Promise<void>;
|
||||
evidence(name: string, value: unknown): Promise<void>;
|
||||
}) {
|
||||
const { api, page, fixtures, execution, nonce } = input;
|
||||
if (execution.profile.qualificationCandidate !== "cursor" || execution.task.id !== "pending-permission-provider-loss") throw new Error("Unknown provider-loss case");
|
||||
const remote = execution.environment.id === "daytona";
|
||||
if (remote && !input.remoteBootstrap) throw new Error("Provider loss requires its admitted remote observer");
|
||||
const local = remote ? undefined : await createDeniedTargetFixture(input.workspacePath, `provider-loss-${nonce}.txt`);
|
||||
const target = local?.targetRelativePath ?? `provider-loss-${nonce}.txt`;
|
||||
let command = local ? cursorDeniedCommand(local.targetPath) : undefined;
|
||||
const observer = remote ? undefined : observeRunProcesses();
|
||||
let issue: Row = {}, runs: Row[] = [], events: Row[] = [];
|
||||
let fixture: NativeRemoteFixture | undefined, baseline: NativeRemoteSnapshot | undefined, sealed: NativeRemoteSnapshot | undefined;
|
||||
let processes: ReturnType<ReturnType<typeof observeRunProcesses>["sample"]> | undefined;
|
||||
let pending: ActiveStopPending | undefined, settled = false;
|
||||
const checks: Check[] = [], samples: Array<{ phase: string; absent: boolean }> = [];
|
||||
const check = (id: string, ok: boolean, detail: string) => { checks.push({ id, passed: ok, detail }); expect(ok, detail).toBe(true); };
|
||||
const scope = (): ActiveStopScope => ({ provider: "cursor", companyId: fixtures.company.id, issueId: issue.id, runId: runs[0]!.id, target, commandSha256: command!.commandSha256 });
|
||||
const bootstrap = (): BootstrapReadProof | undefined => fixture ? { actionFile: fixture.actionFile, events } : undefined;
|
||||
const authority = () => { const run = runs[0]!; return { pid: run.processPid, groupId: run.processGroupId, startedAt: run.processStartedAt, runId: run.id }; };
|
||||
const load = async () => {
|
||||
if (issue.id) issue = await api.get<Row>(`/api/issues/${issue.id}`);
|
||||
const list = await api.get<Row[]>(`/api/companies/${fixtures.company.id}/heartbeat-runs?limit=100`);
|
||||
runs = await Promise.all(list.map(run => api.get<Row>(`/api/heartbeat-runs/${run.id}`)));
|
||||
if (runs.length > 1) throw new Error("Provider loss automatically dispatched another run");
|
||||
events = runs[0] ? await collectRunEvents<Row>((afterSeq, limit) => api.get(`/api/heartbeat-runs/${runs[0]!.id}/events?afterSeq=${afterSeq}&limit=${limit}`)) : [];
|
||||
if (observer && runs[0]?.processPid) processes = observer.sample(authority());
|
||||
input.observe(issue, runs); return { run: runs[0] ?? {}, issue, events };
|
||||
};
|
||||
const sample = async (phase: string) => {
|
||||
const absent = !await exists(local!.targetPath); samples.push({ phase, absent }); check(`no-effect-${phase}`, absent, "The exact unapproved target stays absent");
|
||||
};
|
||||
const cleanup = async () => {
|
||||
const receipts: Check[] = [];
|
||||
try {
|
||||
if (remote) {
|
||||
if (!fixture || !baseline) throw new Error("Provider-loss observer was not armed");
|
||||
sealed ??= await fixture.finish(); assertNativeRemoteRetirement(sealed, baseline);
|
||||
nativeRemoteDeniedSample(sealed, baseline, target, "after-cleanup");
|
||||
} else {
|
||||
await pollUntil({ label: "lost run's owned processes retire", deadlineAt: Date.now() + 5000, intervalMs: 100,
|
||||
load: async () => { await load(); return processes; }, accept: value => Boolean(value?.captured && value.live.length === 0) });
|
||||
await sample("after-cleanup"); const watch = local!.watcher.finish();
|
||||
if (!watch.complete || watch.targetMutationCount !== 0 || samples.length !== 4 || !samples.every(row => row.absent)) throw new Error("Provider-loss continuous no-effects evidence is incomplete");
|
||||
}
|
||||
await load(); if (!settled || !pending) throw new Error("Provider-loss settlement was not established");
|
||||
assertProviderLossSettlement(runs[0]!, issue, events, pending);
|
||||
receipts.push({ id: "provider-loss-retired-no-replay", passed: true, detail: "Owned run and provider processes retired; pending mutation closed without replay or effects" });
|
||||
return receipts;
|
||||
} finally {
|
||||
await input.evidence("native-provider-loss-cleanup.json", { processes, samples, sealed, receipts });
|
||||
await fixture?.close();
|
||||
}
|
||||
};
|
||||
(remote ? input.registerBeforeEnvironmentTeardownAssertion : input.registerCleanupAssertion)(cleanup);
|
||||
const prompt = () => `${execution.task.buildPrompt(nonce)}\nUse this exact native shell command once:\n${command!.command}`;
|
||||
try {
|
||||
const caller = readActiveStopCaller(await api.get("/api/health"), await api.get("/api/auth/get-session"));
|
||||
const agent = await api.get<Row>(`/api/agents/${fixtures.agent.id}`);
|
||||
await api.patch(`/api/agents/${fixtures.agent.id}`, { adapterConfig: { ...agent.adapterConfig, acpxPermissionMode: "approve-reads", acpxSessionMode: "agent", timeoutSec: 120, lifecycleMode: "per_turn" } });
|
||||
const project = await api.post<Row>(`/api/companies/${fixtures.company.id}/projects`, { name: `Native provider loss ${nonce}`, executionWorkspacePolicy: { enabled: true, defaultMode: "shared_workspace", sharedWorkspaceConcurrency: "serialize", allowIssueOverride: false, environmentId: fixtures.environment.id, workspaceStrategy: { type: "project_primary" } }, workspace: { name: "Primary", sourceType: "local_path", cwd: input.workspacePath, isPrimary: true } });
|
||||
if (!remote) await sample("before-request");
|
||||
await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, title: execution.task.buildTitle(nonce), prompt: remote ? input.remoteBootstrap!.prompt(nonce) : prompt(), workMode: "standard", projectName: project.name });
|
||||
issue = (await pollUntil({ label: "browser-created provider-loss task", deadlineAt: input.deadlineAt,
|
||||
load: async () => (await api.get<Row[]>(`/api/companies/${fixtures.company.id}/issues?limit=100`)).find(row => row.title === execution.task.buildTitle(nonce)), accept: Boolean }))!;
|
||||
if (remote) {
|
||||
await pollUntil({ label: "provider-loss bootstrap", deadlineAt: input.deadlineAt, load, accept: value => value.run.status === "running" });
|
||||
fixture = await input.remoteBootstrap!.bindAndRelease({ issueId: issue.id, runId: runs[0]!.id, targets: [target], actionPrompt: async value => {
|
||||
fixture = value; command = cursorDeniedCommand(join(value.remoteCwd, target));
|
||||
const prepared = await prepareNativeRemoteAction({ fixture: value, companyId: fixtures.company.id, environmentId: fixtures.environment.id, runId: runs[0]!.id, target, prompt: prompt() });
|
||||
baseline = prepared.baseline; await input.evidence("provider-loss-baseline.json", baseline); return prepared.prompt;
|
||||
} });
|
||||
}
|
||||
await pollUntil({ label: "unanswered native permission before loss", deadlineAt: input.deadlineAt, intervalMs: 200, load,
|
||||
accept: value => { pending = observeActiveStopPending({ ...value, scope: scope(), caller, cancellationRequestId: randomUUID(), bootstrap: bootstrap() }); return true; } });
|
||||
await page.goto(`/${fixtures.company.issuePrefix}/issues/${issue.identifier ?? issue.id}`);
|
||||
const card = page.getByTestId("task-chat-runtime-request").filter({ visible: true });
|
||||
await expect(card).toHaveCount(1); await expect(card.getByRole("button", { name: "Deny", exact: true })).toBeEnabled();
|
||||
await input.capture("pending-before-loss", "Unanswered native mutation before runtime loss", "pending-before-loss.png");
|
||||
await input.evidence("provider-loss-pending.json", pending);
|
||||
if (remote) {
|
||||
const snap = await fixture!.snapshot("pending-before-loss"); nativeRemoteDeniedSample(snap, baseline!, target, "pending");
|
||||
await input.evidence("provider-loss-pending-remote.json", snap);
|
||||
if (!fixture!.injectOwnedRunLoss) throw new Error("Remote observer cannot inject an owned loss");
|
||||
await input.evidence("provider-loss-injection.json", await fixture!.injectOwnedRunLoss());
|
||||
} else {
|
||||
await sample("pending"); await load();
|
||||
if (!processes?.captured) throw new Error("Local run ownership was not observed");
|
||||
await input.evidence("provider-loss-injection.json", injectObservedRunLoss(authority(), processes.journal));
|
||||
}
|
||||
await pollUntil({ label: "failed run after owned transport loss", deadlineAt: input.deadlineAt, intervalMs: 200, load,
|
||||
accept: value => { if (["succeeded", "cancelled"].includes(value.run.status)) throw new Error("Provider loss falsely succeeded or became Stop"); return value.run.status === "failed"; } });
|
||||
const settlement = assertProviderLossSettlement(runs[0]!, issue, events, pending!); settled = true;
|
||||
const response = await api.request.post(`/api/heartbeat-runs/${runs[0]!.id}/runtime-requests/${encodeURIComponent(pending!.requestId)}/resolve`,
|
||||
{ data: { turnId: pending!.turnId, requestKind: "permission_approval", resolution: { action: "accept" } } });
|
||||
check("stale-acceptance-refused", response.status() === 409, "An approval cannot revive the lost provider or replay its mutation");
|
||||
if (remote) { sealed = await fixture!.finish(); assertNativeRemoteRetirement(sealed, baseline!); nativeRemoteDeniedSample(sealed, baseline!, target, "terminal"); }
|
||||
else await sample("after-loss");
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("task-chat-thread").getByTestId("task-chat-collapsible-marker").filter({ has: page.getByText("Run failed", { exact: true }) })).toBeVisible({ timeout: 30000 });
|
||||
await expect(page.getByTestId("task-chat-history-loading")).toHaveCount(0);
|
||||
await expect(card.getByRole("button", { name: "Deny", exact: true })).toHaveCount(0);
|
||||
await load(); assertProviderLossSettlement(runs[0]!, issue, events, pending!);
|
||||
check("one-unfinished-failed-run", runs.length === 1 && issue.status === "in_progress", "No automatic mutation replay or false task completion");
|
||||
await input.capture("final-state", "Lost runtime fails clearly and leaves the task open", "final-state.png");
|
||||
await input.evidence("api-state.json", { issue, runs, checks, settlement, runEvents: events, runEventsByRun: [{ runId: runs[0]!.id, events }] });
|
||||
return { issue, runs, checks };
|
||||
} finally { await input.evidence("provider-loss-checks.json", { issue, runs, checks, samples, settled }); }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { assertProviderLossSettlement } from "./native-provider-loss-flow.js";
|
||||
import { bootstrapReadExecutionId } from "./native-bootstrap-read-proof.js";
|
||||
import type { ActiveStopPending } from "./native-active-stop-evidence.js";
|
||||
|
||||
function fixture() {
|
||||
const pending = { scope: { runId: "run", companyId: "company", issueId: "issue" }, requestId: "permission", turnId: "turn", toolCallId: "mutation" } as ActiveStopPending;
|
||||
const run = { id: "run", companyId: "company", nativeIssueId: "issue", runtimeMode: "native", status: "failed", error: "provider_transport_failed", resultJson: {} };
|
||||
const issue = { id: "issue", companyId: "company", status: "in_progress" };
|
||||
const events = [{ payload: { prpEvent: { eventType: "runtime_request.expired", payload: { requestId: "permission", turnId: "turn", requestKind: "permission_approval" } } } }];
|
||||
return { pending, run, issue, events };
|
||||
}
|
||||
it("allows completed bootstrap reads while fencing the unanswered mutation", () => {
|
||||
const f = fixture();
|
||||
f.events.push({ payload: { prpEvent: { eventType: "tool.execution.completed", payload: { executionId: "bootstrap-read", status: "completed" } } } } as any);
|
||||
expect(assertProviderLossSettlement(f.run, f.issue, f.events, f.pending)).toMatchObject({ failedRun: true, replayAllowed: false });
|
||||
});
|
||||
it.each(["succeeded", "cancelled", "timed_out"])("does not certify %s as provider loss", status => {
|
||||
const f = fixture(); f.run.status = status;
|
||||
expect(() => assertProviderLossSettlement(f.run, f.issue, f.events, f.pending)).toThrow("failure must leave");
|
||||
});
|
||||
it("rejects accepting the stale permission", () => {
|
||||
const f = fixture(); f.events[0]!.payload.prpEvent.eventType = "runtime_request.resolved";
|
||||
expect(() => assertProviderLossSettlement(f.run, f.issue, f.events, f.pending)).toThrow("must close");
|
||||
});
|
||||
it("rejects a completed mutation even when the final run failed", () => {
|
||||
const f = fixture();
|
||||
f.events.push({ payload: { prpEvent: { eventType: "tool.execution.completed", payload: { executionId: bootstrapReadExecutionId(f.pending.toolCallId), status: "completed" } } } } as any);
|
||||
expect(() => assertProviderLossSettlement(f.run, f.issue, f.events, f.pending)).toThrow("completed mutation");
|
||||
});
|
||||
@@ -14,6 +14,7 @@ export interface NativeRemoteSnapshot {
|
||||
export interface NativeRemoteFixture {
|
||||
binding: NativeRemoteBinding; remoteCwd: string; actionFile: string;
|
||||
snapshot(label: string): Promise<NativeRemoteSnapshot>;
|
||||
injectOwnedRunLoss?(): Promise<unknown>;
|
||||
setupAttachedCommand(input: { marker: string; markerText: string; delayMs: number }): Promise<{ command: string; commandSha256: string }>;
|
||||
finish(): Promise<NativeRemoteSnapshot>; readFile(relative: string): Promise<Buffer>; close(): Promise<void>;
|
||||
}
|
||||
@@ -63,4 +64,3 @@ export async function prepareNativeRemoteAction(input: {
|
||||
if (!target?.complete || !target.absent || target.sha256 !== null || target.mutationCount !== 0) throw new Error("Native remote target was present or unobserved before action");
|
||||
return { baseline, command, prompt: `${input.prompt}\nThe admitted remote workspace is ${f.remoteCwd}.${command ? `\nThe exact supplied command is:\n${command.command}\nDo not inspect or modify fixture code, fabricate its marker, or launch a substitute command.` : ""}` };
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export function assertRemoteNativeEvidencePrerequisites(
|
||||
environment: NodeJS.ProcessEnv,
|
||||
): void {
|
||||
if (!executions.some(execution => execution.environment.id === "daytona"
|
||||
&& ["cursor_native", "native_active_stop"].includes(execution.task.flow))) return;
|
||||
&& ["cursor_native", "native_active_stop", "native_provider_loss"].includes(execution.task.flow))) return;
|
||||
const names = ["PAPERCLIP_E2E_DAYTONA_NODE_SHA256", "PAPERCLIP_E2E_DAYTONA_RUNNERD_SHA256"];
|
||||
const invalid = names.filter(name => !/^sha256:[a-f0-9]{64}$/u.test(environment[name] ?? ""));
|
||||
if (invalid.length) throw new Error(`Native Daytona evidence requires exact image executable digests: ${invalid.join(", ")}`);
|
||||
|
||||
@@ -30,7 +30,7 @@ function relative(value: string): string {
|
||||
}
|
||||
// Only closed diagnostic enums cross the remote boundary; SDK errors and output
|
||||
// are never retained. These diagnostics explain failed evidence, not qualification.
|
||||
const RPC_PHASES = ["runtime-ready", "install", "wait", "close", "arm", "publish", "snapshot", "attached", "read"] as const;
|
||||
const RPC_PHASES = ["runtime-ready", "install", "wait", "close", "arm", "publish", "snapshot", "attached", "read", "inject-loss"] as const;
|
||||
const RPC_CODES = ["node_identity", "sentinel_type", "sentinel", "cwd", "runtime_root_identity", "runtime_binary_identity", "proc_bound", "ambiguous_run_root", "runtime_not_ready", "runtime_identity_changed", "invalid_proc_identity", "invalid_proc_fields", "socket_error", "socket_timeout", "output_bound", "rpc_deadline", "remote_unknown", "transport_failure", "invalid_response", "readiness_deadline"] as const;
|
||||
type RpcPhase = typeof RPC_PHASES[number];
|
||||
type RpcCode = typeof RPC_CODES[number];
|
||||
@@ -174,6 +174,9 @@ const server=net.createServer(socket=>{sockets.add(socket);socket.on('close',()=
|
||||
child=cp.spawn(process.execPath,['-e','setTimeout(()=>process.exit(0),'+r.delayMs+')'],{env:{PATH:'/usr/bin:/bin'},stdio:'ignore'});child.once('error',()=>{attached.failure='child_start';s.destroy()});child.once('exit',(code,signal)=>{attached.commandExit={code:code??-1,observedAtMs:Date.now(),observedMonotonicNs:process.hrtime.bigint().toString()};if(code!==0||signal){attached.failure='child_failed';s.destroy();return}try{fs.writeFileSync(path.join(config.binding.remoteCwd,r.marker),r.markerText,{flag:'wx'});attached.markerWrittenAtMs=Date.now();attached.markerWrittenMonotonicNs=process.hrtime.bigint().toString();s.end(JSON.stringify({code:0})+'\n')}catch{attached.failure='marker_failed';s.destroy()}});
|
||||
}catch{attached.failure='client_rejected';s.destroy()}})});srv.listen(clientSocket);attached.server=srv;result={clientScript,clientSocket};
|
||||
}
|
||||
else if(r.op==='inject-loss'){const p=sample();if(sealed||!complete||!publishedHash||!root||!p.live.includes(root.pid))throw Error('loss_owner_missing');const original=journal.get(root.pid);if(!original||original.startTicks!==root.startTicks||original.bootId!==root.bootId)throw Error('loss_owner_changed');
|
||||
const program="import os,signal,sys\npid=int(sys.argv[1]);fd=os.pidfd_open(pid)\ntry:\n s=open('/proc/'+str(pid)+'/stat').read();ticks=s[s.rfind(') ')+2:].split()[19];boot=open('/proc/sys/kernel/random/boot_id').read().strip()\n if ticks!=sys.argv[2] or boot!=sys.argv[3]:raise RuntimeError('identity changed')\n signal.pidfd_send_signal(fd,signal.SIGKILL)\nfinally:os.close(fd)";
|
||||
const killed=cp.spawnSync('/usr/bin/python3',['-c',program,String(root.pid),root.startTicks,root.bootId],{env:{PATH:'/usr/bin:/bin'},stdio:'pipe',timeout:3000});if(killed.status!==0)throw Error('loss_not_injected');result={root:original,signal:'SIGKILL',observedMonotonicNs:process.hrtime.bigint().toString()};}
|
||||
else if(r.op==='finish'){result=snapshot();if(!result.complete||!result.processes.captured||result.processes.live.length)throw Error('retirement_unproven');sealed=true;workspaceWatch.close();for(const t of targets.values())t.watch.close();}
|
||||
else if(r.op==='close'){shutdown(socket);return;}
|
||||
else throw Error('unknown_operation');
|
||||
@@ -212,6 +215,7 @@ export interface RemoteNativeFixture {
|
||||
/** Watchers and exact run-root identity are already armed at return from bind. */
|
||||
readonly baseline: RemoteNativeSnapshot;
|
||||
snapshot(label: string): Promise<RemoteNativeSnapshot>;
|
||||
injectOwnedRunLoss?(): Promise<unknown>;
|
||||
readFile(path: string): Promise<Buffer>;
|
||||
publishAction(path: string, text: string): Promise<void>;
|
||||
setupAttachedCommand(input: { marker: string; markerText: string; delayMs: number }): Promise<{ command: string; commandSha256: string }>;
|
||||
@@ -442,6 +446,14 @@ export async function bindRemoteNativeFixture(options: RemoteNativeFixtureOption
|
||||
fail(bytes.length <= 65536 && `sha256:${createHash("sha256").update(bytes).digest("hex")}` === data.sha256, "read_digest");
|
||||
return bytes;
|
||||
},
|
||||
async injectOwnedRunLoss() {
|
||||
fail(!closed && published && !finished, "loss_fixture_unavailable");
|
||||
const value = record(await rpc({ op: "inject-loss" }));
|
||||
const observed = record(value.root), root = baseline.processes.root;
|
||||
fail(root && observed.pid === root.pid && observed.startTicks === root.startTicks && observed.bootId === root.bootId
|
||||
&& value.signal === "SIGKILL" && typeof value.observedMonotonicNs === "string" && /^\d+$/u.test(value.observedMonotonicNs), "loss_owner_acknowledgement");
|
||||
return value;
|
||||
},
|
||||
async publishAction(path, text) {
|
||||
fail(!closed && !published && path === actionFile && typeof text === "string" && Buffer.byteLength(text) <= 16384, "publish_bound");
|
||||
// Set before RPC: uncertain delivery must never cause a second publish.
|
||||
|
||||
@@ -6,6 +6,7 @@ import { warmManagedFileEvidence } from "./warm-managed-files.js";
|
||||
import { gitFinalizationEvidence, gitStreamingEvidence, setupGitStreamingWorkspace } from "./daytona-git-streaming.js";
|
||||
import { runsCompletionUpdateProbe, completionQualityControls, completionQualityStatus, judgeCompletionQuality, reserveCompletionQuality, type CompletionQualityRecord } from "./completion-quality.js";
|
||||
import { runNativeActiveStopFlow } from "./native-active-stop-flow.js";
|
||||
import { runNativeProviderLossFlow } from "./native-provider-loss-flow.js";
|
||||
import { runCursorNativeFlow } from "./cursor-native-flow.js";
|
||||
import { createRemoteNativeBootstrap, createRemoteFixtureClient } from "./remote-native-bootstrap.js";
|
||||
import { runCleanupWithObservers, verifyCleanupAssertions, type CleanupAssertion } from "./cleanup-verification.js";
|
||||
@@ -569,7 +570,7 @@ for (const execution of executions) {
|
||||
const credentials = credentialValues();
|
||||
const secrets = normalizedSecrets(Object.values(credentials));
|
||||
const api = new RunnerApi(request);
|
||||
const companyRunFlow = execution.suite.id === "task-titles" || ["blocker_guidance", "continuation_accounting", "continuation", "context_integrity", "agent_chat", "everyday_workflow", "first_task", "instruction_persistence", "cursor_native", "native_active_stop"].includes(execution.task.flow);
|
||||
const companyRunFlow = execution.suite.id === "task-titles" || ["blocker_guidance", "continuation_accounting", "continuation", "context_integrity", "agent_chat", "everyday_workflow", "first_task", "instruction_persistence", "cursor_native", "native_active_stop", "native_provider_loss"].includes(execution.task.flow);
|
||||
const consoleDiagnostics: Array<Record<string, unknown>> = [];
|
||||
const networkDiagnostics: Array<Record<string, unknown>> = [];
|
||||
const pageLifecycleDiagnostics: Array<Record<string, unknown>> = [];
|
||||
@@ -885,7 +886,7 @@ for (const execution of executions) {
|
||||
nativeInitial = { issueIds: issues.map(value => value.id), agentIds: agents.map(value => value.id), workspaceDigest };
|
||||
}
|
||||
const remoteBootstrap = execution.environment.id === "daytona"
|
||||
&& ["cursor_native", "native_active_stop"].includes(execution.task.flow)
|
||||
&& ["cursor_native", "native_active_stop", "native_provider_loss"].includes(execution.task.flow)
|
||||
? createRemoteNativeBootstrap({
|
||||
api, daytona: await createRemoteFixtureClient(credentials.DAYTONA_API_KEY ?? ""),
|
||||
companyId: fixtures.company.id, environmentId: fixtures.environment.id, agentId: fixtures.agent.id,
|
||||
@@ -975,6 +976,15 @@ for (const execution of executions) {
|
||||
evidence: (name, data) => writeSanitizedJson(snapshotsDir, name, data, secrets),
|
||||
});
|
||||
issue = accounting.issue as IssueRecord; selectedRuns = accounting.runs as RunRecord[];
|
||||
} else if (execution.task.flow === "native_provider_loss") {
|
||||
const story = await runNativeProviderLossFlow({
|
||||
page, api, fixtures, execution, nonce, workspacePath, deadlineAt: startedAtMs + deadlineMs,
|
||||
observe: (currentIssue, currentRuns) => { issue = currentIssue as IssueRecord; selectedRuns = currentRuns as RunRecord[]; },
|
||||
capture: captureScreenshot, evidence: (name, data) => writeSanitizedJson(snapshotsDir, name, data, secrets),
|
||||
remoteBootstrap, registerCleanupAssertion, registerBeforeEnvironmentTeardownAssertion,
|
||||
});
|
||||
issue = story.issue as IssueRecord; selectedRuns = story.runs as RunRecord[];
|
||||
matcherResults = story.checks.map(check => ({ matcher: { kind: "json_path" as const, path: `nativeProviderLoss.${check.id}`, expected: true }, passed: check.passed, detail: check.detail }));
|
||||
} else if (execution.task.flow === "native_active_stop") {
|
||||
const story = await runNativeActiveStopFlow({
|
||||
page, api, fixtures, execution, nonce, workspacePath, deadlineAt: startedAtMs + deadlineMs,
|
||||
|
||||
@@ -31,6 +31,7 @@ export type RunnerTaskFlow =
|
||||
| "warm_three_turn"
|
||||
| "instruction_persistence"
|
||||
| "native_active_stop"
|
||||
| "native_provider_loss"
|
||||
| "cursor_native";
|
||||
|
||||
export interface SecretReference {
|
||||
|
||||
Reference in new issue
Block a user