diff --git a/DESIGN.md b/DESIGN.md index a4804cb0e27..bda54f4da2b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -60,8 +60,12 @@ title sizes remain larger. The bottom navigation responds to accumulated scrolling, ignoring small reversals and Safari's edge bounce. It glides out and eases back in with shared -motion tokens; the task composer follows the same motion. Keep page padding -stable while the navigation moves, and honor reduced-motion preferences. +motion tokens. Keep page padding stable while the navigation moves, and honor +reduced-motion preferences. + +Mobile task conversations size their shell to the visible viewport, with one +message scroller and a normal flex footer for the composer. Navigation stays at +the layout viewport bottom, behind the keyboard. Cold task opens reveal the instruction and recent conversation independently of plans, related metadata, and historical run activity. Show the latest human diff --git a/doc/architecture/runner-copilot-capabilities.md b/doc/architecture/runner-copilot-capabilities.md index fcbe1a6f847..36e2f8ea81c 100644 --- a/doc/architecture/runner-copilot-capabilities.md +++ b/doc/architecture/runner-copilot-capabilities.md @@ -1,6 +1,14 @@ # Copilot 1.0.88 rich ACP capability audit -Current source declaration (2026-10-26): **Copilot profile v60** binds the shared +Current source declaration (2026-10-26): **Copilot profile v61** binds controller +journal retirement and the historical profile decoder. Its digest is +`sha256:956276ebee59f880249fa6955adc224af9ca7b6a9da59bcc5da4a8667550c5b3`. +The v60 declaration remains unchanged and readable as history. This source +identity update changes no Copilot executable, arguments, permission policy, +tool receipts, or qualification flags. Offline compatibility checks supply no +new live qualification; prior live outcomes retain their original identities. + +Historical source declaration (2026-10-26): **Copilot profile v60** binds the shared sources that carry Codex's explicit hosted-search restriction. Its digest is `sha256:ca3c2b38ed5f6e25cf1c96c1f73781a86cc90c1c0add93f64716c66f28b3f3aa`. The v59 declaration remains unchanged and readable as history. Exact-current diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 486a33f3fa8..03f0044f38d 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -2015,6 +2015,25 @@ not grant execution authority: calls still use the existing gateway policy, approval, revocation, and audit paths. +Native OpenCode sessions use the server-owned assigned-tool relay for local and +remote execution. The provider receives only bounded search and call schemas, +even when its protocol can fit the full catalog. This bounds provider grammar +compilation independently of the transport's tool-count and byte limits. Search +returns pinned tool aliases and schemas, intersects fresh grants, and respects +planning/ask restrictions. OpenCode discovery pages use a 32 KiB budget; larger +schemas use 16K-character chunks so JSON escaping and the result envelope fit +its HTTP MCP bridge. Other harnesses keep the existing PRP discovery budgets. +Calls retain the original gateway binding and its +argument validation, approvals, revocation, and audit rules; prompt arguments +cannot choose a gateway or credential. Assigned gateway launch credentials stay +on the controller. The MCP bridge preserves exact declared operation names, +including names that start with `paperclip_`; only a catalog-bound transport +prefix may resolve to that declared name. Shortened or unknown names do not +gain authority. OpenCode sessions with the former direct catalog rotate their +provider checkpoint before reuse; other providers retain their existing tool +contract fingerprints. + + ### Use real connection requests (2026-09-14) When a user asks to connect a known service, the agent searches for that service diff --git a/maps/agent-access-map/runs-adapters.md b/maps/agent-access-map/runs-adapters.md index 72bb4fc68a6..285fafde96e 100644 --- a/maps/agent-access-map/runs-adapters.md +++ b/maps/agent-access-map/runs-adapters.md @@ -162,6 +162,18 @@ No grant, token, authority cache or provider dispatch bypass is added. The [executor controls](../../server/src/services/native-runtime/native-session-executor.test.ts) remain separate from live latency and permitted/denied agent acceptance. +Scoped OpenCode assigned-tool source evidence: local and remote native sessions +use the server-owned authenticated relay. The provider sees bounded +`paperclip_search_assigned_tools` and `paperclip_call_assigned_tool` schemas, +then uses pinned aliases. Search intersects fresh grants and work modes; calls +retain the run's original gateway policy, validation, approvals, revocation and +audit path. Prompt arguments cannot select gateway authority. Assigned gateway +launch credentials stay on the controller. Former direct-catalog OpenCode +checkpoints refresh; other provider fingerprints stay unchanged. The +[projection controls](../../server/src/services/native-runtime/assigned-mcp-tools.test.ts) +and [wiring controls](../../server/src/services/native-runtime/native-session-executor.test.ts) +are source evidence, not live permitted/denied sandbox acceptance. + For a linked Codex subscription with server-resolved managed credential provenance, setup checks the resolved owner and stages a private probe copy before local or remote isolation. Without a selected API key, an unbound linked `CODEX_HOME` is diff --git a/maps/feature-map/runs-adapters.md b/maps/feature-map/runs-adapters.md index 547e5e82e1a..af41c8d5ad0 100644 --- a/maps/feature-map/runs-adapters.md +++ b/maps/feature-map/runs-adapters.md @@ -146,6 +146,16 @@ is released before failure returns. Repeat a warm follow-up and retain its actua reply time. The [executor controls](../../server/src/services/native-runtime/native-session-executor.test.ts) cover ordering, failure drain and credential projection; they do not qualify a live response-time target. +For native OpenCode with assigned connections, inspect the provider tool surface +on both local and sandbox execution. It should contain bounded assigned-tool +search/call schemas. Discover an allowed tool, call its returned alias, revoke +its grant, and confirm discovery and execution enforce that revocation. Repeat +in planning/ask mode with a write tool and confirm denial. The controller must +retain the assigned gateway URL and token. An older direct-catalog OpenCode +checkpoint must refresh without changing another provider's fingerprint. The +[assigned-tool](../../server/src/services/native-runtime/assigned-mcp-tools.test.ts) +and executor controls cover source behavior; live sandbox discovery and a real +call remain separate acceptance evidence. Missing dependencies, failed authentication, invalid models and incompatible custom settings must remain actionable errors without silent runner switching. Saved unchanged execution can still be tested without applying new diff --git a/packages/paperclip-runner/acpx-profiles.json b/packages/paperclip-runner/acpx-profiles.json index 662de03e6fe..b2b369867eb 100644 --- a/packages/paperclip-runner/acpx-profiles.json +++ b/packages/paperclip-runner/acpx-profiles.json @@ -32,12 +32,12 @@ "requiresProviderPolicy": true }, "copilot": { - "agentProfileVersion": 61, + "agentProfileVersion": 62, "agentServerPackage": "@github/copilot", "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:ed07dcd9487c5e1e2bd863a25f7297e5c98316abc51629e9650cd2a9ce29faf9", + "commandDigest": "sha256:ec8d1d9e570403fb1b1324b2357575e9226b8a2f9eefcc8bf573b59e36653d52", "requiresProviderPolicy": true }, "claude": { diff --git a/packages/paperclip-runner/docs/durable-recovery.md b/packages/paperclip-runner/docs/durable-recovery.md index ed505bc7712..a95cbd22b04 100644 --- a/packages/paperclip-runner/docs/durable-recovery.md +++ b/packages/paperclip-runner/docs/durable-recovery.md @@ -175,6 +175,22 @@ The ACK means that every source event through sequence 9 is committed or deduplicated. The runner rejects an ACK that moves backward or beyond its produced source cursor. It removes only events at or below a valid ACK. +The controller stages `item.delta` output snapshots with asynchronous file writes and file +flushes. A slow volume does not block unrelated HTTP requests during those +operations. The controller still publishes the snapshot atomically, flushes its +directory, and waits for persistence before acknowledging an event. +A newer command or authenticated connection can save the +same event while staging is pending; the older snapshot cannot replace that +newer durable state. A failed write fences further authority until reload. +Lifecycle, control, authentication, and semantic tool events retain synchronous +persistence before transport readers can observe their state. Semantic tool +admission and dispatch cannot yield to a replacement connection between those operations. +Controller replacement must still stop ingress and drain admitted processing +before a successor reuses the journal. External control routes stop their +reconnect loop before the controller joins admitted work. An incomplete drain +cannot certify a reusable checkpoint. Snapshots use compact JSON; existing +indented snapshots remain readable. + For the lost-ACK fault, the mock commits an event, drops the ACK and socket, and reports the prior cursor once after reconnect. The runner sends the same bytes again. The mock increments delivery count, keeps one logical event, and sends diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs index 6feb0662ab4..64ab71477eb 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs @@ -47,7 +47,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option { agent_runtime_package: None, agent_runtime_version: None, command_digest: - "sha256:ed07dcd9487c5e1e2bd863a25f7297e5c98316abc51629e9650cd2a9ce29faf9", + "sha256:ec8d1d9e570403fb1b1324b2357575e9226b8a2f9eefcc8bf573b59e36653d52", requires_provider_policy: true, }, "claude" => AcpxReleaseProfile { diff --git a/packages/paperclip-runner/scripts/copilot-profile-identity.mjs b/packages/paperclip-runner/scripts/copilot-profile-identity.mjs index 354f06c837a..bc0b7f399bd 100644 --- a/packages/paperclip-runner/scripts/copilot-profile-identity.mjs +++ b/packages/paperclip-runner/scripts/copilot-profile-identity.mjs @@ -7,9 +7,9 @@ import { fileURLToPath } from "node:url"; import { resolve, dirname } from "node:path"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const fixture = resolve(root, "test/fixtures/copilot-profile-v61-identity.json"); +const fixture = resolve(root, "test/fixtures/copilot-profile-v62-identity.json"); const historical = JSON.parse(readFileSync(resolve(root, "test/fixtures/copilot-profile-v12-identity.json"), "utf8")); -const declaration = { ...historical.declaration, agentProfileVersion: 61 }; +const declaration = { ...historical.declaration, agentProfileVersion: 62 }; delete declaration.permissionIdentitySourceSha256; const sources = { displayEventSourceSha256: "src/drivers/acpx/copilot-events.ts", diff --git a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts index 95ad646cfbf..d6366addb8b 100644 --- a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts +++ b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts @@ -658,6 +658,7 @@ describe("native backend factory", () => { ["cursor", "../../test/fixtures/cursor-acp/profile-v7-identity.json"], ["cursor", "../../test/fixtures/cursor-acp/profile-v10-identity.json"], ["copilot", "../../test/fixtures/copilot-profile-v14-identity.json"], + ["copilot", "../../test/fixtures/copilot-profile-v60-identity.json"], ["pi", "../../test-fixtures/pi-acp/profile-v13-identity.json"], ["pi", "../../test-fixtures/pi-acp/profile-v14-identity.json"], ["pi", "../../test-fixtures/pi-acp/profile-v18-identity.json"], diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts index ff4812fcb94..bcbc3e6f243 100644 --- a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts +++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts @@ -13,6 +13,8 @@ import { } from "node:fs"; import { connect, type Socket } from "node:net"; import nodeFs from "node:fs"; +import fsPromises from "node:fs/promises"; +import { createServer } from "node:http"; import { syncBuiltinESMExports } from "node:module"; import { tmpdir } from "node:os"; import { resolve } from "node:path"; @@ -47,6 +49,117 @@ const identity: DurableRecoveryIdentity = { const expectedRunnerVersion = "0.3.0"; const expectedRunnerDigest = `sha256:${"a".repeat(64)}`; +it.each(["event", "newer_command", "replacement", "write_failure", "drain", "semantic_replacement"] as const)( + "keeps HTTP responsive during a durable event write and preserves %s", async (mode) => { + const root = mkdtempSync(resolve(tmpdir(), "runner-event-io-")); + const dispatch = vi.fn(async () => { + expect(JSON.parse(readFileSync(resolve(root, "control-plane-state.json"), "utf8")).ackedSourceSeq).toBe(1); + return { result: { ok: true } }; + }); + const core = new DurablePrpControlPlane({ stateDirectory: root, identity, + expectedRunnerVersion, expectedRunnerDigest, onCommittedEvent: async () => {}, onSemanticToolInput: dispatch }); + const http = createServer((_req, res) => res.end("responsive")); + let release!: () => void; + const barrier = new Promise(resolveWrite => { release = resolveWrite; }); + const originalOpen = fsPromises.open; + let staged = false; + let holdFirst = true; + let client: AuthenticatedClient | null = null; + let replacement: AuthenticatedClient | null = null; + const open = vi.spyOn(fsPromises, "open").mockImplementation(async (...args) => { + const file = await originalOpen(...args); + if (holdFirst && String(args[0]).includes(".control-plane-state.json.")) { + holdFirst = false; + const sync = file.sync.bind(file); + file.sync = async () => { + staged = true; + await barrier; + if (mode === "write_failure") throw new Error("controlled disk failure"); + await sync(); + }; + } + return file; + }); + try { + await core.start(); + await new Promise(resolveListen => http.listen(0, "127.0.0.1", resolveListen)); + client = (await authenticate(core, core.issueBootstrapTicket()))!; + const event = semanticInputEvent(); + const payload = event.payload as Record; + if (mode !== "semantic_replacement") { + payload.eventType = "item.delta"; + payload.priority = 2; + payload.payload = { delta: "output while the volume is slow" }; + } + expect(validatePrpEvent(payload)).toMatchObject({ ok: true }); + sendSecure(client, event); + await vi.waitFor(() => expect(staged || dispatch.mock.calls.length > 0).toBe(true)); + if (mode === "semantic_replacement") { + replacement = (await authenticate(core, client.leaseToken!))!; + release(); + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(core.store.state.commands.some(command => command.type === "semantic_tool.result")).toBe(true)); + return; + } + // The actual socket request completes while the real file's fsync is held. + const address = http.address() as { port: number }; + expect(await (await fetch(`http://127.0.0.1:${address.port}`)).text()).toBe("responsive"); + expect(JSON.parse(readFileSync(core.store.path, "utf8")).ackedSourceSeq).toBe(0); + if (mode === "newer_command") core.queueCommand("session.snapshot", {}, "command_during_event"); + let drained = false; + const retirement = mode === "drain" ? (async () => { + await core.stop(); + await core.drainPendingConnectionProcessing(); + drained = true; + })() : null; + if (retirement) { + await new Promise(resolveTurn => setImmediate(resolveTurn)); + expect(drained).toBe(false); + } + if (mode === "replacement") { + replacement = (await authenticate(core, client.leaseToken!))!; + const second = structuredClone(event); + const next = second.payload as Record; + next.sourceSeq = 2; + next.sourceEventId = "output-event-2"; + sendSecure(replacement, second); + expect(await receiveSecure(replacement)).toMatchObject({ kind: "ack", payload: { ackedSourceSeq: 2 } }); + } + release(); + if (retirement) { + expect(await receiveSecure(client)).toBeNull(); + await retirement; + expect(drained).toBe(true); + } else if (mode === "write_failure") { + expect(await receiveSecure(client)).toBeNull(); + expect(() => core.queueCommand("session.snapshot")).toThrow("indeterminate"); + } else if (mode !== "replacement") { + expect(await receiveSecure(client)).toMatchObject({ kind: "ack", payload: { ackedSourceSeq: 1 } }); + } + await core.stop(); + await core.drainPendingConnectionProcessing(); + const restored = new DurablePrpControlPlane({ stateDirectory: root, identity, + expectedRunnerVersion, expectedRunnerDigest }); + const expectedEvents = mode === "replacement" ? 2 : mode === "write_failure" ? 0 : 1; + expect(restored.store.state.ackedSourceSeq).toBe(expectedEvents); + expect(restored.store.state.committedEvents).toHaveLength(expectedEvents); + if (mode === "newer_command") { + expect(restored.store.state.commands.some(command => command.commandId === "command_during_event")).toBe(true); + } + expect(nodeFs.readdirSync(root).filter(name => name.endsWith(".tmp"))).toEqual([]); + } finally { + release(); + open.mockRestore(); + client?.socket.destroy(); + replacement?.socket.destroy(); + await core.stop(); + await core.drainPendingConnectionProcessing(); + await new Promise(resolveClose => http.close(() => resolveClose())); + rmSync(root, { recursive: true, force: true }); + } + }, +); + it("commits credential updates before ACK and preserves replay identity", async () => { const root = mkdtempSync(resolve(tmpdir(), "runner-credential-update-")); let release!: () => void; diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts index 2556c47f48c..2ce1af85df8 100644 --- a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts +++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts @@ -23,12 +23,13 @@ import { renameSync, unlinkSync, writeFileSync, - type Stats, } from "node:fs"; import { createServer, type IncomingMessage, type Server } from "node:http"; import { dirname, resolve } from "node:path"; import type { Duplex } from "node:stream"; import { fileURLToPath } from "node:url"; +import { withStagedPrivateWrite } from "./staged-private-write.js"; +import { verifyPrivateRegularFile } from "./private-state-file.js"; import { NativeSessionProtocolIntegrityError } from "../contracts/native-session-backend.js"; import { ACPX_CREDENTIAL_BINDING_ENV, ACPX_CREDENTIAL_NAMES, CLAUDE_ROUTING_ENV_KEYS, createAcpxSidecarHostEnvironment } from "../drivers/acpx/environment.js"; @@ -1122,22 +1123,6 @@ function verifyPrivateDirectory(path: string): void { } } -function verifyPrivateRegularFile(file: Stats, path: string): void { - if (!file.isFile()) { - throw new Error(`Private state path is not a regular file: ${path}`); - } - if (process.platform !== "win32") { - if ((file.mode & 0o777) !== 0o600) { - throw new Error(`Private state file does not use mode 0600: ${path}`); - } - if (process.geteuid !== undefined && file.uid !== process.geteuid()) { - throw new Error( - `Private state file is not owned by the daemon user: ${path}`, - ); - } - } -} - export function readPrivateFile(path: string): string | null { let descriptor: number; try { @@ -1218,6 +1203,8 @@ class DurableCoreStore { readonly path: string; #state: StoredCoreState; #writeIndeterminate = false; + #revision = 0; + #savedRevision = 0; constructor(directory: string, identity: DurableRecoveryIdentity) { try { @@ -1255,7 +1242,33 @@ class DurableCoreStore { save(): void { this.assertWritable(); - atomicPrivateWrite(this.path, `${JSON.stringify(this.#state, null, 2)}\n`); + const revision = ++this.#revision; + try { + atomicPrivateWrite(this.path, `${JSON.stringify(this.#state)}\n`); + this.#savedRevision = revision; + } catch (error) { + this.#writeIndeterminate = true; + throw error; + } + } + + async saveEvent(): Promise { + this.assertWritable(); + const revision = ++this.#revision; + try { + await withStagedPrivateWrite(this.path, `${JSON.stringify(this.#state)}\n`, (temporary) => { + this.assertWritable(); + // A command or replacement connection can persist this event and newer + // authority while its file is staged. Never roll that state back. + if (this.#savedRevision >= revision) return; + renameSync(temporary, this.path); + syncParentDirectory(this.path); + this.#savedRevision = revision; + }); + } catch (error) { + this.#writeIndeterminate = true; + throw error; + } } assertWritable(): void { @@ -1268,9 +1281,11 @@ class DurableCoreStore { /** Persist a complete candidate before publishing any new authority in memory. */ commit(candidate: StoredCoreState): void { this.assertWritable(); + const revision = ++this.#revision; try { - atomicPrivateWrite(this.path, `${JSON.stringify(candidate, null, 2)}\n`); + atomicPrivateWrite(this.path, `${JSON.stringify(candidate)}\n`); this.#state = candidate; + this.#savedRevision = revision; } catch (error) { // Rename may already have succeeded before directory fsync failed. // Never overwrite that possibly durable receipt using stale memory. @@ -3292,7 +3307,14 @@ export class DurablePrpControlPlane { }); this.#store.state.ackedSourceSeq = sourceSeq; } - this.#store.save(); + // Output deltas have no lifecycle or control effects. Other events must + // become durable before transport readers can observe them in memory. + if (eventType === "item.delta") { + await this.#store.saveEvent(); + if (this.#eventOwner !== connection || this.#identity !== identity || this.#protocolIntegrityError !== null) return; + } else { + this.#store.save(); + } if ( isSemanticInput && diff --git a/packages/paperclip-runner/src/control-plane/private-state-file.ts b/packages/paperclip-runner/src/control-plane/private-state-file.ts new file mode 100644 index 00000000000..788cce5cbfd --- /dev/null +++ b/packages/paperclip-runner/src/control-plane/private-state-file.ts @@ -0,0 +1,15 @@ +import type { Stats } from "node:fs"; + +export function verifyPrivateRegularFile(file: Stats, path: string): void { + if (!file.isFile()) { + throw new Error(`Private state path is not a regular file: ${path}`); + } + if (process.platform !== "win32") { + if ((file.mode & 0o777) !== 0o600) { + throw new Error(`Private state file does not use mode 0600: ${path}`); + } + if (process.geteuid !== undefined && file.uid !== process.geteuid()) { + throw new Error(`Private state file is not owned by the daemon user: ${path}`); + } + } +} diff --git a/packages/paperclip-runner/src/control-plane/staged-private-write.ts b/packages/paperclip-runner/src/control-plane/staged-private-write.ts new file mode 100644 index 00000000000..fed62c54ba4 --- /dev/null +++ b/packages/paperclip-runner/src/control-plane/staged-private-write.ts @@ -0,0 +1,33 @@ +import { randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import fs from "node:fs/promises"; +import { basename, dirname, resolve } from "node:path"; +import { verifyPrivateRegularFile } from "./private-state-file.js"; + +/** Stage durable bytes off the event loop; the owner publishes them without yielding. */ +export async function withStagedPrivateWrite( + path: string, + contents: string, + publish: (temporary: string) => void, +): Promise { + const temporary = resolve(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`); + const file = await fs.open(temporary, constants.O_WRONLY | constants.O_CREAT | + constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600); + try { + try { + if (process.platform !== "win32") await file.chmod(0o600); + const metadata = await file.stat(); + verifyPrivateRegularFile(metadata, temporary); + await file.writeFile(contents, "utf8"); + await file.sync(); + } finally { + await file.close(); + } + publish(temporary); + } finally { + // Publication can either rename this file or retain a newer durable write. + await fs.unlink(temporary).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + }); + } +} diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts index 6251d7ef47c..9e9eccbfc92 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts @@ -87,19 +87,34 @@ vi.mock("./installation-integrity.js", () => ({ verifyNativeAcpxInstallation: vi describe("Copilot build-owned installation", () => { it("admits the current profile with environment launch preparation", () => { - expect(QUALIFIED_ACPX_PROFILES.copilot.agentProfileVersion).toBe(61); + expect(QUALIFIED_ACPX_PROFILES.copilot.agentProfileVersion).toBe(62); expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBeUndefined(); - expect(QUALIFIED_ACPX_PROFILES.copilot.commandDigest).toBe("sha256:ed07dcd9487c5e1e2bd863a25f7297e5c98316abc51629e9650cd2a9ce29faf9"); + expect(QUALIFIED_ACPX_PROFILES.copilot.commandDigest).toBe("sha256:ec8d1d9e570403fb1b1324b2357575e9226b8a2f9eefcc8bf573b59e36653d52"); }); - it("preserves v60 while binding delegated connection guidance in v61", () => { - const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v60-identity.json", import.meta.url), "utf8")); - const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v61-identity.json", import.meta.url), "utf8")); + it("preserves v61 while binding delegated connection guidance in v62", () => { + const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v61-identity.json", import.meta.url), "utf8")); + const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v62-identity.json", import.meta.url), "utf8")); expect(Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort()).toEqual([ "agentProfileVersion", "nativeRuntimePromptsSourceSha256", "profileCompatibilitySourceSha256", ]); + expect(isSupportedAcpxProfileVersion("copilot", 61)).toBe(true); + expect(current.commandDigest).not.toBe(prior.commandDigest); + }); + + it("preserves v60 while binding controller journal retirement in v61", async () => { + const priorBytes = readFileSync(new URL("../../../test/fixtures/copilot-profile-v60-identity.json", import.meta.url)); + expect(createHash("sha256").update(priorBytes).digest("hex")).toBe("e3c45b0311517d05f06ed10f357db713ae157cfa87e4a8becf087c5b6dfa7003"); + const prior = JSON.parse(priorBytes.toString("utf8")); + const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v61-identity.json", import.meta.url), "utf8")); + expect(Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort()).toEqual([ + "agentProfileVersion", "profileCompatibilitySourceSha256", "runtimeRequestBridgeSourceSha256", + ]); + expect(Object.keys(current.declaration).sort()).toEqual(Object.keys(prior.declaration).sort()); expect(isSupportedAcpxProfileVersion("copilot", 60)).toBe(true); expect(current.commandDigest).not.toBe(prior.commandDigest); + await expect(verifyCopilotInstallation({ ...QUALIFIED_ACPX_PROFILES.copilot, + agentProfileVersion: 60, commandDigest: prior.commandDigest })).rejects.toThrow("exact pinned"); }); it("preserves v59 while binding the Codex-only hosted search restriction in v60", async () => { diff --git a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts index 994c2fee8b0..98d59ad8649 100644 --- a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts @@ -48,12 +48,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = { "protocolVersion": 1, "acpxVersion": "0.13.1", "agent": "copilot", - "agentProfileVersion": 61, + "agentProfileVersion": 62, "agentServerPackage": "@github/copilot", "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:ed07dcd9487c5e1e2bd863a25f7297e5c98316abc51629e9650cd2a9ce29faf9", + "commandDigest": "sha256:ec8d1d9e570403fb1b1324b2357575e9226b8a2f9eefcc8bf573b59e36653d52", "permissionPolicy": "interactive" }, "claude": { diff --git a/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts index 6a1888d273c..e8091c079e2 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts @@ -2,13 +2,13 @@ import { QUALIFIED_ACPX_PROFILE_DATA } from "./generated-profiles.js"; /** Decodable historical profile revisions, not permission to launch them. * Exact current profile/package/digest admission is checked separately. */ -type HistoricalAcpxProfileVersion = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60; +type HistoricalAcpxProfileVersion = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61; type CurrentAcpxProfileVersion = typeof QUALIFIED_ACPX_PROFILE_DATA[keyof typeof QUALIFIED_ACPX_PROFILE_DATA]["agentProfileVersion"]; export type AcpxProfileVersion = HistoricalAcpxProfileVersion | CurrentAcpxProfileVersion; const historicalVersions: Readonly> = { pi: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28], claude: [1, 2, 3, 4, 5], codex: [1, 2, 3, 4, 5], - grok: [1, 2, 3, 4, 5], copilot: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60], + grok: [1, 2, 3, 4, 5], copilot: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61], cursor: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], }; diff --git a/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.test.ts b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.test.ts index 07681400ddd..f281fd2a55a 100644 --- a/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.test.ts +++ b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.test.ts @@ -94,6 +94,33 @@ describe("OpenCode MCP bridge", () => { expect(await conflict.json()).toMatchObject({ result: { isError: true, content: [{ text: "Duplicate call identity conflict." }] } }); }); + it("preserves declared prefixed operation IDs through HTTP MCP and rejects undeclared aliases", async () => { + const names = ["paperclip_search_assigned_tools", "paperclip_call_assigned_tool", "search_assigned_tools"]; + const handler = vi.fn(async call => ({ operation: call.tool })); + const bridge = await startOpenCodeMcpBridge({ tools: names.map(name => ({ name, inputSchema: { type: "object" } })), handler }); + bridges.push(bridge); + const listed = await (await rpc(bridge, { id: "list", method: "tools/list" })).json() as { result: { tools: Array<{ name: string }> } }; + expect(listed.result.tools.map(tool => tool.name)).toEqual([...names, "paperclip_finish", "paperclip_block"]); + for (const [i, name] of names.entries()) { + for (const [j, prefix] of ["", "paperclip_", "paperclip__", "paperclip."].entries()) { + const called = prefix === "paperclip_" && name === "search_assigned_tools" ? name : prefix + name; + const result = await (await rpc(bridge, { id: `call-${i}-${j}`, method: "tools/call", params: { name: called, arguments: {} } })).json(); + expect(result).toMatchObject({ result: { content: [{ text: JSON.stringify({ operation: name }) }] } }); + } + } + const before = handler.mock.calls.length; + expect(await (await rpc(bridge, { id: "unknown", method: "tools/call", params: { name: "paperclip_unassigned_tool", arguments: {} } })).json()).toMatchObject({ result: { isError: true } }); + expect(handler).toHaveBeenCalledTimes(before); + }); + + it("does not authorize a shortened operation when only its prefixed name is declared", async () => { + const handler = vi.fn(async () => ({ ok: true })); + const bridge = await startOpenCodeMcpBridge({ tools: [{ name: "paperclip_search_assigned_tools", inputSchema: { type: "object" } }], handler }); + bridges.push(bridge); + expect(await (await rpc(bridge, { id: "short", method: "tools/call", params: { name: "search_assigned_tools", arguments: {} } })).json()).toMatchObject({ result: { isError: true } }); + expect(handler).not.toHaveBeenCalled(); + }); + it("normalizes all supported OpenCode MCP prefixes", () => { expect(canonicalOpenCodeMcpToolName("paperclip_documents.read")).toBe("documents.read"); expect(canonicalOpenCodeMcpToolName("paperclip__documents.read")).toBe("documents.read"); diff --git a/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts index 1bf1555dd16..cb1efd91922 100644 --- a/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts +++ b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts @@ -108,7 +108,7 @@ function normalizeTools( const tools: OpenCodeMcpToolDefinition[] = []; const seen = new Set(); for (const raw of input) { - const name = typeof raw.name === "string" ? canonicalOpenCodeMcpToolName(raw.name) : ""; + const name = typeof raw.name === "string" ? raw.name.trim() : ""; if (!name || seen.has(name)) continue; const inputSchema = isRecord(raw.inputSchema) ? structuredClone(raw.inputSchema) : { type: "object" }; tools.push({ @@ -218,7 +218,12 @@ async function handleRequest( } const params = isRecord(message.params) ? message.params : {}; const rawName = typeof params.name === "string" ? params.name : ""; - const tool = canonicalOpenCodeMcpToolName(rawName); + // A declared operation may itself start with paperclip_. Preserve that + // authority identity before accepting an OpenCode transport prefix alias. + const exactName = rawName.trim(); + const tool = context.admittedTools.some(entry => entry.name === exactName) + ? exactName + : canonicalOpenCodeMcpToolName(exactName); if (!context.admittedTools.some((entry) => entry.name === tool)) { writeRpc(response, id, { isError: true, content: [{ type: "text", text: "Unsupported tool." }] }); return; diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts index 5d568eddf86..a44136bd364 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts @@ -2219,6 +2219,27 @@ describe("OpenCodeServerDriver", () => { } finally { await session.close({ reason: "monitor default test complete" }); } }); + it("keeps assigned discovery operation IDs at the authenticated MCP and controller boundary", async () => { + await chmod(fixture, 0o755); + const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-assigned-")); + const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-workspace-")); + roots.push(root, workspace); + const calls: string[] = []; + const operation = "paperclip_search_assigned_tools"; + const driver = new OpenCodeServerDriver({ model: "openrouter/deepseek/deepseek-v4.1-flash-1017", runtimeDirectory: root, command: fixture, + environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" }, + dynamicTools: [{ name: operation, inputSchema: { type: "object" } }], + dynamicToolHandler: async call => { calls.push(call.tool); if (call.tool !== operation) throw new Error("unauthorized operation"); return { tools: [] }; }, + }); + const session = await driver.openSession({ runId: "assigned-name", normalizedSessionId: "assigned-name", workingDirectory: workspace }); + try { + await session.startTurn({ message: { role: "user", text: "Discover assigned tools." } }); + const events = await collectTurnEvents(session.events()); + expect(calls).toEqual([operation]); + expect(events.some(event => event.eventType === "item.completed" && (event.payload as { item?: { is_error?: boolean } }).item?.is_error)).toBe(false); + } finally { await session.close({ reason: "assigned operation identity test" }); } + }); + it("normalizes a structured block result", async () => { await chmod(fixture, 0o755); const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-")); diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts index 4ab25b38b83..bf2a8bed9d5 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts @@ -1026,7 +1026,9 @@ class OpenCodeHarnessSession implements HarnessSession { callId: string; arguments: unknown; }): Promise { - const tool = canonicalOpenCodeMcpToolName(call.tool); + // The authenticated MCP bridge has resolved this against its closed + // declared catalog. A second prefix strip changes legitimate operation IDs. + const tool = call.tool; const turnId = this.#activeTurnId; if (turnId === null) throw new Error("OpenCode tool call is not bound to an active turn"); diff --git a/packages/paperclip-runner/src/live/runnerd-acpx-permission-forwarding.test.ts b/packages/paperclip-runner/src/live/runnerd-acpx-permission-forwarding.test.ts index 71b37c8ff25..14e6351c9c9 100644 --- a/packages/paperclip-runner/src/live/runnerd-acpx-permission-forwarding.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-acpx-permission-forwarding.test.ts @@ -27,6 +27,7 @@ vi.mock("../control-plane/durable-prp-control-plane.js", async (importOriginal) } async start() {} async stop() {} + async drainPendingConnectionProcessing() {} disconnectActiveRunner() {} persistRunAttachTemplate() {} issueBootstrapTicket() { return {}; } diff --git a/packages/paperclip-runner/src/live/runnerd-acpx-permission-ordering.test.ts b/packages/paperclip-runner/src/live/runnerd-acpx-permission-ordering.test.ts index 3e0c5314b78..1dcd3de3875 100644 --- a/packages/paperclip-runner/src/live/runnerd-acpx-permission-ordering.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-acpx-permission-ordering.test.ts @@ -25,6 +25,7 @@ vi.mock("../control-plane/durable-prp-control-plane.js", async (importOriginal) } async start() {} async stop() {} + async drainPendingConnectionProcessing() {} disconnectActiveRunner() {} persistRunAttachTemplate() {} issueBootstrapTicket() { return {}; } diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index d0d51df3077..d088b3c7916 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -726,6 +726,7 @@ async function releaseRunnerProcessOwnership(input: { ((settlement: "settled" | "unsettled") => Promise | void) | null; forceKill: () => Promise | void; release: (() => Promise | void) | null; + retireController?: () => Promise; }): Promise { let releaseFailure: unknown; try { @@ -735,6 +736,9 @@ async function releaseRunnerProcessOwnership(input: { } let checkpointFailure: unknown; try { + // External ingress must stop reconnecting before admitted journal writes + // can be joined. A failed retirement cannot authorize a new checkpoint. + await input.retireController?.(); if (input.checkpoint !== null) { // Release only the authenticated control route first. In provider-ingress // mode this stops its reconnect loop; it does not release the sandbox @@ -3569,6 +3573,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { readonly #closedSignal: Promise; #resolveClosedSignal!: () => void; #closePromise: Promise | null = null; + #detachPromise: Promise | null = null; #startupOperation: Promise | null = null; #attachmentOperation: Promise | null = null; #startupDrainTimedOut = false; @@ -4466,7 +4471,8 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { close(reason?: string): Promise { // Detachment relinquishes process ownership. A late execution finalizer // must not suspend or signal the runner now owned by the next controller. - if (this.#controllerDetachedForRestart) return Promise.resolve(); + if (this.#controllerDetachedForRestart) + return this.#detachPromise ?? Promise.resolve(); if (reason) { this.#diagnostic( `runner transport close requested: ${reason.replaceAll(/[\r\n]/g, " ").slice(0, 1_000)}`, @@ -4476,8 +4482,33 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { return this.#closePromise; } - async detachControllerForRestart(): Promise { + detachControllerForRestart(): Promise { + this.#detachPromise ??= this.#detachControllerOnce(); + return this.#detachPromise; + } + + async #stopAndDrainAuthority(deadline: number): Promise { + const core = this.#core; + if (!core) return; + await core.stop(); + let timer: ReturnType | undefined; + try { + await Promise.race([ + core.drainPendingConnectionProcessing(), + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error( + "Controller journal drain timed out; preserve the session before replacing its writer.", + )), Math.max(0, deadline - Date.now())); + }), + ]); + } finally { + clearTimeout(timer); + } + } + + async #detachControllerOnce(): Promise { if (this.#closed) return; + const deadline = Date.now() + runnerCloseGraceMs(this.options); this.#controllerDetachedForRestart = true; this.#closed = true; this.#resolveClosedSignal(); @@ -4495,13 +4526,15 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { `controller route release failed during restart detach: ${String(error)}`, ); }); - await this.#core?.stop().catch((error: unknown) => { - this.#diagnostic( - `controller authority stop failed during restart detach: ${String(error)}`, - ); - }); + // A successor must not share this journal with an admitted async write. + // A timeout rejects detachment, including repeated calls, instead of + // reporting that the old writer has retired. + try { + await this.#stopAndDrainAuthority(deadline); + } finally { + this.#queue.close(); + } this.#handle = null; - this.#queue.close(); this.#diagnostic( "controller authority detached for restart; durable runner left alive", ); @@ -4725,6 +4758,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { this.#evidence.runnerSignal = result.signal as NodeJS.Signals | null; }, release: this.#controlPlaneRelease, + retireController: () => this.#stopAndDrainAuthority(closeDeadline), }); } finally { await this.#core?.stop(); diff --git a/packages/paperclip-runner/src/live/runnerd-detach-durability.test.ts b/packages/paperclip-runner/src/live/runnerd-detach-durability.test.ts new file mode 100644 index 00000000000..d49672cf964 --- /dev/null +++ b/packages/paperclip-runner/src/live/runnerd-detach-durability.test.ts @@ -0,0 +1,150 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import fs from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { expect, it, vi } from "vitest"; +import { DurablePrpControlPlane } from "../control-plane/durable-prp-control-plane.js"; +import { createCapabilityRunnerdCodexTransport, defaultCapabilityRunnerdBinary, readRunnerdArtifactBinding, runnerdRecoveryInternals } from "./runnerd-codex-transport.js"; + +it.each(["resolved", "rejected"] as const)("keeps the checkpoint behind controller retirement (%s)", async outcome => { + const steps: string[] = []; + let release!: () => void; + let reject!: (error: Error) => void; + const barrier = new Promise((resolveDrain, rejectDrain) => { release = resolveDrain; reject = rejectDrain; }); + const operation = runnerdRecoveryInternals.releaseRunnerProcessOwnership({ + runnerSettled: true, + release: () => { steps.push("route"); }, + retireController: async () => { steps.push("drain"); await barrier; }, + checkpoint: () => { steps.push("checkpoint"); }, + forceKill: () => { steps.push("containment"); }, + }); + await new Promise(resolveTurn => setImmediate(resolveTurn)); + expect(steps).toEqual(["route", "drain"]); + if (outcome === "rejected") { + const failed = expect(operation).rejects.toThrow("journal unavailable"); + reject(new Error("journal unavailable")); + await failed; + expect(steps).toEqual(["route", "drain", "containment"]); + } else { + release(); + await operation; + expect(steps).toEqual(["route", "drain", "checkpoint", "containment"]); + } +}); + +it.each(["drain", "timeout", "external_close"] as const)("joins an output flush before controller retirement permits a successor writer (%s)", async mode => { + const root = await fs.mkdtemp(join(tmpdir(), "runnerd-detach-write-")); + await fs.mkdir(join(root, "diagnostics"), { mode: 0o700 }); + const binary = defaultCapabilityRunnerdBinary(); + const artifact = readRunnerdArtifactBinding(binary); + let core!: DurablePrpControlPlane; + const start = DurablePrpControlPlane.prototype.start; + const startSpy = vi.spyOn(DurablePrpControlPlane.prototype, "start").mockImplementation(function (port) { + core = this; + return start.call(this, port); + }); + let release!: () => void; + const barrier = new Promise(resolveWrite => { release = resolveWrite; }); + let staged = false; + const originalOpen = fs.open; + const openSpy = vi.spyOn(fs, "open").mockImplementation(async (...args) => { + const file = await originalOpen(...args); + if (!staged && String(args[0]).includes(".control-plane-state.json.")) { + const sync = file.sync.bind(file); + file.sync = async () => { staged = true; await barrier; await sync(); }; + } + return file; + }); + let child: ChildProcess | undefined; + let exit: Promise | undefined; + const closeSteps: string[] = []; + let stopSpy: ReturnType | undefined; + const bundle = createCapabilityRunnerdCodexTransport({ + runnerBinary: binary, + codexCommand: resolve(import.meta.dirname, "../../runner/target/debug/fake-codex-app-server"), + codexArgs: ["--state-file", join(root, "provider-state.json"), "--split-event-burst", "--split-event-prefix-count", "1"], + stateDirectory: root, + closeGraceMs: mode === "timeout" ? 200 : 1_500, + ...(mode === "external_close" ? { controlPlaneRegistration: async (authority: DurablePrpControlPlane) => { + await authority.start(); + const stop = authority.stop.bind(authority); + stopSpy = vi.spyOn(authority, "stop").mockImplementation(async () => { + closeSteps.push("stop"); + release(); + await stop(); + }); + return { connectUrl: authority.connectUrl, release: () => { closeSteps.push("release"); } }; + } } : {}), + runnerProcessLauncher: spec => { + child = spawn(spec.command, [...spec.args], { cwd: spec.cwd, env: spec.environment, stdio: ["ignore", "pipe", "pipe"] }); + const owned = child; + let stderr = ""; + owned.stderr?.on("data", chunk => { stderr += String(chunk); }); + exit = new Promise(resolveExit => owned.once("exit", () => resolveExit())); + const completion = new Promise<{ code: number | null; signal: string | null; stdout: string; stderr: string }>((resolveExit, reject) => { + owned.once("error", reject); + owned.once("exit", (code, signal) => resolveExit({ code, signal, stdout: "", stderr })); + }); + return { child: owned, completion }; + }, + }); + bundle.transport.setServerRequestHandler(async () => ({ success: true, contentItems: [] })); + let detached: Promise | undefined; + try { + await bundle.transport.request("thread/start", { cwd: root, dynamicTools: [{ + name: "get_task_context", description: "Read fixture context.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + }] }); + await bundle.transport.request("turn/start", { input: [{ type: "text", text: "Exercise a held output flush." }] }); + await vi.waitFor(() => expect(staged).toBe(true), { timeout: 5_000 }); + if (mode === "external_close") { + await bundle.transport.close().catch(error => { + expect(error.message).toMatch(/journal drain timed out|did not durably suspend/); + }); + expect(closeSteps.indexOf("release")).toBeGreaterThanOrEqual(0); + expect(closeSteps.indexOf("release")).toBeLessThan(closeSteps.indexOf("stop")); + return; + } + let settled = false; + detached = bundle.detachControllerForRestart().then(() => { settled = true; }); + await vi.waitFor(() => expect(core.activeRunnerConnectionCount()).toBe(0)); + await new Promise(resolveTurn => setImmediate(resolveTurn)); + expect(settled).toBe(false); + if (mode === "timeout") { + await expect(detached).rejects.toThrow("journal drain timed out"); + await expect(bundle.detachControllerForRestart()).rejects.toThrow("journal drain timed out"); + await expect(bundle.transport.close()).rejects.toThrow("journal drain timed out"); + release(); + await core.drainPendingConnectionProcessing(); + } else { + release(); + await detached; + } + const successor = new DurablePrpControlPlane({ stateDirectory: join(root, "control-plane"), + identity: core.store.state.identity, expectedRunnerVersion: artifact.version, expectedRunnerDigest: artifact.digest }); + successor.queueCommand("session.snapshot", {}, "successor_command"); + await new Promise(resolveTurn => setImmediate(resolveTurn)); + const persisted = JSON.parse(await fs.readFile(successor.store.path, "utf8")); + expect(persisted.commands.some((command: { commandId: string }) => command.commandId === "successor_command")).toBe(true); + } catch (error) { + const diagnostic = await fs.readFile(join(root, "diagnostics", "runnerd.stderr.log"), "utf8").catch(() => ""); + throw new Error(`${String(error)}\n${diagnostic}`, { cause: error }); + } finally { + release(); + await detached?.catch(() => undefined); + openSpy.mockRestore(); + startSpy.mockRestore(); + await bundle.detachControllerForRestart().catch(() => undefined); + const providerPid = bundle.evidence().codexPid; + if (providerPid) { + try { process.kill(providerPid, "SIGKILL"); } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } + } + child?.kill("SIGKILL"); + await exit; + await core?.drainPendingConnectionProcessing(); + stopSpy?.mockRestore(); + await fs.rm(root, { recursive: true, force: true }); + } +}, 15_000); diff --git a/packages/paperclip-runner/src/live/runnerd-opencode-assigned-tools.test.ts b/packages/paperclip-runner/src/live/runnerd-opencode-assigned-tools.test.ts new file mode 100644 index 00000000000..e4722449c17 --- /dev/null +++ b/packages/paperclip-runner/src/live/runnerd-opencode-assigned-tools.test.ts @@ -0,0 +1,65 @@ +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { chmod, copyFile, mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { expect, it } from "vitest"; +import { codexSemanticToolSpecs, dynamicToolResponse } from "../drivers/codex/codex-driver-values.js"; +import { createRunnerdCodexTransport, defaultCapabilityRunnerdBinary } from "./runnerd-codex-transport.js"; + +const runner = defaultCapabilityRunnerdBinary(); +const proxy = resolve("dist/cli/opencode-app-server-proxy.cjs"); +const digest = (path: string) => `sha256:${createHash("sha256").update(readFileSync(path)).digest("hex")}`; + +it.skipIf(process.platform === "win32" || !existsSync(runner) || !existsSync(proxy))( + "retains assigned discovery authority across Rust, the proxy, and HTTP MCP", + async () => { + const root = await mkdtemp(join(tmpdir(), "runnerd-opencode-assigned-")); + const workspace = join(root, "workspace"); + const executable = join(root, "fake-opencode-server.mjs"); + await mkdir(workspace); + await copyFile(resolve("test/fixtures/fake-opencode-server.mjs"), executable); + await chmod(executable, 0o755); + const operation = "paperclip_search_assigned_tools"; + const seen: string[] = []; + const { transport } = createRunnerdCodexTransport({ + provider: "opencode", runnerBinary: runner, stateDirectory: join(root, "state"), sourceCodexHome: "", + providerNodeCommand: process.execPath, providerNodeCommandSha256: digest(process.execPath), + opencodeCommand: executable, opencodeCommandSha256: digest(executable), + opencodeProxyPath: proxy, opencodeProxySha256: digest(proxy), + environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "synthetic-fixture-key" }, + }); + transport.setServerRequestHandler(async request => { + expect(request.method).toBe("item/tool/call"); + const tool = request.params.tool; + expect([operation, "paperclip_finish"]).toContain(tool); + seen.push(String(tool)); + return dynamicToolResponse(tool === operation ? { tools: [] } : "Accepted completion."); + }); + let timer: ReturnType | undefined; + try { + await transport.request("thread/start", { + cwd: workspace, model: "openrouter/deepseek/deepseek-v4.1-flash-1017", + completionContract: { revision: "assigned-tools-v1", criterionIds: ["objective"] }, + dynamicTools: [{ name: operation, description: "Search assigned synthetic tools.", inputSchema: { type: "object" } }, ...codexSemanticToolSpecs()], + }); + const completed = (async () => { + for await (const notification of transport.notifications()) { + if (notification.method === "turn/completed") return; + if (notification.method === "turn/failed") throw new Error("Native OpenCode turn failed"); + } + throw new Error("Native OpenCode notifications ended before completion"); + })(); + await transport.request("turn/start", { input: [{ type: "text", text: "Discover assigned synthetic tools." }] }); + await Promise.race([completed, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("Native assigned-tool smoke timed out")), 20_000); + })]); + expect(seen).toEqual([operation, "paperclip_finish"]); + } finally { + if (timer) clearTimeout(timer); + await transport.close(); + await rm(root, { recursive: true, force: true }); + } + }, + 30_000, +); diff --git a/packages/paperclip-runner/src/testing.ts b/packages/paperclip-runner/src/testing.ts index b220b2ea73a..5260f4f5114 100644 --- a/packages/paperclip-runner/src/testing.ts +++ b/packages/paperclip-runner/src/testing.ts @@ -9,6 +9,7 @@ export * from "./index.js"; export * from "./conformance/control-plane-port.js"; export * from "./conformance/capability-semantic-conformance.js"; export * from "./conformance/harness-driver.js"; +export { startOpenCodeMcpBridge } from "./drivers/opencode/mcp-bridge.js"; export * from "./conformance/semantic-conformance.js"; export * from "./mock-core/deterministic-harness-driver.js"; export * from "./mock-core/mock-control-plane-adapter.js"; diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v61-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v61-identity.json index 8f6e25ae0d7..6a641986fce 100644 --- a/packages/paperclip-runner/test/fixtures/copilot-profile-v61-identity.json +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v61-identity.json @@ -1,5 +1,5 @@ { - "commandDigest": "sha256:ed07dcd9487c5e1e2bd863a25f7297e5c98316abc51629e9650cd2a9ce29faf9", + "commandDigest": "sha256:956276ebee59f880249fa6955adc224af9ca7b6a9da59bcc5da4a8667550c5b3", "declaration": { "schema": "paperclip.acpx_profile_declaration.v1", "agent": "copilot", @@ -49,7 +49,7 @@ "singleReadEvidenceSourceSha256": "4069393405089bde613cecec04f5123c426594811da4c668bd96f0e7e283550f", "nativeSessionRuntimeSourceSha256": "1fe6fa9e6b96d84d70432b85a57c473f833606d4a521563d5022b4d3c41576ce", "nativeRuntimeContextSourceSha256": "71607f80dbb8dc5cc6c123d0b8f3995d6c5407277695a8c82cbedc90fb8f4842", - "nativeRuntimePromptsSourceSha256": "fff5755dba9b7dce8a5f10ff6ac6d5ed06b92ed079ce1e0a44ac72f2ce7869c1", + "nativeRuntimePromptsSourceSha256": "103b83066da72434602e9e461e6278dfb40bb993d2f3b49b2e93df81bbb08b72", "nativeSessionContractSourceSha256": "22a8c96ce41ba4f03e976fc37f0cc7c5d05cee99fa789941da31ee1dab440b90", "harnessSessionContractSourceSha256": "03f4cd7f76f34f6d82e009b66aa51f18f6e23dc235b660778060e35b6ea07412", "harnessBackendSourceSha256": "dc827e281ae6731545b166ee4d709a4f67f594a556dcbe63c70fe8f1aca26249", @@ -60,7 +60,7 @@ "nativeInstructionsSourceSha256": "9173ebccf09cb56a781d2bf5c7eb27fdafb13d3450d99342c6aff86b42d6b43a", "providerLaunchSourceSha256": "fa8db1c42c378898287c141d8cea9069166981e369c9b9c51422cb6177de5a2c", "launchPreparationStoreSourceSha256": "ff6025568bbc0aa6ce545772d73dfca2d3da226740bd3a7b386b069e76cff296", - "runtimeRequestBridgeSourceSha256": "8ddd5d26854f4a153a9559ea8105aef95a3ec94f03209e9a797c95932c162e2a", + "runtimeRequestBridgeSourceSha256": "e6c98128d98a4177c42d68a04c1f749554fd5ec844da1bbbdb3f682670189445", "runtimeRequestFacadeSourceSha256": "d0ed28e0c5ddad34db8f437e704c8a755eb0b47f27e08e44185f66d1d2205295", "runtimeRequestNotificationSourceSha256": "6196c6175f11c69295dc00fc773201f4fdeca26c500653870253c43b81e65211", "guardedInstallationSourceSha256": "56a177d3e702914818dca4ca50e061f0d25f988bfc4c0582c36ce23c1696d82f", diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v62-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v62-identity.json new file mode 100644 index 00000000000..080db65363b --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v62-identity.json @@ -0,0 +1,85 @@ +{ + "commandDigest": "sha256:ec8d1d9e570403fb1b1324b2357575e9226b8a2f9eefcc8bf573b59e36653d52", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 62, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1", + "policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3", + "distributionSourceSha256": "c0a65e247fc47b263bd369a3d5ab505c4b323d4e6493be8a9ecbeb0e9dbb9107", + "agentFilesBinding": "registered-runtime-context-v1", + "systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "innerDistributionSourceSha256": "9bb08347eed01f1ab44e065c8b06ead9be3a25973d6e6329dc4c0a099096279a", + "upstreamAppSha256": "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0", + "patchedAppSha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679", + "messageIdentityContract": "copilot-native-message-id-v1", + "ownedDistributionDelivery": "COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1", + "permissionContextContract": "copilot-edit-permission-context-v1", + "permissionContextSourceSha256": "de2d38d702b3c574161af0c0c04790fb6f6347916eb3916ce7a131a8c229121b", + "permissionAdapterSourceSha256": "ee5929ed2ee647ad0369465dee9d51a00b2dd28e1ff735b43e969ddc9c164668", + "permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9", + "permissionRedactionSourceSha256": "ad6770c69269087951790e2e87e22ac088e3d432769adf17ebc865ed512e6c4f", + "permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4", + "semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2", + "semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791", + "semanticBridgeSourceSha256": "8764b8c9032fdc7d1415b01e75a9fc57c550e7a5e11da4bed7d9100082872ae5", + "toolEvidenceSourceSha256": "6eef5039afc75bb851eaf9cd82a6f099e4bd398ca2deb59dd66f5b2a81a7a706", + "semanticNormalizedInputContract": "validated-forwarded-input-commit-v1", + "semanticSidecarSourceSha256": "17361d1782c0dcebb2b7972635f45c890f2a4081f8bc0ef2d2276550e0b91629", + "semanticDirectDriverSourceSha256": "ff572638f2cb5d9f8249ff8e2f412311c1e3af209ef0bf7e5ea9f10a21d91103", + "semanticValidationSourceSha256": "02fbb92681c78c5de5d2f1aae25aabc25a0e5df6b0ff4baccac652192a8da975", + "semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e", + "semanticCompletionContractSourceSha256": "3343df6362252d44d12b079ff78c8f6539d1544f3ce5ec36ff36db70ab0fac31", + "semanticValidatorsSourceSha256": "f22d9caf2035b600abc1a6625ae23ca97f1409d7e29ed5b7264bb8f9784c7dc9", + "semanticSchemaBundleSourceSha256": "016391a087cc217aafed4ba1f4efafc6e4881a7525952dce824b7f4d24530603", + "semanticSidecarProtocolSourceSha256": "03c0c95b900f9dc18f29f6be27b8c79bc0bcab29780190d46a74e95328081236", + "semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1", + "displayEventSourceSha256": "d72f9a6035c247bc6a42c067c05753076c54322c4b58984e93922f3325dc6877", + "displayProjectionSourceSha256": "75952606b26dd93bbc582c1f0a4424487377272ea745fb3a27ceae031fd5dc7e", + "configuredEnvironmentSourceSha256": "5c9008d121979a3a8987e6b8a5752172eafdc4a2b5d725a73f3bbb8fb83be8ff", + "configuredEnvironmentRustSourceSha256": "85c87fae62b8a9377e4f0a4c00302c7519fae4397b1c48253f8f2b7ba7faf994", + "profileActivitySourceSha256": "4fdb6b0f9125fd8372ba6f27945fa5c8a3d3b2ec891df544092e3dc7a75d5282", + "profileExtensionsSourceSha256": "a80ddc95eb4505095bfdbbf85c758199949ab20be9c84b34c77f762eb7180571", + "profileInstallationSourceSha256": "3e61c4cdd4b78e38fcb16eaadae4f1fd65ce3c7127184ea7a80948c8e5d77f88", + "attachmentPolicySourceSha256": "4f7638b7cc64e18f6a4ba33fd46de2b3d49225e32aeab41f5c695b268fa4f8e2", + "singleReadEvidenceSourceSha256": "4069393405089bde613cecec04f5123c426594811da4c668bd96f0e7e283550f", + "nativeSessionRuntimeSourceSha256": "1fe6fa9e6b96d84d70432b85a57c473f833606d4a521563d5022b4d3c41576ce", + "nativeRuntimeContextSourceSha256": "71607f80dbb8dc5cc6c123d0b8f3995d6c5407277695a8c82cbedc90fb8f4842", + "nativeRuntimePromptsSourceSha256": "fff5755dba9b7dce8a5f10ff6ac6d5ed06b92ed079ce1e0a44ac72f2ce7869c1", + "nativeSessionContractSourceSha256": "22a8c96ce41ba4f03e976fc37f0cc7c5d05cee99fa789941da31ee1dab440b90", + "harnessSessionContractSourceSha256": "03f4cd7f76f34f6d82e009b66aa51f18f6e23dc235b660778060e35b6ea07412", + "harnessBackendSourceSha256": "dc827e281ae6731545b166ee4d709a4f67f594a556dcbe63c70fe8f1aca26249", + "harnessSessionSourceSha256": "81361733e86b56dd5a66415aefc6f72e69be24acc8d3417370154ebb7fef6e94", + "transportContractSourceSha256": "03fca93d36a89b0cc0917335e293ad097fdf04356d21b307827bb0ea4c13f523", + "nativeBackendSourceSha256": "e7b12d4418c7a71f5f2474b046697d3009eae3613b284bec381df5e9965a1c37", + "nativeBackendFactorySourceSha256": "7e431c926b76d53c4c08e3a4105353dcd34bd940f6588a1a68f79631f6feeb44", + "nativeInstructionsSourceSha256": "9173ebccf09cb56a781d2bf5c7eb27fdafb13d3450d99342c6aff86b42d6b43a", + "providerLaunchSourceSha256": "fa8db1c42c378898287c141d8cea9069166981e369c9b9c51422cb6177de5a2c", + "launchPreparationStoreSourceSha256": "ff6025568bbc0aa6ce545772d73dfca2d3da226740bd3a7b386b069e76cff296", + "runtimeRequestBridgeSourceSha256": "e6c98128d98a4177c42d68a04c1f749554fd5ec844da1bbbdb3f682670189445", + "runtimeRequestFacadeSourceSha256": "d0ed28e0c5ddad34db8f437e704c8a755eb0b47f27e08e44185f66d1d2205295", + "runtimeRequestNotificationSourceSha256": "6196c6175f11c69295dc00fc773201f4fdeca26c500653870253c43b81e65211", + "guardedInstallationSourceSha256": "56a177d3e702914818dca4ca50e061f0d25f988bfc4c0582c36ce23c1696d82f", + "nativeExecutionSourceSha256": "3a30d52bffad68c7f02349193be84761050eb99039fc0556c2b449891a36e918", + "profileCompatibilitySourceSha256": "be60656e66d7f61a290f8d1c9a3c9ec511fb13b6541825a087b470e971ccc020", + "installationSourceSha256": "a1c7c6f103b898216973e77ffbac0ef7572e250aa574d1a4065270e9dfa0dc2d", + "assetOwnershipSourceSha256": "1785e3f31e5dad5a13b53e25d8cf9482a9bd89a27b6c127830aed6b81acc605c", + "assetInstallerSourceSha256": "d3bd9514e5920d0cc3567aa50487fb534c57a6f66b8da0d3f22c5f6e499c841f", + "credentialBindingSourceSha256": "7586d44b47c83395498dc8806287cd3538ef58177ba7c5949f5766ed105a0161", + "sidecarErrorSourceSha256": "b09af4577f8bf42eee7036b2ad9842202cfc763e72b53c3287f82c6090cdad80", + "profileSourceSha256": "f5538b3dbefa7634a332b87d5ededcefb56aa7e5790cede1c7a0d948a548a456", + "metadataProbeSourceSha256": "be4c8416edd27061109bc1f33d0c26071b4facbb3f651beaae6b3c0275cce727", + "permissionToolIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1", + "semanticContinuationSourceSha256": "d248e47a0fe7f2536d1e64cea0b0b410f34104800cd643d995b4473104ecc8e0", + "runtimeHostSourceSha256": "94a73749267f5a80b5df19413a65b4b9263c00a9bf7e98fda00f00b8f9fd32ec", + "runtimeAdapterSourceSha256": "e4c764c2c3c7dd655a965b39472bd1ad8d577d1e7258b25594b171c9429752b8", + "runtimeSandboxSourceSha256": "92dfa53f5a3ed0062913ae417eeccec243b430d444495efde2a8e9c4c02e78ea", + "cancellationSourceSha256": "a07d1a9e982dec0ae130c1ad1efa5bc75c6a39e013b730ba3ee45256f09ceeca", + "agentFilesSourceSha256": "fb32f6badc13dfa22f231479509b631b2f0eba0c99b1ab9f3cd5c0c601aa03ba", + "nativeDistributionSourceSha256": "aed327c389e957483a503cb1bb8b247ee7cd7ff4b528fb4567c39bcb4577690b" + } +} diff --git a/server/src/__tests__/connection-management.integration.test.ts b/server/src/__tests__/connection-management.integration.test.ts index 78140ec31d6..963940c179d 100644 --- a/server/src/__tests__/connection-management.integration.test.ts +++ b/server/src/__tests__/connection-management.integration.test.ts @@ -295,7 +295,7 @@ describe("connection management with production default agents", () => { const ownBinding = await f.request("POST", `${path}/bind`, { targetType: "issue", targetId: f.issueId }); expect(ownBinding, JSON.stringify(ownBinding)).toMatchObject({ status: 201 }); expect(await f.request("POST", `${path}/unbind`, { targetType: "issue", targetId: f.issueId })).toMatchObject({ status: 200 }); - for (const target of [{ targetType: "issue", targetId: issue.id }, { targetType: "routine", targetId: routine.id }, { targetType: "gateway", targetId: gateway.body.id }]) { + for (const target of [{ targetType: "project", targetId: f.projectId }, { targetType: "issue", targetId: issue.id }, { targetType: "routine", targetId: routine.id }, { targetType: "gateway", targetId: gateway.body.id }]) { expect(await f.request("POST", `${path}/bind`, target), JSON.stringify(target)).toMatchObject({ status: 403 }); const [saved] = await server.db.insert(toolProfileBindings).values({ ...target, companyId: f.companyId, profileId: profile.body.id }).returning(); expect(await f.request("PATCH", `/tool-profiles/${profile.body.id}`, { description: "Cannot edit retained target" })).toMatchObject({ status: 403 }); diff --git a/server/src/routes/connection-management-authz.ts b/server/src/routes/connection-management-authz.ts index 0e589266c6f..251d7bb8261 100644 --- a/server/src/routes/connection-management-authz.ts +++ b/server/src/routes/connection-management-authz.ts @@ -353,7 +353,7 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai selfServiceUserId: connection.createdByUserId, }); } - if (["issue", "routine", "gateway"].includes(String(selected.targetType)) && text(selected.targetId)) bindingTargets.push({ targetType: String(selected.targetType), targetId: uuid(selected.targetId) }); + if (["project", "issue", "routine", "gateway"].includes(String(selected.targetType)) && text(selected.targetId)) bindingTargets.push({ targetType: String(selected.targetType), targetId: uuid(selected.targetId) }); if (text(selected.routineId)) extraResources.push({ ...resource, routineId: uuid(selected.routineId) }); if (selected.targetType === "project" && text(selected.targetId)) extraResources.push({ ...resource, projectId: String(selected.targetId) }); if (text(selected.projectId)) extraResources.push({ ...resource, projectId: String(selected.projectId) }); diff --git a/server/src/services/connection-management.ts b/server/src/services/connection-management.ts index 299cf897e13..4de417d2605 100644 --- a/server/src/services/connection-management.ts +++ b/server/src/services/connection-management.ts @@ -97,12 +97,10 @@ export function connectionManagementService(db: Db) { const companyAgents = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, resource.companyId)); companyAgents.forEach(agent => expandedTargets.add(agent.id)); } - if (["issue", "routine", "gateway"].includes(binding.targetType)) { + if (["project", "issue", "routine", "gateway"].includes(binding.targetType)) { await authorizeBindingTarget(actor, action, resource, binding, options); } - if (binding.targetType === "project") { - await authorize(actor, action, { ...resource, projectId: binding.targetId }, { ...options, expandedProfile: true, gatewayContextChecked: options.gatewayContextChecked || Boolean(resource.gatewayId) }); - } + } const linkedGateways = await db.select({ id: toolMcpGateways.id }).from(toolMcpGateways).where(and(eq(toolMcpGateways.companyId, resource.companyId), eq(toolMcpGateways.profileId, profile.id))); for (const gateway of linkedGateways) await authorizeBindingTarget(actor, action, resource, { targetType: "gateway", targetId: gateway.id }, options); @@ -250,7 +248,12 @@ export function connectionManagementService(db: Db) { options: NonNullable[3]> = {}): Promise { let resolved = { ...resource }; const checkOptions = { ...options, expandedProfile: true }; - if (binding.targetType === "issue") resolved.issueId = binding.targetId; + if (binding.targetType === "project") { + resolved.projectId = binding.targetId; + const rows = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, resource.companyId)); + checkOptions.targetAgentIds = [...new Set([...(options.targetAgentIds ?? []), ...rows.map(row => row.id)])]; + } + else if (binding.targetType === "issue") resolved.issueId = binding.targetId; else if (binding.targetType === "routine") resolved.routineId = binding.targetId; else if (binding.targetType === "gateway") { const [gateway] = await db.select().from(toolMcpGateways).where(and(eq(toolMcpGateways.id, binding.targetId), eq(toolMcpGateways.companyId, resource.companyId))); diff --git a/server/src/services/heartbeat/runtime-selection.ts b/server/src/services/heartbeat/runtime-selection.ts index 425a2fee60a..05a7b039a2f 100644 --- a/server/src/services/heartbeat/runtime-selection.ts +++ b/server/src/services/heartbeat/runtime-selection.ts @@ -971,6 +971,7 @@ export async function selectHeartbeatRuntime(db: Db, input: HeartbeatRuntimeSele nativeToolContractFingerprint: nativeToolContractFingerprintForTarget( executionTarget?.kind ?? "local", + nativeExecution!.provider.kind, ), ...(lockedProfile.sessionCheckpoint != null ? { sessionCheckpoint: lockedProfile.sessionCheckpoint } diff --git a/server/src/services/native-runtime/assigned-mcp-tools.test.ts b/server/src/services/native-runtime/assigned-mcp-tools.test.ts index 7fa7b7ccb71..21cacfd0845 100644 --- a/server/src/services/native-runtime/assigned-mcp-tools.test.ts +++ b/server/src/services/native-runtime/assigned-mcp-tools.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import type { Db } from "@paperclipai/db"; import { ToolGatewayHttpError, type ToolGatewayDescriptor, type ToolGatewayService } from "../tool-gateway.js"; import { createAssignedMcpTools, getAssignedMcpGateway, registerAssignedMcpGateway } from "./assigned-mcp-tools.js"; +import { startOpenCodeMcpBridge } from "../../vendor/paperclip-runner/testing.js"; function descriptor(name: string, risk: ToolGatewayDescriptor["risk"] = "read"): ToolGatewayDescriptor { return { name, displayName: name, description: `Use ${name}`, parametersSchema: { type: "object", properties: { query: { type: "string" } } }, pluginId: "fixture", providerType: "mcp_remote_http", risk }; @@ -18,6 +19,93 @@ describe("assigned MCP runner tools", () => { const searchName = "paperclip_search_assigned_tools"; const callName = "paperclip_call_assigned_tool"; + it.each(["page", "escaped schema"])("keeps on-demand %s results valid through the OpenCode MCP bridge", async variant => { + const schema = { type: "object", description: variant === "page" ? "x".repeat(20_000) : "\"\\\u0000🙂".repeat(30_000) }; + const f = fixture(Array.from({ length: variant === "page" ? 5 : 1 }, (_, i) => ({ + ...descriptor(`large_${i}`), parametersSchema: schema, + }))); + const assigned = await createAssignedMcpTools({ ...f, delivery: "on_demand" }); + const bridge = await startOpenCodeMcpBridge({ tools: assigned.definitions(), handler: call => assigned.execute(call) }); + let id = 0; + const search = async (args: Record) => { + const response = await fetch(bridge.url, { + method: "POST", headers: { Authorization: `Bearer ${bridge.secret}`, "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method: "tools/call", params: { name: searchName, arguments: args } }), + }); + const rpc = await response.json() as { result: { isError?: boolean; content: Array<{ text: string }> } }; + expect(rpc.result.isError).not.toBe(true); + return JSON.parse(rpc.result.content[0]!.text); + }; + try { + let page = await search({ query: "", limit: 20 }); + if (variant === "page") { + const seen = [...page.tools]; + while (page.nextOffset !== null) { + page = await search({ query: "", offset: page.nextOffset, limit: 20 }); + seen.push(...page.tools); + } + expect(seen).toHaveLength(5); + expect(seen.every(tool => JSON.stringify(tool.inputSchema) === JSON.stringify(schema))).toBe(true); + } else { + const name = page.tools[0]!.inputSchemaRef; + expect(name).toBeTypeOf("string"); + let offset: number | null = 0; + let reconstructed = ""; + do { + const chunk = await search({ query: "", schemaTool: name, schemaOffset: offset }); + reconstructed += chunk.schemaJson; + offset = chunk.nextSchemaOffset; + } while (offset !== null); + expect(JSON.parse(reconstructed)).toEqual(schema); + } + } finally { + await bridge.close(); + } + }); + + it.each([1, 741])("uses bounded on-demand delivery for %i tools even when the direct catalog fits", async count => { + const f = fixture(Array.from({ length: count }, (_, i) => descriptor(`app.operation_${i}`))); + const direct = await createAssignedMcpTools(f); + const assigned = await createAssignedMcpTools({ ...f, delivery: "on_demand" }); + const fits = vi.fn(() => true); + expect(assigned.definitions(fits).map(tool => tool.name)).toEqual([searchName, callName]); + expect(fits).toHaveBeenCalledExactlyOnceWith(assigned.definitions()); + expect(() => assigned.definitions(() => false)).toThrow("assigned_mcp_tool_catalog_capacity_exceeded"); + const page = await assigned.execute({ tool: searchName, arguments: { query: "", limit: 1 } }) as { tools: Array<{ name: string }> }; + expect(page.tools[0]!.name).toBe(direct.definitions().map(tool => String(tool.name)).sort()[0]); + await assigned.execute({ tool: callName, arguments: { name: page.tools[0]!.name, arguments: { query: "fixture" }, gatewayPublicId: "untrusted", bearerToken: "untrusted" } }); + expect(f.executeTool).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ gatewayPublicId: f.gatewayPublicId, sessionToken: f.bearerToken })); + f.listToolsForNamedGateway.mockResolvedValue([]); + await expect(assigned.execute({ tool: searchName, arguments: { query: "" } })).resolves.toEqual({ tools: [], nextOffset: null }); + }); + + it("keeps on-demand aliases pinned, mode restricted, revocable and approval gated", async () => { + const read = descriptor("mcp.first:search"); + const write = descriptor("mcp.second:search", "write"); + const f = fixture([read, write]); + const assigned = await createAssignedMcpTools({ ...f, delivery: "on_demand" }); + const page = await assigned.execute({ tool: searchName, arguments: { query: "" } }) as { tools: Array<{ name: string; description: string }> }; + expect(new Set(page.tools.map(tool => tool.name)).size).toBe(2); + const writeAlias = page.tools.find(tool => tool.description.includes(write.name))!.name; + const call = { tool: callName, arguments: { name: writeAlias, arguments: {} } }; + await expect(assigned.execute(call, "planning")).rejects.toThrow("paperclip_runner_tool_mode_denied"); + await expect(assigned.execute(call, "ask")).rejects.toThrow("paperclip_runner_tool_mode_denied"); + expect(f.executeTool).not.toHaveBeenCalled(); + const denied = new ToolGatewayHttpError(403, "Approval required", "approval_required"); + f.executeTool.mockRejectedValueOnce(denied); + await expect(assigned.execute(call)).rejects.toBe(denied); + f.listToolsForNamedGateway.mockResolvedValue([read, descriptor("newly.granted")]); + const refreshed = await assigned.execute({ tool: searchName, arguments: { query: "" } }) as { tools: Array<{ name: string }> }; + expect(refreshed.tools).toHaveLength(1); + expect(refreshed.tools[0]!.name).not.toBe(writeAlias); + // Execution still goes through the original gateway, which reauthorizes + // each call; discovery never grants an added tool to this pinned session. + const revoked = new ToolGatewayHttpError(403, "Revoked", "connection_revoked"); + f.executeTool.mockRejectedValueOnce(revoked); + await expect(assigned.execute(call)).rejects.toBe(revoked); + await expect(assigned.execute({ tool: callName, arguments: { name: "newly.granted", arguments: {} } })).rejects.toThrow("assigned_mcp_tool_unknown"); + }); + it("deduplicates repeated app tools in direct and on-demand catalogs while retaining revocation", async () => { const read = descriptor("memory.search"); const write = descriptor("memory.store", "write"); diff --git a/server/src/services/native-runtime/assigned-mcp-tools.ts b/server/src/services/native-runtime/assigned-mcp-tools.ts index 535b265d4ad..ff43b86a6dd 100644 --- a/server/src/services/native-runtime/assigned-mcp-tools.ts +++ b/server/src/services/native-runtime/assigned-mcp-tools.ts @@ -86,6 +86,8 @@ export async function createAssignedMcpTools(input: { gatewayPublicId: string; bearerToken: string; workMode?: WorkMode; + /** Bound provider grammar complexity independently of protocol byte capacity. */ + delivery?: "on_demand"; }) { const listed = await input.gateway.listToolsForNamedGateway({ gatewayPublicId: input.gatewayPublicId, @@ -103,6 +105,11 @@ export async function createAssignedMcpTools(input: { tools.set(name, descriptor); } const permits = (tool: ToolGatewayDescriptor, mode: WorkMode = input.workMode ?? "standard") => mode === "standard" || tool.risk === "read"; + // OpenCode's HTTP bridge returns at most 64K JSON characters. Leave room + // for the envelope and for JSON escaping when returning a schema chunk. + // Other harnesses retain the larger PRP page and chunk budgets. + const searchPageBytes = input.delivery === "on_demand" ? 32 * 1024 : SEARCH_PAGE_BYTES; + const schemaChunkCharacters = input.delivery === "on_demand" ? 16 * 1024 : SCHEMA_CHUNK_CHARACTERS; async function search(argumentsValue: unknown, currentWorkMode?: WorkMode) { const args = object(argumentsValue); @@ -132,7 +139,7 @@ export async function createAssignedMcpTools(input: { const selected = authorized.find(([name]) => name === args.schemaTool); if (!selected) throw new Error("assigned_mcp_tool_unknown"); const schema = JSON.stringify(selected[1].parametersSchema); - const end = schemaOffset + SCHEMA_CHUNK_CHARACTERS; + const end = schemaOffset + schemaChunkCharacters; return { name: selected[0], schemaJson: schema.slice(schemaOffset, end), nextSchemaOffset: end < schema.length ? end : null, @@ -144,13 +151,13 @@ export async function createAssignedMcpTools(input: { const page: ToolDefinition[] = []; for (const [name, tool] of matches.slice(offset, offset + limit)) { let next = definition(name, tool); - if (Buffer.byteLength(JSON.stringify([next]), "utf8") > SEARCH_PAGE_BYTES) { + if (Buffer.byteLength(JSON.stringify([next]), "utf8") > searchPageBytes) { // One large schema must not block browsing the tools after it. Expose // a reference whose schema can be fetched in bounded, reauthorized // chunks, without weakening the gateway's argument validation. next = { name, description: String(next.description).slice(0, 2000), inputSchemaRef: name }; } - if (Buffer.byteLength(JSON.stringify([...page, next]), "utf8") > SEARCH_PAGE_BYTES) { + if (Buffer.byteLength(JSON.stringify([...page, next]), "utf8") > searchPageBytes) { break; } page.push(next); @@ -161,9 +168,9 @@ export async function createAssignedMcpTools(input: { return { definitions(fits?: (definitions: ToolDefinition[]) => boolean): ToolDefinition[] { const direct = [...tools].filter(([, tool]) => permits(tool)).map(([name, tool]) => definition(name, tool)); - if (!fits || fits(direct)) return direct; + if (input.delivery !== "on_demand" && (!fits || fits(direct))) return direct; const compact = structuredClone(ON_DEMAND_TOOLS); - if (!fits(compact)) throw new Error("assigned_mcp_tool_catalog_capacity_exceeded"); + if (fits && !fits(compact)) throw new Error("assigned_mcp_tool_catalog_capacity_exceeded"); return compact; }, has(name: string): boolean { diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 39862333f8c..c0d18a93957 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -1471,6 +1471,7 @@ describe("remote provider pack manifest", () => { try { await createRunnerdBackend({ db: leaseDb(remoteExecution), execution: remoteExecution, runnerInstanceId: "bundled-image-runner", runnerIngressAuthorized: true, + ...(provider === "opencode" ? { runnerEnvironment: { PAPERCLIP_NATIVE_MCP_NAME: "assigned-fixture", PAPERCLIP_NATIVE_MCP_URL: "http://127.0.0.1/mcp/gateways/assigned-fixture", PAPERCLIP_NATIVE_MCP_TOKEN: "private-fixture" } } : {}), runnerExecutionTarget: { kind: "remote", transport: "sandbox", remoteCwd: "/workspace", environmentId: "environment", leaseId: "lease", providerKey: "daytona", effectiveCapabilities: { runnerWebSocketIngress: true }, runner: { execute, syncIn } } as never }); @@ -1478,8 +1479,13 @@ describe("remote provider pack manifest", () => { state.createBackend.mock.calls.at(-1)![1].codexTransportFactory!(); const transport = state.createTransport.mock.calls.at(-1)![0] as RunnerTransportOptions & { controlPlaneRegistration: (authority: unknown) => Promise; + environment?: NodeJS.ProcessEnv; }; expect(transport.runnerBinary).toBe(state.bundledRunnerBinary); + if (provider === "opencode") { + expect(state.createAssignedMcpTools).toHaveBeenCalledWith(expect.objectContaining({ gatewayPublicId: "assigned-fixture", bearerToken: "private-fixture", delivery: "on_demand" })); + for (const name of ["PAPERCLIP_NATIVE_MCP_NAME", "PAPERCLIP_NATIVE_MCP_URL", "PAPERCLIP_NATIVE_MCP_TOKEN"]) expect(transport.environment?.[name]).toBeUndefined(); + } if (claude) { expect(state.resolveRemoteRunnerBinary).toHaveBeenCalledWith(expect.objectContaining({ remoteCwd: "/workspace", configuredPath: undefined })); const { bundledRemoteProviderPackManifestPath } = await import("../../vendor/paperclip-runner/index.js"); @@ -7800,7 +7806,7 @@ describe("bounded stopped instruction collection", () => { describe("native warm session supervision", () => { it.each(["collect", "close-failure", "stopped", "destroyed", "missing-lease", "foreign-run", "foreign-environment", "deleted-environment", "claim-edit", "claim-add", "claim-remove", "claim-remove-entry", "claim-canonical", "claim-config", "claim-uncertain"])("composes remote warm ownership with real DB successor leases and current-run collection: %s", async ending => { const tables = await import("@paperclipai/db"); - const { eq } = await import("drizzle-orm"); + const { eq, sql } = await import("drizzle-orm"); const { randomUUID } = await import("node:crypto"); const { promisify } = await import("node:util"); const { execFile } = await import("node:child_process"); @@ -7813,7 +7819,11 @@ describe("native warm session supervision", () => { const home = await realpath(await mkdtemp(join(tmpdir(), "warm-remote-db-"))); const previousHome = process.env.PAPERCLIP_HOME; process.env.PAPERCLIP_HOME = home; - const database = await startEmbeddedPostgresTestDatabase("warm-remote-db-"); + const { resolveExternalTestDatabaseUrl } = await import("../../__tests__/helpers/external-test-database.js"); + const externalUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_TEST_DATABASE_URL"); + const database = externalUrl + ? { connectionString: externalUrl, cleanup: () => tables.closeRegisteredClients(externalUrl) } + : await startEmbeddedPostgresTestDatabase("warm-remote-db-"); const db = tables.createDb(database.connectionString); const companyId = randomUUID(), agentId = randomUUID(), userId = randomUUID(), environmentId = randomUUID(), issueId = randomUUID(); const { resolveManagedInstructionsRoot } = await import("../agent-instructions.js"); @@ -8049,6 +8059,9 @@ describe("native warm session supervision", () => { finally { const cleanupFailures: unknown[] = []; try { await closeWarmNativeSessionsForEnvironment({ environmentId, reason: "test cleanup" }); } catch (error) { cleanupFailures.push(error); } + if (externalUrl) { + try { await db.execute(sql`TRUNCATE TABLE ${tables.companies}, ${tables.environments}, ${tables.authUsers} CASCADE`); } catch (error) { cleanupFailures.push(error); } + } try { await database.cleanup(); } catch (error) { cleanupFailures.push(error); } if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome; try { @@ -11828,7 +11841,10 @@ describe("runnerd provider runtime wiring", () => { } finally {owner.mockRestore();await rm(home,{recursive:true,force:true});} }); - it("keeps assigned MCP credentials on the control plane for remote Codex", async () => { + it.each(["codex", "opencode"] as const)("keeps assigned MCP credentials on the control plane for %s", async (providerKind) => { + const selectedExecution: NativeExecutionInput = providerKind === "opencode" + ? { ...execution, provider: { kind: "opencode", model: "openrouter/deepseek/deepseek-v4.1-flash-1017", permissionMode: "allow" } } + : execution; const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT; vi.stubEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://collector.test"); const exporter = new InMemorySpanExporter(); @@ -11841,13 +11857,13 @@ describe("runnerd provider runtime wiring", () => { state.createTransport.mockClear(); state.toolAuthorityDefinitions.mockClear(); await createRunnerdBackend({ - db: leaseDb(execution), execution, runnerInstanceId: "runner-assigned-mcp", + db: leaseDb(selectedExecution), execution: selectedExecution, runnerInstanceId: "runner-assigned-mcp", runnerEnvironment: { PAPERCLIP_NATIVE_MCP_NAME: "paperclip-assigned", PAPERCLIP_NATIVE_MCP_URL: "http://127.0.0.1:3217/mcp/gateways/assigned-test", PAPERCLIP_NATIVE_MCP_TOKEN: "private-run-token", }, - runnerExecutionTarget: { + runnerExecutionTarget: providerKind === "opencode" ? undefined : { kind: "remote", transport: "sandbox", providerKey: "daytona", leaseId: "lease-assigned-mcp", remoteCwd: "/home/daytona/paperclip-workspace", runner: { execute: vi.fn() }, @@ -11855,6 +11871,7 @@ describe("runnerd provider runtime wiring", () => { }); expect(state.createAssignedMcpTools).toHaveBeenCalledWith(expect.objectContaining({ gatewayPublicId: "assigned-test", bearerToken: "private-run-token", + ...(providerKind === "opencode" ? { delivery: "on_demand" } : {}), })); expect(state.toolAuthorityDefinitions).toHaveBeenCalledWith(expect.objectContaining({ assignedMcpTools })); state.createBackend.mock.calls[0]![1].codexTransportFactory!(); diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 597c36542d5..7bea16d6e0d 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -5563,7 +5563,7 @@ function nativeSessionConfigDigest( // changes so an older thread is rotated instead of falsely resuming // without newly required tools. nativeToolContractFingerprint: - nativeToolContractFingerprintForTarget(executionTargetKind), + nativeToolContractFingerprintForTarget(executionTargetKind, execution.provider.kind), runtimeContract: nativeRuntimeContractForProvider(execution.provider), }), ) @@ -11828,10 +11828,10 @@ async function createRunnerdBackendWithinSessionClaim( if (nativeReview && !await getNativeReviewAssignment(input.db, { ...input.execution.binding, contextSnapshot: nativeReview, })) throw new Error("native_review_assignment_no_longer_available"); - // Remote Codex already sends dynamic tool calls over authenticated PRP. Keep + // Codex and OpenCode send dynamic tool calls over authenticated PRP. Keep // the assigned gateway on the control plane instead of asking the sandbox to // reach the host's HTTP origin (which may be private or loopback-only). - const relayAssignedMcp = Boolean(pluginTask) || (remoteTarget !== null && input.execution.provider.kind === "codex") || input.execution.schema === "paperclip.native-execution-input.v6"; + const relayAssignedMcp = Boolean(pluginTask) || input.execution.provider.kind === "opencode" || (remoteTarget !== null && input.execution.provider.kind === "codex") || input.execution.schema === "paperclip.native-execution-input.v6"; const assignedMcpUrl = input.runnerEnvironment?.PAPERCLIP_NATIVE_MCP_URL; const assignedMcpToken = input.runnerEnvironment?.PAPERCLIP_NATIVE_MCP_TOKEN; const assignedMcpName = input.runnerEnvironment?.PAPERCLIP_NATIVE_MCP_NAME; @@ -11853,6 +11853,7 @@ async function createRunnerdBackendWithinSessionClaim( gatewayPublicId: assignedGatewayPublicId!, bearerToken: assignedMcpToken, workMode: input.execution.task.workMode, + ...(input.execution.provider.kind === "opencode" ? { delivery: "on_demand" as const } : {}), })) : undefined), ]); diff --git a/server/src/services/native-runtime/native-session-resume.test.ts b/server/src/services/native-runtime/native-session-resume.test.ts index bacf0ab22e9..c0f274b08e4 100644 --- a/server/src/services/native-runtime/native-session-resume.test.ts +++ b/server/src/services/native-runtime/native-session-resume.test.ts @@ -32,7 +32,7 @@ import { } from "@paperclipai/paperclip-runner"; import { getEmbeddedPostgresTestSupport, - startEmbeddedPostgresTestDatabase, + startEmbeddedPostgresTestDatabase as startEmbeddedDatabase, } from "../../__tests__/helpers/embedded-postgres.js"; import { canonicalNativeRuntimeContextDigest, @@ -72,6 +72,20 @@ import { selectNativeSessionResumeRun, } from "./native-session-resume.js"; import { nativeRuntimeContextFixture } from "./runtime-context.test-fixture.js"; +import { resolveExternalTestDatabaseUrl } from "../../__tests__/helpers/external-test-database.js"; + +const externalTestDatabaseUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_TEST_DATABASE_URL"); + +async function startEmbeddedPostgresTestDatabase(prefix: string) { + const externalUrl = externalTestDatabaseUrl; + if (!externalUrl) return startEmbeddedDatabase(prefix); + const { closeRegisteredClients } = await import("@paperclipai/db"); + return { connectionString: externalUrl, cleanup: async () => { + const db = createDb(externalUrl); + try { await db.execute(sql`TRUNCATE TABLE companies CASCADE`); } + finally { await closeRegisteredClients(externalUrl); } + } }; +} const companyId = "10000000-0000-4000-8000-000000000001"; const issueId = "20000000-0000-4000-8000-000000000002"; @@ -283,12 +297,13 @@ it("wires exact-session recovery and guarded selected identity into heartbeat pe }); const embeddedSupport = await getEmbeddedPostgresTestSupport(); +const databaseSupported = Boolean(externalTestDatabaseUrl) || embeddedSupport.supported; const recoveryFakeCodex = resolve( import.meta.dirname, "../../../../packages/paperclip-runner/test/fixtures/fake-final-burst-codex-app-server.mjs", ); -(embeddedSupport.supported && existsSync(defaultCapabilityRunnerdBinary()) +(databaseSupported && existsSync(defaultCapabilityRunnerdBinary()) ? it : it.skip)( "keeps actual normalized driver events separate from raw maintenance receipts", @@ -520,7 +535,7 @@ const recoveryFakeCodex = resolve( 30_000, ); -(embeddedSupport.supported && +(databaseSupported && existsSync(defaultCapabilityRunnerdBinary()) && existsSync(recoveryFakeCodex) ? it @@ -1062,7 +1077,7 @@ const recoveryFakeCodex = resolve( 60_000, ); -(embeddedSupport.supported ? describe : describe.skip)( +(databaseSupported ? describe : describe.skip)( "native session recovery database orchestration", () => { it("uses scoped latest checkpoints, preserves progress barriers, and guards bootstrap persistence after the row lock", async () => { @@ -1825,6 +1840,23 @@ describe("rebindNativeSessionCheckpoint", () => { + it.each(["local", "remote"] as const)("refreshes only OpenCode's former direct MCP catalog on %s", target => { + const legacy = nativeToolContractFingerprintForTarget(target); + expect(nativeToolContractFingerprintForTarget(target, "codex")).toBe(legacy); + expect(nativeToolContractFingerprintForTarget(target, "acpx")).toBe(legacy); + const current = nativeToolContractFingerprintForTarget(target, "opencode"); + expect(current).not.toBe(legacy); + const shortenedCatalog = target === "local" + ? "sha256:2ae35faccbb63cfe009d94170ef1ef13f0d57e120af6160540598a3ec72001d7" + : "sha256:ee5285ca5118b8468c0afd1df4ef9c05d85b0934e882bccd0b193dc8722dbb7a"; + expect(current).not.toBe(shortenedCatalog); + const currentExecution = { ...execution(currentRunId), provider: { kind: "opencode", model: "openrouter/deepseek/deepseek-v4.1-flash-1017", permissionMode: "allow" }, session: { ...execution(currentRunId).session, driverKind: "opencode_server" } } as NativeExecutionInput; + const previousExecution = { ...execution(previousRunId), provider: currentExecution.provider, session: currentExecution.session }; + expect(rebindNativeSessionCheckpoint({ previousRun: previousRun({ nativeToolContractFingerprint: shortenedCatalog, nativeExecutionInput: previousExecution }), currentExecution, executionTargetKind: target })).toBeNull(); + expect(rebindNativeSessionCheckpoint({ previousRun: previousRun({ nativeToolContractFingerprint: legacy, nativeExecutionInput: previousExecution }), currentExecution, executionTargetKind: target })).toBeNull(); + expect(rebindNativeSessionCheckpoint({ previousRun: previousRun({ nativeToolContractFingerprint: current, nativeExecutionInput: previousExecution }), currentExecution, executionTargetKind: target })).not.toBeNull(); + }); + it("binds the provider tool catalog to the local or remote execution target", () => { const remoteFingerprint = nativeToolContractFingerprintForTarget("remote"); expect(remoteFingerprint).not.toBe(NATIVE_TOOL_CONTRACT_FINGERPRINT); diff --git a/server/src/services/native-runtime/native-session-resume.ts b/server/src/services/native-runtime/native-session-resume.ts index 3243de9980e..e3ea827cbdf 100644 --- a/server/src/services/native-runtime/native-session-resume.ts +++ b/server/src/services/native-runtime/native-session-resume.ts @@ -29,6 +29,7 @@ export function nativeRuntimeContractForProvider( */ export function nativeToolContractFingerprintForTarget( executionTargetKind: NativeToolExecutionTargetKind, + providerKind?: NativeExecutionInput["provider"]["kind"], ): string { return `sha256:${createHash("sha256") .update( @@ -47,6 +48,9 @@ export function nativeToolContractFingerprintForTarget( structuredHumanInput: "always_advertised_run_issue_agent_binding_gated_current_task_description.v2", semanticCompletion: "finish_accessible_deliverable_evidence.v4", + ...(providerKind === "opencode" + ? { opencodeAssignedMcpTools: "server_gateway_prp_on_demand_exact_names.v2" } + : {}), connectorTools: "assigned_resources_and_pinned_skill_bundle.v1", chatTaskTools: "durable_task_binding_and_accepted_origin.v1", githubResponses: "bound_working_comment_progress_and_final.v1", @@ -482,7 +486,7 @@ export function rebindNativeSessionCheckpoint(input: { const previousProfile = record(input.previousRun.runnerProfileJson); if ( previousProfile.nativeToolContractFingerprint !== - nativeToolContractFingerprintForTarget(input.executionTargetKind ?? "local") + nativeToolContractFingerprintForTarget(input.executionTargetKind ?? "local", input.currentExecution.provider.kind) ) { return null; } diff --git a/server/src/vendor/paperclip-runner/testing.ts b/server/src/vendor/paperclip-runner/testing.ts index e889df919de..06a85b6f36d 100644 --- a/server/src/vendor/paperclip-runner/testing.ts +++ b/server/src/vendor/paperclip-runner/testing.ts @@ -26,6 +26,10 @@ const sourceUrl = new URL( ); const runnerTesting = await import(sourceUrl.href) as RunnerTestingModule; +export const startOpenCodeMcpBridge: + RunnerTestingModule["startOpenCodeMcpBridge"] = + runnerTesting.startOpenCodeMcpBridge; + export const CAPABILITY_HIGH_RISK_SEMANTIC_VECTORS: RunnerTestingModule["CAPABILITY_HIGH_RISK_SEMANTIC_VECTORS"] = runnerTesting.CAPABILITY_HIGH_RISK_SEMANTIC_VECTORS; diff --git a/ui/src/components/Layout.production.tsx b/ui/src/components/Layout.production.tsx index 56afb99fc06..cdb984d85d7 100644 --- a/ui/src/components/Layout.production.tsx +++ b/ui/src/components/Layout.production.tsx @@ -45,6 +45,7 @@ import { useSidebar } from "../context/SidebarContext"; import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"; import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory"; import { useMobileNavVisibility } from "../hooks/useMobileNavVisibility"; +import { useMobileVisualViewport } from "../hooks/useMobileVisualViewport"; import { healthApi } from "../api/health"; import { resolveArchivedCompanyBounce, @@ -178,6 +179,7 @@ export function Layout() { const scrollMemory = useRef(new NavigationScrollMemory()); const activeScrollKey = useRef(location.key); const mobileNavVisible = useMobileNavVisibility(isMobile, location.pathname); + const mobileViewportRef = useMobileVisualViewport(isMobile); const [shortcutsOpen, setShortcutsOpen] = useState(false); const matchedCompany = useMemo(() => { if (!companyPrefix) return null; @@ -588,8 +590,10 @@ export function Layout() { return (
@@ -663,12 +667,13 @@ export function Layout() {
) : null}
-
+
(location.key); const mobileNavVisible = useMobileNavVisibility(isMobile, location.pathname); + const mobileViewportRef = useMobileVisualViewport(isMobile); const [shortcutsOpen, setShortcutsOpen] = useState(false); const matchedCompany = useMemo(() => { if (!companyPrefix) return null; @@ -574,8 +576,10 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) { return (
-
+
{isMobile && sidebarOpen && (