mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-03 15:37:44 +00:00
8e7a567f69
* feat: pilot agent outbound-mode for remote nodes Adds a second mode for managing remote nodes: the agent dials an outbound WebSocket tunnel to the primary, so the remote host no longer needs an inbound port, a reachable URL, or its own TLS certificate. Works behind NAT, residential routers, and corporate firewalls. The primary multiplexes HTTP and WebSocket requests over a single tunnel via a hybrid JSON + binary frame protocol, bridged through a per-tunnel loopback server so existing proxy and upgrade handlers route pilot-mode nodes identically to proxy-mode ones. Enrollment uses a single-use 15-minute pilot_enroll JWT exchanged for a long-lived pilot_tunnel credential on first connect. Proxy mode continues to work unchanged and both modes are supported side-by-side. * test(e2e): switch to proxy mode before asserting api_url field Remote nodes default to Pilot Agent mode, which hides the api_url input. The SSRF-validation tests need proxy mode, so the helper now selects Distributed API Proxy after picking Remote type before asserting the field is visible. * fix(e2e): wire Combobox id prop so node-mode selector resolves The Combobox trigger button had no id, leaving its Label orphaned and making getByRole name-based lookups fail. Adding id to the primitive, passing id="node-mode" from NodeManager, and updating the E2E helper to use #node-mode fixes both the a11y regression and the CI timeout.
122 lines
4.6 KiB
TypeScript
122 lines
4.6 KiB
TypeScript
/**
|
|
* Tests for the pilot tunnel wire protocol.
|
|
*/
|
|
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
BinaryFrameType,
|
|
PROTOCOL_VERSION,
|
|
StreamIdAllocator,
|
|
decodeBinaryFrame,
|
|
decodeJsonFrame,
|
|
encodeBinaryFrame,
|
|
encodeJsonFrame,
|
|
} from '../pilot/protocol';
|
|
|
|
describe('JSON frame roundtrip', () => {
|
|
it('roundtrips a hello frame', () => {
|
|
const frame = encodeJsonFrame({ t: 'hello', version: PROTOCOL_VERSION, role: 'agent', agentVersion: '0.1.0' });
|
|
const decoded = decodeJsonFrame(frame);
|
|
expect(decoded).toEqual({ t: 'hello', version: PROTOCOL_VERSION, role: 'agent', agentVersion: '0.1.0' });
|
|
});
|
|
|
|
it('roundtrips an http_req frame', () => {
|
|
const raw = encodeJsonFrame({
|
|
t: 'http_req',
|
|
s: 7,
|
|
method: 'POST',
|
|
path: '/api/stacks',
|
|
headers: { 'content-type': 'application/json', 'x-sencho-tier': 'paid' },
|
|
});
|
|
const decoded = decodeJsonFrame(raw);
|
|
expect(decoded.t).toBe('http_req');
|
|
if (decoded.t !== 'http_req') throw new Error('narrowing');
|
|
expect(decoded.s).toBe(7);
|
|
expect(decoded.method).toBe('POST');
|
|
expect(decoded.headers['x-sencho-tier']).toBe('paid');
|
|
});
|
|
|
|
it('roundtrips ws lifecycle frames', () => {
|
|
const open = decodeJsonFrame(encodeJsonFrame({ t: 'ws_open', s: 3, path: '/ws', headers: {} }));
|
|
expect(open.t).toBe('ws_open');
|
|
const accept = decodeJsonFrame(encodeJsonFrame({ t: 'ws_accept', s: 3, headers: {} }));
|
|
expect(accept.t).toBe('ws_accept');
|
|
const close = decodeJsonFrame(encodeJsonFrame({ t: 'ws_close', s: 3, code: 1000, reason: 'done' }));
|
|
expect(close.t).toBe('ws_close');
|
|
});
|
|
|
|
it('rejects malformed JSON', () => {
|
|
expect(() => decodeJsonFrame('not json')).toThrow();
|
|
});
|
|
|
|
it('rejects missing type discriminator', () => {
|
|
expect(() => decodeJsonFrame('{"foo":1}')).toThrow(/type discriminator/);
|
|
});
|
|
});
|
|
|
|
describe('Binary frame roundtrip', () => {
|
|
it('roundtrips an http request body chunk', () => {
|
|
const payload = Buffer.from('hello world');
|
|
const encoded = encodeBinaryFrame(BinaryFrameType.HttpReqBody, 42, payload);
|
|
const decoded = decodeBinaryFrame(encoded);
|
|
expect(decoded.type).toBe(BinaryFrameType.HttpReqBody);
|
|
expect(decoded.streamId).toBe(42);
|
|
expect(decoded.payload.equals(payload)).toBe(true);
|
|
});
|
|
|
|
it('roundtrips a ws binary message', () => {
|
|
const payload = Buffer.from([0xde, 0xad, 0xbe, 0xef]);
|
|
const encoded = encodeBinaryFrame(BinaryFrameType.WsMessageBinary, 1, payload);
|
|
const decoded = decodeBinaryFrame(encoded);
|
|
expect(decoded.type).toBe(BinaryFrameType.WsMessageBinary);
|
|
expect(decoded.streamId).toBe(1);
|
|
expect(decoded.payload.equals(payload)).toBe(true);
|
|
});
|
|
|
|
it('roundtrips an empty payload', () => {
|
|
const encoded = encodeBinaryFrame(BinaryFrameType.HttpResBody, 99, Buffer.alloc(0));
|
|
const decoded = decodeBinaryFrame(encoded);
|
|
expect(decoded.payload.length).toBe(0);
|
|
expect(decoded.streamId).toBe(99);
|
|
});
|
|
|
|
it('handles max streamId', () => {
|
|
const encoded = encodeBinaryFrame(BinaryFrameType.HttpReqBody, 0xffffffff, Buffer.from([0]));
|
|
const decoded = decodeBinaryFrame(encoded);
|
|
expect(decoded.streamId).toBe(0xffffffff);
|
|
});
|
|
|
|
it('rejects negative streamId', () => {
|
|
expect(() => encodeBinaryFrame(BinaryFrameType.HttpReqBody, -1, Buffer.alloc(0))).toThrow(/streamId/);
|
|
});
|
|
|
|
it('rejects streamId above uint32 max', () => {
|
|
expect(() => encodeBinaryFrame(BinaryFrameType.HttpReqBody, 0x100000000, Buffer.alloc(0))).toThrow(/streamId/);
|
|
});
|
|
|
|
it('rejects frames shorter than the 5-byte header', () => {
|
|
expect(() => decodeBinaryFrame(Buffer.from([0x01, 0x00]))).toThrow(/too short/);
|
|
});
|
|
|
|
it('rejects unknown binary frame types', () => {
|
|
const buf = Buffer.alloc(5);
|
|
buf.writeUInt8(0x99, 0);
|
|
expect(() => decodeBinaryFrame(buf)).toThrow(/unknown binary frame type/);
|
|
});
|
|
});
|
|
|
|
describe('StreamIdAllocator', () => {
|
|
it('allocates monotonically starting at 1', () => {
|
|
const alloc = new StreamIdAllocator();
|
|
expect(alloc.allocate()).toBe(1);
|
|
expect(alloc.allocate()).toBe(2);
|
|
expect(alloc.allocate()).toBe(3);
|
|
});
|
|
|
|
it('wraps before overflowing int32', () => {
|
|
const alloc = new StreamIdAllocator();
|
|
(alloc as unknown as { next: number }).next = 0x7fffffff;
|
|
expect(alloc.allocate()).toBe(0x7fffffff);
|
|
expect(alloc.allocate()).toBe(1);
|
|
});
|
|
});
|