mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-14 08:26:46 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Sandbox providers carry agent work through controlled execution channels > - The Daytona callback bridge uses a bespoke line-framed protocol over its duplex channel > - The bespoke protocol adds framing work and does not use the Node transport that already supports multiplexed streams > - This pull request carries raw bytes across the channel, adds a Node HTTP/2 bridge, and selects it for Daytona > - The benefit is one authenticated, multiplexed callback session with queue_v1 as the bounded fallback ## Linked Issues or Issue Description **Subsystem affected** The packages/plugins Daytona provider and the shared duplex execution path. **Problem or motivation** The Daytona callback bridge uses a bespoke line-framed protocol over the provider duplex channel. This adds protocol work and limits stream handling. **Proposed solution** Carry raw bytes through the cross-layer channel. Add an authenticated Node HTTP/2 host server and sandbox client gateway. Select http2_v1 for Daytona and retain queue_v1 as the fallback. **Alternatives considered** Keep the current duplex_v1 protocol. This keeps the bespoke framing path and does not provide one HTTP/2 session for callback streams. **Roadmap alignment** ROADMAP.md lists Daytona under cloud and sandbox agents. This change improves the shipped Daytona provider path. **Additional context** The branch adds no dependency. Node 24 provides the http2 module. The host token check and canonical path parser remain the single dispatch path. ## What Changed - Carry raw Uint8Array chunks through the adapter, plugin, worker, runtime, and Daytona layers. - Encode bytes as base64 only across the JSON-RPC hop, because JSON has no binary type. - Add the bounded host HTTP/2 server and the in-sandbox HTTP/2 client gateway. - Authenticate every stream with the per-run bridge token before route work. - Parse the path once and reuse the canonical result for route and forwarding work. - Select http2_v1 for Daytona and fall back once to queue_v1 when the client preface is absent. - Add transport, session, stream, and fallback telemetry. - Mark HTTP/2 as the preferred transport and queue_v1 as the soft-deprecated fallback. ## Verification - `npx vitest run packages/adapter-utils/src` — 990 passed and 4 skipped. - `npx vitest run server/src/__tests__/plugin-worker-manager-duplex.test.ts` — 32 passed. - `npx vitest run --config packages/plugins/sandbox-providers/daytona/vitest.config.ts` — 220 passed and 6 skipped. - `npx tsc --noEmit` in `packages/adapter-utils`, `packages/shared`, `packages/plugins/sdk`, and `server` — clean. - No `package.json` or `pnpm-lock.yaml` file changed. - The live Daytona test skips when `DAYTONA_API_KEY` is absent. - The root `npx tsc --noEmit` command has a pre-existing missing `packages/adapters/droid-local` reference on this branch and on `master`. ## Risks - The transport change affects several duplex layers and could expose byte-boundary errors. - A missing HTTP/2 client preface falls back once to queue_v1 and records `preface_missing`. - The host token check and canonical path parser must remain on the shared dispatch path. - The live Daytona test needs `DAYTONA_API_KEY` and does not run in this agent sandbox. ## Model Used OpenAI GPT-5, tool-enabled coding agent with repository inspection, GitHub CLI, and shell execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
71 lines
3.1 KiB
TypeScript
71 lines
3.1 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
DUPLEX_LOSS_REASONS,
|
|
HTTP2_TELEMETRY_EVENT_NAMES,
|
|
mapHttp2EventToDuplexLossReason,
|
|
normalizeDuplexLossReason,
|
|
normalizeDuplexProvider,
|
|
type DuplexFallbackReason,
|
|
type DuplexTransportValue,
|
|
} from "./duplex-observability.js";
|
|
|
|
describe("duplex observability: HTTP/2 event mapping (accepted security fix 7)", () => {
|
|
it("test_http2_events_map_to_the_loss_taxonomy", () => {
|
|
// Every closed HTTP/2 event name maps to a value from the existing,
|
|
// closed DuplexLossReason set. The map reuses one taxonomy across every
|
|
// transport, so no separate HTTP/2-only reason list exists.
|
|
for (const event of HTTP2_TELEMETRY_EVENT_NAMES) {
|
|
const reason = mapHttp2EventToDuplexLossReason(event);
|
|
expect(DUPLEX_LOSS_REASONS).toContain(reason);
|
|
}
|
|
expect(mapHttp2EventToDuplexLossReason("session_error")).toBe("rpc_failure");
|
|
expect(mapHttp2EventToDuplexLossReason("session_goaway")).toBe("transport_closed");
|
|
expect(mapHttp2EventToDuplexLossReason("session_stall")).toBe("heartbeat_timeout");
|
|
expect(mapHttp2EventToDuplexLossReason("write_error")).toBe("write_error");
|
|
expect(mapHttp2EventToDuplexLossReason("transport_closed")).toBe("transport_closed");
|
|
expect(mapHttp2EventToDuplexLossReason("channel_exit")).toBe("provider_exit");
|
|
});
|
|
|
|
it("test_an_unknown_telemetry_input_maps_to_other", () => {
|
|
// An unknown event name, a raw provider error string, and a missing value
|
|
// all map to `other`. No raw text ever reaches a sink through this map.
|
|
expect(mapHttp2EventToDuplexLossReason("ECONNRESET: read failed at socket.js:42")).toBe("other");
|
|
expect(mapHttp2EventToDuplexLossReason("some_future_event")).toBe("other");
|
|
expect(mapHttp2EventToDuplexLossReason(null)).toBe("other");
|
|
expect(mapHttp2EventToDuplexLossReason(undefined)).toBe("other");
|
|
expect(mapHttp2EventToDuplexLossReason("")).toBe("other");
|
|
});
|
|
|
|
it("keeps the closed HTTP/2 event-name set to the seven named signals", () => {
|
|
expect([...HTTP2_TELEMETRY_EVENT_NAMES].sort()).toEqual(
|
|
[
|
|
"session_error",
|
|
"session_goaway",
|
|
"session_stall",
|
|
"write_error",
|
|
"transport_closed",
|
|
"channel_exit",
|
|
].sort(),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("duplex observability: closed transport and fallback-reason values", () => {
|
|
it("accepts the http2 transport value and the preface_missing fallback reason", () => {
|
|
// A type-level check: these string literals must widen to the exported
|
|
// union types with no cast, so a drift in either union breaks the build.
|
|
const transport: DuplexTransportValue = "http2";
|
|
const fallbackReason: DuplexFallbackReason = "preface_missing";
|
|
expect(transport).toBe("http2");
|
|
expect(fallbackReason).toBe("preface_missing");
|
|
});
|
|
});
|
|
|
|
describe("duplex observability: existing normalization stays intact", () => {
|
|
it("still maps an unknown loss cause and an unknown provider key to their closed defaults", () => {
|
|
expect(normalizeDuplexLossReason("not_a_real_reason")).toBe("other");
|
|
expect(normalizeDuplexProvider("some-unlisted-plugin")).toBe("other");
|
|
});
|
|
});
|