fix(plugins): close subscription gaps found in review

- Losing daemon.manage now releases the session's plugin subscriptions;
  binary events bypass the outbound permission check, so streams would
  otherwise outlive the revocation.
- A subscribe that times out sends unsubscribe, since the plugin may
  still start the handler after the daemon gave up.
- The byte limit leaves room for the largest frame header and routing,
  and lives in assertPluginWireValue so events, outputs and inputs share it.
- A release right after the host accepted keeps `ready` resolved, so a
  handler that returns at once reports closed rather than an error.
- Session trace logging tolerates loggers without isLevelEnabled.
This commit is contained in:
Mohamed Boudra
2026-11-02 05:51:55 +01:00
parent c689b5626e
commit 9233e8cd38
12 changed files with 144 additions and 13 deletions
@@ -8,6 +8,8 @@ import {
encodePluginInputFrame,
encodePluginOutputFrame,
FileTransferOpcode,
MAX_PLUGIN_PAYLOAD_BYTES,
MAX_ROUTED_PAYLOAD_BYTES,
PluginFrameOpcode,
TerminalStreamOpcode,
} from "./index.js";
@@ -70,6 +72,21 @@ describe("the plugin byte rule", () => {
expect(() => assertPluginWireValue(new Uint8Array(4), "event")).not.toThrow();
});
it("rejects bytes that would not fit in one frame with the largest header", () => {
const bytes = new Uint8Array(MAX_PLUGIN_PAYLOAD_BYTES + 1);
expect(() => assertPluginWireValue(bytes.subarray(1), "event")).not.toThrow();
expect(() => assertPluginWireValue(bytes, "event")).toThrow(
`event is ${MAX_PLUGIN_PAYLOAD_BYTES + 1} bytes; one value carries at most ${MAX_PLUGIN_PAYLOAD_BYTES} bytes`,
);
const frame = encodePluginInputFrame({
requestId: "r".repeat(255),
pluginId: "p",
method: "m".repeat(0xffff - 30),
payload: bytes.subarray(1),
});
expect(frame.byteLength).toBeLessThanOrEqual(MAX_ROUTED_PAYLOAD_BYTES);
});
it("rejects bytes nested in a value and names the rule and the path", () => {
expect(() => assertPluginWireValue({ frames: [{ data: new Uint8Array(1) }] }, "event")).toThrow(
"event.frames[0].data is a Uint8Array. Bytes cross the wire only as the whole value: send the Uint8Array itself, or encode it into the value.",
+15 -1
View File
@@ -1,3 +1,5 @@
import { MAX_ROUTED_PAYLOAD_BYTES } from "./host-route.js";
/**
* Plugin values that are a `Uint8Array` travel as binary frames. Opcodes 0x20-0x2f are
* reserved for plugin traffic. Every frame is `[opcode][id length u8][id]...` and the payload
@@ -14,6 +16,12 @@ export const PluginFrameOpcode = {
export type PluginFrameOpcode = (typeof PluginFrameOpcode)[keyof typeof PluginFrameOpcode];
/** The largest frame header: an input's opcode, ID and header JSON, each at its maximum length. */
const MAX_PLUGIN_FRAME_HEADER_BYTES = 2 + 0xff + 2 + 0xffff;
/** The largest `Uint8Array` a plugin value carries, so its frame fits in one routed message. */
export const MAX_PLUGIN_PAYLOAD_BYTES = MAX_ROUTED_PAYLOAD_BYTES - MAX_PLUGIN_FRAME_HEADER_BYTES;
export type PluginFrame =
| { opcode: typeof PluginFrameOpcode.Event; subscriptionId: string; payload: Uint8Array }
| { opcode: typeof PluginFrameOpcode.Output; requestId: string; payload: Uint8Array }
@@ -112,7 +120,13 @@ function decodeInputHeader(bytes: Uint8Array): { pluginId: string; method: strin
* with no `Uint8Array` inside it. Checks object structure only; never reads the bytes.
*/
export function assertPluginWireValue(value: unknown, label: string): void {
if (value instanceof Uint8Array) return;
if (value instanceof Uint8Array) {
if (value.byteLength > MAX_PLUGIN_PAYLOAD_BYTES)
throw new RangeError(
`${label} is ${value.byteLength} bytes; one value carries at most ${MAX_PLUGIN_PAYLOAD_BYTES} bytes`,
);
return;
}
const nested = findNestedBytes(value, label);
if (nested)
throw new TypeError(