Files
PaperClipAI/tests/runner-e2e/daytona-image-content.ts
T
eece07a747 fix: repair the complete projectless repository setup chain (#17088)
## Thinking Path

> - Paperclip lets people manage agents that do useful work.
> - A projectless task can prepare an authorized repository in its
retained workspace.
> - Setup must hand work to a later run without user supervision.
> - Real task attempts exposed gaps across admission, completion, and
queue dispatch.
> - A successful provider exit did not prove that the requested result
was delivered.
> - This PR repairs the existing lifecycle and checks the complete
repository journey.

## Linked Issues or Issue Description

Refs #17047. This PR includes the preserved repairs from #17049, #17052,
and #17082.

**What happened?**

A projectless native task could stop after requesting a repository. It
could also continue through a timer or generic recovery path and lose
its saved conversation lineage. The latest real Codex and Claude
attempts delivered correct files, but the native continuation had been
folded into the finishing predecessor. Both attempts remain failed
measurements.

**Expected behavior**

Prepare the authorized repository, retain the original context, and
dispatch one saved continuation after its predecessor finishes. Deliver
the verified result in the originating conversation. Stop, current
access, approval, and real blocker rules remain authoritative.

**Steps to reproduce**

Run the explicit automatic-repository suite for native Codex or Claude.
Inspect the downloaded result, original context artifact, both committed
run records, and continuation receipts. The suite uses one attempt and
does not widen the default paid selection.

## What Changed

- Resolve the retained local-board identity through current company
membership and workspace authority.
- Prepare required source dependencies before the isolated server
imports native tool authority.
- Explain the saved repository continuation in production completion
tools. Correct only the exact pending-checkout blocker backed by a live
receipt. Reject an unfinished response wait without a real response
source.
- Preserve the persisted task-session workspace source and its explicit
absence of root repository context. Permit Git's exact no-repository
result only for a validated plain task directory. Keep other Git errors
and linked metadata strict.
- Revalidate server-resolved same-agent continuation values against
current ownership and the saved kind, summary, and key. Carry this shape
through the internal bridge schema. Keep provider-facing schemas
restrictive.
- Preserve terminal result authority after a correlated successful
controller response. Controller rejection creates no result authority.
Bind the shared guidance and transport source in Copilot v55 and
preserve historical identities.
- Admit native status wakes with the existing no-coalescing contract.
The existing deferred queue retains a distinct future dispatch instead
of retagging the finishing run.
- Recover deferred conversation lineage through the actual admitted
dispatch. Accept a null outbox run binding only with matching decision,
effect, dispatch actor, idempotency, task, company, agent, and requester
evidence. Reject a conflicting nonnull binding.
- Independently grade retained random context, actual checkout bytes and
HEAD, accessible output, distinct continuation receipts, and admission
order.

## Verification

- Real database and Git regressions fail before their repairs. Prior
focused cohorts cover local-board authority, optional repository waits,
plain task directories, and first/reused workspace provenance.
- The new valid decision/effect-backed assembled-heartbeat regression
reproduces coalescing into its active predecessor before the queue
repair. With the repair, it dispatches one successor after the
predecessor finishes. Duplicate sweeps create no extra run.
- The deferred-outbox conversation regression fails before its repair.
The repaired test also rejects a conflicting predecessor binding.
- The current queue, continuation, publication, cancellation,
stale-admission, and domain cohort passes 344 checks across 11 suites.
Server and Product E2E typechecks pass. The strengthened
automatic-repository oracle passes 34 calibration checks.
- Three omitted/null/resolved forms pass through the compiled Rust
runner and controlled provider. The prior shared bridge cohort passes
492 TypeScript checks and 399 Rust core checks. Runner source identity,
TypeScript, Rust formatting, and locked Rust typechecks pass.
- Six real local development cells passed ordinary repository,
conversion, and necessary-question journeys. Independent artifacts and
rendered question/result evidence agree. They do not qualify staging or
Slack.
- Both latest automatic-repository attempts delivered correct files but
failed the strict continuation check. They remain failures. A fresh
replay of this queue repair is pending.
- On the combined candidate `380047f4b923807a8c4f7ef6016e4fe130ab4a3a`,
`pnpm -r typecheck` and `pnpm build` pass. Product E2E support passes
3,525 checks with two existing skips, plus 128 source checks. The
complete local `pnpm test:run` is running and is not yet a pass.
- The first replay selection failed catalog validation due to an
operator profile-ID error. A second selection used the wrong
default-model suite and was cancelled before any provider job existed.
Both setup attempts remain recorded. The corrected campaign selects the
recorded GPT-6.1 and Opus 5.5 models plus the local Stop/resume journey.
- Exact-head CI, review, and deployment acceptance remain pending.
GitHub reports delayed PR updates and webhook delivery; the current head
and checks have now propagated. No earlier-head green badge establishes
acceptance of this commit.

## Risks

This changes several boundaries of repository setup. Full product and
deployment acceptance remain required. The queue uses the existing
deferred admission and delivery contracts. Batch deduplication remains
in place. The provenance repair requires the actual bound dispatch and
does not grant publication authority. Current authorization is still
checked at publication. No accepted provider result is rewritten, and
completed provider work is not rerun to repair persistence or delivery.

## Model Used

OpenAI Codex, GPT-6 family, with repository tools and code execution.
The runtime does not expose its exact deployment ID or context-window
size. Those values are not inferred.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Paperclip <paperclip@paperclip.ing>
2026-10-21 11:21:55 -05:00

341 lines
13 KiB
TypeScript

import { createHash } from "node:crypto";
import { lstat, readdir, readFile, readlink } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const repositoryRoot = path.resolve(import.meta.dirname, "../..");
export const DAYTONA_IMAGE_CONTENT_SCHEMA =
"paperclip-daytona-runner-image-content/v5";
export const DAYTONA_IMAGE_PLATFORM = "linux/amd64";
export const DAYTONA_IMAGE_DOCKERFILE_PATH = "docker/daytona-runner/Dockerfile";
// This mirrors the explicit repository-local build inputs copied by
// docker/daytona-runner/Dockerfile. Broad package-tree COPYs are forbidden by
// the contract test so development-only files cannot silently enter the image
// without first changing this content-identity contract.
export const DAYTONA_IMAGE_INPUT_PATHS = [
".dockerignore",
".npmrc",
"docker/daytona-runner/Dockerfile",
"package.json",
"patches",
"pnpm-lock.yaml",
"pnpm-workspace.yaml",
"scripts/link-plugin-dev-sdk.mjs",
"tsconfig.base.json",
"packages/paperclip-eval-kernel/package.json",
"packages/paperclip-eval-kernel/src",
"packages/paperclip-eval-kernel/tsconfig.json",
"packages/paperclip-runner/scripts/provision-grok.mjs",
"packages/paperclip-runner/package.json",
"packages/paperclip-runner/cursor-distributions.json",
"packages/paperclip-runner/cursor-contract.json",
"packages/paperclip-runner/protocol",
"packages/paperclip-runner/runner/Cargo.lock",
"packages/paperclip-runner/runner/Cargo.toml",
"packages/paperclip-runner/runner/crates",
"packages/paperclip-runner/scripts/acpx-sidecar-contract.mjs",
"packages/paperclip-runner/scripts/build-provider-pack.mjs",
"packages/paperclip-runner/scripts/provider-pack-executable-shims.mjs",
"packages/paperclip-runner/scripts/build-node-startup-timeout.mjs",
"packages/paperclip-runner/scripts/candidate-provider-pack.mjs",
"packages/paperclip-runner/scripts/build-copilot-distribution.mjs",
"packages/paperclip-runner/scripts/copilot-inner-distribution.mjs",
"packages/paperclip-runner/scripts/copilot-profile-identity.mjs",
"packages/paperclip-runner/scripts/materialize-copilot-binary.mjs",
"packages/paperclip-runner/scripts/install-copilot-assets.mjs",
"packages/paperclip-runner/test/fixtures/copilot-profile-v12-identity.json",
"packages/paperclip-runner/test/fixtures/copilot-profile-v14-identity.json",
"packages/paperclip-runner/test/fixtures/copilot-profile-v21-identity.json",
"packages/paperclip-runner/test/fixtures/copilot-profile-v24-identity.json",
"packages/paperclip-runner/test/fixtures/copilot-profile-v25-identity.json",
"packages/paperclip-runner/test/fixtures/copilot-profile-v26-identity.json",
"packages/paperclip-runner/test/fixtures/copilot-profile-v35-identity.json",
"packages/paperclip-runner/test/fixtures/copilot-profile-v40-identity.json",
"packages/paperclip-runner/test/fixtures/copilot-profile-v42-identity.json",
"packages/paperclip-runner/test/fixtures/copilot-profile-v46-identity.json",
"packages/paperclip-runner/test/fixtures/copilot-profile-v47-identity.json",
"packages/paperclip-runner/test/fixtures/copilot-profile-v48-identity.json",
"packages/paperclip-runner/test/fixtures/copilot-profile-v49-identity.json",
"packages/paperclip-runner/test/fixtures/copilot-profile-v54-identity.json",
"packages/paperclip-runner/test/fixtures/copilot-profile-v55-identity.json",
"packages/paperclip-runner/scripts/materialize-cursor-distribution.mjs",
"packages/paperclip-runner/scripts/cursor-runtime-patch.mjs",
"packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs",
"packages/paperclip-runner/scripts/provider-pack-runtime-tree.mjs",
"packages/paperclip-runner/scripts/generate-acpx-sidecar-contract.mjs",
"packages/paperclip-runner/scripts/generate-protocol-schema-module.mjs",
"packages/paperclip-runner/scripts/materialize-pi-distribution.mjs",
"packages/paperclip-runner/scripts/pi-distribution",
"packages/paperclip-runner/scripts/cursor-native-usage.mjs",
"packages/paperclip-runner/scripts/cursor-native-usage-source.json",
"packages/paperclip-runner/scripts/provision-cursor.mjs",
"packages/paperclip-runner/cursor-distributions.json",
"packages/paperclip-runner/cursor-contract.json",
// Pi runtime/extension/Node/closure pins are included by the src tree below.
"packages/paperclip-runner/src",
"packages/paperclip-runner/styles.css",
"packages/paperclip-runner/tsconfig.json",
"packages/paperclip-runner/tsconfig.surfaces.json",
] as const;
const ignoredRunnerDevelopmentDirectoryPaths = new Set([
"packages/paperclip-runner/devtools",
"packages/paperclip-runner/docs",
"packages/paperclip-runner/examples",
"packages/paperclip-runner/test",
"packages/paperclip-runner/test-fixtures",
"packages/paperclip-runner/test-support",
]);
const runnerDocumentationFilePattern = /\.md$/;
const runnerTestFilePattern = /\.(?:spec|test)\.(?:[cm]?[jt]sx?)$/;
const runnerRustIntegrationTestPathPattern =
/^packages\/paperclip-runner\/runner\/crates\/[^/]+\/tests(?:\/|$)/;
const runnerSmokeScriptPattern =
/^packages\/paperclip-runner\/scripts\/[^/]+-smoke\.mjs$/;
export interface DaytonaImageContentOptions {
repositoryRoot?: string;
inputPaths?: readonly string[];
platform?: string;
baseImages?: readonly string[];
frontendDigest?: string;
/** Must match the Docker PAPERCLIP_RUNNER_CANDIDATE_PROVIDERS build argument. */
candidateProviders?: readonly string[];
}
export function normalizedDaytonaCandidateProviders(values: readonly string[]): string[] {
const selected = [...values].sort();
if (selected.some(value => !["cursor", "copilot", "pi"].includes(value))
|| new Set(selected).size !== selected.length) {
throw new Error("Daytona candidate providers must be distinct known ACP profiles");
}
return selected;
}
function compareNames(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
function normalizedRelativePath(value: string): string {
return value.split(path.sep).join("/");
}
function shouldIgnore(relativePath: string): boolean {
return relativePath.split("/").includes("node_modules");
}
function shouldIgnoreRunnerDevelopmentInput(relativePath: string): boolean {
if (!relativePath.startsWith("packages/paperclip-runner/")) return false;
if (ignoredRunnerDevelopmentDirectoryPaths.has(relativePath)) return true;
return (
runnerDocumentationFilePattern.test(relativePath) ||
runnerTestFilePattern.test(relativePath) ||
runnerRustIntegrationTestPathPattern.test(relativePath) ||
runnerSmokeScriptPattern.test(relativePath)
);
}
function updateRecord(
hash: ReturnType<typeof createHash>,
kind: string,
relativePath: string,
payload = "",
): void {
hash.update(kind);
hash.update("\0");
hash.update(relativePath);
hash.update("\0");
hash.update(payload);
hash.update("\0");
}
function assertPinnedBaseImage(reference: string): void {
if (!/@sha256:[0-9a-f]{64}$/.test(reference)) {
throw new Error(
`Daytona image base must use an immutable sha256 digest: ${reference}`,
);
}
}
function assertPinnedFrontendDigest(digest: string): void {
if (!/^sha256:[0-9a-f]{64}$/.test(digest)) {
throw new Error(
`Daytona Dockerfile frontend must use an immutable sha256 digest: ${digest}`,
);
}
}
export function extractDaytonaDockerfileFrontendDigest(
dockerfile: string,
): string {
const firstLine = dockerfile.split(/\r?\n/, 1)[0] ?? "";
const match = /^#\s*syntax=\S+@(sha256:[0-9a-f]{64})\s*$/.exec(firstLine);
if (!match) {
throw new Error(
"Daytona Dockerfile first line must pin its syntax frontend to an immutable sha256 digest",
);
}
const digest = match[1]!;
assertPinnedFrontendDigest(digest);
return digest;
}
export function extractDaytonaBaseImages(dockerfile: string): string[] {
const baseImages: string[] = [];
const stageAliases = new Set<string>();
for (const line of dockerfile.split(/\r?\n/)) {
if (!/^\s*FROM\s/i.test(line)) continue;
const match = /^\s*FROM(?:\s+--\S+)*\s+(\S+)(?:\s+AS\s+(\S+))?\s*$/i.exec(
line,
);
if (!match) {
throw new Error(`Cannot parse Daytona Dockerfile FROM line: ${line}`);
}
const reference = match[1]!;
if (!stageAliases.has(reference)) {
assertPinnedBaseImage(reference);
baseImages.push(reference);
}
if (match[2]) stageAliases.add(match[2]);
}
if (baseImages.length === 0) {
throw new Error("Daytona Dockerfile does not declare a base image");
}
return baseImages;
}
async function resolveBaseImages(
root: string,
explicitBaseImages?: readonly string[],
): Promise<string[]> {
const baseImages = explicitBaseImages
? [...explicitBaseImages]
: extractDaytonaBaseImages(
await readFile(path.join(root, DAYTONA_IMAGE_DOCKERFILE_PATH), "utf8"),
);
if (baseImages.length === 0) {
throw new Error("Daytona image content identity requires a base image");
}
for (const reference of baseImages) assertPinnedBaseImage(reference);
return baseImages.sort(compareNames);
}
async function resolveFrontendDigest(
root: string,
explicitFrontendDigest?: string,
): Promise<string> {
const digest =
explicitFrontendDigest ??
extractDaytonaDockerfileFrontendDigest(
await readFile(path.join(root, DAYTONA_IMAGE_DOCKERFILE_PATH), "utf8"),
);
assertPinnedFrontendDigest(digest);
return digest;
}
async function hashEntry(
hash: ReturnType<typeof createHash>,
root: string,
relativePath: string,
): Promise<void> {
const normalizedPath = normalizedRelativePath(relativePath);
if (
shouldIgnore(normalizedPath) ||
shouldIgnoreRunnerDevelopmentInput(normalizedPath)
) {
return;
}
const absolutePath = path.resolve(root, relativePath);
const relativeFromRoot = path.relative(root, absolutePath);
if (
relativeFromRoot.startsWith(`..${path.sep}`) ||
relativeFromRoot === ".." ||
path.isAbsolute(relativeFromRoot)
) {
throw new Error(
`Daytona image input escapes repository root: ${relativePath}`,
);
}
const stats = await lstat(absolutePath);
if (stats.isDirectory()) {
const entries = await readdir(absolutePath, { withFileTypes: true });
entries.sort((left, right) => compareNames(left.name, right.name));
for (const entry of entries) {
await hashEntry(hash, root, path.join(relativePath, entry.name));
}
return;
}
if (stats.isSymbolicLink()) {
updateRecord(hash, "symlink", normalizedPath, await readlink(absolutePath));
return;
}
if (stats.isFile()) {
const executable =
(stats.mode & 0o111) === 0 ? "non-executable" : "executable";
updateRecord(hash, "file", normalizedPath, executable);
hash.update(await readFile(absolutePath));
hash.update("\0");
return;
}
throw new Error(`Unsupported Daytona image input type: ${relativePath}`);
}
export async function computeDaytonaImageContentId(
options: DaytonaImageContentOptions = {},
): Promise<string> {
const root = path.resolve(options.repositoryRoot ?? repositoryRoot);
const inputPaths = [
...(options.inputPaths ?? DAYTONA_IMAGE_INPUT_PATHS),
].sort(compareNames);
const hash = createHash("sha256");
updateRecord(
hash,
"contract",
DAYTONA_IMAGE_CONTENT_SCHEMA,
options.platform ?? DAYTONA_IMAGE_PLATFORM,
);
updateRecord(
hash,
"dockerfile-frontend",
await resolveFrontendDigest(root, options.frontendDigest),
);
for (const baseImage of await resolveBaseImages(root, options.baseImages)) {
updateRecord(hash, "base-image", baseImage);
}
for (const candidate of normalizedDaytonaCandidateProviders(options.candidateProviders ?? [])) {
updateRecord(hash, "candidate-provider", candidate);
}
for (const inputPath of inputPaths) {
await hashEntry(hash, root, inputPath);
}
return hash.digest("hex");
}
const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : null;
if (invokedPath && import.meta.url === pathToFileURL(invokedPath).href) {
const arguments_ = process.argv.slice(2);
const candidateFlag = arguments_[0];
if (arguments_.length > 1 || (candidateFlag !== undefined && !candidateFlag.startsWith("--candidate-providers="))) {
throw new Error("Expected only --candidate-providers=cursor,copilot,pi");
}
const candidateProviders = candidateFlag?.slice("--candidate-providers=".length).split(",").filter(Boolean) ?? [];
computeDaytonaImageContentId({ candidateProviders })
.then((contentId) => process.stdout.write(`${contentId}\n`))
.catch((error: unknown) => {
process.stderr.write(
`${error instanceof Error ? error.message : String(error)}\n`,
);
process.exitCode = 1;
});
}
export const DAYTONA_IMAGE_CONTENT_SCRIPT = fileURLToPath(import.meta.url);