Merge current Runner compatibility and complete profile target checks

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* codex/agent-connection-api-parity:
  fix(connections): authorize all agents affected by project profile bindings
  perf(runner): keep event journal writes off the HTTP thread (#17745)
  Bound native OpenCode assigned-tool catalog projection (#17740)
  fix(ui): keep iPhone task chat stable while typing and streaming (#17232)
This commit is contained in:
DottaandPaperclip committed 2026-10-26 11:28:43 -05:00
commit 7687c2dfab
58 files changed
+1254 -556

No files matched your search

+6 -2
View File
@@ -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
@@ -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
+19
View File
@@ -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
+12
View File
@@ -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
+10
View File
@@ -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
+2 -2
View File
@@ -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": {
@@ -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
@@ -47,7 +47,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option<AcpxReleaseProfile> {
agent_runtime_package: None,
agent_runtime_version: None,
command_digest:
"sha256:ed07dcd9487c5e1e2bd863a25f7297e5c98316abc51629e9650cd2a9ce29faf9",
"sha256:ec8d1d9e570403fb1b1324b2357575e9226b8a2f9eefcc8bf573b59e36653d52",
requires_provider_policy: true,
},
"claude" => AcpxReleaseProfile {
@@ -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",
@@ -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"],
@@ -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<void>(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<void>(resolveListen => http.listen(0, "127.0.0.1", resolveListen));
client = (await authenticate(core, core.issueBootstrapTicket()))!;
const event = semanticInputEvent();
const payload = event.payload as Record<string, unknown>;
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<void>(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<string, unknown>;
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<void>(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;
@@ -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<void> {
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 &&
@@ -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}`);
}
}
}
@@ -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<void> {
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;
});
}
}
@@ -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 () => {
@@ -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": {
@@ -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<Record<string, readonly HistoricalAcpxProfileVersion[]>> = {
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],
};
@@ -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");
@@ -108,7 +108,7 @@ function normalizeTools(
const tools: OpenCodeMcpToolDefinition[] = [];
const seen = new Set<string>();
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;
@@ -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-"));
@@ -1026,7 +1026,9 @@ class OpenCodeHarnessSession implements HarnessSession {
callId: string;
arguments: unknown;
}): Promise<unknown> {
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");
@@ -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 {}; }
@@ -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 {}; }
@@ -726,6 +726,7 @@ async function releaseRunnerProcessOwnership(input: {
((settlement: "settled" | "unsettled") => Promise<void> | void) | null;
forceKill: () => Promise<void> | void;
release: (() => Promise<void> | void) | null;
retireController?: () => Promise<void>;
}): Promise<void> {
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<void>;
#resolveClosedSignal!: () => void;
#closePromise: Promise<void> | null = null;
#detachPromise: Promise<void> | null = null;
#startupOperation: Promise<unknown> | null = null;
#attachmentOperation: Promise<void> | null = null;
#startupDrainTimedOut = false;
@@ -4466,7 +4471,8 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
close(reason?: string): Promise<void> {
// 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<void> {
detachControllerForRestart(): Promise<void> {
this.#detachPromise ??= this.#detachControllerOnce();
return this.#detachPromise;
}
async #stopAndDrainAuthority(deadline: number): Promise<void> {
const core = this.#core;
if (!core) return;
await core.stop();
let timer: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
core.drainPendingConnectionProcessing(),
new Promise<never>((_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<void> {
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();
@@ -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<void>((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<void>(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<void>(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<void> | undefined;
const closeSteps: string[] = [];
let stopSpy: ReturnType<typeof vi.spyOn> | 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<void> | 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<void>(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<void>(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);
@@ -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<typeof setTimeout> | 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<never>((_, 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,
);
+1
View File
@@ -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";
@@ -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",
@@ -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"
}
}
@@ -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 });
@@ -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) });
+8 -5
View File
@@ -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<Parameters<typeof authorize>[3]> = {}): Promise<ConnectionManagementContext> {
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)));
@@ -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 }
@@ -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<string, unknown>) => {
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");
@@ -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 {
@@ -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<unknown>;
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!();
@@ -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),
]);
@@ -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);
@@ -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;
}
+4
View File
@@ -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;
+9 -4
View File
@@ -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<string>(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 (
<ChatSetupSidebarProvider>
<div
ref={mobileViewportRef}
data-mobile={isMobile ? "true" : undefined}
className={cn(
"bg-background text-foreground pt-(--sz-safe-top)",
"paperclip-app-shell bg-background text-foreground pt-(--sz-safe-top)",
// overflow-x-clip on mobile keeps a stray wide descendant from making the
// whole viewport scroll horizontally. clip (not hidden) leaves overflow-y
// computed as visible, so native body scroll + the sticky breadcrumb keep
@@ -609,7 +613,7 @@ export function Layout() {
<DevRestartBanner devServer={health?.devServer} />
<div
className={cn(
"min-h-0 flex-1",
"app-main-row min-h-0 flex-1",
isMobile ? "w-full" : "flex overflow-clip",
)}
>
@@ -663,12 +667,13 @@ export function Layout() {
<div
className={cn(
"flex min-w-0 flex-col",
"app-content-column flex min-w-0 flex-col",
isMobile ? "w-full" : "h-full flex-1",
)}
>
<div
className={cn(
"app-breadcrumb",
isMobile &&
"sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85",
)}
@@ -681,7 +686,7 @@ export function Layout() {
</div>
) : null}
</div>
<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>
<div className={cn("app-content-row", isMobile ? "block" : "flex flex-1 min-h-0")}>
<main
id="main-content"
ref={mainContentRef}
+9 -3
View File
@@ -40,6 +40,7 @@ import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled";
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
import { useMobileNavVisibility } from "../hooks/useMobileNavVisibility";
import { useMobileVisualViewport } from "../hooks/useMobileVisualViewport";
import { healthApi } from "../api/health";
import { resolveArchivedCompanyBounce, shouldSyncCompanySelectionFromRoute } from "../lib/company-selection";
import { useOptionalToastActions } from "../context/ToastContext";
@@ -143,6 +144,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
const scrollMemory = useRef(new NavigationScrollMemory());
const activeScrollKey = useRef<string>(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 (
<ChatSetupSidebarProvider>
<div
ref={mobileViewportRef}
data-mobile={isMobile ? "true" : undefined}
className={cn(
"bg-background text-foreground pt-(--sz-safe-top)",
"paperclip-app-shell bg-background text-foreground pt-(--sz-safe-top)",
// overflow-x-clip on mobile keeps a stray wide descendant from making the
// whole viewport scroll horizontally. clip (not hidden) leaves overflow-y
// computed as visible, so native body scroll + the sticky breadcrumb keep
@@ -591,7 +595,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
</a>
<WorktreeBanner />
<DevRestartBanner devServer={health?.devServer} />
<div className={cn("min-h-0 flex-1", isMobile ? "w-full" : "flex overflow-clip")}>
<div className={cn("app-main-row min-h-0 flex-1", isMobile ? "w-full" : "flex overflow-clip")}>
{isMobile && sidebarOpen && (
<button
type="button"
@@ -656,9 +660,10 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
</SecondarySidebar>
) : null}
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
<div className={cn("app-content-column flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
<div
className={cn(
"app-breadcrumb",
!isMobile && useStreamlinedTaskDetailShell && "hidden",
isMobile && "sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85",
)}
@@ -672,6 +677,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
) : null}
</div>
<div className={cn(
"app-content-row",
isMobile ? "block" : "flex flex-1 min-h-0",
!isMobile && useStreamlinedTaskDetailShell && "streamlined-task-detail-surface",
)}>
+9 -13
View File
@@ -3535,7 +3535,7 @@ describe("TaskChatThread composer alignment", () => {
const dock = container
.querySelector('[data-testid="mock-editor"]')
?.closest("div.sticky") as HTMLElement | null;
?.closest('[data-testid="task-chat-composer-dock"]') as HTMLElement | null;
expect(dock?.className).toContain("w-full");
expect(dock?.className).toContain("max-w-(--tc-shell-max-w)");
@@ -4319,26 +4319,22 @@ describe("TaskChatThread Paperclip Runner queue", () => {
});
});
describe("TaskChatThread mobile composer dock (PAP-495)", () => {
it("pins the composer to the nav-aware bottom offset so its action row clears the auto-hiding bottom nav", () => {
describe("TaskChatThread mobile composer dock", () => {
it("scrolls messages independently of the composer on mobile", () => {
sidebarState.isMobile = true;
renderThread({
comments: [],
comments: createLongThreadComments(),
onAdd: async () => {},
draftKey: "task-chat-draft:issue-mobile",
});
const dock = container
.querySelector('[data-testid="mock-editor"]')
?.closest("div.sticky") as HTMLElement | null;
const dock = container.querySelector('[data-testid="task-chat-composer-dock"]');
const scroller = container.querySelector('[data-testid="task-chat-scroller"]');
expect(dock).not.toBeNull();
// Bottom offset comes from --tc-composer-bottom (Layout raises it to the nav
// height while the nav is on screen) — NOT the raw safe-area dock, which is
// what let the nav occlude the action row before PAP-495.
expect(dock?.className).toContain("bottom-(--tc-composer-bottom)");
expect(dock?.className).not.toContain("bottom-(--sz-calc-8)");
expect(scroller).not.toBeNull();
expect(scroller?.textContent).toContain("Thread message 1");
expect(scroller?.contains(dock)).toBe(false);
});
});
+4 -15
View File
@@ -110,7 +110,6 @@ import {
useRunnerGoalControl,
} from "@/components/task-chat/RunnerGoalWidget";
import { TaskChatQueuedMessages } from "@/components/task-chat/TaskChatQueuedMessages";
import { TaskChatWindowScroll } from "@/components/task-chat/useWindowAutoFollow";
import { useSidebar } from "@/context/SidebarContext";
import { useStreamlinedUiEnabled } from "@/hooks/useStreamlinedUiEnabled";
import { useDismissedTaskQuestions } from "@/hooks/useDismissedTaskQuestions";
@@ -3040,25 +3039,19 @@ export function TaskChatThread(props: TaskChatThreadProps) {
<TaskChatRequestHistoryContext.Provider value={issueId ?? "unknown"}>
<TaskChatExpansionState.Provider value={expansionState.current}>
<TaskChatScrollReady.Provider value={!historyPending || historyWaitExpired}>
<TaskChatWindowScroll
contentKey={isMobile ? autoFollowContentKey : 0}
enabled={isMobile && historyRevealed}
/>
<TaskChatPresentationProvider
mode={streamlinedUiEnabled ? "streamlined" : "production"}
>
<div
className={cn(
"flex flex-col",
!isMobile && "min-h-0 flex-1",
"flex min-h-0 flex-1 flex-col",
isMobile && !historyRevealed && "task-chat-history-pending",
)}
data-testid="task-chat-thread"
>
<div
className={cn(
"relative flex flex-col",
(!isMobile || !historyRevealed) && "min-h-0 flex-1",
"relative flex min-h-0 flex-1 flex-col",
)}
aria-busy={!historyRevealed}
>
@@ -3093,8 +3086,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
<div
data-testid="task-chat-history-content"
className={cn(
"flex flex-col",
!isMobile && "min-h-0 flex-1",
"flex min-h-0 flex-1 flex-col",
!historyRevealed && "invisible",
)}
inert={!historyRevealed}
@@ -3103,9 +3095,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
<>
{items.length === 0 && !tailRunId && !optimisticRunnerStartup ? (
<div
className={
isMobile ? undefined : "min-h-0 flex-1 overflow-y-auto"
}
className="min-h-0 flex-1 overflow-y-auto"
>
{threadHeaderWithBlockers ? (
<div
@@ -3241,7 +3231,6 @@ export function TaskChatThread(props: TaskChatThreadProps) {
}
contentKey={`${autoFollowContentKey}:${historyRevealed}`}
className={isMobile ? undefined : "pt-3"}
scroll={!isMobile}
/>
)}
</>
@@ -856,7 +856,7 @@ describe("TaskChatComposer", () => {
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" mobile />);
expect(editable().dataset.contentClassName).toContain("min-h-(--sz-48px)");
expect(editable().dataset.contentClassName).toContain("max-h-(--sz-28dvh)");
expect(editable().dataset.contentClassName).toContain("max-h-(--tc-composer-editor-max-height)");
});
it("submits the trimmed body on Cmd+Enter and clears the draft", async () => {
@@ -1375,7 +1375,7 @@ export function TaskChatComposer({
className={cn(disabled && "opacity-60")}
contentClassName={
mobile
? "max-h-(--sz-28dvh) min-h-(--sz-48px) overflow-y-auto px-1 py-1 text-base scrollbar-auto-hide"
? "max-h-(--tc-composer-editor-max-height) min-h-(--sz-48px) overflow-y-auto px-1 py-1 text-base scrollbar-auto-hide"
: "max-h-(--sz-28dvh) min-h-(--sz-48px) overflow-y-auto px-1 py-1 text-sm scrollbar-auto-hide"
}
/>
@@ -8,7 +8,7 @@ interface TaskChatComposerDockProps {
concealed?: boolean;
}
/** The task thread and its Storybook phone previews share the same composer spacing. */
/** A normal flex footer beside the message scroller, on desktop and mobile. */
export function TaskChatComposerDock({ children, mobile, streamlined, concealed = false }: TaskChatComposerDockProps) {
return <div
data-testid="task-chat-composer-dock"
@@ -16,14 +16,11 @@ export function TaskChatComposerDock({ children, mobile, streamlined, concealed
inert={concealed}
aria-hidden={concealed || undefined}
className={cn(
"sticky flex max-w-(--tc-shell-max-w) flex-col gap-2",
"flex max-w-(--tc-shell-max-w) shrink-0 flex-col gap-2",
concealed && "invisible",
mobile
? cn(
"mobile-nav-composer bottom-(--tc-composer-bottom) z-20 w-auto px-1 pb-1",
streamlined ? "-mx-2" : "mx-2",
)
: "bottom-0 z-10 mx-auto w-full px-1 pb-1 md:px-4 md:pb-2",
? cn("w-auto px-1 pb-1", streamlined ? "-mx-2" : "mx-2")
: "mx-auto w-full px-1 pb-1 md:px-4 md:pb-2",
streamlined && "md:px-0 md:pb-0",
(!streamlined || mobile) &&
"bg-background/80 pt-1 backdrop-blur supports-[backdrop-filter]:bg-background/60 dark:bg-transparent dark:backdrop-blur-none dark:supports-[backdrop-filter]:bg-transparent",
@@ -435,7 +435,7 @@ export function TaskChatThreadView({
// Cheap content signature so streaming growth (text lengthening without the
// item count changing) still advances the auto-follow key. Shared by the
// desktop scroller above and the mobile window-scroll follow (TaskChatThread).
// shared message scroller on desktop and mobile (TaskChatThread).
function signatureOf(it: TaskChatItem): number {
if (it.kind === "message") return it.text.length + (it.attachedTurn ? 1 : 0);
if (it.kind === "thinking") return it.lines.reduce((n, l) => n + l.length, 0);
@@ -125,6 +125,36 @@ describe("TaskMessageScroller", () => {
vi.unstubAllGlobals();
});
it("does not measure historical rows on each streaming update while following the bottom", () => {
let initialized = false;
let rowMeasurements = 0;
const update = (tick: number) => flushSync(() => root.render(
<TaskMessageScroller contentKey={tick}>
<div ref={(node) => {
if (node && !initialized) {
fakeGeometry(node.parentElement!, { scrollHeight: 20000, clientHeight: 400 });
initialized = true;
}
}}>
{Array.from({ length: 200 }, (_, index) => <div key={index} data-thread-anchor={`history-${index}`} ref={(node) => {
if (node) node.getBoundingClientRect = () => {
rowMeasurements++;
const top = index * 100 - scroller().scrollTop;
return { top, bottom: top + 100, height: 100 } as DOMRect;
};
}}>History {index}</div>)}
<p>Live response {tick}</p>
</div>
</TaskMessageScroller>,
));
update(0);
rowMeasurements = 0;
for (let tick = 1; tick <= 30; tick++) update(tick);
expect(container.textContent).toContain("Live response 30");
expect(scroller().scrollTop).toBe(20000);
expect(rowMeasurements).toBe(0);
});
it("applies same-task hash changes and restores each history entry without remounting", async () => {
let initialized = false;
function navigate(key: string, hash: string, restore = false) {
@@ -178,6 +208,42 @@ describe("TaskMessageScroller", () => {
expect(scroller().scrollTop).toBe(150);
});
it("restores bottom-follow on Back after the viewport and message heights change", () => {
const configured = new WeakSet<HTMLElement>();
function mount(restore: boolean, scrollHeight: number, clientHeight: number) {
flushSync(() => root.render(
<TaskChatScrollNavigation.Provider value={{ key: "rotated-bottom-entry", hash: "", restore }}>
<TaskMessageScroller contentKey={scrollHeight}>
<div ref={(node) => {
const viewport = node?.parentElement;
if (viewport && !configured.has(viewport)) {
fakeGeometry(viewport, { scrollHeight, clientHeight });
let top = 0;
Object.defineProperty(viewport, "scrollTop", {
configurable: true,
get: () => top,
set: (value: number) => {
top = Math.max(0, Math.min(value, viewport.scrollHeight - viewport.clientHeight));
},
});
configured.add(viewport);
}
}}>Latest response</div>
</TaskMessageScroller>
</TaskChatScrollNavigation.Provider>,
));
}
mount(false, 1000, 400);
flushSync(() => root.render(null));
// Rotation changed wrapping and the available message viewport while away.
mount(true, 2000, 500);
expect(scroller().scrollTop).toBe(1500);
expect(pill()).toBeNull();
Object.defineProperty(scroller(), "scrollHeight", { value: 2400, configurable: true });
mount(true, 2400, 500);
expect(scroller().scrollTop).toBe(1900);
});
it("renders children inside the scroll container, pill hidden, scrolled to bottom on mount", () => {
render();
const el = scroller();
@@ -116,12 +116,17 @@ export function TaskMessageScroller({ children, contentKey, className }: TaskMes
return true;
}, [scrollToBottom]);
const rememberAnchor = useCallback(() => {
const rememberAnchor = useCallback((capture = true) => {
const el = ref.current;
if (!el || el.clientHeight === 0) return;
const rect = el.getBoundingClientRect();
anchorRef.current = readThreadScrollAnchor(el, rect.top, rect.bottom);
if (initialPositionApplied.current) navigation.remember(el.scrollTop, anchorRef.current);
if (pinnedRef.current) {
// Following the bottom needs only an offset, not a scan of every old row.
anchorRef.current = null;
} else if (capture) {
const rect = el.getBoundingClientRect();
anchorRef.current = readThreadScrollAnchor(el, rect.top, rect.bottom);
}
if (initialPositionApplied.current) navigation.remember(el.scrollTop, anchorRef.current, pinnedRef.current);
}, [navigation.key, navigation.hash, navigation.ready]);
const reconcileContent = useCallback(() => {
@@ -140,18 +145,20 @@ export function TaskMessageScroller({ children, contentKey, className }: TaskMes
const delta = threadScrollAnchorDelta(el, anchorRef.current, el.getBoundingClientRect().top);
if (delta) el.scrollTop += delta;
}
rememberAnchor();
// Compensation preserves the existing reading anchor. Recapture only when
// the reader scrolls or navigation selects a different position.
rememberAnchor(false);
}, [rememberAnchor, scrollToBottom, hidePill]);
const handleScroll = useCallback(() => {
if (!ref.current?.clientHeight) return;
rememberAnchor();
showScrollbarWhileScrolling();
// A growing composer shrinks this viewport. Some browsers dispatch the
// resulting scroll event before ResizeObserver, so preserve the previous
// pinned state here instead of mistaking the layout change for a user
// scroll away from the bottom.
if (followViewportResize()) {
rememberAnchor(false);
hidePill();
return;
}
@@ -164,9 +171,11 @@ export function TaskMessageScroller({ children, contentKey, className }: TaskMes
pinnedRef.current = true;
hidePill();
}
rememberAnchor();
return;
}
pinnedRef.current = pinned;
rememberAnchor();
if (pinned) hidePill();
else showPill();
}, [
@@ -16,7 +16,7 @@ export function taskChatScrollEntry(location: { key: string; hash: string; pathn
// Scoped to browser-history entries, not issues: opening the same task from a
// new Inbox click starts at latest, while Back restores the previous reading.
const positions = new Map<string, { top: number; anchor: ThreadScrollAnchor | null }>();
const positions = new Map<string, { top: number; anchor: ThreadScrollAnchor | null; followBottom: boolean }>();
export function useTaskChatScrollNavigation() {
const navigation = useContext(TaskChatScrollNavigation);
@@ -32,6 +32,9 @@ export function useTaskChatScrollNavigation() {
if (!navigation) return null;
if (navigation.restore && positionKey && positions.has(positionKey)) {
const saved = positions.get(positionKey)!;
// A saved pixel offset goes stale when rotation changes text wrapping.
// Bottom-follow needs no row measurements, even across navigation.
if (saved.followBottom) return Math.max(0, root.scrollHeight - root.clientHeight);
if (saved.anchor && [...root.querySelectorAll<HTMLElement>("[data-thread-anchor]")].some((row) => row.dataset.threadAnchor === saved.anchor?.id)) {
return scrollTop + threadScrollAnchorDelta(root, saved.anchor, viewportTop);
}
@@ -45,9 +48,9 @@ export function useTaskChatScrollNavigation() {
}
return null;
},
remember(top: number, anchor: ThreadScrollAnchor | null) {
remember(top: number, anchor: ThreadScrollAnchor | null, followBottom: boolean) {
if (!positionKey || !ready) return;
positions.set(positionKey, { top, anchor });
positions.set(positionKey, { top, anchor, followBottom });
if (positions.size > 100) positions.delete(positions.keys().next().value!);
},
};
@@ -1,263 +0,0 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useWindowAutoFollow } from "./useWindowAutoFollow";
import { TaskChatScrollNavigation } from "./scroll-navigation";
function Host({ contentKey, enabled }: { contentKey: unknown; enabled: boolean }) {
useWindowAutoFollow(contentKey, enabled);
return <div data-testid="task-chat-thread">thread</div>;
}
/**
* jsdom has no layout: document scroll geometry is faked on the scrolling
* element, window.scrollTo is stubbed to record calls and update scrollY.
*/
describe("useWindowAutoFollow", () => {
let container: HTMLDivElement;
let root: Root;
let scrollToCalls: number[];
function fakeWindowGeometry({ scrollHeight = 2000, innerHeight = 800 } = {}) {
const el = document.scrollingElement ?? document.documentElement;
let currentScrollHeight = scrollHeight;
Object.defineProperty(el, "scrollHeight", {
get: () => currentScrollHeight,
configurable: true,
});
Object.defineProperty(window, "innerHeight", { value: innerHeight, configurable: true });
return {
setScrollHeight(value: number) {
currentScrollHeight = value;
},
};
}
function setWindowScrollY(y: number) {
Object.defineProperty(window, "scrollY", { value: y, configurable: true });
}
function render(contentKey: unknown, enabled = true) {
flushSync(() => {
root.render(<Host contentKey={contentKey} enabled={enabled} />);
});
}
/** Fire a window scroll event and flush the continuous-priority update. */
function scrollWindowTo(y: number) {
setWindowScrollY(y);
window.dispatchEvent(new Event("scroll"));
return new Promise<void>((resolve) => {
setTimeout(resolve);
});
}
/** Wait out the mount effect's rAF re-follow so it can't leak into asserts. */
function flushRaf() {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => setTimeout(resolve));
});
}
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
scrollToCalls = [];
fakeWindowGeometry();
setWindowScrollY(0);
vi.stubGlobal("scrollTo", (options?: ScrollToOptions | number) => {
const top = typeof options === "number" ? options : (options?.top ?? 0);
scrollToCalls.push(top);
// Mirror the browser: a follow to the bottom leaves the window pinned.
setWindowScrollY(Math.max(0, top - window.innerHeight));
});
});
afterEach(() => {
flushSync(() => root.unmount());
container.remove();
vi.unstubAllGlobals();
});
it("reapplies same-task targets and POP positions on mobile without remounting", async () => {
vi.stubGlobal("scrollTo", (options: ScrollToOptions) => setWindowScrollY(Math.min(1200, options.top ?? 0)));
function MobileThread() {
useWindowAutoFollow("unchanged", true);
return <div data-testid="task-chat-thread">{[100, 500].map((top, index) => (
<div key={index} id={`mobile-comment-${index}`} data-thread-anchor={`mobile-comment-${index}`} ref={(node) => {
if (node) node.getBoundingClientRect = () => ({ top: top - window.scrollY, bottom: top + 100 - window.scrollY, height: 100 } as DOMRect);
}}>Comment {index}</div>
))}</div>;
}
const navigate = (key: string, hash: string, restore = false) => flushSync(() => root.render(
<TaskChatScrollNavigation.Provider value={{ key, hash, restore }}><MobileThread /></TaskChatScrollNavigation.Provider>,
));
navigate("mobile-entry-one", "#mobile-comment-0");
const thread = container.firstElementChild;
expect(window.scrollY).toBe(100);
await scrollWindowTo(150);
navigate("mobile-entry-two", "#mobile-comment-1");
expect(container.firstElementChild).toBe(thread);
expect(window.scrollY).toBe(500);
navigate("mobile-entry-one", "#mobile-comment-0", true);
expect(window.scrollY).toBe(150);
navigate("mobile-entry-one", "#mobile-comment-1", true);
expect(window.scrollY).toBe(500);
});
it("owns browser restoration only while the mobile thread is enabled", () => {
window.history.scrollRestoration = "auto";
render(0);
expect(window.history.scrollRestoration).toBe("manual");
render(0, false);
expect(window.history.scrollRestoration).toBe("auto");
});
it("scrolls the window to the bottom on mount", () => {
render(0);
expect(scrollToCalls).toContain(2000);
});
it("follows content growth while pinned to the bottom", async () => {
render(0);
await flushRaf();
await scrollWindowTo(1200); // pinned: 2000 - 1200 - 800 = 0
scrollToCalls = [];
render(1);
expect(scrollToCalls).toContain(2000);
});
it("holds position when the user has scrolled up", async () => {
render(0);
await flushRaf();
await scrollWindowTo(100); // far from the bottom
scrollToCalls = [];
render(1);
expect(scrollToCalls).toHaveLength(0);
});
it("follows document growth while pinned on mobile", async () => {
let triggerResize = () => {};
vi.stubGlobal(
"ResizeObserver",
class {
constructor(callback: ResizeObserverCallback) {
triggerResize = () =>
callback([], this as unknown as ResizeObserver);
}
observe() {}
disconnect() {}
},
);
const geometry = fakeWindowGeometry();
render(0);
await flushRaf();
await scrollWindowTo(1200);
scrollToCalls = [];
geometry.setScrollHeight(2300);
triggerResize();
expect(scrollToCalls).toContain(2300);
});
it("holds document position through mobile composer growth when scrolled up", async () => {
let triggerResize = () => {};
vi.stubGlobal(
"ResizeObserver",
class {
constructor(callback: ResizeObserverCallback) {
triggerResize = () =>
callback([], this as unknown as ResizeObserver);
}
observe() {}
disconnect() {}
},
);
const geometry = fakeWindowGeometry();
render(0);
await flushRaf();
await scrollWindowTo(100);
scrollToCalls = [];
geometry.setScrollHeight(2300);
triggerResize();
expect(scrollToCalls).toHaveLength(0);
});
it("follows a late thread resize without a React content update or body resize", async () => {
const observed = new Set<Element>();
let resizeThread = () => {};
vi.stubGlobal("ResizeObserver", class {
constructor(callback: ResizeObserverCallback) {
resizeThread = () => {
const thread = container.querySelector('[data-testid="task-chat-thread"]')!;
if (observed.has(thread)) callback([{ target: thread } as ResizeObserverEntry], this as unknown as ResizeObserver);
};
}
observe(element: Element) { observed.add(element); }
disconnect() { observed.clear(); }
});
const geometry = fakeWindowGeometry();
render(0);
scrollToCalls = [];
geometry.setScrollHeight(2400);
resizeThread();
expect(scrollToCalls).toContain(2400);
expect(window.scrollY).toBe(1600);
});
it("keeps following when a scroll event arrives after growth before reconciliation", async () => {
const geometry = fakeWindowGeometry();
render(0);
geometry.setScrollHeight(2400);
await scrollWindowTo(1200); // Still at the old bottom; the user did not move up.
scrollToCalls = [];
render(1);
expect(scrollToCalls).toContain(2400);
});
it("holds an upward user scroll during growth and resumes after returning to the bottom", async () => {
const geometry = fakeWindowGeometry();
render(0);
geometry.setScrollHeight(2400);
await scrollWindowTo(900);
scrollToCalls = [];
render(1);
expect(scrollToCalls).toHaveLength(0);
await scrollWindowTo(1600);
geometry.setScrollHeight(2700);
render(2);
expect(scrollToCalls).toContain(2700);
});
it("follows viewport resizing while pinned and holds it while reading older output", async () => {
render(0);
Object.defineProperty(window, "innerHeight", { value: 600, configurable: true });
window.dispatchEvent(new Event("resize"));
expect(window.scrollY).toBe(1400);
await scrollWindowTo(500);
scrollToCalls = [];
Object.defineProperty(window, "innerHeight", { value: 700, configurable: true });
window.dispatchEvent(new Event("resize"));
expect(scrollToCalls).toHaveLength(0);
});
it("does nothing when disabled", () => {
render(0, false);
expect(scrollToCalls).toHaveLength(0);
render(1, false);
expect(scrollToCalls).toHaveLength(0);
});
});
@@ -1,136 +0,0 @@
import { readThreadScrollAnchor, threadScrollAnchorDelta, type ThreadScrollAnchor } from "./scroll-anchor";
import { useEffect, useLayoutEffect, useRef } from "react";
import { useTaskChatScrollNavigation } from "./scroll-navigation";
const PIN_THRESHOLD_PX = 48;
function scrollingElement(): Element | null {
return document.scrollingElement ?? document.documentElement;
}
function windowPinned(): boolean {
const el = scrollingElement();
if (!el) return true;
return el.scrollHeight - window.scrollY - window.innerHeight <= PIN_THRESHOLD_PX;
}
function scrollWindowToBottom(): void {
const el = scrollingElement();
if (!el) return;
window.scrollTo({ top: el.scrollHeight, left: 0, behavior: "auto" });
}
/**
* Window-scroll counterpart of TaskMessageScroller's auto-follow rule, for the
* mobile document-flow thread (the mobile app shell scrolls the document, not
* an inner viewport — see Layout.tsx). While the window is pinned to the
* bottom (within a small threshold) content growth follows with INSTANT
* scroll; once the user scrolls up we hold their position.
*
* The conversation enables this hook after navigation has settled and before
* its coordinated reveal, so initial positioning happens before paint.
*/
export function useWindowAutoFollow(contentKey: unknown, enabled: boolean): void {
const pinnedRef = useRef(true);
const lastScrollYRef = useRef(window.scrollY);
const navigation = useTaskChatScrollNavigation();
const initialPositionApplied = useRef(false);
const appliedNavigation = useRef({ key: navigation.key, hash: navigation.hash });
const anchorRef = useRef<ThreadScrollAnchor | null>(null);
const rememberAnchor = () => {
lastScrollYRef.current = window.scrollY;
const root = document.querySelector('[data-testid="task-chat-thread"]');
if (root) anchorRef.current = readThreadScrollAnchor(root, 0, window.innerHeight);
if (initialPositionApplied.current) navigation.remember(window.scrollY, anchorRef.current);
};
const reconcile = () => {
if (pinnedRef.current) scrollWindowToBottom();
else {
const root = document.querySelector('[data-testid="task-chat-thread"]');
if (root) {
const delta = threadScrollAnchorDelta(root, anchorRef.current, 0);
if (delta) window.scrollTo({ top: window.scrollY + delta, behavior: "auto" });
}
}
rememberAnchor();
};
useEffect(() => {
if (!enabled) return;
const onScroll = () => {
// Content or viewport growth can leave a gap below the old bottom before
// ResizeObserver runs. Only an upward scroll gives up existing follow
// intent; a scroll event at the same position must not strand the output.
pinnedRef.current = windowPinned() || (pinnedRef.current && window.scrollY >= lastScrollYRef.current);
rememberAnchor();
};
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", reconcile);
return () => {
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", reconcile);
};
}, [enabled, navigation.key, navigation.hash, navigation.ready]);
useLayoutEffect(() => {
if (!enabled || typeof ResizeObserver === "undefined") return;
let previousScrollHeight = scrollingElement()?.scrollHeight ?? null;
const observer = new ResizeObserver(() => {
const nextScrollHeight = scrollingElement()?.scrollHeight ?? null;
if (
nextScrollHeight == null ||
nextScrollHeight === previousScrollHeight
) {
return;
}
previousScrollHeight = nextScrollHeight;
reconcile();
});
// The mobile body is viewport-sized even when its contents overflow. Watch
// the thread box too, so streamed rows, images and expanding tools follow
// after their own layout changes without needing a new parent content key.
observer.observe(document.body);
const thread = document.querySelector('[data-testid="task-chat-thread"]');
if (thread) observer.observe(thread);
return () => observer.disconnect();
}, [enabled, navigation.key, navigation.hash, navigation.ready]);
// Follow new content only when already pinned; otherwise hold position.
useLayoutEffect(() => {
if (!enabled) return;
if (appliedNavigation.current.key !== navigation.key || appliedNavigation.current.hash !== navigation.hash) {
appliedNavigation.current = { key: navigation.key, hash: navigation.hash };
initialPositionApplied.current = false;
}
if (navigation.ready && !initialPositionApplied.current) {
const root = document.querySelector('[data-testid="task-chat-thread"]');
const top = root ? navigation.initialPosition(root, 0, window.scrollY) : null;
if (top !== null) {
window.scrollTo({ top, behavior: "auto" });
pinnedRef.current = windowPinned();
rememberAnchor();
}
initialPositionApplied.current = true;
}
reconcile();
}, [contentKey, enabled, navigation.key, navigation.hash, navigation.ready]);
useLayoutEffect(() => {
if (!enabled) return;
// One owner for document-flow compensation, as on the desktop viewport.
const previousRestoration = window.history.scrollRestoration;
window.history.scrollRestoration = "manual";
document.documentElement.classList.add("task-chat-window-scroll");
return () => {
document.documentElement.classList.remove("task-chat-window-scroll");
window.history.scrollRestoration = previousRestoration;
};
}, [enabled]);
}
/** Mount below TaskChatScrollReady so mobile navigation also waits for targets
* fetched after the conversation's first reveal. */
export function TaskChatWindowScroll({ contentKey, enabled }: { contentKey: unknown; enabled: boolean }) {
useWindowAutoFollow(contentKey, enabled);
return null;
}
@@ -0,0 +1,116 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { useMobileVisualViewport } from "./useMobileVisualViewport";
function Host({ mobile = true, conversation = true }: { mobile?: boolean; conversation?: boolean }) {
const ref = useMobileVisualViewport(mobile);
return <div ref={ref}><div data-task-chat-shell={conversation ? "" : undefined}>Chat shell</div></div>;
}
let root: Root;
let container: HTMLDivElement;
let viewport: EventTarget & { height: number; offsetTop: number; scale: number };
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("innerHeight", 695);
Object.defineProperty(document.documentElement, "clientHeight", { configurable: true, value: 695 });
viewport = Object.assign(new EventTarget(), { height: 695, offsetTop: 0, scale: 1 });
vi.stubGlobal("visualViewport", viewport);
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
Reflect.deleteProperty(document.documentElement, "clientHeight");
document.documentElement.scrollTop = 0;
vi.unstubAllGlobals();
});
it("resizes the shell from the visible height when iOS innerHeight shrinks too", () => {
act(() => root.render(<Host />));
const shell = container.firstElementChild as HTMLElement;
act(() => {
vi.stubGlobal("innerHeight", 358);
viewport.height = 358;
viewport.offsetTop = 337;
viewport.dispatchEvent(new Event("resize"));
});
expect(shell.style.getPropertyValue("--mobile-chat-viewport-height")).toBe("358px");
expect(shell.dataset.keyboardOpen).toBe("true");
// Panning must not translate or resize the shell a second time.
act(() => { viewport.offsetTop = 185; viewport.dispatchEvent(new Event("scroll")); });
expect(shell.style.top).toBe("");
expect(shell.style.transform).toBe("");
expect(shell.style.getPropertyValue("--mobile-chat-viewport-height")).toBe("358px");
act(() => { viewport.height = 695; viewport.offsetTop = 0; viewport.dispatchEvent(new Event("resize")); });
expect(shell.style.getPropertyValue("--mobile-chat-viewport-height")).toBe("695px");
expect(shell.dataset.keyboardOpen).toBeUndefined();
});
it("preserves pinch zoom and leaves desktop sizing to CSS", () => {
act(() => root.render(<Host />));
const shell = container.firstElementChild as HTMLElement;
act(() => { viewport.scale = 2; viewport.height = 350; viewport.dispatchEvent(new Event("resize")); });
expect(shell.style.getPropertyValue("--mobile-chat-viewport-height")).toBe("695px");
act(() => root.render(<Host mobile={false} />));
expect(shell.style.getPropertyValue("--mobile-chat-viewport-height")).toBe("");
expect(shell.dataset.keyboardOpen).toBeUndefined();
});
it("does not invalidate shell styles for typing, scrolling, or unchanged viewport events", () => {
act(() => root.render(<Host />));
const shell = container.firstElementChild as HTMLElement;
const observer = new MutationObserver(() => {});
observer.observe(shell, { attributes: true });
act(() => {
for (let i = 0; i < 30; i++) {
shell.dispatchEvent(new Event("input", { bubbles: true }));
viewport.dispatchEvent(new Event("scroll"));
viewport.dispatchEvent(new Event("resize"));
}
});
expect(observer.takeRecords()).toHaveLength(0);
observer.disconnect();
});
it("rejects Safari's stale document focus scroll after the keyboard has resized the conversation", () => {
act(() => root.render(<Host />));
act(() => {
viewport.height = 358;
// Some openings pan before the resize event; others pan one frame later.
document.documentElement.scrollTop = 337;
viewport.dispatchEvent(new Event("resize"));
});
expect(document.documentElement.scrollTop).toBe(0);
document.documentElement.scrollTop = 337;
document.dispatchEvent(new Event("scroll"));
expect(document.documentElement.scrollTop).toBe(0);
});
it("leaves ordinary pages and inner message scrolling alone", () => {
viewport.height = 358;
act(() => root.render(<Host conversation={false} />));
document.documentElement.scrollTop = 337;
document.dispatchEvent(new Event("scroll"));
expect(document.documentElement.scrollTop).toBe(337);
act(() => root.render(<Host />));
const messages = container.querySelector('[data-task-chat-shell]') as HTMLElement;
messages.scrollTop = 250;
messages.dispatchEvent(new Event("scroll"));
expect(messages.scrollTop).toBe(250);
});
it("does not suppress zoom panning when the keyboard was already open", () => {
viewport.height = 358;
act(() => root.render(<Host />));
viewport.scale = 2;
viewport.height = 179;
viewport.dispatchEvent(new Event("resize"));
document.documentElement.scrollTop = 120;
document.dispatchEvent(new Event("scroll"));
expect(document.documentElement.scrollTop).toBe(120);
});
+47
View File
@@ -0,0 +1,47 @@
import { useLayoutEffect, useRef } from "react";
/** Size the mobile conversation shell, never translate it with Safari's pan.
* CSS owns the fixed shell and its flex layout; messages own their scrolling. */
export function useMobileVisualViewport(enabled: boolean) {
const ref = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const shell = ref.current;
const viewport = window.visualViewport;
if (!enabled || !shell || !viewport) return;
const restoreDocumentPosition = () => {
if (viewport.scale !== 1 || shell.dataset.keyboardOpen !== "true" || !shell.querySelector("[data-task-chat-shell]")) return;
const page = document.scrollingElement ?? document.documentElement;
if (page.scrollTop !== 0) page.scrollTop = 0;
};
const update = () => {
if (viewport.scale !== 1 || !Number.isFinite(viewport.height) || viewport.height <= 0) return;
// Read geometry before writing styles, avoiding a forced layout after our
// own height update. Nothing here subscribes to input or thread updates.
const keyboardOpen = document.documentElement.clientHeight - viewport.height > 120;
shell.style.setProperty("--mobile-chat-viewport-height", `${viewport.height}px`);
// innerHeight can shrink with visualViewport on iOS, so it is not the
// keyboard-closed baseline. The layout viewport remains stable.
if (keyboardOpen) {
shell.dataset.keyboardOpen = "true";
} else {
delete shell.dataset.keyboardOpen;
}
restoreDocumentPosition();
};
update();
viewport.addEventListener("resize", update);
window.addEventListener("resize", update);
// Safari can apply its pre-keyboard focus pan AFTER the resize. The fixed
// document is not a scroll surface. Listen without capture so scrolls in
// the message list/editor never reach this fallback.
document.addEventListener("scroll", restoreDocumentPosition);
return () => {
viewport.removeEventListener("resize", update);
window.removeEventListener("resize", update);
document.removeEventListener("scroll", restoreDocumentPosition);
shell.style.removeProperty("--mobile-chat-viewport-height");
delete shell.dataset.keyboardOpen;
};
}, [enabled]);
return ref;
}
+58 -4
View File
@@ -583,6 +583,8 @@
leaves 6px below the dock; its pb-1 adds 4px for a 10px card-to-nav gap.
Desktop and the classic thread use the safe-area default. */
--tc-composer-bottom: var(--sz-calc-8);
--mobile-chat-viewport-height: 100dvh;
--tc-composer-editor-max-height: min(var(--sz-28dvh), calc(var(--mobile-chat-viewport-height) * 0.4));
--tc-composer-visible-nav-offset: calc(4rem + var(--spacing) * 1.5 + var(--sz-safe-bottom));
/* With the dock's pb-1, leave 12px below the card when the nav hides. */
--tc-composer-hidden-nav-offset: calc(var(--sz-safe-bottom) + var(--spacing) * 2);
@@ -887,6 +889,62 @@ select:where(:not([multiple]):not(.appearance-none)):where(:not([size]), [size="
transition: bottom var(--mobile-nav-motion-duration, var(--motion-mobile-nav-enter)) var(--mobile-nav-motion-ease, var(--motion-ease-out-expo));
}
/* Mobile conversations use the same single message scroller + normal footer as
desktop. Opt in from the rendered task shell, so classic tasks, lists, and
the empty agent-chat picker retain their existing document layout. */
html:has(.paperclip-app-shell[data-mobile="true"] [data-task-chat-shell]),
html:has(.paperclip-app-shell[data-mobile="true"] [data-task-chat-shell]) body {
position: fixed;
inset: 0;
width: 100%;
}
html:has(.paperclip-app-shell[data-mobile="true"] [data-task-chat-shell]),
html:has(.paperclip-app-shell[data-mobile="true"] [data-task-chat-shell]) body,
html:has(.paperclip-app-shell[data-mobile="true"] [data-task-chat-shell]) #root {
height: 100%;
overflow: hidden !important;
overscroll-behavior: none;
}
.paperclip-app-shell[data-mobile="true"]:has([data-task-chat-shell]) {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
height: var(--mobile-chat-viewport-height);
min-height: 0;
overflow: hidden;
}
.paperclip-app-shell[data-mobile="true"]:has([data-task-chat-shell]) :is(.app-main-row, .app-content-row) {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
.paperclip-app-shell[data-mobile="true"]:has([data-task-chat-shell]) .app-content-column {
flex: 1;
min-height: 0;
}
.paperclip-app-shell[data-mobile="true"]:has([data-task-chat-shell]) .app-breadcrumb {
flex-shrink: 0;
}
.paperclip-app-shell[data-mobile="true"]:has([data-task-chat-shell]) #main-content {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
padding-bottom: var(--tc-composer-visible-nav-offset);
}
.paperclip-app-shell[data-mobile="true"][data-keyboard-open="true"]:has([data-task-chat-shell]) #main-content {
padding-bottom: 0;
}
@media (prefers-reduced-motion: reduce) {
:root {
--motion-mobile-nav-enter: 0ms;
@@ -3292,10 +3350,6 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
scrollbar-gutter: stable;
}
.task-chat-window-scroll {
overflow-anchor: none;
}
.task-chat-loading-shell .animate-pulse {
animation: none;
}
+4 -10
View File
@@ -7882,11 +7882,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
data-task-chat-shell={taskChatShellEnabled ? "" : undefined}
className={
taskChatShellEnabled
? isMobile
? // Mobile shell scrolls the DOCUMENT (main is overflow-visible,
// auto height) — the thread renders in normal flow (PAP-360).
"flex w-full flex-col gap-6"
: // Fill main exactly so the outer page never scrolls — the
? // Fill main exactly so the outer page never scrolls — the
// thread's own viewport is the only scroll surface.
// Keep status banners close to the transcript. A full section
// gap here shortens the pinned message viewport enough to
@@ -8180,9 +8176,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
onValueChange={setDetailTab}
className={
taskChatShellEnabled
? isMobile
? undefined
: "min-h-0 flex-1"
? "min-h-0 flex-1"
: "space-y-3"
}
>
@@ -8222,8 +8216,8 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
taskChatShellEnabled
? isMobile
? streamlinedTaskDetailEnabled
? undefined
: "-mx-4"
? "flex min-h-0 flex-1 flex-col"
: "-mx-4 flex min-h-0 flex-1 flex-col"
: streamlinedTaskDetailEnabled
? "flex min-h-0 flex-col"
: "-mx-4 -mt-4 md:-mx-6 md:-mt-6 flex min-h-0 flex-col"
@@ -1,10 +1,10 @@
import { useEffect, useState, type CSSProperties } from "react";
import { useEffect, useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { MobileBottomNav } from "@/components/MobileBottomNav";
import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
import { TaskChatComposerDock } from "@/components/task-chat/TaskChatComposerDock";
import { TaskChatThreadView } from "@/components/task-chat/TaskChatThreadView";
import { useWindowAutoFollow } from "@/components/task-chat/useWindowAutoFollow";
import { useMobileVisualViewport } from "@/hooks/useMobileVisualViewport";
function StreamingResponse({ running }: { running: boolean }) {
const [lines, setLines] = useState(16);
@@ -13,7 +13,7 @@ function StreamingResponse({ running }: { running: boolean }) {
const timer = window.setTimeout(() => setLines(lines + 1), 500);
return () => window.clearTimeout(timer);
}, [running, lines]);
return <TaskChatThreadView scroll={false} items={[
return <TaskChatThreadView items={[
{ id: "request", kind: "message", author: "human", text: "Give me a detailed progress report as you work." },
{
id: "response", kind: "message", author: "agent", authorName: "Researcher", streaming: running,
@@ -24,21 +24,19 @@ function StreamingResponse({ running }: { running: boolean }) {
function MobileFollow() {
const [running, setRunning] = useState(true);
// The child updates independently, exercising late layout growth as well as
// normal streaming without relying on a parent content-key notification.
useWindowAutoFollow(0, true);
return <div className="flex min-h-dvh flex-col bg-background text-foreground">
<header className="flex h-12 shrink-0 items-center px-4 text-sm font-medium">Streaming progress report</header>
<main className="flex flex-1 flex-col p-4 pb-(--tc-composer-visible-nav-offset)"
style={{ "--tc-composer-bottom": "var(--tc-composer-visible-nav-offset)" } as CSSProperties}>
<div data-testid="task-chat-thread" className="-mx-4 flex flex-col">
const viewportRef = useMobileVisualViewport(true);
return <div ref={viewportRef} data-mobile="true" className="paperclip-app-shell flex min-h-dvh flex-col bg-background text-foreground">
<div className="app-main-row"><div className="app-content-column flex flex-col">
<header className="app-breadcrumb flex h-12 shrink-0 items-center px-4 text-sm font-medium">iOS layout — focus-scroll correction</header>
<div className="app-content-row"><main id="main-content" className="flex flex-1 flex-col p-4">
<div data-task-chat-shell="" data-testid="task-chat-thread" className="-mx-4 flex min-h-0 flex-1 flex-col">
<StreamingResponse running={running} />
<TaskChatComposerDock mobile streamlined={false}>
<TaskChatComposer mobile workMode="standard" onAdd={async () => setRunning(true)}
onStop={running ? async () => setRunning(false) : undefined} />
</TaskChatComposerDock>
</div>
</main>
</main></div></div></div>
<MobileBottomNav visible />
</div>;
}
@@ -56,7 +54,7 @@ const meta = {
],
"implementation": "production",
"boundary": "fixtures",
"notes": "The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work."
"notes": "Production mobile viewport sizing, message scroller, and composer with bounded synthetic streaming. Physical iPhone keyboard acceptance is required."
},
},
globals: { theme: "light", viewport: { value: "mobile", isRotated: false } },