diff --git a/doc/plans/2026-10-03-cursor-production-readiness.md b/doc/plans/2026-10-03-cursor-production-readiness.md index 5debf8e89a..9586b91b48 100644 --- a/doc/plans/2026-10-03-cursor-production-readiness.md +++ b/doc/plans/2026-10-03-cursor-production-readiness.md @@ -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. diff --git a/packages/paperclip-runner/scripts/cursor-provisioner-layout.mjs b/packages/paperclip-runner/scripts/cursor-provisioner-layout.mjs new file mode 100644 index 0000000000..311ba30d29 --- /dev/null +++ b/packages/paperclip-runner/scripts/cursor-provisioner-layout.mjs @@ -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"); +} diff --git a/packages/paperclip-runner/scripts/cursor-provisioner-layout.test.mjs b/packages/paperclip-runner/scripts/cursor-provisioner-layout.test.mjs new file mode 100644 index 0000000000..abfcde8ebc --- /dev/null +++ b/packages/paperclip-runner/scripts/cursor-provisioner-layout.test.mjs @@ -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/); +}); diff --git a/packages/paperclip-runner/scripts/provision-cursor.mjs b/packages/paperclip-runner/scripts/provision-cursor.mjs index 1623eeb92d..5d04c7f322 100644 --- a/packages/paperclip-runner/scripts/provision-cursor.mjs +++ b/packages/paperclip-runner/scripts/provision-cursor.mjs @@ -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; diff --git a/scripts/generate-npm-package-json.mjs b/scripts/generate-npm-package-json.mjs index a972dc3e94..bfa1e13ecf 100644 --- a/scripts/generate-npm-package-json.mjs +++ b/scripts/generate-npm-package-json.mjs @@ -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 ]"); +} +const outPath = outputPath ? resolve(outputPath) : resolve(repoRoot, "cli/package.json"); writeFileSync(outPath, output); console.log(` ✓ Generated publishable package.json (${Object.keys(sortedDeps).length} deps)`); diff --git a/scripts/verify-cursor-npm-install.mjs b/scripts/verify-cursor-npm-install.mjs new file mode 100644 index 0000000000..f14c028939 --- /dev/null +++ b/scripts/verify-cursor-npm-install.mjs @@ -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)); diff --git a/tests/runner-e2e/catalog.test.ts b/tests/runner-e2e/catalog.test.ts index 794ab9d0cf..c6e79fc5f2 100644 --- a/tests/runner-e2e/catalog.test.ts +++ b/tests/runner-e2e/catalog.test.ts @@ -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); diff --git a/tests/runner-e2e/catalog.ts b/tests/runner-e2e/catalog.ts index 601f42edf8..16c98efd89 100644 --- a/tests/runner-e2e/catalog.ts +++ b/tests/runner-e2e/catalog.ts @@ -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.", diff --git a/tests/runner-e2e/native-local-fixtures.ts b/tests/runner-e2e/native-local-fixtures.ts index 0179869c22..52e0a9f59f 100644 --- a/tests/runner-e2e/native-local-fixtures.ts +++ b/tests/runner-e2e/native-local-fixtures.ts @@ -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); diff --git a/tests/runner-e2e/native-provider-loss-flow.ts b/tests/runner-e2e/native-provider-loss-flow.ts new file mode 100644 index 0000000000..0527ed3194 --- /dev/null +++ b/tests/runner-e2e/native-provider-loss-flow.ts @@ -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; +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): void; + registerBeforeEnvironmentTeardownAssertion(callback: () => Promise): void; + observe(issue: Row, runs: Row[]): void; + capture(id: string, label: string, file: string): Promise; + evidence(name: string, value: unknown): Promise; +}) { + 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["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(`/api/issues/${issue.id}`); + const list = await api.get(`/api/companies/${fixtures.company.id}/heartbeat-runs?limit=100`); + runs = await Promise.all(list.map(run => api.get(`/api/heartbeat-runs/${run.id}`))); + if (runs.length > 1) throw new Error("Provider loss automatically dispatched another run"); + events = runs[0] ? await collectRunEvents((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(`/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(`/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(`/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 }); } +} diff --git a/tests/runner-e2e/native-provider-loss.test.ts b/tests/runner-e2e/native-provider-loss.test.ts new file mode 100644 index 0000000000..a876846772 --- /dev/null +++ b/tests/runner-e2e/native-provider-loss.test.ts @@ -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"); +}); diff --git a/tests/runner-e2e/native-remote-evidence.ts b/tests/runner-e2e/native-remote-evidence.ts index 436a02af27..68b55caa3c 100644 --- a/tests/runner-e2e/native-remote-evidence.ts +++ b/tests/runner-e2e/native-remote-evidence.ts @@ -14,6 +14,7 @@ export interface NativeRemoteSnapshot { export interface NativeRemoteFixture { binding: NativeRemoteBinding; remoteCwd: string; actionFile: string; snapshot(label: string): Promise; + injectOwnedRunLoss?(): Promise; setupAttachedCommand(input: { marker: string; markerText: string; delayMs: number }): Promise<{ command: string; commandSha256: string }>; finish(): Promise; readFile(relative: string): Promise; close(): Promise; } @@ -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.` : ""}` }; } - diff --git a/tests/runner-e2e/prerequisites.ts b/tests/runner-e2e/prerequisites.ts index 7a2020be28..227318fbf5 100644 --- a/tests/runner-e2e/prerequisites.ts +++ b/tests/runner-e2e/prerequisites.ts @@ -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(", ")}`); diff --git a/tests/runner-e2e/remote-native-fixtures.ts b/tests/runner-e2e/remote-native-fixtures.ts index 924e7a9682..baea91715e 100644 --- a/tests/runner-e2e/remote-native-fixtures.ts +++ b/tests/runner-e2e/remote-native-fixtures.ts @@ -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; + injectOwnedRunLoss?(): Promise; readFile(path: string): Promise; publishAction(path: string, text: string): Promise; 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. diff --git a/tests/runner-e2e/runner.spec.ts b/tests/runner-e2e/runner.spec.ts index 10ed14b10a..08d7ebd520 100644 --- a/tests/runner-e2e/runner.spec.ts +++ b/tests/runner-e2e/runner.spec.ts @@ -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> = []; const networkDiagnostics: Array> = []; const pageLifecycleDiagnostics: Array> = []; @@ -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, diff --git a/tests/runner-e2e/types.ts b/tests/runner-e2e/types.ts index 5920929f25..8a5aa34dff 100644 --- a/tests/runner-e2e/types.ts +++ b/tests/runner-e2e/types.ts @@ -31,6 +31,7 @@ export type RunnerTaskFlow = | "warm_three_turn" | "instruction_persistence" | "native_active_stop" + | "native_provider_loss" | "cursor_native"; export interface SecretReference {