mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:35:12 +01:00
Merge qualified retention parent into routine status work
Preserve routine status producers alongside current native, security, retention, and deferred execution owners. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
63c77102a8
470 files changed
+33237
-1570
No files matched your search
@@ -29,6 +29,8 @@ packages/paperclip-runner/**/*.test.ts
|
||||
packages/paperclip-runner/**/*.test.tsx
|
||||
packages/paperclip-runner/runner/crates/*/tests
|
||||
packages/paperclip-runner/scripts/*-smoke.mjs
|
||||
# The provider-pack builder imports this helper during image builds.
|
||||
!packages/paperclip-runner/scripts/kimi-provider-pack-smoke.mjs
|
||||
# Exceptions (last match wins): the image build re-runs the runner's
|
||||
# generated-file drift checks, so their committed outputs and inputs must
|
||||
# survive the slimming above. 2026-09-04: the *.md rule stripped the
|
||||
@@ -48,3 +50,11 @@ packages/grok-acp/bin
|
||||
# Images build their pinned Copilot assets instead of copying host binaries.
|
||||
/packages/paperclip-runner/provider-assets/
|
||||
/server/provider-assets/
|
||||
|
||||
!packages/paperclip-runner/test/fixtures/kimi-profile-v*-identity.json
|
||||
|
||||
!packages/paperclip-runner/test/fixtures/grok-profile-v*-identity.json
|
||||
|
||||
!packages/paperclip-runner/test/fixtures/copilot-profile-v*-identity.json
|
||||
|
||||
!packages/paperclip-runner/test-fixtures/pi-acp/profile-v34-identity.json
|
||||
@@ -24,6 +24,8 @@ WORKDIR /context
|
||||
COPY . .
|
||||
# Verify ACPX release declarations against the exact Docker context.
|
||||
RUN node packages/paperclip-runner/scripts/generate-acpx-profiles.mjs --check
|
||||
# The provider-pack builder imports this offline smoke helper during image builds.
|
||||
RUN node -e 'import("./packages/paperclip-runner/scripts/kimi-provider-pack-smoke.mjs")'
|
||||
# Committed artifacts the image build reads whose drift checks cannot run
|
||||
# here (they need the locked dependency tree or compiled dist/). Existence
|
||||
# in the context is the property this probe guards; content correctness is
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const workflow = readFileSync(new URL("../../workflows/runner-windows.yml", import.meta.url), "utf8");
|
||||
|
||||
function pathFilter(event) {
|
||||
const block = workflow.split(`\n ${event}:\n`)[1]?.split(/\n \S/)[0];
|
||||
assert.ok(block, `${event} trigger is missing`);
|
||||
return [...block.matchAll(/^\s+- (\S+)$/gm)].map(match => match[1]);
|
||||
}
|
||||
|
||||
test("the Windows Runner lane runs on pull requests and master for every Runner input", () => {
|
||||
const pullRequest = pathFilter("pull_request");
|
||||
const push = pathFilter("push");
|
||||
assert.deepEqual(push, pullRequest);
|
||||
assert.match(workflow, /push:\s*\n\s*branches: \[master\]/);
|
||||
for (const path of [
|
||||
".github/workflows/runner-windows.yml",
|
||||
"packages/paperclip-runner/rust-toolchain.toml",
|
||||
"packages/paperclip-runner/runner/**",
|
||||
"packages/paperclip-runner/protocol/**",
|
||||
]) assert.ok(pullRequest.includes(path), `missing path filter ${path}`);
|
||||
});
|
||||
|
||||
test("the Windows Runner lane builds and tests the locked workspace on hosted Windows x64", () => {
|
||||
assert.match(workflow, /runs-on: windows-2025\n/);
|
||||
assert.match(workflow, /shell: bash\n/);
|
||||
assert.match(workflow, /run: \|\n\s+rustup toolchain install\n/);
|
||||
assert.match(workflow, /run: >-\n\s+cargo test --release --manifest-path runner\/Cargo.toml --locked --workspace --no-fail-fast --\n\s+--exact --skip /);
|
||||
assert.doesNotMatch(workflow, /continue-on-error|if: always\(/);
|
||||
});
|
||||
|
||||
test("the Windows Runner lane skips only exact named tests", () => {
|
||||
const skipped = [...workflow.matchAll(/--skip (\S+)/g)].map(match => match[1]);
|
||||
assert.deepEqual(skipped, ["acpx_provider_backend::tests::binds_sidecar_paths_arguments_and_contents_to_the_runner_profile"]);
|
||||
assert.match(workflow, /--exact --skip /);
|
||||
});
|
||||
|
||||
test("the Windows Runner lane is read-only and keeps its own master-written cache", () => {
|
||||
assert.match(workflow, /^permissions: \{\}$/m);
|
||||
assert.match(workflow, /permissions:\n\s+contents: read\n/);
|
||||
assert.doesNotMatch(workflow, /: write\b|secrets\.|pull_request_target/);
|
||||
assert.match(workflow, /persist-credentials: false/);
|
||||
for (const [, ref] of workflow.matchAll(/uses: \S+@(\S+)/g)) assert.match(ref, /^[0-9a-f]{40}$/);
|
||||
assert.match(workflow, /shared-key: runner-windows-v1\n/);
|
||||
assert.match(workflow, /save-if: \$\{\{ github.event_name == 'push' \}\}\n/);
|
||||
});
|
||||
@@ -193,7 +193,7 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Prepare hosted image build capacity
|
||||
if: (inputs.build_eval_image || inputs.publish_eval_image) && inputs.release_run_id == ''
|
||||
if: inputs.build_eval_image || inputs.publish_eval_image
|
||||
run: |
|
||||
# These preinstalled SDKs are absent from the image build context.
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
name: Runner Windows
|
||||
|
||||
# Builds and tests the native Runner on Windows x64. This is a standalone
|
||||
# workflow because pr.yml runs pr-trusted.yml from master: a lane added there
|
||||
# would not run on the pull request that adds or changes it.
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/runner-windows.yml
|
||||
- packages/paperclip-runner/rust-toolchain.toml
|
||||
- packages/paperclip-runner/acpx-profiles.json
|
||||
- packages/paperclip-runner/antigravity-distributions.json
|
||||
- packages/paperclip-runner/runner/**
|
||||
- packages/paperclip-runner/protocol/**
|
||||
- packages/paperclip-runner/test-fixtures/**
|
||||
- packages/paperclip-runner/test/fixtures/**
|
||||
- tests/runner-e2e/fixtures/**
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- .github/workflows/runner-windows.yml
|
||||
- packages/paperclip-runner/rust-toolchain.toml
|
||||
- packages/paperclip-runner/acpx-profiles.json
|
||||
- packages/paperclip-runner/antigravity-distributions.json
|
||||
- packages/paperclip-runner/runner/**
|
||||
- packages/paperclip-runner/protocol/**
|
||||
- packages/paperclip-runner/test-fixtures/**
|
||||
- packages/paperclip-runner/test/fixtures/**
|
||||
- tests/runner-e2e/fixtures/**
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: runner-windows-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
rust:
|
||||
name: Runner Rust (Windows x64)
|
||||
runs-on: windows-2025
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: packages/paperclip-runner
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
# rust-toolchain.toml pins the channel; install it before rust-cache keys on it.
|
||||
- name: Install pinned Rust toolchain
|
||||
run: |
|
||||
rustup toolchain install
|
||||
rustup show active-toolchain
|
||||
# The Linux lanes' caches cannot serve an MSVC build, so this lane keeps its own.
|
||||
- name: Cache Rust build
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: packages/paperclip-runner/runner -> target
|
||||
shared-key: runner-windows-v1
|
||||
# Restore only on pull requests. Never let one evict master's entry.
|
||||
save-if: ${{ github.event_name == 'push' }}
|
||||
# Release mode matches the Linux lane (test:rust); some recovery tests
|
||||
# have deadlines that unoptimized builds miss on hosted runners.
|
||||
# The skipped test needs verified process launch, which Windows does not
|
||||
# have yet. Its file is a Grok profile identity source, so a Windows
|
||||
# ignore attribute there would change that identity.
|
||||
- name: Test
|
||||
run: >-
|
||||
cargo test --release --manifest-path runner/Cargo.toml --locked --workspace --no-fail-fast --
|
||||
--exact --skip acpx_provider_backend::tests::binds_sidecar_paths_arguments_and_contents_to_the_runner_profile
|
||||
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { resolveAntigravityProvisioner, resolveCursorProvisioner } from "../commands/runtime.js";
|
||||
import { resolveAntigravityProvisioner, resolveCursorProvisioner, resolveKimiProvisioner } from "../commands/runtime.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); });
|
||||
@@ -37,6 +37,17 @@ it("rejects a provisioner linked outside the installed package", async () => {
|
||||
await expect(resolveCursorProvisioner(f.url)).rejects.toThrow("escapes");
|
||||
});
|
||||
|
||||
|
||||
it("resolves only Kimi's installed provisioner and rejects a redirected setup", async () => {
|
||||
const f = await fixture("kimi");
|
||||
expect(await resolveKimiProvisioner(f.url)).toBe(f.provisioner);
|
||||
const other = await fixture("kimi");
|
||||
await rm(f.provisioner); await symlink(other.provisioner, f.provisioner);
|
||||
await expect(resolveKimiProvisioner(f.url)).rejects.toThrow("escapes");
|
||||
await writeFile(join(other.root, "package.json"), JSON.stringify({ name: "untrusted" }));
|
||||
await expect(resolveKimiProvisioner(other.url)).rejects.toThrow("identity is invalid");
|
||||
});
|
||||
|
||||
it("resolves Antigravity setup from the public server without importing it", async () => {
|
||||
const f = await fixture("antigravity");
|
||||
expect(await resolveAntigravityProvisioner(f.url)).toBe(f.provisioner);
|
||||
|
||||
@@ -28,7 +28,7 @@ it("rejects foreign package identity and a setup entrypoint outside that package
|
||||
});
|
||||
it("rejects unsupported runtime setup providers before resolution", async () => {
|
||||
const program = new Command(); registerRuntimeCommands(program);
|
||||
await expect(program.parseAsync(["node", "paperclipai", "runtime", "setup", "untrusted"])).rejects.toThrow("Supported runtime setup: paperclipai runtime setup pi, cursor, or antigravity");
|
||||
await expect(program.parseAsync(["node", "paperclipai", "runtime", "setup", "untrusted"])).rejects.toThrow("Supported runtime setup: paperclipai runtime setup pi, cursor, kimi, or antigravity");
|
||||
});
|
||||
|
||||
it("resolves the companion importer only inside the actual public server tar layout", async () => {
|
||||
|
||||
@@ -58,7 +58,6 @@ export async function setupPiRuntime(): Promise<void> {
|
||||
} finally { process.off("SIGINT", cancel); process.off("SIGTERM", cancel); }
|
||||
}
|
||||
|
||||
/** Resolve the installed public dependency without importing or starting it. */
|
||||
export async function resolveCursorProvisioner(serverUrl: string): Promise<string> {
|
||||
const url = new URL(serverUrl);
|
||||
if (url.protocol !== "file:" || url.search || url.hash) throw new Error("Cursor setup requires an installed Paperclip server");
|
||||
@@ -131,6 +130,41 @@ export async function setupAntigravityRuntime(): Promise<void> {
|
||||
}
|
||||
|
||||
|
||||
export async function resolveKimiProvisioner(serverUrl: string): Promise<string> {
|
||||
const url = new URL(serverUrl);
|
||||
if (url.protocol !== "file:" || url.search || url.hash) throw new Error("Kimi setup requires an installed Paperclip server");
|
||||
const entry = await realpath(fileURLToPath(url));
|
||||
if (!entry.endsWith("/dist/index.js")) throw new Error("Kimi setup requires the published server layout");
|
||||
const root = resolve(dirname(entry), "..");
|
||||
const manifest = join(root, "package.json");
|
||||
const info = await lstat(manifest);
|
||||
if (!info.isFile() || info.isSymbolicLink() || info.size > 65536 || JSON.parse(await readFile(manifest, "utf8")).name !== "@paperclipai/server") throw new Error("Kimi setup server package identity is invalid");
|
||||
const provisioner = join(root, "dist/vendor/paperclip-runner/cli/provision-kimi.cjs");
|
||||
if (await realpath(provisioner) !== provisioner || !(await lstat(provisioner)).isFile()) throw new Error("Kimi setup entrypoint escapes its server package");
|
||||
return provisioner;
|
||||
}
|
||||
|
||||
export async function setupKimiRuntime(): Promise<void> {
|
||||
const provisioner = await resolveKimiProvisioner(import.meta.resolve("@paperclipai/server"));
|
||||
const child = spawn(process.execPath, ["--use-env-proxy", provisioner], {
|
||||
stdio: "inherit", env: buildPiSetupEnvironment(),
|
||||
});
|
||||
const cancel = () => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM"); };
|
||||
process.on("SIGINT", cancel); process.on("SIGTERM", cancel);
|
||||
try {
|
||||
await new Promise<void>((accept, reject) => {
|
||||
let spawnError: Error | undefined;
|
||||
child.on("error", error => { spawnError = error; });
|
||||
child.once("close", (code, signal) => {
|
||||
if (spawnError) reject(spawnError);
|
||||
else if (code !== 0 || signal) reject(new Error("Kimi setup did not finish. Review its error; an invalid installation is never replaced automatically."));
|
||||
else accept();
|
||||
});
|
||||
});
|
||||
} finally { process.off("SIGINT", cancel); process.off("SIGTERM", cancel); }
|
||||
}
|
||||
|
||||
|
||||
export function registerRuntimeCommands(program: Command): void {
|
||||
const runtime = program.command("runtime").description("Manage explicitly installed agent runtimes");
|
||||
runtime.command("import-remote <directory>")
|
||||
@@ -149,11 +183,12 @@ export function registerRuntimeCommands(program: Command): void {
|
||||
} finally { process.off("SIGINT", cancel); process.off("SIGTERM", cancel); }
|
||||
});
|
||||
runtime.command("setup <provider>")
|
||||
.description("Install and verify a pinned Pi, Cursor, or Antigravity runtime (public downloads; no model calls)")
|
||||
.description("Install and verify the pinned Pi, Cursor, Kimi Code, or Antigravity runtime for this host (public downloads; no model calls)")
|
||||
.action(async (provider: string) => {
|
||||
if (provider === "pi") await setupPiRuntime();
|
||||
else if (provider === "cursor") await setupCursorRuntime();
|
||||
else if (provider === "kimi") await setupKimiRuntime();
|
||||
else if (provider === "antigravity") await setupAntigravityRuntime();
|
||||
else throw new Error("Supported runtime setup: paperclipai runtime setup pi, cursor, or antigravity");
|
||||
else throw new Error("Supported runtime setup: paperclipai runtime setup pi, cursor, kimi, or antigravity");
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,20 @@
|
||||
# Database
|
||||
|
||||
### Proactive agent chat messages
|
||||
|
||||
Migration `0419_slippery_impossible_man.sql` adds an agent-scoped unique constraint
|
||||
on `issue_comments(issue_id, author_agent_id, client_request_id)` alongside the
|
||||
existing human request constraint. Proactive delivery stores a source-task-scoped
|
||||
retry key and server-authored `metadata.chatMessage` provenance/content fingerprint.
|
||||
The comment and audit record commit together; a retry returns the original receipt.
|
||||
Conversation creation reuses the unique company/agent/human identity and existing
|
||||
advisory-lock order. No message outbox is added.
|
||||
|
||||
Chat list/detail unread projections reuse `issue_read_states`, including chats
|
||||
with no human participation. Read cutoffs use the exact PostgreSQL comment
|
||||
timestamp; upserts take the greatest previous/new cutoff so delayed requests
|
||||
cannot move a read receipt backward or clear a later unseen arrival.
|
||||
|
||||
Paperclip uses PostgreSQL via [Drizzle ORM](https://orm.drizzle.team/). There are three ways to run the database, from simplest to most production-ready.
|
||||
|
||||
### Persistent case handoffs
|
||||
|
||||
+82
-5
@@ -207,11 +207,20 @@ of exempt prose, update this policy as well as the task inputs.
|
||||
For ordinary source PRs (including the audited prose inputs above), a separate `Select Vitest files` job refines the
|
||||
general and serialized test lanes before their runners are allocated. It runs
|
||||
on the gate-approved test fleet, loads the real Vitest projects, and follows
|
||||
imports across workspace packages. Only projects with their own source changes
|
||||
are narrowed; unchanged consumer projects retain their complete coarse lane
|
||||
coverage without paying for another graph traversal. Filesystem, subprocess, network, CommonJS,
|
||||
imports across workspace packages. Every project in the coarse plan is assessed,
|
||||
including unchanged consumers. Projects without affected tests drop out of the
|
||||
matrix; an unchanged directory alone does not prove independence. Filesystem, subprocess, network, CommonJS,
|
||||
dynamic-loader, unresolved, and unapproved external-package consumers are
|
||||
retained conservatively, including indirect consumers through local helpers.
|
||||
Module-relative filesystem reads add explicit dependency edges when the path and
|
||||
read API can be resolved without running code. This includes directory listings
|
||||
and transitive helper reads. Dynamic paths, writes, escaped filesystem APIs,
|
||||
mutable or shadowed bindings, generated inputs and paths outside the checkout
|
||||
remain opaque. Setup-file reads invalidate the whole project when their input
|
||||
changes. Object-valued constants, including URL objects, remain opaque even without
|
||||
an observed mutation; inline URL construction and immutable string constants can
|
||||
be resolved. Symlink reads track both the checked-in link and its physical target.
|
||||
Missing inputs retain coverage.
|
||||
The selector explicitly tracks setup/configuration dependencies as well as test
|
||||
imports. Ordinary source names such as `session-config-metadata.test.ts` or
|
||||
`setup-state.ts` remain eligible; the words `config` and `setup` alone do not
|
||||
@@ -220,6 +229,15 @@ inputs. Other resolved setup/config dependencies invalidate their entire project
|
||||
The small pure-dependency allowlist lives in
|
||||
`scripts/ci/vitest-selection-graph.mjs`; new external packages remain opaque
|
||||
until reviewed. This is deliberately broader than `vitest related` alone.
|
||||
The reviewed Drizzle SQL/schema entry points have no ambient repository inputs.
|
||||
Their full import closures are content-hashed in
|
||||
`scripts/ci/reviewed-test-dependencies.mjs`; changed installed contents revoke the
|
||||
exception. CommonJS and dynamic loaders reject a fingerprint rather than leave
|
||||
unhashed children outside the closure. Database drivers and migration readers remain opaque. OS information,
|
||||
in-memory streams, timers and compression do not read repository files themselves;
|
||||
filesystem or process operations using their outputs retain their normal guards.
|
||||
The syntax audit distinguishes data keys and erased types from runtime globals.
|
||||
It examines the called member, not string arguments earlier in a chained call.
|
||||
For conservative retention, explicit complete local mock factories cut the
|
||||
mocked implementation's I/O edges. Partial mocks, spies, automocks, mock bypasses,
|
||||
and dynamic or escaped mocking APIs do not. Edits to a mocked module still use
|
||||
@@ -238,7 +256,8 @@ jobs; empty lanes allocate none. Serialized files remain serialized, and chat's
|
||||
line shards, native Runner lanes, Rust, builds, typechecking and E2E retain their
|
||||
existing selection/execution rules. The job summary reports files and jobs
|
||||
selected per lane, and the `vitest-selection` artifact records the manifest and
|
||||
dependency audit. Selected jobs execute exact Vitest test specifications, so
|
||||
dependency audit, including file-read edges and the first opaque dependency that
|
||||
retained each test. Selected jobs execute exact Vitest test specifications, so
|
||||
similarly named `.test.ts` and `.test.tsx` files cannot overlap between shards.
|
||||
The wrapper consumes and validates the selection before launching Vitest, then
|
||||
removes selection environment variables from the test process. Nested runner
|
||||
@@ -972,7 +991,16 @@ Use the cheap local default unless you are specifically working on browser flows
|
||||
pnpm test
|
||||
```
|
||||
|
||||
`pnpm test` runs the Vitest suite only. For interactive Vitest watch mode use:
|
||||
`pnpm test` runs the Vitest suite only. Its default chat integration coverage runs
|
||||
first in six sequential, exact-cover child processes, each with its own home,
|
||||
config and embedded PostgreSQL. The remaining server invocation excludes that
|
||||
chat file once and retains the native Runner suites. This bounds shared fixture
|
||||
and process lifetime without increasing concurrency or changing case assertions
|
||||
or timeouts. An explicit `PAPERCLIP_TEST_DATABASE_URL` retains the original
|
||||
single, serial database path. Explicit `--group` invocations and CI shard routing
|
||||
are unchanged.
|
||||
|
||||
For interactive Vitest watch mode use:
|
||||
|
||||
```sh
|
||||
pnpm test:watch
|
||||
@@ -2621,6 +2649,26 @@ and `packages/adapter-utils/src/sandbox-callback-bridge.test.ts` for these contr
|
||||
|
||||
### Native runner restart recovery
|
||||
|
||||
For remote controller disk-loss work, use the existing control-plane snapshot
|
||||
transaction and `native-controller-recovery.ts`; do not add a second snapshot
|
||||
writer. The private versioned metadata is limited to 4 KiB. Its importer handles
|
||||
one verified retained source while the source disk is available. Workspace and
|
||||
provider-backup migrations remain with their storage owners. See
|
||||
[the continuity verification guide](testing-native-remote-continuity.md) for
|
||||
the direct/prepared transport and three-controller continuation fixtures.
|
||||
An upgrade readiness report contains preliminary native evidence, not permission
|
||||
to remove a deployment volume. Keep the checkpoint pool bounded and measure all
|
||||
pools when qualifying database cost; the current size of two is a default.
|
||||
|
||||
For retained Codex event tails, keep source drain/accounting and target admission
|
||||
separate. `native-staged-attachment-recovery.ts` validates private source/target
|
||||
header evidence; `native-recovery-usage-delivery.ts` binds the source's accounting
|
||||
receiver. The transport persists its staged target before source bootstrap, then
|
||||
uses the existing authenticated attachment protocol after durable drain. Do not
|
||||
rotate the identity first, acknowledge before receipt persistence, or weaken the
|
||||
Runner's settled-provider check. A staged target does not yet certify a resumable
|
||||
session. Its eventual snapshot still uses the existing checkpoint transaction.
|
||||
|
||||
Custom source-mode HTTP launchers should create their listener with
|
||||
`createPaperclipHttpServer` from `server/src/http/server.ts`, passing the selected
|
||||
API origin as `apiUrl`. This registers the native Runner WebSocket transport
|
||||
@@ -2763,6 +2811,35 @@ and hard restarts with live and dead runners, the result-finalization race,
|
||||
incomplete bootstrap, repeated crashes with steering, and fail-closed process
|
||||
identity mismatches.
|
||||
|
||||
Native task-session publication is part of the terminal checkpoint transaction,
|
||||
not resource cleanup. `native-task-session-publication.ts` owns its accepted-result,
|
||||
owner, task-epoch and reset checks; heartbeat supplies prepared session metadata.
|
||||
The goal-less regression in `native-session-resumption.test.ts` holds provider
|
||||
cleanup open and then exercises a normal follow-up and an explicit session reset.
|
||||
Control-port tests also cover terminal snapshots before accepted results and
|
||||
result-replay contention with the status writer's coordinator-first lock order.
|
||||
Remote adoption tests must invoke actual external-state preparation. Inspecting
|
||||
transport options alone does not exercise the resumed-lease harness checks.
|
||||
Include a healthy warm handoff before the crash so recovery exercises the
|
||||
handoff's turn and item attachment IDs as well as first-run identities.
|
||||
Retirement tests must exercise the preceding `turn.stop` as well as drain and
|
||||
suspend after result acceptance. Use the actual native-observed, unclaimed
|
||||
admission phase and the production claim before retiring the predecessor;
|
||||
a synthetic legacy preparation does not cover that boundary. Verify failed
|
||||
claims and Stop leave the predecessor and shared attachment staging untouched.
|
||||
Configuration tests compare fresh and recovered
|
||||
home-mode fingerprints while preserving rejection of real mode changes.
|
||||
Include multiple skill versions read back from Postgres JSONB: version-map key
|
||||
order must not change the generated preference or session fingerprint. Exercise
|
||||
the real environment-selection and configuration services with a retained shared
|
||||
workspace, and keep actual model or skill-version changes incompatible.
|
||||
Run it against an isolated database with `PAPERCLIP_TEST_DATABASE_URL` when the
|
||||
host cannot start embedded Postgres:
|
||||
|
||||
```sh
|
||||
pnpm exec vitest run server/src/__tests__/native-session-resumption.test.ts -t 'publishes the completed task session before provider cleanup'
|
||||
```
|
||||
|
||||
## Version-based skill authoring and delivery
|
||||
|
||||
Managed content uses `managed-skill-content.ts`: prepare and verify outside the
|
||||
|
||||
@@ -93,6 +93,10 @@ Tasks have parentage. Every task exists in service of a parent task, all the way
|
||||
|
||||
The current issue model includes stable issue identifiers, parent/sub-issues, blockers, a single assignee, comments, issue documents, attachments and work products, and review/approval handoffs. That structure keeps work inspectable by both the board and agents while still allowing agents to decompose work into smaller tasks.
|
||||
|
||||
When an agent creates a subtask or follow-up task, its source task's feed shows
|
||||
a linked card at creation time with the current title, task number, and status.
|
||||
The Tasks tab keeps the full relationship list.
|
||||
|
||||
Automatic monitor checks pause when a task is blocked. A person can use Check now
|
||||
to request one check of a retained monitor through normal execution admission.
|
||||
Dependencies, recovery, pause, budgets and ownership still apply. Provider-usage
|
||||
@@ -358,6 +362,14 @@ See [iMessage Photon](connections/IMESSAGE-PHOTON.md) for the implementation
|
||||
contract, setup, recovery, boundaries, and qualification status.
|
||||
### Experimental persistent agent conversations
|
||||
|
||||
Agents can deliver proactive task and routine results into their own chat with
|
||||
the task's responsible human. A morning PR digest arrives as an agent message
|
||||
with a source-task link and unread indicators, even before the first human
|
||||
message. The person can reply using the digest as context. Publishing a message
|
||||
and completing work are separate actions; sending never starts or interrupts a
|
||||
chat turn. Delivery obeys membership, run authority and task privacy, and stays
|
||||
behind the Agent Chat setting. This version delivers in-app only.
|
||||
|
||||
Agent Chat is an opt-in core task presentation (`enableAgentChat`, off by default). Each person has one persistent task-backed conversation per agent and company, with ordinary company task visibility. The shared task composer, transcript, tools, files, and document panel remain the interaction surface. Agents clarify goals and hand substantial execution to linked, assigned tasks; a reply ends a turn without completing the conversation. `/new` starts fresh provider context in the same conversation while preserving visible history and artifacts. Healthy idle conversations wait for a message and do not count as unfinished execution work. See `doc/plans/2026-09-10-agent-chat.md` for the implementation contract.
|
||||
|
||||
### Agent chat project handoff (2026-09-11)
|
||||
|
||||
@@ -376,6 +376,13 @@ Include both agent configuration and task-level model selection where supported.
|
||||
These checks establish correct routing and authority. They do not turn a discovery
|
||||
list into a model allowlist: explicit compatible models still follow section 6.1.
|
||||
|
||||
For Kimi Code, when `PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH` selects a
|
||||
controller pack, remote Test prepares and verifies that current pack through the
|
||||
normal installer before its 35-second metadata/catalog budget starts.
|
||||
Installation adds to total Test time. Without a selected pack, remote targets
|
||||
retain manual setup and preinstalled-runtime verification. Neither path sends a
|
||||
model prompt.
|
||||
|
||||
## 7. Deliver instructions, tools, and credentials
|
||||
|
||||
### 7.1 Prove instruction delivery at the native boundary
|
||||
|
||||
@@ -2117,6 +2117,40 @@ placements may use a lazy live character. See [agent-personas.md](agent-personas
|
||||
for persistence, migration, rendering, and integration contracts.
|
||||
### Experimental task-backed agent chat (2026-09-10)
|
||||
|
||||
Ordinary task and routine runs in standard mode may publish with
|
||||
`send_chat_message({body,idempotencyKey})` or `POST /api/agents/me/chat/messages`.
|
||||
The authenticated company, agent and run responsible human determine the sole
|
||||
destination. Recheck execution ownership, membership, task/destination access
|
||||
and canonical source privacy at commit. Reject missing recipients, restricted
|
||||
source tasks, Ask/planning/review/watchdog runs and stopped runs. The shared
|
||||
comment-write boundary enforces the fixed conversation agent.
|
||||
Skill-test and task-bridge credentials cannot publish into standing chats.
|
||||
Persisted skill-test run boundaries remain in force after a task-mode change.
|
||||
Preserve the credential and external-user access intersection and recheck key
|
||||
revocation, scope and responsible-person identity before commit.
|
||||
Each new delivery charges the shared per-run cross-issue influence cap in the
|
||||
same transaction. Retries, conflicting content and receipt-only recovery do not
|
||||
spend another slot. Cap exhaustion returns the canonical 429 response and leaves
|
||||
no message or newly created conversation behind.
|
||||
|
||||
The chat messaging service atomically creates a waiting conversation if needed
|
||||
and commits an attributed issue comment and audit record. Agent request IDs are
|
||||
unique per conversation and author; keys incorporate the source task for retries
|
||||
across runs, and changed content conflicts. Server-authored provenance identifies
|
||||
the source task and run. Existing events refresh discovery, messages and unread
|
||||
projections. Source execution transcripts remain outside the chat's linked-run
|
||||
history. There is no delivery worker, message outbox or extra model turn.
|
||||
|
||||
Sending does not change task disposition, cancel/settle a chat turn or suppress
|
||||
automatic handoff completion notifications. `/new` permits future background
|
||||
messages in the current history. Ordinary chat replies retain session checks.
|
||||
Chat list/detail responses use per-user read state before human participation;
|
||||
the foreground chat marks rendered messages read through an optional comment
|
||||
cutoff, retaining unseen concurrent arrivals. Existing navigation ordering is
|
||||
preserved with accessible unread dots and an aggregate Chat indicator.
|
||||
`enableAgentChat` gates delivery; external channels, push/email and private-chat
|
||||
redesign are outside this contract.
|
||||
|
||||
`enableAgentChat` is an instance experimental flag, default false. Conversation containers remain issues, unique by `(company_id, conversation_agent_id, conversation_user_id)`. The authenticated board actor supplies ownership; local trusted mode uses `local-board`. Ordinary company task access applies. A conversation's agent assignment and identity are immutable through ordinary updates; terminal status mutations are rejected.
|
||||
|
||||
`GET /api/companies/:companyId/chats/:agentRef` reads an existing conversation or null. `POST` atomically resolves its issue when adding a chat or on first send/upload. `GET /api/companies/:companyId/chats` lists only the current board user’s conversations in that company, subject to ordinary issue read access. The Chat navigation opens a searchable secondary sidebar with agent avatars and a picker for starting or reopening the same per-agent conversation. Existing issue comment, attachment, document, interaction, and run APIs apply thereafter. User chat comments require an idempotent UUID `clientRequestId`. Conversation delivery preserves comment order through the existing issue execution queue; the durable comment outbox repairs the commit-to-enqueue crash window.
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
|
||||
Target specification for the Paperclip control plane. Living document — updated incrementally during spec interviews.
|
||||
|
||||
Proactive in-app updates let an active standard task or routine publish to its
|
||||
agent's persistent conversation with the run's responsible human. The server
|
||||
derives the destination, enforces current authority and company-visible chat
|
||||
privacy, and persists an attributed, idempotent comment with source-task provenance.
|
||||
Unread state belongs to the viewing human. Sending is independent of task
|
||||
completion and chat execution; future messages remain possible after `/new`.
|
||||
The V1 contract is specified under experimental task-backed agent chat in
|
||||
`doc/SPEC-implementation.md`.
|
||||
|
||||
Experimental standalone cases provide persistent structured work records that can
|
||||
exist independently of execution. Prompt-driven handoffs move their primary
|
||||
ordinary task between agents while retaining case data and history. Existing task
|
||||
|
||||
@@ -14,6 +14,25 @@ permissions; discovery skips services without grants and excludes unsupported
|
||||
actions. The gateway checks actual grant scopes again before each call, including
|
||||
after refresh. Requested scopes alone never authorize an action.
|
||||
|
||||
Before managed or customer-owned OAuth opens, Paperclip saves the validated
|
||||
service choice as non-secret setup intent on the draft. It is bound to the
|
||||
company, connection, initiating actor, grant kind, and credential subject.
|
||||
Declined or expired authorization and a cold **Finish setup** return restore
|
||||
that choice, not the new-account write defaults. Historical drafts without a
|
||||
matching saved intent require an explicit choice. Reconnecting an established
|
||||
account without a new choice keeps its exact grant request.
|
||||
|
||||
Overlapping customer-owned starts preserve the latest saved draft choice through
|
||||
client binding and authorization metadata updates. Each OAuth state and consent
|
||||
URL keeps that attempt's own requested scopes. These updates preserve unrelated
|
||||
current settings and other users' pending proposals in both configuration records.
|
||||
|
||||
An agent can hand setup to its responsible human without choosing services or
|
||||
creating provider OAuth state. If the agent proposes services, the human can
|
||||
review and change them in the picker. Saving the human's choice consumes only
|
||||
that person's pending proposal, so an old proposal cannot replace their choice
|
||||
after a declined sign-in. Neither draft intent nor a proposal is consent.
|
||||
|
||||
Discovery isolates service outages. Healthy services remain usable while the
|
||||
connection's health message identifies unavailable services and recommends a
|
||||
catalog refresh. Failed services' cached actions are disabled until rediscovered;
|
||||
|
||||
+12
-3
@@ -1,5 +1,11 @@
|
||||
# Paperclip evaluation guide
|
||||
|
||||
The explicit-only [Kimi Code qualification suite](../tests/runner-e2e/README.md#kimi-code-qualification)
|
||||
registers twenty candidate cells across local and Daytona. It reuses the
|
||||
browser, semantic interaction, file/artifact, Stop and continuation oracles with
|
||||
scoped candidate admission and an encrypted Code credential. Registration does
|
||||
not establish a live pass, native question/plan support or usable cost accounting.
|
||||
|
||||
The public directory calls full-product testing **E2E tests**. Suites describe
|
||||
what is tested; deployment targets describe which app instance was tested.
|
||||
QA smoke is a named selection of existing journeys against QA, and appears in
|
||||
@@ -49,8 +55,9 @@ original bounds. Its source registration is not an AWS or TestOps pass. The
|
||||
broader YAML catalog remains planning inventory.
|
||||
|
||||
The explicit-only [Daytona restart continuity journey](../tests/runner-e2e/README.md)
|
||||
extends the existing native restart oracle to a 125-second controller outage,
|
||||
using the recorded Claude and Codex models in disposable development fixtures.
|
||||
extends the existing native restart oracle to a 300-second controller outage
|
||||
and another replacement after completion, using the recorded Claude and Codex
|
||||
models in disposable development fixtures.
|
||||
It requires same-process adoption, saved files and finalization without replay;
|
||||
local regression checks and catalog discovery are separate from live acceptance.
|
||||
|
||||
@@ -84,7 +91,9 @@ successor. Registration and calibration are separate from a live staging pass.
|
||||
|
||||
[Reusable Product journeys](../tests/runner-e2e/PRODUCT-JOURNEYS.md) add explicit
|
||||
deployment targets and starting-state recipes. The shared task-properties
|
||||
journey uses no model; Cloud onboarding supplies reusable verified state. Cloud
|
||||
journey and task-document create/edit/reopen journey use no model; Cloud
|
||||
onboarding supplies reusable verified state. The document journey checks exact
|
||||
saved content and both revision records through independent API reads. Cloud
|
||||
fleet operations and trusted CI remain private. Local eval gateway qualification
|
||||
does not qualify Cloud routes, and these selections stay outside `--all`.
|
||||
|
||||
|
||||
+161
-2
@@ -2167,8 +2167,11 @@ digest. Earlier live qualification results keep their original profile identity.
|
||||
|
||||
Healthy native warm turns retain their runner and provider processes. After a
|
||||
controller crash, continuation can recover a completed remote session from its
|
||||
saved checkpoint and exact sandbox process identity. The prior controller must
|
||||
be stopped, its result committed, and the successor must hold current task and
|
||||
saved checkpoint and exact sandbox process identity. For an externally persisted
|
||||
controller with validated recovery metadata, the prior lease must be released
|
||||
and its result committed. A PID in the replacement container does not establish
|
||||
whether the prior remote controller is alive. Legacy file-backed recovery retains
|
||||
its stopped-process check. The successor must hold current task and
|
||||
coordinator authority. Recovery reconnects through the prior run's route before
|
||||
the authenticated run attachment changes authority. Unavailable identity probes
|
||||
have a shared 30-second retry budget; an identity conflict never permits replay.
|
||||
@@ -2647,3 +2650,159 @@ The existing prior-owner, suspended-state and backup checks still decide whether
|
||||
the next turn may start. Missing checkpoints and ambiguous identity remain
|
||||
blocked; restoration does not authorize a new provider session or change the
|
||||
configured lifecycle policy.
|
||||
|
||||
Remote admission also handles the absence of both native controller projections.
|
||||
`native-controller-recovery.ts` selects the existing bounded task/session source
|
||||
and restores its encrypted PRP records before admission reads local identity.
|
||||
It reconstructs the supervisor envelope from `runnerProfileJson.sessionCheckpoint`
|
||||
and the private `nativeControllerRecovery` metadata. Missing, incompatible, or
|
||||
mixed evidence holds the continuation; it does not authorize fresh execution.
|
||||
The 32-candidate boundary and newer-progress barriers remain in force. Exhausted
|
||||
history and current-run progress cannot establish fresh-bootstrap proof.
|
||||
|
||||
A settled warm journal may contain an authenticated idle goal-read snapshot
|
||||
after `run.terminal`. Recovery accepts only the supported PRP event v1/v2
|
||||
envelopes with the same complete attachment identity, a null goal and
|
||||
`workingNow: false`. All commands must already be complete. Active goals, new
|
||||
work, unknown versions and mixed bindings remain holds. This check adds no
|
||||
streaming persistence or database work.
|
||||
|
||||
If admission finds a live retained controller owner, it preserves the unstarted
|
||||
business run and records the owner's actual deadline through the existing native
|
||||
resumption path. This does not consume a provider attempt or stop the retained
|
||||
sandbox. A locked run/coordinator check rejects Stop, changed ownership or any
|
||||
provider progress. Startup restores even an overdue wait to the same scheduler;
|
||||
ordinary resumption rechecks current permissions and task authority. No idle
|
||||
scan is added. Per-turn continuation reconstructs a missing suspended-state
|
||||
receipt from the admitted sandbox or prepared-task owner, including after a
|
||||
previous read failed with only a PRP projection written.
|
||||
|
||||
The version-one metadata contains a digest of the exact redacted stored snapshot,
|
||||
configuration digests and the PRP checkpoint binding. It is bounded to 4 KiB,
|
||||
excluded from public run projections, and saved by `checkpointSession` in its
|
||||
existing snapshot transaction. No second snapshot writer, per-text-chunk query,
|
||||
pool, or recurring scan is added. A null checkpoint binding records an incomplete
|
||||
publication and prevents expiration. Snapshot and result writes recheck the
|
||||
coordinator generation and lease after lock waits, including the existing limited
|
||||
post-result maintenance exception.
|
||||
|
||||
Goal-less native turns publish their task-session pointer with the first accepted,
|
||||
successful terminal checkpoint, before provider or instruction cleanup can delay
|
||||
the heartbeat completion callback. The pointer uses the native normalized UUID
|
||||
and the checkpoint's provider display identity. Its prepared configuration metadata
|
||||
comes from normal admission; neither streaming events nor ordinary checkpoints
|
||||
add task-session writes. Goal checkpoints retain their existing publication path.
|
||||
|
||||
The snapshot transaction validates the exact accepted result and current owner,
|
||||
then updates only the authenticated run's existing task-session projection. Its
|
||||
private `nativeTaskSessionPublication` receipt shares the 4 KiB recovery-metadata
|
||||
budget. Coordinator, issue and run locks precede the ownership clock check; the
|
||||
issue's `NO KEY UPDATE` lock remains compatible with result insertion's parent
|
||||
`KEY SHARE`. Reset/deletion, a newer task-session epoch or a competing execution
|
||||
prevents publication. Later maintenance and heartbeat completion honor the receipt,
|
||||
so they cannot restore a cleared pointer or overwrite its successor. Unsupported
|
||||
legacy bindings keep the existing completion path. The receipt is excluded from
|
||||
public run projections and contains no session parameters or credentials.
|
||||
|
||||
A terminal snapshot can arrive before result acceptance. That snapshot publishes
|
||||
neither a continuation pointer nor a handled publication receipt. The existing
|
||||
post-acceptance checkpoint completes publication in its normal transaction.
|
||||
The first production release containing recovery readers, complete attachment
|
||||
identity handling and redaction for both private recovery fields is the
|
||||
disk-independent rollback floor. Earlier
|
||||
development-reader probes do not establish a published rollback target.
|
||||
|
||||
Active remote adoption and compatible idle warm reuse carry an existing verified
|
||||
Runner proof. Preparation revalidates that proof and the exact process's liveness,
|
||||
then preserves its in-place harness state. The later protocol attachment still
|
||||
authenticates the durable PRP authority. A provider lease acquisition described as
|
||||
`resumed` does not establish that this already-running harness was suspended.
|
||||
Ordinary resumed leases retain their verified suspension and backup checks.
|
||||
|
||||
Reattachment keeps the complete durable PRP binding, including its turn and item
|
||||
attachment IDs. A healthy warm handoff can create new attachment IDs within the
|
||||
retained Runner. Replacing that run's controller must preserve those IDs; deriving
|
||||
them again from the business run ID would request an invalid authority rotation.
|
||||
|
||||
Cold Codex continuation can restore a completed provider with a retained event
|
||||
suffix. Native remote controllers first persist a private staged attachment under
|
||||
the target run's existing checkpoint owner, then restore and drain the exact
|
||||
source authority before attaching the target. The source's run, turn and item
|
||||
identity stays on every old event. Receipt persistence precedes acknowledgement;
|
||||
unknown semantic work holds the handoff and never reaches the target's tool
|
||||
authority. The provider's settled-session attachment guard remains mandatory.
|
||||
|
||||
The staged controller format is
|
||||
`paperclip.runner.durable.control-plane-state.staged-attachment.v1`. It contains
|
||||
the source and target attachment tuples and one bound target attachment template
|
||||
inside the existing encrypted PRP authority, subject to its existing size limits.
|
||||
The header stores a bounded descriptor and digest. Before command delivery, the
|
||||
template is a separate changed-record part; after queueing, recovery uses the
|
||||
existing command or receipt. Control checkpoints do not rewrite another copy of
|
||||
instructions or tool context in the header.
|
||||
It is separate from the 4 KiB run-profile recovery envelope. Startup discovers
|
||||
this target obligation through bounded encrypted-header reads before a target
|
||||
session snapshot exists. It verifies both frozen runs, the accepted and released
|
||||
source, its original Runner artifact and the exact source header. Remote process
|
||||
verification then chooses authenticated source adoption or verified suspended
|
||||
source restoration. An in-memory rebound snapshot supplies normal admission;
|
||||
an existing target snapshot always takes precedence over that source baseline.
|
||||
The intent remains through command delivery and authenticated handoff, including
|
||||
an uncertain commit response. Source and target receipt phases require their
|
||||
exact physical authority; receipt replay cannot start a second Runner or attach
|
||||
again under a different endpoint. After the ordinary target snapshot commits,
|
||||
an in-memory notification permits intent retirement in the next existing command
|
||||
write. There is no separate metadata/snapshot transaction for that retirement.
|
||||
|
||||
Source accounting uses the source's immutable producer receipts and billing
|
||||
classification. Opening this receiver does not reset accounting ownership or
|
||||
reopen a finalized run. Equal finalized usage retains the existing audit policy;
|
||||
changed, incomplete or conflicting usage remains unacknowledged for
|
||||
reconciliation. The target's usage receiver never receives source events. Staging
|
||||
adds recovery control work only: text streaming and settled idle operation add no
|
||||
queries, snapshot transactions, pools or recurring scans. The first rollback
|
||||
release supporting this continuation must also read the staged controller format;
|
||||
older readers fail closed. Prepared-launch and other provider paths retain their
|
||||
existing admission and recovery contracts.
|
||||
|
||||
Accepted-result retirement still stops the old provider turn before draining and
|
||||
suspending its Runner. The checkpoint codec classifies `turn.stop` as lifecycle
|
||||
maintenance. Existing exact ownership, generation and accepted-result fences
|
||||
govern this cleanup; starting provider work still requires an active owner.
|
||||
A successor claims its existing native coordinator and starts renewal before
|
||||
retiring a superseded warm session. An unclaimed preparation cannot authorize
|
||||
the predecessor's checkpoint writes. Shared attachment staging follows verified
|
||||
retirement; a failed claim or Stop cannot close that predecessor or stage files.
|
||||
Recovery also preserves the implicit isolated-home default when binding admitted
|
||||
configuration, so an unchanged home does not invalidate the retained session.
|
||||
Explicit home-mode changes retain their normal configuration-change checks.
|
||||
Restoring pinned skill versions also preserves the fresh selection's existing
|
||||
name/key order. JSONB map ordering must not change the generated skill preference
|
||||
and invalidate the session when the selected keys, versions and bytes are unchanged.
|
||||
|
||||
For per-turn direct sandbox recovery, the executor reads the retained Runner's
|
||||
suspended lifecycle through the recorded sandbox lease and validates its complete
|
||||
identity and lack of pending delivery/cleanup. Only a lifecycle projection is
|
||||
cached locally; provider files remain on the execution host. Prepared tasks use
|
||||
their existing `read_state` contract. The normal snapshot rebinder, tool-contract
|
||||
checks and current admission authority still govern the next turn.
|
||||
|
||||
An active remote owner causes startup recovery to retain the obligation at the
|
||||
actual lease deadline. The existing recovery dispatch timer is rearmed on drain
|
||||
resume; successful reconciliation leaves no recurring recovery work.
|
||||
Follow-up waits retain their original dispatch mode and owner across a drain,
|
||||
including when another heartbeat service facade resumes the work. A failed
|
||||
dispatch retains a bounded retry obligation rather than losing its deadline.
|
||||
Upgrade diagnostics retain their existing `ready` meaning and add bounded retained-pointer
|
||||
header evidence. That inventory is preliminary, reports partial coverage, and
|
||||
does not certify workspace durability or volume removal.
|
||||
|
||||
`importNativeControllerRecovery` is an internal one-source operation for the
|
||||
migration owner while old controller files remain available. It requires the
|
||||
admitted configuration digest and exact supervisor snapshot, locks the issue,
|
||||
coordinator and run in order, rejects live or unsettled ownership, and publishes
|
||||
only missing metadata. Repeating the same import is a no-op. Normal lifecycle
|
||||
checkpoints publish the metadata without a separate migration write. Disk-free
|
||||
rollback requires a server that reads this envelope and the supported settled
|
||||
warm journal, including its PRP v2 idle goal suffix. Earlier local-only readers
|
||||
and readers lacking that suffix handling remain outside that rollback contract.
|
||||
@@ -81,6 +81,17 @@ Independent SQL writers and additional processes need an explicit wake protocol;
|
||||
process-local notifications do not provide cross-replica delivery. Worker quiet
|
||||
alone is not evidence that the host or database actually suspended.
|
||||
|
||||
## Native reply recovery
|
||||
|
||||
Committed native replies use the shared delivery coordinator and its persisted
|
||||
retry deadlines. One pass checks at most four independent tasks at a time.
|
||||
Checks for the same company and task run in candidate order within the batch.
|
||||
Other scoped deadline workers remain serial unless their owner enables concurrency.
|
||||
Drain retains unstarted checks, and shutdown waits for every admitted check before
|
||||
releasing tracked work. Completed history creates no recurring discovery reads;
|
||||
new committed work reactivates the existing notification path. Pending reply
|
||||
obligations keep their existing sleep blockers.
|
||||
|
||||
## Scheduled routine wake protocol
|
||||
|
||||
Historical failed ephemeral leases and terminal custom-image setup sessions can
|
||||
|
||||
@@ -37,3 +37,21 @@ validation and token gates pass.
|
||||
The production setup components were checked in a background browser using
|
||||
simulated Storybook data: one profile, personal access and OAuth method switching.
|
||||
Full regression checks and PR review are in progress. No live Google proof yet.
|
||||
|
||||
## Draft resume follow-up — 2026-10-28
|
||||
|
||||
The setup draft now retains its validated service request before either managed
|
||||
or customer-owned OAuth handoff. This non-secret intent is bound to the company,
|
||||
connection, initiating actor, grant kind, and credential subject. Denial, expired
|
||||
OAuth state, and a cold Finish setup return restore that exact request rather
|
||||
than the new-account write defaults. Historical or differently owned drafts
|
||||
require an explicit service choice; existing reconnects keep their grant request.
|
||||
|
||||
Agent handoffs still open the addressed human's picker without issuing provider
|
||||
state or inventing service access. A human's reviewed choice consumes only their
|
||||
own pending service proposal, so an older proposal cannot override that choice
|
||||
on the next draft resume. Other users' proposals remain unchanged.
|
||||
|
||||
This follow-up is local to a fresh checkout of master at `820c458465fa`.
|
||||
Provider-fixture/database and production-component tests cover the changed
|
||||
journey; they do not establish live Google consent, merge, or deployment.
|
||||
@@ -0,0 +1,955 @@
|
||||
# Kimi Code native Runner integration — 2026-10-28
|
||||
|
||||
Owner: this chat, with separate provider, distribution, and product owners.
|
||||
Baseline: `dce004259394486f6361d0c0dd5a33a5272acc0a`.
|
||||
Branch: `codex/kimi-code-runner` in the existing managed worktree.
|
||||
Delivery: tested, reviewable PR stack: Runner foundation, shared Core Stop
|
||||
recovery, product setup and acceptance, then qualified pack delivery. Each slice
|
||||
must compile and group cohesive behavior for review.
|
||||
Merge, publication, and production deployment
|
||||
are separate from this handoff. The user confirmed local plus Daytona acceptance.
|
||||
|
||||
## Merge integration — 2026-10-30
|
||||
|
||||
The master integration keeps the existing released Kimi revision 17 and adopts
|
||||
master's upstream-only release attestation. The old source-bound revision 17
|
||||
fixture remains immutable historical evidence with no current launch authority.
|
||||
The pinned Kimi 2.3.3 distribution and platform closure pins are unchanged.
|
||||
Generated profiles, current digest admission, and shared remote installation
|
||||
must pass again on the integrated source. Earlier native acceptance and public
|
||||
packages retain their original source and digest; they do not establish fresh
|
||||
live or ordinary UI acceptance for this integrated identity.
|
||||
|
||||
## Release contract
|
||||
|
||||
An operator selects Kimi Code in native Runner, saves and reopens its exact
|
||||
model/environment configuration, tests the runtime, assigns ordinary work, sees
|
||||
streamed tools and output, receives independently verified files/artifacts and
|
||||
durable task completion, and can Stop owned work. Existing legacy Kimi agents
|
||||
and their history retain their current execution path.
|
||||
|
||||
- First environment: isolated local macOS arm64; remote: Daytona Linux x64.
|
||||
- Runtime under investigation: official `@moonshot-ai/kimi-code@2.3.3`, ACP over
|
||||
stdio, behind the existing ACPX driver. The ambient Mac binary is 0.41.0 and
|
||||
cannot establish the new runtime's compatibility.
|
||||
- Model: explicit caller-selected identifier, acknowledged before the first
|
||||
prompt. Qualification uses authenticated `kimi-for-coding`;
|
||||
discovery is never a product allowlist.
|
||||
- Authentication: explicit company-secret `KIMI_API_KEY` environment binding.
|
||||
Qualification uses only the active global login's short-lived access token;
|
||||
the provider accepted a real model/tool turn. Refresh tokens and login homes
|
||||
are never transferred. Managed Kimi account connections are deferred.
|
||||
- Instructions: exact composed content must reach native model instructions on
|
||||
create and load, with correlated acknowledgment; missing proof rejects prompt
|
||||
admission. Current official ACP does not consume this field, so a pinned,
|
||||
provider-owned runtime patch is required unless a richer interface proves better.
|
||||
- Required: model/instruction admission, scoped authenticated tools, streaming,
|
||||
semantic questions, permission decisions, cancellation, session continuation,
|
||||
terminal settlement, cleanup, clean installation, and the ordinary product path.
|
||||
Native question/plan controls are inventoried separately before any claim.
|
||||
- Deferred unless separately qualified: managed browser login, active steering,
|
||||
inbound media, native question/plan controls, native child/background agents,
|
||||
first-class child projections, additional OS/architectures, and usage accounting.
|
||||
Native ACP reports context occupancy, not usable per-run token/cost totals.
|
||||
Dollar budget enforcement is therefore not a qualified capability.
|
||||
- Spending: proportionate goal-authorized test/provider/infrastructure work;
|
||||
record material costs and unknowns, diagnose first divergence before paid retry,
|
||||
stop uninformative attempts. No recurring infrastructure commitment.
|
||||
|
||||
## Architecture and ownership
|
||||
|
||||
Provider translation and native patch remain Kimi-owned. Shared Runner supervision,
|
||||
PRP transport, durable decisions, authorization, and task finalization retain
|
||||
their existing contracts. Distribution is a pinned installed closure with no
|
||||
fallback to ambient Kimi. Runtime admission remains pending until required live
|
||||
qualification passes. Provider registration does not migrate legacy agents.
|
||||
|
||||
## Current release state — 2026-10-30
|
||||
|
||||
The pending11 campaign is historical baseline evidence. Its final Daytona
|
||||
Stop/new/resume attempt failed because canonical sandbox reuse left an earlier
|
||||
lease for the same allocation without an explicit ownership handoff. The run
|
||||
created all three provider turns and completed the authored reset, but source inspection correctly
|
||||
rejected ambiguous ownership before file preservation. The original failure and
|
||||
cleanup evidence remain unchanged; the campaign has nineteen passing distinct
|
||||
cases, not twenty.
|
||||
|
||||
The repair records an atomic server-owned handoff on both lease rows. Recovery
|
||||
accepts only the complete backward chain of inactive, finalized native owners
|
||||
with matching company, task, agent, workspace, provider and allocation. Missing,
|
||||
foreign, active, conflicting or additional owners remain blocked. The exact
|
||||
producer regression fails before the repair and passes with real file copyback;
|
||||
all 114 owning recovery tests pass. This is deterministic recovery evidence,
|
||||
not a replacement for the live Daytona case.
|
||||
|
||||
The foundation was reconciled with master
|
||||
`a8e07500db68f187b19de83823eb7fabf1196021`. Shared permission, turn attribution
|
||||
and runtime activity changes required new native artifacts and fresh pending14
|
||||
qualification. Its immutable declaration and Mac/Linux artifacts remain recorded
|
||||
at their actual source identities. The pending11 nineteen passes remain historical
|
||||
baseline evidence with no pending14 or later runtime qualification credit.
|
||||
|
||||
CI rejected a stale Pi fixture in a shared Rust unit test after candidate13 was
|
||||
sealed. The corrected test advanced Kimi to pending14 before its shipping build
|
||||
or live cases. Candidate13 and the original failure remain unchanged.
|
||||
|
||||
Pending14 has two accepted local cases: Stop/new and message-marker. Both retain
|
||||
their original grade, scoped native14 audit and full physical host closure. The
|
||||
next local Plan-revise-accept attempt failed before its revised model turn at
|
||||
PRP `run.attach`: `ACPX durable launch profile digest does not match runner startup`.
|
||||
The sequence stopped. Seven other local cases and all ten pending14 Daytona task
|
||||
cases remain unstarted. The original failed Plan and earlier zero-capture diagnostic
|
||||
stay unchanged; the diagnostic has no acceptance credit.
|
||||
|
||||
A credential-free owner regression reproduces the cold restore error. New-run
|
||||
startup authority validates, but the saved launch digest still binds the prior-run
|
||||
grant. The reviewed native repair proves that exact prior digest by changing only
|
||||
the candidate run binding, retains full company/model/profile/closure/expiry/session
|
||||
and artifact checks, and separates bare cold attach from authenticated warm rotation.
|
||||
Its exact-final baseline fails; five focused tests and all thirty-five owning backend
|
||||
tests pass. The initial sandbox TCP-bind failures remain recorded separately from
|
||||
the successful loopback-authorized owning run. These controls are not model acceptance.
|
||||
|
||||
The cold repair shipped as pending15 at source
|
||||
`9e82c00ad8e2eba628a33a2e1857379769ad7128`. All ten local task cases passed
|
||||
with original grades, scoped native15 audits and physical host closure. The
|
||||
causal Plan-revise-accept case reused the same native/provider session across its
|
||||
first and revised runs. No pending15 Daytona model case ran. Its immutable Linux
|
||||
image was built, independently checked, loaded once and published once at
|
||||
`sha256:61b097ec5e1c581a0868c174881497fab556c9197d92261595ddc75469d1787f`.
|
||||
The earlier public-push review rejection and later exact human authorization stay
|
||||
recorded. This is an original-source artifact, not acceptance of a later runtime.
|
||||
|
||||
Master advanced to `0c70cedce9a803bdc201324528abb35e423e7cd0` before remote
|
||||
qualification. The real reconciliation preserves upstream runtime-context and
|
||||
sidecar contract generation, confirmation replay and provider-pack contract digest
|
||||
verification. The critical Kimi inventory now has 58 source inputs. Maintained
|
||||
identity generation derives pending16, Copilot91 and Grok18; Pi36 is unchanged.
|
||||
Colliding Copilot88 and Grok15 historical fixtures retain the upstream canonical
|
||||
filenames while the unmerged Foundation variants remain byte-exact under
|
||||
branch-qualified names with explicit lineage and no execution authority.
|
||||
|
||||
All earlier pending15 passes and artifacts remain historical, source-bound
|
||||
evidence. They supply zero pending16 acceptance credit. The earlier held qualified16
|
||||
proposal is obsolete and remains immutable. The reconciled source requires new
|
||||
native artifacts on both targets, exact installed pack verification and a fresh
|
||||
20-case local/Daytona matrix with full resource, key and host closure before a new
|
||||
ordinary qualification proposal. The existing immutable image may be a physical
|
||||
base only when the normal installer verifies the current full Linux pack and
|
||||
native daemon; its old pack is not relabelled as current. No new artifact build,
|
||||
model test or promotion is authorized by this source proposal.
|
||||
|
||||
The pending14 zero-model Daytona preflight passed native create/load, HTTPS checks,
|
||||
raw sandbox absence, complete scoped inventory and child-key revocation/list absence.
|
||||
It made no model request and supplies zero task-qualification credit. The retained pending15
|
||||
Foundation, Core and Product exact-head CI and reviews are green; the new private
|
||||
master reconciliation has no published-head CI claim. Original runner-shutdown attempts and the single successful
|
||||
failed-jobs-only retry request per Core/Product run remain recorded.
|
||||
|
||||
At pending16 source `603756c22b2e39de9b5b5c3c26a0eb23c67604a1`, the
|
||||
causal local Plan and message cases passed. The next local ask-question case
|
||||
failed with `native_finalization_missing: session returned no semantic result`.
|
||||
Its native terminal reported completed/yielded without a semantic result. The
|
||||
sequence stopped, and original/physical cleanup passed. The original failure is
|
||||
retained for owner diagnosis. Current acceptance is two passed, one failed and
|
||||
seventeen unstarted; no paid retry or source repair is claimed here.
|
||||
|
||||
A proposed ordinary qualified17 transition is prepared separately and remains
|
||||
HELD. It must not be applied, built for release or deployed until Root accepts all
|
||||
twenty pending16 original cases and their full closure at the frozen execution
|
||||
source. Earlier pending15 local passes remain historical and supply no pending16
|
||||
credit. This ledger does not claim a pending16 pass count, a qualified17 artifact,
|
||||
a hosted result or permission to promote.
|
||||
|
||||
The held transition changes the top Product source only. Foundation and Core stay
|
||||
at their reviewed pending16 prerequisite identities; no extra merge or mirrored
|
||||
generation exists only to make branch metadata equal. The final qualified build
|
||||
must bind its actual top revision, complete native58 inventory, full pack contract
|
||||
and exact target artifacts. Ordinary startup and Test must work without a candidate
|
||||
grant, and retained pending16 identities must remain historical-only for execution.
|
||||
The actual accepted pending16 evidence and final qualified limitations must be
|
||||
recorded before the eventual qualified-source commit and artifact build.
|
||||
|
||||
Pending qualification uses the existing trusted startup-admission channel with
|
||||
exact company, model, profile, platform closure, expiry, run and session bindings.
|
||||
A warm handoff may rotate only the authenticated run identity through the normal
|
||||
Runner authority transition. Saved agent configuration cannot grant candidate
|
||||
authority. Ordinary installed and hosted qualified setup must work without that
|
||||
test authority.
|
||||
|
||||
| Required gate | Current evidence | Remaining proof |
|
||||
| --- | --- | --- |
|
||||
| Merged native behavior | Cold repair RED/GREEN retained; current master union and pending16 deterministic checks reviewed separately | Freeze reconciled source; build and verify both native targets and current pack contract |
|
||||
| Local and Daytona acceptance | Pending15 ten local passes; pending14 Plan failure and all original records unchanged | Fresh pending16 twenty-case matrix and full closure; zero carried acceptance credit |
|
||||
| Credential isolation | Protected startup and private-stdin SSH checks pass; pending14 zero-model preflight closes | Verify normal qualified setup and Test without candidate authority |
|
||||
| Normal distribution | Maintained closure has eighteen public packages and four daemon targets | Exact qualified-source build, full checks and clean installed consumption |
|
||||
| Ordinary product setup | Isolated hosted TEST workspace reserved | Installed Mac and hosted Linux setup/Test/save/reopen, assigned skill, download and reconnect |
|
||||
|
||||
Delivery remains a tested, reviewable PR stack and the isolated hosted TEST
|
||||
workspace. Production deployment, fleet defaults, stable publication and GitHub
|
||||
merge remain outside this handoff.
|
||||
|
||||
## Historical pending11 acceptance ledger
|
||||
|
||||
| Required gate | Environment | Observation | Verdict |
|
||||
| --- | --- | --- | --- |
|
||||
| Exact model and instructions | local and Daytona | Pending11 pinned native create/load/model/instruction controls passed on both platforms; the current authenticated product matrix remains incomplete | partial |
|
||||
| Scoped tools and credentials | local and Daytona | Native v6 credential fences passed; the first pending11 Daytona task received its exact full scoped context result and reached the intended unanswered Write | partial |
|
||||
| Ordinary setup | installed local and isolated hosted test | Ordinary connect/save/reopen/Test/run/reconnect and assigned-skill behavior remain required | unattempted |
|
||||
| Useful task | local and Daytona | Pending11 local and Daytona file editing passed; ordinary installed and hosted downloads remain | partial |
|
||||
| Human input and Stop | local and Daytona | All pending11 local human-input and Stop journeys passed; Daytona question, Plan and Write Stop passed; remote Stop/new/resume remains | partial |
|
||||
| Continuation and child loss | local and Daytona | All pending11 local continuation journeys and authenticated Daytona native worker loss passed; remote Stop/new/resume remains | partial |
|
||||
| Installed distribution | macOS arm64 and Linux x64 | Pending11 native closures, Mac binaries and immutable Linux qualification image verified; final normal public-package installation remains | partial |
|
||||
| Regression and repository checks | candidate revision | Prior foundation/product heads passed CI and review; shared recovery and admission controls passed real PostgreSQL owning suites and focused tests; final integrated full checks remain | partial |
|
||||
|
||||
Current pending11 totals remain 0 fully passed, 7 partial, 1 unattempted of 8
|
||||
gates. The two-environment matrix has nineteen distinct passing case records:
|
||||
all ten local journeys and nine Daytona journeys. Daytona Stop/new/resume remains
|
||||
required. Four original cases retain Core/source
|
||||
`0ca0d6b87d6d69b8a900621fb834ed74cb1ae446` and suite V4. Nine later cases retain
|
||||
source `d721f2f9052e19370985e53a7493e8792e319313` and suite V5. Six Daytona
|
||||
cases retain source `e972a46e5aedfcb48d7cb61328e81fb70ce7dccb` and suite V5.
|
||||
A fresh local Plan workflow at that last revision is representative handoff
|
||||
evidence and adds no distinct case. Each record retains its original expected
|
||||
terminal state; conversation continuity can finish in review. None is relabelled
|
||||
as a later source or suite. Original material failures remain failed.
|
||||
|
||||
The final local Stop/new/resume result and cleanup passed. Its original strict
|
||||
audit rejected the server-only `/new` record because that row retains the default
|
||||
legacy field. A separate reviewed classification verifies the saved human command,
|
||||
company/task/session generation, the complete two-event terminal record, and the
|
||||
unchanged server branch that returns before provider selection or execution. It
|
||||
also checks all three actual provider runs against the exact native Kimi11 model,
|
||||
profile and scope. The original audit remains failed; the derived classification
|
||||
has positive and negative controls, including conflicting duplicate rows in both
|
||||
orders. It does not treat the reset operation as inference.
|
||||
|
||||
The next Daytona plan-revision attempt failed during shared agent-directory
|
||||
adoption before the second provider turn started. Its original failed result,
|
||||
cleanup failure and raw package remain unchanged. A real PostgreSQL regression
|
||||
reproduced a metadata-only race: the collection worker changes `nextAttemptAt`
|
||||
and `updatedAt` between the final predecessor read and the adoption claim. The
|
||||
repair in code commit `759aad8f4fa8262b8af6e797617c0d89061681be` locks and rereads
|
||||
the predecessor under the existing agent and lease locks, compares its complete
|
||||
authority except those two scheduling fields, and retains the original claim
|
||||
predicates with the locked timestamp. Eighteen real-database controls and server
|
||||
no-emit validation pass, including competing owners, same-baseline receipt or
|
||||
checkpoint changes, retirement, canonical changes and foreign scope. The original
|
||||
incident did not retain its exact failed predicate, so the reproduced race is not
|
||||
claimed as conclusive incident causality. Closed-field conflict diagnostics will
|
||||
make a further failure discriminating.
|
||||
|
||||
The failed remote campaign's key is revoked. Additional cleanup uses raw sandbox
|
||||
resource APIs with an explicit verified organization: a toolbox lookup returning
|
||||
404 cannot establish sandbox deletion. Exact filtered inventory exposed two
|
||||
archived verification probes omitted by the earlier summary. All eight known
|
||||
owned allocations, including preflight, now return authoritative resource 404.
|
||||
The provider list retains stale entries; every returned owned entry was separately
|
||||
checked through the resource API. The earlier summary and failed cleanup grade
|
||||
remain preserved. Future campaigns require the corrected raw-resource cleanup
|
||||
and its real-SDK mocked-HTTP regression before allocation.
|
||||
|
||||
A separate host prerequisite failed before any test or model request because
|
||||
PostgreSQL 18.1 exhausted macOS System V semaphores. Qualification uses a verified
|
||||
official PostgreSQL 18.6 build with named POSIX semaphores through an isolated,
|
||||
recorded worktree dependency binding. A fresh migrated control plane reached
|
||||
health 200 at the actual source revision; a same-cluster SQL check confirmed its
|
||||
version, database and empty company schema. Owned processes and ports closed.
|
||||
The binding must be restored before normal package validation. Earlier PostgreSQL
|
||||
18.1 evidence retains its original environment identity.
|
||||
|
||||
The source-mode Product E2E control plane serves the checked-out source. Its
|
||||
source revision, production-byte baseline and separately compiled Core artifact
|
||||
are distinct roles. Native Mac binaries and the immutable Linux image retain
|
||||
actual build source `3a2dc2d78df0da51ef56dd6d898a2993496d3567`; neither is
|
||||
relabelled or rebuilt for a Core-only ownership repair.
|
||||
|
||||
The shared directory repair passed a fresh local and Daytona Plan workflow,
|
||||
including three native turns, the exact human revision decisions and resource
|
||||
cleanup. Five subsequent Daytona cases passed: ordinary and structured questions,
|
||||
structured-question restart, file editing, and restart continuity. The last
|
||||
Stop/new/resume case failed before reset: the cancelled native turn retained a
|
||||
workspace preservation obligation, and the saved `/new` message did not cross
|
||||
that hold. The original failed result and its original cleanup grade are retained.
|
||||
Independent raw resource checks found one stopped allocation despite an empty
|
||||
filtered inventory; exact run, organization and ownership evidence authorized
|
||||
its separate deletion, followed by an authoritative resource 404.
|
||||
|
||||
Native admission renewed the real preparation lease and changed the run to native
|
||||
mode without retiring that earlier ownership. The recorded future expiry can
|
||||
block workspace recovery after the provider has stopped. The original inspector
|
||||
log does not identify whether its coordinator, active-control or preparation-lease
|
||||
predicate fired first. A real PostgreSQL regression reproduces the stale fields
|
||||
on both local and remote admission. The repair clears `controllerBootId`,
|
||||
`controllerLeaseExpiresAt` and `executionStage` in the existing row-locked native
|
||||
admission transaction. The current preparation owner is still validated before
|
||||
the native coordinator commits. All twenty-eight controls pass, including foreign
|
||||
and expired ownership, cancellation before admission, transaction rollback, live
|
||||
preparation recovery and a later workspace synchronization failure. Workspace
|
||||
preservation, native ownership and physical Stop guards stay unchanged.
|
||||
|
||||
The preparation handoff repair passed a fresh local Stop/new/resume workflow at
|
||||
`a44ebc8abe8ebc766a207a9e68460db9829d6129`: three native runs, one authored
|
||||
server reset, generation one and a fresh final native session. Original result,
|
||||
raw evidence, native/control classification and physical cleanup pass. It is a
|
||||
representative regression check and adds no distinct case.
|
||||
|
||||
A second real PostgreSQL regression found that a valid Stop can arrive after a
|
||||
workspace check has saved a twenty-four to thirty-six second retry delay. The
|
||||
existing post-cleanup callback did not advance that check when physical Stop
|
||||
became confirmed. The shared repair records one marker bound to the same run,
|
||||
recovery generation and closed native/physical receipt digest. Only the normal
|
||||
post-executor cleanup producer records that evidence. The recovery service can
|
||||
advance its matching Stop recheck once, preserving retries, window and provider
|
||||
cooldown. A marker arriving while the worker runs stays pending until its failure
|
||||
commit, so the notification cannot be lost. File preservation and model admission
|
||||
remain under their existing owners.
|
||||
|
||||
The exact final positive fails against the prior implementation and passes with
|
||||
this repair. Nineteen final focused controls and typecheck pass. The earlier owning
|
||||
recovery and run-control suites passed one hundred thirty-two tests; those results
|
||||
retain their earlier test-module identity. The positive uses the actual resolved
|
||||
preserve-without-replay writer, saved human `/new` intent, real archive copy-back
|
||||
and normal explicit continuation admission. It proves timely reset eligibility;
|
||||
actual browser generation and a fresh native turn still require the final Daytona
|
||||
case. No blocker, timeout, grading, schema or polling change is included.
|
||||
|
||||
The six shared Core source/test changes are a separate prerequisite,
|
||||
[#18245](https://github.com/paperclipai/paperclip/pull/18245), based on the frozen
|
||||
Runner foundation. Product retains its Kimi plan, UI, fixtures and provider files.
|
||||
Product contains the real prerequisite ancestry, including queued-input repair
|
||||
`5c964f5d3cfd65e493b92c53cfe0c80b0d21f54d`. The final compiled Core must
|
||||
identify the integrated product source; earlier Core, native and image identities
|
||||
are retained separately.
|
||||
|
||||
A further real PostgreSQL control reproduced an already queued `/new` request
|
||||
being cancelled before provider admission and its saved wake marked skipped.
|
||||
The shared dispatch owner now keeps only that exact current human wake deferred
|
||||
inside the cancellation transaction. It requires the canonical no-execution proof,
|
||||
matching native preservation action and unfinalized native baseline. The existing
|
||||
queued-writer guard orders copyback after that commit. Real copyback and the
|
||||
normal callback then adopt the original input once, including concurrent callback
|
||||
delivery. Foreign, terminal and unverified requests remain fenced. The unchanged
|
||||
production patch passed 164 owning tests; final focused controls, the actual
|
||||
stranded-recovery retry discriminator and no-emit validation passed. A saved prior
|
||||
session reference remains a hint. The deferred wake prevents a competing automatic
|
||||
retry; no new retry policy, queue hook or polling was added.
|
||||
|
||||
The extra cancelled admission receipt remains in the raw Product E2E evidence.
|
||||
Kimi suite V6 classifies it separately from the three native provider turns and
|
||||
the single authored reset. It requires the complete server event receipt and final
|
||||
public proof of the human command, stopped source, preservation, authorization,
|
||||
reset and fresh native session. Provider counts, deadlines and earlier grades stay
|
||||
unchanged. Forty-nine focused controls and narrow typecheck pass. Controlled
|
||||
Chromium exercised the prior count failure and corrected full flow; that is
|
||||
fixture calibration, not live model acceptance. The nineteen accepted originals
|
||||
retain V4/V5. Only the remaining fresh case uses V6, fingerprint
|
||||
`f7883fff6c2a5fda14571ef150849e3d2514be036fed941e257276e6b956e5d7`.
|
||||
|
||||
Next, complete the one remaining Daytona Stop/new/resume case. The local
|
||||
representative remains valid because the additional event behavior requires a
|
||||
remote physical termination receipt and preserves local Stop policy. The
|
||||
controller must check every exact sandbox ID from authoritative run records,
|
||||
including failed attempts, even when filtered inventory is empty. Retain the
|
||||
nineteen passing cases only under the explicit source impact record. Qualification
|
||||
promotion remains held until twenty distinct cases and physical resource and key
|
||||
closure are verified.
|
||||
Afterward build the normal four-platform daemon and eighteen-package distribution,
|
||||
verify ordinary installed local setup, and deploy the exact qualified revision
|
||||
only to `kimi.test.paperclip.app` for normal configuration, assigned-skill, CSV
|
||||
download and persistence checks. Those installation and hosted journeys remain
|
||||
required. Production, fleet defaults, stable publication and GitHub merge remain
|
||||
outside this handoff.
|
||||
|
||||
## Earlier pending11 recovery observations
|
||||
|
||||
The following observations retain their original source, artifacts and next-step
|
||||
context. The current state above supersedes their campaign instructions.
|
||||
|
||||
At this earlier snapshot, pending11 totals: 0 fully passed, 7 partial, 1 unattempted of 8 gates.
|
||||
The current twenty-cell matrix has four passing cells on frozen Core/source
|
||||
`0ca0d6b87d6d69b8a900621fb834ed74cb1ae446`, pending Kimi11 and suite V4:
|
||||
local native Write Stop, local native worker loss, local message-marker, and
|
||||
Daytona native Write Stop. Their original reports retain that identity; sixteen
|
||||
cells remain. Original failed attempts remain failed. The fixture-only V5 successor
|
||||
clarifies Plan marker formatting and expands detailed run evidence without
|
||||
changing their task prompts, graders, Core, native closures, or immutable image.
|
||||
The remote observer source hash changes with its new diagnostics and remains an
|
||||
explicit test-identity derivative in the scoped context; full model-facing byte
|
||||
equality is not claimed. Original Stop did not invoke the fault branch, common
|
||||
observer functions remain unchanged, and fresh authenticated worker-loss coverage
|
||||
through the new observer is required before final acceptance. Profile9 results remain
|
||||
historical support. These grouped gates do not replace per-cell evidence.
|
||||
|
||||
The explicit-only `kimi-qualification` Product E2E suite has 20 required cells:
|
||||
message-marker, plan-revise-accept, ask-question, structured-question-resume,
|
||||
structured-question-restart-resume, file-edit-validate, continuity-restart, and
|
||||
stop-new-resume, native pending-permission Stop, and native pending-permission worker loss, each on local and Daytona. Each has one required repetition and
|
||||
zero automatic paid retries. Historically, all ten profile9 local cells passed
|
||||
with report packaging and cleanup. Stop retains source `821256d6`, campaign
|
||||
`local-2026-10-29T14-46-04-979Z`, run `91557abb-b9c0-422d-af1e-f0e82e948e96`.
|
||||
The other nine retain source `f7ac439e` and their individual campaigns between
|
||||
15:10 and 15:33 UTC. The qualification-only observer correction leaves all native
|
||||
runtime/profile/image inputs unchanged. It reads argc-bounded kernel argv while
|
||||
retaining exact compiled-loader hashes, sealed native bytes, inode, ancestry and
|
||||
run identity; a real compiled native/ACPX peer proved title mutation, excluded
|
||||
an environment canary, rejected foreign/ambiguous targets and retired the child.
|
||||
All ten original result hashes were independently checked. The four earlier local
|
||||
failures remain unchanged alongside their later diagnoses and cleanup receipts.
|
||||
|
||||
Historical profile9 Daytona qualification stopped at its first lifecycle case.
|
||||
A later independent message-marker control passed all six matchers, durable Done,
|
||||
native Succeeded and cleanup on source `50736cb6`, run
|
||||
`0a809b5e-9a82-4590-9283-cbaa359f676f`. That is one case, not the full matrix.
|
||||
The first launch stopped at
|
||||
admission because two executable digests were missing; no task or sandbox was
|
||||
created. With the exact image Node/runnerd digests supplied, the first actual
|
||||
Stop cell failed before provider startup: setup Test could not reach the global
|
||||
Kimi catalog. Its failed/cleanup-failed grade is preserved. Six archived Test
|
||||
sandboxes were subsequently ownership-verified, deleted and checked absent404;
|
||||
that post-failure recovery does not change the original grade.
|
||||
|
||||
Assigned-skill behavior and ordinary connect/save/reopen/Test/run/reconnect remain
|
||||
separate requirements, including the requested hosted test workspace.
|
||||
|
||||
### Earlier state before fixture V5
|
||||
|
||||
Four current cells passed with independently checked report hashes, native profile/model identity, terminal state, and cleanup. Local Plan acceptance then stopped because the model omitted the required draft marker in its actual `write_document` input; saved bytes, API reads, and the Plan UI matched that input. The strict marker and revision/approval checks remain. A fixture-only successor makes the literal first-line format explicit and increments the Kimi definition to V5; a later passing response does not establish reliability or alter the original failure.
|
||||
|
||||
The first current Daytona worker-loss attempt failed inside its one-shot fault observer. Its original diagnostic erased the failing guard and cannot establish whether a signal occurred. A separate no-network Linux x64 control used the immutable image, actual Kimi and compiled ACPX, and a simulated model. Python 3.13.5 process-handle signaling and all fourteen Linux selector controls passed. The real pending Write was reached, then inspection failed with a missing-file error before any fault signal. Normal Stop retired all seven owned processes, and both controllers independently verified the sandbox absent before the temporary key was revoked. This is diagnostic evidence, not authenticated task acceptance. A second inspect-only control located the exact read: the selector resolved the authenticated parent executable to a deleted Linux memfd alias, then tried to reopen that name instead of its held descriptor. It failed before signaling. Its normal Stop retired all seven processes, and sandbox deletion and key revocation were independently verified. The descriptor correction then passed a same-process Linux comparison: the old selector reproduced that exact error, the corrected selector verified the held executable and its kernel seals, and the unchanged process-handle operation killed only the owned native Kimi child. The unanswered permission expired with `provider_process_lost` and replay disabled; a correctly scoped stale approval failed, no file effect or second model request occurred, and all seven owned processes retired. Sandbox deletion and temporary-key revocation passed independently. All model responses in these controls were simulated. Fresh authenticated worker-loss acceptance remains required. The existing TypeScript wrapper still expected fourteen Python tests after the suite grew to twenty-five. It reproduced that count failure, then passed all four checks after its expected count was updated. This correction changes test accounting only.
|
||||
|
||||
Pending Kimi11 and Copilot83 are frozen after the actual lock-refresh prerequisite
|
||||
was merged. The prerequisite's squash commit has the same complete tree as the
|
||||
reviewed source. Genuine merges preserved all published identity fixtures and
|
||||
the managed lockfile; no history-only reconciliation or policy bypass was used.
|
||||
|
||||
Fresh pending11 Mac native controls passed. The Linux qualification image was
|
||||
built, published under an immutable digest, and independently checked against all
|
||||
42 exported layers, native executable hashes, and installed metadata readers.
|
||||
These are delivery prerequisites, not the twenty authenticated Product E2E cells.
|
||||
|
||||
The first pending11 Daytona Stop attempt reached real inference, one normal UI
|
||||
Allow once for the scoped empty-input context read, and the exact intended native
|
||||
Write permission. It failed before submitting Stop. The fixture incorrectly
|
||||
equated the native turn with the controller envelope turn and tried to parse a
|
||||
bounded display event as the full tool result. The official encrypted checkpoint
|
||||
retained the full matching result and its completed delivery acknowledgment.
|
||||
|
||||
The run then reached its configured 120-second timeout. Recovery later overwrote
|
||||
that error after mistaking the remote numeric PID for an unrelated live process
|
||||
on the controller host. Commit `a24ebd5e7e3fa0d086e91cd3824efd52c8eaee0b`
|
||||
requires exact current host-authored local process identity before local probes
|
||||
or clearing. Remote or unknown identities stay under native recovery authority.
|
||||
Real PostgreSQL tests reproduce both remote collision states, preserve verified
|
||||
local live/dead behavior, and retain authenticated remote termination handling.
|
||||
|
||||
The corrected fixture passed 62 deterministic controls, targeted typecheck,
|
||||
and the real MCP/sidecar/Core-to-official-PostgreSQL boundary with seven
|
||||
negative scope/session controls. It positively bridges native and controller
|
||||
turns through the exact authenticated request and delivered command. It binds
|
||||
the logical workspace separately from the physical observer lease and retains
|
||||
the original calibration fixture as version1 beside the new version2 fixture.
|
||||
The current suite is version4 and remains explicitly pending.
|
||||
|
||||
The next controlled attempt, on `23958e5ad07e0b93b8eef098a9d0038c215bad15`,
|
||||
passed the full scoped context checkpoint proof and reached the native Write.
|
||||
It then failed before Stop because the UI check counted all visible request
|
||||
cards: the resolved context request and the pending Write. Its original
|
||||
failed/cleanup-failure grade, 49 raw files and 65 executable snapshots remain
|
||||
unchanged. Separate cleanup again verified all owned cloud resources absent and
|
||||
revoked the temporary key. This is a second failed attempt, not Stop acceptance.
|
||||
|
||||
The corrected selector binds the exact target card to the independently verified
|
||||
native pending callback before and after lookup. It retains resolved history and
|
||||
the same card after cancellation or expiry. Ten Chromium controls reproduce the
|
||||
old failure and reject wrong targets, duplicates, changed callback identity and
|
||||
disabled decisions. The existing native/context controls and targeted typecheck
|
||||
pass; suite version4 and its promised behavior are unchanged.
|
||||
|
||||
Fresh CI also exposed intended-local test fixtures that inserted bare PIDs without
|
||||
using the real process-metadata writer. Those fixtures now use that writer and
|
||||
retain their original recovery, contract and concurrency assertions. The separate
|
||||
unknown-owner test kept its original assertion and exposed a real guard-order
|
||||
regression: an observed run could lose its ownership warning. The minimal repair
|
||||
restores the existing observed-owner warning before the unknown-ID hold, without
|
||||
probing host PIDs or granting clearing, signaling or retry authority. Both unknown
|
||||
and remote observed-owner controls pass, as do all 65 recovery-module controls and
|
||||
the original assembled recovery cases. Real remote timeouts retain their errors.
|
||||
|
||||
The original failed/cleanup-failure verdict and raw reports remain unchanged.
|
||||
Separate cleanup verified every owned sandbox absent and revoked the temporary
|
||||
test key. The fixture's later close error did not establish its retirement seal;
|
||||
no cleanup check is relaxed. The generic ACPX display clipping metadata defect
|
||||
is pre-existing and remains a documented limitation; full model-facing delivery
|
||||
uses a separate acknowledged path.
|
||||
|
||||
The repair changes controller recovery and qualification fixtures. It does not
|
||||
change the Kimi native patch, either closure, the tested Mac binaries, or the
|
||||
Linux image inputs. A new matrix revision must record the exact reviewed delta
|
||||
and rebuilt server identity while retaining the original artifact build SHAs.
|
||||
The next paid action is one corrected Daytona Stop cell after the rebuilt controller and
|
||||
strict checkpoint/card admission controls pass. Its original grade, raw files and physical cleanup must
|
||||
pass independent audit before the remaining nine remote and ten local cells.
|
||||
|
||||
After all twenty cells pass, promote ordinary admission in a separate reviewable
|
||||
qualification PR, build the normal four-platform daemon/public-package set, and
|
||||
verify the installed local UI. Then deploy the exact qualified revision only to
|
||||
the user-approved isolated `kimi.test.paperclip.app` test workspace and drive the
|
||||
ordinary hosted configuration, assigned-skill, artifact and persistence journey.
|
||||
Production, fleet defaults, stable publication and GitHub merge remain outside
|
||||
this handoff.
|
||||
|
||||
## Reconciliation and earlier qualification history
|
||||
|
||||
The following records describe earlier candidate states and their original
|
||||
evidence. The current state above supersedes their next-step and blocker text.
|
||||
|
||||
The next shipping prerequisite is now frozen at the existing lock-refresh PR
|
||||
[#18185](https://github.com/paperclipai/paperclip/pull/18185), head
|
||||
`9600623783d118a79f8008deb9ab05d80715a15a`. Reconciliation is in progress;
|
||||
the earlier master freeze below remains historical. The trusted repository
|
||||
policy permits lock changes only through its maintained refresh branch or
|
||||
Dependabot. This existing prerequisite contains the exact thirteen-line lock
|
||||
correction already used by the candidate, with matching reviewed report proof.
|
||||
It also inherits three newer master changes, including native finalization and
|
||||
the Antigravity builder. Actual foundation merge conflicts make a genuine
|
||||
reconciliation necessary. The new behavior and published identity bytes must
|
||||
survive; no policy exception, mixed-branch rename, or history-only merge is used.
|
||||
|
||||
The builder is one of Kimi's 31 attested source files, so that reconciliation
|
||||
requires a new pending identity, expected version11, and a later qualified
|
||||
identity, expected version12. No full paid pending10 cell was started. Its
|
||||
successful Linux build, zero-prompt native create/load proof, complete exported
|
||||
image integrity checks, and controlled Mac native fence remain evidence for
|
||||
pending10 only. The exported image is retained without ECR publication. A fresh
|
||||
build and all twenty live cells will bind the reconciled candidate. Later
|
||||
unrelated master activity alone does not reopen this prerequisite freeze.
|
||||
|
||||
The revised remote fixture also passed the actual maintained MCP-to-checkpoint
|
||||
boundary and an isolated real PostgreSQL observer check. That check used the
|
||||
official encrypted checkpoint loader in a read-only transaction, verified the
|
||||
saved checkpoint remained unchanged, and closed the observer before stopping
|
||||
its owned database. The integrated observer's bytes match the calibrated source.
|
||||
These controls are prerequisites for the first live native Stop case; they do
|
||||
not establish authenticated model behavior or full recovery acceptance.
|
||||
|
||||
The shipping baseline is now frozen at master `9f3fc2094f61e311b2c5adb4d5beccefc0199943`.
|
||||
The reconciled foundation is `7c6cffafa26c70e50c241c71dd536aa79406041d` and
|
||||
product code is `60f0bb7b3171e773f21fe80fb524d9130e36500f`, with 97 and 77
|
||||
review paths respectively. Both existing PR branches were updated by fast-forward.
|
||||
Published master identity bytes win over conflicting unmerged variants; original
|
||||
candidate variants remain in preserved Git history and their evidence receipts.
|
||||
|
||||
Current Kimi10 is pending and hidden from ordinary selection, command digest
|
||||
`sha256:c8a39af9d580cdb5fe20a3333a566e2f97a1b4ec3a0025cb647d2144df358604`.
|
||||
The native2.3.3 patch v6 and vendor closures are unchanged. Current dependent
|
||||
identities are Pi35, Copilot82 and Grok14. Earlier prepared qualified10 and
|
||||
Copilot82 digests and the `9ba72696` draft image identity are obsolete and must
|
||||
not be reused. Qualification will promote a new identity only after live gates.
|
||||
|
||||
Master adds reachable notification context/helper environment and Unix Runner
|
||||
FIFO/listener/thread behavior, plus connection/restart ownership fixes. This
|
||||
changes actual Kimi and native Bash execution, beyond identity text. All twenty
|
||||
local/Daytona cells will therefore refresh against pending10, preceded by actual
|
||||
native credential/context controls. Old9 grades and native binaries are not
|
||||
relabeled as pending10 or as the final release.
|
||||
|
||||
The pending10 image build is pinned to product `60f0bb7b`, canonical candidate
|
||||
content `af2d4e8d8ea38381eb265dfdf8141d23ca31369bcb7ae12db8c6daa1b34a4e15`.
|
||||
Its source was verified through an anonymous public checkout; no operator or
|
||||
provider credential is sent to the build. Later test-only changes require a
|
||||
canonical image-input comparison, not a rewritten image provenance claim.
|
||||
|
||||
The foundation PR #18028 at `08a5516b` and product PR #18029 at `6123a67a`
|
||||
had green CI at those heads. The product review was Greptile 5/5 with one
|
||||
actionable report configuration load-order comment. That comment is addressed
|
||||
in the subsequent harness repair; fresh-head CI/review remain required. Both
|
||||
PRs remain draft because real acceptance is incomplete. Full typecheck and build passed. The
|
||||
local full test command failed three AI-connection cases after a first 15-second
|
||||
timeout, then accumulated an idle-transaction/TRUNCATE wait chain in the unchanged
|
||||
Git-expiration suite. The three connection cases passed separately without
|
||||
source changes. The full command was interrupted after recording the lock chain;
|
||||
all owned processes and its database port were independently verified stopped.
|
||||
This is not a full local test pass or an established explanation of every failure.
|
||||
|
||||
Recovery and re-evidence on 2026-10-29:
|
||||
|
||||
- The previous checkout and its worktree registration disappeared. The cause is
|
||||
unknown. Pushed source was restored into a newly attached managed worktree;
|
||||
frozen dependency installation and the full build passed again. Historical
|
||||
local pass logs and all ten audited result hashes survived, but the original
|
||||
raw report packages did not. Those passes are not reconstructed from logs or
|
||||
synthetic calibration evidence.
|
||||
- Commit `6123a67a` adds an explicit absolute report-root override with unchanged
|
||||
default storage, physical output containment, and traversal/symlink rejection.
|
||||
Actual final publication was checked across checkout removal, early failure,
|
||||
redaction, and missing mandatory evidence. The repair passed 123 focused
|
||||
evidence tests, seven sentinel tests, harness typecheck, actual CLI admission
|
||||
checks, and independent outside-sentinel preservation. These are harness
|
||||
controls, not provider acceptance.
|
||||
- All ten fresh local profile9 cells passed at exact `6123a67a`, including final
|
||||
packaging and cleanup. Their original packages now live outside the checkout.
|
||||
Independent collection rehashed all 221 files without mismatch; each owned
|
||||
server and embedded database stopped and each temporary data root was absent.
|
||||
The 19 retained run records are not a model-call count.
|
||||
- An independent temporary Cloud TEST credential reached the global Kimi endpoint
|
||||
and the exact installed Linux9 runtime passed native startup. The first
|
||||
controller attempt then failed before any test body: a Promise resolver
|
||||
shadowed the path resolver during event validation. The original failed and
|
||||
cleanup-failed grade remains. Credential-free actual-event replay reproduced
|
||||
the error and verified the correction. Empty JUnit suites, retained Playwright
|
||||
events, and source readiness gates establish that product fixture allocation
|
||||
and provider admission were never reached. Owned processes/ports were absent;
|
||||
the independent preflight sandbox was deleted and checked404, and the temporary
|
||||
credential was revoked and checked absent.
|
||||
- The corrected controller reached the actual first Daytona cell. That attempt
|
||||
failed closed at `remote_native_fixture:lease_metadata`: its fixture selected a
|
||||
reusable warm environment, while the native lifecycle observer requires an
|
||||
isolated non-reusable lease. The observer guard is retained. Both observed
|
||||
owned sandboxes were separately deleted and checked404; its temporary credential
|
||||
was revoked and checked absent. The original failed and cleanup-failed grade
|
||||
remains; model usage is unavailable. A narrow fixture-producer correction and
|
||||
fresh Daytona acceptance are required before qualification.
|
||||
|
||||
The follow-up harness repair at `d5fc5c42` changes only the two remote native
|
||||
lifecycle fixture configurations to isolated non-reusable leases. The eight
|
||||
other Daytona cases remain warm and all ten local execution inputs are
|
||||
unchanged. This per-case override is included in the suite fingerprint. The real
|
||||
fixture-producer regression first failed both lifecycle cases, then passed with
|
||||
three unchanged controls. The strict remote observer is unchanged. The same
|
||||
repair loads the normal campaign report setting from its local environment file
|
||||
after admission, with a credential-free actual-launch red/green regression;
|
||||
listing and installed-startup admission retain their prior behavior. Focused
|
||||
catalog/producer/observer/storage checks passed 227 tests with one existing skip,
|
||||
and harness typecheck passed. Native profile9, vendor closures and image inputs
|
||||
are unchanged; retain the fresh local evidence at `6123a67a` and pin a fresh
|
||||
Daytona campaign to the repaired source rather than relabeling old attempts.
|
||||
|
||||
Additional profile9 Daytona first divergences and shipping reconciliation:
|
||||
|
||||
- Restoring source did not restore its ignored Linux companion manifest/binary.
|
||||
The next run failed closed before launch. Exact image9 assets were recovered
|
||||
under a Linux-only release descriptor with their original source `5f4c9edc`;
|
||||
no other platform binary was invented or relabeled. Actual source, compiled
|
||||
Runner and compiled server-vendor readers passed strict manifest/profile/
|
||||
closure/binary checks, while the Mac daemon remained unchanged. This proves
|
||||
the scoped controller asset recovery, not a complete four-platform release.
|
||||
- After those assets were restored, a real Kimi turn requested native Read for
|
||||
the bootstrap file. Its canonical request had no proven file target and was
|
||||
never approved. The lifecycle case timed out and failed session recovery;
|
||||
the original failed/cleanup-failed verdict is retained. All observed owned
|
||||
sandboxes and temporary credentials were separately retired and verified.
|
||||
- A bounded real pinned-native control established that approve-reads delegates
|
||||
these provider requests; it does not automatically approve native Read.
|
||||
The proposed replacement uses server-owned get_task_context, whose empty-input
|
||||
schema and active-task authority are independently checked, with one explicit
|
||||
fixture-human approval after observer baseline and continuation publication.
|
||||
Unknown native Read is still unapproved and unqualified. Native Write must
|
||||
remain genuinely pending before Stop/loss. Synthetic proof rows alone do not
|
||||
establish delivery: production boundary calibration identified semantic input
|
||||
in the durable checkpoint and a delivered result command, rather than a
|
||||
semantic result event in the public run log. The checkpoint observer and full
|
||||
fresh runtime acceptance remain gates before using this replacement.
|
||||
- The master merge preserved sealed private-handoff custody ahead of ordinary
|
||||
report-root configuration, including after dotenv. Actual private/public
|
||||
launch regressions passed 57 tests and a wrong-root mutation failed all four
|
||||
relevant cases. A missing upstream tar-stream lock importer was repaired by
|
||||
the canonical pinned package manager: exactly thirteen added lines, with no
|
||||
unrelated resolution churn. Frozen installation and Runner TypeScript build
|
||||
passed. Combined harness checks passed 325 tests with one existing skip, plus
|
||||
137 Node tests and six Rust notification controls with one existing opt-in
|
||||
ignore. Full repository checks and current-head review remain release gates.
|
||||
- All ten fresh local9 report packages remain outside the checkout, and the
|
||||
explicitly approved 14.8 MB private backup was verified by full checksum,
|
||||
byte count and encryption read-back. Future raw payloads have no implied
|
||||
upload authorization from that one archive approval.
|
||||
|
||||
Material local attempts remain distinct:
|
||||
|
||||
- The first profile9 Stop attempt failed its outdated final UI oracle. Inspection
|
||||
also found duplicated partial assistant text; the provider-neutral consumer now
|
||||
respects the existing nested progress marker. Its retained event regression
|
||||
demonstrated red then green.
|
||||
- A later Stop passed all behavior/cleanup checks but failed report packaging:
|
||||
custom screenshot filenames did not satisfy the existing `final-state.png`
|
||||
contract. Two capture names were corrected, with positive and missing-final
|
||||
packager checks. The original grade remains failed.
|
||||
- One replacement failed before provider admission because two of its three
|
||||
credential-home quorum ports belonged to unrelated listeners dating from
|
||||
October 4 and 21. The guard correctly refused ownership. Foreign processes
|
||||
were untouched, and separate scoped cleanup verified our processes retired.
|
||||
- The next local Stop passed behavior, report evidence and physical cleanup in
|
||||
33.8 seconds. The following provider-loss attempt reached an unanswered Write
|
||||
but refused fault injection: macOS `ps` renders loader bytes differently from
|
||||
the pinned argv. An unpaid real guarded-native probe reproduced that exact
|
||||
hash divergence. The corrected observer reads argc-bounded argv with those exact byte and
|
||||
ownership checks retained; the subsequent real loss cell passed.
|
||||
|
||||
Profile9 distribution proof now covers eighteen actual public-layout tarballs,
|
||||
a clean scripts-disabled consumer, fresh installed CLI setup through the admitted
|
||||
Node/npm proxy, network-denied reuse, unchanged lockfile and installed native ACP
|
||||
create/load/model/EOF. No developer login home, refresh token, private database
|
||||
override or model prompt was used for that packaging proof.
|
||||
|
||||
The native Linux image was built from frozen source `5f4c9edc`; later source
|
||||
changes affect UI and test observers, with image inputs unchanged. An initial
|
||||
export timed out after image construction and saved no smoke proof. The corrected
|
||||
job `14af59a4-93be-4f83-990e-e308625ab1cc` completed in 9m40s with timestamped
|
||||
stages, independently saved native smoke proof and a streaming compressed export.
|
||||
Image ID is `sha256:139869415cdd7c7cd2a59f21626b8bdbbe0088f17209abf8cdda4f6a80b99718`;
|
||||
content identity is `4560ebf62a9640e43aff893b1a6dd497746eeb2787732818b62acd911cdb0b7b`.
|
||||
Create/load instruction acknowledgments and bounded EOF passed without prompts or
|
||||
credentials. Local collection verified the full archive, source, profile,
|
||||
closure, pack and daemon hashes. Its published immutable reference is
|
||||
`public.ecr.aws/q2k8x0v0/paperclip/kimi-qualification-eed3@sha256:bad9994dadf1a55743dabed3cd3f785210131938d4407c8b0ca92a30a922c177`.
|
||||
A real credential-free Daytona import/native smoke passed in 122.4 seconds on
|
||||
Linux x64 with 4 CPU, 4 GiB memory and 10 GiB disk, then its exact sandbox was
|
||||
deleted and checked404. It verified native create/load/model/instruction/EOF
|
||||
boundaries without a model prompt. No authenticated Daytona task has passed. All
|
||||
build failures are retained; the rejected IAM expansion
|
||||
was never applied and no operator/provider token was sent to CodeBuild.
|
||||
|
||||
The user additionally requested hosted verification and confirmed an isolated
|
||||
workspace at `https://kimi.test.paperclip.app`. It was created through the normal
|
||||
Cloud UI under the existing authorized owner; the shared QA identity lease was
|
||||
left untouched. It is active and empty on verified Core
|
||||
`f8484a9f8d1f68e9534fee4f29e00e716dc680f4`, distribution `cloud-40777791731-1`,
|
||||
not the Kimi build. A singleton TEST campaign enabled Native Runner on only this
|
||||
workspace with matching applied/desired hashes; fleet defaults stayed unchanged.
|
||||
Use the maintained Core preview deployment for this unmerged branch, record its
|
||||
exact serving SHA/image, and qualify the Kimi user journey there. This does not
|
||||
certify the private Cloud UI composition. No production or fleet-default change
|
||||
is in scope.
|
||||
|
||||
The active remote blocker is now narrowed by independent controls. The same fresh
|
||||
selected credential verifies through the host catalog path, while the actual
|
||||
remote metadata helper returns `KIMI_PROVIDER_ERROR`. Installed helper/Node bytes,
|
||||
profile admission and key/model forwarding match the expected boundary. A fixed
|
||||
remote GET failed with `ECONNRESET` before HTTP, with sandbox blocking false and
|
||||
no allowlists. Credential-free controls also reset for example.com, including
|
||||
direct IPv4 curl, with no proxy variables present. All three diagnostic sandboxes
|
||||
were destroyed and checked404. No TLS protection was weakened, endpoint switched
|
||||
or inference attempted during those diagnostics.
|
||||
|
||||
[Daytona's current network policy](https://www.daytona.io/docs/en/network-limits/)
|
||||
says Tier1/2 organization restrictions override sandbox settings. Its essential
|
||||
service list includes `*.kimi.com` and `*.moonshot.ai`, but not the selected
|
||||
account's official global `api.kimi.ai` endpoint. This is consistent with the
|
||||
observations, not a confirmed cause: this key cannot read its organization policy
|
||||
(HTTP401). It advertises only the public US target. The user was asked to confirm
|
||||
full egress or another suitable test credential, while the existing TEST Cloud
|
||||
provider setup is being checked. The global account must not be routed to the
|
||||
separate CN endpoint as a workaround.
|
||||
|
||||
Critical path: establish legitimate Daytona egress to the global Kimi endpoint,
|
||||
then run ten authenticated Daytona cells. Only after native qualification should
|
||||
a new immutable profile promote ordinary admission/default packaging, with final
|
||||
installed artifact/image checks and ordinary setup/assigned skills. Deploy the
|
||||
exact candidate to the isolated test workspace, complete its hosted journey, and
|
||||
finish the PR stack. A promotion patch has been prepared and tested only in TEMP;
|
||||
it is not applied and does not count as qualification.
|
||||
|
||||
## Evidence and first divergences
|
||||
|
||||
- Frozen root dependency installation passed with the normal network-enabled
|
||||
package manager. Restricted-network signature fetch failures were retained;
|
||||
no signature gate was disabled.
|
||||
- Runner TypeScript/Rust typecheck passed before final sidecar registration edits.
|
||||
Focused Runner contracts: 297 tests passed. Product setup/admission, token,
|
||||
catalog, and route checks have separate passing results; these are not live proof.
|
||||
- Both locked runtime closures built in 8.4 seconds with 33 required npm packages.
|
||||
macOS arm64 installed native metadata and Linux x64 metadata under emulation
|
||||
passed create/model/load/EOF; Linux emulation is not Daytona qualification.
|
||||
- Real bundled ACPX manager admitted exact `kimi-for-coding` and current bound
|
||||
instructions with physical cleanup. The first two attempted turns failed before
|
||||
semantic tool delivery. The second retained the native 403: account subscription
|
||||
lacked Kimi Code entitlement. Native CLI login succeeded, and the user then
|
||||
subscribed. Those failures remain failures; no cost counters were available.
|
||||
- The first entitled turn completed native execution and called the exact MCP
|
||||
echo with the instruction-only nonce. Its smoke observer incorrectly expected
|
||||
`paperclip_echo` at a handler that receives canonical `echo`. Native arguments,
|
||||
MCP receiver error, and terminal completion establish a grading/observer failure,
|
||||
not a successful tool round trip. The corrected observer then passed a real
|
||||
authenticated semantic echo, native terminal completion, and physical cleanup.
|
||||
Its profile2 evidence is retained separately from the final candidate.
|
||||
- Review found and corrected a real normal-path gap: the packaged sidecar's
|
||||
handwritten agent parser omitted Kimi even though direct driver metadata passed.
|
||||
Final sidecar checks and real Rust/sidecar product acceptance remain required.
|
||||
- Native controlled-model probes use the actual pinned Kimi and bundled ACPX.
|
||||
They found upstream approval construction omitted `toolInput`; the owned patch
|
||||
now carries exact nested and UTF-8 arguments, including empty objects. Denied
|
||||
Write caused no file effect, and native HTTP418 remained a typed failed turn.
|
||||
- The next probe found provider credentials inherited into native Bash. The
|
||||
patch filters `KIMI_API_KEY` and private instruction transport from native
|
||||
subprocesses while preserving explicitly assigned task variables. Actual ACPX
|
||||
always advertises terminal capability, which routed Bash through its own host;
|
||||
the final provider patch forces the owned filtered native process path instead.
|
||||
Final verification must observe zero `terminal/create` requests and no secret
|
||||
in the actual child, rather than merely inspecting a filtered object.
|
||||
- Bundling exposed a `Function.toString()` source difference that changed patched
|
||||
native bytes. The patch now embeds one canonical static helper source; raw and
|
||||
archived CJS setup produce the same pinned output. Final source comment removal
|
||||
changes its byte identity again before profile4 freeze; old evidence keeps its
|
||||
original closure and profile identity.
|
||||
- Required repository checks remaining: final full typecheck, supported full test
|
||||
run with the original infrastructure failure retained, full build, and the
|
||||
ordinary setup/save/reopen/Test/run/reconnect path. Token gates and map structure
|
||||
currently pass. Setup Test now uses the official authenticated model catalog;
|
||||
it does not claim inference, quota, or task execution from a metadata response.
|
||||
|
||||
The full repository build passed on candidate profile5. Its first Product E2E
|
||||
message-marker attempt reached real UI/server/database setup, then stayed paused:
|
||||
the installed-runtime-only Test warning is rejected by the ordinary agent lifecycle.
|
||||
No model turn started. This candidate failure is preserved separately from the
|
||||
retry. The correction performs a fixed-endpoint authenticated model-catalog probe
|
||||
before reporting a successful Test. A real catalog request accepted the credential
|
||||
and returned exact `kimi-for-coding`; the official `KimiCLI/2.3.3` client header
|
||||
is required (a generic client was rejected with HTTP403). The selected ID is never
|
||||
substituted. The first release also disables native question and plan tools so
|
||||
only the promised semantic interaction path remains available.
|
||||
|
||||
Historical profile8 command digest:
|
||||
`sha256:53c63af8722894e14d48ed55526f99fcd79e06c86ae1c7aa384ef7ee8c56efd4`.
|
||||
Native runtime patch v6 SHA256:
|
||||
`79980aa95c54f325a05cbdf883f74443705db28f3e412a1b5ae22929d939eb66`.
|
||||
Closure pins: macOS arm64 `6038325abd0431c30097ff1f572a9b29816e1b4da372d8cb86b2ca5d18ea34e7`,
|
||||
Linux x64 `105d8394adbbc76a953a12cc51b5288eb940ad693291e584f6cdc6407213164d`.
|
||||
The actual native Kimi plus bundled ACPX controlled-service probes passed seven
|
||||
requests for instructions/arguments/auth fencing/denial/failure, then nine for
|
||||
pending-permission Stop and spontaneous owned native-worker loss. Both pending
|
||||
callbacks aborted, stale approval caused no response/write/new model request,
|
||||
and workers physically retired within five seconds. The model service was simulated;
|
||||
these results do not establish authenticated product acceptance.
|
||||
|
||||
To retain native macOS Runner acceptance without changing shared IPC state, an
|
||||
official checksummed PostgreSQL 18.6 build with named POSIX semaphores passed fresh
|
||||
initialization, normal migrations, SQL empty-database checks, and cleanup. Only
|
||||
this worktree's unshared native dependency entry was temporarily pointed at that
|
||||
private build. It was restored before canonical public-package staging. The
|
||||
accepted database process/version must be disclosed with each affected result;
|
||||
no database fixture contract or tracked dependency file was changed.
|
||||
|
||||
Early campaign cost record: subscription model calls have no reliable native
|
||||
usage counters; cost is unknown. At that point one cloud job was stopped before
|
||||
building and no image or Daytona sandbox had been created. Original
|
||||
403s, the grading defect, credential-boundary failures, and database setup failure
|
||||
remain separate failed or blocked attempts; none are overwritten by retries.
|
||||
|
||||
The Code endpoint for this finite release is the global coding API
|
||||
`https://api.kimi.ai/coding/v1`, matching the successfully restored account.
|
||||
Mainland-region routing and managed OAuth refresh are outside this release.
|
||||
Authenticated qualification binds only the existing login's access token to the
|
||||
isolated run; refresh tokens and the ambient login home are not transferred.
|
||||
|
||||
The second local message-marker attempt reached the real model and tools. Run
|
||||
`4d394aef-0e6a-49a8-9fb0-873651d0241f` finished in 24 seconds, accepted the finish
|
||||
contract, saved task Done, and passed physical cleanup. Its original verdict is
|
||||
failed: progress text and final text shared one provider item, so the visible
|
||||
final bubble contained both. Native patch v5 now forwards actual turn-step UUID
|
||||
boundaries. The Kimi projection validates their provenance and order before
|
||||
passing generic assistant boundaries to Runner. It does not infer boundaries
|
||||
from text, tools, or HTTP response identifiers. The actual native Kimi/ACPX
|
||||
controlled peer passed this framing case alongside instructions, tool arguments,
|
||||
credential isolation, denial, typed failure, Stop, and native-worker loss.
|
||||
|
||||
Historical profile7 verification: 162 sidecar/projection/metadata/instruction tests,
|
||||
144 driver/projection tests, and 18 Rust provider-state tests passed. TypeScript,
|
||||
Rust daemon staging, bundled entrypoints, and all three affected immutable
|
||||
profile checks passed. The archived installer passed fresh public downloads,
|
||||
offline reuse, tamper rejection and cancellation on profile7. Final installed
|
||||
CLI/server tarballs remain to be rebuilt and checked. The full repository test
|
||||
run continues against the owned migrated PostgreSQL 18.6 Docker database; the
|
||||
earlier host semaphore and unmigrated-database setup failures remain retained.
|
||||
|
||||
The third local message-marker attempt passed all six independent matchers,
|
||||
accepted finish, durable Done, and cleanup on profile7/source
|
||||
`5fe7d496e1603626ceb5838f37c4fecce23580ee`. Campaign
|
||||
`local-2026-10-28T14-24-09-928Z`, run
|
||||
`68705f91-d195-4532-9fd4-e8b1826d6ed9`, completed in 45.1 seconds including
|
||||
cleanup and evidence collection. Its rendered final reply is the exact marker.
|
||||
The profile7 installed CLI/server tarballs also passed fresh public setup,
|
||||
offline reuse, exact native create/model/load/instruction receipts and EOF.
|
||||
|
||||
Lifecycle authoring then found that native Kimi approval omitted its tool kind.
|
||||
Patch v6 carries the native tool's existing canonical kind. The actual native
|
||||
callback now passes shared normalization: exact Write target with Allow once,
|
||||
Deny and Cancel; missing/foreign workspace contexts offer only Deny/Cancel and
|
||||
reject acceptance. The complete nine-request controlled native peer passed.
|
||||
The engine metadata correction and v6 change advance the installed closures.
|
||||
Profile7 evidence retains its original identity; the affected permission and
|
||||
installed-runtime paths require profile8 evidence before qualification.
|
||||
|
||||
CI found stale dependent provider attestations and fixture expectations. Pi31
|
||||
attests the changed shared environment/sandbox sources without changing its native
|
||||
closure. Copilot73 attests historical-profile decoding changes; Grok11 remains
|
||||
current. Old fixtures stay immutable. Ninety-five Pi/Copilot installation tests
|
||||
passed with one existing opt-in skip, plus 18 image contract tests and four Grok
|
||||
identity-generator tests. Kimi lifecycle authoring passed 44 deterministic cases;
|
||||
the exact Linux pidfd selector passed 14 real cases on native ARM64 Linux and 110
|
||||
TypeScript observer/contract tests with one existing skip. Linux x64 emulation
|
||||
returned ENOSYS for pidfd and is retained as an unsuccessful infrastructure
|
||||
attempt; only actual Daytona Linux x64 can qualify that target.
|
||||
|
||||
The rebased profile7 full typecheck/build passed and profile8 full build passed
|
||||
before the final dependent-provider identity refresh. The local full-test run
|
||||
was interrupted after unrelated shared-database cleanup failures and stale
|
||||
migration module errors. A fresh current-build isolated PostgreSQL migration
|
||||
probe passed. A replacement full run and final checks remain required. Later
|
||||
focused HTTP tests hit import/hook timeouts while host load was 63/120 with 92%
|
||||
CPU use; their original failures remain retained, without committed deadline
|
||||
increases. CI for the corrected source will provide an independent Linux check.
|
||||
|
||||
Review branches are published as draft PRs [Runner foundation #18028](https://github.com/paperclipai/paperclip/pull/18028)
|
||||
and [product setup #18029](https://github.com/paperclipai/paperclip/pull/18029).
|
||||
The first foundation review was 5/5 with no comments on its exact prior head;
|
||||
the corrected final head requires a fresh review. Neither PR is ready for merge
|
||||
until required product acceptance and checks pass.
|
||||
|
||||
### 2026-10-30: native behavior and cleanup recovery accepted
|
||||
|
||||
The profile 16 candidate (`e027eb4e…`, SDK 2.3.3, native artifact source
|
||||
`603756c22b2e39de9b5b5c3c26a0eb23c67604a1`) has evidence for ten distinct
|
||||
local behaviors and ten distinct Daytona behaviors. These are functional
|
||||
acceptances, not twenty originally green attempts: local Ask retains its first
|
||||
failure; the Daytona permission case retains an accounting-observer failure;
|
||||
the final provider-loss case retains its original cleanup-failure grade.
|
||||
Fixture/Core sources for the remote evidence are `8bc5ce3b…` and `e7f65045…`;
|
||||
the fixture-only rebuild changed only build-info among 10,993 compiled files.
|
||||
|
||||
The final Daytona case delivered loss to the exact owned native child while
|
||||
Write approval was pending. Its six matchers passed, including no file effect,
|
||||
expiry of the original callback, rejection of stale Allow, one failed native
|
||||
run, blocked task state, and complete native process retirement. Its workspace
|
||||
recovery completed and confirmed stop, then deliberately queued sandbox
|
||||
destruction for five minutes later. The test's 120-second environment-delete
|
||||
wait ended before that ordinary cleanup deadline.
|
||||
|
||||
A separate recovery restored the same captured control-plane state to an
|
||||
isolated working copy and used the normal server, company-secret rotation,
|
||||
cleanup scheduler, and environment DELETE route. Cleanup became successful,
|
||||
DELETE returned its documented HTTP 200, and the environment was absent from
|
||||
the database. No model turn, new run, new lease, or direct domain SQL update was
|
||||
used for recovery. Both the original and recovery processes, ports, provider
|
||||
resources, and temporary keys were closed. Root's combined acceptance record
|
||||
is SHA256 `25d0eae952eab91c6d54abb6850d4abf1b69f1045e7d7bb05333f8b87c184c0a`;
|
||||
it preserves the original failed grades and distinguishes native proof from
|
||||
later normal cleanup recovery.
|
||||
|
||||
The readiness correction waits for the same agent before changing permission
|
||||
policy, avoiding invalidation of an in-flight verification. Setup and native
|
||||
work now have separate finite windows, and remote deadline diagnostics identify
|
||||
the operation without retaining SDK text. Canonical focused verification passed
|
||||
223 tests with one existing Linux-only skip on Mac, plus the full fixture
|
||||
TypeScript check. Native runtime bytes and model/callback limits were unchanged.
|
||||
Profile 17 public-package installation and the actual hosted walkthrough on
|
||||
`kimi.test.paperclip.app` remain separate delivery gates; this section does not
|
||||
claim either has passed.
|
||||
|
||||
The cleanup fixture now uses one absolute Kimi deadline at the original test
|
||||
start plus 660 seconds, within the unchanged 690-second total limit. It leaves
|
||||
30 seconds for final evidence. Each DELETE request and retry sleep uses only
|
||||
the remaining time. Other flows retain their 120-second default. The actual
|
||||
canonical fixture tests passed 73 assertions across three files, including
|
||||
ordinary deferred cleanup beyond 120 seconds, deadline exhaustion, request
|
||||
timeout forwarding, and HTTP success versus pending cleanup. This source
|
||||
correction does not regrade the preserved failed live attempt.
|
||||
@@ -0,0 +1,583 @@
|
||||
# Native-controller recovery during webserver upgrades
|
||||
|
||||
Date: 2026-10-30. Status: implementation delivered; final qualification is tracked
|
||||
in [PR #18343](https://github.com/paperclipai/paperclip/pull/18343).
|
||||
Source inspected: `0c70cedce9a803bdc201324528abb35e423e7cd0` in the current task worktree.
|
||||
|
||||
## Goal and finish line
|
||||
|
||||
Make native controller state independent of the main Paperclip webserver's disk,
|
||||
as one prerequisite for zero-downtime webserver deployments. A person starts a
|
||||
task, the webserver is replaced, and the same remote execution continues. After
|
||||
completion, another replacement can continue the retained session without the
|
||||
previous webserver's files. Recovery must preserve Stop, current authorization,
|
||||
accepted results, pending tool decisions, and single-owner execution.
|
||||
|
||||
The implementation endpoint is a tested, reviewable PR for the native-controller
|
||||
portion only. General HTTP readiness, traffic switching, request draining,
|
||||
browser delivery, storage migration and Railway rollout belong to other chats.
|
||||
Native shutdown integrates with the existing server lifecycle coordinator.
|
||||
|
||||
Initial supported environment: native remote Runner execution, external Postgres,
|
||||
a stable externally supplied instance encryption key, surviving remote sandbox
|
||||
storage, and mutually compatible server/Runner releases. Qualify the existing
|
||||
Daytona path, including its prepared-task transport. Preserve local/self-hosted
|
||||
behavior. No new database, queue service, or shared filesystem is needed.
|
||||
|
||||
Keep the existing outage contract: a 300-second controller outage, with recovery
|
||||
completed within the Runner's existing 420-second reconnect grace. The remote
|
||||
controller lease is currently 60 seconds. The 300-second fault-tolerance test is
|
||||
not the permitted downtime of the webserver deployment. Do not shorten it or renew the Runner's
|
||||
disconnected lifetime on each retry to make a test pass. This contract permits
|
||||
server-dependent tools to wait; it does not promise uninterrupted HTTP service.
|
||||
|
||||
## What already exists
|
||||
|
||||
The earlier [Explore zero-downtime Railway deploy](codex://threads/01a1836e-8c8e-7b83-b09c-5db49a5e89de)
|
||||
plan included remotely persisted native controller checkpoints. Much of that work
|
||||
has since landed. Build on it instead of introducing another checkpoint store.
|
||||
|
||||
| Existing capability | Owner / evidence inspected |
|
||||
| --- | --- |
|
||||
| Encrypted, company/task/run-bound PRP checkpoints in Postgres | [`native-runner-checkpoint.ts`](../../server/src/services/native-runtime/native-runner-checkpoint.ts), [`native_runner_checkpoints`](../../packages/db/src/schema/native_runner_checkpoints.ts); landed in `09ad9adce2` / PR #17439 |
|
||||
| Opaque checkpoint codec, restore, authenticated replay cursor and persistence-before-effect barriers | [`durable-prp-control-plane.ts`](../../packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts) |
|
||||
| Expiring ownership, controller generations, stale-writer fencing and isolated renewal/checkpoint pools | [`native-restart-recovery.ts`](../../server/src/services/native-runtime/native-restart-recovery.ts), [`native-execution-lease.ts`](../../server/src/services/native-runtime/native-execution-lease.ts) |
|
||||
| Active remote reattachment from DB state and the admitted Runner artifact | [`native-session-executor.ts`](../../server/src/services/native-runtime/native-session-executor.ts), `executePaperclipNativeSessionWithinScope` |
|
||||
| Bounded database discovery of prior task/session checkpoints | [`native-session-resume.ts`](../../server/src/services/native-runtime/native-session-resume.ts) |
|
||||
| Empty-controller-directory, receipt-recovery, concurrency, retention and SQL-cost tests | [`native-runner-restart-recovery.integration.test.ts`](../../server/src/services/native-runtime/native-runner-restart-recovery.integration.test.ts), [continuity runbook](../testing-native-remote-continuity.md) |
|
||||
| Read-only active-run upgrade eligibility | [`heartbeat/upgrade-readiness.ts`](../../server/src/services/heartbeat/upgrade-readiness.ts); landed in `567c043b17` / PR #18037 |
|
||||
|
||||
The related [Enable uninterrupted agent upgrades](codex://threads/01a15e84-545f-7023-a854-9a642cbc76e1)
|
||||
chat reports actual PAP staging replacements preserving active executions. It is
|
||||
also working on broader default enablement. Those are reported results from that
|
||||
chat, not a new qualification of this checkout or proof that the entire webserver
|
||||
filesystem can be discarded. Reconcile that work before implementing overlapping
|
||||
changes; do not duplicate its rollout or configuration work.
|
||||
|
||||
## Remaining dependencies and evidence gaps
|
||||
|
||||
1. **Ordinary continuation still discovers PRP state through local identity.**
|
||||
`restoreWarmRunnerdController` and `restorePerTurnRunnerdController` first read
|
||||
the local full state or external marker. They return when neither supplies a
|
||||
matching identity. Active restart recovery instead starts from a DB run
|
||||
binding. Extend that approach to ordinary continuation without skipping the
|
||||
existing prior-owner, session, configuration, and suspended-state checks.
|
||||
2. **Warm supervisor state has its own local persistence path.**
|
||||
`persistWarmNativeCheckpoint` writes `paperclip.native-session-supervisor.v1`
|
||||
with the snapshot, configuration digest, and process-configuration digest;
|
||||
`loadWarmNativeCheckpoint` and warm process reuse read that file. The separate
|
||||
Postgres PRP restore writes only `control-plane-state.json`. Existing saved
|
||||
`runnerProfileJson.sessionCheckpoint` and admitted execution input provide
|
||||
reusable data, but the complete warm reuse decision must become reconstructible.
|
||||
3. **Current focused tests do not establish the whole application contract.**
|
||||
The empty-directory transport fixture restores explicitly and sets the old
|
||||
lease expired. It is valuable protocol evidence, but does not prove normal
|
||||
admission with no markers, natural ownership expiry, HTTP routing during
|
||||
overlap, or a second fresh-server continuation. Existing supervisor/Product
|
||||
E2E coverage may prove some of these; reconcile it before adding new cells.
|
||||
4. **Upgrade eligibility is narrower than disk independence.**
|
||||
The current report checks active runs and bounded PRP headers. It does not
|
||||
certify completed retained sessions or dereference workspace recovery files.
|
||||
An empty active-run list must not imply that all controller history is safe
|
||||
to discard.
|
||||
|
||||
These are inspected dependencies and missing proof, not newly reproduced
|
||||
production failures. Begin with characterization tests to determine which paths
|
||||
already reconstruct sufficient state through their callers.
|
||||
|
||||
## Design
|
||||
|
||||
### One durable source for each kind of state
|
||||
|
||||
| State | Durable owner | Local treatment |
|
||||
| --- | --- | --- |
|
||||
| PRP authentication, command receipts, control events and replay state | Existing encrypted `native_runner_checkpoints` | Reconstructed cache/marker |
|
||||
| Run authority and finalization | Existing `native_run_finalizations`, run and issue locks | No local substitute |
|
||||
| Provider/session snapshot and native reuse compatibility | Existing run profile plus a versioned native recovery envelope where fields are missing | Reconstructed supervisor projection |
|
||||
| Session lookup | Existing task-session pointer and bounded prior-run selection | Never depends on directory enumeration or a marker |
|
||||
| Provider process, provider history and unacknowledged outbox | Surviving remote Runner/sandbox | Never copied from an older controller cache over live state |
|
||||
| Workspace descriptors, manifests, archives and provider backup blobs | Their existing workspace/backup owners, with remote durability supplied by that workstream | Logical references; independent eligibility requirement |
|
||||
|
||||
Keep Runner's codec and opaque record contract in the Runner package. Native
|
||||
runtime owns session selection, compatibility, and the server-side recovery
|
||||
envelope. The database adapter owns encryption and transactional fencing.
|
||||
Heartbeat only coordinates these contracts. Extract the affected persistence and
|
||||
lookup responsibility from the large executor into a cohesive native-runtime
|
||||
module rather than expanding the executor or creating a generic recovery system.
|
||||
|
||||
### Reconstruct from durable identity
|
||||
|
||||
Use the exact active run for restart adoption. For a subsequent turn, use the
|
||||
existing task/session pointer and `native-session-resume.ts` selection policy to
|
||||
locate the eligible prior run. Validate company, task, agent, normalized session,
|
||||
workspace scope, admitted execution, provider configuration and current authority.
|
||||
Retain the existing progress barriers: a newer incompatible or incomplete record
|
||||
must not make recovery select an older convenient checkpoint.
|
||||
|
||||
Load and validate the PRP checkpoint and native snapshot before materializing
|
||||
local projections. The destination is computed from the replacement's local
|
||||
root; absolute paths recorded by the old controller never identify durable data.
|
||||
An existing local projection must agree with the selected committed state or be
|
||||
replaced under the same authority. Missing, corrupt, undecodable or mismatched
|
||||
state remains a specific recovery hold, without a fresh provider turn.
|
||||
|
||||
Default persistence design: reuse `runnerProfileJson.sessionCheckpoint` and add
|
||||
only a private, versioned native recovery envelope for missing compatibility and
|
||||
checkpoint-binding metadata. Persist the snapshot and its metadata together
|
||||
under the current run's fenced ownership. Do not duplicate the PRP journal or
|
||||
store server-specific fields as arbitrary Runner checkpoint parts. If existing
|
||||
profile metadata already supplies a field, reference it instead of copying it.
|
||||
Use a schema migration only if characterization demonstrates that the existing
|
||||
run/session records cannot express a required durable lookup or concurrency check.
|
||||
|
||||
The private `nativeControllerRecovery` envelope is limited to 4 KiB serialized.
|
||||
It contains a format version, digest of the exact redacted stored snapshot,
|
||||
native configuration digest, process-configuration digest or explicit absence,
|
||||
and PRP attachment/checkpoint binding. Run/company/task/agent/session identity is
|
||||
validated against existing records and the snapshot, not duplicated execution
|
||||
inputs. Keep credentials in the
|
||||
existing encrypted authority; persist logical resource identities, not secret
|
||||
values or host paths. Any newly sensitive recovery material uses the existing
|
||||
encryption provider and remains excluded from public run projections.
|
||||
|
||||
`PaperclipControlPlanePort.checkpointSession` already persists the snapshot before
|
||||
the local supervisor callback. Add the envelope to that same transaction and
|
||||
update; do not introduce a second snapshot writer. Compute stable compatibility
|
||||
inputs once per admission/attachment, preserve unrelated profile fields, and
|
||||
recheck durable ownership after lock waits with the established lock ordering.
|
||||
Keep legitimate post-result suspension/cleanup checkpoints permitted. The generic
|
||||
Runner persistence port must not learn Paperclip database fields. Remote local
|
||||
files become projections; local-only consumers retain authoritative files.
|
||||
|
||||
The recovery owner resolves, validates, materializes and attaches through existing
|
||||
owners. Outcomes distinguish recoverable, waiting for an owner with its actual
|
||||
deadline, missing/inconsistent evidence, unsupported format/capability, dependency
|
||||
unavailable and proven no-prior-execution. Missing evidence is not fresh bootstrap.
|
||||
Keep the 32-candidate selection window and newer-progress barriers; fetch the full
|
||||
payload only for the selected source. PRP attachment identity and current business
|
||||
run write authority differ during warm handoff and must remain explicit.
|
||||
|
||||
Publish a resumable snapshot only after its referenced PRP boundary is durable.
|
||||
Across separate commits, interruption may leave a pending obligation, never a
|
||||
false ready state. Recovery reconciles that exact obligation without rerunning
|
||||
the provider. Completion acceptance, workspace settlement, session reuse, and
|
||||
cleanup remain separate facts.
|
||||
|
||||
### Preserve ownership during replacement
|
||||
|
||||
The new server may load recovery data while the old server serves, but it cannot
|
||||
send commands, acknowledge events, or settle results until it owns the execution.
|
||||
Reuse the existing boot identity, generation and lease-owner fences. Cross-host
|
||||
takeover uses DB lease authority, never a local check of another container's PID.
|
||||
|
||||
Graceful shutdown fences new local controller work, completes outstanding durable
|
||||
barriers, detaches supported sessions, and releases only its own authority. A
|
||||
crash uses natural expiry. The successor takes the existing fenced claim,
|
||||
restores state, revalidates permissions and attaches to the exact remote process.
|
||||
Lost authority detaches the stale controller without killing the surviving Runner.
|
||||
|
||||
Retain wake/recovery obligations while the previous lease is live. Reconcile at
|
||||
startup and the actual lease/retry deadline through the shared scheduler. Cover
|
||||
a lost local release notification with durable state and the deadline. Add no
|
||||
permanent checkpoint scans or independent polling timers. A future shared
|
||||
cross-instance notification mechanism can accelerate this path without becoming
|
||||
its source of truth.
|
||||
|
||||
Exercise Stop and tool decisions arriving at either instance. Use existing durable
|
||||
intents and receipts; fix a demonstrated missing native delivery path in its
|
||||
owner. Never retransmit an uncertain external mutation just because its HTTP
|
||||
response was lost. Current access, pause, cancellation and budget controls still
|
||||
apply; restoring an admitted run is not a new grant of authority.
|
||||
|
||||
### Compatibility, adoption and retention
|
||||
|
||||
Use additive readers/writers. Before an ephemeral deployment is eligible, its
|
||||
rollback target must understand the recovery envelope, sparse checkpoint policy
|
||||
and authenticated cursor. A v2 decoder alone is insufficient. Preserve the
|
||||
original Runner artifact identity; an old running binary does not gain new
|
||||
capabilities merely because the webserver upgraded.
|
||||
|
||||
For existing file-backed sessions, allow the current legitimate owner to publish
|
||||
a validated durable snapshot at a normal checkpoint boundary. A bounded explicit
|
||||
backfill may import retained supervisor state while the disk is still available,
|
||||
after rechecking the same bindings and ownership. Never fabricate missing
|
||||
credentials, checkpoints or remote process evidence. Unprovable sessions remain
|
||||
ineligible until normal completion/retirement or explicit recovery.
|
||||
|
||||
Keep local mode intact. Require the same externally supplied encryption key on
|
||||
replacement; a recovery read must not generate a new key. Broad external-mode
|
||||
startup validation belongs to the parent persistence workstream.
|
||||
|
||||
Extend existing retention reference checks for any new envelope references.
|
||||
Retain active owners, unresolved handoffs/finalization and selected continuation
|
||||
sources. Cleanup must not race a new continuation claim or allow a late writer to
|
||||
resurrect deleted authority. Preserve bounded startup cleanup and zero idle SQL.
|
||||
|
||||
Expose an additive, versioned **native controller durability** diagnostic through
|
||||
the existing internal upgrade report, with explicit unknown/ineligible reasons.
|
||||
It must distinguish active adoption from retained-session continuation, and it
|
||||
must never claim whole-instance volume removal readiness. Keep the existing
|
||||
`ready` meaning compatible; the broader deploy coordinator composes this evidence
|
||||
with workspace and other persistence checks. Derive eligibility from committed
|
||||
records and supported readers, not a persisted boolean that can become stale.
|
||||
Report active adoption, selected retained continuation, outstanding persistence or
|
||||
handoff obligations and unsupported evidence separately. Include observation time,
|
||||
bounded reason counts and coverage/truncation. Use bounded batched metadata/header
|
||||
reads and pagination, and report incomplete inventory as unknown coverage. Add no
|
||||
public endpoint. Establish the first envelope-capable release as the rollback
|
||||
floor; an older local-only reader is not a valid empty-disk rollback target.
|
||||
|
||||
## PostgreSQL load contract
|
||||
|
||||
- Text streaming adds zero recovery SQL, transactions or checkpoint rows per chunk.
|
||||
- Ordinary usage remains coalesced into control checkpoints. Preserve changed-part
|
||||
PRP writes and exact removals; do not add a journal, transcript or archive copy.
|
||||
- Snapshot metadata rides the existing snapshot transaction, including unchanged
|
||||
metadata; no parallel writer, per-agent connection or new pool.
|
||||
- Idle servers add no recurring SQL/network work once obligations settle.
|
||||
- Scoped candidate selection fetches only the selected payload. Diagnostics use
|
||||
bounded headers/metadata, never an automatic scan of historical payloads.
|
||||
|
||||
Keep the existing dedicated checkpoint pool initially with its two-connection
|
||||
default, lazy creation, idle release and shutdown fencing. Two is not an
|
||||
architectural invariant. The invariant is bounded checkpoint traffic that meets
|
||||
recovery deadlines without starving requests or renewals. Include both servers'
|
||||
pools during overlap. Measure queue wait separately from SQL and lock wait. If
|
||||
capacity fails acceptance, compare bounded sizes under the same workload and
|
||||
choose the smallest passing size in a separately reviewable tuning change. Do not
|
||||
increase the cap to conceal slow queries or lock contention.
|
||||
|
||||
Run paired baseline/candidate workloads with identical fixtures and counts and
|
||||
all database clients included. Record statement/transaction classes, changed rows,
|
||||
persisted bytes, WAL, CPU, memory, connections, request latency, checkpoint queue/
|
||||
lock/execution latency, renewal latency/deadlines, throughput, retries and failures.
|
||||
Use the existing 8/512-chunk test plus a stream beyond the retained-event window;
|
||||
require exactly zero extra recovery SQL during text-only intervals. Repeat the
|
||||
documented 300-controller/10,000-historical-task steady and aligned-burst workload,
|
||||
alternating order and including actual session snapshot persistence.
|
||||
|
||||
Gates: unchanged lifecycle sequence has no additional normal checkpoint
|
||||
transactions; no new idle work, missed lease deadlines, lost owners, dropped
|
||||
operations or premature ACKs; existing absolute latency/throughput gates pass.
|
||||
Investigate any repeatable increase above 10% in steady CPU per completed operation,
|
||||
WAL per completed operation or request p99 before handoff; report absolute values
|
||||
too. Measure replacement bursts separately within the 420-second grace. Never
|
||||
relax persistence-before-effect guarantees to meet a performance target.
|
||||
|
||||
## Implementation slices
|
||||
|
||||
| Slice | Owned surfaces and useful result | Dependency / review evidence |
|
||||
| --- | --- | --- |
|
||||
| 1. Complete native recovery state | Native checkpoint adapter, executor persistence/lookup, session-resume owner and focused tests. Normal active, warm and per-turn paths reconstruct with no local marker or supervisor file. | Reuse landed checkpoint work; first reproduce the exact missing path. Test current local mode and incompatible-session barriers. Add only demonstrated missing envelope fields. |
|
||||
| 2. Replacement ownership and compatibility | Native lifecycle, shared recovery scheduling, shutdown wiring, retention and internal upgrade diagnostics. Competing servers hand off safely, and historical sessions have an honest eligibility path. | Slice 1; real Postgres ownership races, natural expiry, stale callbacks, Stop, migration/read compatibility and rollback tests. Keep heartbeat wiring small. |
|
||||
| 3. Application and hosted qualification | Existing restart supervisor/Product E2E suites, continuity docs and acceptance evidence. Two replacements preserve execution and later continuation. | Slices 1–2; workspace durability dependency for full-home cases. Real immutable A/B releases and compatible rollback; no synthetic claims of Daytona qualification. |
|
||||
|
||||
Tests and documentation travel with the behavior they verify. These are review
|
||||
boundaries, not a fixed PR count. Combine tightly coupled implementation where
|
||||
splitting would create an unusable intermediate state. Rebase onto the current
|
||||
default branch and reconcile the active upgrades chat before writing source.
|
||||
|
||||
## Acceptance matrix
|
||||
|
||||
| Scenario | Required result and independently inspectable evidence |
|
||||
| --- | --- |
|
||||
| Fresh active remote run; graceful replacement | Same run, Runner process/start identity, provider session and active turn; one provider start, one final result |
|
||||
| SIGKILL and absent controller storage | Normal startup claims after natural expiry; no manually edited lease, injected recovery marker or manual restore call |
|
||||
| Output-only gap and pending tool/decision | Authenticated cursor recovers; pending input settles once; independent business receipt/effect count confirms no duplicate mutation |
|
||||
| Completion while disconnected or during shutdown | Accepted result, accounting and finalization survive; incomplete persistence resumes without another provider turn |
|
||||
| Completed warm and per-turn sessions | B can discover the correct source from DB; a later C continues it after B's controller storage is removed; no new provider session solely because a marker was missing |
|
||||
| Warm config changes | Verified close/suspend and permitted saved-history resume; never incorrectly reuse changed credentials, tool contract or workspace |
|
||||
| Old and new server overlap | B respects A's live lease; only one successor wins; delayed A cannot checkpoint, ACK, dispatch or finalize after losing ownership |
|
||||
| Stop, revoked access, pause and cross-company attempts | Saved intent and current authorization prevail; no recovery bypass or delayed mutation |
|
||||
| Corrupt/missing checkpoint, key or unsupported version | Precise ineligibility/hold; no silent fallback, replay, key generation or destruction of the last recoverable copy |
|
||||
| Existing records and rollback | Valid local state can be imported under authority; unsupported state stays explicit; compatible prior release reads newly written records |
|
||||
| Cleanup and idle | Selected checkpoints survive cleanup races; settled/idle states issue no new recurring SQL or network work; next legitimate producer reactivates recovery |
|
||||
| Full application with empty homes | A → B → C uses the normal API/admission path and independently verifies the saved result; blocked until required workspace/backup dependencies are remotely durable |
|
||||
|
||||
## Feedback path and verification
|
||||
|
||||
First experiment: extend the existing real-Postgres remote continuation fixture
|
||||
with a completed warm turn, remove both PRP marker and supervisor directories,
|
||||
then submit the next turn through normal admission. Keep the remote Runner alive.
|
||||
Assert the prior run/session chosen, process identity, one new turn and a useful
|
||||
result. Also run the per-turn control. Do not prepopulate the desired recovery
|
||||
state. This distinguishes a lookup/envelope gap from missing workspace material.
|
||||
|
||||
Use the existing local Linux/container harness and scripted provider initially;
|
||||
it avoids paid model calls while preserving database locks and process lifetime.
|
||||
Measure setup separately from edit → run → inspected evidence. Start with a short
|
||||
outage; run the real 300-second outage after the narrow behavior passes. Record
|
||||
elapsed time and the exact revision in this plan when implementation begins.
|
||||
|
||||
Then exercise separate compatible application revisions with distinct native roots
|
||||
and shared Postgres, through normal startup/admission. Remove access to A's native
|
||||
controller storage. Identify surviving non-native dependencies explicitly: this
|
||||
is not evidence of whole-webserver filesystem loss. Test both SIGTERM and SIGKILL, B starting
|
||||
while A still owns the run, decisions/Stop during handoff, and a second B → C
|
||||
replacement. Retain an independent effects ledger and process identity evidence.
|
||||
If this reaches a workspace/backup dependency, keep that failure as explicit
|
||||
parent-workstream evidence; do not hide it with a copied controller directory.
|
||||
|
||||
Qualify one disposable Daytona environment using the existing Product E2E path,
|
||||
including completion and follow-up, plus a compatible rollback. Record server
|
||||
revisions, Runner artifacts, run/session/sandbox identities, lease timestamps,
|
||||
receipt counts, recovery latency and cleanup. Reuse an existing healthy campaign
|
||||
only after checking its owner and exact revision. No fleet rollout is part of
|
||||
this checkpoint feature.
|
||||
|
||||
Required handoff checks for implementation: focused affected suites, then
|
||||
`pnpm -r typecheck`, `pnpm test:run`, and `pnpm build`. If schema changes prove
|
||||
necessary, use `pnpm db:generate` and the migration/snapshot checks. Update
|
||||
`doc/execution-semantics.md`, `doc/testing-native-remote-continuity.md`, relevant
|
||||
developer/database documentation and affected capability maps. Run
|
||||
`node scripts/check-maps.mjs` after map edits. PRs use the complete repo template.
|
||||
Report skipped or unavailable checks without promoting older evidence to a pass.
|
||||
|
||||
## Parent-workstream dependencies and non-goals
|
||||
|
||||
Native checkpoint success alone does not permit removing the webserver volume.
|
||||
Workspace sync still resolves local descriptors/manifests in
|
||||
[`native-workspace-sync.ts`](../../server/src/services/native-runtime/native-workspace-sync.ts).
|
||||
That owner must provide durable logical recovery references and saved generations;
|
||||
this plan consumes that contract rather than moving workspace archives into PRP.
|
||||
Provider backup blobs and prepared resources needed after a process has suspended
|
||||
must likewise resolve without the old server disk.
|
||||
|
||||
Agent files/instructions, logs and accounting journals, imports, mutable packages,
|
||||
global background-work claims, browser live-event delivery, HTTP readiness/drain,
|
||||
and Cloud/Railway volume migration retain their own slices in the broader plan.
|
||||
Do not turn native checkpoints into a second transcript: the existing sparse
|
||||
checkpoint excludes display-only output, and this work does not strengthen the
|
||||
run log's durability guarantee. Destroyed sandbox recovery, legacy local stdio
|
||||
continuity and permanent horizontal scaling are outside this release.
|
||||
|
||||
Railway still prevents overlapping deployments on a service with an attached
|
||||
volume. Its separate overlap/drain controls become useful after all required
|
||||
volume dependencies and concurrent-server behavior are qualified. Sources checked
|
||||
2026-10-30: [volume caveats](https://docs.railway.com/volumes/reference#caveats),
|
||||
[deployment teardown](https://docs.railway.com/deployments/deployment-teardown).
|
||||
|
||||
## Current state and next action
|
||||
|
||||
The observations below retain earlier qualification attempts and their limits.
|
||||
The PR's Verification section is the current acceptance record. The final native
|
||||
runtime candidate is `eaef0d56552c087aaa4fa68a8655282ffbd12667`; documentation-only
|
||||
updates do not change those runtime bytes. Its CI and fresh 5/5 review passed.
|
||||
The corrected source-process test has completed active adoption and a fresh
|
||||
retained follow-up. Distinct compatible rollback, installed-application and live
|
||||
Daytona gates remain under verification as of 2026-10-30 20:28 UTC. Preserve failed
|
||||
attempts when adding later passes. The rollback floor must include the idle-goal
|
||||
reader fix; an envelope-only reader is insufficient.
|
||||
|
||||
Implementation is underway on `codex/native-controller-disk-independent-recovery`.
|
||||
The related Cloud policy work merged as paperclip-cloud PR #1048; it retains
|
||||
compatibility checks and drain fallback and does not implement this component.
|
||||
Verified locally: metadata/source-selection/lock-wait checks (21 tests), readiness
|
||||
and recovery scheduling (55 tests), durable discovery/import/inventory (7 tests),
|
||||
and the complete native executor suite. The combined executor/selection/retention
|
||||
run passed 1,063 tests and exposed two overbroad retention guards; those guards
|
||||
were corrected and all 75 retention cases then passed. The snapshot fence test
|
||||
also checks stale result acceptance and lease expiry during a run-row lock wait.
|
||||
|
||||
Both warm and per-turn fixtures passed three separate controller processes,
|
||||
deleting both native controller directories before B and again before C. They
|
||||
check provider history, Runner identity, invocation counts, and the existing
|
||||
suspension policy. Per-turn uses the production admission selector and snapshot
|
||||
rebinder. These processes use the same candidate source, not distinct release
|
||||
artifacts. Workspace and remote sandbox storage remain available. This is not
|
||||
full-webserver filesystem-loss evidence.
|
||||
|
||||
Both direct and prepared-task transport cases passed the 300-second outage with
|
||||
natural lease expiry. The 8/512/8,192-chunk SQL test passed: each sample used seven
|
||||
control transactions and twelve changed checkpoint records, with zero recovery
|
||||
SQL/transactions/rows during the text interval and zero idle SQL. The long sample
|
||||
included normal lease renewals. Its cluster-wide WAL values include simultaneous
|
||||
test migrations and are diagnostic only, not a load acceptance measurement.
|
||||
|
||||
Workspace typecheck, build, and map structural checks passed on the earlier
|
||||
candidate; the later check dispositions are recorded below. The paired
|
||||
300-controller/10,000-task workload now includes actual snapshot persistence.
|
||||
Its initial host pilot missed throughput gates; the subsequent isolated Linux
|
||||
measurements are recorded below. Database cost acceptance, distinct-release application startup,
|
||||
compatible rollback, and native Daytona qualification remain open. The published Daytona qualification image was independently resolved from successful
|
||||
workflow 40709784515 to `sha256:b38ed051d00fed409092d354eea9ae1b01fb232bcb7d5a187ed763382dda2d0b`
|
||||
(source `8fe5d5defcdfb55a1b9049a801cfa5498629cebc`). The exact Linux Runner was
|
||||
extracted from that image. The local attempt failed during the environment probe before agent creation. A read-only lookup independently returned `DaytonaAuthenticationError: Invalid credentials`. The private database and cleanup obligation remain preserved. The protected single-cell workflow [41093316372](https://github.com/paperclipai/paperclip/actions/runs/41093316372), targeting `512cc6d6dc`, was cancelled during image/artifact builds before provider allocation so acceptance can use the rebased candidate. Its protected secret is separate from the invalid local fixture.
|
||||
No production deployment or complete zero-downtime acceptance is claimed.
|
||||
|
||||
Failures retained in the implementation record: sandbox listener denial and host
|
||||
embedded-Postgres startup limits; a corrected missing `phase` in a new fixture;
|
||||
a corrected unexecuted clock query in result persistence; Linux fixture builds
|
||||
first failed on shared build storage and then missing protocol source files,
|
||||
resolved by a container-local build with the complete inputs. The 8,192-chunk
|
||||
case exposed a test-provider cap of 4,096 (expanded in the test binary), then
|
||||
exceeded the old short-stream wait limit. Its longer measurement now uses the
|
||||
normal 60-second lease and renewal rather than an artificially long owner lease.
|
||||
The long-stream renewal fixture initially omitted the controller PID/start identity;
|
||||
renewal correctly rejected it. Complete fixture identity fixed the test. Per-turn
|
||||
characterization then exposed the missing remote suspended-lifecycle projection;
|
||||
that is now verified against the bound surviving sandbox. The fixture also now
|
||||
uses the existing admission snapshot rebinder, which its warm-only predecessor
|
||||
omitted. The first benchmark attempts lacked ES-module setup and the observer's
|
||||
pg_stat_statements extension; the isolated Linux pilot needed its native Sharp
|
||||
dependency. No database-load acceptance claim has been made from failed attempts.
|
||||
There is no Paperclip issue bound to this local chat; this versioned source plan
|
||||
is the handoff, rather than an upload to an unrelated company or issue.
|
||||
|
||||
The remaining native qualification and compatibility cells require reproducible
|
||||
measurements and a reviewed PR. Full-home application
|
||||
acceptance remains dependent on workspace/backup durability; no controller-only
|
||||
test can waive that gate.
|
||||
|
||||
Qualification update: the legacy and changed-process-configuration warm cases
|
||||
exposed a marker left by suspension before authority rotation. Restoring the
|
||||
committed PRP checkpoint before constructing the replacement backend fixes both;
|
||||
both real-process cases pass. The initial rerun used the wrong fixture executable
|
||||
path and failed before provider startup; its log is retained. All 59 control-plane
|
||||
port tests pass after narrowing the snapshot read to the binding and profile
|
||||
columns it actually needs. The initial paired 300-controller ABBA comparison
|
||||
completed every offered operation with no ownership losses, but candidate latency
|
||||
and CPU increased above the investigation threshold. It is not performance
|
||||
acceptance. A new paired run will measure the narrower snapshot projection.
|
||||
|
||||
|
||||
Qualification update at `512cc6d6dc`: the snapshot port now combines coordinator
|
||||
and run locks into one statement, with a database-clock check after both waits.
|
||||
All 59 real-Postgres port tests pass. Snapshot and PRP transaction counts remain
|
||||
unchanged; the first completed 300-controller baseline/candidate pair each issued
|
||||
74,790 total statements and 7,800 control transactions across all pools. The
|
||||
candidate adds the metadata bytes but no SQL per streamed chunk.
|
||||
|
||||
The dedicated Daytona suite now fixes the active outage at 300 seconds and adds
|
||||
normal UI follow-up after a second replacement following completion. Its oracle
|
||||
requires a new successful native run, retained native/provider session identity,
|
||||
a completed tool receipt and correct saved-project output. Focused E2E support
|
||||
checks passed (56 tests, one optional external-database case skipped), plus all
|
||||
137 Node support tests and E2E typecheck. The explicit external-database fixture
|
||||
was separately exercised against owned Postgres earlier. The broad E2E unit
|
||||
attempt lacked local listener permissions and was stopped; that failure remains
|
||||
in `/tmp/native-controller-e2e-unit.log`, and broad qualification is still open.
|
||||
|
||||
Database load remains **unqualified**. The narrowed-read and combined-query ABBA
|
||||
runs became heavily contended as other local checks ran, including our E2E support
|
||||
suite during the final pair. Both baseline and candidate lost absolute gates;
|
||||
dropped operations invalidate relative comparisons. The combined-query first
|
||||
pair completed all work, but its candidate p99 was 303 ms versus 131 ms and PG CPU
|
||||
35.69 s versus 25.89 s. Unchanged business queries also slowed. These observations
|
||||
require a quiet-host paired rerun; they are neither a performance pass nor proof
|
||||
that the implementation caused all observed regression. The connection limit
|
||||
remains unchanged. Summarized counters are retained in the adjacent
|
||||
`2026-10-30-native-controller-load-evidence.json`; raw measurements remain under
|
||||
`/tmp/native-controller-load/`. The reproducible script records failed absolute
|
||||
and relative gates and marks unequal-operation comparisons ineligible.
|
||||
|
||||
Remaining critical path: finish the protected Daytona attempt and cleanup;
|
||||
complete full repository checks on the final rebased revision; finish the quiet
|
||||
steady/burst/replacement load gates and explicit lock-wait latency measurement;
|
||||
qualify distinct installed A/B/A artifacts; then resolve review on the draft
|
||||
PR. Current server startup smoke and three-controller component tests are useful
|
||||
layer-specific evidence, not substitutes for those remaining gates.
|
||||
|
||||
|
||||
The full local `pnpm test:run` attempt was stopped after roughly 45 minutes while
|
||||
still progressing through the general chat suite. Its output is retained at
|
||||
`/tmp/native-controller-full-tests.log`; it is incomplete, not a pass. Broad
|
||||
checks will run on hosted Linux for the rebased candidate. The broad E2E support
|
||||
rerun with two workers was also stopped before rebasing; focused support checks
|
||||
remain the completed evidence. A separate `server-config.test.ts` diagnostic
|
||||
confirmed the original restricted run's `listen EPERM` cause.
|
||||
|
||||
|
||||
### Rebased review and qualification follow-up
|
||||
|
||||
Draft implementation: https://github.com/paperclipai/paperclip/pull/18343.
|
||||
At rebased head `3aa90fbf0b5553ffa66a9cdc0fb153f348b56d75`, all 66 focused
|
||||
persistence/discovery cases and full `pnpm -r typecheck` passed. The PR's general
|
||||
server shard found 190 executor unit failures where synthetic IDs and a partial
|
||||
Db double reached the new real discovery boundary. That fixture boundary is
|
||||
being corrected; no production validation is relaxed. Greptile scored 3/5 and
|
||||
identified missing prepared-task lifecycle reconstruction and retry after a
|
||||
failed lifecycle read. Corrections and regression cases are under verification.
|
||||
A separately identified retained-owner admission wait now uses the existing
|
||||
same-run retry record/deadline, with unstarted-run and current-authority fences.
|
||||
|
||||
Protected Daytona workflow `41094539946` failed before any test cell ran: the
|
||||
image publisher exhausted disk (`No space left on device`, exit 102), including
|
||||
its cleanup/log writes. It is not live recovery evidence, and repeating the
|
||||
same build without a capacity fix is not justified. The default-branch workflow
|
||||
has no per-run disk-capacity or upgrade-manifest override. CI infrastructure owns
|
||||
that dependency. The invalid local credential attempt remains unresolved evidence.
|
||||
|
||||
Local review-check attempts also encountered exhausted macOS Postgres semaphores;
|
||||
only these owned test processes were stopped and moved to dedicated Docker
|
||||
Postgres fixtures. One additional direct-read retry scenario timed out during
|
||||
its source-controller fixture startup before a Runner appeared. Fixture import
|
||||
readiness is now measured separately from the unchanged 60-second admission
|
||||
limit. These failed/incomplete attempts remain distinct from subsequent passes.
|
||||
|
||||
Review-fix verification: all 1,066 cases across native executor, durable
|
||||
discovery, startup recovery and heartbeat runtime execution passed against owned
|
||||
Docker Postgres. After the final database-clock and operator-Stop fences, all
|
||||
nine durable discovery/import/wait tests passed and server typecheck passed.
|
||||
The direct per-turn failed-read regression passed through normal admission with
|
||||
three real controller processes: the failed read leaves the reconstructed PRP
|
||||
projection, the next admission reconstructs the missing lifecycle receipt, and
|
||||
the next replacement still continues the same provider history. Prepared-task
|
||||
resumed and failed-read/retry cases also pass for Codex and Claude fixtures.
|
||||
These are scripted-provider component results with remote workspace storage
|
||||
available, not live Daytona or distinct-release acceptance.
|
||||
|
||||
|
||||
### Bounded qualification campaign on October 30
|
||||
|
||||
The follow-up campaign began at 18:33 UTC with an approximately one-hour budget.
|
||||
Three GPT-6.1 Sol workers own live Daytona, database load, and distinct-reader
|
||||
replacement/rollback; the coordinator owns integration, artifact builds and review.
|
||||
The exact native implementation under test is `a0ef3ce7493c8bb76c4766ff007e8a390118d145`.
|
||||
Qualification commit `da8c7f6e1837ddd896dfe83a7524812fee813487` adds measurement and
|
||||
installed-journey admission only; its native runtime, Runner, shared and database
|
||||
production files are byte-identical to that implementation.
|
||||
|
||||
The independent Linux database campaign uses an eight-vCPU, 32 GiB disposable
|
||||
host, with PostgreSQL and its Node workload each limited to two CPUs and 1536 MiB.
|
||||
It preserves the existing 10/2/2 request/renewal/checkpoint pool caps per logical
|
||||
server. Steady and aligned-burst ABBA comparisons passed the existing absolute,
|
||||
correctness and 10% relative regression gates with 300 controllers and 10,000
|
||||
historical tasks. Neither changes snapshot/PRP transaction counts. The separate
|
||||
claim-capacity probe waits for natural lease expiry, invokes the production
|
||||
claim path for 300 synthetic executions, restores real encrypted checkpoints
|
||||
under empty controller directories, and measures current-owner checkpoint and
|
||||
renewal work. Synthetic provider references do not establish sandbox existence
|
||||
or authenticated provider reattachment.
|
||||
|
||||
The closed, sanitized measurements and limits are recorded in
|
||||
`2026-10-30-native-controller-qualification-evidence.json`. The earlier failed,
|
||||
contended-host measurements remain in `2026-10-30-native-controller-load-evidence.json`.
|
||||
Lock measurements use calibrated PostgreSQL wait-start timestamps and sampled
|
||||
duration bounds. Observer traffic contributes to the measured database cost;
|
||||
short unsampled waits and full HTTP latency remain outside that measurement.
|
||||
|
||||
Live qualification uses protected campaign
|
||||
[41108162900](https://github.com/paperclipai/paperclip/actions/runs/41108162900).
|
||||
Its initial catalog timeout occurred before provider allocation after Azure
|
||||
reported a dependency-cache egress limit. A scoped exact-entry cache invalidation
|
||||
and targeted retry forced a successful cold install. Only this campaign's
|
||||
ephemeral image publisher received a larger, delete-on-termination disk; image
|
||||
identity, source admission and provider authorization gates remain in force.
|
||||
|
||||
Provider-free package builds pin first reader `59ff89a18fe1312b5f0bb908439bbdb1237b0252`
|
||||
and candidate `a0ef3ce7493c8bb76c4766ff007e8a390118d145` in runs
|
||||
[41108342354](https://github.com/paperclipai/paperclip/actions/runs/41108342354) and
|
||||
[41108352138](https://github.com/paperclipai/paperclip/actions/runs/41108352138).
|
||||
The source-process crash probe independently observed B waiting for A's live
|
||||
lease and claiming the same execution after natural expiry, with no lease edits.
|
||||
Its initial all-turns/single-provider-process assertion also crossed completion
|
||||
contract revision 1 to revision 2. The existing Runner attachment policy can
|
||||
retire a provider at a changed semantic contract while preserving its conversation;
|
||||
phase-specific observations distinguish that boundary from same-contract rollback.
|
||||
Earlier strict failures remain retained until that distinction is demonstrated.
|
||||
|
||||
The final live, reader, installed-artifact, cleanup and CI outcomes are maintained
|
||||
in [PR #18343](https://github.com/paperclipai/paperclip/pull/18343), with their exact
|
||||
revisions and evidence limits. Campaign registration, successful artifact builds
|
||||
and this plan entry do not themselves certify that a live journey passed.
|
||||
@@ -0,0 +1,170 @@
|
||||
--- scripts/benchmark-native-controller-recovery.ts
|
||||
+++ scripts/benchmark-native-controller-recovery.ts
|
||||
@@ -14 +14 @@
|
||||
-const repoRoot = fileURLToPath(new URL("../", import.meta.url));
|
||||
+const repoRoot = path.resolve(required("BENCH_CANDIDATE_ROOT"));
|
||||
@@ -241,0 +242,112 @@
|
||||
+async function claimCapacityPhase(db: any, u: string, items: any[], monitor: any) {
|
||||
+ const { claimNativeRestartRecoveries } = await load(repoRoot, "server/src/services/native-runtime/native-restart-recovery.ts");
|
||||
+ const { loadNativeRunnerCheckpoint } = await load(repoRoot, "server/src/services/native-runtime/native-runner-checkpoint.ts");
|
||||
+ const replacement = createDb(u, { maxConnections: 10, applicationName: "benchmark-claim-replacement" });
|
||||
+ const successor = { bootId: randomUUID(), pid: process.pid, processStartedAt: new Date() };
|
||||
+ const result: any = { schema: "paperclip.native-controller-claim-capacity/v1", ...provenance,
|
||||
+ scope: "Production DB claim function against synthetic prepared-workspace references and real persisted snapshots/PRP records. Natural lease expiry; two server database clients. No provider attachment or external sandbox existence claim.",
|
||||
+ errors: [], claims: [], reads: [], samples: [], sqlByClient: {}, renewedOwnerLosses: 0, restoredCheckpoints: 0,
|
||||
+ retainedOldControllerLeaseMs: 60000, remoteReconnectGraceMs: 420000, checkpointPoolPerServer: 2 };
|
||||
+ const clients = [
|
||||
+ ["old_request", db.$client], ["old_liveness", livenessDb(db).$client], ["old_checkpoint", checkpointDb(db).$client],
|
||||
+ ["new_request", replacement.$client], ["new_liveness", livenessDb(replacement).$client], ["new_checkpoint", checkpointDb(replacement).$client],
|
||||
+ ];
|
||||
+ for (const [name, client] of clients) { result.sqlByClient[name] = 0; client.options.debug = () => { result.sqlByClient[name]++; }; }
|
||||
+ // Data is persisted by the ordinary snapshot port during the preconditioning phase.
|
||||
+ const validity = await db.$client.unsafe("select count(*)::int as valid from heartbeat_runs r join native_run_finalizations f on f.run_id=r.id where r.status='running' and r.runner_profile_json->'nativeWorkspaceSync'->>'state'='prepared' and r.runner_profile_json->'sessionCheckpoint'->'identity'->>'runId'=r.id::text and exists(select 1 from native_runner_checkpoints c where c.run_id=r.id)");
|
||||
+ assert.equal(validity[0].valid, 300, "All claim inputs need real snapshots/checkpoints and prepared references");
|
||||
+ const before = pgResources(), started = performance.now(), requests = new Set<Promise<any>>(), renewals: any[] = [];
|
||||
+ const observed = observeLockWaits(monitor);
|
||||
+ let tick = 0, offered = 0, completed = 0;
|
||||
+ const traffic = setInterval(() => {
|
||||
+ for (let j = 0; j < 20; j++) {
|
||||
+ const index = tick++, item = items[index % items.length], client = index % 2 ? replacement : db;
|
||||
+ const at = performance.now(); offered++;
|
||||
+ const task = loadAgentRunAuthentication(client, item).then(() => { completed++; result.reads.push(performance.now() - at); })
|
||||
+ .catch((e: any) => result.errors.push({ source: "request", message: e.message })).finally(() => requests.delete(task));
|
||||
+ requests.add(task);
|
||||
+ }
|
||||
+ }, 100); // 200 database authentication requests/second, split evenly between servers.
|
||||
+ let sampling: Promise<any> | null = null;
|
||||
+ const sample = setInterval(() => {
|
||||
+ if (sampling) return;
|
||||
+ sampling = monitor`select application_name,count(*)::int as connections,count(*) filter(where state='active')::int as active from pg_stat_activity where datname=current_database() and application_name!='benchmark-observer' group by application_name`
|
||||
+ .then((rows: any) => result.samples.push({ elapsedMs: performance.now() - started, rows, postgresMemoryBytes: pgResources().memoryBytes }))
|
||||
+ .catch((e: any) => result.errors.push({ source: "observer", message: e.message })).finally(() => { sampling = null; });
|
||||
+ }, 1000);
|
||||
+ try {
|
||||
+ const [lease] = await db.$client.unsafe("select greatest(0,extract(epoch from(max(lease_expires_at)-clock_timestamp()))*1000)::float8 as remaining from native_run_finalizations where lease_owner is not null");
|
||||
+ result.naturalExpiryWaitMs = Number(lease.remaining);
|
||||
+ await sleep(result.naturalExpiryWaitMs + 100);
|
||||
+ const [expired] = await db.$client.unsafe("select count(*)::int as expired from native_run_finalizations where lease_owner is not null and lease_expires_at<=clock_timestamp()");
|
||||
+ assert.equal(expired.expired, 300, "Never rewrite or shorten a controller lease for the probe");
|
||||
+ const claimStarted = performance.now();
|
||||
+ const dispositions = await claimNativeRestartRecoveries({ db: replacement, controller: successor, restartKind: "hard",
|
||||
+ runIds: items.map(item => item.runId), limit: 300,
|
||||
+ onClaim: (claim: any, scope: any) => {
|
||||
+ result.claims.push({ runId: claim.runId, kind: claim.kind, elapsedMs: performance.now() - claimStarted, generation: claim.controllerGeneration });
|
||||
+ renewals.push(startNativeSessionExecutionLeaseRenewal({ db: replacement,
|
||||
+ companyId: scope.companyId, issueId: scope.issueId, runId: claim.runId, leaseOwner: claim.leaseOwner,
|
||||
+ attempt: claim.providerAttempt, controller: successor, remote: true,
|
||||
+ onLost: async () => { result.renewedOwnerLosses++; } }));
|
||||
+ },
|
||||
+ });
|
||||
+ result.claimDrainMs = performance.now() - claimStarted;
|
||||
+ assert.equal(dispositions.length, 300);
|
||||
+ assert(dispositions.every((claim: any) => claim.kind === "reattach_remote_runner"), "Every DB claim must independently validate its evidence");
|
||||
+ // Restore persisted records into empty replacement directories, then persist a real new control and snapshot.
|
||||
+ const work = [...dispositions];
|
||||
+ await Promise.all(Array.from({ length: 4 }, async () => {
|
||||
+ while (work.length) {
|
||||
+ const claim: any = work.shift(), item = items.find(i => i.runId === claim.runId);
|
||||
+ const checkpoint = await loadNativeRunnerCheckpoint(replacement, { companyId: item.companyId, issueId: item.issueId, runId: item.runId });
|
||||
+ assert(checkpoint);
|
||||
+ const directory = root + "/replacement-states/" + item.runId;
|
||||
+ checkpoint.restoreSessionDirectory(directory);
|
||||
+ const header = JSON.parse(checkpoint.records.get("header")!);
|
||||
+ const core = new CandidateCore({ stateDirectory: directory, identity: header.state.identity,
|
||||
+ expectedRunnerVersion: checkpoint.runnerVersion, expectedRunnerDigest: checkpoint.runnerDigest });
|
||||
+ const binding = { companyId: item.companyId, issueId: item.issueId, runId: item.runId,
|
||||
+ authorityRunId: item.runId, bootId: successor.bootId, generation: claim.controllerGeneration, leaseOwner: claim.leaseOwner };
|
||||
+ core.setCheckpointPersistence(CandidateWriter(replacement, binding));
|
||||
+ core.queueCommand("semantic_tool.result", { callId: "replacement-capacity", operationId: "fixture_read", result: { body: "x".repeat(4096) }, isError: false }, "replacement-control");
|
||||
+ await core.flushCheckpoint();
|
||||
+ const identity = item.snapshot.identity;
|
||||
+ const port = new CandidatePort(replacement, { ...identity, sourceInstanceId: checkpoint.recoveryEvidence.runnerInstanceId,
|
||||
+ controlPlaneSourceInstanceId: "replacement-controller", completionContractId: item.completionContractId, completionContractSha256: item.completionContractSha256 }, { nativeRecovery: {
|
||||
+ compatibility: { configDigest: "sha256:" + "a".repeat(64), processConfigDigest: null }, owner: binding } });
|
||||
+ await port.openRun({ identity, backendKind: "codex", sourceInstanceId: checkpoint.recoveryEvidence.runnerInstanceId });
|
||||
+ await port.checkpointSession(item.snapshot);
|
||||
+ result.restoredCheckpoints++;
|
||||
+ await core.stop();
|
||||
+ }
|
||||
+ }));
|
||||
+ result.claimAndRestoreDrainMs = performance.now() - claimStarted;
|
||||
+ // Observe one natural 20-second renewal from the last replacement claim.
|
||||
+ await sleep(Math.max(0, 21000 - (performance.now() - claimStarted - result.claimDrainMs)));
|
||||
+ const [saved] = await replacement.$client.unsafe("select count(*)::int as owners,min(controller_generation)::int as min_generation,max(controller_generation)::int as max_generation,min(extract(epoch from(lease_expires_at-clock_timestamp()))*1000)::float8 as minimum_remaining_ms from native_run_finalizations where controller_boot_id=$1 and lease_owner is not null", [successor.bootId]);
|
||||
+ result.postRenewalOwners = saved;
|
||||
+ assert.equal(saved.owners, 300); assert.equal(saved.min_generation, 2); assert.equal(saved.max_generation, 2);
|
||||
+ assert(saved.minimum_remaining_ms > 40000, "Replacement leases should have naturally renewed from the database clock");
|
||||
+ } catch (e: any) { result.errors.push({ source: "claim-or-restore", message: e.message, stack: e.stack }); }
|
||||
+ finally {
|
||||
+ clearInterval(traffic); clearInterval(sample);
|
||||
+ await Promise.allSettled([...requests]); await sampling;
|
||||
+ await Promise.allSettled(renewals.map(r => r.stop()));
|
||||
+ result.lockWait = await observed.stop();
|
||||
+ }
|
||||
+ result.elapsedMs = performance.now() - started;
|
||||
+ result.requestsOffered = offered; result.requestsCompleted = completed; result.requestLatency = summary(result.reads); delete result.reads;
|
||||
+ result.postgresCpuUsec = pgResources().cpuUsec - before.cpuUsec;
|
||||
+ const totalSql = () => Object.values(result.sqlByClient).reduce((a: number, b: any) => a + Number(b), 0);
|
||||
+ const idleStart = totalSql(); await sleep(1000); result.settledIdleSql = totalSql() - idleStart;
|
||||
+ result.passed = result.errors.length === 0 && result.lockWait.errors.length === 0 && result.claims.length === 300
|
||||
+ && result.restoredCheckpoints === 300 && completed === offered && result.renewedOwnerLosses === 0
|
||||
+ && result.claimAndRestoreDrainMs < 420000 && result.requestLatency.p99 < 250 && result.settledIdleSql === 0;
|
||||
+ await writeFile(root + "/claim-capacity.json", JSON.stringify(result, null, 2) + "\n");
|
||||
+ console.log(JSON.stringify({ phase: "claim-capacity-complete", passed: result.passed, claims: result.claims.length,
|
||||
+ claimDrainMs: result.claimDrainMs, claimAndRestoreDrainMs: result.claimAndRestoreDrainMs,
|
||||
+ requests: result.requestLatency, errors: result.errors.slice(0, 3) }));
|
||||
+ if (!result.passed) process.exitCode = 1;
|
||||
+}
|
||||
+
|
||||
@@ -429,0 +542 @@
|
||||
+ runnerProfileJson: { nativeWorkspaceSync: { schema: "paperclip.native-workspace-sync/v3", state: "prepared", descriptorSha256: "a".repeat(64), baselineSha256: "b".repeat(64), finalHostSha256: null, workspaceId: randomUUID(), leaseId: randomUUID(), providerLeaseId: randomUUID(), remoteCwd: "/workspace", resourceDisposition: "destroy" } },
|
||||
@@ -544,0 +658,2 @@
|
||||
+ completionContractId: contractId,
|
||||
+ completionContractSha256: "fixture",
|
||||
@@ -905,0 +1021 @@
|
||||
+ await claimCapacityPhase(db, u, items, monitor);
|
||||
@@ -917,6 +1033 @@
|
||||
- const profile = [
|
||||
- ["baseline", 300],
|
||||
- ["candidate", 300],
|
||||
- ["candidate", 300],
|
||||
- ["baseline", 300],
|
||||
- ] as const;
|
||||
+ const profile = [["candidate", 300]] as const;
|
||||
@@ -953,35 +1064 @@
|
||||
- const perOperation = (r: any, key: string) =>
|
||||
- r[key] / (r.normalCompleted + r.normalWritesCompleted + r.controls + r.admissionsCompleted);
|
||||
- const average = (values: number[]) => values.reduce((a, b) => a + b, 0) / values.length;
|
||||
- const comparable =
|
||||
- result.every((r) => r.correctnessPassed) &&
|
||||
- result.every(
|
||||
- (r) =>
|
||||
- r.controls === result[0].controls &&
|
||||
- r.normalCompleted === result[0].normalCompleted &&
|
||||
- r.normalWritesCompleted === result[0].normalWritesCompleted,
|
||||
- );
|
||||
- const relative = {};
|
||||
- for (const key of ["postgresCpuUsec", "walBytes", "requestP99"]) {
|
||||
- const value = (r: any) => (key === "requestP99" ? r.normalReadSummary.p99 : perOperation(r, key));
|
||||
- const baseline = average(result.filter((r) => r.variant === "baseline").map(value));
|
||||
- const candidate = average(result.filter((r) => r.variant === "candidate").map(value));
|
||||
- relative[key] = {
|
||||
- baseline,
|
||||
- candidate,
|
||||
- increasePercent: comparable ? 100 * (candidate / baseline - 1) : null,
|
||||
- passed: comparable ? candidate <= baseline * 1.1 : null,
|
||||
- };
|
||||
- }
|
||||
- Object.assign(report, {
|
||||
- relative,
|
||||
- comparable,
|
||||
- passed: result.every((r) => r.passed) && Object.values(relative).every((r: any) => r.passed),
|
||||
- measurementLimits: [
|
||||
- "Database request latency is not HTTP latency",
|
||||
- "Pool queue includes client encoding; BEGIN response includes network time",
|
||||
- "pg_stat_statements execution includes lock waits; lock episodes report sampled duration bounds, with short waits potentially missed",
|
||||
- "Peak memory is sampled once a second",
|
||||
- "Use an otherwise idle cluster and host; WAL and cgroup CPU are cluster-wide",
|
||||
- ],
|
||||
- });
|
||||
+ Object.assign(report, { passed: result.every(r => r.passed), scope: "Preconditioning only for the separate claim-capacity.json report" });
|
||||
@@ -0,0 +1,390 @@
|
||||
{
|
||||
"schema": "paperclip.native-controller-load-summary/v1",
|
||||
"baselineRevision": "59e191c368",
|
||||
"candidateRevision": "4f710fd7e7",
|
||||
"qualification": "failed; host contention and unequal completed work invalidate the ABBA comparison",
|
||||
"sourceReport": "/tmp/native-controller-load/combined-steady/report.json",
|
||||
"fixture": {
|
||||
"mode": "steady",
|
||||
"normalReadsPerSecond": 500,
|
||||
"normalWritesPerSecond": 100,
|
||||
"databaseAdmissionEverySeconds": 5,
|
||||
"checkpointControlEverySeconds": 5,
|
||||
"controlPayloadBytes": 4096,
|
||||
"phaseSeconds": 65,
|
||||
"snapshotTransactionsPerControl": 1,
|
||||
"baselineCheckpointPool": 2,
|
||||
"baselineRenewalIntervalSeconds": 20,
|
||||
"candidateRenewalIntervalSeconds": 20,
|
||||
"historicalIssues": 10000
|
||||
},
|
||||
"limits": [
|
||||
"Database request latency is not HTTP latency",
|
||||
"Pool queue includes client encoding; BEGIN response includes network time",
|
||||
"pg_stat_statements execution includes lock waits; samples report concurrent lock waiters, not a lock latency distribution",
|
||||
"Peak memory is sampled once a second",
|
||||
"Use an otherwise idle cluster and host; WAL and cgroup CPU are cluster-wide"
|
||||
],
|
||||
"results": [
|
||||
{
|
||||
"label": "01_baseline_300",
|
||||
"controls": 3900,
|
||||
"normalOffered": 32500,
|
||||
"normalCompleted": 32500,
|
||||
"normalWritesCompleted": 6500,
|
||||
"dropped": 0,
|
||||
"renewalLosses": 0,
|
||||
"checkpointTransactions": 3900,
|
||||
"snapshotTransactions": 3900,
|
||||
"sqlCount": 54687,
|
||||
"livenessSqlCount": 4502,
|
||||
"checkpointSqlCount": 15601,
|
||||
"changedRows": 7800,
|
||||
"changedBytes": 17371500,
|
||||
"walBytes": 63819704,
|
||||
"postgresCpuUsec": 25887716,
|
||||
"postgresPeakSampledMemoryBytes": 697917440,
|
||||
"normalReadSummary": {
|
||||
"n": 32500,
|
||||
"p50": 53.82264099999884,
|
||||
"p95": 83.97040299999935,
|
||||
"p99": 131.4543599999888,
|
||||
"max": 249.75419500000135
|
||||
},
|
||||
"checkpointLatencySummary": {
|
||||
"n": 3900,
|
||||
"p50": 3.5057919999962905,
|
||||
"p95": 14.36212499999965,
|
||||
"p99": 79.32949999999255,
|
||||
"max": 220.70087599998806
|
||||
},
|
||||
"poolQueueByPool": {
|
||||
"request": {
|
||||
"n": 3913,
|
||||
"p50": 0.04241599999659229,
|
||||
"p95": 4.89183399999456,
|
||||
"p99": 25.90625,
|
||||
"max": 210.7756250000093
|
||||
},
|
||||
"liveness": {
|
||||
"n": 900,
|
||||
"p50": 401.07175099999586,
|
||||
"p95": 1987.666584000006,
|
||||
"p99": 2269.4225429999933,
|
||||
"max": 2296.889626000004
|
||||
},
|
||||
"checkpoint": {
|
||||
"n": 3900,
|
||||
"p50": 0.05258299999695737,
|
||||
"p95": 0.14449999999487773,
|
||||
"p99": 41.99183400000038,
|
||||
"max": 188.36633400000574
|
||||
}
|
||||
},
|
||||
"transactionLatencyByPool": {
|
||||
"request": {
|
||||
"n": 3913,
|
||||
"p50": 3.6501250000001164,
|
||||
"p95": 14.756584000002476,
|
||||
"p99": 62.41062499998952,
|
||||
"max": 240.88450000000012
|
||||
},
|
||||
"liveness": {
|
||||
"n": 900,
|
||||
"p50": 406.2205419999955,
|
||||
"p95": 2044.2198760000028,
|
||||
"p99": 2277.324250999998,
|
||||
"max": 2308.3716669999994
|
||||
},
|
||||
"checkpoint": {
|
||||
"n": 3900,
|
||||
"p50": 3.297791999997571,
|
||||
"p95": 14.160208000001148,
|
||||
"p99": 79.12295900000026,
|
||||
"max": 220.50066700000025
|
||||
}
|
||||
},
|
||||
"drainMs": 26.716459000002942,
|
||||
"absoluteGates": {
|
||||
"requestP99": true,
|
||||
"drain": true,
|
||||
"throughput": true,
|
||||
"postgresCpu": true,
|
||||
"postgresMemory": true
|
||||
},
|
||||
"passed": true,
|
||||
"startedAt": "2026-10-30T16:40:56.503Z",
|
||||
"endedAt": "2026-10-30T16:42:01.534Z"
|
||||
},
|
||||
{
|
||||
"label": "02_candidate_300",
|
||||
"controls": 3900,
|
||||
"normalOffered": 32500,
|
||||
"normalCompleted": 32500,
|
||||
"normalWritesCompleted": 6500,
|
||||
"dropped": 0,
|
||||
"renewalLosses": 0,
|
||||
"checkpointTransactions": 3900,
|
||||
"snapshotTransactions": 3900,
|
||||
"sqlCount": 54687,
|
||||
"livenessSqlCount": 4502,
|
||||
"checkpointSqlCount": 15601,
|
||||
"changedRows": 7800,
|
||||
"changedBytes": 17371500,
|
||||
"walBytes": 66746424,
|
||||
"postgresCpuUsec": 35685605,
|
||||
"postgresPeakSampledMemoryBytes": 666578944,
|
||||
"normalReadSummary": {
|
||||
"n": 32500,
|
||||
"p50": 68.02364699999453,
|
||||
"p95": 127.37661100001424,
|
||||
"p99": 303.326441000012,
|
||||
"max": 474.2829830000119
|
||||
},
|
||||
"checkpointLatencySummary": {
|
||||
"n": 3900,
|
||||
"p50": 5.670542000007117,
|
||||
"p95": 241.97500100001344,
|
||||
"p99": 479.3440830000036,
|
||||
"max": 553.2187080000003
|
||||
},
|
||||
"poolQueueByPool": {
|
||||
"request": {
|
||||
"n": 3913,
|
||||
"p50": 0.05162500002188608,
|
||||
"p95": 37.2935839999991,
|
||||
"p99": 136.02920900000026,
|
||||
"max": 420.89487499999814
|
||||
},
|
||||
"liveness": {
|
||||
"n": 900,
|
||||
"p50": 536.7470420000027,
|
||||
"p95": 3620.454543,
|
||||
"p99": 4146.325211000018,
|
||||
"max": 4238.34758500001
|
||||
},
|
||||
"checkpoint": {
|
||||
"n": 3900,
|
||||
"p50": 0.057792000006884336,
|
||||
"p95": 209.87512499999139,
|
||||
"p99": 443.83324999999604,
|
||||
"max": 528.2805419999931
|
||||
}
|
||||
},
|
||||
"transactionLatencyByPool": {
|
||||
"request": {
|
||||
"n": 3913,
|
||||
"p50": 6.640833000012208,
|
||||
"p95": 66.06445900001563,
|
||||
"p99": 184.52537499999744,
|
||||
"max": 449.3999579999945
|
||||
},
|
||||
"liveness": {
|
||||
"n": 900,
|
||||
"p50": 545.1022919999959,
|
||||
"p95": 3665.246376999974,
|
||||
"p99": 4172.158336000022,
|
||||
"max": 4248.078752000001
|
||||
},
|
||||
"checkpoint": {
|
||||
"n": 3900,
|
||||
"p50": 5.484875000009197,
|
||||
"p95": 241.83212600002298,
|
||||
"p99": 478.8787080000038,
|
||||
"max": 553.1449999999895
|
||||
}
|
||||
},
|
||||
"drainMs": 76.8760000000184,
|
||||
"absoluteGates": {
|
||||
"requestP99": false,
|
||||
"drain": true,
|
||||
"throughput": true,
|
||||
"postgresCpu": true,
|
||||
"postgresMemory": true
|
||||
},
|
||||
"passed": false,
|
||||
"startedAt": "2026-10-30T16:42:16.915Z",
|
||||
"endedAt": "2026-10-30T16:43:21.995Z"
|
||||
},
|
||||
{
|
||||
"label": "03_candidate_300",
|
||||
"controls": 3887,
|
||||
"normalOffered": 32500,
|
||||
"normalCompleted": 32379,
|
||||
"normalWritesCompleted": 6470,
|
||||
"dropped": 164,
|
||||
"renewalLosses": 0,
|
||||
"checkpointTransactions": 3887,
|
||||
"snapshotTransactions": 3887,
|
||||
"sqlCount": 54484,
|
||||
"livenessSqlCount": 4502,
|
||||
"checkpointSqlCount": 15549,
|
||||
"changedRows": 7774,
|
||||
"changedBytes": 17313559,
|
||||
"walBytes": 66470080,
|
||||
"postgresCpuUsec": 53715752,
|
||||
"postgresPeakSampledMemoryBytes": 670437376,
|
||||
"normalReadSummary": {
|
||||
"n": 32379,
|
||||
"p50": 87.29533699998865,
|
||||
"p95": 407.7868620000081,
|
||||
"p99": 715.8863589999964,
|
||||
"max": 1413.3376139999891
|
||||
},
|
||||
"checkpointLatencySummary": {
|
||||
"n": 3887,
|
||||
"p50": 35.722666999994544,
|
||||
"p95": 2254.7568759999995,
|
||||
"p99": 4195.890377000003,
|
||||
"max": 4784.590544000006
|
||||
},
|
||||
"poolQueueByPool": {
|
||||
"request": {
|
||||
"n": 3900,
|
||||
"p50": 0.12583400000585243,
|
||||
"p95": 218.51912499999162,
|
||||
"p99": 498.72595799999544,
|
||||
"max": 1368.8183760000102
|
||||
},
|
||||
"liveness": {
|
||||
"n": 900,
|
||||
"p50": 2733.2611259999976,
|
||||
"p95": 7990.602337999997,
|
||||
"p99": 8707.207421,
|
||||
"max": 8915.786796
|
||||
},
|
||||
"checkpoint": {
|
||||
"n": 3887,
|
||||
"p50": 3.155832999997074,
|
||||
"p95": 2207.667333999998,
|
||||
"p99": 4154.626294000016,
|
||||
"max": 4726.676418999996
|
||||
}
|
||||
},
|
||||
"transactionLatencyByPool": {
|
||||
"request": {
|
||||
"n": 3900,
|
||||
"p50": 27.709541999996873,
|
||||
"p95": 276.61000000001513,
|
||||
"p99": 625.9165419999918,
|
||||
"max": 1627.7409179999959
|
||||
},
|
||||
"liveness": {
|
||||
"n": 900,
|
||||
"p50": 2782.7440009999846,
|
||||
"p95": 8019.347628999996,
|
||||
"p99": 8734.34838000001,
|
||||
"max": 8964.291171000019
|
||||
},
|
||||
"checkpoint": {
|
||||
"n": 3887,
|
||||
"p50": 35.49954200000502,
|
||||
"p95": 2254.554459999985,
|
||||
"p99": 4195.5352520000015,
|
||||
"max": 4784.371294000011
|
||||
}
|
||||
},
|
||||
"drainMs": 4928.940960000007,
|
||||
"absoluteGates": {
|
||||
"requestP99": false,
|
||||
"drain": false,
|
||||
"throughput": true,
|
||||
"postgresCpu": true,
|
||||
"postgresMemory": true
|
||||
},
|
||||
"passed": false,
|
||||
"startedAt": "2026-10-30T16:43:41.479Z",
|
||||
"endedAt": "2026-10-30T16:44:51.414Z"
|
||||
},
|
||||
{
|
||||
"label": "04_baseline_300",
|
||||
"controls": 2390,
|
||||
"normalOffered": 32500,
|
||||
"normalCompleted": 10713,
|
||||
"normalWritesCompleted": 1503,
|
||||
"dropped": 28305,
|
||||
"renewalLosses": 0,
|
||||
"checkpointTransactions": 2390,
|
||||
"snapshotTransactions": 2390,
|
||||
"sqlCount": 21797,
|
||||
"livenessSqlCount": 4502,
|
||||
"checkpointSqlCount": 9561,
|
||||
"changedRows": 4780,
|
||||
"changedBytes": 10643952,
|
||||
"walBytes": 31871472,
|
||||
"postgresCpuUsec": 55047197,
|
||||
"postgresPeakSampledMemoryBytes": 643657728,
|
||||
"normalReadSummary": {
|
||||
"n": 10713,
|
||||
"p50": 291.001007999992,
|
||||
"p95": 841.957837999973,
|
||||
"p99": 2601.1680469999555,
|
||||
"max": 3106.3534709999803
|
||||
},
|
||||
"checkpointLatencySummary": {
|
||||
"n": 2390,
|
||||
"p50": 6933.93029400002,
|
||||
"p95": 16553.939049999986,
|
||||
"p99": 17176.082591000013,
|
||||
"max": 17434.499467000016
|
||||
},
|
||||
"poolQueueByPool": {
|
||||
"request": {
|
||||
"n": 2392,
|
||||
"p50": 0.10450000001583248,
|
||||
"p95": 520.4013340000529,
|
||||
"p99": 798.97454200004,
|
||||
"max": 2614.81958499999
|
||||
},
|
||||
"liveness": {
|
||||
"n": 900,
|
||||
"p50": 5497.6995860000025,
|
||||
"p95": 16836.80413299997,
|
||||
"p99": 17748.473174999992,
|
||||
"max": 18895.275049999997
|
||||
},
|
||||
"checkpoint": {
|
||||
"n": 2390,
|
||||
"p50": 6882.121711000043,
|
||||
"p95": 16492.48646600003,
|
||||
"p99": 17097.382091999985,
|
||||
"max": 17352.715134
|
||||
}
|
||||
},
|
||||
"transactionLatencyByPool": {
|
||||
"request": {
|
||||
"n": 2392,
|
||||
"p50": 55.64137500000652,
|
||||
"p95": 638.8517920000013,
|
||||
"p99": 2574.2484169999952,
|
||||
"max": 2879.7436260000104
|
||||
},
|
||||
"liveness": {
|
||||
"n": 900,
|
||||
"p50": 5623.322503000032,
|
||||
"p95": 16872.938590999984,
|
||||
"p99": 18552.84471799998,
|
||||
"max": 18921.25067499996
|
||||
},
|
||||
"checkpoint": {
|
||||
"n": 2390,
|
||||
"p50": 6933.636502000038,
|
||||
"p95": 16553.763133,
|
||||
"p99": 17175.909424000012,
|
||||
"max": 17433.771509000042
|
||||
}
|
||||
},
|
||||
"drainMs": 12508.142965000006,
|
||||
"absoluteGates": {
|
||||
"requestP99": false,
|
||||
"drain": false,
|
||||
"throughput": false,
|
||||
"postgresCpu": true,
|
||||
"postgresMemory": true
|
||||
},
|
||||
"passed": false,
|
||||
"startedAt": "2026-10-30T16:45:39.710Z",
|
||||
"endedAt": "2026-10-30T16:46:57.219Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
--- scripts/benchmark-native-controller-recovery.ts
|
||||
+++ scripts/benchmark-native-controller-recovery.ts
|
||||
@@ -0,0 +1 @@
|
||||
+// Temporary qualification probe derived from frozen benchmark SHA256 abaa29d50eb95013836b54b8dacc94c72e080edf24744cc5d291cc5eb1ff2818.
|
||||
@@ -14 +15 @@
|
||||
-const repoRoot = fileURLToPath(new URL("../", import.meta.url));
|
||||
+const repoRoot = path.resolve(required("BENCH_CANDIDATE_ROOT"));
|
||||
@@ -187 +188 @@
|
||||
- const byPool = Object.fromEntries(["benchmark-application", "paperclip-controller-lease", "paperclip-runner-checkpoint"].map(pool => {
|
||||
+ const byPool = Object.fromEntries(["benchmark-application", "benchmark-replacement-application", "paperclip-controller-lease", "paperclip-runner-checkpoint"].map(pool => {
|
||||
@@ -250,0 +252 @@
|
||||
+ const replacementDb = createDb(u, { maxConnections: 10, applicationName: "benchmark-replacement-application" });
|
||||
@@ -256,0 +259 @@
|
||||
+ const replacementOwner = { ...owner, bootId: randomUUID() };
|
||||
@@ -299,0 +303,3 @@
|
||||
+ ["replacement_request", replacementDb.$client],
|
||||
+ ["replacement_liveness", livenessDb(replacementDb).$client],
|
||||
+ ["replacement_checkpoint", checkpointDb(replacementDb).$client],
|
||||
@@ -369,0 +376 @@
|
||||
+ replacementDb.$client.options.debug = db.$client.options.debug;
|
||||
@@ -386,0 +394,2 @@
|
||||
+ livenessDb(replacementDb).$client.options.debug = livenessDb(db).$client.options.debug;
|
||||
+ checkpointDb(replacementDb).$client.options.debug = checkpointDb(db).$client.options.debug;
|
||||
@@ -389,0 +399,2 @@
|
||||
+ const controllerDb = i % 2 ? replacementDb : db;
|
||||
+ const controllerOwner = i % 2 ? replacementOwner : owner;
|
||||
@@ -443,3 +454,3 @@
|
||||
- controllerBootId: owner.bootId,
|
||||
- controllerPid: owner.pid,
|
||||
- controllerProcessStartedAt: owner.processStartedAt,
|
||||
+ controllerBootId: controllerOwner.bootId,
|
||||
+ controllerPid: controllerOwner.pid,
|
||||
+ controllerProcessStartedAt: controllerOwner.processStartedAt,
|
||||
@@ -467 +478 @@
|
||||
- const writer = (variant === "candidate" ? CandidateWriter : BaselineWriter)(db, {
|
||||
+ const writer = (variant === "candidate" ? CandidateWriter : BaselineWriter)(controllerDb, {
|
||||
@@ -472 +483 @@
|
||||
- bootId: owner.bootId,
|
||||
+ bootId: controllerOwner.bootId,
|
||||
@@ -511 +522 @@
|
||||
- db,
|
||||
+ controllerDb,
|
||||
@@ -531 +542 @@
|
||||
- bootId: owner.bootId,
|
||||
+ bootId: controllerOwner.bootId,
|
||||
@@ -544,0 +556,2 @@
|
||||
+ db: controllerDb,
|
||||
+ owner: controllerOwner,
|
||||
@@ -592 +605 @@
|
||||
- for (let i = 0; i < 100; i++) await loadAgentRunAuthentication(db, items[i % n]);
|
||||
+ for (let i = 0; i < 100; i++) await loadAgentRunAuthentication(items[i % n].db, items[i % n]);
|
||||
@@ -639 +652 @@
|
||||
- const input = { db, ...item, attempt: 1, controller: owner };
|
||||
+ const input = { ...item, attempt: 1, controller: item.owner };
|
||||
@@ -664 +677 @@
|
||||
- const rows = await loadAgentRunAuthentication(db, item);
|
||||
+ const rows = await loadAgentRunAuthentication(item.db, item);
|
||||
@@ -700 +713 @@
|
||||
- await db.update(issues).set({ updatedAt: new Date() }).where(eq(issues.id, item.issueId));
|
||||
+ await item.db.update(issues).set({ updatedAt: new Date() }).where(eq(issues.id, item.issueId));
|
||||
@@ -713 +726 @@
|
||||
- await db.transaction(async (tx: any) => {
|
||||
+ await (metrics.admissionsOffered % 2 ? replacementDb : db).transaction(async (tx: any) => {
|
||||
@@ -917,6 +930 @@
|
||||
- const profile = [
|
||||
- ["baseline", 300],
|
||||
- ["candidate", 300],
|
||||
- ["candidate", 300],
|
||||
- ["baseline", 300],
|
||||
- ] as const;
|
||||
+ const profile = [["baseline", 300], ["candidate", 300], ["candidate", 300], ["baseline", 300]] as const;
|
||||
@@ -929 +937 @@
|
||||
- "Paired baseline/candidate, including real PaperclipControlPlanePort snapshot persistence on every control checkpoint. Database/control-plane component stress; real production checkpoint writer and natural remote lease timers. Same synthetic DB request load. Not full HTTP, harness or hosted capacity qualification.",
|
||||
+ "Two logical servers in one client process, 150 controllers each, separate request/liveness/checkpoint pools at unchanged caps; same aggregate aligned-burst workload. Matched baseline/candidate ABBA pool-overlap component qualification; no takeover latency or provider reattachment claim. Includes real PaperclipControlPlanePort snapshot persistence on every control checkpoint. Database/control-plane component stress; real production checkpoint writer and natural remote lease timers. Same synthetic DB request load. Not full HTTP, harness or hosted capacity qualification.",
|
||||
@@ -930,0 +939 @@
|
||||
+ logicalServers: 2,
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"schema": "paperclip.native-controller.release-qualification-receipt/v1",
|
||||
"finishedAt": "2026-10-30T19:33:20.587023+00:00",
|
||||
"sourceIntegrity": {
|
||||
"A": {
|
||||
"revision": "59ff89a18fe1312b5f0bb908439bbdb1237b0252",
|
||||
"nativeRuntimeSourceBytesUnchanged": true,
|
||||
"mismatches": []
|
||||
},
|
||||
"B": {
|
||||
"revision": "98f05fcd5b452704131a35ff09486f5c04c6959a",
|
||||
"nativeRuntimeSourceBytesUnchanged": true,
|
||||
"mismatches": []
|
||||
}
|
||||
},
|
||||
"sourceAndArtifact": {
|
||||
"A": "59ff89a18fe1312b5f0bb908439bbdb1237b0252",
|
||||
"B": "98f05fcd5b452704131a35ff09486f5c04c6959a",
|
||||
"admittedRunnerSha256": "b1f04caa1ce04e720dec7eb62ede170eb3621948c0849b8f305ea5c5116bced8",
|
||||
"runnerArtifact": {
|
||||
"role": "admitted Runner artifact A, unchanged through every controller",
|
||||
"sourceRevision": "59ff89a18fe1312b5f0bb908439bbdb1237b0252",
|
||||
"binary": "/private/tmp/native-controller-qualification-20261030-1833/installed-A/consumer/node_modules/@paperclipai/server/dist/vendor/paperclip-runner/bin/linux-arm64/paperclip-runnerd",
|
||||
"sha256": "b1f04caa1ce04e720dec7eb62ede170eb3621948c0849b8f305ea5c5116bced8",
|
||||
"bytes": 35345144,
|
||||
"installationReceipt": "/private/tmp/native-controller-qualification-20261030-1833/installed-A/receipt.json"
|
||||
}
|
||||
},
|
||||
"passed": [
|
||||
"Distinct source-controller active SIGKILL followed by natural 60-second lease takeover; exact same run/provider attempt/Runner retained",
|
||||
"Current real-PostgreSQL stream oracle at 8,512,8192 chunks: zero incremental checkpoint SQL/transactions/changed rows; idle SQL zero; positive control passed",
|
||||
"B1 recovered run completed and committed its result with ready native snapshot and valid v1 recovery envelope"
|
||||
],
|
||||
"failed": [
|
||||
"Fresh B2 retained continuation rejected after B1 completed because durable control journal has a null-goal snapshot after run.terminal",
|
||||
"Final 98f05fc live probe still rejects actual paperclip.prp.event.v2 because proposed guard accepts only v1"
|
||||
],
|
||||
"notQualified": [
|
||||
"Complete A/B/A native installed-application startup/rollback gate",
|
||||
"Uninterrupted warm provider process through changed completion-contract boundary",
|
||||
"First compatible published empty-disk rollback reader"
|
||||
],
|
||||
"limits": [
|
||||
"Source controller processes and scripted Codex-compatible provider in Docker; no live-provider or live-Daytona claim",
|
||||
"Qualified immutable A release Runner used; source controller native modules hash-verified, not full installed server execution",
|
||||
"Only native controller projections deleted; non-native configuration, encryption key, workspace/sandbox dependencies preserved",
|
||||
"Local stream WAL/elapsed diagnostics excluded from quantitative performance due concurrent isolated fixture"
|
||||
],
|
||||
"evidence": {
|
||||
"finalProbe": "/tmp/native-controller-release-qualification-qualified-A-20261030/evidence.json",
|
||||
"finalProbeLog": "/tmp/native-controller-release-qualification-qualified-A-20261030/run-final-fix.log",
|
||||
"controlLog": "/tmp/native-controller-release-qualification-qualified-A-20261030/controller.log",
|
||||
"streamOracle": "/tmp/native-controller-release-qualification-20261030/stream-cost-current.json",
|
||||
"streamLog": "/tmp/native-controller-release-qualification-20261030/stream-cost-current.log",
|
||||
"earlierThreeTurnBusinessContinuation": "/tmp/native-controller-release-qualification-20261030/evidence-run-06.json"
|
||||
},
|
||||
"finalProbeFailure": {
|
||||
"message": "Error: runner_state_identity_mismatch: prior_owner_terminal_state_indeterminate\n at deny (/private/tmp/native-controller-release-qualification-qualified-A-20261030/B-98f05f/server/src/services/native-runtime/remote-warm-session-recovery.ts:56:37)\n at inspectRemoteWarmSessionRecovery (/private/tmp/native-controller-release-qualification-qualified-A-20261030/B-98f05f/server/src/services/native-runtime/remote-warm-session-recovery.ts:85:67)\n at process.processTicksAndRejections (node:internal/process/task_queues:104:5)\n at async executePaperclipNativeSessionWithinScope (/private/tmp/native-controller-release-qualification-qualified-A-20261030/B-98f05f/server/src/services/native-runtime/native-session-executor.ts:8237:28)\n at async executePaperclipNativeSession (/private/tmp/native-controller-release-qualification-qualified-A-20261030/B-98f05f/server/src/services/native-runtime/native-session-executor.ts:8057:12)\n at async process.<anonymous> (/private/tmp/native-controller-release-qualification-qualified-A-20261030/controller-worker.mjs:36:16)",
|
||||
"cause": null
|
||||
},
|
||||
"candidateCorrection": {
|
||||
"change": "Accept supported PRP event v1 and v2 idle goal suffixes; reject unknown versions",
|
||||
"fullSourceReplay": "not rerun after the final v2 correction within the one-hour qualification window",
|
||||
"focusedTests": {
|
||||
"result": "pass",
|
||||
"cases": 14,
|
||||
"test": "server/src/services/native-runtime/remote-warm-session-recovery.test.ts"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,13 +109,15 @@ not on the ordinary write pool. The helper ends that physical connection in
|
||||
actual refresh settlement. Summary writeback runs watched-query reads on its
|
||||
existing transaction, so a one-connection main pool completes the full flow.
|
||||
|
||||
Migration0419 adds nullable internal `next_eval_reason` to the existing card row.
|
||||
Migration0420 adds nullable internal `next_eval_reason` to the existing card row.
|
||||
Membership deadlines may be reconstructed at startup after a committed source
|
||||
hint was lost. Policy eligibility and the atomically recorded five-minute retry
|
||||
lease retain their saved deadline. Explicit refresh deadlines remain due work.
|
||||
The HTTP card response removes this internal provenance; the public schema stays
|
||||
unchanged.
|
||||
|
||||
Migration0419 retains the published agent-comment retry uniqueness and conversation read-state backfill. The status-card column follows it in migration0420; the combined schema snapshot is generated from both owners.
|
||||
|
||||
For legacy rows, a pending change hash preserves policy eligibility. An untagged
|
||||
active deadline farther than five minutes away cannot be an outstanding
|
||||
five-minute claim, so startup rechecks it. An ambiguous untagged deadline within
|
||||
|
||||
@@ -7,7 +7,7 @@ and backup validation against a deterministic fake Codex process in Linux. It
|
||||
checks the same runner process identity and one provider process across three warm
|
||||
turns, provider history reuse, and verified suspension at idle close. Separate
|
||||
cases kill the actual controller process and recover the next turn. They reject
|
||||
a still-live controller and a replacement sandbox before reconnecting. Legacy
|
||||
a live execution lease and a replacement sandbox before reconnecting. Legacy
|
||||
checkpoints and changed process configuration require verified suspension and
|
||||
replacement, while preserving the conversation and starting each turn once.
|
||||
Recovery can restart a provider to refresh its tool contract; process reuse is
|
||||
@@ -24,6 +24,39 @@ subscription. The controller must register the connection before subscribing so
|
||||
completed message processing can drain and the suspended session can retire.
|
||||
It does not qualify a live model or a sandbox provider API.
|
||||
|
||||
The `diskless` and `diskless-per-turn` cases delete both native controller
|
||||
directories after A completes, continue in a separate B process, delete those
|
||||
directories after B completes, and continue in C. They use the production source
|
||||
selector and snapshot rebinder for per-turn admission, followed by the real
|
||||
native executor. They do not inject PRP files or edit leases to produce recovery.
|
||||
Warm mode verifies compatible process reuse; per-turn mode verifies the existing
|
||||
suspension and history-resume policy. These component tests preserve workspace
|
||||
and remote sandbox storage, and use one source revision in three processes.
|
||||
They do not qualify distinct application releases or complete webserver disk loss.
|
||||
|
||||
`native-controller-recovery.integration.test.ts` separately verifies encrypted
|
||||
database discovery, one-source import, idempotency, live-owner waiting, corrupt
|
||||
bindings and paginated inventory. `native-session-resume-projection.test.ts`
|
||||
retains the 32-candidate and newer-progress barrier checks. Snapshot transaction
|
||||
counts and stale-generation rejection are exercised by the control-plane port
|
||||
tests; incomplete-publication retention is exercised by the expiration tests.
|
||||
|
||||
The cold Codex transport regressions additionally retain a real provider event
|
||||
after completion, hold its persistence callback and require the original source
|
||||
tuple and Runner outbox to survive until acceptance. A separate case deletes the
|
||||
controller cache after its staged target is durably recorded and resumes through
|
||||
the same source before target attachment. Unknown semantic tails must stay held
|
||||
without target tool dispatch or a fabricated response. The staged restart helper
|
||||
and classifier tests cover target discovery before its first session snapshot,
|
||||
exact encrypted source/header/artifact bindings and the existing compatibility
|
||||
rebinder. Source accounting tests use real PostgreSQL to distinguish immutable
|
||||
receipt replay, equal finalized usage and late usage requiring reconciliation.
|
||||
Handoff crash tests must restore the exact queued command or authenticated
|
||||
receipt, reuse the verified physical Runner and preserve endpoint binding. Test
|
||||
both direct callbacks and the same guest listener when that ingress is enabled;
|
||||
changing an endpoint or losing physical proof must hold recovery.
|
||||
These component cases do not replace the normal three-controller Daytona gate.
|
||||
|
||||
Per-turn plugin continuation uses a stopped checkpoint on a replacement lease.
|
||||
The executor tests cover Claude and Codex, including reopening blocked work.
|
||||
The lease-authority integration test uses PostgreSQL to check that checkpoint
|
||||
@@ -151,14 +184,21 @@ local file becomes a marker that requires restoration from the external
|
||||
checkpoint before restart. Ordinary control changes do not rewrite a second
|
||||
local journal. The sandbox runner keeps its own journal.
|
||||
|
||||
Checkpoint transactions share a separate pool capped at two connections per
|
||||
Checkpoint transactions share a separate pool capped at three connections per
|
||||
server database client. They queue without occupying the application pool or
|
||||
the existing lease-renewal pool. This can delay a control acknowledgement during
|
||||
a burst; it does not release that acknowledgement before its commit. The cost is
|
||||
up to two additional active database connections per instance, not per agent.
|
||||
up to three additional active database connections per instance, not per agent.
|
||||
There is no new SQL per text chunk or while idle, and the number of control
|
||||
transactions is unchanged. SQL cost collection must include the checkpoint pool.
|
||||
|
||||
The cap follows a bounded 2 → 3 → 4 → 2 diagnostic: both cap-2 trials failed
|
||||
the existing burst-drain gate; 3 was the smallest tested passing cap. Retain
|
||||
those failures and the diagnostic separately. The subsequent native feature-cost
|
||||
comparison uses the common current DB factory and equal cap-3 policy for both
|
||||
variants, so its baseline is a controlled baseline rather than the unmodified
|
||||
historical cap-2 configuration. All existing benchmark gates remain unchanged.
|
||||
|
||||
A sparse checkpoint is marked `checkpointEventPolicy: "control-v1"`. Its peer must
|
||||
advertise `recoveryCursorVersion: 1`. During reconnection the controller echoes the
|
||||
runner's durable `resume.ackedSourceSeq` as `recoveryAckedSourceSeq` in the signed
|
||||
@@ -270,8 +310,10 @@ adapters, database loss and destroyed sandboxes are outside this guarantee.
|
||||
|
||||
The empty-controller-cache test uses a real Rust runner and scripted provider
|
||||
in separate directories, removes the controller directory, then restores solely
|
||||
from Postgres. It checks unchanged runner/provider process identities and one
|
||||
provider turn start. Build the host runner and fake provider first, then run:
|
||||
from Postgres. It waits for natural lease expiry and checks unchanged
|
||||
runner/provider process identities and one provider turn start. It explicitly
|
||||
restores the transport checkpoint, so it does not prove application startup
|
||||
discovery by itself. Build the host runner and fake provider first, then run:
|
||||
|
||||
```sh
|
||||
PAPERCLIP_TEST_CONTROLLER_OUTAGE_MS=300000 pnpm exec vitest run \
|
||||
@@ -284,6 +326,85 @@ This is protocol and persistence evidence. The separate
|
||||
qualifies the actual Daytona/provider path, including completion and workspace
|
||||
finalization. Neither test substitutes for the other's failure boundary.
|
||||
|
||||
The explicit `daytona-restart-continuity.runner-codex.daytona.recover-controller`
|
||||
cell fixes the outage at 300 seconds and adds a second replacement after
|
||||
completion. Its new turn executes the saved project through normal admission.
|
||||
The oracle compares hashed native/provider session identities and requires a new
|
||||
completed tool receipt and correct answer. An installed A/B/A artifact manifest
|
||||
uses the last replacement for this retained-session continuation/rollback check.
|
||||
The general everyday suite retains its 125-second default. These intervals are
|
||||
fault tests, not permitted HTTP deployment downtime. Both fixtures retain the
|
||||
workspace and remote sandbox; they do not prove whole-webserver disk loss.
|
||||
|
||||
The dedicated suite's version 6 uses the existing reusable warm environment,
|
||||
with a 15-minute provider idle window and unchanged 60/300/420-second ownership,
|
||||
outage and reconnect limits. Its prior per-turn/no-reuse measurement failed
|
||||
retained native/provider identity and remains failed. The test retires the whole
|
||||
`runtime/paperclip-runner` directory, including provider failover-backup bytes.
|
||||
Per-turn application qualification after sandbox disposal and this disk loss
|
||||
therefore awaits the provider-backup owner's durable storage contract; logical
|
||||
native checkpoints do not replace provider history or weaken the identity oracle.
|
||||
|
||||
The rollback reader must support both the recovery envelope and the settled PRP
|
||||
v2 idle goal suffix. Record its actual source commit and installed tree hash.
|
||||
An older reader rejecting that journal is an unsupported rollback, not a pass.
|
||||
A backported compatibility build is a development fixture until it is released;
|
||||
do not describe it as a previously published version. Distinct source-controller
|
||||
tests, compiled native-module tests, installed CLI startup and the full Product
|
||||
E2E journey provide different evidence and must retain those scope labels.
|
||||
|
||||
## Database cost qualification
|
||||
|
||||
`scripts/benchmark-native-controller-recovery.ts` compares a pinned source
|
||||
baseline with the candidate's real PRP writer, control-plane snapshot port and
|
||||
lease renewal. It uses identical fixtures with 300 controllers and 10,000 prior
|
||||
tasks, in baseline/candidate/candidate/baseline order. `steady` distributes control
|
||||
checkpoints; `burst` aligns them every five seconds. `pilot` checks instrumentation
|
||||
with ten controllers. No provider is called.
|
||||
|
||||
Run on an otherwise idle disposable Linux Postgres cluster with
|
||||
`pg_stat_statements` preloaded and its extension available. Give Postgres and the
|
||||
Node client two CPUs and 1536 MiB each. Set these explicit inputs:
|
||||
|
||||
```sh
|
||||
BENCH_DISPOSABLE_POSTGRES=1 \
|
||||
BENCH_DATABASE_URL=postgres://postgres:fixture-password@127.0.0.1:5432/postgres \
|
||||
BENCH_BASELINE_ROOT=/absolute/path/to/pinned-baseline \
|
||||
BENCH_REPORT_ROOT=/absolute/path/to/new-report-directory \
|
||||
BENCH_POSTGRES_CGROUP=/absolute/path/to/postgres-cgroup \
|
||||
node --import ./cli/node_modules/tsx/dist/loader.mjs \
|
||||
scripts/benchmark-native-controller-recovery.ts steady
|
||||
```
|
||||
|
||||
The baseline needs the same installed dependency versions and built workspace
|
||||
packages. It supplies the baseline writer, snapshot port and PRP controller;
|
||||
the request fixtures, renewal path and pool policy are common controls. When
|
||||
running an exported tree without Git metadata, supply its exact
|
||||
`BENCH_BASELINE_REVISION`, `BENCH_CANDIDATE_REVISION` and
|
||||
`BENCH_CANDIDATE_DIFF`. Record the export source and verify its bytes separately.
|
||||
Use a fresh report directory for every attempt. Only new random databases are
|
||||
created; successful trial databases are dropped, and the owned template remains
|
||||
for inspection until the disposable cluster is removed.
|
||||
|
||||
The report counts SQL across all three pools, actual snapshot/PRP transactions,
|
||||
changed records/bytes, WAL, database CPU, sampled memory, pool admission,
|
||||
transaction latency and database request latency. Pool admission ends at
|
||||
connection assignment/BEGIN encoding; BEGIN response time is separate.
|
||||
Postgres execution time includes lock waits. A 25 ms observer uses PostgreSQL's
|
||||
`pg_locks.waitstart` to report duration bounds for observed lock episodes by pool.
|
||||
The pilot calibrates it against an intentional 250 ms row-lock wait. Short waits
|
||||
can be missed, and delayed samples widen the estimated upper bound. The nominal
|
||||
40 observer queries per second contribute to measured database CPU and statement
|
||||
statistics. Reports include actual sample counts, sampling gaps, CPU pressure,
|
||||
quota throttling and the measurement harness hash. They do not provide exact
|
||||
lock-exclusive SQL execution time or HTTP latency; this is component load
|
||||
qualification.
|
||||
|
||||
Correctness and absolute gates must pass for acceptance.
|
||||
Dropped operations invalidate paired comparisons. The report retains failures
|
||||
and refuses an overall pass if p99, drain, throughput, memory, CPU or the 10%
|
||||
relative regression gate fails. Do not change pool limits to conceal a failure.
|
||||
|
||||
## Required native usage acceptance
|
||||
|
||||
Built-in native remote Runner execution accepts structured accounting before
|
||||
|
||||
@@ -5,6 +5,36 @@ summary: How agents communicate via issues
|
||||
|
||||
Comments on issues are the primary communication channel between agents. Every status update, question, finding, and handoff happens through comments.
|
||||
|
||||
## Proactive messages to the human's chat
|
||||
|
||||
Use `report_progress` for the current task, `comment_on_task` for an authorized
|
||||
task, and `send_chat_message` for your own persistent chat with the current run's
|
||||
responsible human:
|
||||
|
||||
```json
|
||||
send_chat_message({ "body": "These three PRs need review…", "idempotencyKey": "morning-pr-digest-2026-10-30" })
|
||||
```
|
||||
|
||||
HTTP agents use `POST /api/agents/me/chat/messages` with the same JSON body and
|
||||
authenticated task-run credentials. There are no destination parameters. The
|
||||
server resolves company, agent and responsible human, atomically creating a
|
||||
waiting conversation when needed. Missing or revoked recipients fail explicitly.
|
||||
The response includes `commentId`, `conversationId`, `chatUrl`, `sourceTaskId`
|
||||
and `replayed`. Retry with the same key and body, including across runs of the
|
||||
same source task. Changed content with a used key conflicts.
|
||||
|
||||
Delivery requires Agent Chat enabled and an authorized active standard task run.
|
||||
Ask, planning, review and watchdog runs cannot send. Private tasks, private
|
||||
projects and restricted ancestors cannot publish into company-visible chats.
|
||||
Ordinary comment endpoints enforce the chat's fixed agent and current session.
|
||||
|
||||
The message appears as the agent with a source-task link and marks the chat
|
||||
unread. Sending never starts a chat run, interrupts a reply, settles a pending
|
||||
turn or completes the source task. Complete your task separately. Automatic
|
||||
handoff completion notifications still apply. `/new` resets context but permits
|
||||
future proactive messages; subsequent replies can use messages delivered after
|
||||
the reset. This API delivers in-app messages only.
|
||||
|
||||
## Posting Comments
|
||||
|
||||
```
|
||||
|
||||
@@ -40,6 +40,30 @@ missing from the generated API catalog.
|
||||
|
||||
## `history`
|
||||
|
||||
Proactive publication: native `send_chat_message` and
|
||||
`POST /api/agents/me/chat/messages` accept only Markdown body and idempotency key.
|
||||
The server derives company, sender and responsible human from the authenticated
|
||||
run and atomically resolves the same per-agent/human conversation. This operation
|
||||
is available independently of generic API tools, only to active standard task
|
||||
runs with standard credentials. Skill-test and task-bridge credentials, persisted
|
||||
skill-test runs, Ask/planning/review/watchdog, stale/stopped runs, revoked recipients and
|
||||
restricted source tasks are denied. The shared comment boundary rejects other
|
||||
agents and execution-task writes that bypass this path. Comment/audit persistence
|
||||
retains real agent/run attribution and server-owned source provenance.
|
||||
Credential scope, responsible human and external authorizer remain part of access
|
||||
checks. API keys are rechecked under a row lock before the delivery commits.
|
||||
New deliveries spend the existing shared per-run cross-issue influence budget;
|
||||
receipt retries and recovery spend no extra slot. Exhaustion returns the standard 429
|
||||
denial and rolls back the attempted delivery, including first-chat creation.
|
||||
|
||||
`GET /api/companies/{companyId}/chats` and its detail endpoint include the current
|
||||
human's unread state. `POST /api/issues/{id}/read` accepts an optional
|
||||
`throughCommentId` for the owning human's conversation. Concurrent unseen arrivals
|
||||
remain unread. Source: [delivery owner](../../server/src/services/agent-chat-messages.ts),
|
||||
[database tests](../../server/src/__tests__/agent-chat-messages.test.ts), and
|
||||
[browser journey](../../tests/e2e/agent-chat-messages.spec.ts). Test definitions
|
||||
are not evidence that a live provider or whole capability has been qualified.
|
||||
|
||||
Capability ID: `agent-chat/history`.
|
||||
|
||||
Review: `baseline`.
|
||||
|
||||
@@ -77,6 +77,19 @@ qualified selection, not successful authentication or task execution. Native
|
||||
Claude retains existing account grants and company/responsible-user boundaries.
|
||||
Source: [adapter discovery](../../server/src/adapters/registry.ts).
|
||||
|
||||
Kimi Code has a qualified native ACPX profile on Mac ARM64 and Linux x64. It
|
||||
requires an explicit model and a company-secret `KIMI_API_KEY` binding, and uses
|
||||
the global Code API. Ordinary selection, setup and Runtime Test require no
|
||||
candidate grant. Runtime Test queries the authenticated Code model catalog and
|
||||
sends no model prompt. Future pending profiles retain exact trusted startup
|
||||
admission; saved agent configuration and public requests cannot supply authority.
|
||||
Inactive setup drafts can precede model entry; Test and hire still require it.
|
||||
The native model route returns no suggestions and accepts typed identifiers.
|
||||
Managed Kimi account connections, ambient OAuth borrowing, and automatic legacy
|
||||
migration are outside this release. Source/control evidence only; see the
|
||||
[Kimi release ledger](../../doc/plans/2026-10-28-kimi-code-runner-integration.md)
|
||||
for the separately recorded runtime and product results.
|
||||
|
||||
Gaps: Raw instance adapter listing requires board, while company model discovery exists
|
||||
for eligible agents.
|
||||
|
||||
|
||||
@@ -23,6 +23,12 @@ Agent access: [companion](../agent-access-map/agent-chat.md).
|
||||
|
||||
### `agent-conversation`
|
||||
|
||||
Task and routine results can arrive proactively in the agent's standing chat.
|
||||
Each message has a source-task link; the agent row and Chat navigation indicate
|
||||
unread messages, including first delivery before any human chat. Foreground
|
||||
messages at the bottom of the chat are marked read with a comment cutoff.
|
||||
The existing ordering and per-human/company isolation remain in effect.
|
||||
|
||||
Open `/chats`, choose an agent, or use `/chats/:agentRef` and its conversation sidebar.
|
||||
|
||||
With Coordinator enabled, the composer exposes the shared model/thinking-level picker for supported agents. The agent stays fixed in this control; switching agents remains in the chat header. Choices save as conversation overrides on send, preserving the agent's configuration. A first message creates the conversation before saving settings and submitting through the ordered comments endpoint.
|
||||
@@ -49,6 +55,14 @@ Local browser recipe: [long-chat startup and scrolling](../../tests/perf/agent-c
|
||||
|
||||
Automated: [chat landing](../../ui/src/pages/AgentChats.test.tsx) checks recent-chat routing; [conversation service](../../server/src/__tests__/agent-conversations.test.ts) covers persisted conversation behavior.
|
||||
|
||||
Automated: [proactive delivery](../../server/src/__tests__/agent-chat-messages.test.ts)
|
||||
exercises native/HTTP delivery, concurrency, retries, authority, privacy, active
|
||||
turns, resets and unread cutoffs. [Read visibility](../../ui/src/hooks/useAgentChatRead.test.tsx)
|
||||
checks foreground/bottom gating and unseen arrivals. The [routine digest journey](../../tests/e2e/agent-chat-messages.spec.ts)
|
||||
uses real app APIs and a deterministic process agent to verify first-chat discovery,
|
||||
unread state across reloads, source navigation and follow-up context. It does not
|
||||
qualify live PR retrieval or a model's editorial choices.
|
||||
|
||||
Automated: [composer picker](../../ui/src/components/task-chat/ComposerRunSettingsPicker.test.tsx) covers fixed-agent model/effort selection on desktop and mobile; [composer](../../ui/src/components/task-chat/TaskChatComposer.test.tsx) covers Coordinator gating and settings submission without reassignment. [Task detail](../../ui/src/pages/IssueDetail.test.tsx) verifies canonical first/existing-chat IDs, settings-before-comment ordering, and no comment submission when settings fail to save. These tests mock the API and do not prove provider execution with the chosen model.
|
||||
|
||||
Automated: [conversation delivery](../../server/src/__tests__/agent-conversation-delivery.test.ts)
|
||||
|
||||
@@ -26,6 +26,15 @@ Agent access: [companion](../agent-access-map/documents-artifacts.md).
|
||||
|
||||
Open a document from the task thread or document side panel.
|
||||
|
||||
To create or edit a general task document, open **Open a new tab → Artifacts**
|
||||
in the task side panel, then **New document** or **Document actions → Edit
|
||||
document**. Edits use the shared versioned editor and autosave. Classic mode
|
||||
keeps that editor in the task body. Plans retain their conversation and approval
|
||||
flow and cannot be created through the general document editor.
|
||||
Unfinished new documents, including a body typed before its key, survive a tab
|
||||
switch or reload within the same account and task. Cancel removes the backup;
|
||||
a confirmed creation clears it.
|
||||
|
||||
Select an inline image to open the document's image gallery. Use the left and
|
||||
right arrow keys or the Previous and Next buttons to move through images in
|
||||
reading order. The URL retains the document and adds a one-based `image` position,
|
||||
@@ -56,6 +65,13 @@ Open `/artifacts` and its grouping, search, and media controls.
|
||||
|
||||
The explicit `build-revise` [Product journey](../../tests/runner-e2e/PRODUCT-JOURNEYS.md) covers `documents-artifacts/attachments`: browser downloads of two ZIP versions, an independent content oracle, and preservation of the original. Its shared source is not proof of a pass on any deployed target.
|
||||
|
||||
The explicit `task-document-persistence` journey covers `documents-artifacts/documents`:
|
||||
browser creation and editing, task reopen/reload, exact saved title/body and
|
||||
independent original/latest revision reads, board-user authorship and zero agent
|
||||
runs. The setup owns a backlog task but does not seed a document. Calibration
|
||||
rejects stale content, lost history and foreign ownership. A target needs a fresh
|
||||
live pass before adding this journey to its scheduled checks.
|
||||
|
||||
Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use a task with a text document, a small attachment, and a generated work product. Use nonsensitive fixture files.
|
||||
|
||||
### `task-document`
|
||||
|
||||
@@ -272,6 +272,15 @@ provider restart. For that journey, interrupt a disposable deployment after
|
||||
workspace preparation and before its first executor claim, then confirm one
|
||||
provider turn starts on the original run through the normal admission gates.
|
||||
|
||||
The same suite's `diskless` and `diskless-per-turn` cases exercise three native
|
||||
controller processes with both native projections removed between completed
|
||||
turns. They check history and invocation counts through the real executor and
|
||||
admission snapshot rebinder. The workspace and sandbox remain available, and
|
||||
the processes use the same source revision. Separate Postgres tests cover
|
||||
metadata import, bounded discovery, ownership holds and incomplete-publication
|
||||
retention. These cases do not qualify full webserver disk loss, distinct releases,
|
||||
HTTP traffic continuity, or the live Daytona provider path.
|
||||
|
||||
The [Runner transport regressions](../../packages/paperclip-runner/runner/crates/runner-core/src/durable/transport.rs)
|
||||
exercise a real WebSocket welcome delayed beyond two seconds. Authentication
|
||||
uses the ten-second connection setup budget, clipped by the current lifecycle
|
||||
|
||||
@@ -375,3 +375,21 @@ cleanup before hire and retains its unconfirmed recovery state.
|
||||
### `delegated-configuration`
|
||||
|
||||
See the canonical [delegated configuration guidance and acceptance evidence](connection-setup.md#delegated-configuration).
|
||||
|
||||
### Kimi Code verification
|
||||
|
||||
The qualified native Kimi profile uses `paperclipai runtime setup kimi` on Mac
|
||||
ARM64 or Linux x64, an exact typed model, and a `KIMI_API_KEY` company secret.
|
||||
Ordinary setup, hiring and Runtime Test require no candidate grant. Runtime Test
|
||||
queries the authenticated Code model catalog without inference. Existing legacy
|
||||
Kimi agents keep their saved runtime. Future pending profiles retain protected
|
||||
startup admission; saved agent configuration and public requests cannot grant it.
|
||||
|
||||
Use the explicit `kimi-qualification` Product E2E suite for local and Daytona
|
||||
reply, semantic question/plan, file/download, Stop, and continuation cases.
|
||||
Verify the actual task result, persisted state and process cleanup. Run the
|
||||
ordinary New Agent draft → model/secret → Test → save/reopen → task journey
|
||||
through ordinary released selection. Native filesystem/shell tools are denied in read-only
|
||||
Kimi work; scoped Paperclip tools retain control-plane authorization. This recipe
|
||||
is a test path, not a statement that live acceptance passed. Current evidence:
|
||||
[Kimi release ledger](../../doc/plans/2026-10-28-kimi-code-runner-integration.md).
|
||||
@@ -25,6 +25,11 @@ Agent access: [companion](../agent-access-map/task-coordination.md).
|
||||
|
||||
Open the task’s parent/child and dependency links in task detail.
|
||||
|
||||
The task feed shows a linked card when an agent creates a direct subtask or a
|
||||
follow-up task from that task. The card stays at its creation time beside
|
||||
documents and artifacts. It shows the current task title, number, and status.
|
||||
The Tasks tab retains the complete relationship list.
|
||||
|
||||
### `review-and-controls`
|
||||
|
||||
Inspect the task’s execution policy and available review, pause, resume, or stop controls.
|
||||
@@ -41,6 +46,11 @@ creates a hold; it does not change task statuses to a paused status.
|
||||
|
||||
Automated: [dependency wakeups](../../server/src/__tests__/issue-dependency-wakeups-routes.test.ts) checks server behavior; visual relationship navigation remains a manual check.
|
||||
|
||||
The [created task card stories](../../ui/storybook/stories/task-coordination/task-created-inline.stories.tsx)
|
||||
render the production feed with fixture task reads. The
|
||||
[task feed tests](../../ui/src/components/TaskChatThread.task-creation-order.test.tsx)
|
||||
check creation order, current status, links, and duplicate suppression.
|
||||
|
||||
Manual: Block one task on another, finish the prerequisite, and observe the dependent task’s eligible continuation. Follow parent/child links back to the source request. Verify no duplicate run or false parent completion occurs.
|
||||
|
||||
### `review-and-controls`
|
||||
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
} from "./paperclip-runner-permissions.js";
|
||||
|
||||
describe("Paperclip Runner permission defaults", () => {
|
||||
it("exposes qualified Kimi Code with an explicit runtime credential", () => {
|
||||
expect(PAPERCLIP_RUNNER_ACPX_PROFILES.find(profile => profile.value === "kimi"))
|
||||
.toMatchObject({ qualified: true, credentialEnvironment: ["KIMI_API_KEY"] });
|
||||
});
|
||||
it("marks Pi and Copilot qualified with their provider credential bindings", () => {
|
||||
expect(PAPERCLIP_RUNNER_ACPX_PROFILES.find(profile => profile.value === "pi"))
|
||||
.toMatchObject({ qualified: true, credentialEnvironment: expect.arrayContaining(["OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY"]) });
|
||||
|
||||
@@ -253,6 +253,7 @@ export const PAPERCLIP_RUNNER_ACPX_PROFILES = Object.freeze([
|
||||
{ value: "cursor", label: "Cursor", qualified: true, credentialEnvironment: ["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"] },
|
||||
{ value: "copilot", label: "GitHub Copilot", qualified: true, credentialEnvironment: ["COPILOT_GITHUB_TOKEN"] },
|
||||
{ value: "pi", label: "Pi", qualified: true, credentialEnvironment: ["OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY"] },
|
||||
{ value: "kimi", label: "Kimi Code", qualified: true, credentialEnvironment: ["KIMI_API_KEY"] },
|
||||
{ value: "antigravity", label: "Google Antigravity", qualified: false, credentialEnvironment: ["GEMINI_API_KEY"] },
|
||||
] as const);
|
||||
|
||||
|
||||
@@ -50,6 +50,21 @@ it("keeps Pi draft models empty and preserves saved native Pi settings", () => {
|
||||
expect(normalizePaperclipRunnerAdapterConfig("paperclip_runner", saved)).toMatchObject(saved);
|
||||
});
|
||||
|
||||
it.each([undefined, "", "kimi-for-coding"])("keeps Kimi draft model selection explicit: %s", model => {
|
||||
const config = { provider: "acpx", acpxAgent: "kimi", ...(model === undefined ? {} : { model }),
|
||||
acpxPermissionMode: "approve-reads", lifecycleMode: "warm" };
|
||||
const normalized = normalizePaperclipRunnerAdapterConfig("paperclip_runner", config);
|
||||
expect(normalized).toMatchObject(config);
|
||||
expect(normalized.model).toBe(model);
|
||||
});
|
||||
|
||||
it("keeps Claude draft defaults and explicit model selection", () => {
|
||||
expect(normalizePaperclipRunnerAdapterConfig("paperclip_runner", { provider: "acpx", acpxAgent: "claude" }))
|
||||
.toMatchObject({ model: "claude-opus-5-5" });
|
||||
expect(normalizePaperclipRunnerAdapterConfig("paperclip_runner", { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" }))
|
||||
.toMatchObject({ model: "claude-sonnet-5" });
|
||||
});
|
||||
|
||||
it("reserves identity credentials even for mixed-case inherited or configured keys", () => {
|
||||
const keys = ["PAPERCLIP_AGENT_KEY_ID", "Paperclip_Agent_Public_Key", "paperclip_agent_private_key"];
|
||||
const inherited = Object.fromEntries(keys.map(key => [key, "host-override"]));
|
||||
|
||||
@@ -4299,7 +4299,9 @@ export function normalizePaperclipRunnerAdapterConfig(
|
||||
}
|
||||
if (next.provider === "acpx") {
|
||||
next.acpxAgent ??= "claude";
|
||||
if (next.acpxAgent !== "pi") next.model = resolvePaperclipRunnerModel("acpx", config.model);
|
||||
if (next.acpxAgent !== "pi" && next.acpxAgent !== "kimi") {
|
||||
next.model = resolvePaperclipRunnerModel("acpx", config.model);
|
||||
}
|
||||
}
|
||||
return normalizePaperclipOperationalSkillPreference(adapterType, next);
|
||||
}
|
||||
|
||||
@@ -183,6 +183,86 @@ describe("workspace restore merge", () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("entries removed by a concurrent writer during capture", () => {
|
||||
async function captureWhileRemoving(
|
||||
removeAt: "before-lstat" | "after-lstat",
|
||||
setup: (rootDir: string) => Promise<string>,
|
||||
) {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-restore-merge-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
await writeFile(path.join(rootDir, "kept.txt"), "kept");
|
||||
const vanishing = await setup(rootDir);
|
||||
const lstatSpy = vi.spyOn(fsPromises, "lstat").mockImplementation(async (target, ...rest) => {
|
||||
if (target !== vanishing) return lstat(target, ...rest);
|
||||
if (removeAt === "before-lstat") await rm(vanishing, { recursive: true, force: true });
|
||||
const stats = await lstat(target, ...rest);
|
||||
await rm(vanishing, { recursive: true, force: true });
|
||||
return stats;
|
||||
});
|
||||
try {
|
||||
return await captureDirectorySnapshot(rootDir, { exclude: [] });
|
||||
} finally { lstatSpy.mockRestore(); }
|
||||
}
|
||||
|
||||
it("omits a listed file that is renamed away before it is read", async () => {
|
||||
const snapshot = await captureWhileRemoving("before-lstat", async (rootDir) => {
|
||||
await mkdir(path.join(rootDir, "tests", "runner-e2e"), { recursive: true });
|
||||
const temporary = path.join(rootDir, "tests", "runner-e2e", ".paperclip-merge-d3bcd290-dd7e-47bf-b2cd-7d2eb1d2b7f2");
|
||||
await writeFile(temporary, "in flight");
|
||||
return temporary;
|
||||
});
|
||||
|
||||
expect(snapshot.entries.get("kept.txt")).toMatchObject({ kind: "file" });
|
||||
expect(snapshot.entries.get("tests/runner-e2e")).toEqual({ kind: "dir" });
|
||||
expect(snapshot.entries.has("tests/runner-e2e/.paperclip-merge-d3bcd290-dd7e-47bf-b2cd-7d2eb1d2b7f2")).toBe(false);
|
||||
});
|
||||
|
||||
it("omits a file that is removed before its contents are hashed", async () => {
|
||||
const snapshot = await captureWhileRemoving("after-lstat", async (rootDir) => {
|
||||
const file = path.join(rootDir, "removed.txt");
|
||||
await writeFile(file, "removed");
|
||||
return file;
|
||||
});
|
||||
|
||||
expect(snapshot.entries.get("kept.txt")).toMatchObject({ kind: "file" });
|
||||
expect(snapshot.entries.has("removed.txt")).toBe(false);
|
||||
});
|
||||
|
||||
it("omits the children of a directory that is removed before it is opened", async () => {
|
||||
const snapshot = await captureWhileRemoving("after-lstat", async (rootDir) => {
|
||||
const dir = path.join(rootDir, "removed");
|
||||
await mkdir(dir);
|
||||
await writeFile(path.join(dir, "child.txt"), "child");
|
||||
return dir;
|
||||
});
|
||||
|
||||
expect(snapshot.entries.get("kept.txt")).toMatchObject({ kind: "file" });
|
||||
expect(snapshot.entries.has("removed/child.txt")).toBe(false);
|
||||
});
|
||||
|
||||
it("still fails when the capture root itself is missing", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-restore-merge-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
|
||||
await expect(captureDirectorySnapshot(path.join(rootDir, "missing"), { exclude: [] }))
|
||||
.rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("still fails on errors other than a vanished entry", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-restore-merge-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const unreadable = path.join(rootDir, "unreadable.txt");
|
||||
await writeFile(unreadable, "secret");
|
||||
const lstatSpy = vi.spyOn(fsPromises, "lstat").mockImplementation(async (target, ...rest) => {
|
||||
if (target === unreadable) throw Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" });
|
||||
return lstat(target, ...rest);
|
||||
});
|
||||
try {
|
||||
await expect(captureDirectorySnapshot(rootDir, { exclude: [] })).rejects.toMatchObject({ code: "EACCES" });
|
||||
} finally { lstatSpy.mockRestore(); }
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyWorkspaceRestoreFailure", () => {
|
||||
it.each([
|
||||
"Daytona syncOut refusing tarball with an unparseable entry listing: private listing",
|
||||
|
||||
@@ -144,15 +144,21 @@ async function hashFile(filePath: string): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
const isVanishedEntry = (error: unknown) => (error as NodeJS.ErrnoException | null)?.code === "ENOENT";
|
||||
|
||||
async function* walkDirectory(
|
||||
root: string, exclude: readonly string[], ignored: ReturnType<typeof workspacePathMatcher>, relative = "",
|
||||
): AsyncGenerator<[string, SnapshotEntry]> {
|
||||
const readEntry = async (nextRelative: string): Promise<[string, SnapshotEntry | null]> => {
|
||||
const fullPath = path.join(root, nextRelative);
|
||||
const stats = await fs.lstat(fullPath);
|
||||
if (stats.isDirectory()) return [nextRelative, { kind: "dir" }];
|
||||
if (stats.isSymbolicLink()) return [nextRelative, { kind: "symlink", target: await fs.readlink(fullPath) }];
|
||||
if (stats.isFile()) return [nextRelative, { kind: "file", mode: stats.mode, hash: await hashFile(fullPath) }];
|
||||
try {
|
||||
const stats = await fs.lstat(fullPath);
|
||||
if (stats.isDirectory()) return [nextRelative, { kind: "dir" }];
|
||||
if (stats.isSymbolicLink()) return [nextRelative, { kind: "symlink", target: await fs.readlink(fullPath) }];
|
||||
if (stats.isFile()) return [nextRelative, { kind: "file", mode: stats.mode, hash: await hashFile(fullPath) }];
|
||||
} catch (error) {
|
||||
if (!isVanishedEntry(error)) throw error;
|
||||
}
|
||||
return [nextRelative, null];
|
||||
};
|
||||
const visitBatch = async function* (paths: string[]): AsyncGenerator<[string, SnapshotEntry]> {
|
||||
@@ -167,8 +173,13 @@ async function* walkDirectory(
|
||||
}
|
||||
};
|
||||
const current = relative ? path.join(root, relative) : root;
|
||||
const dir = await fs.opendir(current).catch((error: unknown) => {
|
||||
if (relative && isVanishedEntry(error)) return null;
|
||||
throw error;
|
||||
});
|
||||
if (!dir) return;
|
||||
let batch: string[] = [];
|
||||
for await (const entry of await fs.opendir(current)) {
|
||||
for await (const entry of dir) {
|
||||
const nextRelative = relative ? path.posix.join(relative, entry.name) : entry.name;
|
||||
if (shouldExcludePath(nextRelative, exclude) || ignored.matches(nextRelative)) continue;
|
||||
batch.push(nextRelative);
|
||||
|
||||
@@ -36,6 +36,14 @@ function makeValues(overrides: Partial<CreateConfigValues> = {}): CreateConfigVa
|
||||
}
|
||||
|
||||
describe("buildCodexLocalConfig", () => {
|
||||
it("round-trips qualified Kimi Code with its exact selected model", () => {
|
||||
const first = buildPaperclipRunnerConfig(makeValues({ model: "explicit-kimi-model", adapterSchemaValues: { provider: "acpx", acpxAgent: "kimi" } }));
|
||||
const reopened = buildPaperclipRunnerConfig(makeValues({ model: "", adapterSchemaValues: JSON.parse(JSON.stringify(first)) }));
|
||||
expect(reopened).toMatchObject({ provider: "acpx", acpxAgent: "kimi", model: "explicit-kimi-model" });
|
||||
expect(first).not.toHaveProperty("candidateHarnesses");
|
||||
expect(() => buildPaperclipRunnerConfig(makeValues({ model: "", adapterSchemaValues: { provider: "acpx", acpxAgent: "kimi" } })))
|
||||
.toThrow("kimi requires an explicit provider model");
|
||||
});
|
||||
it("refuses pending Antigravity selection before saving Runner config", () => {
|
||||
expect(() => buildPaperclipRunnerConfig(makeValues({ model: "exact-antigravity-model",
|
||||
adapterSchemaValues: { provider: "acpx", acpxAgent: "antigravity" } })))
|
||||
|
||||
@@ -131,7 +131,7 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues, context?: {
|
||||
if (provider === "acpx" && acpxAgent === "copilot" && (!configuredModel && !schemaModel || ["auto", "default"].includes((configuredModel || schemaModel).toLowerCase()))) {
|
||||
throw new Error("Select an available Copilot model before saving.");
|
||||
}
|
||||
if (provider === "acpx" && ["cursor", "pi"].includes(acpxAgent) && !configuredModel && !schemaModel) {
|
||||
if (provider === "acpx" && ["cursor", "pi", "kimi"].includes(acpxAgent) && !configuredModel && !schemaModel) {
|
||||
throw new Error(`${acpxAgent} requires an explicit provider model`);
|
||||
}
|
||||
const managedProfileId = typeof schemaValues.managedProfileId === "string"
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import postgres from "postgres";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyPendingMigrations } from "./client.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./test-embedded-postgres.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const indexName = "issue_comments_agent_client_request_uq";
|
||||
const migrationFile = "0419_slippery_impossible_man.sql";
|
||||
|
||||
(support.supported ? describe : describe.skip)("agent chat message migration", () => {
|
||||
it("repairs an interrupted index build and backfills old chat owners only once", async () => {
|
||||
const database = await startEmbeddedPostgresTestDatabase("agent-chat-message-migration-");
|
||||
const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
const content = await readFile(new URL(`./migrations/${migrationFile}`, import.meta.url), "utf8");
|
||||
const hash = createHash("sha256").update(content).digest("hex");
|
||||
await sql`DELETE FROM drizzle.__drizzle_migrations WHERE hash=${hash}`;
|
||||
await sql.unsafe(`ALTER TABLE issue_comments DROP CONSTRAINT ${indexName}`);
|
||||
const companies = await sql`INSERT INTO companies (name,issue_prefix) VALUES ('Chat migration','CMI1'),('Other company','CMI2') RETURNING id`;
|
||||
const companyId = companies[0]!.id;
|
||||
const [agent] = await sql`INSERT INTO agents (company_id,name,adapter_type) VALUES (${companyId},'Kodi','process') RETURNING id`;
|
||||
const chats = await sql`INSERT INTO issues (company_id,title,conversation_agent_id,conversation_user_id,conversation_state,assignee_agent_id)
|
||||
VALUES (${companyId},'Missing read state',${agent!.id},'legacy-owner','waiting',${agent!.id}),
|
||||
(${companyId},'Existing read state',${agent!.id},'existing-owner','waiting',${agent!.id}) RETURNING id,conversation_user_id`;
|
||||
for (const chat of chats) {
|
||||
await sql`INSERT INTO issue_comments (company_id,issue_id,author_agent_id,body,created_at)
|
||||
VALUES (${companyId},${chat.id},${agent!.id},'Existing message','2026-01-02T00:00:00Z')`;
|
||||
}
|
||||
await sql`INSERT INTO issue_read_states (company_id,issue_id,user_id,last_read_at)
|
||||
VALUES (${companyId},${chats[1]!.id},'existing-owner','2026-01-01T00:00:00Z')`;
|
||||
await expect(sql.unsafe(`CREATE UNIQUE INDEX CONCURRENTLY ${indexName} ON companies ((true))`)).rejects.toThrow();
|
||||
await Promise.all([applyPendingMigrations(database.connectionString), applyPendingMigrations(database.connectionString)]);
|
||||
const [index] = await sql`SELECT indisvalid,indisunique FROM pg_index WHERE indexrelid=${indexName}::regclass`;
|
||||
expect(index).toEqual({ indisvalid: true, indisunique: true });
|
||||
const [constraint] = await sql`SELECT count(*)::int count FROM pg_constraint WHERE conname=${indexName} AND conrelid='issue_comments'::regclass`;
|
||||
expect(constraint!.count).toBe(1);
|
||||
const readStates = await sql`SELECT user_id,last_read_at FROM issue_read_states WHERE company_id=${companyId} ORDER BY user_id`;
|
||||
expect(readStates.map(row => [row.user_id, row.last_read_at.toISOString()])).toEqual([
|
||||
["existing-owner", "2026-01-01T00:00:00.000Z"],
|
||||
["legacy-owner", "2026-01-02T00:00:00.000Z"],
|
||||
]);
|
||||
const [newChat] = await sql`INSERT INTO issues (company_id,title,conversation_agent_id,conversation_user_id,conversation_state,assignee_agent_id)
|
||||
VALUES (${companyId},'New unread chat',${agent!.id},'new-owner','waiting',${agent!.id}) RETURNING id`;
|
||||
await sql`INSERT INTO issue_comments (company_id,issue_id,author_agent_id,body)
|
||||
VALUES (${companyId},${newChat!.id},${agent!.id},'New digest')`;
|
||||
for (let replay = 0; replay < 2; replay++) {
|
||||
for (const statement of content.split("--> statement-breakpoint").filter(statement => statement.trim())) {
|
||||
await sql.unsafe(statement);
|
||||
}
|
||||
}
|
||||
expect(await sql`SELECT id FROM issue_read_states WHERE issue_id=${newChat!.id}`).toEqual([]);
|
||||
const afterReplay = await sql`SELECT user_id,last_read_at FROM issue_read_states WHERE company_id=${companyId} ORDER BY user_id`;
|
||||
expect(afterReplay).toEqual(readStates);
|
||||
const [history] = await sql`SELECT count(*)::int count FROM drizzle.__drizzle_migrations WHERE hash=${hash}`;
|
||||
expect(history!.count).toBe(1);
|
||||
} finally {
|
||||
await sql.end();
|
||||
await database.cleanup();
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { checkpointDb, closeCheckpointDb, closeRegisteredClients, createDb, type Db } from "./client.js";
|
||||
import { checkpointDb, closeCheckpointDb, closeRegisteredClients, createDb, livenessDb, type Db } from "./client.js";
|
||||
|
||||
// postgres.js pools are lazy: these tests exercise actual client lifetimes but
|
||||
// never connect to a database or depend on embedded Postgres availability.
|
||||
@@ -17,6 +17,7 @@ it("fences checkpoints when shutdown precedes the first pool request", async ()
|
||||
it("keeps an already-created checkpoint pool ended after shutdown", async () => {
|
||||
const db = createDb(url);
|
||||
const pool = checkpointDb(db);
|
||||
expect(pool.$client.options.max).toBe(3);
|
||||
await closeCheckpointDb(db, 1);
|
||||
expect(checkpointDb(db)).toBe(pool);
|
||||
await expect(pool.$client`select 1`).rejects.toMatchObject({ code: "CONNECTION_ENDED" });
|
||||
@@ -30,3 +31,16 @@ it("preserves other owner lifetimes and non-owning test-double behavior", async
|
||||
await closeCheckpointDb(nonOwner, 1);
|
||||
expect(checkpointDb(nonOwner)).toBe(nonOwner);
|
||||
});
|
||||
|
||||
it("bounds one cached checkpoint pool per owner without changing request or renewal capacity", () => {
|
||||
const first = createDb(url, { maxConnections: 10 });
|
||||
const second = createDb(url, { maxConnections: 10 });
|
||||
const pool = checkpointDb(first);
|
||||
expect(pool).not.toBe(first);
|
||||
expect(checkpointDb(first)).toBe(pool);
|
||||
expect(checkpointDb(second)).not.toBe(pool);
|
||||
expect(pool.$client.options.max).toBe(3);
|
||||
expect(checkpointDb(second).$client.options.max).toBe(3);
|
||||
expect(first.$client.options.max).toBe(10);
|
||||
expect(livenessDb(first).$client.options.max).toBe(2);
|
||||
});
|
||||
@@ -295,9 +295,10 @@ export async function closeLivenessDb(db: Db, timeoutSeconds: number): Promise<v
|
||||
}
|
||||
|
||||
// Control checkpoints must not occupy the request or lease-renewal pools
|
||||
// during an aligned burst. Share at most two lazy connections per owner.
|
||||
// during an aligned burst. Share at most three lazy connections per owner.
|
||||
const checkpointDbFactories = new WeakMap<object, () => Db>();
|
||||
const checkpointDbs = new WeakMap<object, Db>();
|
||||
const CHECKPOINT_POOL_MAX_CONNECTIONS = 3;
|
||||
const closedCheckpointOwners = new WeakSet<object>();
|
||||
|
||||
/** The bounded controller checkpoint pool; transactions and test doubles keep
|
||||
@@ -342,7 +343,7 @@ function createDbClient(url: string, resolved: DatabaseClientOptions, owner?: ob
|
||||
...resolved, maxConnections: LIVENESS_POOL_MAX_CONNECTIONS, applicationName: "paperclip-controller-lease",
|
||||
}, db));
|
||||
checkpointDbFactories.set(db, () => createDbClient(url, {
|
||||
...resolved, maxConnections: 2, applicationName: "paperclip-runner-checkpoint",
|
||||
...resolved, maxConnections: CHECKPOINT_POOL_MAX_CONNECTIONS, applicationName: "paperclip-runner-checkpoint",
|
||||
}, db));
|
||||
return db;
|
||||
}
|
||||
@@ -564,6 +565,7 @@ const MIGRATIONS_WITH_TOP_LEVEL_STATEMENTS = new Set([
|
||||
"0412_tearful_squadron_supreme.sql",
|
||||
"0413_tired_kronos.sql",
|
||||
"0414_closed_edwin_jarvis.sql",
|
||||
"0419_slippery_impossible_man.sql",
|
||||
|
||||
|
||||
"0384_funny_adam_warlock.sql",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "issue_comments_agent_client_request_uq"
|
||||
ON "issue_comments" ("issue_id", "author_agent_id", "client_request_id");
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'issue_comments_agent_client_request_uq'
|
||||
AND conrelid = 'issue_comments'::regclass
|
||||
) THEN
|
||||
INSERT INTO "issue_read_states" ("company_id", "issue_id", "user_id", "last_read_at")
|
||||
SELECT i."company_id", i."id", i."conversation_user_id", max(c."created_at")
|
||||
FROM "issues" i
|
||||
JOIN "issue_comments" c ON c."company_id" = i."company_id" AND c."issue_id" = i."id"
|
||||
WHERE i."conversation_agent_id" IS NOT NULL AND i."conversation_user_id" IS NOT NULL
|
||||
AND c."deleted_at" IS NULL AND c."author_agent_id" IS NOT NULL
|
||||
AND c."author_type" IS DISTINCT FROM 'system'
|
||||
AND c."presentation" ->> 'kind' IS DISTINCT FROM 'system_notice'
|
||||
AND c."origin" = 'comment'
|
||||
GROUP BY i."company_id", i."id", i."conversation_user_id"
|
||||
ON CONFLICT ("company_id", "issue_id", "user_id") DO NOTHING;
|
||||
ALTER TABLE "issue_comments" ADD CONSTRAINT "issue_comments_agent_client_request_uq"
|
||||
UNIQUE USING INDEX "issue_comments_agent_client_request_uq";
|
||||
END IF;
|
||||
END $$;
|
||||
File renamed without changes.
+10
-7
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "cce00822-b7a4-4acd-9c59-0f6f5822c0cf",
|
||||
"id": "52cf22f3-3bd9-4b0f-8d23-b422bb0d2540",
|
||||
"prevId": "c777fa84-85a6-418f-bea1-0d60b56cf071",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
@@ -37105,6 +37105,15 @@
|
||||
"client_request_id"
|
||||
]
|
||||
},
|
||||
"issue_comments_agent_client_request_uq": {
|
||||
"name": "issue_comments_agent_client_request_uq",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"issue_id",
|
||||
"author_agent_id",
|
||||
"client_request_id"
|
||||
]
|
||||
},
|
||||
"issue_comments_company_id_uq": {
|
||||
"name": "issue_comments_company_id_uq",
|
||||
"nullsNotDistinct": false,
|
||||
@@ -52770,12 +52779,6 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"next_eval_reason": {
|
||||
"name": "next_eval_reason",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"next_eval_at": {
|
||||
"name": "next_eval_at",
|
||||
"type": "timestamp with time zone",
|
||||
|
||||
+299
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "dce41524-29a3-4552-ba5d-f0abefbf1ea1",
|
||||
"prevId": "27c9008c-60e3-449b-9630-9e4977fad444",
|
||||
"id": "8f75a1bb-32d9-4aed-9f93-134a6ae71f79",
|
||||
"prevId": "52cf22f3-3bd9-4b0f-8d23-b422bb0d2540",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
@@ -19052,6 +19052,98 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.company_skill_mutation_receipts": {
|
||||
"name": "company_skill_mutation_receipts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"fingerprint": {
|
||||
"name": "fingerprint",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"skill_id": {
|
||||
"name": "skill_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"version_id": {
|
||||
"name": "version_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"response": {
|
||||
"name": "response",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"company_skill_mutation_receipts_key_idx": {
|
||||
"name": "company_skill_mutation_receipts_key_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "key",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"company_skill_mutation_receipts_company_id_companies_id_fk": {
|
||||
"name": "company_skill_mutation_receipts_company_id_companies_id_fk",
|
||||
"tableFrom": "company_skill_mutation_receipts",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.company_skill_repository_snapshots": {
|
||||
"name": "company_skill_repository_snapshots",
|
||||
"schema": "",
|
||||
@@ -34070,7 +34162,7 @@
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"where": "\"heartbeat_runs\".\"runtime_mode\" = 'native' AND \"heartbeat_runs\".\"status\" = 'cancelled'\n AND \"heartbeat_runs\".\"runner_profile_json\"->'nativeWorkspaceSync'->>'state' = 'prepared'\n AND \"heartbeat_runs\".\"result_json\"->'nativeCancellation'->>'dispatchState' = 'acknowledged'\n AND (\"heartbeat_runs\".\"result_json\"->'workspaceRestoreRecovery'->>'schema' = 'paperclip.workspace-restore-recovery.v1'\n AND \"heartbeat_runs\".\"result_json\"->>'workspaceRestoreFailure' IN ('restore_permission_denied', 'restore_lock_timeout', 'restore_unsafe_archive', 'restore_failed')) IS NOT TRUE",
|
||||
"where": "\"heartbeat_runs\".\"runtime_mode\" = 'native' AND \"heartbeat_runs\".\"status\" = 'cancelled'\n AND \"heartbeat_runs\".\"runner_profile_json\"->'nativeWorkspaceSync'->>'state' = 'prepared'\n AND \"heartbeat_runs\".\"result_json\"->'nativeCancellation'->>'dispatchState' = 'acknowledged'\n AND (\"heartbeat_runs\".\"result_json\"->'workspaceRestoreRecovery'->>'schema' = 'paperclip.workspace-restore-recovery.v1'\n AND \"heartbeat_runs\".\"result_json\"->>'workspaceRestoreFailure' IN ('restore_permission_denied', 'restore_lock_timeout', 'restore_unsafe_archive', 'restore_invalid_metadata', 'restore_failed')) IS NOT TRUE",
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
@@ -37013,6 +37105,15 @@
|
||||
"client_request_id"
|
||||
]
|
||||
},
|
||||
"issue_comments_agent_client_request_uq": {
|
||||
"name": "issue_comments_agent_client_request_uq",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"issue_id",
|
||||
"author_agent_id",
|
||||
"client_request_id"
|
||||
]
|
||||
},
|
||||
"issue_comments_company_id_uq": {
|
||||
"name": "issue_comments_company_id_uq",
|
||||
"nullsNotDistinct": false,
|
||||
@@ -51482,6 +51583,195 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.sandbox_creation_operations": {
|
||||
"name": "sandbox_creation_operations",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"environment_id": {
|
||||
"name": "environment_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"original_environment_id": {
|
||||
"name": "original_environment_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"request_key": {
|
||||
"name": "request_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'run'"
|
||||
},
|
||||
"issue_id": {
|
||||
"name": "issue_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"execution_workspace_id": {
|
||||
"name": "execution_workspace_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"provider": {
|
||||
"name": "provider",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"plugin_id": {
|
||||
"name": "plugin_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"driver_key": {
|
||||
"name": "driver_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"state": {
|
||||
"name": "state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"config": {
|
||||
"name": "config",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"metadata": {
|
||||
"name": "metadata",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"lease_id": {
|
||||
"name": "lease_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"next_attempt_at": {
|
||||
"name": "next_attempt_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"sandbox_creation_operations_pending_deadline_idx": {
|
||||
"name": "sandbox_creation_operations_pending_deadline_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "next_attempt_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"where": "\"sandbox_creation_operations\".\"state\" = 'pending'",
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"sandbox_creation_operations_owner_idx": {
|
||||
"name": "sandbox_creation_operations_owner_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "original_environment_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "run_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "request_key",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"sandbox_creation_operations_environment_id_environments_id_fk": {
|
||||
"name": "sandbox_creation_operations_environment_id_environments_id_fk",
|
||||
"tableFrom": "sandbox_creation_operations",
|
||||
"tableTo": "environments",
|
||||
"columnsFrom": [
|
||||
"environment_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.secret_access_events": {
|
||||
"name": "secret_access_events",
|
||||
"schema": "",
|
||||
@@ -52489,6 +52779,12 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"next_eval_reason": {
|
||||
"name": "next_eval_reason",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"next_eval_at": {
|
||||
"name": "next_eval_at",
|
||||
"type": "timestamp with time zone",
|
||||
+9
-2
@@ -2924,8 +2924,15 @@
|
||||
{
|
||||
"idx": 419,
|
||||
"version": "7",
|
||||
"when": 1793402408013,
|
||||
"tag": "0419_moaning_vulture",
|
||||
"when": 1793400383797,
|
||||
"tag": "0419_slippery_impossible_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 420,
|
||||
"version": "7",
|
||||
"when": 1793453735600,
|
||||
"tag": "0420_broad_johnny_storm",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -49,6 +49,7 @@ export const issueComments = pgTable(
|
||||
},
|
||||
(table) => ({
|
||||
clientRequestUq: unique("issue_comments_client_request_uq").on(table.issueId, table.authorUserId, table.clientRequestId),
|
||||
agentClientRequestUq: unique("issue_comments_agent_client_request_uq").on(table.issueId, table.authorAgentId, table.clientRequestId),
|
||||
companyIdUq: unique("issue_comments_company_id_uq").on(table.companyId, table.id),
|
||||
issueIdx: index("issue_comments_issue_idx").on(table.issueId),
|
||||
reviewResponseReceiptIdx: index("issue_comments_review_response_receipt_idx").on(
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import postgres from "postgres";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyPendingMigrations, inspectMigrations } from "./client.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./test-embedded-postgres.js";
|
||||
|
||||
const adminUrl = process.env.PAPERCLIP_TEST_POSTGRES_ADMIN_URL;
|
||||
const support = adminUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
|
||||
const journal = JSON.parse(await readFile(new URL("./migrations/meta/_journal.json", import.meta.url), "utf8")) as {
|
||||
entries: Array<{ idx: number; tag: string }>;
|
||||
};
|
||||
const master = journal.entries.find(entry => entry.tag === "0419_slippery_impossible_man")!;
|
||||
const status = journal.entries.find(entry => entry.tag === "0420_broad_johnny_storm")!;
|
||||
const statusSql = await readFile(new URL(`./migrations/${status.tag}.sql`, import.meta.url), "utf8");
|
||||
const hash = async (tag: string) => createHash("sha256")
|
||||
.update(await readFile(new URL(`./migrations/${tag}.sql`, import.meta.url), "utf8")).digest("hex");
|
||||
|
||||
async function database() {
|
||||
if (!adminUrl) return startEmbeddedPostgresTestDatabase("status-reason-upgrade-");
|
||||
const name = `status_reason_${randomUUID().replaceAll("-", "")}_test`;
|
||||
const admin = postgres(adminUrl, { max: 1, onnotice: () => {} });
|
||||
try { await admin.unsafe(`create database "${name}"`); }
|
||||
finally { await admin.end(); }
|
||||
const url = new URL(adminUrl); url.pathname = `/${name}`;
|
||||
const cleanup = async () => {
|
||||
const admin = postgres(adminUrl, { max: 1, onnotice: () => {} });
|
||||
try { await admin.unsafe(`drop database "${name}"`); }
|
||||
finally { await admin.end(); }
|
||||
};
|
||||
try { await applyPendingMigrations(url.toString()); }
|
||||
catch (error) { await cleanup(); throw error; }
|
||||
return { connectionString: url.toString(), cleanup };
|
||||
}
|
||||
|
||||
async function seed(sql: postgres.Sql) {
|
||||
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), userId = randomUUID();
|
||||
const lastMessage = new Date("2026-10-20T12:00:00.000Z");
|
||||
await sql`insert into companies (id, name, issue_prefix) values (${companyId}, 'Migration proof', ${`M${companyId.slice(0, 10)}`})`;
|
||||
await sql`insert into agents (id, company_id, name, adapter_type) values (${agentId}, ${companyId}, 'Agent', 'process')`;
|
||||
await sql`insert into "user" (id, name, email, email_verified, created_at, updated_at)
|
||||
values (${userId}, 'Reader', ${`${userId}@example.test`}, true, now(), now())`;
|
||||
await sql`insert into issues (id, company_id, title, status, assignee_agent_id,
|
||||
conversation_agent_id, conversation_user_id, conversation_state)
|
||||
values (${issueId}, ${companyId}, 'Conversation', 'in_review', ${agentId}, ${agentId}, ${userId}, 'waiting')`;
|
||||
await sql`insert into issue_comments (company_id, issue_id, author_agent_id, author_type, body, origin, client_request_id, created_at)
|
||||
values (${companyId}, ${issueId}, ${agentId}, 'agent', 'Preserved reply', 'comment', 'retry-key', ${lastMessage})`;
|
||||
await sql`insert into issue_comments (company_id, issue_id, author_agent_id, author_type, body, origin, created_at)
|
||||
values (${companyId}, ${issueId}, ${agentId}, 'system', 'Excluded system notice', 'comment', ${new Date(lastMessage.getTime() + 1000)})`;
|
||||
const cardId = randomUUID(), savedDeadline = new Date("2026-10-21T12:00:00.000Z");
|
||||
await sql`insert into status_cards (id, company_id, interest_prompt, refresh_policy, next_eval_at, pending_change_hash)
|
||||
values (${cardId}, ${companyId}, 'Watch preserved work', '{"mode":"manual"}'::jsonb, ${savedDeadline}, 'preserved-change')`;
|
||||
return { companyId, agentId, issueId, userId, lastMessage, cardId, savedDeadline };
|
||||
}
|
||||
|
||||
async function assertFinal(sql: postgres.Sql, row: Awaited<ReturnType<typeof seed>>, expectedRead: Date) {
|
||||
const [column] = await sql`select data_type, is_nullable from information_schema.columns
|
||||
where table_schema = 'public' and table_name = 'status_cards' and column_name = 'next_eval_reason'`;
|
||||
expect(column).toMatchObject({ data_type: "text", is_nullable: "YES" });
|
||||
const [constraint] = await sql`select i.indisvalid, i.indisunique from pg_constraint c
|
||||
join pg_index i on i.indexrelid = c.conindid
|
||||
where c.conrelid = 'issue_comments'::regclass and c.conname = 'issue_comments_agent_client_request_uq'`;
|
||||
expect(constraint).toMatchObject({ indisvalid: true, indisunique: true });
|
||||
const [read] = await sql`select last_read_at from issue_read_states
|
||||
where company_id = ${row.companyId} and issue_id = ${row.issueId} and user_id = ${row.userId}`;
|
||||
expect(read.last_read_at).toEqual(expectedRead);
|
||||
const [card] = await sql`select next_eval_at, next_eval_reason, pending_change_hash from status_cards where id = ${row.cardId}`;
|
||||
expect(card).toMatchObject({ next_eval_at: row.savedDeadline, next_eval_reason: null, pending_change_hash: "preserved-change" });
|
||||
await sql`update status_cards set next_eval_reason = 'retry' where id = ${row.cardId}`;
|
||||
expect((await sql`select next_eval_at, next_eval_reason from status_cards where id = ${row.cardId}`)[0])
|
||||
.toMatchObject({ next_eval_at: row.savedDeadline, next_eval_reason: "retry" });
|
||||
await expect(sql`insert into issue_comments (company_id, issue_id, author_agent_id, body, client_request_id)
|
||||
values (${row.companyId}, ${row.issueId}, ${row.agentId}, 'Duplicate', 'retry-key')`)
|
||||
.rejects.toMatchObject({ code: "23505", constraint_name: "issue_comments_agent_client_request_uq" });
|
||||
}
|
||||
|
||||
describe.skipIf(!support.supported)("status-card reason migration after the published comment migration", () => {
|
||||
it.each(["0418", "master0419"] as const)("upgrades from %s without dropping comment uniqueness or read history", async previous => {
|
||||
expect(master.idx).toBe(419);
|
||||
expect(status.idx).toBeGreaterThan(master.idx);
|
||||
expect(statusSql.trim()).toBe('ALTER TABLE "status_cards" ADD COLUMN "next_eval_reason" text;');
|
||||
const db = await database();
|
||||
const sql = postgres(db.connectionString, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
// Reconstruct exactly the two migrations' preimages; all older schema and
|
||||
// receipts stay intact, as in the repository's other replay regressions.
|
||||
await sql`alter table status_cards drop column next_eval_reason`;
|
||||
await sql`delete from drizzle.__drizzle_migrations where hash = ${await hash(status.tag)}`;
|
||||
if (previous === "0418") {
|
||||
await sql`alter table issue_comments drop constraint issue_comments_agent_client_request_uq`;
|
||||
await sql`delete from drizzle.__drizzle_migrations where hash = ${await hash(master.tag)}`;
|
||||
}
|
||||
const row = await seed(sql);
|
||||
const preservedRead = new Date("2026-10-19T12:00:00.000Z");
|
||||
if (previous === "master0419") await sql`insert into issue_read_states (company_id, issue_id, user_id, last_read_at)
|
||||
values (${row.companyId}, ${row.issueId}, ${row.userId}, ${preservedRead})`;
|
||||
expect(await inspectMigrations(db.connectionString)).toMatchObject({
|
||||
status: "needsMigrations",
|
||||
pendingMigrations: previous === "0418" ? [`${master.tag}.sql`, `${status.tag}.sql`] : [`${status.tag}.sql`],
|
||||
});
|
||||
await applyPendingMigrations(db.connectionString);
|
||||
expect(await inspectMigrations(db.connectionString)).toMatchObject({ status: "upToDate" });
|
||||
await assertFinal(sql, row, previous === "0418" ? row.lastMessage : preservedRead);
|
||||
const receipts = await sql`select hash from drizzle.__drizzle_migrations order by id`;
|
||||
const hashes = receipts.map(receipt => receipt.hash);
|
||||
expect(hashes.indexOf(await hash(master.tag))).toBeLessThan(hashes.indexOf(await hash(status.tag)));
|
||||
await applyPendingMigrations(db.connectionString);
|
||||
expect(await sql`select hash from drizzle.__drizzle_migrations order by id`).toEqual(receipts);
|
||||
} finally { await sql.end(); await db.cleanup(); }
|
||||
}, 90_000);
|
||||
});
|
||||
@@ -311,7 +311,8 @@ installation journey or a paid provider task on ARM64.
|
||||
Automatic creation selects legacy execution when a Linux host has musl, unknown
|
||||
libc, or a measured GNU libc version below the baseline. Existing agents retain
|
||||
their stored runner. Windows, 32-bit ARM, and Alpine/musl native qualification
|
||||
remain deferred.
|
||||
remain deferred. The `Runner Windows` workflow builds and tests the Rust
|
||||
workspace on Windows x64. It does not qualify Windows agent runs.
|
||||
|
||||
Native release jobs cache Cargo dependencies per platform and OS baseline,
|
||||
including compiler, lockfile, and external generated-contract inputs. Workspace
|
||||
@@ -980,6 +981,39 @@ while daemon work might still be running. The socket and its parent must be priv
|
||||
canonical paths owned by the same account. Local stdio assignment requires Unix;
|
||||
other platforms retain the HTTP path.
|
||||
|
||||
## Kimi Code
|
||||
|
||||
Kimi Code uses the pinned `@moonshot-ai/kimi-code@2.3.3` distribution through
|
||||
ACPX through the qualified native profile. Ordinary selection and Runtime Test
|
||||
require no candidate grant.
|
||||
The installation targets are macOS ARM64 and Linux x64. Platform startup,
|
||||
authenticated driver checks, and product acceptance are separate evidence levels.
|
||||
|
||||
Run `paperclipai runtime setup kimi` on the execution host to install its complete
|
||||
verified runtime. Setup downloads public pinned dependencies without model calls
|
||||
or provider credentials. An invalid existing installation fails without ambient
|
||||
Kimi fallback. Bind a company secret as `KIMI_API_KEY` and select an exact model.
|
||||
This release uses the global Code endpoint at `https://api.kimi.ai/coding/v1`.
|
||||
Model suggestions are empty; a compatible typed model is decided by the provider.
|
||||
The account needs Kimi Code entitlement. Runtime Test verifies installation and
|
||||
queries the authenticated Code model catalog for the exact selected model. It
|
||||
sends no model prompt; an actual task verifies inference and tool execution.
|
||||
|
||||
The qualified native profile uses ordinary setup, hiring, metadata Test and task
|
||||
startup on these two supported targets without operator admission flags. Protected
|
||||
candidate startup records remain reserved for future pending profiles; saved agent
|
||||
configuration and public requests cannot supply that authority.
|
||||
|
||||
Existing legacy Kimi agents retain their runtime and history. Managed Kimi connections,
|
||||
ambient login borrowing, other regions/platforms, steering, native child work,
|
||||
and per-run token/dollar accounting are outside this release. Read-only Kimi work
|
||||
uses scoped Paperclip tools; native filesystem and shell tools are denied.
|
||||
The provider-owned patch binds native instructions, preserves permission arguments,
|
||||
and removes provider auth from tool subprocesses. See the
|
||||
[protocol dossier](src/providers/kimi/README.md) and
|
||||
[release ledger](../../doc/plans/2026-10-28-kimi-code-runner-integration.md) for
|
||||
current evidence and limits.
|
||||
|
||||
## ACPX controller replacement regression tests
|
||||
|
||||
`src/control-plane/acpx-controller-recovery.test.ts` runs the production Rust
|
||||
|
||||
@@ -67,6 +67,15 @@
|
||||
"agentRuntimeVersion": "0.160.0",
|
||||
"commandDigest": "sha256:443491f4e814501a5dc47347e4510e84faeec9c9382e2d4e53bfe69386d5aabc",
|
||||
"requiresProviderPolicy": false
|
||||
},
|
||||
"kimi": {
|
||||
"agentProfileVersion": 17,
|
||||
"agentServerPackage": "@moonshot-ai/kimi-code",
|
||||
"agentServerVersion": "2.3.3",
|
||||
"agentRuntimePackage": null,
|
||||
"agentRuntimeVersion": null,
|
||||
"commandDigest": "sha256:6ee5fb7ac18c1361ac63effe9117d08ed129add8349756a140e73e0c86ba5da7",
|
||||
"requiresProviderPolicy": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,7 @@ Each provider declares how in `ACPX_RELEASE_ATTESTATIONS`
|
||||
| --- | --- | --- |
|
||||
| `installed-command` | Claude, Codex | the upstream package's launch command, hashed at installation |
|
||||
| `launcher` | Grok | the SHA-256 of the launcher shipped verbatim |
|
||||
| `closure` | Copilot, Pi | upstream versions and the complete platform closure pins |
|
||||
| `closure` | Copilot, Pi, Kimi | upstream versions and the complete platform closure pins |
|
||||
| `declaration` | Cursor, Antigravity | a checked-in release declaration, as canonical JSON |
|
||||
|
||||
`generate:acpx-profiles` writes the computed digests and `check:acpx-profiles`
|
||||
@@ -127,6 +127,12 @@ An administrator-owned read-only Runner can therefore use a user-writable cache,
|
||||
but this provides weaker protection against that same user than hashing every
|
||||
executable on each admission. Removing the cache restores full hashing.
|
||||
|
||||
Kimi retains released revision 17, which separates ordinary admission from
|
||||
pending revision 16. Its upstream-only release attestation replaces the earlier
|
||||
source-bound digest without another revision bump. Historical candidate and
|
||||
released records remain unchanged and readable; exact current digest admission
|
||||
refuses their old launch identities.
|
||||
|
||||
## External controller checkpoints
|
||||
|
||||
The optional controller persistence callback stores recovery control state, not
|
||||
|
||||
@@ -1 +1 @@
|
||||
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaimLine truncated
|
||||
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaimLine truncated
|
||||
@@ -533,6 +533,45 @@
|
||||
"title": "Report durable progress",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"allowedModes": [
|
||||
"standard"
|
||||
],
|
||||
"description": "Send a Markdown message directly to your own persistent chat with this task's responsible human. The destination is automatic. Use for requested digests and proactive task updates; report_progress writes to this task and comment_on_task writes to a chosen task. This sends no message to other agents, starts no chat run, and changes no task status. Reuse idempotencyKey on retries.",
|
||||
"effect": "write",
|
||||
"inputSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"body": {
|
||||
"description": "The message the human should see.",
|
||||
"maxLength": 20000,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"idempotencyKey": {
|
||||
"description": "Stable key for this message within the source task, including across run retries.",
|
||||
"maxLength": 240,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"body",
|
||||
"idempotencyKey"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"operationId": "send_chat_message",
|
||||
"outputSchema": {
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
"placement": "always",
|
||||
"requiredClaims": [],
|
||||
"schema": "paperclip.semantic-action.v1",
|
||||
"title": "Send message to your chat",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"allowedModes": [
|
||||
"standard",
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
"typecheck:browser": "tsc -p tsconfig.browser.json --noEmit",
|
||||
"test": "pnpm run test:typescript && pnpm run test:rust",
|
||||
"test:typescript": "pnpm run test:typescript:prep && vitest run",
|
||||
"test:typescript:prep": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node ../../scripts/ci-profile/node-test.mjs test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs test/acpx-model-selection-package-contract.test.mjs test/acpx-message-boundaries-package-contract.test.mjs test/acpx-rich-extensions-package-contract.test.mjs test/acpx-pi-receipt-package-contract.test.mjs test/acpx-response-delivery-package-contract.test.mjs scripts/candidate-provider-pack.test.mjs scripts/materialize-pi-distribution.test.mjs scripts/cursor-provisioner-layout.test.mjs scripts/release-provider-pack.test.mjs scripts/acpx-release-attestations.test.mjs scripts/provider-pack-runtime-tree.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs scripts/installed-package-pack.test.mjs scripts/runner-runtime-dependency-roots.test.mjs scripts/runner-package-executable.test.mjs scripts/stage-runner-binary.test.mjs scripts/build-inputs.test.mjs scripts/contracts/contract-fingerprints.test.mjs scripts/provider-pack-executable-shims.test.mjs scripts/controlled-evals/controller.test.mjs scripts/controlled-evals/portable.test.mjs && node --test scripts/build-copilot-distribution.test.mjs scripts/copilot-inner-distribution.test.mjs scripts/copilot-smoke-resources.test.mjs scripts/discover-copilot-acp.test.mjs scripts/materialize-copilot-binary.test.mjs scripts/install-copilot-assets.test.mjs scripts/probe-copilot-acp.test.mjs scripts/qualify-copilot-acp.test.mjs test/acpx-message-boundaries-package-contract.test.mjs",
|
||||
"test:typescript:prep": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node ../../scripts/ci-profile/node-test.mjs test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs test/acpx-model-selection-package-contract.test.mjs test/acpx-message-boundaries-package-contract.test.mjs test/acpx-rich-extensions-package-contract.test.mjs test/acpx-pi-receipt-package-contract.test.mjs test/acpx-response-delivery-package-contract.test.mjs scripts/candidate-provider-pack.test.mjs scripts/materialize-pi-distribution.test.mjs scripts/cursor-provisioner-layout.test.mjs scripts/release-provider-pack.test.mjs scripts/acpx-release-attestations.test.mjs scripts/provider-pack-runtime-tree.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs scripts/materialize-kimi-distribution.test.mjs scripts/provision-kimi-package.test.mjs scripts/kimi-metadata-probe.test.mjs scripts/patch-kimi-runtime.test.mjs test/kimi-installed-runtime.test.mjs scripts/installed-package-pack.test.mjs scripts/runner-runtime-dependency-roots.test.mjs scripts/runner-package-executable.test.mjs scripts/stage-runner-binary.test.mjs scripts/build-inputs.test.mjs scripts/contracts/contract-fingerprints.test.mjs scripts/provider-pack-executable-shims.test.mjs scripts/controlled-evals/controller.test.mjs scripts/controlled-evals/portable.test.mjs && node --test scripts/build-copilot-distribution.test.mjs scripts/copilot-inner-distribution.test.mjs scripts/copilot-smoke-resources.test.mjs scripts/discover-copilot-acp.test.mjs scripts/materialize-copilot-binary.test.mjs scripts/install-copilot-assets.test.mjs scripts/probe-copilot-acp.test.mjs scripts/qualify-copilot-acp.test.mjs test/acpx-message-boundaries-package-contract.test.mjs",
|
||||
"test:typescript:vitest": "node ./scripts/run-pr-vitest-lane.mjs",
|
||||
"test:rust": "cargo test --release --manifest-path runner/Cargo.toml --locked --workspace",
|
||||
"test:codex": "cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --test codex_provider",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"prpVersion": 1,
|
||||
"nativeExecutionVersion": 1,
|
||||
"catalogVersion": 1,
|
||||
"catalogSha256": "sha256:015531b07c9906c6edfa6029890cc7b2c69435c1c0c37432ffe21459e6f15c43",
|
||||
"catalogSha256": "sha256:bb6aec4a12397e82d4f881fbce993e796cb8e8cc6852b29b308fc01ade7ef3d2",
|
||||
"driverContractVersion": 1,
|
||||
"driverKind": "paperclip-deterministic",
|
||||
"driverVersion": "1.0.0"
|
||||
|
||||
@@ -180,7 +180,7 @@
|
||||
},
|
||||
{
|
||||
"path": "fixtures/evals/native-execution-seeded.json",
|
||||
"sha256": "0b878d30ad4b5514a14f7387c3792662cbfeebc7149188ffe304197e41c20e40",
|
||||
"sha256": "810659b1b73ed62e70598bee5cc693d5632471c0f457788167f9b8eecb0d0022",
|
||||
"expectation": "accept",
|
||||
"compatibilityCase": "canonical"
|
||||
},
|
||||
|
||||
+221
-12
@@ -3,7 +3,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::generated_acpx_profiles::acpx_release_profile;
|
||||
use crate::generated_acpx_profiles::{acpx_release_profile, kimi_native_closure};
|
||||
use crate::local_runner::LocalRunnerError;
|
||||
use crate::stable_identity::{is_stable_id, DURABLE_STABLE_ID_CHARS, SHORT_STABLE_ID_CHARS};
|
||||
|
||||
@@ -63,15 +63,41 @@ impl AcpxCandidateAdmission {
|
||||
}
|
||||
|
||||
fn validate_at(&self, now: u64, os: &str, architecture: &str) -> Result<(), LocalRunnerError> {
|
||||
let profile = acpx_release_profile("antigravity").ok_or_else(invalid)?;
|
||||
let manifest: Value = serde_json::from_str(DISTRIBUTIONS).map_err(|_| invalid())?;
|
||||
let closure = manifest
|
||||
.pointer("/platforms/darwin-arm64/closureSha256")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(invalid)?;
|
||||
let profile = acpx_release_profile(&self.agent).ok_or_else(invalid)?;
|
||||
let (closure, host_matches) = match self.agent.as_str() {
|
||||
"antigravity" => {
|
||||
let manifest: Value = serde_json::from_str(DISTRIBUTIONS).map_err(|_| invalid())?;
|
||||
let closure = manifest
|
||||
.pointer("/platforms/darwin-arm64/closureSha256")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(invalid)?
|
||||
.to_owned();
|
||||
(
|
||||
closure,
|
||||
self.platform == "darwin"
|
||||
&& self.architecture == "arm64"
|
||||
&& os == "macos"
|
||||
&& architecture == "aarch64",
|
||||
)
|
||||
}
|
||||
"kimi" if profile.qualification_pending => {
|
||||
let closure =
|
||||
kimi_native_closure(&self.platform, &self.architecture).ok_or_else(invalid)?;
|
||||
let host_matches = matches!(
|
||||
(
|
||||
self.platform.as_str(),
|
||||
self.architecture.as_str(),
|
||||
os,
|
||||
architecture
|
||||
),
|
||||
("darwin", "arm64", "macos", "aarch64") | ("linux", "x64", "linux", "x86_64")
|
||||
);
|
||||
(closure.to_owned(), host_matches)
|
||||
}
|
||||
_ => return Err(invalid()),
|
||||
};
|
||||
if self.schema != SCHEMA
|
||||
|| !is_stable_id(&self.company_id, DURABLE_STABLE_ID_CHARS)
|
||||
|| self.agent != "antigravity"
|
||||
|| self.model.trim().is_empty()
|
||||
|| self.model != self.model.trim()
|
||||
|| self.model.len() > 512
|
||||
@@ -79,10 +105,7 @@ impl AcpxCandidateAdmission {
|
||||
|| matches!(self.model.to_ascii_lowercase().as_str(), "auto" | "default")
|
||||
|| self.profile_digest != profile.command_digest
|
||||
|| self.native_closure_digest != format!("sha256:{closure}")
|
||||
|| self.platform != "darwin"
|
||||
|| self.architecture != "arm64"
|
||||
|| os != "macos"
|
||||
|| architecture != "aarch64"
|
||||
|| !host_matches
|
||||
|| self.expires_at > MAX_JSON_SAFE_INTEGER
|
||||
|| self.expires_at <= now
|
||||
|| self.expires_at > now.saturating_add(MAX_LIFETIME_MS)
|
||||
@@ -94,6 +117,27 @@ impl AcpxCandidateAdmission {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_next_run(
|
||||
&self,
|
||||
next: &Self,
|
||||
agent: &str,
|
||||
model: &str,
|
||||
run_id: &str,
|
||||
normalized_session_id: &str,
|
||||
) -> Result<(), LocalRunnerError> {
|
||||
if self.agent != "kimi" || self.run_id == next.run_id {
|
||||
return Err(invalid());
|
||||
}
|
||||
self.validate()?;
|
||||
next.validate_binding(agent, model, run_id, normalized_session_id)?;
|
||||
let mut rebound = next.clone();
|
||||
rebound.run_id = self.run_id.clone();
|
||||
if &rebound != self {
|
||||
return Err(invalid());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_binding(
|
||||
&self,
|
||||
agent: &str,
|
||||
@@ -208,4 +252,169 @@ mod tests {
|
||||
let error = AcpxCandidateAdmission::parse_json("private malformed input").unwrap_err();
|
||||
assert!(!error.to_string().contains("private"));
|
||||
}
|
||||
|
||||
fn kimi_admission(platform: &str, architecture: &str) -> AcpxCandidateAdmission {
|
||||
AcpxCandidateAdmission {
|
||||
agent: "kimi".to_owned(),
|
||||
model: "kimi-for-coding".to_owned(),
|
||||
profile_digest: acpx_release_profile("kimi")
|
||||
.unwrap()
|
||||
.command_digest
|
||||
.to_owned(),
|
||||
native_closure_digest: format!(
|
||||
"sha256:{}",
|
||||
kimi_native_closure(platform, architecture).unwrap()
|
||||
),
|
||||
platform: platform.to_owned(),
|
||||
architecture: architecture.to_owned(),
|
||||
..test_admission("kimi-run", "kimi-session")
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn released_kimi_rejects_candidate_records_on_both_native_platforms() {
|
||||
assert!(!acpx_release_profile("kimi").unwrap().qualification_pending);
|
||||
for (platform, architecture, os, host_arch) in [
|
||||
("darwin", "arm64", "macos", "aarch64"),
|
||||
("linux", "x64", "linux", "x86_64"),
|
||||
] {
|
||||
let candidate = kimi_admission(platform, architecture);
|
||||
let now = candidate.expires_at - 1;
|
||||
assert!(candidate.validate_at(now, os, host_arch).is_err());
|
||||
for (wrong_os, wrong_arch) in [
|
||||
("windows", "x86_64"),
|
||||
("linux", "aarch64"),
|
||||
("macos", "x86_64"),
|
||||
] {
|
||||
assert!(candidate.validate_at(now, wrong_os, wrong_arch).is_err());
|
||||
}
|
||||
let (other_os, other_arch) = if os == "macos" {
|
||||
("linux", "x86_64")
|
||||
} else {
|
||||
("macos", "aarch64")
|
||||
};
|
||||
assert!(candidate.validate_at(now, other_os, other_arch).is_err());
|
||||
assert!(candidate
|
||||
.validate_at(candidate.expires_at, os, host_arch)
|
||||
.is_err());
|
||||
assert!(candidate
|
||||
.validate_at(candidate.expires_at - MAX_LIFETIME_MS - 1, os, host_arch)
|
||||
.is_err());
|
||||
for (field, value) in [
|
||||
("companyId", json!("bad company")),
|
||||
("agent", json!("copilot")),
|
||||
("model", json!("default")),
|
||||
("profileDigest", json!("sha256:wrong")),
|
||||
("nativeClosureDigest", json!("sha256:wrong")),
|
||||
("platform", json!("windows")),
|
||||
("architecture", json!("s390x")),
|
||||
("runId", json!("bad/run")),
|
||||
("normalizedSessionId", json!("bad/session")),
|
||||
] {
|
||||
let mut changed = serde_json::to_value(&candidate).unwrap();
|
||||
changed[field] = value;
|
||||
let changed: AcpxCandidateAdmission = serde_json::from_value(changed).unwrap();
|
||||
assert!(changed.validate_at(now, os, host_arch).is_err(), "{field}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(any(
|
||||
all(target_os = "macos", target_arch = "aarch64"),
|
||||
all(target_os = "linux", target_arch = "x86_64")
|
||||
))]
|
||||
fn released_kimi_rejects_candidate_rebinding_and_startup_records() {
|
||||
assert!(!acpx_release_profile("kimi").unwrap().qualification_pending);
|
||||
let (platform, architecture) = if cfg!(target_os = "macos") {
|
||||
("darwin", "arm64")
|
||||
} else {
|
||||
("linux", "x64")
|
||||
};
|
||||
let current = kimi_admission(platform, architecture);
|
||||
let mut next = current.clone();
|
||||
next.run_id = "kimi-next-run".to_owned();
|
||||
assert!(current.validate().is_err());
|
||||
assert!(
|
||||
AcpxCandidateAdmission::parse_json(&serde_json::to_string(¤t).unwrap()).is_err()
|
||||
);
|
||||
assert!(current
|
||||
.validate_next_run(
|
||||
&next,
|
||||
"kimi",
|
||||
"kimi-for-coding",
|
||||
"kimi-next-run",
|
||||
"kimi-session",
|
||||
)
|
||||
.is_err());
|
||||
assert!(current
|
||||
.validate_next_run(
|
||||
¤t,
|
||||
"kimi",
|
||||
"kimi-for-coding",
|
||||
"kimi-run",
|
||||
"kimi-session"
|
||||
)
|
||||
.is_err());
|
||||
for (field, change) in [
|
||||
("companyId", json!("another-company")),
|
||||
("model", json!("another-model")),
|
||||
("profileDigest", json!("sha256:wrong")),
|
||||
("nativeClosureDigest", json!("sha256:wrong")),
|
||||
("normalizedSessionId", json!("another-session")),
|
||||
("expiresAt", json!(next.expires_at + 1)),
|
||||
] {
|
||||
let mut value = serde_json::to_value(&next).unwrap();
|
||||
value[field] = change;
|
||||
let changed = serde_json::from_value(value).unwrap();
|
||||
assert!(current
|
||||
.validate_next_run(
|
||||
&changed,
|
||||
"kimi",
|
||||
"kimi-for-coding",
|
||||
"kimi-next-run",
|
||||
"kimi-session"
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
assert!(current
|
||||
.validate_next_run(
|
||||
&next,
|
||||
"kimi",
|
||||
"kimi-for-coding",
|
||||
"foreign-run",
|
||||
"kimi-session"
|
||||
)
|
||||
.is_err());
|
||||
assert!(current
|
||||
.validate_next_run(
|
||||
&next,
|
||||
"kimi",
|
||||
"kimi-for-coding",
|
||||
"kimi-next-run",
|
||||
"foreign-session"
|
||||
)
|
||||
.is_err());
|
||||
let mut expired = current.clone();
|
||||
expired.expires_at = 0;
|
||||
assert!(expired
|
||||
.validate_next_run(
|
||||
&next,
|
||||
"kimi",
|
||||
"kimi-for-coding",
|
||||
"kimi-next-run",
|
||||
"kimi-session"
|
||||
)
|
||||
.is_err());
|
||||
let antigravity = test_admission("run-1", "session-1");
|
||||
assert!(antigravity
|
||||
.validate_next_run(
|
||||
&next,
|
||||
"kimi",
|
||||
"kimi-for-coding",
|
||||
"kimi-next-run",
|
||||
"kimi-session"
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
@@ -927,6 +927,35 @@ impl AcpxCommandExecutor {
|
||||
.canonical_digest()
|
||||
}
|
||||
|
||||
fn restore_launch_profile_digest(
|
||||
&self,
|
||||
state: &AcpxDurableState,
|
||||
) -> Result<String, DurableRunnerError> {
|
||||
let Some(profile) = self.launch_profile.as_ref() else {
|
||||
return self.launch_profile_digest();
|
||||
};
|
||||
let Some(current) = profile.candidate_admission.as_ref().filter(|grant| {
|
||||
grant.agent == "kimi" && state.descriptor.run_id != self.context.run_id
|
||||
}) else {
|
||||
return profile.canonical_digest();
|
||||
};
|
||||
let mut prior = profile.clone();
|
||||
prior.candidate_admission.as_mut().unwrap().run_id = state.descriptor.run_id.clone();
|
||||
prior
|
||||
.candidate_admission
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.validate_next_run(
|
||||
current,
|
||||
&state.descriptor.agent,
|
||||
&state.descriptor.model,
|
||||
&self.context.run_id,
|
||||
&self.context.normalized_session_id,
|
||||
)
|
||||
.map_err(|error| DurableRunnerError::invalid(error.to_string()))?;
|
||||
prior.canonical_digest()
|
||||
}
|
||||
|
||||
fn restore(&mut self) -> Result<(), DurableRunnerError> {
|
||||
self.ensure_persistence_healthy()?;
|
||||
if self.restore_checked {
|
||||
@@ -958,7 +987,10 @@ impl AcpxCommandExecutor {
|
||||
)))
|
||||
}
|
||||
};
|
||||
if self.candidate_admission().is_some() {
|
||||
if self
|
||||
.candidate_admission()
|
||||
.is_some_and(|admission| admission.agent == "antigravity")
|
||||
{
|
||||
return Err(DurableRunnerError::invalid(
|
||||
"ACPX candidate cannot adopt a prior durable runner activation",
|
||||
));
|
||||
@@ -983,7 +1015,7 @@ impl AcpxCommandExecutor {
|
||||
let state: AcpxDurableState = serde_json::from_slice(&bytes).map_err(|error| {
|
||||
DurableRunnerError::invalid(format!("ACPX provider state is malformed: {error}"))
|
||||
})?;
|
||||
let launch_profile_digest = self.launch_profile_digest()?;
|
||||
let launch_profile_digest = self.restore_launch_profile_digest(&state)?;
|
||||
state.validate(&self.context, &launch_profile_digest)?;
|
||||
self.state = Some(state);
|
||||
self.restore_session_if_needed()
|
||||
@@ -1218,7 +1250,10 @@ impl AcpxCommandExecutor {
|
||||
}
|
||||
|
||||
fn attach_run(&mut self, payload: &Value) -> Result<(), DurableRunnerError> {
|
||||
if self.candidate_admission().is_some() {
|
||||
if self
|
||||
.candidate_admission()
|
||||
.is_some_and(|admission| admission.agent == "antigravity")
|
||||
{
|
||||
return Err(DurableRunnerError::invalid(
|
||||
"ACPX candidate cannot rotate or adopt a live runner authority",
|
||||
));
|
||||
@@ -1258,6 +1293,62 @@ impl AcpxCommandExecutor {
|
||||
attachment_context.run_id = run_id.to_owned();
|
||||
}
|
||||
descriptor.validate(&attachment_context)?;
|
||||
let cold_candidate_attachment = self
|
||||
.candidate_admission()
|
||||
.is_some_and(|grant| grant.agent == "kimi")
|
||||
&& payload.get("paperclipNextAuthority").is_none()
|
||||
&& self
|
||||
.state
|
||||
.as_ref()
|
||||
.is_some_and(|state| state.descriptor.run_id != self.context.run_id);
|
||||
let next_candidate = if let Some(admission) = self
|
||||
.candidate_admission()
|
||||
.filter(|admission| admission.agent == "kimi")
|
||||
{
|
||||
if cold_candidate_attachment {
|
||||
let state = self.state.as_ref().unwrap();
|
||||
let digest = self.restore_launch_profile_digest(state)?;
|
||||
state.validate(&self.context, &digest)?;
|
||||
admission
|
||||
.validate_binding(
|
||||
&descriptor.agent,
|
||||
&descriptor.model,
|
||||
&self.context.run_id,
|
||||
&self.context.normalized_session_id,
|
||||
)
|
||||
.map_err(|error| DurableRunnerError::invalid(error.to_string()))?;
|
||||
Some(admission.clone())
|
||||
} else {
|
||||
let proposed: crate::acpx_candidate_admission::AcpxCandidateAdmission =
|
||||
serde_json::from_value(
|
||||
payload
|
||||
.pointer("/paperclipNextAuthority/candidateAdmission")
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
DurableRunnerError::invalid(
|
||||
"Kimi run.attach requires exact trusted candidate admission",
|
||||
)
|
||||
})?,
|
||||
)
|
||||
.map_err(|_| {
|
||||
DurableRunnerError::invalid(
|
||||
"Kimi run.attach candidate admission is invalid",
|
||||
)
|
||||
})?;
|
||||
admission
|
||||
.validate_next_run(
|
||||
&proposed,
|
||||
&descriptor.agent,
|
||||
&descriptor.model,
|
||||
&descriptor.run_id,
|
||||
&descriptor.normalized_session_id,
|
||||
)
|
||||
.map_err(|error| DurableRunnerError::invalid(error.to_string()))?;
|
||||
Some(proposed)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let tool_set = authorized_tool_set(payload)?;
|
||||
let state = self
|
||||
.state
|
||||
@@ -1327,16 +1418,30 @@ impl AcpxCommandExecutor {
|
||||
})?;
|
||||
}
|
||||
self.session = None;
|
||||
let state = self
|
||||
if let Some(admission) = next_candidate {
|
||||
self.launch_profile
|
||||
.as_mut()
|
||||
.expect("candidate launch profile remains available")
|
||||
.candidate_admission = Some(admission);
|
||||
}
|
||||
let launch_profile_digest = self.launch_profile_digest()?;
|
||||
let mut state = self
|
||||
.state
|
||||
.as_mut()
|
||||
.expect("ACPX state remains available while attaching a run");
|
||||
.as_ref()
|
||||
.expect("ACPX state remains available while attaching a run")
|
||||
.clone();
|
||||
state.launch_profile_digest = launch_profile_digest;
|
||||
state.descriptor = descriptor;
|
||||
state.tool_set = tool_set;
|
||||
state.semantic_result = None;
|
||||
state.pending_events.clear();
|
||||
state.lifecycle = "suspended".to_owned();
|
||||
self.save_state()
|
||||
if cold_candidate_attachment {
|
||||
self.commit_staged_state(state)
|
||||
} else {
|
||||
self.state = Some(state);
|
||||
self.save_state()
|
||||
}
|
||||
}
|
||||
|
||||
fn open_session(&mut self) -> Result<CommandExecution, DurableRunnerError> {
|
||||
@@ -2032,7 +2137,10 @@ impl AcpxCommandExecutor {
|
||||
}
|
||||
|
||||
fn suspend(&mut self) -> Result<CommandExecution, DurableRunnerError> {
|
||||
if self.candidate_admission().is_some() {
|
||||
if self
|
||||
.candidate_admission()
|
||||
.is_some_and(|admission| admission.agent == "antigravity")
|
||||
{
|
||||
return self.close_session("candidate runner.suspend retires the owned process");
|
||||
}
|
||||
if self
|
||||
@@ -2270,7 +2378,9 @@ fn goal_terminal_disposition<'a>(
|
||||
impl CommandExecutor for AcpxCommandExecutor {
|
||||
fn execute(&mut self, command: &Command) -> Result<CommandExecution, DurableRunnerError> {
|
||||
self.restore()?;
|
||||
if self.candidate_admission().is_some()
|
||||
if self
|
||||
.candidate_admission()
|
||||
.is_some_and(|admission| admission.agent == "antigravity")
|
||||
&& command.command_type == "run.attach"
|
||||
&& command.payload.get("paperclipNextAuthority").is_some()
|
||||
{
|
||||
@@ -2346,6 +2456,12 @@ impl CommandExecutor for AcpxCommandExecutor {
|
||||
self.context.normalized_session_id = config.normalized_session_id.clone();
|
||||
self.context.turn_id = config.turn_id.clone();
|
||||
self.context.item_id = config.item_id.clone();
|
||||
if self
|
||||
.candidate_admission()
|
||||
.is_some_and(|admission| admission.agent == "kimi")
|
||||
{
|
||||
self.launch_profile = config.acpx_launch_profile.clone();
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_events(&mut self) -> Result<Vec<PolledEvent>, DurableRunnerError> {
|
||||
@@ -4824,4 +4940,536 @@ mod tests {
|
||||
drop(listeners.pop());
|
||||
(candidates, listeners)
|
||||
}
|
||||
#[test]
|
||||
#[cfg(any(
|
||||
all(target_os = "macos", target_arch = "aarch64"),
|
||||
all(target_os = "linux", target_arch = "x86_64")
|
||||
))]
|
||||
fn released_kimi_attachment_preserves_ordinary_profile_without_candidate_admission() {
|
||||
assert!(!acpx_release_profile("kimi").unwrap().qualification_pending);
|
||||
let directory = temporary_directory("kimi-candidate-attachment");
|
||||
let mut grant = crate::acpx_candidate_admission::test_admission("run-1", "session-1");
|
||||
grant.agent = "kimi".to_owned();
|
||||
grant.model = "explicit-test-model".to_owned();
|
||||
grant.profile_digest = acpx_release_profile("kimi")
|
||||
.unwrap()
|
||||
.command_digest
|
||||
.to_owned();
|
||||
grant.platform = if cfg!(target_os = "macos") {
|
||||
"darwin"
|
||||
} else {
|
||||
"linux"
|
||||
}
|
||||
.to_owned();
|
||||
grant.architecture = if cfg!(target_os = "macos") {
|
||||
"arm64"
|
||||
} else {
|
||||
"x64"
|
||||
}
|
||||
.to_owned();
|
||||
grant.native_closure_digest = format!(
|
||||
"sha256:{}",
|
||||
crate::generated_acpx_profiles::kimi_native_closure(
|
||||
&grant.platform,
|
||||
&grant.architecture
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
let command = directory.join("owned-sidecar");
|
||||
write_artifact(&command, b"#!/bin/sh\nexit 1\n", true);
|
||||
let profile = AcpxLaunchProfile {
|
||||
candidate_admission: None,
|
||||
authority_digest: format!("sha256:{}", "d".repeat(64)),
|
||||
command: command.clone(),
|
||||
args: vec![],
|
||||
artifacts: vec![artifact(&command)],
|
||||
};
|
||||
let runtime = directory.join("runtime");
|
||||
let workspace = directory.join("workspace");
|
||||
fs::create_dir_all(&runtime).unwrap();
|
||||
fs::create_dir_all(&workspace).unwrap();
|
||||
fs::set_permissions(&runtime, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
fs::set_permissions(&workspace, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let mut value = descriptor("kimi");
|
||||
value["runtimeDirectory"] = json!(runtime);
|
||||
value["cwd"] = json!(workspace);
|
||||
value["sidecarCommand"] = json!(command);
|
||||
value["sidecarArgs"] = json!([]);
|
||||
let desc: AcpxProviderDescriptor = serde_json::from_value(value.clone()).unwrap();
|
||||
let identity = AcpxProviderSessionIdentity {
|
||||
kind: "acpx".to_owned(),
|
||||
normalized_session_id: "session-1".to_owned(),
|
||||
acpx_record_id: "record-1".to_owned(),
|
||||
backend_session_id: "backend-1".to_owned(),
|
||||
agent_session_id: "agent-1".to_owned(),
|
||||
profile_digest: desc.command_digest.clone(),
|
||||
workspace_digest: format!("sha256:{}", "a".repeat(64)),
|
||||
requested_model: desc.model.clone(),
|
||||
effective_model: desc.model.clone(),
|
||||
permission_mode: Some(desc.permission_mode),
|
||||
mode: None,
|
||||
pi_thinking_level: None,
|
||||
claude_effort: None,
|
||||
grok_effort: None,
|
||||
grok_disable_web_search: None,
|
||||
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
|
||||
};
|
||||
let operations = Vec::new();
|
||||
let tools = AuthorizedToolSet {
|
||||
schema: TOOL_SET_SCHEMA.to_owned(),
|
||||
schema_version: 1,
|
||||
catalog_digest: authorized_tool_catalog_digest(&operations).unwrap(),
|
||||
operations,
|
||||
};
|
||||
let previous_digest = profile.canonical_digest().unwrap();
|
||||
let mut state = AcpxDurableState::new(desc, tools, previous_digest.clone());
|
||||
state.lifecycle = "suspended".to_owned();
|
||||
state.identity = Some(identity);
|
||||
let config = test_config(&directory, Some(profile));
|
||||
let mut executor = AcpxCommandExecutor::with_runner_config(&directory, &config);
|
||||
executor.state = Some(state);
|
||||
executor.save_state().unwrap();
|
||||
let before = fs::read(executor.state_path()).unwrap();
|
||||
let mut provider = value;
|
||||
provider["runId"] = json!("run-2");
|
||||
let payload = json!({"provider":provider,"paperclipNextAuthority":{
|
||||
"identity":{"runId":"run-2","normalizedSessionId":"session-1"}
|
||||
}});
|
||||
let mut injected_profile = executor.launch_profile.as_ref().unwrap().clone();
|
||||
injected_profile.candidate_admission = Some(grant.clone());
|
||||
let injected_config = test_config(&directory, Some(injected_profile.clone()));
|
||||
assert!(injected_config.validate().is_err());
|
||||
let prior = executor.state.as_ref().unwrap();
|
||||
assert!(prior
|
||||
.descriptor
|
||||
.session_config(
|
||||
prior.tool_set.clone(),
|
||||
prior.identity.clone(),
|
||||
Some(&injected_profile),
|
||||
)
|
||||
.is_err());
|
||||
assert_eq!(fs::read(executor.state_path()).unwrap(), before);
|
||||
for (field, changed) in [
|
||||
("normalizedSessionId", json!("foreign-session")),
|
||||
("runId", json!("run-1")),
|
||||
] {
|
||||
let mut invalid = payload.clone();
|
||||
invalid["paperclipNextAuthority"]["identity"][field] = changed;
|
||||
assert!(executor.attach_run(&invalid).is_err(), "{field}");
|
||||
assert_eq!(fs::read(executor.state_path()).unwrap(), before);
|
||||
}
|
||||
assert!(executor.attach_run(&json!({"provider":provider})).is_err());
|
||||
executor.attach_run(&payload).unwrap();
|
||||
let updated = executor.state.as_ref().unwrap();
|
||||
assert_eq!(updated.descriptor.run_id, "run-2");
|
||||
assert_eq!(executor.context.run_id, "run-1");
|
||||
let profile = executor.launch_profile.as_ref().unwrap();
|
||||
assert!(profile.candidate_admission.is_none());
|
||||
assert_eq!(updated.launch_profile_digest, previous_digest);
|
||||
assert_eq!(
|
||||
updated.launch_profile_digest,
|
||||
profile.canonical_digest().unwrap()
|
||||
);
|
||||
let launch = updated
|
||||
.descriptor
|
||||
.session_config(
|
||||
updated.tool_set.clone(),
|
||||
updated.identity.clone(),
|
||||
Some(profile),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(launch.candidate_admission.is_none());
|
||||
let saved: AcpxDurableState =
|
||||
serde_json::from_slice(&fs::read(executor.state_path()).unwrap()).unwrap();
|
||||
assert_eq!(saved.descriptor.run_id, "run-2");
|
||||
assert_eq!(saved.launch_profile_digest, updated.launch_profile_digest);
|
||||
assert!(executor.session.is_none());
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
#[cfg(any(
|
||||
all(target_os = "macos", target_arch = "aarch64"),
|
||||
all(target_os = "linux", target_arch = "x86_64")
|
||||
))]
|
||||
fn kimi_cold_restore_fixture(
|
||||
label: &str,
|
||||
) -> (
|
||||
PathBuf,
|
||||
DurableRunnerConfig,
|
||||
Value,
|
||||
Vec<u8>,
|
||||
PathBuf,
|
||||
crate::acpx_candidate_admission::AcpxCandidateAdmission,
|
||||
) {
|
||||
assert!(!acpx_release_profile("kimi").unwrap().qualification_pending);
|
||||
let directory = temporary_directory(label);
|
||||
let mut grant = crate::acpx_candidate_admission::test_admission("run-1", "session-1");
|
||||
grant.agent = "kimi".to_owned();
|
||||
grant.model = "explicit-test-model".to_owned();
|
||||
grant.profile_digest = acpx_release_profile("kimi")
|
||||
.unwrap()
|
||||
.command_digest
|
||||
.to_owned();
|
||||
grant.platform = if cfg!(target_os = "macos") {
|
||||
"darwin"
|
||||
} else {
|
||||
"linux"
|
||||
}
|
||||
.to_owned();
|
||||
grant.architecture = if cfg!(target_os = "macos") {
|
||||
"arm64"
|
||||
} else {
|
||||
"x64"
|
||||
}
|
||||
.to_owned();
|
||||
grant.native_closure_digest = format!(
|
||||
"sha256:{}",
|
||||
crate::generated_acpx_profiles::kimi_native_closure(
|
||||
&grant.platform,
|
||||
&grant.architecture
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
let command = directory.join("owned-sidecar");
|
||||
let sentinel = directory.join("provider-opened");
|
||||
write_artifact(
|
||||
&command,
|
||||
format!("#!/bin/sh\ntouch '{}'\nexit 1\n", sentinel.display()).as_bytes(),
|
||||
true,
|
||||
);
|
||||
let profile = AcpxLaunchProfile {
|
||||
candidate_admission: None,
|
||||
authority_digest: format!("sha256:{}", "d".repeat(64)),
|
||||
command: command.clone(),
|
||||
args: vec![],
|
||||
artifacts: vec![artifact(&command)],
|
||||
};
|
||||
let runtime = directory.join("runtime");
|
||||
let workspace = directory.join("workspace");
|
||||
fs::create_dir_all(&runtime).unwrap();
|
||||
fs::create_dir_all(&workspace).unwrap();
|
||||
fs::set_permissions(&runtime, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
fs::set_permissions(&workspace, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let mut value = descriptor("kimi");
|
||||
value["runtimeDirectory"] = json!(runtime);
|
||||
value["cwd"] = json!(workspace);
|
||||
value["sidecarCommand"] = json!(command);
|
||||
value["sidecarArgs"] = json!([]);
|
||||
let desc: AcpxProviderDescriptor = serde_json::from_value(value.clone()).unwrap();
|
||||
let identity = AcpxProviderSessionIdentity {
|
||||
kind: "acpx".to_owned(),
|
||||
normalized_session_id: "session-1".to_owned(),
|
||||
acpx_record_id: "record-1".to_owned(),
|
||||
backend_session_id: "backend-1".to_owned(),
|
||||
agent_session_id: "agent-1".to_owned(),
|
||||
profile_digest: desc.command_digest.clone(),
|
||||
workspace_digest: format!("sha256:{}", "a".repeat(64)),
|
||||
requested_model: desc.model.clone(),
|
||||
effective_model: desc.model.clone(),
|
||||
permission_mode: Some(desc.permission_mode),
|
||||
mode: None,
|
||||
pi_thinking_level: None,
|
||||
claude_effort: None,
|
||||
grok_effort: None,
|
||||
grok_disable_web_search: None,
|
||||
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
|
||||
};
|
||||
let operations = Vec::new();
|
||||
let tools = AuthorizedToolSet {
|
||||
schema: TOOL_SET_SCHEMA.to_owned(),
|
||||
schema_version: 1,
|
||||
catalog_digest: authorized_tool_catalog_digest(&operations).unwrap(),
|
||||
operations,
|
||||
};
|
||||
let previous_digest = profile.canonical_digest().unwrap();
|
||||
let mut state = AcpxDurableState::new(desc, tools, previous_digest.clone());
|
||||
state.lifecycle = "suspended".to_owned();
|
||||
state.identity = Some(identity);
|
||||
let config = test_config(&directory, Some(profile));
|
||||
let mut executor = AcpxCommandExecutor::with_runner_config(&directory, &config);
|
||||
executor.state = Some(state);
|
||||
executor.save_state().unwrap();
|
||||
let before = fs::read(executor.state_path()).unwrap();
|
||||
let mut next_config = config.clone();
|
||||
next_config.run_id = "run-2".to_owned();
|
||||
next_config.turn_id = "turn-2".to_owned();
|
||||
next_config.item_id = "item-2".to_owned();
|
||||
grant.run_id = "run-2".to_owned();
|
||||
next_config.validate().unwrap();
|
||||
let mut next_provider = value;
|
||||
next_provider["runId"] = json!("run-2");
|
||||
(
|
||||
directory,
|
||||
next_config,
|
||||
json!({"provider": next_provider}),
|
||||
before,
|
||||
sentinel,
|
||||
grant,
|
||||
)
|
||||
}
|
||||
|
||||
fn cold_attach_command(payload: Value) -> Command {
|
||||
Command {
|
||||
schema: "paperclip.prp.command.v1".into(),
|
||||
command_id: "cold-attach".into(),
|
||||
controller_seq: 1,
|
||||
command_type: "run.attach".into(),
|
||||
issued_at: "2026-10-30T00:00:00.000Z".into(),
|
||||
deadline_at: None,
|
||||
precondition: None,
|
||||
payload,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(any(
|
||||
all(target_os = "macos", target_arch = "aarch64"),
|
||||
all(target_os = "linux", target_arch = "x86_64")
|
||||
))]
|
||||
fn released_kimi_governed_wait_cold_restore_uses_ordinary_startup_without_grant() {
|
||||
let (directory, next_config, payload, before, sentinel, _) =
|
||||
kimi_cold_restore_fixture("kimi-candidate-cold-governed-wait");
|
||||
let mut recovered = AcpxCommandExecutor::with_runner_config(&directory, &next_config);
|
||||
recovered.restore().expect("released cold Kimi restore retains its settled prior session under the same full ordinary profile");
|
||||
assert!(recovered.session.is_none());
|
||||
assert!(recovered.poll_events().unwrap().is_empty());
|
||||
assert!(!sentinel.exists());
|
||||
recovered.attach_run(&payload)
|
||||
.expect("released cold replacement attaches with the ordinary current startup and no candidate record");
|
||||
let saved: AcpxDurableState =
|
||||
serde_json::from_slice(&fs::read(recovered.state_path()).unwrap()).unwrap();
|
||||
assert_eq!(saved.descriptor.run_id, "run-2");
|
||||
assert_eq!(
|
||||
saved.launch_profile_digest,
|
||||
next_config
|
||||
.acpx_launch_profile
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.canonical_digest()
|
||||
.unwrap()
|
||||
);
|
||||
assert!(recovered.session.is_none());
|
||||
assert!(!sentinel.exists());
|
||||
assert_ne!(fs::read(recovered.state_path()).unwrap(), before);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(any(
|
||||
all(target_os = "macos", target_arch = "aarch64"),
|
||||
all(target_os = "linux", target_arch = "x86_64")
|
||||
))]
|
||||
fn released_kimi_cold_restore_rejects_candidate_injection_and_changed_full_profile() {
|
||||
for field in [
|
||||
"company",
|
||||
"model",
|
||||
"expiry",
|
||||
"expired",
|
||||
"profile",
|
||||
"closure",
|
||||
"platform",
|
||||
"architecture",
|
||||
"session",
|
||||
"grant_run",
|
||||
"injected_candidate",
|
||||
"context_run",
|
||||
"authority",
|
||||
"command",
|
||||
"args",
|
||||
"artifact",
|
||||
"missing_profile",
|
||||
"saved_digest",
|
||||
] {
|
||||
let (directory, mut config, payload, before, sentinel, candidate) =
|
||||
kimi_cold_restore_fixture("kimi-released-cold-reject");
|
||||
assert!(config
|
||||
.acpx_launch_profile
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.candidate_admission
|
||||
.is_none());
|
||||
match field {
|
||||
"context_run" => config.run_id = "foreign-run".into(),
|
||||
"missing_profile" => config.acpx_launch_profile = None,
|
||||
"saved_digest" => {
|
||||
let mut state: AcpxDurableState = serde_json::from_slice(&before).unwrap();
|
||||
state.launch_profile_digest = format!("sha256:{}", "0".repeat(64));
|
||||
fs::write(
|
||||
directory.join("acpx-provider-state.json"),
|
||||
serde_json::to_vec(&state).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
_ => {
|
||||
let profile = config.acpx_launch_profile.as_mut().unwrap();
|
||||
match field {
|
||||
"authority" => {
|
||||
profile.authority_digest = format!("sha256:{}", "0".repeat(64))
|
||||
}
|
||||
"command" => profile.command = directory.join("foreign-command"),
|
||||
"args" => profile.args.push("foreign-argument".into()),
|
||||
"artifact" => {
|
||||
profile.artifacts[0].sha256 = format!("sha256:{}", "0".repeat(64))
|
||||
}
|
||||
_ => {
|
||||
let mut grant = candidate;
|
||||
match field {
|
||||
"company" => grant.company_id = "foreign-company".into(),
|
||||
"model" => grant.model = "foreign-model".into(),
|
||||
"expiry" => grant.expires_at += 1,
|
||||
"expired" => grant.expires_at = 1,
|
||||
"profile" => {
|
||||
grant.profile_digest = format!("sha256:{}", "0".repeat(64))
|
||||
}
|
||||
"closure" => {
|
||||
grant.native_closure_digest =
|
||||
format!("sha256:{}", "0".repeat(64))
|
||||
}
|
||||
"platform" => grant.platform = "foreign-platform".into(),
|
||||
"architecture" => {
|
||||
grant.architecture = "foreign-architecture".into()
|
||||
}
|
||||
"session" => grant.normalized_session_id = "foreign-session".into(),
|
||||
"grant_run" => grant.run_id = "foreign-run".into(),
|
||||
"injected_candidate" => {}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
profile.candidate_admission = Some(grant);
|
||||
assert!(config.validate().is_err(), "{field}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut recovered = AcpxCommandExecutor::with_runner_config(&directory, &config);
|
||||
let retained = fs::read(recovered.state_path()).unwrap();
|
||||
assert!(
|
||||
recovered.execute(&cold_attach_command(payload)).is_err(),
|
||||
"{field}"
|
||||
);
|
||||
assert!(recovered.session.is_none(), "{field}");
|
||||
assert!(!sentinel.exists(), "{field}");
|
||||
assert_eq!(
|
||||
fs::read(recovered.state_path()).unwrap(),
|
||||
retained,
|
||||
"{field}"
|
||||
);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(any(
|
||||
all(target_os = "macos", target_arch = "aarch64"),
|
||||
all(target_os = "linux", target_arch = "x86_64")
|
||||
))]
|
||||
fn released_kimi_cold_attach_rejects_unsettled_or_changed_session_before_provider_open() {
|
||||
for field in [
|
||||
"run",
|
||||
"session",
|
||||
"model",
|
||||
"workspace",
|
||||
"active_turn",
|
||||
"pending_event",
|
||||
"exit_unconfirmed",
|
||||
"identity",
|
||||
"closed",
|
||||
] {
|
||||
let (directory, config, mut payload, before, sentinel, _) =
|
||||
kimi_cold_restore_fixture("kimi-candidate-cold-binding");
|
||||
let mut recovered = AcpxCommandExecutor::with_runner_config(&directory, &config);
|
||||
match field {
|
||||
"run" => payload["provider"]["runId"] = json!("foreign-run"),
|
||||
"session" => payload["provider"]["normalizedSessionId"] = json!("foreign-session"),
|
||||
"model" => payload["provider"]["model"] = json!("foreign-model"),
|
||||
"workspace" => {
|
||||
payload["provider"]["cwd"] = json!(directory.join("foreign-workspace"))
|
||||
}
|
||||
_ => {
|
||||
let mut state: AcpxDurableState = serde_json::from_slice(&before).unwrap();
|
||||
match field {
|
||||
"active_turn" => state.active_turn_id = Some("active-turn".into()),
|
||||
"pending_event" => {
|
||||
state
|
||||
.push(NormalizedProviderEvent {
|
||||
event_type: "runtime_request.created".into(),
|
||||
priority: EventPriority::P0,
|
||||
payload: json!({"request":pending_input_request("input-1")}),
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
"exit_unconfirmed" => state.provider_exit_unconfirmed = true,
|
||||
"identity" => state.identity = None,
|
||||
"closed" => state.lifecycle = "closed".into(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
fs::write(recovered.state_path(), serde_json::to_vec(&state).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
let retained = fs::read(recovered.state_path()).unwrap();
|
||||
assert!(
|
||||
recovered.execute(&cold_attach_command(payload)).is_err(),
|
||||
"{field}"
|
||||
);
|
||||
assert!(recovered.session.is_none(), "{field}");
|
||||
assert!(!sentinel.exists(), "{field}");
|
||||
assert_eq!(
|
||||
fs::read(recovered.state_path()).unwrap(),
|
||||
retained,
|
||||
"{field}"
|
||||
);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(any(
|
||||
all(target_os = "macos", target_arch = "aarch64"),
|
||||
all(target_os = "linux", target_arch = "x86_64")
|
||||
))]
|
||||
fn released_kimi_cold_attach_post_rename_failure_quarantines_public_execution() {
|
||||
let (directory, config, payload, before, sentinel, _) =
|
||||
kimi_cold_restore_fixture("kimi-candidate-cold-write-failure");
|
||||
let mut recovered = AcpxCommandExecutor::with_runner_config(&directory, &config);
|
||||
recovered.restore().unwrap();
|
||||
let previous_state = recovered.state.clone();
|
||||
recovered.fail_after_state_rename = true;
|
||||
let command = cold_attach_command(payload);
|
||||
let error = recovered.execute(&command).unwrap_err();
|
||||
assert!(error
|
||||
.to_string()
|
||||
.contains("injected post-rename sync failure"));
|
||||
assert_ne!(recovered.state, previous_state);
|
||||
assert!(recovered.session.is_none());
|
||||
assert!(!sentinel.exists());
|
||||
let disk = fs::read(recovered.state_path()).unwrap();
|
||||
assert_ne!(disk, before);
|
||||
let saved: AcpxDurableState = serde_json::from_slice(&disk).unwrap();
|
||||
assert_eq!(saved.descriptor.run_id, "run-2");
|
||||
assert_eq!(
|
||||
saved.launch_profile_digest,
|
||||
config
|
||||
.acpx_launch_profile
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.canonical_digest()
|
||||
.unwrap()
|
||||
);
|
||||
recovered.fail_after_state_rename = false;
|
||||
for result in [
|
||||
recovered.retained_events().map(|_| ()),
|
||||
recovered.poll_events().map(|_| ()),
|
||||
recovered.acknowledge_events(0),
|
||||
recovered.restore(),
|
||||
recovered.save_state(),
|
||||
recovered.shutdown(),
|
||||
] {
|
||||
assert_eq!(result.unwrap_err().to_string(), error.to_string());
|
||||
}
|
||||
assert_eq!(
|
||||
recovered.execute(&command).unwrap_err().to_string(),
|
||||
error.to_string()
|
||||
);
|
||||
assert_eq!(fs::read(recovered.state_path()).unwrap(), disk);
|
||||
assert!(!sentinel.exists());
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -11,7 +11,7 @@ pub(crate) enum RunAttachmentPolicy {
|
||||
|
||||
pub(crate) fn run_attachment_policy(agent: &str) -> RunAttachmentPolicy {
|
||||
match agent {
|
||||
"cursor" | "copilot" | "pi" => RunAttachmentPolicy::AuthenticatedRunGrants,
|
||||
"cursor" | "copilot" | "pi" | "kimi" => RunAttachmentPolicy::AuthenticatedRunGrants,
|
||||
"claude" => RunAttachmentPolicy::AuthenticatedAssetPaths,
|
||||
_ => RunAttachmentPolicy::ImmutableInstructions,
|
||||
}
|
||||
|
||||
@@ -109,8 +109,8 @@ pub struct AcpxProviderState {
|
||||
plan_revision: u64,
|
||||
assistant_text: String,
|
||||
assistant_message_id: Option<String>,
|
||||
pi_assistant_message: Option<String>,
|
||||
pi_assistant_message_ids: BTreeSet<String>,
|
||||
native_assistant_message: Option<String>,
|
||||
native_assistant_message_ids: BTreeSet<String>,
|
||||
pending_tools: BTreeMap<String, AcpxPendingTool>,
|
||||
pending_tool_input_bytes: usize,
|
||||
pending_permissions: BTreeMap<String, (usize, Value)>,
|
||||
@@ -128,8 +128,8 @@ impl AcpxProviderState {
|
||||
plan_revision: 0,
|
||||
assistant_text: String::new(),
|
||||
assistant_message_id: None,
|
||||
pi_assistant_message: None,
|
||||
pi_assistant_message_ids: BTreeSet::new(),
|
||||
native_assistant_message: None,
|
||||
native_assistant_message_ids: BTreeSet::new(),
|
||||
pending_tools: BTreeMap::new(),
|
||||
pending_tool_input_bytes: 0,
|
||||
pending_permissions: BTreeMap::new(),
|
||||
@@ -222,8 +222,8 @@ impl AcpxProviderState {
|
||||
self.plan_revision = 0;
|
||||
self.assistant_text.clear();
|
||||
self.assistant_message_id = None;
|
||||
self.pi_assistant_message = None;
|
||||
self.pi_assistant_message_ids.clear();
|
||||
self.native_assistant_message = None;
|
||||
self.native_assistant_message_ids.clear();
|
||||
self.semantic_result = None;
|
||||
self.attempted_turn_controls.clear();
|
||||
Ok(())
|
||||
@@ -371,9 +371,9 @@ impl AcpxProviderState {
|
||||
}])
|
||||
}
|
||||
AcpxEventPayload::TurnTerminal { status, error } => {
|
||||
if status == AcpxTurnStatus::Completed && self.pi_assistant_message.is_some() {
|
||||
if status == AcpxTurnStatus::Completed && self.native_assistant_message.is_some() {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"Pi assistant message ended without its native boundary",
|
||||
"ACP assistant message ended without its native boundary",
|
||||
));
|
||||
}
|
||||
let turn_id = event
|
||||
@@ -394,8 +394,8 @@ impl AcpxProviderState {
|
||||
self.assistant_text.clear();
|
||||
}
|
||||
self.assistant_message_id = None;
|
||||
self.pi_assistant_message = None;
|
||||
self.pi_assistant_message_ids.clear();
|
||||
self.native_assistant_message = None;
|
||||
self.native_assistant_message_ids.clear();
|
||||
events.push(AcpxProviderStateEvent::TurnTerminal {
|
||||
turn_id,
|
||||
status,
|
||||
@@ -518,6 +518,10 @@ impl AcpxProviderState {
|
||||
};
|
||||
if kind == AcpxRuntimeEventKind::TextDelta
|
||||
&& payload.get("piMessageHistory").and_then(Value::as_bool) != Some(true)
|
||||
&& payload
|
||||
.get("assistantMessageHistory")
|
||||
.and_then(Value::as_bool)
|
||||
!= Some(true)
|
||||
{
|
||||
let provider_message_id = payload
|
||||
.get("messageId")
|
||||
@@ -534,55 +538,67 @@ impl AcpxProviderState {
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let mut native_boundary_clears = false;
|
||||
if let Some(boundary) = payload.get("piMessageBoundary") {
|
||||
if payload.get("assistantMessageBoundary").is_some()
|
||||
&& payload.get("piMessageBoundary").is_some()
|
||||
{
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACP assistant boundary has ambiguous representations",
|
||||
));
|
||||
}
|
||||
if let Some(boundary) = payload
|
||||
.get("assistantMessageBoundary")
|
||||
.or_else(|| payload.get("piMessageBoundary"))
|
||||
{
|
||||
let phase = boundary.get("phase").and_then(Value::as_str);
|
||||
let reason = boundary.get("stopReason").and_then(Value::as_str);
|
||||
if !raw_text.is_empty() || provider_message_id.is_none() {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"Pi assistant boundary contains invalid content or identity",
|
||||
"ACP assistant boundary contains invalid content or identity",
|
||||
));
|
||||
}
|
||||
match phase {
|
||||
Some("start") if self.pi_assistant_message.is_none() && reason.is_none() => {
|
||||
if self.pi_assistant_message_ids.len() >= 4096
|
||||
Some("start")
|
||||
if self.native_assistant_message.is_none() && reason.is_none() =>
|
||||
{
|
||||
if self.native_assistant_message_ids.len() >= 4096
|
||||
|| !self
|
||||
.pi_assistant_message_ids
|
||||
.native_assistant_message_ids
|
||||
.insert(provider_message_id.clone().unwrap())
|
||||
{
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"Pi assistant identity is duplicated or exceeds its bound",
|
||||
"ACP assistant identity is duplicated or exceeds its bound",
|
||||
));
|
||||
}
|
||||
self.pi_assistant_message = provider_message_id.clone();
|
||||
self.native_assistant_message = provider_message_id.clone();
|
||||
native_boundary_clears = true;
|
||||
}
|
||||
Some("end")
|
||||
if self.pi_assistant_message.is_some()
|
||||
&& self.pi_assistant_message == provider_message_id =>
|
||||
if self.native_assistant_message.is_some()
|
||||
&& self.native_assistant_message == provider_message_id =>
|
||||
{
|
||||
if !matches!(
|
||||
reason,
|
||||
Some("stop" | "length" | "toolUse" | "error" | "aborted")
|
||||
) {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"Pi assistant stop reason is invalid",
|
||||
"ACP assistant stop reason is invalid",
|
||||
));
|
||||
}
|
||||
self.pi_assistant_message = None;
|
||||
self.native_assistant_message = None;
|
||||
native_boundary_clears = !matches!(reason, Some("stop" | "length"));
|
||||
}
|
||||
_ => {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"Pi assistant boundaries are out of order",
|
||||
"ACP assistant boundaries are out of order",
|
||||
))
|
||||
}
|
||||
}
|
||||
} else if !self.pi_assistant_message_ids.is_empty()
|
||||
&& (self.pi_assistant_message.is_none()
|
||||
|| self.pi_assistant_message != provider_message_id)
|
||||
} else if !self.native_assistant_message_ids.is_empty()
|
||||
&& (self.native_assistant_message.is_none()
|
||||
|| self.native_assistant_message != provider_message_id)
|
||||
{
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"Pi assistant delta has no matching native message",
|
||||
"ACP assistant delta has no matching native message",
|
||||
));
|
||||
}
|
||||
// ACP can produce more than one assistant message in a turn. All
|
||||
|
||||
@@ -216,6 +216,7 @@ impl AcpxSidecarTransport {
|
||||
"pi" => &[],
|
||||
"cursor" => &["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"],
|
||||
"copilot" => &["COPILOT_GITHUB_TOKEN"],
|
||||
"kimi" => &["KIMI_API_KEY"],
|
||||
"antigravity" => &[
|
||||
"GEMINI_API_KEY",
|
||||
"PAPERCLIP_ACPX_ANTIGRAVITY_AUTH_JSON_SECRET",
|
||||
@@ -254,7 +255,7 @@ impl AcpxSidecarTransport {
|
||||
"PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT",
|
||||
"PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST",
|
||||
];
|
||||
if matches!(agent, "pi" | "cursor" | "copilot" | "antigravity") {
|
||||
if matches!(agent, "pi" | "cursor" | "copilot" | "kimi" | "antigravity") {
|
||||
// Credential values alone are not proof of an explicit task binding.
|
||||
// The sidecar checks this controller-minted provider/session marker.
|
||||
keys.push("PAPERCLIP_ACPX_CREDENTIAL_BINDING");
|
||||
|
||||
+2
-2
@@ -769,8 +769,8 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.map(|value| value.parse::<usize>())
|
||||
.transpose()?
|
||||
.unwrap_or(64);
|
||||
if !(1..=4096).contains(&held_output_count) {
|
||||
return Err("--held-output-count must be between 1 and 4096".into());
|
||||
if !(1..=8192).contains(&held_output_count) {
|
||||
return Err("--held-output-count must be between 1 and 8192".into());
|
||||
}
|
||||
let exit_after_turn_start = args.iter().any(|value| value == "--exit-after-turn-start");
|
||||
let exit_after_turn_completion = args
|
||||
|
||||
@@ -671,10 +671,15 @@ mod tests {
|
||||
args.extend(options.into_iter().map(str::to_owned));
|
||||
assert!(metadata_with_provider_packs(&args).is_err());
|
||||
}
|
||||
let missing_root = if cfg!(windows) {
|
||||
r"C:\missing-provider-pack"
|
||||
} else {
|
||||
"/missing-provider-pack"
|
||||
};
|
||||
let metadata = metadata_with_provider_packs(&[
|
||||
"--build-metadata".to_owned(),
|
||||
"--provider-pack-root".to_owned(),
|
||||
"/missing-provider-pack".to_owned(),
|
||||
missing_root.to_owned(),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(metadata["binaryContractVersion"], 2);
|
||||
|
||||
@@ -4874,6 +4874,7 @@ done
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")]
|
||||
fn verified_opencode_proxy_executes_the_descriptor_safe_commonjs_bundle() {
|
||||
let nonce = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
|
||||
@@ -52,6 +52,7 @@ const RESERVED: &[&str] = &[
|
||||
"GITHUB_ENTERPRISE_TOKEN",
|
||||
"GITHUB_TOKEN",
|
||||
"HOME",
|
||||
"KIMI_API_KEY",
|
||||
"NODE_OPTIONS",
|
||||
"NODE_PATH",
|
||||
"OPENAI_API_KEY",
|
||||
@@ -209,6 +210,7 @@ mod tests {
|
||||
"[\"PAPERCLIP_RUNTIME_NOTIFICATION_CONTEXT\"]",
|
||||
"[\"PAPERCLIP_RUNTIME_NOTIFICATION_HELPER\"]",
|
||||
"[\"OPENAI_API_KEY\"]",
|
||||
"[\"KIMI_API_KEY\"]",
|
||||
"[\"LD_PRELOAD\"]",
|
||||
"[\"bad=name\"]",
|
||||
"[\"CUSTOM\",\"CUSTOM\"]",
|
||||
@@ -226,6 +228,14 @@ mod tests {
|
||||
assert_eq!(command.get_envs().count(), 0);
|
||||
}
|
||||
let mut command = Command::new("unused");
|
||||
assert!(apply_configured_environment(&mut command, |key| match key {
|
||||
MARKER => Some("[\"KIMI_API_KEY\"]".into()),
|
||||
"KIMI_API_KEY" => Some("selected-code-key".into()),
|
||||
_ => None,
|
||||
})
|
||||
.is_err());
|
||||
assert_eq!(command.get_envs().count(), 0);
|
||||
let mut command = Command::new("unused");
|
||||
assert!(
|
||||
apply_configured_environment(&mut command, |key| Some(if key == MARKER {
|
||||
"[\"CUSTOM\"]".into()
|
||||
|
||||
@@ -199,7 +199,6 @@ pub(crate) fn next_authority_config(
|
||||
next.normalized_session_id = read_identity("normalizedSessionId")?;
|
||||
next.turn_id = read_identity("turnId")?;
|
||||
next.item_id = read_identity("itemId")?;
|
||||
next.validate()?;
|
||||
if next.runner_instance_id != current.runner_instance_id
|
||||
|| next.environment_lease_id != current.environment_lease_id
|
||||
|| next.normalized_session_id != current.normalized_session_id
|
||||
@@ -209,6 +208,47 @@ pub(crate) fn next_authority_config(
|
||||
"run.attach authority changed an immutable session binding",
|
||||
));
|
||||
}
|
||||
if let Some(admission) = current
|
||||
.acpx_launch_profile
|
||||
.as_ref()
|
||||
.and_then(|profile| profile.candidate_admission.as_ref())
|
||||
{
|
||||
if admission.agent == "kimi" {
|
||||
let proposed: crate::acpx_candidate_admission::AcpxCandidateAdmission =
|
||||
serde_json::from_value(boundary.get("candidateAdmission").cloned().ok_or_else(
|
||||
|| {
|
||||
DurableRunnerError::invalid(
|
||||
"Kimi authority rotation requires exact trusted candidate admission",
|
||||
)
|
||||
},
|
||||
)?)
|
||||
.map_err(|_| {
|
||||
DurableRunnerError::invalid("Kimi authority rotation admission is invalid")
|
||||
})?;
|
||||
admission
|
||||
.validate_next_run(
|
||||
&proposed,
|
||||
"kimi",
|
||||
&admission.model,
|
||||
&next.run_id,
|
||||
&next.normalized_session_id,
|
||||
)
|
||||
.map_err(|error| DurableRunnerError::invalid(error.to_string()))?;
|
||||
next.acpx_launch_profile
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.candidate_admission = Some(proposed);
|
||||
} else if boundary.get("candidateAdmission").is_some() {
|
||||
return Err(DurableRunnerError::invalid(
|
||||
"candidate authority rotation is not admitted",
|
||||
));
|
||||
}
|
||||
} else if boundary.get("candidateAdmission").is_some() {
|
||||
return Err(DurableRunnerError::invalid(
|
||||
"authority rotation cannot create candidate admission",
|
||||
));
|
||||
}
|
||||
next.validate()?;
|
||||
Ok(Some(next))
|
||||
}
|
||||
|
||||
@@ -5104,4 +5144,130 @@ mod tests {
|
||||
assert_eq!(executor.calls, 1);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
#[test]
|
||||
#[cfg(any(
|
||||
all(target_os = "macos", target_arch = "aarch64"),
|
||||
all(target_os = "linux", target_arch = "x86_64")
|
||||
))]
|
||||
fn released_kimi_handoff_uses_ordinary_authority_and_rejects_candidate_injection() {
|
||||
assert!(
|
||||
!crate::generated_acpx_profiles::acpx_release_profile("kimi")
|
||||
.unwrap()
|
||||
.qualification_pending
|
||||
);
|
||||
let directory =
|
||||
std::env::temp_dir().join(format!("kimi-grant-handoff-{}", std::process::id()));
|
||||
let mut current = config(directory);
|
||||
let mut grant = crate::acpx_candidate_admission::test_admission(
|
||||
"run_1",
|
||||
¤t.normalized_session_id,
|
||||
);
|
||||
grant.agent = "kimi".to_owned();
|
||||
grant.profile_digest = crate::generated_acpx_profiles::acpx_release_profile("kimi")
|
||||
.unwrap()
|
||||
.command_digest
|
||||
.to_owned();
|
||||
grant.platform = if cfg!(target_os = "macos") {
|
||||
"darwin"
|
||||
} else {
|
||||
"linux"
|
||||
}
|
||||
.to_owned();
|
||||
grant.architecture = if cfg!(target_os = "macos") {
|
||||
"arm64"
|
||||
} else {
|
||||
"x64"
|
||||
}
|
||||
.to_owned();
|
||||
grant.native_closure_digest = format!(
|
||||
"sha256:{}",
|
||||
crate::generated_acpx_profiles::kimi_native_closure(
|
||||
&grant.platform,
|
||||
&grant.architecture
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
current.acpx_launch_profile = Some(crate::durable::AcpxLaunchProfile {
|
||||
authority_digest: format!("sha256:{}", "a".repeat(64)),
|
||||
command: PathBuf::from("/fixture/node"),
|
||||
args: vec!["/fixture/sidecar.cjs".to_owned()],
|
||||
artifacts: vec![crate::durable::QualifiedLaunchArtifact {
|
||||
path: PathBuf::from("/fixture/node"),
|
||||
sha256: format!("sha256:{}", "b".repeat(64)),
|
||||
}],
|
||||
candidate_admission: None,
|
||||
});
|
||||
current.validate().unwrap();
|
||||
let mut next_grant = grant.clone();
|
||||
next_grant.run_id = "run_2".to_owned();
|
||||
let mut attach = command("run.attach");
|
||||
attach.payload = json!({"paperclipNextAuthority":{
|
||||
"identity":{"runnerInstanceId":current.runner_instance_id,"environmentLeaseId":current.environment_lease_id,
|
||||
"runId":"run_2","normalizedSessionId":current.normalized_session_id,"turnId":"turn_2","itemId":"item_2"},
|
||||
"connection":{"mode":"external"}
|
||||
}});
|
||||
let next = next_authority_config(&attach, ¤t).unwrap().unwrap();
|
||||
assert!(next
|
||||
.acpx_launch_profile
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.candidate_admission
|
||||
.is_none());
|
||||
assert_eq!(next.acpx_launch_profile, current.acpx_launch_profile);
|
||||
assert_eq!(next.run_id, "run_2");
|
||||
assert_eq!(next.turn_id, "turn_2");
|
||||
assert_eq!(next.item_id, "item_2");
|
||||
let mut injected = attach.clone();
|
||||
injected.payload["paperclipNextAuthority"]["candidateAdmission"] = json!(next_grant);
|
||||
assert!(next_authority_config(&injected, ¤t).is_err());
|
||||
assert_eq!(next.runner_instance_id, current.runner_instance_id);
|
||||
assert_eq!(next.environment_lease_id, current.environment_lease_id);
|
||||
assert_eq!(next.normalized_session_id, current.normalized_session_id);
|
||||
for (field, value) in [
|
||||
("companyId", json!("foreign-company")),
|
||||
("agent", json!("antigravity")),
|
||||
("model", json!("different-model")),
|
||||
("profileDigest", json!(format!("sha256:{}", "f".repeat(64)))),
|
||||
(
|
||||
"nativeClosureDigest",
|
||||
json!(format!("sha256:{}", "e".repeat(64))),
|
||||
),
|
||||
("expiresAt", json!(grant.expires_at + 1)),
|
||||
("runId", json!("unbound-run")),
|
||||
("normalizedSessionId", json!("foreign-session")),
|
||||
] {
|
||||
let mut changed = injected.clone();
|
||||
changed.payload["paperclipNextAuthority"]["candidateAdmission"][field] = value;
|
||||
assert!(
|
||||
next_authority_config(&changed, ¤t).is_err(),
|
||||
"{field}"
|
||||
);
|
||||
}
|
||||
for field in [
|
||||
"runnerInstanceId",
|
||||
"environmentLeaseId",
|
||||
"normalizedSessionId",
|
||||
] {
|
||||
let mut changed = attach.clone();
|
||||
changed.payload["paperclipNextAuthority"]["identity"][field] = json!("foreign");
|
||||
assert!(
|
||||
next_authority_config(&changed, ¤t).is_err(),
|
||||
"{field}"
|
||||
);
|
||||
}
|
||||
let mut missing = injected.clone();
|
||||
missing.payload["paperclipNextAuthority"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("candidateAdmission");
|
||||
assert!(next_authority_config(&missing, ¤t).unwrap().is_some());
|
||||
let mut stale_candidate = current.clone();
|
||||
stale_candidate
|
||||
.acpx_launch_profile
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.candidate_admission = Some(grant);
|
||||
assert!(stale_candidate.validate().is_err());
|
||||
assert!(next_authority_config(&injected, &stale_candidate).is_err());
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,18 @@ pub(crate) struct AcpxReleaseProfile {
|
||||
pub qualification_pending: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn kimi_native_closure(platform: &str, architecture: &str) -> Option<&'static str> {
|
||||
match (platform, architecture) {
|
||||
("darwin", "arm64") => {
|
||||
Some("6038325abd0431c30097ff1f572a9b29816e1b4da372d8cb86b2ca5d18ea34e7")
|
||||
}
|
||||
("linux", "x64") => {
|
||||
Some("105d8394adbbc76a953a12cc51b5288eb940ad693291e584f6cdc6407213164d")
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn acpx_release_profile(agent: &str) -> Option<AcpxReleaseProfile> {
|
||||
Some(match agent {
|
||||
"antigravity" => AcpxReleaseProfile {
|
||||
@@ -85,6 +97,16 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option<AcpxReleaseProfile> {
|
||||
requires_provider_policy: false,
|
||||
qualification_pending: false,
|
||||
},
|
||||
"kimi" => AcpxReleaseProfile {
|
||||
agent_server_package: "@moonshot-ai/kimi-code",
|
||||
agent_server_version: "2.3.3",
|
||||
agent_runtime_package: None,
|
||||
agent_runtime_version: None,
|
||||
command_digest:
|
||||
"sha256:6ee5fb7ac18c1361ac63effe9117d08ed129add8349756a140e73e0c86ba5da7",
|
||||
requires_provider_policy: true,
|
||||
qualification_pending: false,
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -138,7 +138,12 @@ impl NativeProviderCommandExecutor {
|
||||
.config
|
||||
.acpx_launch_profile
|
||||
.as_ref()
|
||||
.is_some_and(|profile| profile.candidate_admission.is_some())
|
||||
.is_some_and(|profile| {
|
||||
profile
|
||||
.candidate_admission
|
||||
.as_ref()
|
||||
.is_some_and(|admission| admission.agent == "antigravity")
|
||||
})
|
||||
&& [
|
||||
CODEX_PROVIDER_STATE_FILE,
|
||||
ACPX_PROVIDER_STATE_FILE,
|
||||
|
||||
@@ -454,6 +454,7 @@ impl VerifiedProcessLaunch {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
pub(crate) fn spawn_stdio(
|
||||
&self,
|
||||
environment: &std::collections::BTreeMap<String, String>,
|
||||
|
||||
@@ -176,92 +176,103 @@ fn promotes_only_the_latest_provider_message_as_the_terminal_reply() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_native_boundaries_keep_progress_but_never_promote_tool_narration_or_history() {
|
||||
for final_reason in ["stop", "length", "toolUse", "error", "aborted", "empty"] {
|
||||
let mut state = AcpxProviderState::new("run-1").unwrap();
|
||||
state.begin_turn("turn-1").unwrap();
|
||||
let mut messages = vec![
|
||||
json!({"type":"text_delta","messageId":"history","text":"Earlier session answer","piMessageHistory":true}),
|
||||
json!({"type":"text_delta","messageId":"first","text":"","piMessageBoundary":{"phase":"start"}}),
|
||||
json!({"type":"text_delta","messageId":"first","text":"Calling finish."}),
|
||||
json!({"type":"text_delta","messageId":"first","text":"","piMessageBoundary":{"phase":"end","stopReason":"toolUse"}}),
|
||||
json!({"type":"text_delta","messageId":"last","text":"","piMessageBoundary":{"phase":"start"}}),
|
||||
];
|
||||
if final_reason != "empty" {
|
||||
messages.push(json!({"type":"text_delta","messageId":"last","text":"EXACT_MARKER"}));
|
||||
}
|
||||
messages.push(json!({"type":"text_delta","messageId":"last","text":"","piMessageBoundary":{"phase":"end","stopReason":if final_reason == "empty" { "stop" } else { final_reason }}}));
|
||||
let count = messages.len() as u64;
|
||||
for (index, payload) in messages.into_iter().enumerate() {
|
||||
state
|
||||
fn native_boundaries_keep_progress_but_never_promote_tool_narration_or_history() {
|
||||
for (boundary_field, history_field) in [
|
||||
("piMessageBoundary", "piMessageHistory"),
|
||||
("assistantMessageBoundary", "assistantMessageHistory"),
|
||||
] {
|
||||
for final_reason in ["stop", "length", "toolUse", "error", "aborted", "empty"] {
|
||||
let mut state = AcpxProviderState::new("run-1").unwrap();
|
||||
state.begin_turn("turn-1").unwrap();
|
||||
let mut messages = vec![
|
||||
json!({"type":"text_delta","messageId":"history","text":"Earlier session answer",(history_field):true}),
|
||||
json!({"type":"text_delta","messageId":"first","text":"",(boundary_field):{"phase":"start"}}),
|
||||
json!({"type":"text_delta","messageId":"first","text":"Calling finish."}),
|
||||
json!({"type":"text_delta","messageId":"first","text":"",(boundary_field):{"phase":"end","stopReason":"toolUse"}}),
|
||||
json!({"type":"text_delta","messageId":"last","text":"",(boundary_field):{"phase":"start"}}),
|
||||
];
|
||||
if final_reason != "empty" {
|
||||
messages
|
||||
.push(json!({"type":"text_delta","messageId":"last","text":"EXACT_MARKER"}));
|
||||
}
|
||||
messages.push(json!({"type":"text_delta","messageId":"last","text":"",(boundary_field):{"phase":"end","stopReason":if final_reason == "empty" { "stop" } else { final_reason }}}));
|
||||
let count = messages.len() as u64;
|
||||
for (index, payload) in messages.into_iter().enumerate() {
|
||||
state
|
||||
.accept_event(&event(
|
||||
index as u64 + 1,
|
||||
GeneratedAcpxSidecarEventType::RuntimeEvent,
|
||||
Some("turn-1"),
|
||||
payload,
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
let terminal = state
|
||||
.accept_event(&event(
|
||||
index as u64 + 1,
|
||||
GeneratedAcpxSidecarEventType::RuntimeEvent,
|
||||
count + 1,
|
||||
GeneratedAcpxSidecarEventType::RuntimeTurnTerminal,
|
||||
Some("turn-1"),
|
||||
payload,
|
||||
json!({"status":"completed"}),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
let terminal = state
|
||||
.accept_event(&event(
|
||||
count + 1,
|
||||
GeneratedAcpxSidecarEventType::RuntimeTurnTerminal,
|
||||
Some("turn-1"),
|
||||
json!({"status":"completed"}),
|
||||
))
|
||||
.unwrap();
|
||||
let replies: Vec<_> = terminal
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
if let AcpxProviderStateEvent::AssistantMessage { text, .. } = entry {
|
||||
Some(text.as_str())
|
||||
let replies: Vec<_> = terminal
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
if let AcpxProviderStateEvent::AssistantMessage { text, .. } = entry {
|
||||
Some(text.as_str())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
replies,
|
||||
if matches!(final_reason, "stop" | "length") {
|
||||
vec!["EXACT_MARKER"]
|
||||
} else {
|
||||
None
|
||||
vec![]
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
replies,
|
||||
if matches!(final_reason, "stop" | "length") {
|
||||
vec!["EXACT_MARKER"]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_native_boundary_order_and_terminal_completeness_fail_closed() {
|
||||
for malformed in [
|
||||
json!({"type":"text_delta","messageId":"first","text":"","piMessageBoundary":{"phase":"start"}}),
|
||||
json!({"type":"text_delta","messageId":"other","text":"","piMessageBoundary":{"phase":"end","stopReason":"stop"}}),
|
||||
json!({"type":"text_delta","messageId":"first","text":"","piMessageBoundary":{"phase":"end","stopReason":"invented"}}),
|
||||
json!({"type":"text_delta","messageId":"other","text":"Out of order"}),
|
||||
fn native_boundary_order_and_terminal_completeness_fail_closed() {
|
||||
for (boundary_field, _history_field) in [
|
||||
("piMessageBoundary", "piMessageHistory"),
|
||||
("assistantMessageBoundary", "assistantMessageHistory"),
|
||||
] {
|
||||
for malformed in [
|
||||
json!({"type":"text_delta","messageId":"first","text":"",(boundary_field):{"phase":"start"}}),
|
||||
json!({"type":"text_delta","messageId":"other","text":"",(boundary_field):{"phase":"end","stopReason":"stop"}}),
|
||||
json!({"type":"text_delta","messageId":"first","text":"",(boundary_field):{"phase":"end","stopReason":"invented"}}),
|
||||
json!({"type":"text_delta","messageId":"other","text":"Out of order"}),
|
||||
] {
|
||||
let mut state = AcpxProviderState::new("run-1").unwrap();
|
||||
state.begin_turn("turn-1").unwrap();
|
||||
state.accept_event(&event(1, GeneratedAcpxSidecarEventType::RuntimeEvent, Some("turn-1"), json!({"type":"text_delta","messageId":"first","text":"",(boundary_field):{"phase":"start"}}))).unwrap();
|
||||
assert!(state
|
||||
.accept_event(&event(
|
||||
2,
|
||||
GeneratedAcpxSidecarEventType::RuntimeEvent,
|
||||
Some("turn-1"),
|
||||
malformed
|
||||
))
|
||||
.is_err());
|
||||
}
|
||||
let mut state = AcpxProviderState::new("run-1").unwrap();
|
||||
state.begin_turn("turn-1").unwrap();
|
||||
state.accept_event(&event(1, GeneratedAcpxSidecarEventType::RuntimeEvent, Some("turn-1"), json!({"type":"text_delta","messageId":"first","text":"","piMessageBoundary":{"phase":"start"}}))).unwrap();
|
||||
state.accept_event(&event(1, GeneratedAcpxSidecarEventType::RuntimeEvent, Some("turn-1"), json!({"type":"text_delta","messageId":"first","text":"",(boundary_field):{"phase":"start"}}))).unwrap();
|
||||
assert!(state
|
||||
.accept_event(&event(
|
||||
2,
|
||||
GeneratedAcpxSidecarEventType::RuntimeEvent,
|
||||
GeneratedAcpxSidecarEventType::RuntimeTurnTerminal,
|
||||
Some("turn-1"),
|
||||
malformed
|
||||
json!({"status":"completed"})
|
||||
))
|
||||
.is_err());
|
||||
}
|
||||
let mut state = AcpxProviderState::new("run-1").unwrap();
|
||||
state.begin_turn("turn-1").unwrap();
|
||||
state.accept_event(&event(1, GeneratedAcpxSidecarEventType::RuntimeEvent, Some("turn-1"), json!({"type":"text_delta","messageId":"first","text":"","piMessageBoundary":{"phase":"start"}}))).unwrap();
|
||||
assert!(state
|
||||
.accept_event(&event(
|
||||
2,
|
||||
GeneratedAcpxSidecarEventType::RuntimeTurnTerminal,
|
||||
Some("turn-1"),
|
||||
json!({"status":"completed"})
|
||||
))
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -6970,7 +6970,12 @@ fn explicit_skill_input_survives_durable_turn_and_cold_restore() {
|
||||
executor.shutdown().unwrap();
|
||||
drop(executor);
|
||||
let mut restored = CodexCommandExecutor::with_runner_config(&directory, &runner_config);
|
||||
let skill = json!({"type":"skill", "name":"first-task", "path":"/materialized/skills/first-task/SKILL.md"});
|
||||
let skill_path = if cfg!(windows) {
|
||||
r"C:\materialized\skills\first-task\SKILL.md"
|
||||
} else {
|
||||
"/materialized/skills/first-task/SKILL.md"
|
||||
};
|
||||
let skill = json!({"type":"skill", "name":"first-task", "path":skill_path});
|
||||
restored
|
||||
.execute(&command(
|
||||
"turn",
|
||||
|
||||
@@ -558,6 +558,7 @@ fn shutdown_recovered_acpx_fixture(executor: &mut NativeProviderCommandExecutor,
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")]
|
||||
fn acpx_response_delivery_survives_crash_before_journaling_without_replaying_the_response() {
|
||||
for mode in [
|
||||
"permissions-interactive",
|
||||
@@ -633,6 +634,7 @@ fn acpx_response_delivery_survives_crash_before_journaling_without_replaying_the
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")]
|
||||
fn acpx_delivered_response_is_acknowledged_once_before_session_close() {
|
||||
for mode in ["permissions-interactive", "resolutions"] {
|
||||
let directory = temporary_directory("response-ack");
|
||||
@@ -662,6 +664,7 @@ fn acpx_delivered_response_is_acknowledged_once_before_session_close() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")]
|
||||
fn acpx_uncommitted_delivery_expires_after_failed_state_write_without_replay() {
|
||||
for mode in ["permissions-interactive", "resolutions"] {
|
||||
let directory = temporary_directory("response-save-failure");
|
||||
@@ -743,6 +746,7 @@ fn acpx_uncommitted_delivery_expires_after_failed_state_write_without_replay() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")]
|
||||
fn acpx_failed_settlement_ack_replays_the_same_delivered_event_without_expiry() {
|
||||
for mode in ["permissions-interactive", "resolutions"] {
|
||||
let directory = temporary_directory("response-ack-save-failure");
|
||||
@@ -808,6 +812,7 @@ fn acpx_failed_settlement_ack_replays_the_same_delivered_event_without_expiry()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")]
|
||||
fn acpx_uncommitted_expiry_and_cancellation_cannot_escape_through_poll_or_drain() {
|
||||
for (mode, close) in [
|
||||
("permissions-wrong-ack", false),
|
||||
@@ -879,6 +884,7 @@ fn acpx_uncommitted_expiry_and_cancellation_cannot_escape_through_poll_or_drain(
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")]
|
||||
fn acpx_failed_or_cancelled_responses_retain_one_durable_settlement() {
|
||||
for (mode, close) in [
|
||||
("permissions-wrong-ack", false),
|
||||
@@ -937,6 +943,7 @@ fn acpx_failed_or_cancelled_responses_retain_one_durable_settlement() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")]
|
||||
fn preserves_acpx_semantic_disposition_in_the_run_terminal() {
|
||||
let directory = temporary_directory("acpx-blocked");
|
||||
let config = acpx_config(&directory, "turns-reserved-block-terminal");
|
||||
@@ -1072,6 +1079,7 @@ fn preserves_managed_provider_descriptors_through_the_native_selector() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")]
|
||||
fn executes_a_qualified_acpx_profile_through_the_native_selector() {
|
||||
let directory = temporary_directory("acpx");
|
||||
let config = acpx_config(&directory, "turns-reserved-result-terminal");
|
||||
@@ -1131,6 +1139,7 @@ fn executes_a_qualified_acpx_profile_through_the_native_selector() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")]
|
||||
fn routes_reserved_completion_feedback_with_the_full_controller_catalog() {
|
||||
let directory = temporary_directory("acpx-controller-completion");
|
||||
let mode = "turns-reserved-feedback-roundtrip";
|
||||
@@ -1215,6 +1224,7 @@ fn routes_reserved_completion_feedback_with_the_full_controller_catalog() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")]
|
||||
fn acpx_transport_failure_retains_its_error_without_reopening_the_provider() {
|
||||
let directory = temporary_directory("acpx-transport-failure");
|
||||
let mode = "turns-malformed-activity";
|
||||
@@ -1276,6 +1286,7 @@ fn acpx_transport_failure_retains_its_error_without_reopening_the_provider() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")]
|
||||
fn provider_death_expires_input_and_commits_failure_without_querying_the_dead_provider() {
|
||||
let directory = temporary_directory("acpx-provider-death");
|
||||
let mode = "turns-provider-death";
|
||||
@@ -1381,6 +1392,7 @@ fn provider_death_expires_input_and_commits_failure_without_querying_the_dead_pr
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")]
|
||||
fn resumes_an_idle_acpx_session_in_a_cold_replacement_runner() {
|
||||
let directory = temporary_directory("acpx-cold-idle-recovery");
|
||||
let config = acpx_config(&directory, "turns-reserved-result-terminal");
|
||||
@@ -1432,6 +1444,7 @@ fn resumes_an_idle_acpx_session_in_a_cold_replacement_runner() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")]
|
||||
fn keeps_native_acpx_semantic_events_on_the_durable_controller_turn() {
|
||||
let directory = temporary_directory("acpx-durable-turn-correlation");
|
||||
let config = acpx_config(&directory, "turns-tool");
|
||||
@@ -1481,6 +1494,7 @@ fn keeps_native_acpx_semantic_events_on_the_durable_controller_turn() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")]
|
||||
fn starts_a_distinct_acpx_provider_turn_for_same_run_recovery() {
|
||||
let directory = temporary_directory("acpx-same-run-recovery");
|
||||
let config = acpx_config(&directory, "turns-reserved-result-terminal");
|
||||
@@ -1537,6 +1551,7 @@ fn starts_a_distinct_acpx_provider_turn_for_same_run_recovery() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")]
|
||||
fn executes_opencode_through_the_local_facade_without_codex_event_labels() {
|
||||
let directory = temporary_directory("opencode");
|
||||
let config = opencode_config(&directory);
|
||||
@@ -1605,6 +1620,7 @@ fn executes_opencode_through_the_local_facade_without_codex_event_labels() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")]
|
||||
fn replacement_shutdown_restores_the_persisted_provider_before_cleanup() {
|
||||
let directory = temporary_directory("opencode-replacement-shutdown");
|
||||
let config = opencode_config(&directory);
|
||||
@@ -1730,6 +1746,7 @@ fn rejects_pi_without_an_explicit_model_before_starting_a_sidecar() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")]
|
||||
fn pi_thinking_change_cannot_attach_to_an_existing_provider_identity() {
|
||||
let directory = temporary_directory("pi-thinking-attach");
|
||||
let config = pi_acpx_config(&directory, "bootstrap");
|
||||
@@ -1770,6 +1787,7 @@ fn pi_thinking_change_cannot_attach_to_an_existing_provider_identity() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")]
|
||||
fn stops_an_idle_pi_provider_before_suspension_without_waiting_for_its_close_rpc() {
|
||||
let directory = temporary_directory("pi-idle-suspension");
|
||||
let mut config = pi_acpx_config(&directory, "bootstrap");
|
||||
@@ -1818,6 +1836,7 @@ fn stops_an_idle_pi_provider_before_suspension_without_waiting_for_its_close_rpc
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")]
|
||||
fn idle_pi_stop_cannot_publish_a_reusable_identity_while_its_lifetime_fence_is_held() {
|
||||
let directory = temporary_directory("pi-idle-held-lifetime");
|
||||
// Linux can assign port 0 below the identity contract's dynamic-port range.
|
||||
@@ -1886,6 +1905,7 @@ fn idle_pi_stop_cannot_publish_a_reusable_identity_while_its_lifetime_fence_is_h
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(windows, ignore = "verified launch is not supported on Windows yet")]
|
||||
fn publishes_only_changed_pi_controls_before_the_new_turn() {
|
||||
for (mode, initially_available, turn_available) in [
|
||||
("controls-lazy", false, true),
|
||||
|
||||
@@ -28,6 +28,10 @@ export const ACPX_RELEASE_ATTESTATIONS = Object.freeze({
|
||||
nodeVersion: ["pi-node-pins.ts", "PI_NODE_VERSION"],
|
||||
closure: ["pi-closure-pins.ts", "PI_DISTRIBUTION_CLOSURE_SHA256"],
|
||||
} },
|
||||
kimi: { kind: "closure", pins: {
|
||||
nodeVersion: ["kimi-closure-pins.ts", "KIMI_NODE_VERSION"],
|
||||
closure: ["kimi-closure-pins.ts", "KIMI_DISTRIBUTION_CLOSURE_SHA256"],
|
||||
} },
|
||||
cursor: { kind: "declaration", path: "cursor-contract.json", digestField: "commandDigest" },
|
||||
antigravity: { kind: "declaration", path: "antigravity-distributions.json" },
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ const packageFiles = [
|
||||
"scripts/acpx-release-attestations.mjs", "scripts/generate-acpx-profiles.mjs",
|
||||
"src/providers/grok/launcher.cjs", "src/drivers/acpx/copilot-closure-pins.ts",
|
||||
"src/drivers/acpx/pi-closure-pins.ts", "src/drivers/acpx/pi-node-pins.ts",
|
||||
"src/drivers/acpx/kimi-closure-pins.ts",
|
||||
"src/drivers/acpx/generated-profiles.ts", "runner/crates/runner-core/src/generated_acpx_profiles.rs",
|
||||
];
|
||||
|
||||
@@ -37,7 +38,7 @@ const generate = (root, ...args) => spawnSync(process.execPath, [join(root, "scr
|
||||
|
||||
test("every profile declares how its digest attests the upstream release", async () => {
|
||||
assert.deepEqual(Object.keys(ACPX_RELEASE_ATTESTATIONS).sort(), Object.keys(manifest.profiles).sort());
|
||||
assert.deepEqual(COMPUTED.sort(), ["antigravity", "copilot", "cursor", "grok", "pi"]);
|
||||
assert.deepEqual(COMPUTED.sort(), ["antigravity", "copilot", "cursor", "grok", "kimi", "pi"]);
|
||||
for (const agent of COMPUTED) {
|
||||
assert.equal(manifest.profiles[agent].commandDigest, (await acpxReleaseAttestation(agent, packageRoot)).commandDigest, agent);
|
||||
}
|
||||
@@ -66,10 +67,11 @@ test("a relocated copy of the provider inputs computes the same attestations", a
|
||||
});
|
||||
|
||||
test("the attestations bind no Paperclip source beyond the Grok launcher", async () => {
|
||||
for (const agent of ["copilot", "pi"]) {
|
||||
for (const agent of ["copilot", "pi", "kimi"]) {
|
||||
const { declaration } = await acpxReleaseAttestation(agent, packageRoot);
|
||||
assert.deepEqual(Object.keys(declaration).filter(key => /source/iu.test(key)), [], agent);
|
||||
assert.deepEqual(Object.keys(declaration.closure).sort(), ["darwin-arm64", "darwin-x64", "linux-x64"]);
|
||||
assert.deepEqual(Object.keys(declaration.closure).sort(), agent === "kimi"
|
||||
? ["darwin-arm64", "linux-x64"] : ["darwin-arm64", "darwin-x64", "linux-x64"]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -86,6 +88,10 @@ for (const [name, agents, edit] of [
|
||||
const path = join(root, "src/drivers/acpx/pi-closure-pins.ts");
|
||||
writeFileSync(path, readFileSync(path, "utf8").replace(/"linux-x64": "[a-f0-9]{64}"/u, `"linux-x64": "${"0".repeat(64)}"`));
|
||||
}],
|
||||
["a Kimi platform closure", ["kimi"], root => {
|
||||
const path = join(root, "src/drivers/acpx/kimi-closure-pins.ts");
|
||||
writeFileSync(path, readFileSync(path, "utf8").replace(/"linux-x64": "[a-f0-9]{64}"/u, `"linux-x64": "${"0".repeat(64)}"`));
|
||||
}],
|
||||
["the Grok launcher bytes", ["grok"], root => {
|
||||
const path = join(root, "src/providers/grok/launcher.cjs");
|
||||
writeFileSync(path, `${readFileSync(path, "utf8")}\n// changed\n`);
|
||||
|
||||
@@ -21,6 +21,7 @@ import { dirname, join, relative, resolve } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseProviderPackArguments, materializeCandidateProviderPack, providerPackProviders, providerPackManifestFields } from "./candidate-provider-pack.mjs";
|
||||
import { smokeKimiProviderPack } from "./kimi-provider-pack-smoke.mjs";
|
||||
import { buildNodeStartupTimeout } from "./build-node-startup-timeout.mjs";
|
||||
import { writePortableExecutableShim, writePortableCopilotShims } from "./provider-pack-executable-shims.mjs";
|
||||
import { providerPackLibc, removeControllerArtifactsFromProviderPack, removeForeignLibcPackagesFromProviderPack } from "./provider-pack-runtime-tree.mjs";
|
||||
@@ -183,7 +184,7 @@ try {
|
||||
|| !/^sha256:[a-f0-9]{64}$/.test(metadata.profileDigest)
|
||||
|| !/^sha256:[a-f0-9]{64}$/.test(metadata.closureDigest)) throw new Error("Candidate builder omitted its pinned identity");
|
||||
candidateProviders[provider] = { version: metadata.version, profileDigest: metadata.profileDigest,
|
||||
closureDigest: metadata.closureDigest, qualification: ["pi", "cursor", "copilot"].includes(provider) ? "qualified" : "pending", path: assetPath,
|
||||
closureDigest: metadata.closureDigest, qualification: ["pi", "cursor", "copilot"].includes(provider) || provider === "kimi" && profiles.profiles.kimi.qualificationStatus !== "pending" ? "qualified" : "pending", path: assetPath,
|
||||
sha256: sha256Tree(join(temporaryRoot, assetPath)) };
|
||||
}
|
||||
|
||||
@@ -386,6 +387,11 @@ try {
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
|
||||
if (payload.providers?.kimi && profiles.profiles.kimi.qualificationStatus !== "pending") {
|
||||
const smoke = await smokeKimiProviderPack(temporaryRoot);
|
||||
process.stdout.write(`${JSON.stringify(smoke)}\n`);
|
||||
}
|
||||
|
||||
rmSync(outputRoot, { recursive: true, force: true });
|
||||
renameSync(temporaryRoot, outputRoot);
|
||||
process.stdout.write(`${outputRoot}\n`);
|
||||
|
||||
@@ -8,6 +8,12 @@ import { build } from "esbuild";
|
||||
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
export const verifiedProviderEntrypoints = Object.freeze([
|
||||
Object.freeze({
|
||||
name: "kimi-metadata-probe",
|
||||
source: resolve(packageRoot, "src/cli/kimi-metadata-probe.ts"),
|
||||
output: resolve(packageRoot, "dist/cli/kimi-metadata-probe.js"),
|
||||
verifiedOutput: resolve(packageRoot, "dist/cli/kimi-metadata-probe.cjs"),
|
||||
}),
|
||||
Object.freeze({
|
||||
name: "provision-antigravity",
|
||||
source: resolve(packageRoot, "scripts/provision-antigravity.mjs"),
|
||||
@@ -80,6 +86,7 @@ export async function bundleVerifiedProviderEntrypoints({ write = true } = {}) {
|
||||
platform: "node",
|
||||
format,
|
||||
target: "node24",
|
||||
absWorkingDir: entrypoint.name === "kimi-metadata-probe" ? packageRoot : undefined,
|
||||
packages: "bundle",
|
||||
splitting: false,
|
||||
sourcemap: false,
|
||||
@@ -116,6 +123,7 @@ export async function bundleVerifiedProviderEntrypoints({ write = true } = {}) {
|
||||
results.push({ entrypoint, result, verifiedResult });
|
||||
}
|
||||
await bundlePiProvisioner({ write });
|
||||
await bundleKimiProvisioner({ write });
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -146,6 +154,28 @@ export async function bundlePiProvisioner({ write = true, outputRoot = resolve(p
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function bundleKimiProvisioner({ write = true, outputRoot = resolve(packageRoot, "dist/cli") } = {}) {
|
||||
const entrypoint = { name: "provision-kimi", source: resolve(packageRoot, "scripts/provision-kimi.mjs") };
|
||||
const result = await build({
|
||||
entryPoints: [entrypoint.source], outfile: resolve(outputRoot, "provision-kimi.cjs"),
|
||||
bundle: true, platform: "node", format: "cjs", target: "node24", packages: "bundle",
|
||||
splitting: false, sourcemap: false, legalComments: "none", metafile: true,
|
||||
treeShaking: true, write, logLevel: "silent",
|
||||
banner: { js: 'const __paperclipVerifiedEntrypointUrl = require("node:url").pathToFileURL(__filename).href;' },
|
||||
define: { "import.meta.dirname": "__dirname", "import.meta.url": "__paperclipVerifiedEntrypointUrl" },
|
||||
});
|
||||
assertSelfContainedBundle(entrypoint, result);
|
||||
if (write) {
|
||||
const inputs = resolve(outputRoot, "kimi-provision-inputs");
|
||||
await mkdir(inputs, { recursive: true });
|
||||
for (const [source, name] of [
|
||||
["scripts/kimi-distribution/package.json", "package.json"],
|
||||
["scripts/kimi-distribution/package-lock.json", "package-lock.json"],
|
||||
]) await cp(resolve(packageRoot, source), resolve(inputs, name));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const invokedPath = process.argv[1]
|
||||
? pathToFileURL(resolve(process.argv[1])).href
|
||||
: null;
|
||||
|
||||
@@ -41,6 +41,11 @@ test("written provider entrypoints satisfy qualified launch permissions", async
|
||||
|
||||
// Package-layout regression runs in the existing verified-entrypoint test lane.
|
||||
import "./provision-pi-package.test.mjs";
|
||||
|
||||
import "./materialize-kimi-distribution.test.mjs";
|
||||
import "./provision-kimi-package.test.mjs";
|
||||
import "./patch-kimi-runtime.test.mjs";
|
||||
import "./kimi-metadata-probe.test.mjs";
|
||||
import "./antigravity-provisioner-layout.test.mjs";
|
||||
import "./materialize-antigravity-distribution.test.mjs";
|
||||
import "./probe-antigravity-metadata.test.mjs";
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import { materializePiDistribution } from "./materialize-pi-distribution.mjs";
|
||||
import profiles from "../acpx-profiles.json" with { type: "json" };
|
||||
import { materializePinnedCursorDistribution } from "./materialize-cursor-distribution.mjs";
|
||||
const CANDIDATES = new Set(["cursor", "copilot", "pi"]);
|
||||
const CANDIDATES = new Set(["cursor", "copilot", "pi", "kimi"]);
|
||||
const releasedKimi = () => profiles.profiles.kimi.qualificationStatus !== "pending";
|
||||
const ordinaryProviders = () => ["pi", "cursor", "copilot", ...(releasedKimi() ? ["kimi"] : [])];
|
||||
|
||||
/** Only materialized providers enter the serialized manifest and its digest. */
|
||||
export function providerPackManifestFields(providers, candidates) {
|
||||
return {
|
||||
...(["pi", "cursor", "copilot"].some(provider => providers[provider]) ? { providers: Object.fromEntries(["pi", "cursor", "copilot"].filter(provider => providers[provider]).map(provider => [provider, providers[provider]])) } : {}),
|
||||
...(["pi", ...candidates].some(provider => provider !== "copilot" && providers[provider]) ? { candidateProviders: Object.fromEntries([...new Set(["pi", ...candidates])].filter(provider => provider !== "copilot" && providers[provider]).map(provider => [provider, providers[provider]])) } : {}),
|
||||
...(ordinaryProviders().some(provider => providers[provider]) ? { providers: Object.fromEntries(ordinaryProviders().filter(provider => providers[provider]).map(provider => [provider, providers[provider]])) } : {}),
|
||||
...(["pi", ...candidates].some(provider => provider !== "copilot" && !(provider === "kimi" && releasedKimi()) && providers[provider]) ? { candidateProviders: Object.fromEntries([...new Set(["pi", ...candidates])].filter(provider => provider !== "copilot" && !(provider === "kimi" && releasedKimi()) && providers[provider]).map(provider => [provider, providers[provider]])) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function providerPackProviders(platform, architecture, candidates) {
|
||||
const nativeSupported = ["darwin-arm64", "darwin-x64", "linux-x64"].includes(`${platform}-${architecture}`);
|
||||
return [...new Set([...(nativeSupported ? ["pi", "cursor", "copilot"] : []), ...candidates])];
|
||||
const kimiSupported = ["darwin-arm64", "linux-x64"].includes(`${platform}-${architecture}`);
|
||||
return [...new Set([...(nativeSupported ? ["pi", "cursor", "copilot"] : []), ...(releasedKimi() && kimiSupported ? ["kimi"] : []), ...candidates])];
|
||||
}
|
||||
|
||||
export function parseProviderPackArguments(args) {
|
||||
@@ -34,6 +37,11 @@ export function parseProviderPackArguments(args) {
|
||||
/** Closed source-owned builder registry; provider branches add their exact pins. */
|
||||
export async function materializeCandidateProviderPack({ provider, outputRoot }) {
|
||||
if (!CANDIDATES.has(provider)) throw new Error("Unknown candidate provider");
|
||||
if (provider === "kimi") {
|
||||
const { materializeKimiDistribution } = await import("./materialize-kimi-distribution.mjs");
|
||||
const result = await materializeKimiDistribution({ outputRoot });
|
||||
return { version: result.version, profileDigest: profiles.profiles.kimi.commandDigest, closureDigest: result.closureDigest };
|
||||
}
|
||||
if (provider === "pi") return materializePiDistribution({ outputRoot });
|
||||
if (provider === "copilot") {
|
||||
const { buildPinnedCopilotDistribution } = await import("./build-copilot-distribution.mjs");
|
||||
|
||||
@@ -43,11 +43,36 @@ test("provider manifest digest survives disk JSON on supported and unsupported t
|
||||
test("candidate options stay explicit and supported native assets are included by default", () => {
|
||||
assert.deepEqual(parseProviderPackArguments(["--", "/pack"]), { output: "/pack", candidates: [] });
|
||||
assert.deepEqual(parseProviderPackArguments(["/pack", "--candidate-providers=pi,cursor"]), { output: "/pack", candidates: ["pi", "cursor"] });
|
||||
assert.deepEqual(parseProviderPackArguments(["/pack", "--candidate-providers=kimi"]), { output: "/pack", candidates: ["kimi"] });
|
||||
});
|
||||
|
||||
test("released Kimi enters ordinary inventory on only its two supported targets", () => {
|
||||
const kimi = { version: "2.3.3", qualification: "qualified", profileDigest: "sha256:profile", closureDigest: "sha256:closure" };
|
||||
for (const [platform, architecture] of [["darwin", "arm64"], ["linux", "x64"]]) {
|
||||
assert.equal(providerPackProviders(platform, architecture, []).filter(provider => provider === "kimi").length, 1);
|
||||
assert.equal(providerPackProviders(platform, architecture, ["kimi"]).filter(provider => provider === "kimi").length, 1);
|
||||
}
|
||||
for (const [platform, architecture] of [["darwin", "x64"], ["linux", "arm64"], ["win32", "x64"]]) {
|
||||
assert.equal(providerPackProviders(platform, architecture, []).includes("kimi"), false);
|
||||
}
|
||||
for (const candidates of [[], ["kimi"]]) {
|
||||
const fields = providerPackManifestFields({ kimi }, candidates);
|
||||
assert.equal(fields.providers.kimi, kimi);
|
||||
assert.equal(fields.candidateProviders?.kimi, undefined);
|
||||
assert.equal(digest(JSON.parse(JSON.stringify(fields))), digest(fields));
|
||||
}
|
||||
});
|
||||
|
||||
test("Kimi's candidate builder rejects invalid paths before any public download", async t => {
|
||||
let requests = 0;
|
||||
t.mock.method(globalThis, "fetch", async () => { requests++; throw new Error("Unexpected Kimi download"); });
|
||||
await assert.rejects(materializeCandidateProviderPack({ provider: "kimi", outputRoot: "relative-kimi-output" }), /Kimi distribution output must be absolute/);
|
||||
assert.equal(requests, 0);
|
||||
});
|
||||
test("normal packs include Cursor and Copilot on their three pinned targets without breaking other hosts", () => {
|
||||
for (const [platform, architecture] of [["darwin", "arm64"], ["darwin", "x64"], ["linux", "x64"]]) {
|
||||
assert.deepEqual(providerPackProviders(platform, architecture, []), ["pi", "cursor", "copilot"]);
|
||||
assert.deepEqual(providerPackProviders(platform, architecture, ["cursor"]), ["pi", "cursor", "copilot"]);
|
||||
assert.deepEqual(providerPackProviders(platform, architecture, []), ["pi", "cursor", "copilot", ...((platform === "darwin" && architecture === "arm64") || platform === "linux" ? ["kimi"] : [])]);
|
||||
assert.deepEqual(providerPackProviders(platform, architecture, ["cursor"]), providerPackProviders(platform, architecture, []));
|
||||
}
|
||||
assert.deepEqual(providerPackProviders("linux", "arm64", []), []);
|
||||
assert.deepEqual(providerPackProviders("win32", "x64", []), []);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { KIMI_DISTRIBUTION_CLOSURE_SHA256 } from "../src/drivers/acpx/kimi-closure-pins.ts";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -90,6 +91,13 @@ pub(crate) struct AcpxReleaseProfile {
|
||||
pub qualification_pending: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn kimi_native_closure(platform: &str, architecture: &str) -> Option<&'static str> {
|
||||
match (platform, architecture) {
|
||||
${Object.entries(KIMI_DISTRIBUTION_CLOSURE_SHA256).map(([target, digest]) => { const [platform, architecture] = target.split("-"); return ` (${quote(platform)}, ${quote(architecture)}) => {\n Some(${quote(digest)})\n }`; }).join("\n")}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn acpx_release_profile(agent: &str) -> Option<AcpxReleaseProfile> {
|
||||
Some(match agent {
|
||||
${entries.map(([agent, p]) => ` ${quote(agent)} => AcpxReleaseProfile {
|
||||
|
||||
@@ -0,0 +1,606 @@
|
||||
{
|
||||
"name": "paperclip-kimi-runtime-distribution",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paperclip-kimi-runtime-distribution",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@moonshot-ai/kimi-code": "2.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mariozechner/clipboard": {
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz",
|
||||
"integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@mariozechner/clipboard-darwin-arm64": "0.3.9",
|
||||
"@mariozechner/clipboard-darwin-universal": "0.3.9",
|
||||
"@mariozechner/clipboard-darwin-x64": "0.3.9",
|
||||
"@mariozechner/clipboard-linux-arm64-gnu": "0.3.9",
|
||||
"@mariozechner/clipboard-linux-arm64-musl": "0.3.9",
|
||||
"@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9",
|
||||
"@mariozechner/clipboard-linux-x64-gnu": "0.3.9",
|
||||
"@mariozechner/clipboard-linux-x64-musl": "0.3.9",
|
||||
"@mariozechner/clipboard-win32-arm64-msvc": "0.3.9",
|
||||
"@mariozechner/clipboard-win32-x64-msvc": "0.3.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@mariozechner/clipboard-darwin-arm64": {
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz",
|
||||
"integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@mariozechner/clipboard-darwin-universal": {
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz",
|
||||
"integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@mariozechner/clipboard-darwin-x64": {
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz",
|
||||
"integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@mariozechner/clipboard-linux-arm64-gnu": {
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz",
|
||||
"integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@mariozechner/clipboard-linux-arm64-musl": {
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz",
|
||||
"integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@mariozechner/clipboard-linux-riscv64-gnu": {
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz",
|
||||
"integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@mariozechner/clipboard-linux-x64-gnu": {
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz",
|
||||
"integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@mariozechner/clipboard-linux-x64-musl": {
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz",
|
||||
"integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@mariozechner/clipboard-win32-arm64-msvc": {
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz",
|
||||
"integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@mariozechner/clipboard-win32-x64-msvc": {
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz",
|
||||
"integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@moonshot-ai/kimi-code": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@moonshot-ai/kimi-code/-/kimi-code-2.3.3.tgz",
|
||||
"integrity": "sha512-3V8bycucf9KgPRH27S8De2u0ip84/Ry3FpNn6gi5z9JmoEHt9vxiYHHZqiMdyDf5nPm4bv+YKGwKEQL4oYpRTw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"bin": {
|
||||
"kimi": "dist/main.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@mariozechner/clipboard": "^0.3.9",
|
||||
"node-pty": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/node-pty": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
|
||||
"integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-addon-api": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14.18.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.22.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
|
||||
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "paperclip-kimi-runtime-distribution",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@moonshot-ai/kimi-code": "2.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.11.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export const KIMI_INSTRUCTION_RUNTIME_SOURCE = [
|
||||
"function createKimiInstructionRuntime(raw, { createHash, byteLength }) {",
|
||||
" const schema = \"paperclip.kimi.instructions.v1\";",
|
||||
" const fail = () => { throw new Error(\"Kimi instruction binding is missing or invalid\"); };",
|
||||
" if (typeof raw !== \"string\" || byteLength(raw) > 200_000) fail();",
|
||||
" let value;",
|
||||
" try { value = JSON.parse(raw); } catch { fail(); }",
|
||||
" if (!value || typeof value !== \"object\" || Array.isArray(value)",
|
||||
" || Object.keys(value).sort().join(\",\") !== \"byteLength,content,digest,schema\"",
|
||||
" || value.schema !== schema || typeof value.content !== \"string\"",
|
||||
" || value.content.includes(\"\\0\") || !value.content.isWellFormed()",
|
||||
" || byteLength(value.content) > 32_768 || value.byteLength !== byteLength(value.content)",
|
||||
" || typeof value.digest !== \"string\") fail();",
|
||||
" const digest = text => `sha256:${createHash(\"sha256\").update(text, \"utf8\").digest(\"hex\")}`;",
|
||||
" const content = value.content;",
|
||||
" if (value.digest !== digest(content)) fail();",
|
||||
" const suffix = content.length === 0 ? \"\" : `\\n\\n${content}`;",
|
||||
" const append = base => {",
|
||||
" if (typeof base !== \"string\" || byteLength(base) > 2_097_152) fail();",
|
||||
" return suffix.length === 0 || base.endsWith(suffix) ? base : `${base}${suffix}`;",
|
||||
" };",
|
||||
" return Object.freeze({",
|
||||
" append,",
|
||||
" receipt(sessionId, effectivePrompt) {",
|
||||
" if (typeof sessionId !== \"string\" || !sessionId.trim() || append(effectivePrompt) !== effectivePrompt) fail();",
|
||||
" return Object.freeze({ schema, sessionId, digest: value.digest,",
|
||||
" byteLength: byteLength(content), effectiveDigest: digest(effectivePrompt),",
|
||||
" effectiveByteLength: byteLength(effectivePrompt) });",
|
||||
" },",
|
||||
" });",
|
||||
"}",
|
||||
].join("\n");
|
||||
|
||||
export const createKimiInstructionRuntime = new Function(`return (${KIMI_INSTRUCTION_RUNTIME_SOURCE});`)();
|
||||
@@ -0,0 +1,127 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, mkdir, readFile, realpath, rm, stat, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { build } from "esbuild";
|
||||
import { bundleVerifiedProviderEntrypoints } from "./build-verified-provider-entrypoints.mjs";
|
||||
|
||||
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const sha = bytes => `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
||||
|
||||
test("Kimi metadata CLI is self-contained and rejects incomplete assets without inheriting credentials or making requests", { timeout: 20_000 }, async t => {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), "paperclip-kimi-metadata-cli-")));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
const bundles = await bundleVerifiedProviderEntrypoints({ write: false });
|
||||
const bundle = bundles.find(value => value.entrypoint.name === "kimi-metadata-probe");
|
||||
assert.ok(bundle, "The ordinary provider pack build must include the metadata CLI");
|
||||
const bytes = bundle.verifiedResult.outputFiles[0].contents;
|
||||
const relocatedBuild = spawnSync(process.execPath, ["--input-type=module", "-e", `
|
||||
import { createHash } from "node:crypto";
|
||||
import { bundleVerifiedProviderEntrypoints } from ${JSON.stringify(new URL("./build-verified-provider-entrypoints.mjs", import.meta.url).href)};
|
||||
const bundles = await bundleVerifiedProviderEntrypoints({ write: false });
|
||||
const bytes = bundles.find(value => value.entrypoint.name === "kimi-metadata-probe").verifiedResult.outputFiles[0].contents;
|
||||
process.stdout.write("sha256:" + createHash("sha256").update(bytes).digest("hex"));
|
||||
`], { cwd: root, env: { PATH: "/usr/bin:/bin" }, encoding: "utf8", timeout: 10_000 });
|
||||
assert.ifError(relocatedBuild.error);
|
||||
assert.equal(relocatedBuild.status, 0, relocatedBuild.stderr);
|
||||
assert.equal(relocatedBuild.stdout, sha(bytes), "Metadata CLI admission bytes must be independent of the build cwd");
|
||||
const entry = join(root, "kimi-metadata-probe.cjs");
|
||||
await writeFile(entry, bytes);
|
||||
assert.doesNotMatch(Buffer.from(bytes).toString(), /\bimport\.meta\b/);
|
||||
for (const output of Object.values(bundle.verifiedResult.metafile.outputs)) {
|
||||
for (const dependency of output.imports) assert.match(dependency.path, /^node:/);
|
||||
}
|
||||
const manifest = join(root, "package.json");
|
||||
await writeFile(manifest, '{"name":"@paperclipai/paperclip-runner","type":"module"}');
|
||||
const preload = join(root, "deny-external-work.cjs");
|
||||
await writeFile(preload, `
|
||||
const assert = require("node:assert/strict");
|
||||
process.nextTick(() => {
|
||||
for (const name of ["KIMI_API_KEY", "PAPERCLIP_KIMI_PROBE_MODEL", "KIMI_MODEL_DEFAULT", "HOME", "HTTPS_PROXY", "NODE_OPTIONS", "NODE_PATH", "ANTHROPIC_API_KEY", "PAPERCLIP_ACPX_CANDIDATE_ADMISSION"]) {
|
||||
assert.equal(process.env[name], undefined, name);
|
||||
}
|
||||
assert.equal(process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT, ${JSON.stringify(root)});
|
||||
});
|
||||
const fail = () => { throw Error("UNEXPECTED_NETWORK_OR_CHILD"); };
|
||||
globalThis.fetch = fail;
|
||||
for (const name of ["node:net", "node:tls", "node:http", "node:https"]) {
|
||||
const dependency = require(name);
|
||||
for (const key of ["connect", "createConnection", "request", "get"]) if (key in dependency) dependency[key] = fail;
|
||||
}
|
||||
const child = require("node:child_process");
|
||||
for (const key of ["spawn", "exec", "execFile"]) child[key] = fail;
|
||||
`);
|
||||
const pinnedNode = join(packageRoot, "provider-assets/kimi", `${process.platform}-${process.arch}`, "runtime/node/bin/node");
|
||||
const node = await stat(pinnedNode).then(() => pinnedNode, () => process.execPath);
|
||||
const profile = JSON.parse(await readFile(join(packageRoot, "acpx-profiles.json"), "utf8")).profiles.kimi;
|
||||
const { KIMI_DISTRIBUTION_CLOSURE_SHA256 } = await import(new URL("../src/drivers/acpx/kimi-closure-pins.ts", import.meta.url));
|
||||
const admission = { schema: "paperclip.acpx-candidate-admission.v1", companyId: "metadata-cli-company", agent: "kimi", model: "selected-model",
|
||||
profileDigest: profile.commandDigest, nativeClosureDigest: `sha256:${KIMI_DISTRIBUTION_CLOSURE_SHA256[`${process.platform}-${process.arch}`]}`,
|
||||
platform: process.platform, architecture: process.arch, expiresAt: Date.now() + 60_000,
|
||||
runId: "metadata-operation", normalizedSessionId: "metadata-session" };
|
||||
const child = spawnSync(node, ["--require", preload, entry], {
|
||||
env: { KIMI_API_KEY: "selected-secret-canary", PAPERCLIP_KIMI_PROBE_MODEL: "selected-model",
|
||||
...(profile.qualificationStatus === "pending" ? { PAPERCLIP_ACPX_CANDIDATE_ADMISSION: JSON.stringify(admission) } : {}),
|
||||
PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT: root, PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST: manifest,
|
||||
KIMI_MODEL_DEFAULT: "unselected-model", HOME: "/private/unselected-home", HTTPS_PROXY: "http://127.0.0.1:9",
|
||||
ANTHROPIC_API_KEY: "unselected-secret-canary", NODE_OPTIONS: "", NODE_PATH: "/unselected-module-path" },
|
||||
encoding: "utf8", timeout: 10_000,
|
||||
});
|
||||
assert.ifError(child.error);
|
||||
assert.equal(child.status, 1);
|
||||
assert.equal(child.signal, null);
|
||||
assert.doesNotMatch(child.stdout + child.stderr, /UNEXPECTED_NETWORK_OR_CHILD|selected-secret-canary|unselected-secret-canary/);
|
||||
assert.deepEqual(JSON.parse(child.stdout), { schema: "paperclip.kimi.metadata.v1", status: "failed",
|
||||
code: "KIMI_INSTALLATION_INVALID", message: process.versions.node === "24.21.0" || node === pinnedNode
|
||||
? "Install the current verified Kimi Code runtime in this execution environment."
|
||||
: "Kimi metadata verification requires the pinned Node runtime.", promptSent: false });
|
||||
|
||||
const withoutAdmission = spawnSync(node, ["--require", preload, entry], {
|
||||
env: { KIMI_API_KEY: "selected-secret-canary", PAPERCLIP_KIMI_PROBE_MODEL: "selected-model",
|
||||
PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT: root, PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST: manifest },
|
||||
encoding: "utf8", timeout: 10_000,
|
||||
});
|
||||
assert.ifError(withoutAdmission.error); assert.equal(withoutAdmission.status, 1);
|
||||
if (process.versions.node === "24.21.0" || node === pinnedNode) {
|
||||
assert.equal(JSON.parse(withoutAdmission.stdout).code,
|
||||
profile.qualificationStatus === "pending" ? "KIMI_QUALIFICATION_REQUIRED" : "KIMI_INSTALLATION_INVALID");
|
||||
}
|
||||
assert.doesNotMatch(withoutAdmission.stdout + withoutAdmission.stderr, /UNEXPECTED_NETWORK_OR_CHILD|selected-secret-canary/);
|
||||
|
||||
if (profile.qualificationStatus !== "pending") {
|
||||
const formerAdmission = spawnSync(node, ["--require", preload, entry], {
|
||||
env: { KIMI_API_KEY: "selected-secret-canary", PAPERCLIP_KIMI_PROBE_MODEL: "selected-model",
|
||||
PAPERCLIP_ACPX_CANDIDATE_ADMISSION: JSON.stringify(admission),
|
||||
PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT: root, PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST: manifest,
|
||||
HOME: "/private/unselected-home", HTTPS_PROXY: "http://127.0.0.1:9", ANTHROPIC_API_KEY: "unselected-secret-canary" },
|
||||
encoding: "utf8", timeout: 10_000,
|
||||
});
|
||||
assert.ifError(formerAdmission.error); assert.equal(formerAdmission.status, 1); assert.equal(formerAdmission.signal, null);
|
||||
assert.deepEqual(JSON.parse(formerAdmission.stdout), { schema: "paperclip.kimi.metadata.v1", status: "failed",
|
||||
code: "KIMI_REQUEST_FAILED", message: "Kimi Code metadata verification failed.", promptSent: false });
|
||||
assert.doesNotMatch(formerAdmission.stdout + formerAdmission.stderr, /UNEXPECTED_NETWORK_OR_CHILD|selected-secret-canary|unselected-secret-canary/);
|
||||
}
|
||||
|
||||
for (const layout of ["runner", "server"]) {
|
||||
const packageDirectory = join(root, layout);
|
||||
const outputRoot = layout === "server" ? join(packageDirectory, "dist/vendor/paperclip-runner") : join(packageDirectory, "dist");
|
||||
const helper = join(outputRoot, "drivers/acpx/kimi-metadata-probe.js");
|
||||
const cli = join(outputRoot, "cli/kimi-metadata-probe.cjs");
|
||||
await mkdir(dirname(cli), { recursive: true });
|
||||
await writeFile(join(packageDirectory, "package.json"), '{"type":"module"}');
|
||||
await writeFile(cli, bytes);
|
||||
await build({ entryPoints: [join(packageRoot, "src/drivers/acpx/kimi-metadata-probe.ts")], outfile: helper,
|
||||
bundle: true, platform: "node", format: "esm", target: "node24", logLevel: "silent" });
|
||||
const api = await import(pathToFileURL(helper).href);
|
||||
assert.equal(await api.kimiMetadataProbeDigest(), sha(bytes), `${layout} fixed artifact`);
|
||||
await writeFile(cli, "different artifact bytes");
|
||||
assert.equal(await api.kimiMetadataProbeDigest(), sha("different artifact bytes"), `${layout} digest is fresh`);
|
||||
await rm(cli);
|
||||
await symlink(entry, cli);
|
||||
await assert.rejects(api.kimiMetadataProbeDigest(), /ELOOP|symbolic/i);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { once } from "node:events";
|
||||
import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
export function assertKimiProviderPackInventory(manifest, profile, closures, platform = process.platform, architecture = process.arch) {
|
||||
assert.equal(manifest.schema, "paperclip-runner/remote-provider-pack/v1");
|
||||
assert.deepEqual(manifest.payload.target, { platform, architecture });
|
||||
const target = `${platform}-${architecture}`;
|
||||
assert.ok(["darwin-arm64", "linux-x64"].includes(target));
|
||||
const pending = profile.qualificationStatus === "pending";
|
||||
const identity = pending ? manifest.payload.candidateProviders?.kimi : manifest.payload.providers?.kimi;
|
||||
assert.equal(pending ? manifest.payload.providers?.kimi : manifest.payload.candidateProviders?.kimi, undefined);
|
||||
assert.equal(identity?.qualification, pending ? "pending" : "qualified");
|
||||
assert.equal(identity.version, profile.agentServerVersion);
|
||||
assert.equal(identity.path, `provider-assets/kimi/${target}`);
|
||||
assert.equal(identity.profileDigest, profile.commandDigest);
|
||||
assert.match(closures[target] ?? "", /^[a-f0-9]{64}$/);
|
||||
assert.equal(identity.closureDigest, `sha256:${closures[target]}`);
|
||||
assert.match(identity.sha256, /^sha256:[a-f0-9]{64}$/);
|
||||
return identity;
|
||||
}
|
||||
|
||||
export async function smokeKimiProviderPack(directory) {
|
||||
const pack = await realpath(resolve(directory));
|
||||
const manifest = JSON.parse(await readFile(join(pack, "provider-pack.json"), "utf8"));
|
||||
assert.equal(manifest.schema, "paperclip-runner/remote-provider-pack/v1");
|
||||
assert.equal(manifest.payload.target.platform, process.platform);
|
||||
assert.equal(manifest.payload.target.architecture, process.arch);
|
||||
if (process.env.PAPERCLIP_EXPECTED_SOURCE_REVISION) assert.equal(manifest.payload.runnerSourceRevision, process.env.PAPERCLIP_EXPECTED_SOURCE_REVISION);
|
||||
const load = path => import(pathToFileURL(join(pack, "dist/drivers/acpx", path)));
|
||||
const [{ verifyKimiInstallation }, { QUALIFIED_ACPX_PROFILES }, { kimiManagedConfiguration }, { kimiInstructionBinding }, { KIMI_DISTRIBUTION_CLOSURE_SHA256 }] = await Promise.all([
|
||||
load("kimi-installation.js"), load("qualified-profiles.js"), load("kimi-config.js"), load("kimi-instructions.js"), load("kimi-closure-pins.js"),
|
||||
]);
|
||||
const provider = assertKimiProviderPackInventory(manifest, QUALIFIED_ACPX_PROFILES.kimi, KIMI_DISTRIBUTION_CLOSURE_SHA256);
|
||||
const keys = ["PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", "PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST"];
|
||||
const previous = keys.map(key => process.env[key]);
|
||||
process.env[keys[0]] = pack; process.env[keys[1]] = join(pack, "package.json");
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), "paperclip-kimi-pack-smoke-")));
|
||||
const home = join(root, "home"), workspace = join(root, "workspace");
|
||||
await mkdir(home); await mkdir(workspace);
|
||||
const model = "kimi-for-coding";
|
||||
const binding = kimiInstructionBinding("Do not call a model during this installed-package smoke. Exact suffix: café.\n");
|
||||
await writeFile(join(home, "config.toml"), kimiManagedConfiguration(model), { mode: 0o600 });
|
||||
const active = new Set(); let sequence = 0;
|
||||
try {
|
||||
const installation = await verifyKimiInstallation(QUALIFIED_ACPX_PROFILES.kimi);
|
||||
const connect = async () => {
|
||||
const lease = await installation.openCommand();
|
||||
const child = lease.spawn([], { cwd: workspace, stdio: "pipe", env: {
|
||||
PATH: "/usr/bin:/bin", LANG: "C.UTF-8", KIMI_CODE_HOME: home,
|
||||
KIMI_CODE_NO_AUTO_UPDATE: "1", KIMI_API_KEY: "paperclip-offline-placeholder",
|
||||
PAPERCLIP_KIMI_INSTRUCTIONS: binding.payload, CI: "1", NO_COLOR: "1", TERM: "dumb",
|
||||
} });
|
||||
const closed = once(child, "close"), pending = new Map();
|
||||
let buffer = "", bytes = 0;
|
||||
const fail = error => { for (const request of pending.values()) request.reject(error); pending.clear(); };
|
||||
child.on("error", fail); child.on("close", () => fail(new Error("Kimi exited with a pending ACP request")));
|
||||
child.stderr.resume();
|
||||
child.stdout.on("data", chunk => {
|
||||
bytes += chunk.length;
|
||||
if (bytes > 1024 * 1024) { fail(new Error("Kimi ACP output exceeded its package smoke bound")); return; }
|
||||
buffer += chunk.toString("utf8");
|
||||
let newline;
|
||||
while ((newline = buffer.indexOf("\n")) >= 0) {
|
||||
const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1);
|
||||
if (!line.trim()) continue;
|
||||
let value;
|
||||
try { value = JSON.parse(line); } catch { fail(new Error("Kimi emitted malformed ACP JSON")); return; }
|
||||
const request = pending.get(value.id);
|
||||
if (request && value.method === undefined) {
|
||||
pending.delete(value.id);
|
||||
if (value.error) request.reject(new Error(`Kimi rejected ACP request (${value.error.code})`));
|
||||
else request.resolve(value.result);
|
||||
}
|
||||
}
|
||||
});
|
||||
const request = (method, params) => new Promise((accept, reject) => {
|
||||
const id = sequence++;
|
||||
const timeout = setTimeout(() => { pending.delete(id); reject(new Error(`Kimi ${method} timed out`)); }, 15_000);
|
||||
const settle = callback => value => { clearTimeout(timeout); callback(value); };
|
||||
pending.set(id, { resolve: settle(accept), reject: settle(reject) });
|
||||
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
|
||||
});
|
||||
let closing;
|
||||
const close = () => closing ??= (async () => {
|
||||
const started = Date.now(); child.stdin.end();
|
||||
const term = setTimeout(() => child.kill("SIGTERM"), 4_000), kill = setTimeout(() => child.kill("SIGKILL"), 5_000);
|
||||
try {
|
||||
const [code, signal] = await closed;
|
||||
assert.equal(code, 0, "Kimi did not exit cleanly after EOF"); assert.equal(signal, null);
|
||||
return Date.now() - started;
|
||||
} finally { clearTimeout(term); clearTimeout(kill); await lease.close(); active.delete(close); }
|
||||
})();
|
||||
active.add(close);
|
||||
const initialized = await request("initialize", { protocolVersion: 1, clientCapabilities: {}, clientInfo: { name: "paperclip-provider-pack-smoke", version: "1" } });
|
||||
assert.equal(initialized.agentInfo?.version, "2.3.3");
|
||||
return { request, close };
|
||||
};
|
||||
const assertBinding = (result, sessionId) => {
|
||||
const ack = result._meta?.paperclipKimiInstructions;
|
||||
assert.equal(ack?.sessionId, sessionId); assert.equal(ack.digest, binding.digest); assert.equal(ack.byteLength, binding.byteLength);
|
||||
assert.match(ack.effectiveDigest, /^sha256:[a-f0-9]{64}$/);
|
||||
};
|
||||
const first = await connect();
|
||||
const created = await first.request("session/new", { cwd: workspace, mcpServers: [] });
|
||||
assertBinding(created, created.sessionId);
|
||||
const selected = await first.request("session/set_config_option", { sessionId: created.sessionId, configId: "model", value: model });
|
||||
assert.equal(selected.configOptions.find(option => option.id === "model").currentValue, model);
|
||||
const createExitMs = await first.close();
|
||||
const second = await connect();
|
||||
const loaded = await second.request("session/load", { sessionId: created.sessionId, cwd: workspace, mcpServers: [] });
|
||||
assertBinding(loaded, created.sessionId);
|
||||
assert.equal(loaded.configOptions.find(option => option.id === "model").currentValue, model);
|
||||
const loadExitMs = await second.close();
|
||||
return { schema: "paperclip.kimi-provider-pack-smoke.v1", sourceRevision: manifest.payload.runnerSourceRevision, providerPackDigest: manifest.digest,
|
||||
platform: process.platform, architecture: process.arch, runtimeVersion: "2.3.3", profileDigest: provider.profileDigest, closureDigest: provider.closureDigest,
|
||||
createAcknowledged: true, loadAcknowledged: true, model, createExitMs, loadExitMs, providerPrompts: 0, providerCredentials: false,
|
||||
qualification: "installed runtime only; no model entitlement or task completion" };
|
||||
} finally {
|
||||
try { await Promise.all([...active].map(close => close())); }
|
||||
finally { keys.forEach((key, index) => { if (previous[index] === undefined) delete process.env[key]; else process.env[key] = previous[index]; }); await rm(root, { recursive: true, force: true }); }
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
||||
if (process.argv.length !== 3) throw new Error("Usage: node scripts/kimi-provider-pack-smoke.mjs /absolute/installed-provider-pack");
|
||||
smokeKimiProviderPack(process.argv[2]).then(result => console.log(JSON.stringify(result))).catch(error => { console.error(error.message); process.exitCode = 1; });
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { assertKimiProviderPackInventory } from "./kimi-provider-pack-smoke.mjs";
|
||||
import profiles from "../acpx-profiles.json" with { type: "json" };
|
||||
import historical from "../test/fixtures/kimi-profile-v14-identity.json" with { type: "json" };
|
||||
import { KIMI_DISTRIBUTION_CLOSURE_SHA256 as closures } from "../src/drivers/acpx/kimi-closure-pins.ts";
|
||||
|
||||
function fixture(profile, target) {
|
||||
const [platform, architecture] = target.split("-");
|
||||
const pending = profile.qualificationStatus === "pending";
|
||||
return { schema: "paperclip-runner/remote-provider-pack/v1", payload: { target: { platform, architecture },
|
||||
[pending ? "candidateProviders" : "providers"]: { kimi: { version: profile.agentServerVersion,
|
||||
qualification: pending ? "pending" : "qualified", profileDigest: profile.commandDigest,
|
||||
closureDigest: `sha256:${closures[target]}`, path: `provider-assets/kimi/${target}`, sha256: `sha256:${"a".repeat(64)}` } } } };
|
||||
}
|
||||
for (const target of ["darwin-arm64", "linux-x64"]) {
|
||||
test(`ordinary ${target} smoke needs released current17 and no candidate entry`, () => {
|
||||
const profile = profiles.profiles.kimi;
|
||||
assert.equal(profile.agentProfileVersion, 17);
|
||||
assert.notEqual(profile.qualificationStatus, "pending");
|
||||
const manifest = fixture(profile, target), [platform, architecture] = target.split("-");
|
||||
assert.equal(assertKimiProviderPackInventory(manifest, profile, closures, platform, architecture), manifest.payload.providers.kimi);
|
||||
for (const change of [{ profileDigest: historical.commandDigest }, { closureDigest: "sha256:" + "b".repeat(64) },
|
||||
{ qualification: "pending" }, { path: "provider-assets/kimi/foreign" }]) {
|
||||
const invalid = structuredClone(manifest); Object.assign(invalid.payload.providers.kimi, change);
|
||||
assert.throws(() => assertKimiProviderPackInventory(invalid, profile, closures, platform, architecture));
|
||||
}
|
||||
const duplicate = structuredClone(manifest); duplicate.payload.candidateProviders = { kimi: duplicate.payload.providers.kimi };
|
||||
assert.throws(() => assertKimiProviderPackInventory(duplicate, profile, closures, platform, architecture));
|
||||
assert.throws(() => assertKimiProviderPackInventory(manifest, profile, closures, "linux", "arm64"));
|
||||
});
|
||||
test(`historical pending14 ${target} remains a distinct inventory contract`, () => {
|
||||
const profile = { ...historical.declaration, commandDigest: historical.commandDigest };
|
||||
const manifest = fixture(profile, target), [platform, architecture] = target.split("-");
|
||||
assert.equal(profile.qualificationStatus, "pending");
|
||||
assert.equal(assertKimiProviderPackInventory(manifest, profile, closures, platform, architecture), manifest.payload.candidateProviders.kimi);
|
||||
assert.throws(() => assertKimiProviderPackInventory(manifest, profiles.profiles.kimi, closures, platform, architecture));
|
||||
});
|
||||
}
|
||||
|
||||
test("the actual builder verifies ordinary Kimi before replacing the retained pack", async () => {
|
||||
const source = readFileSync(new URL("./build-provider-pack.mjs", import.meta.url), "utf8");
|
||||
const begin = source.indexOf(' if (payload.providers?.kimi && profiles.profiles.kimi.qualificationStatus !== "pending") {');
|
||||
const end = source.indexOf('\n} finally {', begin);
|
||||
assert.ok(begin > 0 && end > begin);
|
||||
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
||||
const run = new AsyncFunction("payload", "profiles", "smokeKimiProviderPack", "temporaryRoot", "outputRoot", "process", "rmSync", "renameSync", source.slice(begin, end));
|
||||
for (const failed of [false, true]) {
|
||||
const order = [], smoke = async root => { assert.equal(root, "/owned/temporary"); order.push("smoke"); if (failed) throw Error("smoke failed"); return { providerPrompts: 0 }; };
|
||||
const work = run({ providers: { kimi: {} } }, profiles, smoke, "/owned/temporary", "/owned/output",
|
||||
{ stdout: { write() {} } }, () => order.push("remove-old"), () => order.push("publish"));
|
||||
if (failed) { await assert.rejects(work, /smoke failed/); assert.deepEqual(order, ["smoke"]); }
|
||||
else { await work; assert.deepEqual(order, ["smoke", "remove-old", "publish"]); }
|
||||
}
|
||||
for (const [payload, status] of [[{ candidateProviders: { kimi: {} } }, "pending"], [{ providers: {} }, undefined]]) {
|
||||
let calls = 0;
|
||||
await run(payload, { profiles: { kimi: { qualificationStatus: status } } }, () => { calls++; }, "/owned/temporary", "/owned/output",
|
||||
{ stdout: { write() {} } }, () => {}, () => {});
|
||||
assert.equal(calls, 0);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmod, copyFile, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, unlink, writeFile } from "node:fs/promises";
|
||||
import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { KIMI_DISTRIBUTION_CLOSURE_SHA256, KIMI_DISTRIBUTION_LOCK_SHA256, KIMI_NODE_DISTRIBUTIONS, KIMI_NODE_VERSION, KIMI_RUNTIME_VERSION } from "../src/drivers/acpx/kimi-closure-pins.ts";
|
||||
import { inventoryKimiRuntimeFiles, kimiRuntimeClosureDigest } from "../src/drivers/acpx/kimi-runtime-files.ts";
|
||||
import { patchKimiRuntime } from "./patch-kimi-runtime.mjs";
|
||||
|
||||
const run = promisify(execFile);
|
||||
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const repositoryRoot = resolve(packageRoot, "../..");
|
||||
const hash = bytes => createHash("sha256").update(bytes).digest("hex");
|
||||
export const KIMI_DISTRIBUTION_ENTRYPOINT = "node_modules/@moonshot-ai/kimi-code/dist/main.mjs";
|
||||
|
||||
export async function runKimiBuildCommand(command, args, options, checkCancelled = () => {}, signal) {
|
||||
checkCancelled(); signal?.throwIfAborted();
|
||||
const pending = run(command, args, { ...options, signal, killSignal: "SIGKILL" });
|
||||
const closed = new Promise(resolve => pending.child.once("close", resolve));
|
||||
try {
|
||||
const result = await pending;
|
||||
checkCancelled(); signal?.throwIfAborted();
|
||||
return result;
|
||||
} finally { await closed; }
|
||||
}
|
||||
|
||||
export function kimiDistributionInstallCommand() {
|
||||
return ["ci", "--ignore-scripts", "--omit=optional", "--omit=dev", "--no-audit", "--no-fund", "--registry=https://registry.npmjs.org", "--userconfig=.npmrc", "--globalconfig=.npmrc-global"];
|
||||
}
|
||||
|
||||
export function kimiDistributionEnvironment(source = process.env) {
|
||||
return Object.fromEntries(["PATH", "LANG", "LC_ALL", "HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", "https_proxy", "http_proxy", "no_proxy", "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS"].flatMap(key => typeof source[key] === "string" ? [[key, source[key]]] : []));
|
||||
}
|
||||
|
||||
export async function verifyLockedKimiPackageGraph(root, lock) {
|
||||
if (lock.lockfileVersion !== 3 || !lock.packages || lock.packages[""]?.dependencies?.["@moonshot-ai/kimi-code"] !== KIMI_RUNTIME_VERSION) throw new Error("Kimi distribution lock is invalid");
|
||||
let packages = 0;
|
||||
for (const [path, entry] of Object.entries(lock.packages)) {
|
||||
if (!path) continue;
|
||||
if (!path.startsWith("node_modules/") || /[\\\u0000-\u001f\u007f]/.test(path) || path.split("/").some(part => !part || part === "." || part === "..") || entry.link || typeof entry.version !== "string" || !/^https:\/\/registry\.npmjs\.org\//.test(entry.resolved ?? "") || !/^sha512-[A-Za-z0-9+/]+=*$/.test(entry.integrity ?? "")) throw new Error("Kimi distribution lock contains an unpinned package");
|
||||
if (entry.optional === true) {
|
||||
try { await lstat(join(root, path)); } catch (error) { if (error.code === "ENOENT") continue; throw error; }
|
||||
throw new Error("Kimi headless distribution unexpectedly installed an optional TUI dependency");
|
||||
}
|
||||
const file = join(root, path, "package.json");
|
||||
if (await realpath(file) !== file || !(await lstat(file)).isFile()) throw new Error("Kimi distribution package metadata is not a regular file");
|
||||
const actual = JSON.parse(await readFile(file, "utf8"));
|
||||
if (actual.version !== entry.version) throw new Error(`Kimi distribution package differs from its lock: ${path}`);
|
||||
packages++;
|
||||
}
|
||||
return packages;
|
||||
}
|
||||
|
||||
export function assertKimiNodeSystemDependencies(listing, platform) {
|
||||
for (const line of listing.split("\n").map(value => value.trim()).filter(Boolean)) {
|
||||
if (platform === "darwin") {
|
||||
if (line.endsWith(":")) continue;
|
||||
if (!line.startsWith("/usr/lib/") && !line.startsWith("/System/Library/")) throw new Error("Kimi Node depends on an unbundled library");
|
||||
} else if (!line.startsWith("linux-vdso.so.") && !/^(?:[^\s]+ => )?\/(?:usr\/)?lib(?:64)?\//.test(line)) throw new Error("Kimi Node depends on an unbundled library");
|
||||
}
|
||||
}
|
||||
|
||||
async function nodeDistribution(staging, target, archivePath, environment, runOwned, signal) {
|
||||
const pin = KIMI_NODE_DISTRIBUTIONS[target];
|
||||
if (!pin) throw new Error(`Kimi distribution target is unsupported: ${target}`);
|
||||
let bytes;
|
||||
if (archivePath) bytes = await readFile(archivePath);
|
||||
else {
|
||||
const timeout = AbortSignal.timeout(60_000);
|
||||
const response = await fetch(`https://nodejs.org/dist/v${KIMI_NODE_VERSION}/node-v${KIMI_NODE_VERSION}-${target}.tar.gz`, { redirect: "error", signal: signal ? AbortSignal.any([signal, timeout]) : timeout });
|
||||
if (!response.ok || !response.body) throw new Error("Pinned Kimi Node download failed");
|
||||
const chunks = []; let length = 0;
|
||||
for await (const chunk of response.body) {
|
||||
length += chunk.length;
|
||||
if (length > 128 * 1024 * 1024) throw new Error("Kimi Node archive exceeds its bound");
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
bytes = Buffer.concat(chunks);
|
||||
}
|
||||
if (bytes.length > 128 * 1024 * 1024 || hash(bytes) !== pin.archiveSha256) throw new Error("Kimi Node archive does not match its release pin");
|
||||
const archive = join(staging, `${target}.tar.gz`); await writeFile(archive, bytes);
|
||||
const output = join(staging, `node-${target}`); await mkdir(output);
|
||||
await runOwned("tar", ["-xzf", archive, "-C", output, "--strip-components=1", `node-v${KIMI_NODE_VERSION}-${target}/bin/node`, `node-v${KIMI_NODE_VERSION}-${target}/lib/node_modules/npm`], { env: environment, timeout: 30_000 });
|
||||
const executable = join(output, "bin/node");
|
||||
if (hash(await readFile(executable)) !== pin.executableSha256 || (await lstat(executable)).size !== pin.executableSize) throw new Error("Kimi Node executable does not match its target pin");
|
||||
const npm = join(output, "lib/node_modules/npm/bin/npm-cli.js");
|
||||
if (JSON.parse(await readFile(join(output, "lib/node_modules/npm/package.json"), "utf8")).version !== "11.19.0") throw new Error("Kimi Node archive has an unexpected npm version");
|
||||
return { executable, npm };
|
||||
}
|
||||
|
||||
async function removePackageManagerLinks(root) {
|
||||
const visit = async directory => {
|
||||
for (const name of await readdir(directory)) {
|
||||
const path = join(directory, name), stat = await lstat(path);
|
||||
if (stat.isSymbolicLink()) {
|
||||
if (dirname(path).endsWith("/node_modules/.bin")) await unlink(path);
|
||||
else throw new Error("Kimi package payload contains an unsupported symbolic link");
|
||||
} else if (stat.isDirectory()) await visit(path);
|
||||
else if (!stat.isFile()) throw new Error("Kimi package payload contains a non-file resource");
|
||||
}
|
||||
};
|
||||
await visit(root);
|
||||
await rm(join(root, "node_modules/.package-lock.json"), { force: true });
|
||||
}
|
||||
|
||||
export async function buildKimiDistribution({ outputRoot, target = `${process.platform}-${process.arch}`, lockDirectory = join(packageRoot, "scripts/kimi-distribution"), nodeArchives = {}, checkCancelled = () => {}, signal }) {
|
||||
checkCancelled(); signal?.throwIfAborted();
|
||||
if (typeof outputRoot !== "string" || !isAbsolute(outputRoot)) throw new Error("Kimi distribution output must be absolute");
|
||||
const output = resolve(outputRoot);
|
||||
if (["/", packageRoot, repositoryRoot].includes(output)) throw new Error("Refusing unsafe Kimi distribution output");
|
||||
if (!Object.hasOwn(KIMI_NODE_DISTRIBUTIONS, target)) throw new Error("Kimi distribution target is unsupported");
|
||||
const host = `${process.platform}-${process.arch}`;
|
||||
if (!Object.hasOwn(KIMI_NODE_DISTRIBUTIONS, host)) throw new Error("Kimi distribution build host is unsupported");
|
||||
try { await lstat(output); throw new Error("Kimi distribution output already exists"); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
||||
const lockBytes = await readFile(join(lockDirectory, "package-lock.json"));
|
||||
if (hash(lockBytes) !== KIMI_DISTRIBUTION_LOCK_SHA256) throw new Error("Kimi distribution lock differs from its source pin");
|
||||
await mkdir(dirname(output), { recursive: true });
|
||||
const staging = await mkdtemp(join(await realpath(dirname(output)), ".paperclip-kimi-distribution-"));
|
||||
const environment = kimiDistributionEnvironment();
|
||||
const runOwned = (command, args, options) => runKimiBuildCommand(command, args, options, checkCancelled, signal);
|
||||
const runtime = join(staging, "runtime");
|
||||
try {
|
||||
const buildHome = join(staging, "build-home"); await mkdir(buildHome); environment.HOME = buildHome;
|
||||
const installedNode = await nodeDistribution(staging, target, nodeArchives[target], environment, runOwned, signal);
|
||||
const buildNode = host === target ? installedNode : await nodeDistribution(staging, host, nodeArchives[host], environment, runOwned, signal);
|
||||
if ((await runOwned(buildNode.executable, ["--version"], { env: {}, timeout: 10_000 })).stdout.trim() !== `v${KIMI_NODE_VERSION}`) throw new Error("Kimi distribution build Node cannot execute");
|
||||
await mkdir(runtime);
|
||||
for (const name of ["package.json", "package-lock.json"]) await copyFile(join(lockDirectory, name), join(runtime, name));
|
||||
await writeFile(join(runtime, ".npmrc"), "registry=https://registry.npmjs.org/\nignore-scripts=true\naudit=false\nfund=false\n");
|
||||
await writeFile(join(runtime, ".npmrc-global"), "");
|
||||
await runOwned(buildNode.executable, [buildNode.npm, ...kimiDistributionInstallCommand()], { cwd: runtime, env: environment, timeout: 300_000, maxBuffer: 4 * 1024 * 1024 });
|
||||
if (!(await readFile(join(runtime, "package-lock.json"))).equals(lockBytes)) throw new Error("Kimi installation changed its committed lock");
|
||||
const packageCount = await verifyLockedKimiPackageGraph(runtime, JSON.parse(lockBytes.toString("utf8")));
|
||||
const patch = await patchKimiRuntime(join(runtime, KIMI_DISTRIBUTION_ENTRYPOINT));
|
||||
await mkdir(join(runtime, "node/bin"), { recursive: true });
|
||||
await copyFile(installedNode.executable, join(runtime, "node/bin/node")); await chmod(join(runtime, "node/bin/node"), 0o755);
|
||||
if (host === target) {
|
||||
const listing = process.platform === "darwin"
|
||||
? await runOwned("/usr/bin/otool", ["-L", join(runtime, "node/bin/node")], { env: {}, timeout: 10_000 })
|
||||
: await runOwned("ldd", [join(runtime, "node/bin/node")], { env: { PATH: "/usr/bin:/bin" }, timeout: 10_000 });
|
||||
assertKimiNodeSystemDependencies(listing.stdout, process.platform);
|
||||
if ((await runOwned(join(runtime, "node/bin/node"), ["--version"], { env: {}, timeout: 10_000 })).stdout.trim() !== `v${KIMI_NODE_VERSION}`) throw new Error("Kimi Node cannot execute after relocation");
|
||||
}
|
||||
await removePackageManagerLinks(runtime);
|
||||
const entries = await inventoryKimiRuntimeFiles(runtime);
|
||||
const closure = kimiRuntimeClosureDigest(entries);
|
||||
const metadata = { schema: "paperclip.kimi-distribution.v1", version: KIMI_RUNTIME_VERSION, target, nodeVersion: KIMI_NODE_VERSION, runtimeRoot: "runtime", entrypoint: KIMI_DISTRIBUTION_ENTRYPOINT, lockSha256: KIMI_DISTRIBUTION_LOCK_SHA256, patch, packageCount, nativeClosureSha256: closure };
|
||||
await writeFile(join(staging, "native-closure.json"), `${JSON.stringify({ entries })}\n`);
|
||||
await writeFile(join(staging, "kimi-distribution.json"), `${JSON.stringify(metadata, null, 2)}\n`);
|
||||
for (const name of await readdir(staging)) if (!["runtime", "native-closure.json", "kimi-distribution.json"].includes(name)) await rm(join(staging, name), { recursive: true, force: true });
|
||||
checkCancelled();
|
||||
await rename(staging, output);
|
||||
return { outputRoot: output, runtimeRoot: join(output, "runtime"), version: KIMI_RUNTIME_VERSION, closureDigest: `sha256:${closure}`, metadata };
|
||||
} catch (error) { await rm(staging, { recursive: true, force: true }); throw error; }
|
||||
}
|
||||
|
||||
export async function materializeKimiDistribution(options) {
|
||||
if (typeof options.outputRoot !== "string" || !isAbsolute(options.outputRoot)) throw new Error("Kimi distribution output must be absolute");
|
||||
const output = resolve(options.outputRoot);
|
||||
if (["/", packageRoot, repositoryRoot].includes(output)) throw new Error("Refusing unsafe Kimi distribution output");
|
||||
const target = options.target ?? `${process.platform}-${process.arch}`;
|
||||
if (!Object.hasOwn(KIMI_DISTRIBUTION_CLOSURE_SHA256, target)) throw new Error("Kimi distribution target is unsupported");
|
||||
try { await lstat(output); throw new Error("Kimi distribution output already exists"); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
||||
await mkdir(dirname(output), { recursive: true });
|
||||
const parent = await realpath(dirname(output));
|
||||
const temporary = await mkdtemp(join(parent, ".paperclip-kimi-admission-"));
|
||||
const candidate = join(temporary, "candidate");
|
||||
try {
|
||||
const result = await buildKimiDistribution({ ...options, outputRoot: candidate });
|
||||
if (result.metadata.nativeClosureSha256 !== KIMI_DISTRIBUTION_CLOSURE_SHA256[target]) throw new Error("Kimi native closure differs from its trusted target pin");
|
||||
try { await lstat(options.outputRoot); throw new Error("Kimi distribution output already exists"); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
||||
await rename(candidate, options.outputRoot);
|
||||
return { ...result, outputRoot: options.outputRoot, runtimeRoot: join(options.outputRoot, "runtime") };
|
||||
} finally { await rm(temporary, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
if (import.meta.url.endsWith("/materialize-kimi-distribution.mjs") && process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length !== 1) throw new Error("Usage: node scripts/materialize-kimi-distribution.mjs /absolute/output-directory");
|
||||
materializeKimiDistribution({ outputRoot: args[0] }).then(result => console.log(JSON.stringify(result))).catch(error => { console.error(error.message); process.exitCode = 1; });
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmod, link, mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { assertKimiNodeSystemDependencies, buildKimiDistribution, kimiDistributionEnvironment, kimiDistributionInstallCommand, materializeKimiDistribution, runKimiBuildCommand, verifyLockedKimiPackageGraph } from "./materialize-kimi-distribution.mjs";
|
||||
import { KIMI_DISTRIBUTION_LOCK_SHA256, KIMI_NODE_DISTRIBUTIONS } from "../src/drivers/acpx/kimi-closure-pins.ts";
|
||||
import { inventoryKimiRuntimeFiles, kimiRuntimeClosureDigest } from "../src/drivers/acpx/kimi-runtime-files.ts";
|
||||
|
||||
async function fixture(t) {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), "paperclip-kimi-distribution-test-")));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
const write = async (path, content) => { const file = join(root, path); await mkdir(dirname(file), { recursive: true }); await writeFile(file, content); };
|
||||
return { root, write };
|
||||
}
|
||||
|
||||
test("Kimi's portable graph pins public integrity and excludes lifecycle and optional TUI dependencies", async () => {
|
||||
const bytes = await readFile(new URL("./kimi-distribution/package-lock.json", import.meta.url));
|
||||
assert.equal(createHash("sha256").update(bytes).digest("hex"), KIMI_DISTRIBUTION_LOCK_SHA256);
|
||||
const lock = JSON.parse(bytes.toString("utf8"));
|
||||
const pkg = JSON.parse(await readFile(new URL("./kimi-distribution/package.json", import.meta.url), "utf8"));
|
||||
assert.deepEqual(pkg.dependencies, { "@moonshot-ai/kimi-code": "2.3.3" });
|
||||
assert.deepEqual(lock.packages[""].dependencies, pkg.dependencies);
|
||||
assert.equal(lock.packages["node_modules/@moonshot-ai/kimi-code"].integrity, "sha512-3V8bycucf9KgPRH27S8De2u0ip84/Ry3FpNn6gi5z9JmoEHt9vxiYHHZqiMdyDf5nPm4bv+YKGwKEQL4oYpRTw==");
|
||||
assert.equal(kimiDistributionInstallCommand()[0], "ci");
|
||||
assert.ok(kimiDistributionInstallCommand().includes("--ignore-scripts"));
|
||||
assert.ok(kimiDistributionInstallCommand().includes("--omit=optional"));
|
||||
assert.ok(kimiDistributionInstallCommand().includes("--registry=https://registry.npmjs.org"));
|
||||
assert.deepEqual(Object.keys(KIMI_NODE_DISTRIBUTIONS).sort(), ["darwin-arm64", "linux-x64"]);
|
||||
});
|
||||
|
||||
test("distribution build admits network settings without provider keys or Node injection", () => {
|
||||
const network = { PATH: "/usr/bin:/bin", LANG: "C.UTF-8", HTTPS_PROXY: "http://proxy.invalid", NODE_EXTRA_CA_CERTS: "/public/ca.pem" };
|
||||
assert.deepEqual(kimiDistributionEnvironment({ ...network, HOME: "/private/home", KIMI_MODEL_API_KEY: "canary-secret", NPM_TOKEN: "canary-secret", NODE_OPTIONS: "--require /foreign.js", NODE_PATH: "/foreign", NODE_TLS_REJECT_UNAUTHORIZED: "0" }), network);
|
||||
});
|
||||
|
||||
test("setup cancellation physically stops an in-flight owned child", { timeout: 5_000 }, async t => {
|
||||
const { root } = await fixture(t), pidFile = join(root, "pid");
|
||||
const controller = new AbortController();
|
||||
const result = runKimiBuildCommand(process.execPath, ["-e", "require('node:fs').writeFileSync(process.argv[1],String(process.pid));setInterval(()=>{},1000)", pidFile], { env: {}, timeout: 3_000 }, undefined, controller.signal).then(() => null, error => error);
|
||||
let pid;
|
||||
try {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
try { pid = Number(await readFile(pidFile, "utf8")); break; }
|
||||
catch (error) { if (error.code !== "ENOENT") throw error; await delay(10); }
|
||||
}
|
||||
assert.ok(Number.isSafeInteger(pid) && pid > 0, "The real child did not start");
|
||||
controller.abort();
|
||||
assert.equal((await result).code, "ABORT_ERR");
|
||||
assert.throws(() => process.kill(pid, 0), error => error.code === "ESRCH");
|
||||
} finally { controller.abort(); await result; }
|
||||
});
|
||||
|
||||
test("setup cancellation aborts the pending download and removes its staging tree", { timeout: 5_000 }, async t => {
|
||||
const { root } = await fixture(t), controller = new AbortController();
|
||||
let started;
|
||||
const downloading = new Promise(resolve => { started = resolve; });
|
||||
t.mock.method(globalThis, "fetch", (_url, options) => new Promise((_resolve, reject) => {
|
||||
started(); options.signal.addEventListener("abort", () => reject(options.signal.reason), { once: true });
|
||||
}));
|
||||
const result = buildKimiDistribution({ outputRoot: join(root, "output"), signal: controller.signal });
|
||||
await downloading; controller.abort();
|
||||
await assert.rejects(result, error => error.name === "AbortError");
|
||||
assert.deepEqual(await readdir(root), []);
|
||||
});
|
||||
|
||||
test("the installed graph rejects version drift and optional packages", async t => {
|
||||
const { root, write } = await fixture(t);
|
||||
const entry = { version: "2.3.3", resolved: "https://registry.npmjs.org/@moonshot-ai/kimi-code/-/kimi-code-2.3.3.tgz", integrity: "sha512-YWJjZA==" };
|
||||
const lock = { lockfileVersion: 3, packages: { "": { dependencies: { "@moonshot-ai/kimi-code": "2.3.3" } }, "node_modules/@moonshot-ai/kimi-code": entry, "node_modules/optional-native": { ...entry, optional: true } } };
|
||||
await write("node_modules/@moonshot-ai/kimi-code/package.json", '{"version":"2.3.3"}');
|
||||
assert.equal(await verifyLockedKimiPackageGraph(root, lock), 1);
|
||||
await write("node_modules/@moonshot-ai/kimi-code/package.json", '{"version":"2.3.4"}');
|
||||
await assert.rejects(verifyLockedKimiPackageGraph(root, lock), /differs from its lock/);
|
||||
await write("node_modules/@moonshot-ai/kimi-code/package.json", '{"version":"2.3.3"}');
|
||||
await write("node_modules/optional-native/package.json", '{"version":"2.3.3"}');
|
||||
await assert.rejects(verifyLockedKimiPackageGraph(root, lock), /optional TUI dependency/);
|
||||
});
|
||||
|
||||
test("inventory binds native assets, workers, permissions and bytes in stable path order", async t => {
|
||||
const { root, write } = await fixture(t);
|
||||
await write("node_modules/provider/worker.mjs", "worker");
|
||||
await write("node_modules/provider/native/addon.node", Buffer.from([0, 1, 2, 3]));
|
||||
await write("node/bin/node", "node");
|
||||
const first = await inventoryKimiRuntimeFiles(root);
|
||||
assert.deepEqual(first.map(entry => entry.path), ["node/bin/node", "node_modules/provider/native/addon.node", "node_modules/provider/worker.mjs"]);
|
||||
const digest = kimiRuntimeClosureDigest(first);
|
||||
await write("node_modules/provider/worker.mjs", "worker changed");
|
||||
assert.notEqual(kimiRuntimeClosureDigest(await inventoryKimiRuntimeFiles(root)), digest);
|
||||
await chmod(join(root, "node/bin/node"), 0o755);
|
||||
assert.equal((await inventoryKimiRuntimeFiles(root))[0].executable, true);
|
||||
});
|
||||
|
||||
test("inventory rejects external links, hardlinks and special files", async t => {
|
||||
const { root, write } = await fixture(t);
|
||||
await write("original", "content");
|
||||
await symlink("/outside", join(root, "escape"));
|
||||
await assert.rejects(inventoryKimiRuntimeFiles(root), /symbolic link/);
|
||||
await rm(join(root, "escape"));
|
||||
await link(join(root, "original"), join(root, "alias"));
|
||||
await assert.rejects(inventoryKimiRuntimeFiles(root), /invalid file/);
|
||||
});
|
||||
|
||||
test("setup refuses relative, root, unsupported and existing outputs before network work", async t => {
|
||||
const { root } = await fixture(t);
|
||||
for (const operation of [buildKimiDistribution, materializeKimiDistribution]) {
|
||||
await assert.rejects(operation({ outputRoot: "relative" }), /must be absolute/);
|
||||
await assert.rejects(operation({ outputRoot: "/" }), /unsafe/);
|
||||
await assert.rejects(operation({ outputRoot: join(root, "output"), target: "linux-arm64" }), /unsupported/);
|
||||
await assert.rejects(operation({ outputRoot: root }), /already exists/);
|
||||
}
|
||||
});
|
||||
|
||||
test("the bundled Node may depend only on platform system libraries", () => {
|
||||
assert.doesNotThrow(() => assertKimiNodeSystemDependencies("node:\n /usr/lib/libSystem.B.dylib (version 0)\n", "darwin"));
|
||||
assert.throws(() => assertKimiNodeSystemDependencies("node:\n /opt/homebrew/lib/libnode.dylib (version 0)\n", "darwin"), /unbundled/);
|
||||
assert.doesNotThrow(() => assertKimiNodeSystemDependencies("linux-vdso.so.1 (0x0)\nlibc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x0)\n/lib64/ld-linux-x86-64.so.2 (0x0)\n", "linux"));
|
||||
assert.throws(() => assertKimiNodeSystemDependencies("libcustom.so => /opt/private/libcustom.so (0x0)\n", "linux"), /unbundled/);
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { KIMI_INSTRUCTION_RUNTIME_SOURCE } from "./kimi-instructions-runtime.mjs";
|
||||
|
||||
export const KIMI_RUNTIME_PATCH = Object.freeze({
|
||||
version: "paperclip-kimi-2.3.3-instructions-v6",
|
||||
vendorSha256: "f18b711e22947d473175126d0c2da437d641187963a3bc300d66e183060703e3",
|
||||
patchedSha256: "79980aa95c54f325a05cbdf883f74443705db28f3e412a1b5ae22929d939eb66",
|
||||
});
|
||||
|
||||
const sha256 = value => createHash("sha256").update(value).digest("hex");
|
||||
|
||||
export function replaceKimiPatchAnchor(source, before, after) {
|
||||
if (source.split(before).length !== 2) throw new Error("Kimi runtime patch requires exactly one source anchor");
|
||||
return source.replace(before, () => after);
|
||||
}
|
||||
|
||||
export function patchKimiRuntimeSource(input) {
|
||||
if (sha256(input) !== KIMI_RUNTIME_PATCH.vendorSha256) throw new Error("Kimi runtime patch input digest mismatch");
|
||||
let source = input;
|
||||
const replace = (before, after) => { source = replaceKimiPatchAnchor(source, before, after); };
|
||||
replace("#!/usr/bin/env node\n", `#!/usr/bin/env node
|
||||
import { createHash as paperclipKimiCreateHash } from "node:crypto";
|
||||
let paperclipKimiInstructionSnapshot;
|
||||
function paperclipKimiInstructionState() {
|
||||
if (paperclipKimiInstructionSnapshot) return paperclipKimiInstructionSnapshot;
|
||||
const raw = process.env.PAPERCLIP_KIMI_INSTRUCTIONS;
|
||||
delete process.env.PAPERCLIP_KIMI_INSTRUCTIONS;
|
||||
return paperclipKimiInstructionSnapshot = (${KIMI_INSTRUCTION_RUNTIME_SOURCE})(raw, {
|
||||
createHash: paperclipKimiCreateHash, byteLength: value => Buffer.byteLength(value, "utf8")
|
||||
});
|
||||
}
|
||||
function paperclipKimiChildEnvironment(overrides) {
|
||||
const environment = { ...process.env, ...overrides };
|
||||
delete environment.KIMI_API_KEY;
|
||||
delete environment.PAPERCLIP_KIMI_INSTRUCTIONS;
|
||||
return environment;
|
||||
}
|
||||
`);
|
||||
replace('function buildEnv(overrides) {\n\tif (overrides === void 0) return;\n\treturn {\n\t\t...process.env,\n\t\t...overrides\n\t};\n}',
|
||||
'function buildEnv(overrides) {\n\treturn paperclipKimiChildEnvironment(overrides);\n}');
|
||||
replace('env: globalThis.process.env', 'env: paperclipKimiChildEnvironment()');
|
||||
replace('\t\tbindTerminalCapability(terminal) {\n\t\t\tthis._terminalEnabled = terminal === true;\n\t\t}',
|
||||
'\t\tbindTerminalCapability(terminal) {\n\t\t\tthis._terminalEnabled = false;\n\t\t}');
|
||||
replace("\t\tget systemPrompt() {\n\t\t\treturn this.profileState.systemPrompt;\n\t\t}",
|
||||
"\t\tget systemPrompt() {\n\t\t\treturn paperclipKimiInstructionState().append(this.profileState.systemPrompt);\n\t\t}");
|
||||
replace("async function runAcpServerWithStream(stream, opts = {}) {\n",
|
||||
"async function runAcpServerWithStream(stream, opts = {}) {\n\tpaperclipKimiInstructionState();\n");
|
||||
replace('\t\tmerged() {\n\t\t\treturn {\n\t\t\t\t...Object.fromEntries(this.pluginServers),\n\t\t\t\t...Object.fromEntries(this.fileServers)\n\t\t\t};\n\t\t}',
|
||||
'\t\tmerged() {\n\t\t\treturn {};\n\t\t}');
|
||||
replace('\tawait pushFirstExisting$1(roots, USER_GENERIC_DIRS$1, osHomeDir, "user");', '');
|
||||
replace('async function projectRoots(workDir, options = {}) {\n', 'async function projectRoots(workDir, options = {}) {\n\treturn [];\n');
|
||||
replace('async function projectAgentRoots(fs, workDir, warn) {\n', 'async function projectAgentRoots(fs, workDir, warn) {\n\treturn [];\n');
|
||||
replace('async function userAgentRoots(fs, homeDir, osHomeDir, warn) {\n', 'async function userAgentRoots(fs, homeDir, osHomeDir, warn) {\n\treturn [];\n');
|
||||
replace('this.byEvent = indexHooks([...configured ?? [], ...pluginHooks]);', 'this.byEvent = new Map();');
|
||||
replace('\t\tasync evaluate(context) {\n\t\t\tfor (const policy of this.policies) {',
|
||||
'\t\tasync evaluate(context) {\n\t\t\treturn { policyName: "paperclip-runner", result: { kind: "ask" } };\n\t\t\tfor (const policy of this.policies) {');
|
||||
replace('if (req.display.kind !== "plan_review") return CANONICAL_OPTIONS;',
|
||||
'if (req.display.kind !== "plan_review") return CANONICAL_OPTIONS.filter(option => option.kind !== "allow_always");');
|
||||
replace('\t\t\t\ttoolName: name,\n\t\t\t\taction,\n\t\t\t\tdisplay\n\t\t\t};\n\t\t\tconst approvalContext = {',
|
||||
'\t\t\t\ttoolName: name,\n\t\t\t\ttoolInput: context.args,\n\t\t\t\taction,\n\t\t\t\tdisplay\n\t\t\t};\n\t\t\tconst approvalContext = {');
|
||||
replace('\t\ttitle: req.toolName,\n\t\tcontent\n', '\t\ttitle: req.toolName,\n\t\tkind: inferToolKind(req.toolName),\n\t\trawInput: req.toolInput,\n\t\tcontent\n');
|
||||
const bindRuntime = '\t\t\thandle.accessor.get(IAgentLifecycleService).handleOf(agentContext.agentId).accessor.get(IAgentRuntimeBindingService).switch(runtimeId);';
|
||||
replace(bindRuntime, `${bindRuntime}
|
||||
\t\t\tconst paperclipProfile = handle.accessor.get(IAgentLifecycleService).handleOf(agentContext.agentId).accessor.get(IAgentProfileService);
|
||||
\t\t\treturn paperclipKimiInstructionState().receipt(sessionId, paperclipProfile.getSystemPrompt());`);
|
||||
replace("\t\t\tawait this.bindSessionRuntime?.(sessionId);\n",
|
||||
"\t\t\tconst paperclipReceipt = await this.bindSessionRuntime?.(sessionId);\n\t\t\tif (!paperclipReceipt || paperclipReceipt.sessionId !== sessionId) throw new Error(\"Kimi native instruction receipt is missing\");\n\t\t\tthis.paperclipInstructionReceipts ??= new Map();\n\t\t\tthis.paperclipInstructionReceipts.set(sessionId, paperclipReceipt);\n");
|
||||
replace("\t\t\t\t...await this.activateSession(meta.id)\n",
|
||||
"\t\t\t\t...await this.activateSession(meta.id),\n\t\t\t\t_meta: { paperclipKimiInstructions: this.paperclipInstructionReceipts.get(meta.id) }\n");
|
||||
const lifecycleState = "\t\t\t\tconfigOptions: await acpSession.configOptions(),\n\t\t\t\tmodes: acpSession.modeState()\n\t\t\t};\n\t\t}\n\t\tasync ";
|
||||
replace(`${lifecycleState}resumeSession(params)`,
|
||||
`${lifecycleState.replace("modes: acpSession.modeState()", "modes: acpSession.modeState(),\n\t\t\t\t_meta: { paperclipKimiInstructions: this.paperclipInstructionReceipts.get(params.sessionId) }")}resumeSession(params)`);
|
||||
replace(`${lifecycleState}listSessions(params)`,
|
||||
`${lifecycleState.replace("modes: acpSession.modeState()", "modes: acpSession.modeState(),\n\t\t\t\t_meta: { paperclipKimiInstructions: this.paperclipInstructionReceipts.get(params.sessionId) }")}listSessions(params)`);
|
||||
replace('\t\t\t\tdriver.resolve({ stopReason: turnEndReasonToStopReason(event.reason, error) });',
|
||||
'\t\t\t\tif (event.reason === "failed") {\n\t\t\t\t\tdriver.reject(RequestError$1.internalError({ schema: "paperclip.kimi.provider-error.v1", kind: "provider_failed" }, "Kimi provider turn failed"));\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tdriver.resolve({ stopReason: turnEndReasonToStopReason(event.reason, error) });');
|
||||
const stepSchema = 'object({ type: string(), turnId: number$1().int().nonnegative(), step: number$1().int().positive(), stepId: string().min(1).max(256).optional(), finishReason: string().max(32).optional(), reason: string().max(32).optional() })';
|
||||
replace('\tagentEvents = {\n\t\t"turn.started": {', `\tagentEvents = {
|
||||
\t\t"turn.step.started": { kind: "stream", name: "events", type: "turn.step.started", schema: ${stepSchema} },
|
||||
\t\t"turn.step.completed": { kind: "stream", name: "events", type: "turn.step.completed", schema: ${stepSchema} },
|
||||
\t\t"turn.step.interrupted": { kind: "stream", name: "events", type: "turn.step.interrupted", schema: ${stepSchema} },
|
||||
\t\t"turn.started": {`);
|
||||
replace('\t\t\tthis.subscriptions.push(events.on("assistant.delta", (event) => {', `\t\t\tthis.subscriptions.push(
|
||||
\t\t\t\tevents.on("turn.step.started", event => this.dispatchTurnEvent(event.turnId, () => this.paperclipKimiStep(event, "start"))),
|
||||
\t\t\t\tevents.on("turn.step.completed", event => this.dispatchTurnEvent(event.turnId, () => this.paperclipKimiStep(event, "end"))),
|
||||
\t\t\t\tevents.on("turn.step.interrupted", event => this.dispatchTurnEvent(event.turnId, () => this.paperclipKimiStep(event, "interrupted"))),
|
||||
\t\t\t\tevents.on("assistant.delta", (event) => {`);
|
||||
replace('\t\tonAssistantDelta(event) {\n\t\t\tif (this.driverFor(event.turnId) === void 0) return;\n\t\t\tthis.emit(assistantDeltaToSessionUpdate(this.sessionId, event));\n\t\t}', `\t\tpaperclipKimiMessageFailure(driver) {
|
||||
\t\t\tthis.settleDriver(driver, () => driver.reject(RequestError$1.internalError({ schema: "paperclip.kimi.provider-error.v1", kind: "message_boundary_invalid" }, "Kimi native message boundary is invalid")));
|
||||
\t\t}
|
||||
\t\tpaperclipKimiChunk(driver, text, kind, thought = false) {
|
||||
\t\t\tthis.emit({ sessionId: this.sessionId, update: {
|
||||
\t\t\t\tsessionUpdate: thought ? "agent_thought_chunk" : "agent_message_chunk",
|
||||
\t\t\t\tmessageId: driver.paperclipKimiMessage.id, content: { type: "text", text },
|
||||
\t\t\t\t_meta: { origin: "kimi-native-assistant", source: "kimi-turn-step-v1", kind }
|
||||
\t\t\t} });
|
||||
\t\t}
|
||||
\t\tpaperclipKimiStep(event, phase) {
|
||||
\t\t\tconst driver = this.driverFor(event.turnId);
|
||||
\t\t\tif (driver === void 0) return;
|
||||
\t\t\tconst current = driver.paperclipKimiMessage;
|
||||
\t\t\tif (!Number.isSafeInteger(event.step) || event.step < 1 || !Number.isSafeInteger(event.turnId) || event.turnId < 0) return this.paperclipKimiMessageFailure(driver);
|
||||
\t\t\tif (phase === "start") {
|
||||
\t\t\t\tif (current?.active || typeof event.stepId !== "string" || !/^[A-Za-z0-9_-]{1,256}$/.test(event.stepId) || current && event.step <= current.step) return this.paperclipKimiMessageFailure(driver);
|
||||
\t\t\t\tdriver.paperclipKimiMessageIds ??= new Set();
|
||||
\t\t\t\tconst id = "kimi-message-" + paperclipKimiCreateHash("sha256").update(JSON.stringify([this.sessionId, event.turnId, event.stepId])).digest("hex");
|
||||
\t\t\t\tif (driver.paperclipKimiMessageIds.size >= 4096 || driver.paperclipKimiMessageIds.has(id)) return this.paperclipKimiMessageFailure(driver);
|
||||
\t\t\t\tdriver.paperclipKimiMessageIds.add(id);
|
||||
\t\t\t\tdriver.paperclipKimiMessage = { id, step: event.step, stepId: event.stepId, active: true };
|
||||
\t\t\t\tthis.paperclipKimiChunk(driver, "", "start");
|
||||
\t\t\t\treturn;
|
||||
\t\t\t}
|
||||
\t\t\tif (!current || current.step !== event.step || event.stepId !== void 0 && event.stepId !== current.stepId) return this.paperclipKimiMessageFailure(driver);
|
||||
\t\t\tconst reason = phase === "interrupted" ? event.reason : event.finishReason;
|
||||
\t\t\tconst reasons = phase === "interrupted" ? ["aborted", "max_steps", "error"] : ["end_turn", "max_tokens", "tool_use", "filtered", "paused", "other"];
|
||||
\t\t\tif (!reasons.includes(reason)) return this.paperclipKimiMessageFailure(driver);
|
||||
\t\t\tif (!current.active) {
|
||||
\t\t\t\tif (phase === "interrupted") return;
|
||||
\t\t\t\treturn this.paperclipKimiMessageFailure(driver);
|
||||
\t\t\t}
|
||||
\t\t\tthis.paperclipKimiChunk(driver, "", "end:" + reason);
|
||||
\t\t\tcurrent.active = false;
|
||||
\t\t}
|
||||
\t\tonAssistantDelta(event) {
|
||||
\t\t\tconst driver = this.driverFor(event.turnId);
|
||||
\t\t\tif (driver === void 0) return;
|
||||
\t\t\tif (!driver.paperclipKimiMessage?.active || typeof event.delta !== "string") return this.paperclipKimiMessageFailure(driver);
|
||||
\t\t\tthis.paperclipKimiChunk(driver, event.delta, "delta");
|
||||
\t\t}`);
|
||||
replace('\t\tonThinkingDelta(event) {\n\t\t\tif (this.driverFor(event.turnId) === void 0) return;\n\t\t\tthis.emit(thinkingDeltaToSessionUpdate(this.sessionId, event));\n\t\t}', `\t\tonThinkingDelta(event) {
|
||||
\t\t\tconst driver = this.driverFor(event.turnId);
|
||||
\t\t\tif (driver === void 0) return;
|
||||
\t\t\tif (!driver.paperclipKimiMessage?.active || typeof event.delta !== "string") return this.paperclipKimiMessageFailure(driver);
|
||||
\t\t\tthis.paperclipKimiChunk(driver, event.delta, "delta", true);
|
||||
\t\t}`);
|
||||
return source;
|
||||
}
|
||||
|
||||
export async function patchKimiRuntime(path) {
|
||||
const input = await readFile(path, "utf8");
|
||||
const digest = sha256(input);
|
||||
if (digest === KIMI_RUNTIME_PATCH.patchedSha256) return { ...KIMI_RUNTIME_PATCH };
|
||||
const patched = patchKimiRuntimeSource(input);
|
||||
if (sha256(patched) !== KIMI_RUNTIME_PATCH.patchedSha256) throw new Error("Kimi runtime patch output digest mismatch");
|
||||
await writeFile(path, patched);
|
||||
return { ...KIMI_RUNTIME_PATCH };
|
||||
}
|
||||
|
||||
if (import.meta.url?.endsWith("/patch-kimi-runtime.mjs") && process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const path = process.argv[2];
|
||||
if (!path || process.argv.length !== 3) throw new Error("Usage: node patch-kimi-runtime.mjs <dist/main.mjs>");
|
||||
patchKimiRuntime(path).then(result => {
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
}).catch(error => {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : "Kimi runtime patch failed"}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { access, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { createRequire } from "node:module";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import { createKimiInstructionRuntime } from "./kimi-instructions-runtime.mjs";
|
||||
import { KIMI_RUNTIME_PATCH, patchKimiRuntime, patchKimiRuntimeSource, replaceKimiPatchAnchor } from "./patch-kimi-runtime.mjs";
|
||||
|
||||
const sha = text => createHash("sha256").update(text, "utf8").digest("hex");
|
||||
const binding = content => JSON.stringify({ schema: "paperclip.kimi.instructions.v1", content,
|
||||
digest: `sha256:${sha(content)}`, byteLength: Buffer.byteLength(content) });
|
||||
const runtime = raw => createKimiInstructionRuntime(raw, { createHash, byteLength: text => Buffer.byteLength(text, "utf8") });
|
||||
|
||||
test("native instruction binding preserves exact literals and rejects false readback", () => {
|
||||
const content = "\nGovernance: résumé 日本語 ${cwd} ${base_prompt}\n\n";
|
||||
const state = runtime(binding(content));
|
||||
const native = "Original native system prompt";
|
||||
const effective = `${native}\n\n${content}`;
|
||||
assert.equal(state.append(native), effective);
|
||||
assert.equal(state.append(effective), effective);
|
||||
const receipt = state.receipt("native-session", effective);
|
||||
assert.deepEqual(receipt, { schema: "paperclip.kimi.instructions.v1", sessionId: "native-session",
|
||||
digest: `sha256:${sha(content)}`, byteLength: Buffer.byteLength(content),
|
||||
effectiveDigest: `sha256:${sha(effective)}`, effectiveByteLength: Buffer.byteLength(effective) });
|
||||
assert.throws(() => state.receipt("native-session", native), /binding/);
|
||||
assert.throws(() => state.receipt("", effective), /binding/);
|
||||
assert.throws(() => state.append("x".repeat(2_097_153)), /binding/);
|
||||
assert.ok(Object.isFrozen(receipt));
|
||||
});
|
||||
|
||||
test("explicit empty instructions retain the native prompt and acknowledge zero bytes", () => {
|
||||
const state = runtime(binding(""));
|
||||
assert.equal(state.append("native"), "native");
|
||||
assert.equal(state.receipt("s", "native").byteLength, 0);
|
||||
});
|
||||
|
||||
test("malformed, changed, oversized, and ill-formed instruction bindings fail closed", () => {
|
||||
const valid = JSON.parse(binding("test"));
|
||||
for (const raw of [undefined, "{", "[]", "null", " ".repeat(200_001),
|
||||
binding("x\0y"), binding("x".repeat(32_769)), binding("\ud800"),
|
||||
JSON.stringify({ ...valid, byteLength: 1 }), JSON.stringify({ ...valid, digest: "sha256:" + "0".repeat(64) }),
|
||||
JSON.stringify({ ...valid, extra: true }), JSON.stringify({ schema: valid.schema, content: "test", digest: valid.digest })]) {
|
||||
assert.throws(() => runtime(raw), /binding/);
|
||||
}
|
||||
});
|
||||
|
||||
test("patch rejects unknown source and ambiguous anchors before writing", async () => {
|
||||
assert.throws(() => patchKimiRuntimeSource("unverified runtime"), /input digest/);
|
||||
assert.throws(() => replaceKimiPatchAnchor("absent", "needle", "replacement"), /exactly one/);
|
||||
assert.throws(() => replaceKimiPatchAnchor("needle needle", "needle", "replacement"), /exactly one/);
|
||||
const root = await mkdtemp(join(tmpdir(), "kimi-patch-test-"));
|
||||
const path = join(root, "main.mjs");
|
||||
try {
|
||||
await writeFile(path, "unverified runtime");
|
||||
await assert.rejects(patchKimiRuntime(path), /input digest/);
|
||||
assert.equal(await readFile(path, "utf8"), "unverified runtime");
|
||||
} finally { await rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
function segment(source, start, end) {
|
||||
const first = source.indexOf(start);
|
||||
assert.ok(first >= 0, `Missing native start: ${start}`);
|
||||
const last = source.indexOf(end, first + start.length);
|
||||
assert.ok(last > first, `Missing native end: ${end}`);
|
||||
return source.slice(first, last);
|
||||
}
|
||||
|
||||
test("pinned native package binds new/load and rejects failed turns", {
|
||||
skip: !process.env.PAPERCLIP_KIMI_VENDOR_MAIN,
|
||||
}, async () => {
|
||||
const input = await readFile(process.env.PAPERCLIP_KIMI_VENDOR_MAIN, "utf8");
|
||||
const patched = patchKimiRuntimeSource(input);
|
||||
assert.equal(sha(patched), KIMI_RUNTIME_PATCH.patchedSha256);
|
||||
const { build } = await import("esbuild");
|
||||
const bundledRoot = await mkdtemp(join(tmpdir(), "kimi-bundled-patch-test-"));
|
||||
try {
|
||||
const bundledPath = join(bundledRoot, "patch-kimi-runtime.bundle.cjs");
|
||||
await build({ entryPoints: [fileURLToPath(new URL("./patch-kimi-runtime.mjs", import.meta.url))],
|
||||
outfile: bundledPath, bundle: true, platform: "node", format: "cjs", target: "node24", logLevel: "silent",
|
||||
define: { "import.meta.url": JSON.stringify(pathToFileURL(bundledPath).href) } });
|
||||
const bundled = (await import(pathToFileURL(bundledPath))).default;
|
||||
assert.equal(sha(bundled.patchKimiRuntimeSource(input)), KIMI_RUNTIME_PATCH.patchedSha256,
|
||||
"installed bundled patcher produces exactly the same pinned native bytes");
|
||||
} finally { await rm(bundledRoot, { recursive: true, force: true }); }
|
||||
const content = " Exact ${cwd} instructions\n日本語\n";
|
||||
const env = { PAPERCLIP_KIMI_INSTRUCTIONS: binding(content) };
|
||||
const helper = segment(patched, "let paperclipKimiInstructionSnapshot;", "import { fileURLToPath");
|
||||
const context = { process: { env }, Buffer, paperclipKimiCreateHash: createHash };
|
||||
const state = runInNewContext(`${helper};paperclipKimiInstructionState()`, context);
|
||||
assert.equal(env.PAPERCLIP_KIMI_INSTRUCTIONS, undefined);
|
||||
env.PAPERCLIP_KIMI_INSTRUCTIONS = binding("replacement");
|
||||
assert.equal(runInNewContext("paperclipKimiInstructionState()", context), state);
|
||||
env.KIMI_API_KEY = "fake-provider-key";
|
||||
env.PAPERCLIP_TASK_SCOPED = "assigned-task-value";
|
||||
const childEnvironment = segment(patched, "function buildEnv(overrides) {", "function waitForSpawn(child)");
|
||||
const buildEnvironment = runInNewContext(`${childEnvironment};buildEnv`, context);
|
||||
for (const overrides of [undefined, { KIMI_API_KEY: "override-key", PAPERCLIP_KIMI_INSTRUCTIONS: "private", EXTRA: "assigned" }]) {
|
||||
const child = buildEnvironment(overrides);
|
||||
assert.equal(child.KIMI_API_KEY, undefined);
|
||||
assert.equal(child.PAPERCLIP_KIMI_INSTRUCTIONS, undefined);
|
||||
assert.equal(child.PAPERCLIP_TASK_SCOPED, "assigned-task-value");
|
||||
assert.equal(child.EXTRA, overrides?.EXTRA);
|
||||
}
|
||||
assert.equal(env.KIMI_API_KEY, "fake-provider-key");
|
||||
const terminalBinding = segment(patched, "\t\tbindTerminalCapability(terminal) {", "\n\t\tget terminalEnabled()");
|
||||
const bindTerminal = runInNewContext(`({${terminalBinding}}).bindTerminalCapability`);
|
||||
const terminalHolder = {};
|
||||
bindTerminal.call(terminalHolder, true);
|
||||
assert.equal(terminalHolder._terminalEnabled, false);
|
||||
const getter = segment(patched, "\t\tget systemPrompt() {", "\t\tget thinkingLevel()");
|
||||
const native = runInNewContext(`({profileState:{systemPrompt:"native"},${getter}})`, context);
|
||||
assert.equal(native.systemPrompt, `native\n\n${content}`);
|
||||
assert.equal(native.profileState.systemPrompt, "native");
|
||||
const receipt = state.receipt("s", native.systemPrompt);
|
||||
const baseline = segment(patched, "\t\tmerged() {\n\t\t\treturn {};", "\n\t\tasync watchConfigFiles()");
|
||||
const merged = runInNewContext(`({${baseline}}).merged`, context);
|
||||
assert.deepEqual(Object.keys(merged.call(new Proxy({}, { get: () => assert.fail("ambient MCP was read") }))), []);
|
||||
const policy = segment(patched, '\t\tasync evaluate(context) {\n\t\t\treturn { policyName: "paperclip-runner"', '\n\t};');
|
||||
const evaluate = runInNewContext(`({${policy}}).evaluate`, context);
|
||||
assert.equal((await evaluate.call(new Proxy({}, { get: () => assert.fail("native heuristic was read") }), {})).result.kind, "ask");
|
||||
const userRoots = segment(patched, "async function userRoots(homeDir, osHomeDir, options = {}) {", "async function projectRoots(");
|
||||
const discovered = [];
|
||||
const roots = await runInNewContext(`(${userRoots})`, { USER_BRAND_DIRS$1: ["skills"],
|
||||
pushBrandGroup: async (_roots, _dirs, home) => { discovered.push(home); },
|
||||
pushFirstExisting$1: () => assert.fail("global skills were read") })("/owned-kimi", "/ambient-home");
|
||||
assert.deepEqual(discovered, ["/owned-kimi"]);
|
||||
assert.equal(roots.length, 0);
|
||||
const host = { klient: { session: () => ({ agent: () => ({}) }) },
|
||||
bindDefaultModel: async () => {}, bindSessionRuntime: async () => undefined };
|
||||
const wire = segment(patched, "\t\tasync wireSession(sessionId) {", "\n\t\t/**");
|
||||
await assert.rejects(runInNewContext(`({${wire}}).wireSession`, context).call(host, "s"), /native instruction receipt/);
|
||||
const admitted = { ensureAuthed: async () => {}, klient: { global: { sessions: { create: async () => ({ id: "s" }) } } },
|
||||
activateSession: async () => ({}), paperclipInstructionReceipts: new Map([["s", receipt]]) };
|
||||
const create = segment(patched, "\t\tasync newSession(params) {", "\n\t\t/**");
|
||||
const createContext = { ...context, acpMcpServersToConfigRecord: value => value };
|
||||
const created = await runInNewContext(`({${create}}).newSession`, createContext).call(admitted, { cwd: "/workspace", mcpServers: [] });
|
||||
assert.equal(created._meta.paperclipKimiInstructions, receipt);
|
||||
for (const [name, next] of [["loadSession", "resumeSession"], ["resumeSession", "listSessions"]]) {
|
||||
const method = segment(patched, `\t\tasync ${name}(params) {`, `\t\tasync ${next}(params)`);
|
||||
const response = await runInNewContext(`({${method}}).${name}`, createContext).call({ ...admitted,
|
||||
warnIgnoredAdditionalDirs() {}, scheduleAvailableCommandsUpdate() {},
|
||||
resumeAcpSession: async () => ({ replayHistory: async () => {}, configOptions: async () => [], modeState: () => ({}) }) },
|
||||
{ sessionId: "s", mcpServers: [] });
|
||||
assert.equal(response._meta.paperclipKimiInstructions, receipt);
|
||||
}
|
||||
const terminal = segment(patched, "\t\tonTurnEnded(event) {", "\n\t\t/**");
|
||||
const rejection = [];
|
||||
const RequestError = { authRequired: () => ({ kind: "auth" }), internalError: data => data };
|
||||
const end = runInNewContext(`({${terminal}}).onTurnEnded`, { RequestError$1: RequestError,
|
||||
isAuthError: error => error?.code === "provider.auth_error", turnEndReasonToStopReason: () => "end_turn" });
|
||||
for (const code of ["provider.auth_error", "provider.server_error"]) {
|
||||
end.call({ driverFor: () => ({ reject: error => rejection.push(error), resolve: () => assert.fail("failed turn resolved") }),
|
||||
settleDriver: (_driver, action) => action(), emitUsageUpdate() {} }, { turnId: 1, reason: "failed", error: { code } });
|
||||
}
|
||||
assert.equal(rejection[0].kind, "auth");
|
||||
assert.equal(rejection[1].kind, "provider_failed");
|
||||
const infer = segment(patched, "function inferToolKind(name) {", "\n/**");
|
||||
const approval = segment(patched, "function buildPermissionToolCallUpdate(req) {", "\n/**");
|
||||
const permission = runInNewContext(`${infer}\n${approval}\nbuildPermissionToolCallUpdate`, {
|
||||
displayBlockToAcpContent: () => null, acpToolCallId: (turn, id) => `${turn}:${id}`,
|
||||
});
|
||||
for (const [toolName, kind] of [["Write", "edit"], ["Edit", "edit"], ["Read", "read"], ["Bash", "execute"], ["mcp__paperclip__read_workspace_file", "other"]]) {
|
||||
const args = { path: "/workspace/exact.txt", content: "body" };
|
||||
const request = permission({ toolName, toolInput: args, toolCallId: "native-tool", turnId: 1, display: {}, action: "test" });
|
||||
assert.equal(request.kind, kind);
|
||||
assert.equal(request.rawInput, args);
|
||||
assert.equal(request.toolCallId, "1:native-tool");
|
||||
}
|
||||
});
|
||||
|
||||
test("native step identity separates identical commentary/final text and retains empty boundaries", {
|
||||
skip: !process.env.PAPERCLIP_KIMI_VENDOR_MAIN,
|
||||
}, async () => {
|
||||
const patched = patchKimiRuntimeSource(await readFile(process.env.PAPERCLIP_KIMI_VENDOR_MAIN, "utf8"));
|
||||
const methods = segment(patched, "\t\tpaperclipKimiMessageFailure(driver) {", "\n\t\tonToolCallStarted(event)");
|
||||
const host = () => {
|
||||
const events = [], rejected = [];
|
||||
const driver = { reject: value => rejected.push(value) };
|
||||
const session = runInNewContext(`new class {${methods}}`, { paperclipKimiCreateHash: createHash,
|
||||
RequestError$1: { internalError: value => value } });
|
||||
Object.assign(session, { sessionId: "native-session", emit: value => events.push(value),
|
||||
driverFor: turnId => turnId === 7 && !driver.settled ? driver : undefined,
|
||||
settleDriver: (value, action) => { value.settled = true; action(); } });
|
||||
return { session, events, rejected, driver };
|
||||
};
|
||||
const h = host();
|
||||
h.session.paperclipKimiStep({ turnId: 7, step: 1, stepId: "native-step-uuid-one" }, "start");
|
||||
h.session.onAssistantDelta({ turnId: 7, delta: "SAME café" });
|
||||
h.session.onThinkingDelta({ turnId: 7, delta: "reasoning" });
|
||||
h.session.paperclipKimiStep({ turnId: 7, step: 1, stepId: "native-step-uuid-one", finishReason: "tool_use" }, "end");
|
||||
h.session.paperclipKimiStep({ turnId: 7, step: 2, stepId: "native-step-uuid-two" }, "start");
|
||||
h.session.onAssistantDelta({ turnId: 7, delta: "SAME café" });
|
||||
h.session.paperclipKimiStep({ turnId: 7, step: 2, stepId: "native-step-uuid-two", finishReason: "end_turn" }, "end");
|
||||
h.session.paperclipKimiStep({ turnId: 7, step: 3, stepId: "native-step-uuid-empty" }, "start");
|
||||
h.session.paperclipKimiStep({ turnId: 7, step: 3, stepId: "native-step-uuid-empty", finishReason: "end_turn" }, "end");
|
||||
const chunks = h.events.map(event => event.update);
|
||||
assert.equal(h.rejected.length, 0);
|
||||
assert.deepEqual(chunks.map(chunk => chunk._meta.kind), ["start", "delta", "delta", "end:tool_use", "start", "delta", "end:end_turn", "start", "end:end_turn"]);
|
||||
assert.equal(chunks[0].messageId, `kimi-message-${sha(JSON.stringify(["native-session", 7, "native-step-uuid-one"]))}`);
|
||||
assert.equal(new Set(chunks.map(chunk => chunk.messageId)).size, 3);
|
||||
assert.equal(chunks[2].sessionUpdate, "agent_thought_chunk");
|
||||
assert.ok(chunks.every(chunk => chunk._meta.origin === "kimi-native-assistant" && chunk._meta.source === "kimi-turn-step-v1"));
|
||||
assert.ok(chunks.filter(chunk => chunk._meta.kind !== "delta").every(chunk => chunk.content.text === ""));
|
||||
h.session.onAssistantDelta({ turnId: 8, delta: "foreign turn" });
|
||||
assert.equal(h.events.length, 9);
|
||||
for (const mutate of [
|
||||
value => value.session.onAssistantDelta({ turnId: 7, delta: "orphan" }),
|
||||
value => { value.session.paperclipKimiStep({ turnId: 7, step: 1, stepId: "uuid" }, "start"); value.session.paperclipKimiStep({ turnId: 7, step: 2, stepId: "other" }, "start"); },
|
||||
value => { value.session.paperclipKimiStep({ turnId: 7, step: 1, stepId: "uuid" }, "start"); value.session.paperclipKimiStep({ turnId: 7, step: 1, stepId: "foreign", finishReason: "end_turn" }, "end"); },
|
||||
value => { value.session.paperclipKimiStep({ turnId: 7, step: 1, stepId: "uuid" }, "start"); value.session.paperclipKimiStep({ turnId: 7, step: 1, stepId: "uuid", finishReason: "invented" }, "end"); },
|
||||
value => { value.session.paperclipKimiStep({ turnId: 7, step: 1, stepId: "uuid" }, "start"); value.session.paperclipKimiStep({ turnId: 7, step: 1, reason: "aborted" }, "interrupted"); value.session.paperclipKimiStep({ turnId: 7, step: 2, stepId: "uuid" }, "start"); },
|
||||
]) {
|
||||
const invalid = host(); mutate(invalid);
|
||||
assert.equal(invalid.rejected.length, 1);
|
||||
assert.equal(invalid.rejected[0].kind, "message_boundary_invalid");
|
||||
const count = invalid.events.length;
|
||||
invalid.session.onAssistantDelta({ turnId: 7, delta: "late after failure" });
|
||||
assert.equal(invalid.events.length, count);
|
||||
}
|
||||
});
|
||||
|
||||
test("real pinned Kimi and ACPX exchange instructions/tools/denial/errors with a simulated local model service", {
|
||||
skip: process.env.PAPERCLIP_TEST_KIMI_MODEL_PEER !== "1",
|
||||
timeout: 60_000,
|
||||
}, async t => {
|
||||
const packageRoot = await realpath(fileURLToPath(new URL("../", import.meta.url)));
|
||||
const assets = join(packageRoot, "provider-assets/kimi", `${process.platform}-${process.arch}`);
|
||||
try { await access(join(assets, "native-closure.json")); }
|
||||
catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
t.skip("Install the pinned Kimi runtime with paperclipai runtime setup kimi first");
|
||||
return;
|
||||
}
|
||||
const { tsImport } = await import("tsx/esm/api");
|
||||
const selectorPeer = process.env.PAPERCLIP_TEST_KIMI_FAULT_SELECTOR_PEER === "1";
|
||||
let compiledFactory;
|
||||
if (selectorPeer) {
|
||||
assert.equal(process.platform, "darwin"); assert.equal(process.arch, "arm64");
|
||||
const cli = join(packageRoot, "dist/cli/acpx-runtime-sidecar.cjs");
|
||||
const compiled = await readFile(cli, "utf8");
|
||||
const boundary = "var lines = (0, import_node_readline.createInterface)({";
|
||||
const at = compiled.indexOf(boundary);
|
||||
assert.ok(at > 0 && compiled.lastIndexOf(boundary) === at && Buffer.byteLength(compiled) <= 16 * 1024 * 1024);
|
||||
const module = { exports: {} };
|
||||
new Function("require", "module", "exports", "__filename", "__dirname", compiled.slice(0, at).replace(/^#![^\n]*\n/, "\n")
|
||||
+ "\nmodule.exports={verifyKimiInstallation,QUALIFIED_ACPX_PROFILES,acquireAcpxProviderLifetimeLease,createAcpxSidecarHostEnvironment,createSanitizedAcpxSpawnInput};")(
|
||||
createRequire(cli), module, module.exports, cli, dirname(cli));
|
||||
compiledFactory = module.exports;
|
||||
}
|
||||
const [{ verifyKimiInstallation }, { QUALIFIED_ACPX_PROFILES }, { kimiManagedConfiguration }, instructionModule, { createKimiMessageProjection }, { normalizeAcpxPermission }, environmentModule, { configuredEnvironmentProjection, CONFIGURED_ENVIRONMENT_KEYS }] = await Promise.all([
|
||||
tsImport("../src/drivers/acpx/kimi-installation.ts", import.meta.url),
|
||||
tsImport("../src/drivers/acpx/qualified-profiles.ts", import.meta.url),
|
||||
tsImport("../src/drivers/acpx/kimi-config.ts", import.meta.url),
|
||||
tsImport("../src/drivers/acpx/kimi-instructions.ts", import.meta.url),
|
||||
tsImport("../src/drivers/acpx/kimi-message-boundary.ts", import.meta.url),
|
||||
tsImport("../src/drivers/acpx/acp-permission-adapter.ts", import.meta.url),
|
||||
tsImport("../src/drivers/acpx/environment.ts", import.meta.url),
|
||||
tsImport("../src/configured-environment.ts", import.meta.url),
|
||||
]);
|
||||
const { createAcpRuntime, createFileSessionStore } = await import(pathToFileURL(join(packageRoot, "node_modules/acpx/dist/runtime.js")));
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), "kimi-simulated-model-peer-")));
|
||||
const home = join(root, "home"), workspace = join(root, "workspace");
|
||||
await mkdir(home, { mode: 0o700 }); await mkdir(workspace);
|
||||
const instructions = "\nExact governance café 日本語 ${cwd} ${base_prompt}\nKeep the final LF.\n";
|
||||
const selectedModel = "kimi-for-coding";
|
||||
const binding = instructionModule.kimiInstructionBinding(instructions);
|
||||
const argumentsObject = { payload: "café 🧪\n${literal}", nested: { count: 0, empty: {}, flag: false } };
|
||||
const toolReceipt = sha(JSON.stringify(argumentsObject));
|
||||
const deniedPath = join(workspace, "denied-peer.txt");
|
||||
const notificationEnvironment = {
|
||||
PAPERCLIP_RUNTIME_NOTIFICATION_CONTEXT: join(root, "controlled-notification-context.json"),
|
||||
PAPERCLIP_RUNTIME_NOTIFICATION_HELPER: join(root, "controlled-notification-helper"),
|
||||
};
|
||||
await writeFile(notificationEnvironment.PAPERCLIP_RUNTIME_NOTIFICATION_CONTEXT, '{"controlledEnvironmentOnly":true}\n', { flag: "wx", mode: 0o600 });
|
||||
await writeFile(notificationEnvironment.PAPERCLIP_RUNTIME_NOTIFICATION_HELPER, "controlled path fixture\n", { flag: "wx", mode: 0o700 });
|
||||
const shellQuote = value => "'" + value.replaceAll("'", "'\\''") + "'";
|
||||
const notificationChecks = Object.entries(notificationEnvironment).map(([key, value]) => `if test "$${key}" != ${shellQuote(value)}; then printf "NOTIFICATION_DISCOVERY_CHANGED"; exit 1; fi; `).join("");
|
||||
const bashArguments = { command: 'if test -n "${KIMI_API_KEY+x}"; then printf "PROVIDER_KEY_PRESENT"; exit 1; fi; if test -n "${PAPERCLIP_KIMI_INSTRUCTIONS+x}"; then printf "PRIVATE_BINDING_PRESENT"; exit 1; fi; if test "$PAPERCLIP_TASK_SCOPED" != "assigned-task-canary"; then printf "TASK_VARIABLE_MISSING"; exit 1; fi; ' + notificationChecks + 'printf "KIMI_CHILD_CREDENTIAL_FENCE_OK"', timeout: 5 };
|
||||
const modelRequests = [], mcpCalls = [], permissions = [], deliveries = [], nativeMessages = [], owners = [];
|
||||
let scenario = "echo", modelStep = 0, peerFailure, permissionGate;
|
||||
const complete = (response, chunks) => {
|
||||
response.writeHead(200, { "content-type": "text/event-stream" });
|
||||
const bytes = Buffer.from(chunks.map(chunk => `data: ${JSON.stringify({ id: "simulated-peer", ...chunk })}\n\n`).join("") + "data: [DONE]\n\n");
|
||||
const index = bytes.indexOf(Buffer.from("é"));
|
||||
if (index >= 0) { response.write(bytes.subarray(0, index + 1)); response.end(bytes.subarray(index + 1)); }
|
||||
else response.end(bytes);
|
||||
};
|
||||
const server = createServer(async (request, response) => {
|
||||
try {
|
||||
if (request.method !== "POST") { response.writeHead(405); response.end(); return; }
|
||||
let body = "";
|
||||
for await (const chunk of request) { body += chunk; assert.ok(Buffer.byteLength(body) < 2_097_152); }
|
||||
const value = JSON.parse(body);
|
||||
if (request.url === "/v1/chat/completions") {
|
||||
assert.equal(request.headers.authorization, "Bearer simulated-peer-not-a-credential");
|
||||
assert.equal(value.model, selectedModel);
|
||||
const system = value.messages.find(message => message.role === "system")?.content;
|
||||
assert.equal(typeof system, "string");
|
||||
assert.ok(system.endsWith(`\n\n${instructions}`), "exact literal instructions reached native HTTP request");
|
||||
const ack = nativeMessages.map(entry => entry.message.result?._meta?.paperclipKimiInstructions).filter(Boolean).at(-1);
|
||||
assert.equal(ack?.digest, binding.digest);
|
||||
assert.equal(ack?.effectiveDigest, `sha256:${sha(system)}`, "native instruction receipt matches the full actual model-request prompt");
|
||||
modelRequests.push({ scenario, value });
|
||||
assert.ok(modelRequests.length <= 9, "bounded simulated model requests");
|
||||
modelStep++;
|
||||
if (scenario === "error") {
|
||||
response.writeHead(418, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ error: { type: "simulated_provider_error", message: "private-peer-error-canary" } }));
|
||||
return;
|
||||
}
|
||||
if (modelStep === 1) {
|
||||
const tools = scenario === "echo"
|
||||
? [{ id: "peer-echo", name: "mcp__peer__echo_peer", args: argumentsObject }, { id: "peer-empty", name: "mcp__peer__empty_peer", args: {} }]
|
||||
: scenario === "bash" ? [{ id: "peer-bash", name: "Bash", args: bashArguments }]
|
||||
: [{ id: `peer-${scenario}`, name: "Write", args: { path: permissionGate?.path ?? deniedPath, content: "must never be written" } }];
|
||||
for (const tool of tools) assert.ok(value.tools.some(declared => declared.function?.name === tool.name), `native request discloses ${tool.name}`);
|
||||
complete(response, [
|
||||
{ choices: [{ index: 0, delta: { content: "Before tool café." } }] },
|
||||
{ choices: [{ index: 0, delta: { tool_calls: tools.map((tool, index) => ({ index, id: tool.id, type: "function", function: { name: tool.name, arguments: JSON.stringify(tool.args).slice(0, 1) } })) } }] },
|
||||
...tools.map((tool, index) => ({ choices: [{ index: 0, delta: { tool_calls: [{ index, function: { arguments: JSON.stringify(tool.args).slice(1) } }] } }] })),
|
||||
{ choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] },
|
||||
]);
|
||||
} else {
|
||||
const result = value.messages.find(message => message.role === "tool" && message.tool_call_id === `peer-${scenario}`);
|
||||
assert.ok(result, "the exact native tool result returned to the model");
|
||||
if (scenario === "echo") {
|
||||
assert.ok(JSON.stringify(result).includes(toolReceipt), `MCP receiver result reached the next native request: ${JSON.stringify({ result, mcpCalls: mcpCalls.length, permissions })}`);
|
||||
assert.ok(value.messages.some(message => message.role === "tool" && message.tool_call_id === "peer-empty" && JSON.stringify(message).includes("EMPTY_TOOL_ACCEPTED")), "zero-argument MCP result returned to the native model");
|
||||
}
|
||||
else if (scenario === "bash") assert.match(result.content, /^(?:Wall time: [0-9.]+ seconds\n)?KIMI_CHILD_CREDENTIAL_FENCE_OK\n?$/, "native Bash excluded provider/private credentials and retained the assigned task variable plus owned Runner notification discovery");
|
||||
else assert.match(JSON.stringify(result), /denied|rejected|approval/i);
|
||||
complete(response, [{ choices: [{ index: 0, delta: { content: scenario === "echo" ? "PEER_ECHO_OK café" : scenario === "bash" ? "PEER_BASH_OK" : "PEER_DENIED_OK" } }] },
|
||||
{ choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 12, completion_tokens: 3, total_tokens: 15 } }]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
assert.equal(request.url, "/mcp");
|
||||
if (value.id === undefined) { response.writeHead(202); response.end(); return; }
|
||||
if (value.method === "tools/call") {
|
||||
assert.ok(["echo_peer", "empty_peer"].includes(value.params.name));
|
||||
assert.deepEqual(value.params.arguments, value.params.name === "echo_peer" ? argumentsObject : {});
|
||||
mcpCalls.push(value.params);
|
||||
}
|
||||
const result = value.method === "initialize"
|
||||
? { protocolVersion: value.params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "simulated-peer", version: "1" } }
|
||||
: value.method === "tools/list" ? { tools: [{ name: "echo_peer", description: "Echo the supplied JSON through the controlled peer.",
|
||||
inputSchema: { type: "object", properties: { payload: { type: "string" }, nested: { type: "object", additionalProperties: true } }, required: ["payload", "nested"] } },
|
||||
{ name: "empty_peer", description: "Return a receipt from an empty object argument.", inputSchema: { type: "object", properties: {}, additionalProperties: false } }] }
|
||||
: { content: [{ type: "text", text: value.params.name === "empty_peer" ? "EMPTY_TOOL_ACCEPTED" : JSON.stringify({ echoed: argumentsObject, receipt: toolReceipt }) }] };
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ jsonrpc: "2.0", id: value.id, result }));
|
||||
} catch (error) {
|
||||
peerFailure ??= error;
|
||||
if (!response.headersSent) response.writeHead(400, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ error: { type: "peer_assertion_failed", message: "simulated peer assertion failed" } }));
|
||||
}
|
||||
});
|
||||
const keys = ["PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", "PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST"];
|
||||
const previous = keys.map(key => process.env[key]);
|
||||
const closeOwner = async owner => {
|
||||
if (owner.closed) return;
|
||||
owner.closed = true;
|
||||
const started = Date.now();
|
||||
const terminate = setTimeout(() => { for (const child of owner.children) if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM"); }, 2_000);
|
||||
const kill = setTimeout(() => { for (const child of owner.children) if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); }, 3_000);
|
||||
try {
|
||||
if (owner.handle) await owner.runtime.close({ handle: owner.handle, reason: "simulated peer cleanup" });
|
||||
} finally {
|
||||
try {
|
||||
await Promise.all(owner.completions);
|
||||
assert.ok(Date.now() - started < 5_000, "native process cleanup is bounded");
|
||||
} finally { clearTimeout(terminate); clearTimeout(kill); await owner.lease.close(); await owner.fence?.close(); }
|
||||
}
|
||||
};
|
||||
try {
|
||||
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); });
|
||||
const endpoint = `http://127.0.0.1:${server.address().port}`;
|
||||
const config = kimiManagedConfiguration(selectedModel).replace(/^base_url = .+$/m, `base_url = ${JSON.stringify(`${endpoint}/v1`)}`)
|
||||
+ "\n[loop_control]\nmax_attempts_per_step = 1\n";
|
||||
await writeFile(join(home, "config.toml"), config, { mode: 0o600 });
|
||||
process.env[keys[0]] = packageRoot; process.env[keys[1]] = join(packageRoot, "package.json");
|
||||
const installation = compiledFactory
|
||||
? await compiledFactory.verifyKimiInstallation(compiledFactory.QUALIFIED_ACPX_PROFILES.kimi)
|
||||
: await verifyKimiInstallation(QUALIFIED_ACPX_PROFILES.kimi);
|
||||
const store = createFileSessionStore({ stateDir: join(root, "acpx-state") });
|
||||
const taskEnvironment = configuredEnvironmentProjection({ PAPERCLIP_TASK_SCOPED: "assigned-task-canary",
|
||||
...Object.fromEntries(Object.keys(notificationEnvironment).map(key => [key, "forged-task-override"])) });
|
||||
for (const key of Object.keys(notificationEnvironment)) assert.ok(!Object.hasOwn(taskEnvironment, key));
|
||||
const explicitEnvironment = { ...taskEnvironment, ...notificationEnvironment, PATH: "/usr/bin:/bin", LANG: "C.UTF-8", KIMI_API_KEY: "simulated-peer-not-a-credential" };
|
||||
const sessionId = "controlled-native-environment";
|
||||
const compose = compiledFactory ?? environmentModule;
|
||||
const sidecarEnvironment = compose.createAcpxSidecarHostEnvironment({ ...explicitEnvironment,
|
||||
[environmentModule.ACPX_CREDENTIAL_BINDING_ENV]: environmentModule.createAcpxCredentialBinding(explicitEnvironment, "kimi", sessionId) }, "kimi", sessionId);
|
||||
const launchEnvironment = compose.createSanitizedAcpxSpawnInput(sidecarEnvironment, "kimi").env;
|
||||
for (const [key, value] of Object.entries(notificationEnvironment)) {
|
||||
assert.equal(launchEnvironment[key], value);
|
||||
assert.throws(() => compose.createSanitizedAcpxSpawnInput({ ...sidecarEnvironment,
|
||||
[CONFIGURED_ENVIRONMENT_KEYS]: JSON.stringify([key]), [key]: "forged-task-override" }, "kimi"), /configured environment projection/);
|
||||
}
|
||||
assert.equal(launchEnvironment.PAPERCLIP_TASK_SCOPED, "assigned-task-canary");
|
||||
assert.equal(launchEnvironment.KIMI_API_KEY, "simulated-peer-not-a-credential");
|
||||
const spawnNative = (lease, owner) => {
|
||||
const child = lease.spawn([], { cwd: workspace, stdio: "pipe", env: { ...launchEnvironment, HOME: home,
|
||||
CI: "1", NO_COLOR: "1", TERM: "dumb", KIMI_CODE_HOME: home,
|
||||
KIMI_CODE_NO_AUTO_UPDATE: "1", KIMI_DISABLE_TELEMETRY: "1",
|
||||
KIMI_CODE_WATCH: "0", KIMI_DISABLE_CRON: "1", PAPERCLIP_KIMI_INSTRUCTIONS: binding.payload,
|
||||
...(selectorPeer ? { PAPERCLIP_FAULT_ENV_CANARY: "must-not-appear-in-observed-argv" } : {}) } },
|
||||
owner.fence ? { credentialFenceFds: owner.fence.lifetimeFenceFds, activateCredentialFenceOwner: pid => owner.fence.activateLifetimeOwner(pid) } : undefined);
|
||||
owner.children.push(child);
|
||||
owner.completions.push(new Promise((resolve, reject) => { child.once("close", resolve); child.once("error", reject); }));
|
||||
return child;
|
||||
};
|
||||
const connect = async (resumeSessionId, sessionKey = "scripted-kimi-peer") => {
|
||||
const lease = await installation.openCommand();
|
||||
const admission = instructionModule.createKimiInstructionAdmission(instructions);
|
||||
const fence = compiledFactory ? await compiledFactory.acquireAcpxProviderLifetimeLease({ agentHomeDirectory: home }) : undefined;
|
||||
const owner = { lease, fence, children: [], completions: [], handle: undefined, runtime: undefined, closed: false };
|
||||
owners.push(owner);
|
||||
owner.runtime = createAcpRuntime({ cwd: workspace, sessionStore: store,
|
||||
agentRegistry: { resolve: () => "paperclip-verified-kimi-command", list: () => ["kimi"] },
|
||||
mcpServers: [{ type: "http", name: "peer", url: `${endpoint}/mcp`, headers: [] }],
|
||||
permissionMode: "approve-all", permissionPolicy: { defaultAction: "escalate" }, timeoutMs: 15_000,
|
||||
protocolGuardFactory: () => admission.createGuard(),
|
||||
onAcpMessage: (direction, message) => nativeMessages.push({ owner, direction, message }),
|
||||
onPermissionRequest: async (request, context) => {
|
||||
permissions.push(request.raw);
|
||||
assert.ok(context.responseDelivery instanceof Promise);
|
||||
assert.ok(request.raw.options.every(option => option.kind !== "allow_always"));
|
||||
const normalized = normalizeAcpxPermission(request, { provider: "kimi", workingDirectory: workspace });
|
||||
if (request.raw.toolCall.title === "Write") {
|
||||
assert.equal(request.raw.toolCall.kind, "edit", "native approval carries the actual tool's canonical kind");
|
||||
const path = request.raw.toolCall.rawInput.path;
|
||||
assert.equal(normalized.title, `Change file: ${path.slice(workspace.length + 1)}`);
|
||||
assert.equal(normalized.kind, "edit");
|
||||
assert.deepEqual(normalized.choices.map(choice => choice.key), ["accept", "decline", "cancel"]);
|
||||
for (const workingDirectory of [undefined, home]) {
|
||||
const unsafe = normalizeAcpxPermission(request, { provider: "kimi", workingDirectory });
|
||||
assert.equal(unsafe.title, "File change requested; target unavailable. Deny or cancel this request.");
|
||||
assert.deepEqual(unsafe.choices.map(choice => choice.key), ["decline", "cancel"]);
|
||||
assert.throws(() => unsafe.resolve({ action: "accept" }), /not an offered choice/);
|
||||
}
|
||||
}
|
||||
if (permissionGate) {
|
||||
assert.equal(request.raw.toolCall.title, "Write");
|
||||
assert.deepEqual(request.raw.toolCall.rawInput, { path: permissionGate.path, content: "must never be written" });
|
||||
permissionGate.context = context;
|
||||
permissionGate.delivery = context.responseDelivery.then(() => "delivered", () => "suppressed");
|
||||
permissionGate.requestId = nativeMessages.findLast(entry => entry.owner === owner && entry.direction === "inbound"
|
||||
&& entry.message.method === "session/request_permission"
|
||||
&& entry.message.params.toolCall.rawInput?.path === permissionGate.path)?.message.id;
|
||||
assert.ok(permissionGate.requestId !== undefined);
|
||||
permissionGate.markSeen();
|
||||
return permissionGate.decision;
|
||||
}
|
||||
deliveries.push(context.responseDelivery);
|
||||
if (request.raw.toolCall.title === "Write") {
|
||||
assert.deepEqual(request.raw.toolCall.rawInput, { path: deniedPath, content: "must never be written" });
|
||||
await assert.rejects(access(deniedPath), { code: "ENOENT" });
|
||||
return { outcome: "reject_once" };
|
||||
}
|
||||
if (request.raw.toolCall.title === "Bash") {
|
||||
assert.deepEqual(request.raw.toolCall.rawInput, bashArguments);
|
||||
return { outcome: "allow_once" };
|
||||
}
|
||||
assert.ok(["mcp__peer__echo_peer", "mcp__peer__empty_peer"].includes(request.raw.toolCall.title));
|
||||
assert.deepEqual(request.raw.toolCall.rawInput, request.raw.toolCall.title === "mcp__peer__echo_peer" ? argumentsObject : {});
|
||||
return { outcome: "allow_once" };
|
||||
},
|
||||
spawnAgent: () => spawnNative(lease, owner),
|
||||
});
|
||||
owner.handle = await owner.runtime.ensureSession({ sessionKey, agent: "kimi", mode: "persistent", cwd: workspace,
|
||||
...(resumeSessionId ? { resumeSessionId } : {}),
|
||||
sessionOptions: { model: selectedModel } });
|
||||
return owner;
|
||||
};
|
||||
const prompt = async (owner, name) => {
|
||||
scenario = name; modelStep = 0;
|
||||
const turn = owner.runtime.startTurn({ handle: owner.handle, mode: "prompt", text: `Controlled ${name} boundary.`, requestId: `peer-${name}` });
|
||||
const events = [];
|
||||
const drained = (async () => { for await (const event of turn.events) events.push(event); })();
|
||||
const result = await turn.result;
|
||||
await drained;
|
||||
if (peerFailure) throw peerFailure;
|
||||
if (name !== "error") {
|
||||
const chunks = events.filter(event => event.type === "text_delta" && event.stream === "output");
|
||||
assert.deepEqual(chunks.map(event => event.meta?.kind), ["start", "delta", "end:tool_use", "start", "delta", "end:end_turn"]);
|
||||
assert.ok(chunks.every(event => /^kimi-message-[a-f0-9]{64}$/.test(event.messageId)
|
||||
&& event.meta.origin === "kimi-native-assistant" && event.meta.source === "kimi-turn-step-v1"));
|
||||
assert.equal(new Set(chunks.map(event => event.messageId)).size, 2);
|
||||
assert.equal(chunks[0].messageId, chunks[2].messageId);
|
||||
assert.equal(chunks[3].messageId, chunks[5].messageId);
|
||||
assert.notEqual(chunks[0].messageId, chunks[3].messageId);
|
||||
assert.equal(chunks[1].text, "Before tool café.");
|
||||
assert.ok(chunks.filter(event => event.meta.kind !== "delta").every(event => event.text === ""));
|
||||
const projection = createKimiMessageProjection();
|
||||
const projected = events.map(event => projection.normalize(event));
|
||||
projection.settle();
|
||||
assert.deepEqual(projected.filter(event => event.assistantMessageBoundary).map(event => event.assistantMessageBoundary), [
|
||||
{ phase: "start" }, { phase: "end", stopReason: "toolUse" },
|
||||
{ phase: "start" }, { phase: "end", stopReason: "stop" },
|
||||
]);
|
||||
}
|
||||
return { result, events };
|
||||
};
|
||||
const first = await connect();
|
||||
const echoed = await prompt(first, "echo");
|
||||
assert.equal(echoed.result.status, "completed");
|
||||
assert.equal(mcpCalls.length, 2);
|
||||
assert.ok(echoed.events.some(event => event.type === "text_delta" && event.text.includes("PEER_ECHO_OK")));
|
||||
const shell = await prompt(first, "bash");
|
||||
assert.equal(shell.result.status, "completed");
|
||||
assert.ok(shell.events.some(event => event.type === "text_delta" && event.text.includes("PEER_BASH_OK")));
|
||||
assert.ok(nativeMessages.some(entry => entry.direction === "outbound" && entry.message.method === "initialize" && entry.message.params.clientCapabilities.terminal === true), "actual ACPX offered its mandatory client terminal");
|
||||
assert.ok(!nativeMessages.some(entry => entry.direction === "inbound" && entry.message.method === "terminal/create"), "Kimi kept Bash in its native fenced process service despite ACPX client terminal capability");
|
||||
await closeOwner(first);
|
||||
const second = await connect(first.handle.backendSessionId);
|
||||
const denied = await prompt(second, "deny");
|
||||
assert.equal(denied.result.status, "completed");
|
||||
await assert.rejects(access(deniedPath), { code: "ENOENT" });
|
||||
const failed = await prompt(second, "error");
|
||||
assert.equal(failed.result.status, "failed");
|
||||
assert.match(JSON.stringify(failed.result), /Kimi provider turn failed/);
|
||||
assert.ok(!JSON.stringify(failed.result).includes("private-peer-error-canary"));
|
||||
assert.ok(nativeMessages.some(entry => entry.direction === "outbound" && ["session/load", "session/resume"].includes(entry.message.method)), "ACPX restored the native session before the second model turn");
|
||||
assert.ok(permissions.some(request => request.toolCall.title === "Write"));
|
||||
assert.equal(permissions.length, 4);
|
||||
await Promise.all(deliveries);
|
||||
assert.equal(modelRequests.length, 7);
|
||||
await closeOwner(second);
|
||||
assert.ok(owners.every(owner => owner.children.every(child => child.exitCode !== null || child.signalCode !== null)));
|
||||
await assert.rejects(access(deniedPath), { code: "ENOENT" });
|
||||
t.diagnostic("SIMULATED provider service; real pinned Kimi 2.3.3 + ACPX manager: 7 native model requests with provider auth intact, exact create/resume instruction suffix, native UUID start/end separates pre-tool commentary from final text despite reused model response IDs, actual provider projection yields toolUse/stop boundaries, two MCP JSON/result round trips including terminal {}, native Bash excludes provider/private credentials and retains the assigned task variable and exact owned Runner notification paths after actual configured-environment projection rejected forged task overrides, rejected Write with no file, typed provider failure, owned process closure; zero external model calls");
|
||||
if (process.env.PAPERCLIP_TEST_KIMI_LIFECYCLE_PEER === "1") {
|
||||
const { readProcessTable, observeDescendantProcessTree } = await tsImport("../../../tests/runner-e2e/process-tree.ts", import.meta.url);
|
||||
const bounded = async (promise, label, milliseconds = 5_000) => {
|
||||
let timer;
|
||||
try {
|
||||
return await Promise.race([promise, new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(`${label} exceeded its bound`)), milliseconds);
|
||||
})]);
|
||||
} finally { clearTimeout(timer); }
|
||||
};
|
||||
const nativeWorker = async owner => {
|
||||
assert.equal(owner.children.length, 1);
|
||||
if (selectorPeer) {
|
||||
const { readKimiLocalProcesses, selectKimiLocalTarget } = await tsImport("../../../tests/runner-e2e/kimi-local-provider-fault.ts", import.meta.url);
|
||||
const { kimiFaultRuntimeContract } = await tsImport("../../../tests/runner-e2e/kimi-provider-fault-contract.ts", import.meta.url);
|
||||
const table = await readProcessTable(); assert.ok(table);
|
||||
const root = table.find(row => row.pid === process.pid); assert.ok(root && root.processGroupId === process.pid);
|
||||
const tree = observeDescendantProcessTree(table, process.pid);
|
||||
const observed = tree.members.map(row => row.process);
|
||||
const processes = readKimiLocalProcesses(observed.map(row => row.pid));
|
||||
assert.ok(!JSON.stringify(processes).includes("must-not-appear-in-observed-argv"));
|
||||
const runId = process.argv[process.argv.indexOf("--run-id") + 1];
|
||||
const input = { authority: { pid: root.pid, groupId: root.processGroupId, startedAt: root.started, runId },
|
||||
journal: observed.map(row => ({ pid: row.pid, parent: row.parentPid, start: row.started })), processes,
|
||||
workspacePath: workspace, guardLoaderSha256: kimiFaultRuntimeContract("darwin-arm64").guardLoaderSha256 };
|
||||
const selected = selectKimiLocalTarget(input);
|
||||
assert.equal(selected.target.command, "kimi-code");
|
||||
assert.equal(selected.target.argv[0], "kimi-code");
|
||||
assert.ok(selected.ancestry.some(row => row.pid === owner.children[0].pid));
|
||||
for (const kind of ["missing", "foreign", "ambiguous"]) {
|
||||
const wrong = structuredClone(input);
|
||||
if (kind === "missing") wrong.processes = wrong.processes.filter(row => row.pid !== selected.target.pid);
|
||||
if (kind === "foreign") wrong.processes.find(row => row.pid === selected.target.ppid).argv[2] = "foreign loader";
|
||||
if (kind === "ambiguous") { const extra = { ...selected.target, pid: 2147483000 }; wrong.processes.push(extra); wrong.journal.push({ pid: extra.pid, parent: extra.ppid, start: new Date(Number(extra.startUs) / 1000).toISOString() }); }
|
||||
assert.throws(() => selectKimiLocalTarget(wrong), /kimi_local_provider_fault:/);
|
||||
}
|
||||
owner.selectorInput = input;
|
||||
return table.find(row => row.pid === selected.target.pid);
|
||||
}
|
||||
const rootPid = owner.children[0].pid;
|
||||
const table = await readProcessTable();
|
||||
assert.ok(table);
|
||||
const tree = observeDescendantProcessTree(table, rootPid);
|
||||
const candidates = tree.members.filter(member => member.depth === 1);
|
||||
assert.equal(candidates.length, 1, `one native Kimi worker belongs directly to the verified command root: ${JSON.stringify(tree.members.map(member => ({ depth: member.depth, kind: member.process.kind })))}`);
|
||||
const rootProcess = table.find(row => row.pid === rootPid);
|
||||
const worker = candidates[0].process;
|
||||
assert.ok(rootProcess && worker.pid > 1 && worker.pid !== rootPid && worker.pid !== process.pid);
|
||||
const fresh = await readProcessTable();
|
||||
assert.ok(fresh?.some(row => row.pid === rootPid && row.started === rootProcess.started && row.processGroupId === rootProcess.processGroupId));
|
||||
assert.ok(fresh?.some(row => row.pid === worker.pid && row.started === worker.started
|
||||
&& row.parentPid === rootPid && row.processGroupId === worker.processGroupId));
|
||||
return worker;
|
||||
};
|
||||
const assertRetired = async worker => {
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (Date.now() < deadline) {
|
||||
const table = await readProcessTable();
|
||||
assert.ok(table);
|
||||
if (!table.some(row => row.pid === worker.pid && row.started === worker.started)) return;
|
||||
await new Promise(resolve => setTimeout(resolve, 25));
|
||||
}
|
||||
assert.fail("the observed native worker survived owned cleanup");
|
||||
};
|
||||
for (const boundary of ["stop", "loss"]) {
|
||||
const path = join(workspace, `${boundary}-must-not-exist.txt`);
|
||||
let markSeen, decide;
|
||||
const seen = new Promise(resolve => { markSeen = resolve; });
|
||||
const decision = new Promise(resolve => { decide = resolve; });
|
||||
permissionGate = { path, markSeen, decision, decide };
|
||||
const owner = await connect(undefined, `scripted-kimi-peer-${boundary}`);
|
||||
scenario = boundary; modelStep = 0;
|
||||
const turn = owner.runtime.startTurn({ handle: owner.handle, mode: "prompt", text: `Controlled pending permission ${boundary}.`, requestId: `peer-${boundary}` });
|
||||
const drained = (async () => { for await (const _event of turn.events) {} })();
|
||||
try {
|
||||
await bounded(seen, `${boundary} native permission observation`);
|
||||
await assert.rejects(access(path), { code: "ENOENT" });
|
||||
const worker = await nativeWorker(owner);
|
||||
if (boundary === "stop") await bounded(turn.cancel({ reason: "controlled pending permission Stop" }), "native Stop");
|
||||
else if (selectorPeer) {
|
||||
const { terminateKimiLocalProvider } = await tsImport("../../../tests/runner-e2e/kimi-local-provider-fault.ts", import.meta.url);
|
||||
const receipt = terminateKimiLocalProvider(owner.selectorInput);
|
||||
assert.equal(receipt.target.pid, worker.pid); assert.equal(receipt.workerSignalled, false);
|
||||
} else process.kill(worker.pid, "SIGKILL");
|
||||
const result = await bounded(turn.result, `${boundary} terminal result`);
|
||||
await bounded(drained, `${boundary} event drain`);
|
||||
if (peerFailure) throw peerFailure;
|
||||
assert.equal(result.status, boundary === "stop" ? "cancelled" : "failed");
|
||||
if (boundary === "loss") assert.equal(typeof result.error?.code, "string");
|
||||
assert.equal(permissionGate.context.signal.aborted, true, "pending host callback was revoked at settlement");
|
||||
const countAtSettlement = modelRequests.length;
|
||||
permissionGate.decide({ outcome: "allow_once" });
|
||||
assert.equal(await bounded(permissionGate.delivery, `${boundary} stale permission suppression`), "suppressed");
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
assert.equal(modelRequests.length, countAtSettlement, "a stale callback cannot restart provider inference");
|
||||
assert.ok(!nativeMessages.some(entry => entry.owner === owner && entry.direction === "outbound" && entry.message.id === permissionGate.requestId && entry.message.result?.outcome?.outcome === "selected"), "no stale allow response crossed the provider connection");
|
||||
await assert.rejects(access(path), { code: "ENOENT" });
|
||||
await closeOwner(owner);
|
||||
await assertRetired(worker);
|
||||
await assert.rejects(access(path), { code: "ENOENT" });
|
||||
} finally { permissionGate.decide({ outcome: "reject_once" }); permissionGate = undefined; }
|
||||
}
|
||||
assert.equal(modelRequests.length, 9);
|
||||
assert.equal(permissions.length, 6);
|
||||
if (selectorPeer) t.diagnostic("Compiled production factories and real lifetime fences selected the exact native Kimi child despite ps rendering; actual title mutation and environment exclusion passed, missing/foreign/ambiguous targets were refused, and sealed-byte/inode/run-bound fault injection retired only the owned native tree");
|
||||
t.diagnostic("SIMULATED provider service; pending native Write permission Stop and exact observed native-worker SIGKILL both revoked host callbacks, suppressed stale allow responses, produced no file/model continuation, settled typed results and retired owned native processes; zero external model calls");
|
||||
}
|
||||
} finally {
|
||||
try { await Promise.all(owners.map(closeOwner)); }
|
||||
finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
keys.forEach((key, index) => { if (previous[index] === undefined) delete process.env[key]; else process.env[key] = previous[index]; });
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile, spawn, spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
import { mkdtemp, mkdir, readFile, readdir, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { promisify } from "node:util";
|
||||
import { bundleKimiProvisioner } from "./build-verified-provider-entrypoints.mjs";
|
||||
import { KIMI_DISTRIBUTION_CLOSURE_SHA256 } from "../src/drivers/acpx/kimi-closure-pins.ts";
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
test("installed Kimi setup is self-contained, rejects corrupt assets and cancels pending downloads", { timeout: 10_000 }, async t => {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), "paperclip-kimi-public-layout-")));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
const pkg = join(root, "package"), cli = join(pkg, "dist/vendor/paperclip-runner/cli");
|
||||
await mkdir(cli, { recursive: true });
|
||||
await writeFile(join(pkg, "package.json"), '{"name":"@paperclipai/server","type":"module"}');
|
||||
await writeFile(join(pkg, "dist/index.js"), 'throw Error("server must not start");');
|
||||
await writeFile(join(cli, "acpx-runtime-sidecar.cjs"), "// installed layout fixture");
|
||||
const entry = join(cli, "provision-kimi.cjs");
|
||||
const packageRoot = resolve(dirname(new URL(import.meta.url).pathname), "..");
|
||||
const result = await bundleKimiProvisioner({ outputRoot: cli });
|
||||
for (const output of Object.values(result.metafile.outputs)) for (const dependency of output.imports) assert.match(dependency.path, /^node:/);
|
||||
assert.deepEqual((await readdir(join(cli, "kimi-provision-inputs"))).sort(), ["package-lock.json", "package.json"]);
|
||||
for (const name of ["package-lock.json", "package.json"]) assert.deepEqual(await readFile(join(cli, "kimi-provision-inputs", name)), await readFile(join(packageRoot, "scripts/kimi-distribution", name)));
|
||||
const setup = createRequire(import.meta.url)(entry);
|
||||
const layout = await setup.provisionKimiPackageRoot(entry);
|
||||
assert.equal(layout.root, pkg);
|
||||
assert.equal(layout.assetRoot, join(pkg, "dist/vendor/paperclip-runner"));
|
||||
const linked = join(cli, "linked.cjs"); await symlink(entry, linked);
|
||||
await assert.rejects(setup.provisionKimiPackageRoot(linked), /installed entrypoint/);
|
||||
assert.deepEqual(setup.provisionEnvironment({ PATH: "/usr/bin:/bin", HTTPS_PROXY: "http://127.0.0.1:9", KIMI_MODEL_API_KEY: "sensitive-canary", HOME: "/private/home", NODE_OPTIONS: "--require /foreign", NODE_PATH: "/foreign", NPM_TOKEN: "sensitive-canary" }), { PATH: "/usr/bin:/bin", LANG: "C.UTF-8", HTTPS_PROXY: "http://127.0.0.1:9" });
|
||||
const target = `${process.platform}-${process.arch}`;
|
||||
const parent = join(layout.assetRoot, "provider-assets/kimi");
|
||||
await mkdir(join(parent, target, "runtime"), { recursive: true });
|
||||
const deny = join(root, "deny.cjs");
|
||||
await writeFile(deny, `process.nextTick(()=>{const assert=require('node:assert/strict');for(const key of ['KIMI_MODEL_API_KEY','NPM_TOKEN','HOME','NODE_OPTIONS','NODE_PATH'])assert.equal(process.env[key],undefined,key)});const fail=()=>{throw Error('UNEXPECTED_NETWORK_OR_CHILD')};globalThis.fetch=fail;for(const m of ['node:net','node:tls','node:http','node:https']){const x=require(m);for(const key of ['connect','createConnection','request','get'])if(key in x)x[key]=fail}const c=require('node:child_process');for(const key of ['spawn','execFile','exec'])c[key]=fail;`);
|
||||
const child = spawnSync(process.execPath, ["--require", deny, entry], { env: { PATH: "/usr/bin:/bin", KIMI_MODEL_API_KEY: "sensitive-canary", NPM_TOKEN: "sensitive-canary", HOME: "/private/home", NODE_OPTIONS: "", NODE_PATH: "/foreign" }, encoding: "utf8", timeout: 10_000 });
|
||||
assert.ifError(child.error); assert.equal(child.status, 1); assert.match(child.stderr, /Kimi setup failed/);
|
||||
assert.doesNotMatch(child.stderr + child.stdout, /UNEXPECTED_NETWORK_OR_CHILD|sensitive-canary/);
|
||||
assert.deepEqual(await readdir(parent), [target]);
|
||||
assert.equal(JSON.parse(await readFile(join(pkg, "package.json"), "utf8")).name, "@paperclipai/server");
|
||||
await rm(join(parent, target), { recursive: true });
|
||||
const ready = join(root, "download-ready"), aborted = join(root, "download-aborted");
|
||||
const preload = join(root, "pending-download.cjs");
|
||||
await writeFile(preload, `globalThis.fetch=(_url,{signal})=>new Promise((_resolve,reject)=>{const pending=setInterval(()=>{},1000);require('node:fs').writeFileSync(${JSON.stringify(ready)},'ready');signal.addEventListener('abort',()=>{clearInterval(pending);require('node:fs').writeFileSync(${JSON.stringify(aborted)},'aborted');reject(signal.reason)},{once:true})});`);
|
||||
const cancelled = spawn(process.execPath, ["--require", preload, entry], { env: { PATH: "/usr/bin:/bin" }, stdio: ["ignore", "ignore", "pipe"] });
|
||||
const closed = once(cancelled, "close");
|
||||
let stderr = ""; cancelled.stderr.on("data", chunk => { stderr = (stderr + chunk).slice(-4096); });
|
||||
const kill = setTimeout(() => cancelled.kill("SIGKILL"), 5_000);
|
||||
try {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
try { await readFile(ready); break; }
|
||||
catch (error) { if (error.code !== "ENOENT") throw error; await delay(10); }
|
||||
}
|
||||
assert.equal(await readFile(ready, "utf8"), "ready");
|
||||
cancelled.kill("SIGTERM");
|
||||
const [code, signal] = await closed;
|
||||
assert.equal(code, 1); assert.equal(signal, null);
|
||||
assert.match(stderr, /Kimi setup failed/);
|
||||
assert.equal(await readFile(aborted, "utf8"), "aborted");
|
||||
assert.deepEqual(await readdir(parent), []);
|
||||
} finally { clearTimeout(kill); if (cancelled.exitCode === null && cancelled.signalCode === null) { cancelled.kill("SIGKILL"); await closed; } }
|
||||
});
|
||||
|
||||
test("fresh archived installed provisioner downloads the exact closure, reuses it offline and rejects tampering", {
|
||||
skip: process.env.PAPERCLIP_TEST_KIMI_PUBLIC_SETUP !== "1" && "Set PAPERCLIP_TEST_KIMI_PUBLIC_SETUP=1 for public registry downloads; no model calls or provider credentials",
|
||||
timeout: 180_000,
|
||||
}, async t => {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), "paperclip-kimi-public-setup-")));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
const packed = join(root, "packed"), pkg = join(packed, "package"), cli = join(pkg, "dist/vendor/paperclip-runner/cli");
|
||||
await mkdir(cli, { recursive: true });
|
||||
await writeFile(join(pkg, "package.json"), '{"name":"@paperclipai/server","type":"module"}');
|
||||
await writeFile(join(pkg, "dist/index.js"), 'throw Error("server must not start");');
|
||||
await writeFile(join(cli, "acpx-runtime-sidecar.cjs"), "// installed layout fixture");
|
||||
await bundleKimiProvisioner({ outputRoot: cli });
|
||||
const archive = join(root, "setup.tgz"), installed = join(root, "installed");
|
||||
await run("tar", ["-czf", archive, "-C", packed, "package"], { timeout: 10_000 });
|
||||
await mkdir(installed);
|
||||
await run("tar", ["-xzf", archive, "-C", installed], { timeout: 10_000 });
|
||||
const entry = join(installed, "package/dist/vendor/paperclip-runner/cli/provision-kimi.cjs");
|
||||
const api = createRequire(import.meta.url)(entry);
|
||||
const environment = api.provisionEnvironment(process.env);
|
||||
const fresh = await run(process.execPath, ["--use-env-proxy", entry], { env: { ...environment, KIMI_API_KEY: "sensitive-canary", NPM_TOKEN: "sensitive-canary", HOME: "/private/foreign-home" }, timeout: 120_000, maxBuffer: 1024 * 1024 });
|
||||
assert.doesNotMatch(fresh.stdout + fresh.stderr, /sensitive-canary/);
|
||||
const receipt = JSON.parse(fresh.stdout.trim());
|
||||
assert.equal(receipt.status, "installed_verified");
|
||||
const target = `${process.platform}-${process.arch}`;
|
||||
const assets = join(installed, "package/dist/vendor/paperclip-runner/provider-assets/kimi", target);
|
||||
const metadata = JSON.parse(await readFile(join(assets, "kimi-distribution.json"), "utf8"));
|
||||
assert.equal(metadata.nativeClosureSha256, KIMI_DISTRIBUTION_CLOSURE_SHA256[target]);
|
||||
const offline = { ...environment, HTTPS_PROXY: "http://127.0.0.1:9", HTTP_PROXY: "http://127.0.0.1:9", https_proxy: "http://127.0.0.1:9", http_proxy: "http://127.0.0.1:9", NO_PROXY: "", no_proxy: "" };
|
||||
const reused = await run(process.execPath, ["--use-env-proxy", entry], { env: offline, timeout: 20_000, maxBuffer: 1024 * 1024 });
|
||||
assert.equal(JSON.parse(reused.stdout.trim()).status, "verified_existing");
|
||||
const worker = join(assets, "runtime/node_modules/@moonshot-ai/kimi-code/dist/search-worker.mjs");
|
||||
const changed = Buffer.concat([await readFile(worker), Buffer.from("\n// integrity test\n")]);
|
||||
await writeFile(worker, changed);
|
||||
await assert.rejects(run(process.execPath, ["--use-env-proxy", entry], { env: offline, timeout: 20_000, maxBuffer: 1024 * 1024 }), error => {
|
||||
assert.match(error.stderr, /Kimi setup failed/);
|
||||
assert.doesNotMatch(error.stderr, /download|ECONNREFUSED/i);
|
||||
return true;
|
||||
});
|
||||
assert.deepEqual(await readFile(worker), changed, "Invalid existing assets must not be silently replaced");
|
||||
t.diagnostic(JSON.stringify({ target, closure: metadata.nativeClosureSha256, patch: metadata.patch.version, provisionerSha256: createHash("sha256").update(await readFile(entry)).digest("hex"), freshInstalled: true, offlineReuse: true, tamperRejected: true, modelPrompts: 0, scope: "self-contained public-layout setup component; full Paperclip tarball is a separate proof" }));
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env node
|
||||
import { constants } from "node:fs";
|
||||
import { lstat, mkdir, mkdtemp, open, readdir, realpath, rename, rm, unlink, writeFile } from "node:fs/promises";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { materializeKimiDistribution, kimiDistributionEnvironment } from "./materialize-kimi-distribution.mjs";
|
||||
import { KIMI_DISTRIBUTION_CLOSURE_SHA256 } from "../src/drivers/acpx/kimi-closure-pins.ts";
|
||||
import { verifyKimiInstallation } from "../src/drivers/acpx/kimi-installation.ts";
|
||||
import { QUALIFIED_ACPX_PROFILES } from "../src/drivers/acpx/qualified-profiles.ts";
|
||||
|
||||
export function provisionEnvironment(source = process.env) {
|
||||
return { ...kimiDistributionEnvironment(source), LANG: "C.UTF-8", PATH: source.PATH ?? "/usr/bin:/bin" };
|
||||
}
|
||||
|
||||
export async function provisionKimiPackageRoot(entrypoint) {
|
||||
const canonical = await realpath(entrypoint);
|
||||
if (canonical !== resolve(entrypoint) || basename(canonical) !== "provision-kimi.cjs" || !(await lstat(canonical)).isFile()) throw new Error("Kimi setup requires its installed entrypoint");
|
||||
const cli = dirname(canonical);
|
||||
const vendored = cli.endsWith("/dist/vendor/paperclip-runner/cli");
|
||||
if (!vendored && !cli.endsWith("/dist/cli")) throw new Error("Kimi setup requires the installed runner or server layout");
|
||||
const root = resolve(cli, vendored ? "../../../.." : "../..");
|
||||
const manifest = join(root, "package.json");
|
||||
const file = await open(manifest, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
try {
|
||||
const before = await file.stat({ bigint: true });
|
||||
if (!before.isFile() || before.nlink !== 1n || before.size < 1n || before.size > 64n * 1024n) throw new Error("Kimi setup package manifest is invalid");
|
||||
const bytes = await file.readFile(), after = await file.stat({ bigint: true }), named = await lstat(manifest, { bigint: true });
|
||||
if (bytes.length !== Number(before.size) || before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs || named.dev !== before.dev || named.ino !== before.ino || named.isSymbolicLink()) throw new Error("Kimi setup package manifest changed");
|
||||
if (JSON.parse(bytes.toString("utf8")).name !== (vendored ? "@paperclipai/server" : "@paperclipai/paperclip-runner")) throw new Error("Kimi setup package identity does not match its layout");
|
||||
} finally { await file.close(); }
|
||||
return { root, manifest, cli, assetRoot: vendored ? resolve(cli, "..") : root };
|
||||
}
|
||||
|
||||
async function verifyInstallation(root, manifest) {
|
||||
const keys = ["PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", "PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST"], previous = keys.map(key => process.env[key]);
|
||||
process.env[keys[0]] = root; process.env[keys[1]] = manifest;
|
||||
try {
|
||||
const installation = await verifyKimiInstallation(QUALIFIED_ACPX_PROFILES.kimi);
|
||||
const lease = await installation.openCommand();
|
||||
await lease.close();
|
||||
} finally { keys.forEach((key, index) => { if (previous[index] === undefined) delete process.env[key]; else process.env[key] = previous[index]; }); }
|
||||
}
|
||||
|
||||
async function absent(path) { try { await lstat(path); return false; } catch (error) { if (error.code === "ENOENT") return true; throw error; } }
|
||||
async function containedDirectory(root, path) {
|
||||
await mkdir(path, { recursive: true, mode: 0o700 });
|
||||
if (await realpath(path) !== path || !(await lstat(path)).isDirectory() || !path.startsWith(root + "/")) throw new Error("Kimi setup asset directory escapes its package");
|
||||
}
|
||||
|
||||
export async function provisionKimi(entrypoint, checkCancelled = () => {}, signal) {
|
||||
checkCancelled(); signal?.throwIfAborted();
|
||||
const target = `${process.platform}-${process.arch}`;
|
||||
if (!Object.hasOwn(KIMI_DISTRIBUTION_CLOSURE_SHA256, target)) throw new Error(`Kimi is not supported on ${target}`);
|
||||
const { root, manifest, cli, assetRoot } = await provisionKimiPackageRoot(entrypoint);
|
||||
const assets = join(assetRoot, "provider-assets"), parent = join(assets, "kimi");
|
||||
await containedDirectory(root, assets); await containedDirectory(root, parent);
|
||||
const lockPath = join(parent, `.setup-${target}.lock`);
|
||||
const lock = await open(lockPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
|
||||
let lockIdentity, temporary, temporaryIdentity, published, committed = false;
|
||||
const output = join(parent, target);
|
||||
try {
|
||||
lockIdentity = await lock.stat({ bigint: true });
|
||||
checkCancelled();
|
||||
if (!(await absent(output))) {
|
||||
await verifyInstallation(root, manifest);
|
||||
return { status: "verified_existing", target, profileDigest: QUALIFIED_ACPX_PROFILES.kimi.commandDigest };
|
||||
}
|
||||
temporary = await mkdtemp(join(parent, ".setup-private-")); temporaryIdentity = await lstat(temporary, { bigint: true });
|
||||
const stagingRoot = join(temporary, "package"); await mkdir(stagingRoot, { mode: 0o700 });
|
||||
await writeFile(join(stagingRoot, "package.json"), JSON.stringify({ name: "@paperclipai/paperclip-runner" }), { flag: "wx", mode: 0o600 });
|
||||
const stagedOutput = join(stagingRoot, "provider-assets/kimi", target);
|
||||
await materializeKimiDistribution({ outputRoot: stagedOutput, lockDirectory: join(cli, "kimi-provision-inputs"), checkCancelled, signal });
|
||||
await verifyInstallation(stagingRoot, join(stagingRoot, "package.json"));
|
||||
if (!(await absent(output))) throw new Error("Kimi setup destination appeared during installation; refusing replacement");
|
||||
checkCancelled();
|
||||
await mkdir(output, { mode: 0o700 }); published = await lstat(output, { bigint: true });
|
||||
for (const name of await readdir(stagedOutput)) {
|
||||
const named = await lstat(output, { bigint: true });
|
||||
if (named.dev !== published.dev || named.ino !== published.ino || named.isSymbolicLink()) throw new Error("Kimi setup output ownership changed during publication");
|
||||
await rename(join(stagedOutput, name), join(output, name));
|
||||
}
|
||||
await verifyInstallation(root, manifest);
|
||||
checkCancelled(); committed = true;
|
||||
return { status: "installed_verified", target, profileDigest: QUALIFIED_ACPX_PROFILES.kimi.commandDigest };
|
||||
} finally {
|
||||
const cleanup = [];
|
||||
if (published && !committed) cleanup.push((async () => {
|
||||
const named = await lstat(output, { bigint: true });
|
||||
if (named.dev !== published.dev || named.ino !== published.ino || named.isSymbolicLink()) throw new Error("Kimi setup output ownership changed; refusing cleanup");
|
||||
await rm(output, { recursive: true });
|
||||
})());
|
||||
if (temporary) cleanup.push((async () => {
|
||||
const named = await lstat(temporary, { bigint: true });
|
||||
if (!temporaryIdentity || named.dev !== temporaryIdentity.dev || named.ino !== temporaryIdentity.ino || named.isSymbolicLink()) throw new Error("Kimi setup staging ownership changed; refusing cleanup");
|
||||
await rm(temporary, { recursive: true });
|
||||
})());
|
||||
cleanup.push((async () => {
|
||||
try {
|
||||
lockIdentity ??= await lock.stat({ bigint: true }); const named = await lstat(lockPath, { bigint: true });
|
||||
if (named.dev !== lockIdentity.dev || named.ino !== lockIdentity.ino) throw new Error("Kimi setup lock ownership changed; refusing cleanup");
|
||||
await unlink(lockPath);
|
||||
} finally { await lock.close(); }
|
||||
})());
|
||||
const results = await Promise.allSettled(cleanup), failed = results.filter(result => result.status === "rejected");
|
||||
if (failed.length) throw new AggregateError(failed.map(result => result.reason), "Kimi setup cleanup failed; inspect the package before retrying");
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
|
||||
if (process.argv.length !== 2) throw new Error("Usage: paperclipai runtime setup kimi (explicit host installation; no model calls)");
|
||||
const environment = provisionEnvironment();
|
||||
for (const key of Object.keys(process.env)) delete process.env[key]; Object.assign(process.env, environment);
|
||||
const controller = new AbortController();
|
||||
const cancel = () => controller.abort();
|
||||
process.on("SIGINT", cancel); process.on("SIGTERM", cancel);
|
||||
const deadline = setTimeout(cancel, 900_000); deadline.unref();
|
||||
provisionKimi(fileURLToPath(import.meta.url), () => { if (controller.signal.aborted) throw new Error("Kimi setup cancelled; no installation was admitted"); }, controller.signal).finally(() => {
|
||||
clearTimeout(deadline); process.off("SIGINT", cancel); process.off("SIGTERM", cancel);
|
||||
}).then(result => console.log(JSON.stringify(result))).catch(error => { console.error(`Kimi setup failed: ${error.message}`); process.exitCode = 1; });
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import profiles from "../acpx-profiles.json" with { type: "json" };
|
||||
import distributions from "../cursor-distributions.json" with { type: "json" };
|
||||
import { KIMI_DISTRIBUTION_CLOSURE_SHA256 as kimiClosures } from "../src/drivers/acpx/kimi-closure-pins.ts";
|
||||
|
||||
const canonical = value => Array.isArray(value) ? `[${value.map(canonical).join(",")}]`
|
||||
: value && typeof value === "object" ? `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`
|
||||
: JSON.stringify(value);
|
||||
|
||||
/** Release assembly must reject a self-consistent pack built for another release. */
|
||||
export function verifyReleaseProviderPack(identity, sourceRevision, target = "linux-x64", expected = { profiles, distributions }) {
|
||||
export function verifyReleaseProviderPack(identity, sourceRevision, target = "linux-x64", expected = { profiles, distributions, kimiClosures }) {
|
||||
const { profiles: releaseProfiles, distributions: releaseDistributions } = expected;
|
||||
const payload = identity?.payload;
|
||||
if (identity?.schema !== "paperclip-runner/remote-provider-pack/v1" || !payload
|
||||
@@ -46,5 +47,20 @@ export function verifyReleaseProviderPack(identity, sourceRevision, target = "li
|
||||
|| !/^sha256:[a-f0-9]{64}$/.test(copilot.sha256 ?? "")) {
|
||||
throw new Error("Remote provider-pack Copilot identity does not match this release");
|
||||
}
|
||||
const kimiProfile = releaseProfiles.profiles.kimi;
|
||||
if (kimiProfile && kimiProfile.qualificationStatus !== "pending") {
|
||||
const releaseKimiClosures = expected.kimiClosures
|
||||
?? (kimiProfile.commandDigest === profiles.profiles.kimi.commandDigest ? kimiClosures : undefined);
|
||||
const kimi = payload.providers?.kimi;
|
||||
if (Object.hasOwn(payload.candidateProviders ?? {}, "kimi") || !kimi
|
||||
|| kimi.version !== kimiProfile.agentServerVersion || kimi.profileDigest !== kimiProfile.commandDigest
|
||||
|| !/^[a-f0-9]{64}$/.test(releaseKimiClosures?.["linux-x64"] ?? "")
|
||||
|| kimi.closureDigest !== `sha256:${releaseKimiClosures["linux-x64"]}`
|
||||
|| kimi.qualification !== "qualified" || kimi.path !== "provider-assets/kimi/linux-x64"
|
||||
|| Object.keys(kimi).sort().join(",") !== "closureDigest,path,profileDigest,qualification,sha256,version"
|
||||
|| !/^sha256:[a-f0-9]{64}$/.test(kimi.sha256 ?? "")) {
|
||||
throw new Error("Remote provider-pack Kimi identity does not match this release");
|
||||
}
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
import profiles from "../acpx-profiles.json" with { type: "json" };
|
||||
import historicalKimi from "../test/fixtures/kimi-profile-v14-identity.json" with { type: "json" };
|
||||
import { KIMI_DISTRIBUTION_CLOSURE_SHA256 as kimiClosures } from "../src/drivers/acpx/kimi-closure-pins.ts";
|
||||
import distributions from "../cursor-distributions.json" with { type: "json" };
|
||||
import { verifyReleaseProviderPack } from "./release-provider-pack.mjs";
|
||||
import { existingReleaseProviderPack, releaseProviderPackManifest } from "./stage-release-runner-binaries.mjs";
|
||||
@@ -25,6 +27,9 @@ function fixture() {
|
||||
payload.providers.copilot = { version: profiles.profiles.copilot.agentServerVersion,
|
||||
profileDigest: profiles.profiles.copilot.commandDigest, closureDigest: "sha256:a3d8f4367cfa1694d79e3f8b7930b6fbfe42a229c272b375b11951d631db8d07",
|
||||
qualification: "qualified", path: "provider-assets/copilot/linux-x64", sha256: digest };
|
||||
payload.providers.kimi = { version: profiles.profiles.kimi.agentServerVersion,
|
||||
profileDigest: profiles.profiles.kimi.commandDigest, closureDigest: `sha256:${kimiClosures["linux-x64"]}`,
|
||||
qualification: "qualified", path: "provider-assets/kimi/linux-x64", sha256: digest };
|
||||
return payload;
|
||||
}
|
||||
function seal(payload) { return { schema: "paperclip-runner/remote-provider-pack/v1", payload,
|
||||
@@ -55,7 +60,7 @@ test("release assembly rejects rehashed stale or missing packs before staging an
|
||||
const source = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
try {
|
||||
// Run the actual assembler in an isolated layout so it cannot overwrite built artifacts.
|
||||
for (const file of ["scripts/stage-release-runner-binaries.mjs", "scripts/release-provider-pack.mjs", "src/live/runner-binary.ts", "acpx-profiles.json", "cursor-distributions.json", "release-runner-targets.json"]) {
|
||||
for (const file of ["scripts/stage-release-runner-binaries.mjs", "scripts/release-provider-pack.mjs", "src/live/runner-binary.ts", "src/drivers/acpx/kimi-closure-pins.ts", "acpx-profiles.json", "cursor-distributions.json", "release-runner-targets.json"]) {
|
||||
mkdirSync(dirname(join(root, file)), { recursive: true }); cpSync(join(source, file), join(root, file));
|
||||
}
|
||||
const platforms = {};
|
||||
@@ -172,3 +177,29 @@ test("uses the frozen release's Copilot profile when the workflow checkout has a
|
||||
assert.equal(verifyReleaseProviderPack(manifest, revision, "linux-x64", { profiles: releaseProfiles, distributions }), manifest);
|
||||
assert.throws(() => verifyReleaseProviderPack(manifest, revision), /Copilot identity/);
|
||||
});
|
||||
|
||||
for (const field of ["version", "profileDigest", "closureDigest", "qualification", "path", "sha256"]) {
|
||||
test(`rejects an independently rehashed stale Kimi ${field}`, () => {
|
||||
const payload = fixture(); payload.providers.kimi[field] = "stale";
|
||||
assert.throws(() => verifyReleaseProviderPack(seal(payload), revision), /Kimi identity/);
|
||||
});
|
||||
}
|
||||
test("released Kimi requires one ordinary identity and exact frozen closure", () => {
|
||||
const missing = fixture(); delete missing.providers.kimi;
|
||||
assert.throws(() => verifyReleaseProviderPack(seal(missing), revision), /Kimi identity/);
|
||||
const duplicate = fixture(); duplicate.candidateProviders = { kimi: duplicate.providers.kimi };
|
||||
assert.throws(() => verifyReleaseProviderPack(seal(duplicate), revision), /Kimi identity/);
|
||||
const frozen = structuredClone(profiles); frozen.profiles.kimi.commandDigest = "sha256:" + "e".repeat(64);
|
||||
const payload = fixture(); payload.providers.kimi.profileDigest = frozen.profiles.kimi.commandDigest;
|
||||
assert.throws(() => verifyReleaseProviderPack(seal(payload), revision, "linux-x64", { profiles: frozen, distributions }), /Kimi identity/);
|
||||
assert.equal(verifyReleaseProviderPack(seal(payload), revision, "linux-x64", { profiles: frozen, distributions, kimiClosures }).payload, payload);
|
||||
});
|
||||
test("historical pending releases keep their separate candidate inventory", () => {
|
||||
const historical = structuredClone(profiles);
|
||||
historical.profiles.kimi = { ...historical.profiles.kimi, agentProfileVersion: 14,
|
||||
commandDigest: historicalKimi.commandDigest, qualificationStatus: historicalKimi.declaration.qualificationStatus };
|
||||
const payload = fixture(); payload.candidateProviders = { kimi: { ...payload.providers.kimi,
|
||||
profileDigest: historicalKimi.commandDigest, qualification: "pending" } }; delete payload.providers.kimi;
|
||||
const pack = seal(payload);
|
||||
assert.equal(verifyReleaseProviderPack(pack, revision, "linux-x64", { profiles: historical, distributions }), pack);
|
||||
});
|
||||
Loaded 100 of 470 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user