@robota-sdk/agent-transport-ws 3.0.0-beta.83 → 3.0.0-beta.84
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +76 -12
- package/dist/node/index.cjs +1 -1
- package/dist/node/index.d.cts +3 -0
- package/dist/node/index.d.cts.map +1 -1
- package/dist/node/index.d.ts +3 -0
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +1 -1
- package/dist/node/index.js.map +1 -1
- package/package.json +9 -8
- package/src/__tests__/ws-session-binder.test.ts +2 -0
- package/src/__tests__/ws-session-directory.test.ts +2 -0
- package/src/__tests__/ws-transport-auth.test.ts +33 -0
- package/src/ws-transport-config.ts +4 -0
- package/src/ws-transport-configurable.ts +5 -1
- package/src/ws-transport.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
1
1
|
# @robota-sdk/agent-transport-ws
|
|
2
2
|
|
|
3
|
+
## 3.0.0-beta.84
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 9c6a8db: The wire protocol gained several request/reply pairs so a GUI never has to scrape command output for
|
|
8
|
+
data it can read directly.
|
|
9
|
+
|
|
10
|
+
`get-settings`/`update-settings` read and write the Settings screen; a patch applies through the same
|
|
11
|
+
function its matching slash command uses, so the two paths cannot drift. `list-models` returns the
|
|
12
|
+
configured models grouped by provider profile, with the current profile and model. `get-agent-definitions`
|
|
13
|
+
returns the agent switcher's roster and current selection. `project-status`, `project-diff` and
|
|
14
|
+
`project-memory` back the Project panel's git status, one file's diff, and project memory; a host
|
|
15
|
+
without this capability answers `protocol_error` instead of hanging. `rename-session` and
|
|
16
|
+
`delete-session` rename or remove a stored session from the list, current or not, each with its own
|
|
17
|
+
success/failure reply.
|
|
18
|
+
|
|
19
|
+
The `messages` frame gained an optional `display` field — the same history projected into display
|
|
20
|
+
segments (text runs and finished tool calls, diffs included) — and the connection's own `driverId`, so
|
|
21
|
+
a reload, reconnect or resume shows tool rows and tells a co-driver's turns from its own. The `error`
|
|
22
|
+
frame gained optional `code`, `provider`, `retryAfterSeconds` and `model` fields so a renderer can say
|
|
23
|
+
what kind of failure happened (`auth`, `rate_limit`, `model_unavailable`, `network`, `provider`) instead
|
|
24
|
+
of showing only raw text; a session error this classification does not recognize is unchanged.
|
|
25
|
+
|
|
26
|
+
`TCommandSurfaceLocality` names whether a `'remote'`-sourced command is provably on this machine. The
|
|
27
|
+
WebRTC/device-mesh transport now forwards `'remote'` for it (it proves no locality, the same rule
|
|
28
|
+
already applied to pairing), and both the WS and WebRTC transports forward it into the Settings
|
|
29
|
+
read/write path, so a command that refuses a remote surface (installing a plugin) refuses identically
|
|
30
|
+
regardless of which transport carried it.
|
|
31
|
+
|
|
32
|
+
- Updated dependencies [9f46375]
|
|
33
|
+
- Updated dependencies [e8d70ac]
|
|
34
|
+
- Updated dependencies [9c6a8db]
|
|
35
|
+
- Updated dependencies [9c6a8db]
|
|
36
|
+
- Updated dependencies [9c6a8db]
|
|
37
|
+
- @robota-sdk/agent-core@3.0.0-beta.84
|
|
38
|
+
- @robota-sdk/agent-interface-session@3.0.0-beta.84
|
|
39
|
+
- @robota-sdk/agent-interface-analytics@3.0.0-beta.84
|
|
40
|
+
- @robota-sdk/agent-transport@3.0.0-beta.84
|
|
41
|
+
- @robota-sdk/agent-interface-transport@3.0.0-beta.84
|
|
42
|
+
|
|
3
43
|
## 3.0.0-beta.83
|
|
4
44
|
|
|
5
45
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -1,21 +1,85 @@
|
|
|
1
1
|
# @robota-sdk/agent-transport-ws
|
|
2
2
|
|
|
3
|
-
WebSocket
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
WebSocket transport for the Robota SDK. `WsTransport` serves a running agent session on a loopback
|
|
4
|
+
WebSocket server, so a local client (a browser page, a desktop app, another process) can drive the
|
|
5
|
+
session in real time: submit prompts, receive streamed output, answer permission prompts, and read
|
|
6
|
+
history and status. The wire protocol and the session bridge come from `@robota-sdk/agent-transport`;
|
|
7
|
+
this package adds the socket server, connection admission and lifecycle, and pulls in only `ws`.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @robota-sdk/agent-transport-ws @robota-sdk/agent-transport
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Requires Node.js 22.12 or later. `@robota-sdk/agent-transport` provides the session and message types
|
|
16
|
+
used below.
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
6
19
|
|
|
7
20
|
```typescript
|
|
8
21
|
import { WsTransport } from '@robota-sdk/agent-transport-ws';
|
|
9
|
-
import {
|
|
10
|
-
|
|
22
|
+
import type { IProtocolSession } from '@robota-sdk/agent-transport';
|
|
23
|
+
|
|
24
|
+
declare const session: IProtocolSession; // e.g. an InteractiveSession from @robota-sdk/agent-framework
|
|
25
|
+
|
|
26
|
+
const transport = new WsTransport({ port: 7070 });
|
|
27
|
+
transport.attach(session);
|
|
28
|
+
await transport.start();
|
|
29
|
+
|
|
30
|
+
// Hand both to your client; it connects to ws://127.0.0.1:<port>/?token=<token>
|
|
31
|
+
console.log(transport.boundPort, transport.resolvedToken);
|
|
32
|
+
|
|
33
|
+
await transport.stop();
|
|
11
34
|
```
|
|
12
35
|
|
|
13
|
-
|
|
36
|
+
The server listens on `127.0.0.1` only. If the port is taken it tries the next one, up to `maxRetries`
|
|
37
|
+
times; `boundPort` reports the port actually bound.
|
|
38
|
+
|
|
39
|
+
Every connection must present the token, as a `?token=` query parameter or as the
|
|
40
|
+
`Sec-WebSocket-Protocol` value, or it is closed before any session data is sent. Without a `token`
|
|
41
|
+
option, a random token is minted per launch and exposed as `resolvedToken`. The `Host` header must be a
|
|
42
|
+
loopback name, and a browser's `Origin` must be loopback, so another web page cannot reach the socket.
|
|
43
|
+
Running without a token takes `open: true` with an `openReason`, and is discouraged: any local process
|
|
44
|
+
could then drive the session.
|
|
45
|
+
|
|
46
|
+
Clients exchange JSON text frames typed as `TClientMessage` / `TServerMessage` from
|
|
47
|
+
`@robota-sdk/agent-transport`, whose `./client` entry point also provides browser-safe decoders.
|
|
48
|
+
|
|
49
|
+
## `WsTransport` options
|
|
50
|
+
|
|
51
|
+
| Option | Type | Description |
|
|
52
|
+
| ------------------------------------------------------------------------ | ----------------------------- | ---------------------------------------------------------------------------------------------------- |
|
|
53
|
+
| `port` | `number` | Port to bind. Default `7070`. |
|
|
54
|
+
| `maxRetries` | `number` | Next ports to try when the port is in use. Default `20`. |
|
|
55
|
+
| `token` | `string` | Credential every connection must present. Omitted: one is minted per launch. |
|
|
56
|
+
| `open` / `openReason` | `boolean` / `string` | Run without a token; `openReason` must say why. Rejected together with `token`. |
|
|
57
|
+
| `allowedHosts` / `allowedOrigins` | `readonly string[]` | Extra `Host` names and browser `Origin`s to accept beyond loopback. |
|
|
58
|
+
| `driverId` / `surface` | `TDriverId` / `TUsageSurface` | Identity recorded on turns submitted through this transport, set by the host rather than the client. |
|
|
59
|
+
| `sessionDirectory` | `ISessionDirectory` | Lets clients list, start and switch the host's sessions. |
|
|
60
|
+
| `sessionBinder` | `ISessionBinder` | Gives each connection its own session binding, so one client's switch does not move the others. |
|
|
61
|
+
| `personalUsageReporter` / `usageReporter` / `storedSessionUsageReporter` | reporters | Host-owned usage read models, available to admitted connections. |
|
|
62
|
+
|
|
63
|
+
`start()` before `attach()` throws, as does a second `start()` while running. `stop()` closes every
|
|
64
|
+
client (forcing any that do not close within a few seconds), is safe to call more than once, and
|
|
65
|
+
requires `attach()` again before the next `start()`.
|
|
66
|
+
|
|
67
|
+
Delivery failures are the connection's problem, not the session's: if a socket is closed or a send fails,
|
|
68
|
+
that connection is cleaned up once and the session operation that produced the event is unaffected.
|
|
69
|
+
|
|
70
|
+
## Other exports
|
|
71
|
+
|
|
72
|
+
- `createWsTransport(options)` — the same session bridge without a server, for a WebSocket you already
|
|
73
|
+
own. Pass a `send` function; after `start()`, feed each inbound text frame to `transport.onMessage`.
|
|
74
|
+
- `registerChannel(descriptor)` on `WsTransport` and the standalone `PayloadChannelRegistry` — carry
|
|
75
|
+
application-defined events and binary frames on the same connection as the agent protocol. Binary
|
|
76
|
+
frames go to channels; text frames go to the agent protocol.
|
|
77
|
+
- Types: `IWsTransportConfig` (the options above), `IWsTransport`, `IWsTransportOptions`,
|
|
78
|
+
`TChannelSink`.
|
|
14
79
|
|
|
15
|
-
|
|
16
|
-
If a socket is not open or `send` fails asynchronously, the carrier runs one idempotent connection cleanup
|
|
17
|
-
path and reports the delivery failure without throwing back through the committed session operation.
|
|
80
|
+
## Related
|
|
18
81
|
|
|
19
|
-
|
|
20
|
-
transport.
|
|
21
|
-
|
|
82
|
+
- [`@robota-sdk/agent-transport`](../agent-transport/README.md) — the wire protocol and session bridge.
|
|
83
|
+
- [`@robota-sdk/agent-transport-webrtc`](../agent-transport-webrtc/README.md) — the same protocol
|
|
84
|
+
carried peer to peer.
|
|
85
|
+
- [docs/SPEC.md](./docs/SPEC.md) — package contract and invariants.
|
package/dist/node/index.cjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});let e=require("@robota-sdk/agent-transport"),t=require("node:http"),n=require("ws"),r=require("node:crypto"),i=require("@robota-sdk/agent-transport/node");function a(t){let n=null,r=null,i=e=>Object.assign(Error(`WebSocket transport ${e}.`),{name:`TransportLifecycleError`,code:e,transportName:`ws`}),a={name:`ws`,lifecycle:Object.freeze({kind:`service`}),onMessage:null,attach(e){n=e},async start(){if(!n)throw i(`not-attached`);if(r)throw i(`already-started`);let o=(0,e.createOutboundDelivery)(t.send,(e,n)=>{s.cleanup(),r=null,a.onMessage=null,t.onDeliveryError?.(e,n)}),s=(0,e.createSessionMessageHandler)({session:n,deliver:o,...t.driverId?{driverId:t.driverId}:{},...t.surface?{surface:t.surface}:{},...t.personalUsageReporter?{personalUsageReporter:t.personalUsageReporter}:{},...t.usageReporter?{usageReporter:t.usageReporter}:{},...t.storedSessionUsageReporter?{storedSessionUsageReporter:t.storedSessionUsageReporter}:{},...t.sessionDirectory?{sessionDirectory:t.sessionDirectory}:{}});r=s.cleanup,this.onMessage=s.onMessage},async stop(){r?.(),r=null,this.onMessage=null,n=null}};return a}var o=class t{channels=new Map;sinks=new Set;addSink(e){return this.sinks.add(e),()=>{this.sinks.delete(e)}}registerChannel(e){if(this.channels.has(e.name))throw Error(`Channel "${e.name}" is already registered on this transport`);let t={descriptor:e,declaredEvents:new Set(e.events),eventHandlers:new Map,binaryHandlers:new Set,seq:0,open:!0};return this.channels.set(e.name,t),this.createHandle(e,t)}receive(t){let n=(0,e.decodeChannelFrame)(t);if(!n.ok)return n;let{frame:r}=n,i=this.channels.get(r.channel);if(!i||!i.open)return{ok:!1,error:`No channel registered for "${r.channel}"`};if(r.kind===`binary`){if(i.descriptor.binary!==!0)return{ok:!1,error:`Channel "${r.channel}" did not declare binary payload support`};for(let e of[...i.binaryHandlers])e(r);return{ok:!0,frame:r}}if(!i.declaredEvents.has(r.event))return{ok:!1,error:`Channel "${r.channel}" did not declare event "${r.event}"`};let a=i.eventHandlers.get(r.event);if(a)for(let e of[...a])e(r.payload,r);return{ok:!0,frame:r}}broadcast(e){for(let t of[...this.sinks])t(e)}static assertOpen(e){if(!e.open)throw Error(`Channel "${e.descriptor.name}" is closed`)}createHandle(n,r){return{descriptor:n,sendEvent:(i,a)=>{if(t.assertOpen(r),!r.declaredEvents.has(i))throw Error(`Channel "${n.name}" did not declare event "${i}"`);let o=r.seq;r.seq+=1,this.broadcast((0,e.encodeChannelEventFrame)({kind:`event`,channel:n.name,seq:o,event:i,payload:a}))},sendBinary:i=>{if(t.assertOpen(r),n.binary!==!0)throw Error(`Channel "${n.name}" did not declare binary payload support`);let a=r.seq;r.seq+=1,this.broadcast((0,e.encodeBinaryFrame)({kind:`binary`,channel:n.name,seq:a,payload:i}))},onEvent:(e,i)=>{if(t.assertOpen(r),!r.declaredEvents.has(e))throw Error(`Channel "${n.name}" did not declare event "${e}"`);let a=r.eventHandlers.get(e)??new Set,o=i;return a.add(o),r.eventHandlers.set(e,a),()=>{a.delete(o)}},onBinary:e=>(t.assertOpen(r),r.binaryHandlers.add(e),()=>{r.binaryHandlers.delete(e)}),close:()=>{r.open=!1,r.eventHandlers.clear(),r.binaryHandlers.clear(),this.channels.delete(n.name)}}}};const s=new Set([`localhost`,`127.0.0.1`,`::1`,`[::1]`]);function c(e){if(!e)return null;if(e.startsWith(`[`))return e.slice(0,e.indexOf(`]`)+1)||null;let t=e.lastIndexOf(`:`);return t===-1?e:e.slice(0,t)}function l(e,t){let n=c(e.headers.host);return n===null?!1:s.has(n)||t.has(n)}function u(e,t){let n=e.headers.origin;if(n===void 0||t.has(n))return!0;try{return s.has(new URL(n).hostname)}catch{return!1}}function d(e,t){if(!t)return!1;let n=Buffer.from(e),i=Buffer.from(t);return n.length===i.length&&(0,r.timingSafeEqual)(n,i)}function f(e){try{let t=new URL(e.url??``,`ws://127.0.0.1`).searchParams.get(`token`);if(t)return t}catch{}let t=e.headers[`sec-websocket-protocol`];return typeof t==`string`&&t.split(`,`)[0]?.trim()||null}function p(e){return(0,i.resolveAdmission)({...e.token===void 0?{}:{token:e.token},...e.open===!0?{open:!0}:{},...e.openReason===void 0?{}:{openReason:e.openReason}})}function m(e){return Array.isArray(e)?new Uint8Array(Buffer.concat(e)):e instanceof ArrayBuffer?new Uint8Array(e):new Uint8Array(e.buffer,e.byteOffset,e.byteLength)}var h=class{socket;cleanupProtocol=()=>void 0;detachSink=()=>void 0;closed=!1;stallClock=(0,e.createPendingStallClock)();deliver;constructor(t){this.socket=t,this.deliver=(0,e.createOutboundDelivery)(e=>this.rawSend(e),()=>this.close(),()=>this.socket.bufferedAmount,e.DEFAULT_MAX_PENDING_BYTES,this.stallClock)}deliverBinary=t=>{if(!this.closed&&this.socket.readyState===n.WebSocket.OPEN){if((0,e.isOverPendingBudget)(this.socket.bufferedAmount)||this.stallClock.observe(this.socket.bufferedAmount)!==void 0){this.close();return}this.socket.send(t,{binary:!0},e=>{e&&this.close()})}};rawSend(e){if(this.socket.readyState!==n.WebSocket.OPEN)throw Error(`WebSocket is not open`);this.socket.send(JSON.stringify(e),e=>{e&&this.close()})}bindProtocolCleanup(e){this.cleanupProtocol=e}bindSinkDetach(e){this.detachSink=e}close=()=>{this.closed||(this.closed=!0,this.detachSink(),this.cleanupProtocol(),(this.socket.readyState===n.WebSocket.OPEN||this.socket.readyState===n.WebSocket.CONNECTING)&&this.socket.close(1011,`session event delivery failed`))}};function g(e){return{...e.personalUsageReporter?{personalUsageReporter:e.personalUsageReporter}:{},...e.usageReporter?{usageReporter:e.usageReporter}:{},...e.storedSessionUsageReporter?{storedSessionUsageReporter:e.storedSessionUsageReporter}:{}}}function _(e){return{...e.driverId?{driverId:e.driverId}:{},...e.surface?{surface:e.surface}:{},...g(e),...e.sessionDirectory?{sessionDirectory:e.sessionDirectory}:{}}}function v(e){let{port:t,maxRetries:n}=e;return t!==void 0&&(typeof t!=`number`||t<1||t>65535)?!1:n===void 0||typeof n==`number`&&n>=0}function y(e,t){return Object.assign(Error(`${e} ${t}.`),{name:`TransportLifecycleError`,code:t,transportName:e})}var b=class{name=`ws`;lifecycle=Object.freeze({kind:`service`});defaultEnabled=!0;optionsSchema={port:{type:`number`,description:`WebSocket server port`,default:7070},maxRetries:{type:`number`,description:`Port retry attempts when port is occupied`,default:20}};session=null;stopFn=null;state=`detached`;startOperation;startCancelled=!1;port;maxRetries;token;allowedHosts;allowedOrigins;handlerOptions;sessionBinder;channels=new o;resolvedPort;constructor(e={}){this.port=e.port??7070,this.maxRetries=e.maxRetries??20;let t=p(e);t.token!==null&&(this.token=t.token),this.allowedHosts=new Set(e.allowedHosts??[]),this.allowedOrigins=new Set(e.allowedOrigins??[]),this.handlerOptions=_(e),this.sessionBinder=e.sessionBinder}attach(e){this.session=e,this.state=`attached`}registerChannel(e){return this.channels.registerChannel(e)}get boundPort(){return this.resolvedPort}get resolvedToken(){return this.token}async start(){if(!this.session)throw y(this.name,`not-attached`);if(this.state===`starting`||this.state===`ready`||this.state===`stopping`)throw y(this.name,`already-started`);this.state=`starting`,this.startCancelled=!1;let e=this.bindWithRetry(this.session,this.port,this.maxRetries);this.startOperation=e;let t;try{t=await e}catch(t){throw this.state===`starting`&&this.startOperation===e&&(this.startOperation=void 0,this.state=this.session?`attached`:`detached`),t}if(this.startCancelled||this.startOperation!==e)throw await t.stop(),this.state=this.session?`attached`:`detached`,Error(`WsTransport startup was stopped.`);this.stopFn=t.stop,this.resolvedPort=t.port,this.startOperation=void 0,this.state=`ready`}async stop(){if(this.state===`starting`){this.state=`stopping`,this.startCancelled=!0;try{await this.startOperation}catch{}}await this.stopFn?.(),this.stopFn=null,this.startOperation=void 0,this.resolvedPort=void 0,this.session=null,this.state=`detached`}configure(e){if(this.state!==`detached`&&this.state!==`attached`)throw y(this.name,`already-started`);if(!this.validateOptions(e))throw TypeError(`WsTransport options are invalid (port 1-65535, maxRetries >= 0).`);typeof e.port==`number`&&(this.port=e.port),typeof e.maxRetries==`number`&&(this.maxRetries=e.maxRetries)}validateOptions(e){return v(e)}bindConnection(e){if(!this.sessionBinder)return{binding:void 0};try{return{binding:this.sessionBinder.bind(`drive`)}}catch{return e.close(1013,`session unavailable`),null}}bindWithRetry(e,t,n){return this.tryBind(e,t).catch(r=>{if(r.code===`EADDRINUSE`&&n>0)return this.bindWithRetry(e,t+1,n-1);throw r})}tryBind(r,i){return new Promise((a,o)=>{let s=(0,t.createServer)((e,t)=>{t.writeHead(400).end(`WebSocket endpoint`)});s.on(`error`,e=>{s.close(),o(e)});let c=this.token,p=this.allowedHosts,g=this.allowedOrigins,_=this.channels;s.listen(i,`127.0.0.1`,()=>{let t=new n.WebSocketServer({server:s,verifyClient:(e,t)=>{if(!l(e.req,p)){t(!1,403,`Forbidden host`);return}if(!u(e.req,g)){t(!1,403,`Forbidden origin`);return}t(!0)}});t.on(`connection`,(t,n)=>{if(c!==void 0&&!d(c,f(n))){t.close(1008,`unauthorized`);return}let i=this.bindConnection(t);if(i===null)return;let{binding:a}=i,o=a?.session??r,s=new h(t),l=(0,e.createSessionMessageHandler)({session:o,deliver:s.deliver,...this.handlerOptions,...a?{sessionDirectory:a.directory}:{}});s.bindProtocolCleanup(()=>{l.cleanup(),a?.release()}),s.bindSinkDetach(_.addSink(e=>s.deliverBinary(e))),t.on(`message`,(e,t)=>{if(!t){l.onMessage(String(e));return}let n=_.receive(m(e));n.ok||s.deliver({type:`protocol_error`,message:n.error})}),t.on(`close`,s.close),t.on(`error`,s.close),s.deliver({type:`messages`,messages:o.getMessages()}),s.deliver({type:`execution_workspace_event`,snapshot:o.getExecutionWorkspaceSnapshot()})}),a({port:i,stop:()=>new Promise(e=>{for(let e of t.clients)e.close(1001,`server shutting down`);let n=setTimeout(()=>{for(let e of t.clients)e.terminate()},5e3);t.close(()=>{clearTimeout(n),s.close(()=>e())})})})})})}};exports.PayloadChannelRegistry=o,exports.WsTransport=b,exports.createWsTransport=a;
|
|
1
|
+
Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});let e=require("@robota-sdk/agent-transport"),t=require("node:http"),n=require("ws"),r=require("node:crypto"),i=require("@robota-sdk/agent-transport/node");function a(t){let n=null,r=null,i=e=>Object.assign(Error(`WebSocket transport ${e}.`),{name:`TransportLifecycleError`,code:e,transportName:`ws`}),a={name:`ws`,lifecycle:Object.freeze({kind:`service`}),onMessage:null,attach(e){n=e},async start(){if(!n)throw i(`not-attached`);if(r)throw i(`already-started`);let o=(0,e.createOutboundDelivery)(t.send,(e,n)=>{s.cleanup(),r=null,a.onMessage=null,t.onDeliveryError?.(e,n)}),s=(0,e.createSessionMessageHandler)({session:n,deliver:o,...t.driverId?{driverId:t.driverId}:{},...t.surface?{surface:t.surface}:{},...t.personalUsageReporter?{personalUsageReporter:t.personalUsageReporter}:{},...t.usageReporter?{usageReporter:t.usageReporter}:{},...t.storedSessionUsageReporter?{storedSessionUsageReporter:t.storedSessionUsageReporter}:{},...t.sessionDirectory?{sessionDirectory:t.sessionDirectory}:{},...t.settingsReporter?{settingsReporter:t.settingsReporter}:{}});r=s.cleanup,this.onMessage=s.onMessage},async stop(){r?.(),r=null,this.onMessage=null,n=null}};return a}var o=class t{channels=new Map;sinks=new Set;addSink(e){return this.sinks.add(e),()=>{this.sinks.delete(e)}}registerChannel(e){if(this.channels.has(e.name))throw Error(`Channel "${e.name}" is already registered on this transport`);let t={descriptor:e,declaredEvents:new Set(e.events),eventHandlers:new Map,binaryHandlers:new Set,seq:0,open:!0};return this.channels.set(e.name,t),this.createHandle(e,t)}receive(t){let n=(0,e.decodeChannelFrame)(t);if(!n.ok)return n;let{frame:r}=n,i=this.channels.get(r.channel);if(!i||!i.open)return{ok:!1,error:`No channel registered for "${r.channel}"`};if(r.kind===`binary`){if(i.descriptor.binary!==!0)return{ok:!1,error:`Channel "${r.channel}" did not declare binary payload support`};for(let e of[...i.binaryHandlers])e(r);return{ok:!0,frame:r}}if(!i.declaredEvents.has(r.event))return{ok:!1,error:`Channel "${r.channel}" did not declare event "${r.event}"`};let a=i.eventHandlers.get(r.event);if(a)for(let e of[...a])e(r.payload,r);return{ok:!0,frame:r}}broadcast(e){for(let t of[...this.sinks])t(e)}static assertOpen(e){if(!e.open)throw Error(`Channel "${e.descriptor.name}" is closed`)}createHandle(n,r){return{descriptor:n,sendEvent:(i,a)=>{if(t.assertOpen(r),!r.declaredEvents.has(i))throw Error(`Channel "${n.name}" did not declare event "${i}"`);let o=r.seq;r.seq+=1,this.broadcast((0,e.encodeChannelEventFrame)({kind:`event`,channel:n.name,seq:o,event:i,payload:a}))},sendBinary:i=>{if(t.assertOpen(r),n.binary!==!0)throw Error(`Channel "${n.name}" did not declare binary payload support`);let a=r.seq;r.seq+=1,this.broadcast((0,e.encodeBinaryFrame)({kind:`binary`,channel:n.name,seq:a,payload:i}))},onEvent:(e,i)=>{if(t.assertOpen(r),!r.declaredEvents.has(e))throw Error(`Channel "${n.name}" did not declare event "${e}"`);let a=r.eventHandlers.get(e)??new Set,o=i;return a.add(o),r.eventHandlers.set(e,a),()=>{a.delete(o)}},onBinary:e=>(t.assertOpen(r),r.binaryHandlers.add(e),()=>{r.binaryHandlers.delete(e)}),close:()=>{r.open=!1,r.eventHandlers.clear(),r.binaryHandlers.clear(),this.channels.delete(n.name)}}}};const s=new Set([`localhost`,`127.0.0.1`,`::1`,`[::1]`]);function c(e){if(!e)return null;if(e.startsWith(`[`))return e.slice(0,e.indexOf(`]`)+1)||null;let t=e.lastIndexOf(`:`);return t===-1?e:e.slice(0,t)}function l(e,t){let n=c(e.headers.host);return n===null?!1:s.has(n)||t.has(n)}function u(e,t){let n=e.headers.origin;if(n===void 0||t.has(n))return!0;try{return s.has(new URL(n).hostname)}catch{return!1}}function d(e,t){if(!t)return!1;let n=Buffer.from(e),i=Buffer.from(t);return n.length===i.length&&(0,r.timingSafeEqual)(n,i)}function f(e){try{let t=new URL(e.url??``,`ws://127.0.0.1`).searchParams.get(`token`);if(t)return t}catch{}let t=e.headers[`sec-websocket-protocol`];return typeof t==`string`&&t.split(`,`)[0]?.trim()||null}function p(e){return(0,i.resolveAdmission)({...e.token===void 0?{}:{token:e.token},...e.open===!0?{open:!0}:{},...e.openReason===void 0?{}:{openReason:e.openReason}})}function m(e){return Array.isArray(e)?new Uint8Array(Buffer.concat(e)):e instanceof ArrayBuffer?new Uint8Array(e):new Uint8Array(e.buffer,e.byteOffset,e.byteLength)}var h=class{socket;cleanupProtocol=()=>void 0;detachSink=()=>void 0;closed=!1;stallClock=(0,e.createPendingStallClock)();deliver;constructor(t){this.socket=t,this.deliver=(0,e.createOutboundDelivery)(e=>this.rawSend(e),()=>this.close(),()=>this.socket.bufferedAmount,e.DEFAULT_MAX_PENDING_BYTES,this.stallClock)}deliverBinary=t=>{if(!this.closed&&this.socket.readyState===n.WebSocket.OPEN){if((0,e.isOverPendingBudget)(this.socket.bufferedAmount)||this.stallClock.observe(this.socket.bufferedAmount)!==void 0){this.close();return}this.socket.send(t,{binary:!0},e=>{e&&this.close()})}};rawSend(e){if(this.socket.readyState!==n.WebSocket.OPEN)throw Error(`WebSocket is not open`);this.socket.send(JSON.stringify(e),e=>{e&&this.close()})}bindProtocolCleanup(e){this.cleanupProtocol=e}bindSinkDetach(e){this.detachSink=e}close=()=>{this.closed||(this.closed=!0,this.detachSink(),this.cleanupProtocol(),(this.socket.readyState===n.WebSocket.OPEN||this.socket.readyState===n.WebSocket.CONNECTING)&&this.socket.close(1011,`session event delivery failed`))}};function g(e){return{...e.personalUsageReporter?{personalUsageReporter:e.personalUsageReporter}:{},...e.usageReporter?{usageReporter:e.usageReporter}:{},...e.storedSessionUsageReporter?{storedSessionUsageReporter:e.storedSessionUsageReporter}:{}}}function _(e){return{...e.driverId?{driverId:e.driverId}:{},...e.surface?{surface:e.surface}:{},...g(e),...e.sessionDirectory?{sessionDirectory:e.sessionDirectory}:{},...e.settingsReporter?{settingsReporter:e.settingsReporter}:{}}}function v(e){let{port:t,maxRetries:n}=e;return t!==void 0&&(typeof t!=`number`||t<1||t>65535)?!1:n===void 0||typeof n==`number`&&n>=0}function y(e,t){return Object.assign(Error(`${e} ${t}.`),{name:`TransportLifecycleError`,code:t,transportName:e})}var b=class{name=`ws`;lifecycle=Object.freeze({kind:`service`});defaultEnabled=!0;optionsSchema={port:{type:`number`,description:`WebSocket server port`,default:7070},maxRetries:{type:`number`,description:`Port retry attempts when port is occupied`,default:20}};session=null;stopFn=null;state=`detached`;startOperation;startCancelled=!1;port;maxRetries;token;allowedHosts;allowedOrigins;handlerOptions;sessionBinder;channels=new o;resolvedPort;constructor(e={}){this.port=e.port??7070,this.maxRetries=e.maxRetries??20;let t=p(e);t.token!==null&&(this.token=t.token),this.allowedHosts=new Set(e.allowedHosts??[]),this.allowedOrigins=new Set(e.allowedOrigins??[]),this.handlerOptions=_(e),this.sessionBinder=e.sessionBinder}attach(e){this.session=e,this.state=`attached`}registerChannel(e){return this.channels.registerChannel(e)}get boundPort(){return this.resolvedPort}get resolvedToken(){return this.token}async start(){if(!this.session)throw y(this.name,`not-attached`);if(this.state===`starting`||this.state===`ready`||this.state===`stopping`)throw y(this.name,`already-started`);this.state=`starting`,this.startCancelled=!1;let e=this.bindWithRetry(this.session,this.port,this.maxRetries);this.startOperation=e;let t;try{t=await e}catch(t){throw this.state===`starting`&&this.startOperation===e&&(this.startOperation=void 0,this.state=this.session?`attached`:`detached`),t}if(this.startCancelled||this.startOperation!==e)throw await t.stop(),this.state=this.session?`attached`:`detached`,Error(`WsTransport startup was stopped.`);this.stopFn=t.stop,this.resolvedPort=t.port,this.startOperation=void 0,this.state=`ready`}async stop(){if(this.state===`starting`){this.state=`stopping`,this.startCancelled=!0;try{await this.startOperation}catch{}}await this.stopFn?.(),this.stopFn=null,this.startOperation=void 0,this.resolvedPort=void 0,this.session=null,this.state=`detached`}configure(e){if(this.state!==`detached`&&this.state!==`attached`)throw y(this.name,`already-started`);if(!this.validateOptions(e))throw TypeError(`WsTransport options are invalid (port 1-65535, maxRetries >= 0).`);typeof e.port==`number`&&(this.port=e.port),typeof e.maxRetries==`number`&&(this.maxRetries=e.maxRetries)}validateOptions(e){return v(e)}bindConnection(e){if(!this.sessionBinder)return{binding:void 0};try{return{binding:this.sessionBinder.bind(`drive`)}}catch{return e.close(1013,`session unavailable`),null}}bindWithRetry(e,t,n){return this.tryBind(e,t).catch(r=>{if(r.code===`EADDRINUSE`&&n>0)return this.bindWithRetry(e,t+1,n-1);throw r})}tryBind(r,i){return new Promise((a,o)=>{let s=(0,t.createServer)((e,t)=>{t.writeHead(400).end(`WebSocket endpoint`)});s.on(`error`,e=>{s.close(),o(e)});let c=this.token,p=this.allowedHosts,g=this.allowedOrigins,_=this.channels;s.listen(i,`127.0.0.1`,()=>{let t=new n.WebSocketServer({server:s,verifyClient:(e,t)=>{if(!l(e.req,p)){t(!1,403,`Forbidden host`);return}if(!u(e.req,g)){t(!1,403,`Forbidden origin`);return}t(!0)}});t.on(`connection`,(t,n)=>{if(c!==void 0&&!d(c,f(n))){t.close(1008,`unauthorized`);return}let i=this.bindConnection(t);if(i===null)return;let{binding:a}=i,o=a?.session??r,s=new h(t),l=(0,e.createSessionMessageHandler)({session:o,deliver:s.deliver,...this.handlerOptions,...a?{sessionDirectory:a.directory}:{}});s.bindProtocolCleanup(()=>{l.cleanup(),a?.release()}),s.bindSinkDetach(_.addSink(e=>s.deliverBinary(e))),t.on(`message`,(e,t)=>{if(!t){l.onMessage(String(e));return}let n=_.receive(m(e));n.ok||s.deliver({type:`protocol_error`,message:n.error})}),t.on(`close`,s.close),t.on(`error`,s.close),s.deliver({type:`messages`,messages:o.getMessages(),...this.handlerOptions.driverId?{driverId:this.handlerOptions.driverId}:{}}),s.deliver({type:`execution_workspace_event`,snapshot:o.getExecutionWorkspaceSnapshot()})}),a({port:i,stop:()=>new Promise(e=>{for(let e of t.clients)e.close(1001,`server shutting down`);let n=setTimeout(()=>{for(let e of t.clients)e.terminate()},5e3);t.close(()=>{clearTimeout(n),s.close(()=>e())})})})})})}};exports.PayloadChannelRegistry=o,exports.WsTransport=b,exports.createWsTransport=a;
|
package/dist/node/index.d.cts
CHANGED
|
@@ -13,6 +13,7 @@ interface IWsTransportOptions {
|
|
|
13
13
|
usageReporter?: ISessionMessageHandlerOptions['usageReporter'];
|
|
14
14
|
storedSessionUsageReporter?: ISessionMessageHandlerOptions['storedSessionUsageReporter'];
|
|
15
15
|
sessionDirectory?: ISessionMessageHandlerOptions['sessionDirectory'];
|
|
16
|
+
settingsReporter?: ISessionMessageHandlerOptions['settingsReporter'];
|
|
16
17
|
driverId?: TDriverId;
|
|
17
18
|
surface?: TUsageSurface;
|
|
18
19
|
}
|
|
@@ -35,6 +36,8 @@ interface IWsTransportConfig {
|
|
|
35
36
|
storedSessionUsageReporter?: ISessionMessageHandlerOptions['storedSessionUsageReporter'];
|
|
36
37
|
/** Host-owned session directory (#3189): lets every connection list, start and switch sessions. */
|
|
37
38
|
sessionDirectory?: ISessionMessageHandlerOptions['sessionDirectory'];
|
|
39
|
+
/** #3282 §4a: host-owned read/write for the GUI Settings screen, forwarded unchanged. */
|
|
40
|
+
settingsReporter?: ISessionMessageHandlerOptions['settingsReporter'];
|
|
38
41
|
/**
|
|
39
42
|
* #3189: gives each connection a binding of its own — its own session and its own directory view —
|
|
40
43
|
* so one connection's switch moves only that connection. Released when the connection closes. When
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.cts","names":[],"sources":["../../src/ws-transport.ts","../../src/ws-transport-config.ts","../../src/ws-transport-configurable.ts","../../src/payload-channels.ts"],"mappings":";;;;;;UAqBiB;;EAEf,OAAO,SAAS;;EAEhB,mBAAmB,OAAO,OAAO;EACjC,wBAAwB;EACxB,gBAAgB;EAChB,6BAA6B;EAC7B,mBAAmB;EACnB,WAAW;EACX,UAAU;;UAGK,qBAAqB,kBAAkB;EACtD,aAAa;;iBAGC,kBAAkB,SAAS,sBAAsB;;;
|
|
1
|
+
{"version":3,"file":"index.d.cts","names":[],"sources":["../../src/ws-transport.ts","../../src/ws-transport-config.ts","../../src/ws-transport-configurable.ts","../../src/payload-channels.ts"],"mappings":";;;;;;UAqBiB;;EAEf,OAAO,SAAS;;EAEhB,mBAAmB,OAAO,OAAO;EACjC,wBAAwB;EACxB,gBAAgB;EAChB,6BAA6B;EAC7B,mBAAmB;EACnB,mBAAmB;EACnB,WAAW;EACX,UAAU;;UAGK,qBAAqB,kBAAkB;EACtD,aAAa;;iBAGC,kBAAkB,SAAS,sBAAsB;;;UCtBhD;;EAEf,WAAW;;EAEX,UAAU;;EAEV,wBAAwB;;EAExB,gBAAgB;;EAEhB,6BAA6B;;EAE7B,mBAAmB;;EAEnB,mBAAmB;;;;;;;EAOnB,gBAAgB,eAAe;EAC/B;EACA;;;;;;;;;;;EAWA;;;;;;;;;;EAUA;;EAEA;;;;;EAKA;;;;;;EAMA;;;;;;;;;cCpBW,uBAAuB,uBAAuB,mBAAmB;WACnE;WACA,WAAS;;;WACT;WACA;;;;;;;;;;;;UASD;UACA;UACA;UACA;UACA;UACA;UACA;mBACS;mBACA;mBACA;mBACA;mBACA;mBACA;UACT;EAEI,YAAA,SAAQ;EAapB,OAAO,SAAS;;;;;;;EAWhB,gBAAgB,gBAAgB,kBAC9B,YAAY,mBAAmB,WAC9B,gBAAgB;;MAKf;;;;;;MASA;EAIE,SAAS;EA8BT,QAAQ;;EAmBd,UAAU,SAAS,eAAe;EAWlC,gBAAgB,SAAS,eAAe;;;;;UAQhC;UAYA;UAYA;;;;;KC3LE,gBAAgB,OAAO;cAqBtB,kCAAkC;mBAC5B;mBACA;;;;;EAMjB,QAAQ,MAAM;EAOd,gBAAgB,gBAAgB,kBAC9B,YAAY,mBAAmB,WAC9B,gBAAgB;;;;;EAoBnB,QAAQ,OAAO,aAAa;;UAmCpB;;iBAKO;UAIP"}
|
package/dist/node/index.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ interface IWsTransportOptions {
|
|
|
13
13
|
usageReporter?: ISessionMessageHandlerOptions['usageReporter'];
|
|
14
14
|
storedSessionUsageReporter?: ISessionMessageHandlerOptions['storedSessionUsageReporter'];
|
|
15
15
|
sessionDirectory?: ISessionMessageHandlerOptions['sessionDirectory'];
|
|
16
|
+
settingsReporter?: ISessionMessageHandlerOptions['settingsReporter'];
|
|
16
17
|
driverId?: TDriverId;
|
|
17
18
|
surface?: TUsageSurface;
|
|
18
19
|
}
|
|
@@ -35,6 +36,8 @@ interface IWsTransportConfig {
|
|
|
35
36
|
storedSessionUsageReporter?: ISessionMessageHandlerOptions['storedSessionUsageReporter'];
|
|
36
37
|
/** Host-owned session directory (#3189): lets every connection list, start and switch sessions. */
|
|
37
38
|
sessionDirectory?: ISessionMessageHandlerOptions['sessionDirectory'];
|
|
39
|
+
/** #3282 §4a: host-owned read/write for the GUI Settings screen, forwarded unchanged. */
|
|
40
|
+
settingsReporter?: ISessionMessageHandlerOptions['settingsReporter'];
|
|
38
41
|
/**
|
|
39
42
|
* #3189: gives each connection a binding of its own — its own session and its own directory view —
|
|
40
43
|
* so one connection's switch moves only that connection. Released when the connection closes. When
|
package/dist/node/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","names":[],"sources":["../../src/ws-transport.ts","../../src/ws-transport-config.ts","../../src/ws-transport-configurable.ts","../../src/payload-channels.ts"],"mappings":";;;;;;UAqBiB;;EAEf,OAAO,SAAS;;EAEhB,mBAAmB,OAAO,OAAO;EACjC,wBAAwB;EACxB,gBAAgB;EAChB,6BAA6B;EAC7B,mBAAmB;EACnB,WAAW;EACX,UAAU;;UAGK,qBAAqB,kBAAkB;EACtD,aAAa;;iBAGC,kBAAkB,SAAS,sBAAsB;;;
|
|
1
|
+
{"version":3,"file":"index.d.ts","names":[],"sources":["../../src/ws-transport.ts","../../src/ws-transport-config.ts","../../src/ws-transport-configurable.ts","../../src/payload-channels.ts"],"mappings":";;;;;;UAqBiB;;EAEf,OAAO,SAAS;;EAEhB,mBAAmB,OAAO,OAAO;EACjC,wBAAwB;EACxB,gBAAgB;EAChB,6BAA6B;EAC7B,mBAAmB;EACnB,mBAAmB;EACnB,WAAW;EACX,UAAU;;UAGK,qBAAqB,kBAAkB;EACtD,aAAa;;iBAGC,kBAAkB,SAAS,sBAAsB;;;UCtBhD;;EAEf,WAAW;;EAEX,UAAU;;EAEV,wBAAwB;;EAExB,gBAAgB;;EAEhB,6BAA6B;;EAE7B,mBAAmB;;EAEnB,mBAAmB;;;;;;;EAOnB,gBAAgB,eAAe;EAC/B;EACA;;;;;;;;;;;EAWA;;;;;;;;;;EAUA;;EAEA;;;;;EAKA;;;;;;EAMA;;;;;;;;;cCpBW,uBAAuB,uBAAuB,mBAAmB;WACnE;WACA,WAAS;;;WACT;WACA;;;;;;;;;;;;UASD;UACA;UACA;UACA;UACA;UACA;UACA;mBACS;mBACA;mBACA;mBACA;mBACA;mBACA;UACT;EAEI,YAAA,SAAQ;EAapB,OAAO,SAAS;;;;;;;EAWhB,gBAAgB,gBAAgB,kBAC9B,YAAY,mBAAmB,WAC9B,gBAAgB;;MAKf;;;;;;MASA;EAIE,SAAS;EA8BT,QAAQ;;EAmBd,UAAU,SAAS,eAAe;EAWlC,gBAAgB,SAAS,eAAe;;;;;UAQhC;UAYA;UAYA;;;;;KC3LE,gBAAgB,OAAO;cAqBtB,kCAAkC;mBAC5B;mBACA;;;;;EAMjB,QAAQ,MAAM;EAOd,gBAAgB,gBAAgB,kBAC9B,YAAY,mBAAmB,WAC9B,gBAAgB;;;;;EAoBnB,QAAQ,OAAO,aAAa;;UAmCpB;;iBAKO;UAIP"}
|
package/dist/node/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{DEFAULT_MAX_PENDING_BYTES as e,createOutboundDelivery as t,createPendingStallClock as n,createSessionMessageHandler as r,decodeChannelFrame as i,encodeBinaryFrame as a,encodeChannelEventFrame as o,isOverPendingBudget as s}from"@robota-sdk/agent-transport";import{createServer as c}from"node:http";import{WebSocket as l,WebSocketServer as u}from"ws";import{timingSafeEqual as d}from"node:crypto";import{resolveAdmission as f}from"@robota-sdk/agent-transport/node";function p(e){let n=null,i=null,a=e=>Object.assign(Error(`WebSocket transport ${e}.`),{name:`TransportLifecycleError`,code:e,transportName:`ws`}),o={name:`ws`,lifecycle:Object.freeze({kind:`service`}),onMessage:null,attach(e){n=e},async start(){if(!n)throw a(`not-attached`);if(i)throw a(`already-started`);let s=t(e.send,(t,n)=>{c.cleanup(),i=null,o.onMessage=null,e.onDeliveryError?.(t,n)}),c=r({session:n,deliver:s,...e.driverId?{driverId:e.driverId}:{},...e.surface?{surface:e.surface}:{},...e.personalUsageReporter?{personalUsageReporter:e.personalUsageReporter}:{},...e.usageReporter?{usageReporter:e.usageReporter}:{},...e.storedSessionUsageReporter?{storedSessionUsageReporter:e.storedSessionUsageReporter}:{},...e.sessionDirectory?{sessionDirectory:e.sessionDirectory}:{}});i=c.cleanup,this.onMessage=c.onMessage},async stop(){i?.(),i=null,this.onMessage=null,n=null}};return o}var m=class e{channels=new Map;sinks=new Set;addSink(e){return this.sinks.add(e),()=>{this.sinks.delete(e)}}registerChannel(e){if(this.channels.has(e.name))throw Error(`Channel "${e.name}" is already registered on this transport`);let t={descriptor:e,declaredEvents:new Set(e.events),eventHandlers:new Map,binaryHandlers:new Set,seq:0,open:!0};return this.channels.set(e.name,t),this.createHandle(e,t)}receive(e){let t=i(e);if(!t.ok)return t;let{frame:n}=t,r=this.channels.get(n.channel);if(!r||!r.open)return{ok:!1,error:`No channel registered for "${n.channel}"`};if(n.kind===`binary`){if(r.descriptor.binary!==!0)return{ok:!1,error:`Channel "${n.channel}" did not declare binary payload support`};for(let e of[...r.binaryHandlers])e(n);return{ok:!0,frame:n}}if(!r.declaredEvents.has(n.event))return{ok:!1,error:`Channel "${n.channel}" did not declare event "${n.event}"`};let a=r.eventHandlers.get(n.event);if(a)for(let e of[...a])e(n.payload,n);return{ok:!0,frame:n}}broadcast(e){for(let t of[...this.sinks])t(e)}static assertOpen(e){if(!e.open)throw Error(`Channel "${e.descriptor.name}" is closed`)}createHandle(t,n){return{descriptor:t,sendEvent:(r,i)=>{if(e.assertOpen(n),!n.declaredEvents.has(r))throw Error(`Channel "${t.name}" did not declare event "${r}"`);let a=n.seq;n.seq+=1,this.broadcast(o({kind:`event`,channel:t.name,seq:a,event:r,payload:i}))},sendBinary:r=>{if(e.assertOpen(n),t.binary!==!0)throw Error(`Channel "${t.name}" did not declare binary payload support`);let i=n.seq;n.seq+=1,this.broadcast(a({kind:`binary`,channel:t.name,seq:i,payload:r}))},onEvent:(r,i)=>{if(e.assertOpen(n),!n.declaredEvents.has(r))throw Error(`Channel "${t.name}" did not declare event "${r}"`);let a=n.eventHandlers.get(r)??new Set,o=i;return a.add(o),n.eventHandlers.set(r,a),()=>{a.delete(o)}},onBinary:t=>(e.assertOpen(n),n.binaryHandlers.add(t),()=>{n.binaryHandlers.delete(t)}),close:()=>{n.open=!1,n.eventHandlers.clear(),n.binaryHandlers.clear(),this.channels.delete(t.name)}}}};const h=new Set([`localhost`,`127.0.0.1`,`::1`,`[::1]`]);function g(e){if(!e)return null;if(e.startsWith(`[`))return e.slice(0,e.indexOf(`]`)+1)||null;let t=e.lastIndexOf(`:`);return t===-1?e:e.slice(0,t)}function _(e,t){let n=g(e.headers.host);return n===null?!1:h.has(n)||t.has(n)}function v(e,t){let n=e.headers.origin;if(n===void 0||t.has(n))return!0;try{return h.has(new URL(n).hostname)}catch{return!1}}function y(e,t){if(!t)return!1;let n=Buffer.from(e),r=Buffer.from(t);return n.length===r.length&&d(n,r)}function b(e){try{let t=new URL(e.url??``,`ws://127.0.0.1`).searchParams.get(`token`);if(t)return t}catch{}let t=e.headers[`sec-websocket-protocol`];return typeof t==`string`&&t.split(`,`)[0]?.trim()||null}function x(e){return f({...e.token===void 0?{}:{token:e.token},...e.open===!0?{open:!0}:{},...e.openReason===void 0?{}:{openReason:e.openReason}})}function S(e){return Array.isArray(e)?new Uint8Array(Buffer.concat(e)):e instanceof ArrayBuffer?new Uint8Array(e):new Uint8Array(e.buffer,e.byteOffset,e.byteLength)}var C=class{socket;cleanupProtocol=()=>void 0;detachSink=()=>void 0;closed=!1;stallClock=n();deliver;constructor(n){this.socket=n,this.deliver=t(e=>this.rawSend(e),()=>this.close(),()=>this.socket.bufferedAmount,e,this.stallClock)}deliverBinary=e=>{if(!this.closed&&this.socket.readyState===l.OPEN){if(s(this.socket.bufferedAmount)||this.stallClock.observe(this.socket.bufferedAmount)!==void 0){this.close();return}this.socket.send(e,{binary:!0},e=>{e&&this.close()})}};rawSend(e){if(this.socket.readyState!==l.OPEN)throw Error(`WebSocket is not open`);this.socket.send(JSON.stringify(e),e=>{e&&this.close()})}bindProtocolCleanup(e){this.cleanupProtocol=e}bindSinkDetach(e){this.detachSink=e}close=()=>{this.closed||(this.closed=!0,this.detachSink(),this.cleanupProtocol(),(this.socket.readyState===l.OPEN||this.socket.readyState===l.CONNECTING)&&this.socket.close(1011,`session event delivery failed`))}};function w(e){return{...e.personalUsageReporter?{personalUsageReporter:e.personalUsageReporter}:{},...e.usageReporter?{usageReporter:e.usageReporter}:{},...e.storedSessionUsageReporter?{storedSessionUsageReporter:e.storedSessionUsageReporter}:{}}}function T(e){return{...e.driverId?{driverId:e.driverId}:{},...e.surface?{surface:e.surface}:{},...w(e),...e.sessionDirectory?{sessionDirectory:e.sessionDirectory}:{}}}function E(e){let{port:t,maxRetries:n}=e;return t!==void 0&&(typeof t!=`number`||t<1||t>65535)?!1:n===void 0||typeof n==`number`&&n>=0}function D(e,t){return Object.assign(Error(`${e} ${t}.`),{name:`TransportLifecycleError`,code:t,transportName:e})}var O=class{name=`ws`;lifecycle=Object.freeze({kind:`service`});defaultEnabled=!0;optionsSchema={port:{type:`number`,description:`WebSocket server port`,default:7070},maxRetries:{type:`number`,description:`Port retry attempts when port is occupied`,default:20}};session=null;stopFn=null;state=`detached`;startOperation;startCancelled=!1;port;maxRetries;token;allowedHosts;allowedOrigins;handlerOptions;sessionBinder;channels=new m;resolvedPort;constructor(e={}){this.port=e.port??7070,this.maxRetries=e.maxRetries??20;let t=x(e);t.token!==null&&(this.token=t.token),this.allowedHosts=new Set(e.allowedHosts??[]),this.allowedOrigins=new Set(e.allowedOrigins??[]),this.handlerOptions=T(e),this.sessionBinder=e.sessionBinder}attach(e){this.session=e,this.state=`attached`}registerChannel(e){return this.channels.registerChannel(e)}get boundPort(){return this.resolvedPort}get resolvedToken(){return this.token}async start(){if(!this.session)throw D(this.name,`not-attached`);if(this.state===`starting`||this.state===`ready`||this.state===`stopping`)throw D(this.name,`already-started`);this.state=`starting`,this.startCancelled=!1;let e=this.bindWithRetry(this.session,this.port,this.maxRetries);this.startOperation=e;let t;try{t=await e}catch(t){throw this.state===`starting`&&this.startOperation===e&&(this.startOperation=void 0,this.state=this.session?`attached`:`detached`),t}if(this.startCancelled||this.startOperation!==e)throw await t.stop(),this.state=this.session?`attached`:`detached`,Error(`WsTransport startup was stopped.`);this.stopFn=t.stop,this.resolvedPort=t.port,this.startOperation=void 0,this.state=`ready`}async stop(){if(this.state===`starting`){this.state=`stopping`,this.startCancelled=!0;try{await this.startOperation}catch{}}await this.stopFn?.(),this.stopFn=null,this.startOperation=void 0,this.resolvedPort=void 0,this.session=null,this.state=`detached`}configure(e){if(this.state!==`detached`&&this.state!==`attached`)throw D(this.name,`already-started`);if(!this.validateOptions(e))throw TypeError(`WsTransport options are invalid (port 1-65535, maxRetries >= 0).`);typeof e.port==`number`&&(this.port=e.port),typeof e.maxRetries==`number`&&(this.maxRetries=e.maxRetries)}validateOptions(e){return E(e)}bindConnection(e){if(!this.sessionBinder)return{binding:void 0};try{return{binding:this.sessionBinder.bind(`drive`)}}catch{return e.close(1013,`session unavailable`),null}}bindWithRetry(e,t,n){return this.tryBind(e,t).catch(r=>{if(r.code===`EADDRINUSE`&&n>0)return this.bindWithRetry(e,t+1,n-1);throw r})}tryBind(e,t){return new Promise((n,i)=>{let a=c((e,t)=>{t.writeHead(400).end(`WebSocket endpoint`)});a.on(`error`,e=>{a.close(),i(e)});let o=this.token,s=this.allowedHosts,l=this.allowedOrigins,d=this.channels;a.listen(t,`127.0.0.1`,()=>{let i=new u({server:a,verifyClient:(e,t)=>{if(!_(e.req,s)){t(!1,403,`Forbidden host`);return}if(!v(e.req,l)){t(!1,403,`Forbidden origin`);return}t(!0)}});i.on(`connection`,(t,n)=>{if(o!==void 0&&!y(o,b(n))){t.close(1008,`unauthorized`);return}let i=this.bindConnection(t);if(i===null)return;let{binding:a}=i,s=a?.session??e,c=new C(t),l=r({session:s,deliver:c.deliver,...this.handlerOptions,...a?{sessionDirectory:a.directory}:{}});c.bindProtocolCleanup(()=>{l.cleanup(),a?.release()}),c.bindSinkDetach(d.addSink(e=>c.deliverBinary(e))),t.on(`message`,(e,t)=>{if(!t){l.onMessage(String(e));return}let n=d.receive(S(e));n.ok||c.deliver({type:`protocol_error`,message:n.error})}),t.on(`close`,c.close),t.on(`error`,c.close),c.deliver({type:`messages`,messages:s.getMessages()}),c.deliver({type:`execution_workspace_event`,snapshot:s.getExecutionWorkspaceSnapshot()})}),n({port:t,stop:()=>new Promise(e=>{for(let e of i.clients)e.close(1001,`server shutting down`);let t=setTimeout(()=>{for(let e of i.clients)e.terminate()},5e3);i.close(()=>{clearTimeout(t),a.close(()=>e())})})})})})}};export{m as PayloadChannelRegistry,O as WsTransport,p as createWsTransport};
|
|
1
|
+
import{DEFAULT_MAX_PENDING_BYTES as e,createOutboundDelivery as t,createPendingStallClock as n,createSessionMessageHandler as r,decodeChannelFrame as i,encodeBinaryFrame as a,encodeChannelEventFrame as o,isOverPendingBudget as s}from"@robota-sdk/agent-transport";import{createServer as c}from"node:http";import{WebSocket as l,WebSocketServer as u}from"ws";import{timingSafeEqual as d}from"node:crypto";import{resolveAdmission as f}from"@robota-sdk/agent-transport/node";function p(e){let n=null,i=null,a=e=>Object.assign(Error(`WebSocket transport ${e}.`),{name:`TransportLifecycleError`,code:e,transportName:`ws`}),o={name:`ws`,lifecycle:Object.freeze({kind:`service`}),onMessage:null,attach(e){n=e},async start(){if(!n)throw a(`not-attached`);if(i)throw a(`already-started`);let s=t(e.send,(t,n)=>{c.cleanup(),i=null,o.onMessage=null,e.onDeliveryError?.(t,n)}),c=r({session:n,deliver:s,...e.driverId?{driverId:e.driverId}:{},...e.surface?{surface:e.surface}:{},...e.personalUsageReporter?{personalUsageReporter:e.personalUsageReporter}:{},...e.usageReporter?{usageReporter:e.usageReporter}:{},...e.storedSessionUsageReporter?{storedSessionUsageReporter:e.storedSessionUsageReporter}:{},...e.sessionDirectory?{sessionDirectory:e.sessionDirectory}:{},...e.settingsReporter?{settingsReporter:e.settingsReporter}:{}});i=c.cleanup,this.onMessage=c.onMessage},async stop(){i?.(),i=null,this.onMessage=null,n=null}};return o}var m=class e{channels=new Map;sinks=new Set;addSink(e){return this.sinks.add(e),()=>{this.sinks.delete(e)}}registerChannel(e){if(this.channels.has(e.name))throw Error(`Channel "${e.name}" is already registered on this transport`);let t={descriptor:e,declaredEvents:new Set(e.events),eventHandlers:new Map,binaryHandlers:new Set,seq:0,open:!0};return this.channels.set(e.name,t),this.createHandle(e,t)}receive(e){let t=i(e);if(!t.ok)return t;let{frame:n}=t,r=this.channels.get(n.channel);if(!r||!r.open)return{ok:!1,error:`No channel registered for "${n.channel}"`};if(n.kind===`binary`){if(r.descriptor.binary!==!0)return{ok:!1,error:`Channel "${n.channel}" did not declare binary payload support`};for(let e of[...r.binaryHandlers])e(n);return{ok:!0,frame:n}}if(!r.declaredEvents.has(n.event))return{ok:!1,error:`Channel "${n.channel}" did not declare event "${n.event}"`};let a=r.eventHandlers.get(n.event);if(a)for(let e of[...a])e(n.payload,n);return{ok:!0,frame:n}}broadcast(e){for(let t of[...this.sinks])t(e)}static assertOpen(e){if(!e.open)throw Error(`Channel "${e.descriptor.name}" is closed`)}createHandle(t,n){return{descriptor:t,sendEvent:(r,i)=>{if(e.assertOpen(n),!n.declaredEvents.has(r))throw Error(`Channel "${t.name}" did not declare event "${r}"`);let a=n.seq;n.seq+=1,this.broadcast(o({kind:`event`,channel:t.name,seq:a,event:r,payload:i}))},sendBinary:r=>{if(e.assertOpen(n),t.binary!==!0)throw Error(`Channel "${t.name}" did not declare binary payload support`);let i=n.seq;n.seq+=1,this.broadcast(a({kind:`binary`,channel:t.name,seq:i,payload:r}))},onEvent:(r,i)=>{if(e.assertOpen(n),!n.declaredEvents.has(r))throw Error(`Channel "${t.name}" did not declare event "${r}"`);let a=n.eventHandlers.get(r)??new Set,o=i;return a.add(o),n.eventHandlers.set(r,a),()=>{a.delete(o)}},onBinary:t=>(e.assertOpen(n),n.binaryHandlers.add(t),()=>{n.binaryHandlers.delete(t)}),close:()=>{n.open=!1,n.eventHandlers.clear(),n.binaryHandlers.clear(),this.channels.delete(t.name)}}}};const h=new Set([`localhost`,`127.0.0.1`,`::1`,`[::1]`]);function g(e){if(!e)return null;if(e.startsWith(`[`))return e.slice(0,e.indexOf(`]`)+1)||null;let t=e.lastIndexOf(`:`);return t===-1?e:e.slice(0,t)}function _(e,t){let n=g(e.headers.host);return n===null?!1:h.has(n)||t.has(n)}function v(e,t){let n=e.headers.origin;if(n===void 0||t.has(n))return!0;try{return h.has(new URL(n).hostname)}catch{return!1}}function y(e,t){if(!t)return!1;let n=Buffer.from(e),r=Buffer.from(t);return n.length===r.length&&d(n,r)}function b(e){try{let t=new URL(e.url??``,`ws://127.0.0.1`).searchParams.get(`token`);if(t)return t}catch{}let t=e.headers[`sec-websocket-protocol`];return typeof t==`string`&&t.split(`,`)[0]?.trim()||null}function x(e){return f({...e.token===void 0?{}:{token:e.token},...e.open===!0?{open:!0}:{},...e.openReason===void 0?{}:{openReason:e.openReason}})}function S(e){return Array.isArray(e)?new Uint8Array(Buffer.concat(e)):e instanceof ArrayBuffer?new Uint8Array(e):new Uint8Array(e.buffer,e.byteOffset,e.byteLength)}var C=class{socket;cleanupProtocol=()=>void 0;detachSink=()=>void 0;closed=!1;stallClock=n();deliver;constructor(n){this.socket=n,this.deliver=t(e=>this.rawSend(e),()=>this.close(),()=>this.socket.bufferedAmount,e,this.stallClock)}deliverBinary=e=>{if(!this.closed&&this.socket.readyState===l.OPEN){if(s(this.socket.bufferedAmount)||this.stallClock.observe(this.socket.bufferedAmount)!==void 0){this.close();return}this.socket.send(e,{binary:!0},e=>{e&&this.close()})}};rawSend(e){if(this.socket.readyState!==l.OPEN)throw Error(`WebSocket is not open`);this.socket.send(JSON.stringify(e),e=>{e&&this.close()})}bindProtocolCleanup(e){this.cleanupProtocol=e}bindSinkDetach(e){this.detachSink=e}close=()=>{this.closed||(this.closed=!0,this.detachSink(),this.cleanupProtocol(),(this.socket.readyState===l.OPEN||this.socket.readyState===l.CONNECTING)&&this.socket.close(1011,`session event delivery failed`))}};function w(e){return{...e.personalUsageReporter?{personalUsageReporter:e.personalUsageReporter}:{},...e.usageReporter?{usageReporter:e.usageReporter}:{},...e.storedSessionUsageReporter?{storedSessionUsageReporter:e.storedSessionUsageReporter}:{}}}function T(e){return{...e.driverId?{driverId:e.driverId}:{},...e.surface?{surface:e.surface}:{},...w(e),...e.sessionDirectory?{sessionDirectory:e.sessionDirectory}:{},...e.settingsReporter?{settingsReporter:e.settingsReporter}:{}}}function E(e){let{port:t,maxRetries:n}=e;return t!==void 0&&(typeof t!=`number`||t<1||t>65535)?!1:n===void 0||typeof n==`number`&&n>=0}function D(e,t){return Object.assign(Error(`${e} ${t}.`),{name:`TransportLifecycleError`,code:t,transportName:e})}var O=class{name=`ws`;lifecycle=Object.freeze({kind:`service`});defaultEnabled=!0;optionsSchema={port:{type:`number`,description:`WebSocket server port`,default:7070},maxRetries:{type:`number`,description:`Port retry attempts when port is occupied`,default:20}};session=null;stopFn=null;state=`detached`;startOperation;startCancelled=!1;port;maxRetries;token;allowedHosts;allowedOrigins;handlerOptions;sessionBinder;channels=new m;resolvedPort;constructor(e={}){this.port=e.port??7070,this.maxRetries=e.maxRetries??20;let t=x(e);t.token!==null&&(this.token=t.token),this.allowedHosts=new Set(e.allowedHosts??[]),this.allowedOrigins=new Set(e.allowedOrigins??[]),this.handlerOptions=T(e),this.sessionBinder=e.sessionBinder}attach(e){this.session=e,this.state=`attached`}registerChannel(e){return this.channels.registerChannel(e)}get boundPort(){return this.resolvedPort}get resolvedToken(){return this.token}async start(){if(!this.session)throw D(this.name,`not-attached`);if(this.state===`starting`||this.state===`ready`||this.state===`stopping`)throw D(this.name,`already-started`);this.state=`starting`,this.startCancelled=!1;let e=this.bindWithRetry(this.session,this.port,this.maxRetries);this.startOperation=e;let t;try{t=await e}catch(t){throw this.state===`starting`&&this.startOperation===e&&(this.startOperation=void 0,this.state=this.session?`attached`:`detached`),t}if(this.startCancelled||this.startOperation!==e)throw await t.stop(),this.state=this.session?`attached`:`detached`,Error(`WsTransport startup was stopped.`);this.stopFn=t.stop,this.resolvedPort=t.port,this.startOperation=void 0,this.state=`ready`}async stop(){if(this.state===`starting`){this.state=`stopping`,this.startCancelled=!0;try{await this.startOperation}catch{}}await this.stopFn?.(),this.stopFn=null,this.startOperation=void 0,this.resolvedPort=void 0,this.session=null,this.state=`detached`}configure(e){if(this.state!==`detached`&&this.state!==`attached`)throw D(this.name,`already-started`);if(!this.validateOptions(e))throw TypeError(`WsTransport options are invalid (port 1-65535, maxRetries >= 0).`);typeof e.port==`number`&&(this.port=e.port),typeof e.maxRetries==`number`&&(this.maxRetries=e.maxRetries)}validateOptions(e){return E(e)}bindConnection(e){if(!this.sessionBinder)return{binding:void 0};try{return{binding:this.sessionBinder.bind(`drive`)}}catch{return e.close(1013,`session unavailable`),null}}bindWithRetry(e,t,n){return this.tryBind(e,t).catch(r=>{if(r.code===`EADDRINUSE`&&n>0)return this.bindWithRetry(e,t+1,n-1);throw r})}tryBind(e,t){return new Promise((n,i)=>{let a=c((e,t)=>{t.writeHead(400).end(`WebSocket endpoint`)});a.on(`error`,e=>{a.close(),i(e)});let o=this.token,s=this.allowedHosts,l=this.allowedOrigins,d=this.channels;a.listen(t,`127.0.0.1`,()=>{let i=new u({server:a,verifyClient:(e,t)=>{if(!_(e.req,s)){t(!1,403,`Forbidden host`);return}if(!v(e.req,l)){t(!1,403,`Forbidden origin`);return}t(!0)}});i.on(`connection`,(t,n)=>{if(o!==void 0&&!y(o,b(n))){t.close(1008,`unauthorized`);return}let i=this.bindConnection(t);if(i===null)return;let{binding:a}=i,s=a?.session??e,c=new C(t),l=r({session:s,deliver:c.deliver,...this.handlerOptions,...a?{sessionDirectory:a.directory}:{}});c.bindProtocolCleanup(()=>{l.cleanup(),a?.release()}),c.bindSinkDetach(d.addSink(e=>c.deliverBinary(e))),t.on(`message`,(e,t)=>{if(!t){l.onMessage(String(e));return}let n=d.receive(S(e));n.ok||c.deliver({type:`protocol_error`,message:n.error})}),t.on(`close`,c.close),t.on(`error`,c.close),c.deliver({type:`messages`,messages:s.getMessages(),...this.handlerOptions.driverId?{driverId:this.handlerOptions.driverId}:{}}),c.deliver({type:`execution_workspace_event`,snapshot:s.getExecutionWorkspaceSnapshot()})}),n({port:t,stop:()=>new Promise(e=>{for(let e of i.clients)e.close(1001,`server shutting down`);let t=setTimeout(()=>{for(let e of i.clients)e.terminate()},5e3);i.close(()=>{clearTimeout(t),a.close(()=>e())})})})})})}};export{m as PayloadChannelRegistry,O as WsTransport,p as createWsTransport};
|
|
2
2
|
//# sourceMappingURL=index.js.map
|
package/dist/node/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../../src/ws-transport.ts","../../src/payload-channels.ts","../../src/ws-connection-guards.ts","../../src/ws-message-data.ts","../../src/ws-session-delivery.ts","../../src/ws-transport-config.ts","../../src/ws-transport-configurable.ts"],"sourcesContent":["/**\n * ITransportAdapter implementation for WebSocket transport.\n *\n * Wraps createSessionMessageHandler into the unified ITransportAdapter interface.\n * After start(), the consumer must wire onMessage to their WebSocket.\n */\n\nimport { createOutboundDelivery, createSessionMessageHandler } from '@robota-sdk/agent-transport';\n\nimport type { TUsageSurface } from '@robota-sdk/agent-interface-analytics';\nimport type { TDriverId } from '@robota-sdk/agent-interface-session';\nimport type {\n ITransportAdapter,\n ITransportLifecycleError,\n} from '@robota-sdk/agent-interface-transport';\nimport type {\n IProtocolSession,\n ISessionMessageHandlerOptions,\n TServerMessage,\n} from '@robota-sdk/agent-transport';\n\nexport interface IWsTransportOptions {\n /** Send a JSON message to the connected WebSocket client. */\n send: (message: TServerMessage) => void;\n /** Owning socket lifecycle callback for outbound session-event delivery failures. */\n onDeliveryError?: (error: Error, event: string) => void;\n personalUsageReporter?: ISessionMessageHandlerOptions['personalUsageReporter'];\n usageReporter?: ISessionMessageHandlerOptions['usageReporter'];\n storedSessionUsageReporter?: ISessionMessageHandlerOptions['storedSessionUsageReporter'];\n sessionDirectory?: ISessionMessageHandlerOptions['sessionDirectory'];\n driverId?: TDriverId;\n surface?: TUsageSurface;\n}\n\nexport interface IWsTransport extends ITransportAdapter<IProtocolSession> {\n onMessage: ((data: string) => void) | null;\n}\n\nexport function createWsTransport(options: IWsTransportOptions): IWsTransport {\n let session: IProtocolSession | null = null;\n let cleanup: (() => void) | null = null;\n const lifecycleError = (code: ITransportLifecycleError['code']): ITransportLifecycleError =>\n Object.assign(new Error(`WebSocket transport ${code}.`), {\n name: 'TransportLifecycleError' as const,\n code,\n transportName: 'ws',\n });\n\n const transport: IWsTransport = {\n name: 'ws',\n lifecycle: Object.freeze({ kind: 'service' }),\n onMessage: null,\n attach(s: IProtocolSession) {\n session = s;\n },\n async start() {\n if (!session) throw lifecycleError('not-attached');\n if (cleanup) throw lifecycleError('already-started');\n // ARCH-030: this adapter is the carrier here, so it builds the connection's outbound boundary\n // from its own injected sink and its own failure policy, then passes it down.\n const deliver = createOutboundDelivery(options.send, (error, event) => {\n handler.cleanup();\n cleanup = null;\n transport.onMessage = null;\n options.onDeliveryError?.(error, event);\n });\n const handler = createSessionMessageHandler({\n session,\n deliver,\n ...(options.driverId ? { driverId: options.driverId } : {}),\n ...(options.surface ? { surface: options.surface } : {}),\n ...(options.personalUsageReporter\n ? { personalUsageReporter: options.personalUsageReporter }\n : {}),\n ...(options.usageReporter ? { usageReporter: options.usageReporter } : {}),\n ...(options.storedSessionUsageReporter\n ? { storedSessionUsageReporter: options.storedSessionUsageReporter }\n : {}),\n ...(options.sessionDirectory ? { sessionDirectory: options.sessionDirectory } : {}),\n });\n cleanup = handler.cleanup;\n this.onMessage = handler.onMessage;\n },\n async stop() {\n cleanup?.();\n cleanup = null;\n this.onMessage = null;\n session = null;\n },\n };\n return transport;\n}\n","/**\n * Payload-agnostic channel registry (TRANS-001) — the transport-side half of\n * {@link IPayloadChannelHost}.\n *\n * Owns the multiplexing the carrier needs and nothing else: which channels exist, what each one\n * declared, per-channel outbound `seq`, the attached connection sinks, and inbound routing. It is\n * completely blind to payload semantics — a channel carrying audio, a file, or app events is the\n * same object to this class.\n *\n * Every rejection is EXPLICIT: an undeclared event, binary traffic on a text-only channel, or a\n * frame for an unregistered channel comes back as a stated error the carrier reports to the peer.\n * Nothing is silently dropped or defaulted.\n */\n\nimport {\n decodeChannelFrame,\n encodeBinaryFrame,\n encodeChannelEventFrame,\n} from '@robota-sdk/agent-transport';\n\nimport type {\n IBinaryFrame,\n IChannelDescriptor,\n IChannelEventFrame,\n IPayloadChannel,\n IPayloadChannelHost,\n TChannelEventMap,\n TChannelReceiveResult,\n} from '@robota-sdk/agent-interface-transport';\n\n/** Delivers an encoded frame to one attached connection. */\nexport type TChannelSink = (frame: Uint8Array) => void;\n\ntype TBinaryHandler = (frame: IBinaryFrame) => void;\n/**\n * Storage type for a consumer's event handler. `never` parameters make every concrete\n * `(payload: TEvents[K], frame: IChannelEventFrame<TEvents[K]>) => void` assignable to it, so the\n * per-channel handler sets stay heterogeneous without an `unknown` double-cast. The declared-event\n * check at both `onEvent` and `receive` is what keeps the erased types honest at runtime.\n */\ntype TEventHandler = (payload: never, frame: never) => void;\n\n/** Per-channel mutable state. Kept private to the registry — consumers only see `IPayloadChannel`. */\ninterface IChannelState {\n readonly descriptor: IChannelDescriptor;\n readonly declaredEvents: ReadonlySet<string>;\n readonly eventHandlers: Map<string, Set<TEventHandler>>;\n readonly binaryHandlers: Set<TBinaryHandler>;\n seq: number;\n open: boolean;\n}\n\nexport class PayloadChannelRegistry implements IPayloadChannelHost {\n private readonly channels = new Map<string, IChannelState>();\n private readonly sinks = new Set<TChannelSink>();\n\n /**\n * Attach a connection. Every frame a channel sends is fanned out to all attached sinks; the\n * returned function detaches (call it on socket close).\n */\n addSink(sink: TChannelSink): () => void {\n this.sinks.add(sink);\n return (): void => {\n this.sinks.delete(sink);\n };\n }\n\n registerChannel<TEvents extends TChannelEventMap>(\n descriptor: IChannelDescriptor<TEvents>,\n ): IPayloadChannel<TEvents> {\n if (this.channels.has(descriptor.name)) {\n throw new Error(`Channel \"${descriptor.name}\" is already registered on this transport`);\n }\n const state: IChannelState = {\n descriptor: descriptor as IChannelDescriptor,\n declaredEvents: new Set<string>(descriptor.events),\n eventHandlers: new Map(),\n binaryHandlers: new Set(),\n seq: 0,\n open: true,\n };\n this.channels.set(descriptor.name, state);\n return this.createHandle<TEvents>(descriptor, state);\n }\n\n /**\n * Route one inbound encoded frame to its channel's handlers. The result states exactly what\n * happened so the carrier can answer the peer (a `protocol_error`) instead of dropping it.\n */\n receive(bytes: Uint8Array): TChannelReceiveResult {\n const decoded = decodeChannelFrame(bytes);\n if (!decoded.ok) return decoded;\n\n const { frame } = decoded;\n const state = this.channels.get(frame.channel);\n if (!state || !state.open) {\n return { ok: false, error: `No channel registered for \"${frame.channel}\"` };\n }\n\n if (frame.kind === 'binary') {\n if (state.descriptor.binary !== true) {\n return {\n ok: false,\n error: `Channel \"${frame.channel}\" did not declare binary payload support`,\n };\n }\n for (const handler of [...state.binaryHandlers]) handler(frame);\n return { ok: true, frame };\n }\n\n if (!state.declaredEvents.has(frame.event)) {\n return {\n ok: false,\n error: `Channel \"${frame.channel}\" did not declare event \"${frame.event}\"`,\n };\n }\n const handlers = state.eventHandlers.get(frame.event);\n if (handlers) {\n for (const handler of [...handlers]) handler(frame.payload as never, frame as never);\n }\n return { ok: true, frame };\n }\n\n /** Fan one encoded frame out to every attached connection. */\n private broadcast(frame: Uint8Array): void {\n for (const sink of [...this.sinks]) sink(frame);\n }\n\n /** Assert the channel is still open before any send/subscribe. */\n private static assertOpen(state: IChannelState): void {\n if (!state.open) throw new Error(`Channel \"${state.descriptor.name}\" is closed`);\n }\n\n private createHandle<TEvents extends TChannelEventMap>(\n descriptor: IChannelDescriptor<TEvents>,\n state: IChannelState,\n ): IPayloadChannel<TEvents> {\n return {\n descriptor,\n\n sendEvent: <K extends keyof TEvents & string>(event: K, payload: TEvents[K]): void => {\n PayloadChannelRegistry.assertOpen(state);\n if (!state.declaredEvents.has(event)) {\n throw new Error(`Channel \"${descriptor.name}\" did not declare event \"${event}\"`);\n }\n const seq = state.seq;\n state.seq += 1;\n this.broadcast(\n encodeChannelEventFrame({\n kind: 'event',\n channel: descriptor.name,\n seq,\n event,\n payload,\n }),\n );\n },\n\n sendBinary: (payload: Uint8Array): void => {\n PayloadChannelRegistry.assertOpen(state);\n if (descriptor.binary !== true) {\n throw new Error(`Channel \"${descriptor.name}\" did not declare binary payload support`);\n }\n const seq = state.seq;\n state.seq += 1;\n this.broadcast(\n encodeBinaryFrame({ kind: 'binary', channel: descriptor.name, seq, payload }),\n );\n },\n\n onEvent: <K extends keyof TEvents & string>(\n event: K,\n handler: (payload: TEvents[K], frame: IChannelEventFrame<TEvents[K]>) => void,\n ): (() => void) => {\n PayloadChannelRegistry.assertOpen(state);\n if (!state.declaredEvents.has(event)) {\n throw new Error(`Channel \"${descriptor.name}\" did not declare event \"${event}\"`);\n }\n const set = state.eventHandlers.get(event) ?? new Set<TEventHandler>();\n const typed: TEventHandler = handler;\n set.add(typed);\n state.eventHandlers.set(event, set);\n return (): void => {\n set.delete(typed);\n };\n },\n\n onBinary: (handler: (frame: IBinaryFrame) => void): (() => void) => {\n PayloadChannelRegistry.assertOpen(state);\n state.binaryHandlers.add(handler);\n return (): void => {\n state.binaryHandlers.delete(handler);\n };\n },\n\n close: (): void => {\n state.open = false;\n state.eventHandlers.clear();\n state.binaryHandlers.clear();\n this.channels.delete(descriptor.name);\n },\n };\n }\n}\n","/**\n * Connection-admission guards for the WS transport (SEC-001 / GUI-002).\n *\n * Pure, socket-free predicates over the HTTP upgrade request — token minting/comparison plus the\n * `Host` and `Origin` allow-lists. Split out of `ws-transport-configurable.ts` so the transport file\n * carries lifecycle and frame routing only; the admission policy is independently readable and the\n * transport module stays within the file-size ratchet.\n */\n\nimport { timingSafeEqual } from 'node:crypto';\n\nimport { resolveAdmission } from '@robota-sdk/agent-transport/node';\n\nimport type { ITransportAdmission } from '@robota-sdk/agent-interface-transport';\nimport type { IncomingMessage } from 'node:http';\n\n/**\n * Host names that are always loopback (port stripped by the caller). IPv4 bind only, so `[::1]`\n * inbound is moot, but accepting it is harmless.\n */\nconst LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);\n\n/** Strip the `:port` suffix from a Host/authority (port-agnostic — `bindWithRetry` can walk the port). */\nfunction hostname(hostHeader: string | undefined): string | null {\n if (!hostHeader) return null;\n if (hostHeader.startsWith('[')) return hostHeader.slice(0, hostHeader.indexOf(']') + 1) || null;\n const colon = hostHeader.lastIndexOf(':');\n return colon === -1 ? hostHeader : hostHeader.slice(0, colon);\n}\n\n/**\n * The upgrade `Host` must be a loopback name (port-agnostic) or an explicitly allowed host — closes\n * DNS rebinding. A missing `Host` is rejected (a well-formed HTTP/1.1 client always sends one).\n */\nexport function hostAllowed(req: IncomingMessage, allowedHosts: ReadonlySet<string>): boolean {\n const host = hostname(req.headers.host);\n if (host === null) return false;\n return LOOPBACK_HOSTS.has(host) || allowedHosts.has(host);\n}\n\n/**\n * A browser sends an unforgeable `Origin`; require it to be loopback or explicitly allowed. A\n * non-browser client omits `Origin` (allowed here — the token is its gate). Closes the browser\n * drive-by hole.\n */\nexport function originAllowed(req: IncomingMessage, allowedOrigins: ReadonlySet<string>): boolean {\n const origin = req.headers.origin;\n if (origin === undefined) return true; // non-browser client; token still required\n if (allowedOrigins.has(origin)) return true;\n try {\n return LOOPBACK_HOSTS.has(new URL(origin).hostname);\n } catch {\n return false; // malformed Origin → reject\n }\n}\n\n/**\n * Constant-time token comparison. Returns false on any length mismatch (never throws), so an\n * absent/short/long presented token is a plain reject, not an error.\n */\nexport function tokenMatches(expected: string, presented: string | null | undefined): boolean {\n if (!presented) return false;\n const a = Buffer.from(expected);\n const b = Buffer.from(presented);\n if (a.length !== b.length) return false;\n return timingSafeEqual(a, b);\n}\n\n/** Read the presented token from the upgrade request: `?token=` query param, else the WS subprotocol. */\nexport function presentedToken(req: IncomingMessage): string | null {\n try {\n const url = new URL(req.url ?? '', 'ws://127.0.0.1');\n const q = url.searchParams.get('token');\n if (q) return q;\n } catch {\n // malformed URL → fall through to the subprotocol header\n }\n const proto = req.headers['sec-websocket-protocol'];\n return typeof proto === 'string' ? proto.split(',')[0]?.trim() || null : null;\n}\n\n/**\n * This transport's admission decision, asked of the shared seam.\n *\n * Lives here rather than in the transport file because this module already owns the admission\n * policy — token comparison and the Host/Origin allow-lists — and the transport file carries\n * lifecycle and frame routing. The split is also what keeps that file inside its size ratchet.\n */\nexport function resolveWsAdmission(config: {\n token?: string;\n open?: boolean;\n openReason?: string;\n}): ITransportAdmission {\n // No fabricated reason. The first version filled one in for any caller that omitted it, which made\n // WS the one transport where `{ open: true }` alone was accepted — HTTP and WebRTC both refuse it.\n // Review found that: a reason invented on the caller's behalf reads, to the next person, as a\n // decision somebody made. The whole point of requiring it is that nobody can produce one by\n // accident, so the seam is asked the question exactly as it was given.\n return resolveAdmission({\n ...(config.token !== undefined ? { token: config.token } : {}),\n ...(config.open === true ? { open: true } : {}),\n ...(config.openReason !== undefined ? { openReason: config.openReason } : {}),\n });\n}\n","import type { RawData } from 'ws';\n\n/** Normalize the `ws` inbound payload variants to one binary view. */\nexport function toBytes(data: RawData): Uint8Array {\n if (Array.isArray(data)) return new Uint8Array(Buffer.concat(data));\n if (data instanceof ArrayBuffer) return new Uint8Array(data);\n return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);\n}\n","import {\n createOutboundDelivery,\n createPendingStallClock,\n DEFAULT_MAX_PENDING_BYTES,\n isOverPendingBudget,\n} from '@robota-sdk/agent-transport';\nimport { WebSocket } from 'ws';\n\nimport type {\n IPendingStallClock,\n TOutboundDeliver,\n TServerMessage,\n} from '@robota-sdk/agent-transport';\n\n/**\n * Connection-scoped session delivery lifecycle shared by sync and async WebSocket failures.\n *\n * ARCH-030: {@link deliver} is the ONLY way out of this class. The raw sink — the `readyState` check\n * plus `socket.send` — is private, so no caller can put a frame on this socket outside the connection's\n * one outbound boundary. That is not stylistic: the previous shape exposed a public `send` that threw\n * on a closed socket, and every reply the protocol handler produced went through it unguarded.\n */\nexport class WsSessionDelivery {\n private cleanupProtocol = (): void => undefined;\n private detachSink = (): void => undefined;\n private closed = false;\n /**\n * Issue #2306: ONE drain clock for the socket. `bufferedAmount` does not distinguish text from\n * binary, so both halves observe the same clock — a slow reader of payload frames is caught when a\n * JSON reply is attempted, and the reverse.\n */\n private readonly stallClock: IPendingStallClock = createPendingStallClock();\n\n /**\n * The connection's outbound boundary. Built here, from this class's own sink and its own `close`\n * policy, and passed DOWN into `createSessionMessageHandler` — the carrier owns both halves, so it builds the\n * boundary rather than handing the protocol layer a raw sink to wrap.\n */\n readonly deliver: TOutboundDeliver;\n\n constructor(private readonly socket: WebSocket) {\n this.deliver = createOutboundDelivery(\n (message) => this.rawSend(message),\n () => this.close(),\n // ARCH-030 / issue #1734: the socket's own count of what it has accepted and not yet written.\n // Read at call time — it changes underneath.\n () => this.socket.bufferedAmount,\n DEFAULT_MAX_PENDING_BYTES,\n this.stallClock,\n );\n }\n\n /**\n * Put a TRANS-001 payload frame on this socket, under the SAME budget and the same close policy as\n * the text protocol (ARCH-030 / issue #1734).\n *\n * A WebSocket carries two kinds of outbound frame over one socket, and until this existed the\n * binary half went straight to `socket.send` — outside the boundary, with no budget, and with a\n * failure that could only surface as a throw inside a listener. `bufferedAmount` does not\n * distinguish the two, so a non-reading peer accumulating payload frames was invisible to a budget\n * that only guarded the other half.\n *\n * It is not `deliver`: that boundary is typed for `TServerMessage` and its brand is what makes a\n * raw send unusable where a protocol frame is required. Widening it to accept bytes would remove\n * the property the brand exists for.\n */\n readonly deliverBinary = (frame: Uint8Array): void => {\n if (this.closed) return;\n if (this.socket.readyState !== WebSocket.OPEN) return;\n if (\n isOverPendingBudget(this.socket.bufferedAmount) ||\n this.stallClock.observe(this.socket.bufferedAmount) !== undefined\n ) {\n this.close();\n return;\n }\n this.socket.send(frame, { binary: true }, (error) => {\n if (error) this.close();\n });\n };\n\n private rawSend(message: TServerMessage): void {\n if (this.socket.readyState !== WebSocket.OPEN) throw new Error('WebSocket is not open');\n this.socket.send(JSON.stringify(message), (error) => {\n if (error) this.close();\n });\n }\n\n bindProtocolCleanup(cleanup: () => void): void {\n this.cleanupProtocol = cleanup;\n }\n\n bindSinkDetach(detach: () => void): void {\n this.detachSink = detach;\n }\n\n readonly close = (): void => {\n if (this.closed) return;\n this.closed = true;\n this.detachSink();\n this.cleanupProtocol();\n if (\n this.socket.readyState === WebSocket.OPEN ||\n this.socket.readyState === WebSocket.CONNECTING\n ) {\n this.socket.close(1011, 'session event delivery failed');\n }\n };\n}\n","/**\n * The construction contract for `WsTransport` — what a caller decides before the server exists.\n *\n * Split from `ws-transport-configurable.ts` under the file-size ceiling, and the seam is the\n * honest one: everything here is a decision the CALLER makes (port, retries, admission — SEC-008's\n * `token`/`open`/`openReason` contract lives in these doc comments), while the class next door is\n * what the transport DOES with those decisions.\n */\n\nimport type { TUsageSurface } from '@robota-sdk/agent-interface-analytics';\nimport type { ISessionBinder, TDriverId } from '@robota-sdk/agent-interface-session';\nimport type { ITransportLifecycleError } from '@robota-sdk/agent-interface-transport';\nimport type { IProtocolSession, ISessionMessageHandlerOptions } from '@robota-sdk/agent-transport';\n\nexport const DEFAULT_PORT = 7070;\nexport const DEFAULT_MAX_RETRIES = 20;\n\nexport interface IWsTransportConfig {\n /** Trusted server-assigned identity for turns submitted over this product surface. */\n driverId?: TDriverId;\n /** Trusted product surface, independent from the connection's driver identity. */\n surface?: TUsageSurface;\n /** Host-owned cross-session usage read model, forwarded unchanged to the protocol handler. */\n personalUsageReporter?: ISessionMessageHandlerOptions['personalUsageReporter'];\n /** Host-owned current-session trace/cost producer, forwarded unchanged to the protocol handler. */\n usageReporter?: ISessionMessageHandlerOptions['usageReporter'];\n /** Host-owned stored-session trace/cost producer for personal-usage drill-down. */\n storedSessionUsageReporter?: ISessionMessageHandlerOptions['storedSessionUsageReporter'];\n /** Host-owned session directory (#3189): lets every connection list, start and switch sessions. */\n sessionDirectory?: ISessionMessageHandlerOptions['sessionDirectory'];\n /**\n * #3189: gives each connection a binding of its own — its own session and its own directory view —\n * so one connection's switch moves only that connection. Released when the connection closes. When\n * set, a connection uses its binding's session and directory instead of the attached session and\n * `sessionDirectory`; without it every connection shares the attached session.\n */\n sessionBinder?: ISessionBinder<IProtocolSession>;\n port?: number;\n maxRetries?: number;\n /**\n * OPTIONAL explicit loopback auth token. When set, every connection MUST present a matching token\n * (query param `?token=` or the `Sec-WebSocket-Protocol` subprotocol) or the socket is closed BEFORE any\n * session data is emitted (GUI-002; the GUI sidecar sets `ROBOTA_WS_TOKEN`). SEC-001: when this is unset\n * AND `open` is not `true`, the transport AUTO-MINTS a random per-launch token (secure by default) —\n * `resolvedToken` exposes it so the surface can deliver it to the co-located client (a `0600` connection\n * file / the served monitor's injected `ws-url`). An explicit token here wins over the auto-mint — but NOT\n * over `open`: SEC-008 makes a token together with `open: true` a contradiction that throws, rather\n * than a precedence one side quietly wins.\n */\n token?: string;\n /**\n * SEC-001 discouraged opt-out: when `true`, run WITHOUT auth (no token, no auto-mint) — the pre-SEC-001\n * open loopback behavior. NOT RECOMMENDED (any local process or browser page can then drive+authorize the\n * session); mirrors Jupyter's `c.ServerApp.token = ''`.\n *\n * SEC-008: `open: true` together with a non-empty `token` is REJECTED, not resolved by precedence. The\n * two ask for opposite things, and picking a winner silently is how a caller ends up with the admission\n * it did not choose. `resolveAdmission` decides that for every transport, so they cannot differ.\n */\n open?: boolean;\n /** SEC-008: why running with no credential is correct here. Required when `open` is true. */\n openReason?: string;\n /**\n * SEC-001 defense-in-depth: extra host names (beyond `localhost`/`127.0.0.1`/`::1`) accepted in the\n * upgrade `Host` header. The `Host` allow-list closes DNS-rebinding independently of the token.\n */\n allowedHosts?: readonly string[];\n /**\n * SEC-001 defense-in-depth: extra browser `Origin`s (beyond loopback) accepted on the upgrade — e.g. the\n * `apps/agent-web` app origin. A browser sends an unforgeable `Origin`; a non-browser client omits it (and\n * is gated by the token instead). Closes the \"any web page in any browser\" hole before history is emitted.\n */\n allowedOrigins?: readonly string[];\n}\n\ntype TUsageReporters = Pick<\n ISessionMessageHandlerOptions,\n 'personalUsageReporter' | 'usageReporter' | 'storedSessionUsageReporter'\n>;\n\n/** Remove absent reporters so exact-optional handler options remain truthful. */\nfunction configuredUsageReporters(config: IWsTransportConfig): TUsageReporters {\n return {\n ...(config.personalUsageReporter\n ? { personalUsageReporter: config.personalUsageReporter }\n : {}),\n ...(config.usageReporter ? { usageReporter: config.usageReporter } : {}),\n ...(config.storedSessionUsageReporter\n ? { storedSessionUsageReporter: config.storedSessionUsageReporter }\n : {}),\n };\n}\n\ntype TConfiguredHandlerOptions = Pick<\n ISessionMessageHandlerOptions,\n | 'driverId'\n | 'surface'\n | 'personalUsageReporter'\n | 'usageReporter'\n | 'storedSessionUsageReporter'\n | 'sessionDirectory'\n>;\n\n/** Project immutable transport attribution/reporters into each protocol handler. */\nexport function configuredWsHandlerOptions(config: IWsTransportConfig): TConfiguredHandlerOptions {\n return {\n ...(config.driverId ? { driverId: config.driverId } : {}),\n ...(config.surface ? { surface: config.surface } : {}),\n ...configuredUsageReporters(config),\n ...(config.sessionDirectory ? { sessionDirectory: config.sessionDirectory } : {}),\n };\n}\n\nexport function validTransportOptions(options: Readonly<Record<string, unknown>>): boolean {\n const { port, maxRetries } = options;\n if (port !== undefined && (typeof port !== 'number' || port < 1 || port > 65535)) return false;\n return maxRetries === undefined || (typeof maxRetries === 'number' && maxRetries >= 0);\n}\n\nexport function transportLifecycleError(\n transportName: string,\n code: ITransportLifecycleError['code'],\n): ITransportLifecycleError {\n return Object.assign(new Error(`${transportName} ${code}.`), {\n name: 'TransportLifecycleError' as const,\n code,\n transportName,\n });\n}\n","/**\n * Self-contained WS transport implementing IConfigurableTransport.\n * Owns the WebSocket server lifecycle (ws package), started/stopped via the transport registry.\n */\n\nimport { createServer, type IncomingMessage, type Server } from 'node:http';\n\nimport { createSessionMessageHandler } from '@robota-sdk/agent-transport';\nimport { WebSocketServer } from 'ws';\n\nimport { PayloadChannelRegistry } from './payload-channels.js';\nimport {\n hostAllowed,\n originAllowed,\n presentedToken,\n resolveWsAdmission,\n tokenMatches,\n} from './ws-connection-guards.js';\nimport { toBytes } from './ws-message-data.js';\nimport { WsSessionDelivery } from './ws-session-delivery.js';\nimport {\n DEFAULT_MAX_RETRIES,\n DEFAULT_PORT,\n configuredWsHandlerOptions,\n transportLifecycleError,\n validTransportOptions,\n} from './ws-transport-config.js';\n\nimport type { IWsTransportConfig } from './ws-transport-config.js';\nimport type { TUniversalValue } from '@robota-sdk/agent-core';\nimport type { ISessionBinder, ISessionBinding } from '@robota-sdk/agent-interface-session';\nimport type {\n IChannelDescriptor,\n IConfigurableTransport,\n IPayloadChannel,\n IPayloadChannelHost,\n TChannelEventMap,\n} from '@robota-sdk/agent-interface-transport';\nimport type { IProtocolSession } from '@robota-sdk/agent-transport';\nimport type { RawData, WebSocket } from 'ws';\n\n/**\n * RUNTIME-13: forced-terminate deadline for `stop()`. `WebSocketServer.close()` fires its callback only after\n * every client socket is gone, so a still-connected client would hang `stop()` forever. We send a close frame\n * (1001 \"going away\") to each client, then `terminate()` any socket still open at this deadline — the RFC 6455\n * + `ws` + drain-then-force convention (a WS close handshake completes in well under a second; 5s is generous).\n */\nconst WS_STOP_TERMINATE_DEADLINE_MS = 5000;\n\n/**\n * TRANS-001: the WS transport is a payload-agnostic CARRIER that routes by WebSocket frame opcode —\n * TEXT frames go to the text-agent protocol profile (`createSessionMessageHandler`), BINARY frames go to the\n * consumer-declared channels. The two profiles share one connection and never constrain each other.\n */\nexport class WsTransport implements IConfigurableTransport<IProtocolSession>, IPayloadChannelHost {\n readonly name = 'ws';\n readonly lifecycle = Object.freeze({ kind: 'service' as const });\n readonly defaultEnabled = true;\n readonly optionsSchema = {\n port: { type: 'number', description: 'WebSocket server port', default: DEFAULT_PORT },\n maxRetries: {\n type: 'number',\n description: 'Port retry attempts when port is occupied',\n default: DEFAULT_MAX_RETRIES,\n },\n };\n\n private session: IProtocolSession | null = null;\n private stopFn: (() => Promise<void>) | null = null;\n private state: 'detached' | 'attached' | 'starting' | 'ready' | 'stopping' = 'detached';\n private startOperation: Promise<{ stop: () => Promise<void>; port: number }> | undefined;\n private startCancelled = false;\n private port: number;\n private maxRetries: number;\n private readonly token?: string;\n private readonly allowedHosts: ReadonlySet<string>;\n private readonly allowedOrigins: ReadonlySet<string>;\n private readonly handlerOptions: ReturnType<typeof configuredWsHandlerOptions>;\n private readonly sessionBinder: ISessionBinder<IProtocolSession> | undefined;\n private readonly channels = new PayloadChannelRegistry();\n private resolvedPort?: number;\n\n constructor(config: IWsTransportConfig = {}) {\n this.port = config.port ?? DEFAULT_PORT;\n this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;\n // SEC-001/SEC-008: secure by default, decided by the shared seam rather than a copy here — see\n // SPEC § Transport Admission. A failed mint throws out of the constructor (fail-closed).\n const admission = resolveWsAdmission(config);\n if (admission.token !== null) this.token = admission.token;\n this.allowedHosts = new Set(config.allowedHosts ?? []);\n this.allowedOrigins = new Set(config.allowedOrigins ?? []);\n this.handlerOptions = configuredWsHandlerOptions(config);\n this.sessionBinder = config.sessionBinder;\n }\n\n attach(session: IProtocolSession): void {\n this.session = session;\n this.state = 'attached';\n }\n\n /**\n * TRANS-001: declare an app-level channel carried on this transport's connections. The channel's\n * events and opaque binary frames ride alongside the text-agent protocol on the SAME socket — the\n * transport never inspects the payloads. Register before or after `start()`; a channel opened\n * later is served by every already-connected client.\n */\n registerChannel<TEvents extends TChannelEventMap>(\n descriptor: IChannelDescriptor<TEvents>,\n ): IPayloadChannel<TEvents> {\n return this.channels.registerChannel(descriptor);\n }\n\n /** Actually-bound port after start; undefined before start. */\n get boundPort(): number | undefined {\n return this.resolvedPort;\n }\n\n /**\n * SEC-001: the resolved auth token (explicit or auto-minted), or `undefined` in the discouraged `open`\n * mode. The surface reads this to deliver the token to the co-located client (a `0600` connection file /\n * the served monitor's injected `ws-url`). Never logged by the transport itself.\n */\n get resolvedToken(): string | undefined {\n return this.token;\n }\n\n async start(): Promise<void> {\n if (!this.session) throw transportLifecycleError(this.name, 'not-attached');\n if (this.state === 'starting' || this.state === 'ready' || this.state === 'stopping') {\n throw transportLifecycleError(this.name, 'already-started');\n }\n this.state = 'starting';\n this.startCancelled = false;\n const operation = this.bindWithRetry(this.session, this.port, this.maxRetries);\n this.startOperation = operation;\n let handle: Awaited<typeof operation>;\n try {\n handle = await operation;\n } catch (error) {\n if (this.state === 'starting' && this.startOperation === operation) {\n this.startOperation = undefined;\n this.state = this.session ? 'attached' : 'detached';\n }\n throw error;\n }\n if (this.startCancelled || this.startOperation !== operation) {\n await handle.stop();\n this.state = this.session ? 'attached' : 'detached';\n throw new Error('WsTransport startup was stopped.');\n }\n this.stopFn = handle.stop;\n this.resolvedPort = handle.port;\n this.startOperation = undefined;\n this.state = 'ready';\n }\n\n async stop(): Promise<void> {\n if (this.state === 'starting') {\n this.state = 'stopping';\n this.startCancelled = true;\n try {\n await this.startOperation;\n } catch {\n // The start caller owns bind failure; stop still clears lifecycle state.\n }\n }\n await this.stopFn?.();\n this.stopFn = null;\n this.startOperation = undefined;\n this.resolvedPort = undefined;\n this.session = null;\n this.state = 'detached';\n }\n\n /** TRANS-002 (issue #2480): persisted `port`/`maxRetries` reach the server through here, before start. */\n configure(options: Record<string, TUniversalValue>): void {\n if (this.state !== 'detached' && this.state !== 'attached') {\n throw transportLifecycleError(this.name, 'already-started');\n }\n if (!this.validateOptions(options)) {\n throw new TypeError('WsTransport options are invalid (port 1-65535, maxRetries >= 0).');\n }\n if (typeof options['port'] === 'number') this.port = options['port'];\n if (typeof options['maxRetries'] === 'number') this.maxRetries = options['maxRetries'];\n }\n\n validateOptions(options: Record<string, TUniversalValue>): boolean {\n return validTransportOptions(options);\n }\n\n /**\n * The connection's binding, `{ binding: undefined }` without a binder, or `null` when the host\n * would not bind it (shutting down, say): the socket is then closed before any session data.\n */\n private bindConnection(\n ws: WebSocket,\n ): { binding: ISessionBinding<IProtocolSession> | undefined } | null {\n if (!this.sessionBinder) return { binding: undefined };\n try {\n return { binding: this.sessionBinder.bind('drive') };\n } catch {\n ws.close(1013, 'session unavailable');\n return null;\n }\n }\n\n private bindWithRetry(\n session: IProtocolSession,\n port: number,\n retriesLeft: number,\n ): Promise<{ stop: () => Promise<void>; port: number }> {\n return this.tryBind(session, port).catch((err: NodeJS.ErrnoException) => {\n if (err.code === 'EADDRINUSE' && retriesLeft > 0)\n return this.bindWithRetry(session, port + 1, retriesLeft - 1);\n throw err;\n });\n }\n\n private tryBind(\n session: IProtocolSession,\n port: number,\n ): Promise<{ stop: () => Promise<void>; port: number }> {\n return new Promise((resolve, reject) => {\n const httpServer: Server = createServer((_, res) => {\n res.writeHead(400).end('WebSocket endpoint');\n });\n\n httpServer.on('error', (err: NodeJS.ErrnoException) => {\n httpServer.close();\n reject(err);\n });\n\n const expectedToken = this.token;\n const allowedHosts = this.allowedHosts;\n const allowedOrigins = this.allowedOrigins;\n const channels = this.channels;\n httpServer.listen(port, '127.0.0.1', () => {\n const wss = new WebSocketServer({\n server: httpServer,\n // Reject disallowed Host/Origin before the protocol upgrade or any session data.\n verifyClient: (\n info: { origin: string; secure: boolean; req: IncomingMessage },\n cb: (res: boolean, code?: number, message?: string) => void,\n ) => {\n if (!hostAllowed(info.req, allowedHosts)) {\n cb(false, 403, 'Forbidden host');\n return;\n }\n if (!originAllowed(info.req, allowedOrigins)) {\n cb(false, 403, 'Forbidden origin');\n return;\n }\n cb(true);\n },\n });\n\n wss.on('connection', (ws: WebSocket, req: IncomingMessage) => {\n // Reject unauthenticated peers before sending history or accepting prompt responses.\n if (expectedToken !== undefined && !tokenMatches(expectedToken, presentedToken(req))) {\n ws.close(1008, 'unauthorized');\n return;\n }\n\n // #3189: with a binder, this connection gets its own session and directory, so its\n // switch (and the `session_switched` that follows) stays on this connection. Every WS\n // connection drives: this carrier has no observe role.\n const bound = this.bindConnection(ws);\n if (bound === null) return;\n const { binding } = bound;\n const connectionSession = binding?.session ?? session;\n const delivery = new WsSessionDelivery(ws);\n const handler = createSessionMessageHandler({\n session: connectionSession,\n deliver: delivery.deliver,\n ...this.handlerOptions,\n ...(binding ? { sessionDirectory: binding.directory } : {}),\n });\n delivery.bindProtocolCleanup(() => {\n handler.cleanup();\n binding?.release();\n });\n\n delivery.bindSinkDetach(\n // ARCH-030: through the connection's own delivery, so payload frames share the text\n // protocol's backpressure budget and its close policy. The bare `readyState` check plus\n // `ws.send` that stood here was the last outbound path on this socket outside the\n // boundary.\n channels.addSink((frame: Uint8Array) => delivery.deliverBinary(frame)),\n );\n\n // Route by frame opcode: TEXT → the text-agent protocol profile, BINARY → the\n // payload-agnostic channels. An unroutable channel frame is answered with an explicit\n // `protocol_error` rather than silently dropped.\n ws.on('message', (data: RawData, isBinary: boolean) => {\n if (!isBinary) {\n handler.onMessage(String(data));\n return;\n }\n const result = channels.receive(toBytes(data));\n if (!result.ok) delivery.deliver({ type: 'protocol_error', message: result.error });\n });\n ws.on('close', delivery.close);\n ws.on('error', delivery.close);\n\n delivery.deliver({ type: 'messages', messages: connectionSession.getMessages() });\n delivery.deliver({\n type: 'execution_workspace_event',\n snapshot: connectionSession.getExecutionWorkspaceSnapshot(),\n });\n });\n\n resolve({\n port,\n stop: () =>\n new Promise<void>((res) => {\n // RUNTIME-13: graceful-then-forced shutdown. Send a close frame to each client so well-behaved\n // peers close cleanly; terminate any socket still open at the deadline so `wss.close()`'s\n // all-clients-gone callback can never hang. `verifyClient`/token gates are connection-time and\n // untouched here (SEC-001 preserved).\n for (const client of wss.clients) client.close(1001, 'server shutting down');\n const deadline = setTimeout(() => {\n for (const client of wss.clients) client.terminate();\n }, WS_STOP_TERMINATE_DEADLINE_MS);\n wss.close(() => {\n clearTimeout(deadline);\n httpServer.close(() => res());\n });\n }),\n });\n });\n });\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../src/ws-transport.ts","../../src/payload-channels.ts","../../src/ws-connection-guards.ts","../../src/ws-message-data.ts","../../src/ws-session-delivery.ts","../../src/ws-transport-config.ts","../../src/ws-transport-configurable.ts"],"sourcesContent":["/**\n * ITransportAdapter implementation for WebSocket transport.\n *\n * Wraps createSessionMessageHandler into the unified ITransportAdapter interface.\n * After start(), the consumer must wire onMessage to their WebSocket.\n */\n\nimport { createOutboundDelivery, createSessionMessageHandler } from '@robota-sdk/agent-transport';\n\nimport type { TUsageSurface } from '@robota-sdk/agent-interface-analytics';\nimport type { TDriverId } from '@robota-sdk/agent-interface-session';\nimport type {\n ITransportAdapter,\n ITransportLifecycleError,\n} from '@robota-sdk/agent-interface-transport';\nimport type {\n IProtocolSession,\n ISessionMessageHandlerOptions,\n TServerMessage,\n} from '@robota-sdk/agent-transport';\n\nexport interface IWsTransportOptions {\n /** Send a JSON message to the connected WebSocket client. */\n send: (message: TServerMessage) => void;\n /** Owning socket lifecycle callback for outbound session-event delivery failures. */\n onDeliveryError?: (error: Error, event: string) => void;\n personalUsageReporter?: ISessionMessageHandlerOptions['personalUsageReporter'];\n usageReporter?: ISessionMessageHandlerOptions['usageReporter'];\n storedSessionUsageReporter?: ISessionMessageHandlerOptions['storedSessionUsageReporter'];\n sessionDirectory?: ISessionMessageHandlerOptions['sessionDirectory'];\n settingsReporter?: ISessionMessageHandlerOptions['settingsReporter'];\n driverId?: TDriverId;\n surface?: TUsageSurface;\n}\n\nexport interface IWsTransport extends ITransportAdapter<IProtocolSession> {\n onMessage: ((data: string) => void) | null;\n}\n\nexport function createWsTransport(options: IWsTransportOptions): IWsTransport {\n let session: IProtocolSession | null = null;\n let cleanup: (() => void) | null = null;\n const lifecycleError = (code: ITransportLifecycleError['code']): ITransportLifecycleError =>\n Object.assign(new Error(`WebSocket transport ${code}.`), {\n name: 'TransportLifecycleError' as const,\n code,\n transportName: 'ws',\n });\n\n const transport: IWsTransport = {\n name: 'ws',\n lifecycle: Object.freeze({ kind: 'service' }),\n onMessage: null,\n attach(s: IProtocolSession) {\n session = s;\n },\n async start() {\n if (!session) throw lifecycleError('not-attached');\n if (cleanup) throw lifecycleError('already-started');\n // ARCH-030: this adapter is the carrier here, so it builds the connection's outbound boundary\n // from its own injected sink and its own failure policy, then passes it down.\n const deliver = createOutboundDelivery(options.send, (error, event) => {\n handler.cleanup();\n cleanup = null;\n transport.onMessage = null;\n options.onDeliveryError?.(error, event);\n });\n const handler = createSessionMessageHandler({\n session,\n deliver,\n ...(options.driverId ? { driverId: options.driverId } : {}),\n ...(options.surface ? { surface: options.surface } : {}),\n ...(options.personalUsageReporter\n ? { personalUsageReporter: options.personalUsageReporter }\n : {}),\n ...(options.usageReporter ? { usageReporter: options.usageReporter } : {}),\n ...(options.storedSessionUsageReporter\n ? { storedSessionUsageReporter: options.storedSessionUsageReporter }\n : {}),\n ...(options.sessionDirectory ? { sessionDirectory: options.sessionDirectory } : {}),\n ...(options.settingsReporter ? { settingsReporter: options.settingsReporter } : {}),\n });\n cleanup = handler.cleanup;\n this.onMessage = handler.onMessage;\n },\n async stop() {\n cleanup?.();\n cleanup = null;\n this.onMessage = null;\n session = null;\n },\n };\n return transport;\n}\n","/**\n * Payload-agnostic channel registry (TRANS-001) — the transport-side half of\n * {@link IPayloadChannelHost}.\n *\n * Owns the multiplexing the carrier needs and nothing else: which channels exist, what each one\n * declared, per-channel outbound `seq`, the attached connection sinks, and inbound routing. It is\n * completely blind to payload semantics — a channel carrying audio, a file, or app events is the\n * same object to this class.\n *\n * Every rejection is EXPLICIT: an undeclared event, binary traffic on a text-only channel, or a\n * frame for an unregistered channel comes back as a stated error the carrier reports to the peer.\n * Nothing is silently dropped or defaulted.\n */\n\nimport {\n decodeChannelFrame,\n encodeBinaryFrame,\n encodeChannelEventFrame,\n} from '@robota-sdk/agent-transport';\n\nimport type {\n IBinaryFrame,\n IChannelDescriptor,\n IChannelEventFrame,\n IPayloadChannel,\n IPayloadChannelHost,\n TChannelEventMap,\n TChannelReceiveResult,\n} from '@robota-sdk/agent-interface-transport';\n\n/** Delivers an encoded frame to one attached connection. */\nexport type TChannelSink = (frame: Uint8Array) => void;\n\ntype TBinaryHandler = (frame: IBinaryFrame) => void;\n/**\n * Storage type for a consumer's event handler. `never` parameters make every concrete\n * `(payload: TEvents[K], frame: IChannelEventFrame<TEvents[K]>) => void` assignable to it, so the\n * per-channel handler sets stay heterogeneous without an `unknown` double-cast. The declared-event\n * check at both `onEvent` and `receive` is what keeps the erased types honest at runtime.\n */\ntype TEventHandler = (payload: never, frame: never) => void;\n\n/** Per-channel mutable state. Kept private to the registry — consumers only see `IPayloadChannel`. */\ninterface IChannelState {\n readonly descriptor: IChannelDescriptor;\n readonly declaredEvents: ReadonlySet<string>;\n readonly eventHandlers: Map<string, Set<TEventHandler>>;\n readonly binaryHandlers: Set<TBinaryHandler>;\n seq: number;\n open: boolean;\n}\n\nexport class PayloadChannelRegistry implements IPayloadChannelHost {\n private readonly channels = new Map<string, IChannelState>();\n private readonly sinks = new Set<TChannelSink>();\n\n /**\n * Attach a connection. Every frame a channel sends is fanned out to all attached sinks; the\n * returned function detaches (call it on socket close).\n */\n addSink(sink: TChannelSink): () => void {\n this.sinks.add(sink);\n return (): void => {\n this.sinks.delete(sink);\n };\n }\n\n registerChannel<TEvents extends TChannelEventMap>(\n descriptor: IChannelDescriptor<TEvents>,\n ): IPayloadChannel<TEvents> {\n if (this.channels.has(descriptor.name)) {\n throw new Error(`Channel \"${descriptor.name}\" is already registered on this transport`);\n }\n const state: IChannelState = {\n descriptor: descriptor as IChannelDescriptor,\n declaredEvents: new Set<string>(descriptor.events),\n eventHandlers: new Map(),\n binaryHandlers: new Set(),\n seq: 0,\n open: true,\n };\n this.channels.set(descriptor.name, state);\n return this.createHandle<TEvents>(descriptor, state);\n }\n\n /**\n * Route one inbound encoded frame to its channel's handlers. The result states exactly what\n * happened so the carrier can answer the peer (a `protocol_error`) instead of dropping it.\n */\n receive(bytes: Uint8Array): TChannelReceiveResult {\n const decoded = decodeChannelFrame(bytes);\n if (!decoded.ok) return decoded;\n\n const { frame } = decoded;\n const state = this.channels.get(frame.channel);\n if (!state || !state.open) {\n return { ok: false, error: `No channel registered for \"${frame.channel}\"` };\n }\n\n if (frame.kind === 'binary') {\n if (state.descriptor.binary !== true) {\n return {\n ok: false,\n error: `Channel \"${frame.channel}\" did not declare binary payload support`,\n };\n }\n for (const handler of [...state.binaryHandlers]) handler(frame);\n return { ok: true, frame };\n }\n\n if (!state.declaredEvents.has(frame.event)) {\n return {\n ok: false,\n error: `Channel \"${frame.channel}\" did not declare event \"${frame.event}\"`,\n };\n }\n const handlers = state.eventHandlers.get(frame.event);\n if (handlers) {\n for (const handler of [...handlers]) handler(frame.payload as never, frame as never);\n }\n return { ok: true, frame };\n }\n\n /** Fan one encoded frame out to every attached connection. */\n private broadcast(frame: Uint8Array): void {\n for (const sink of [...this.sinks]) sink(frame);\n }\n\n /** Assert the channel is still open before any send/subscribe. */\n private static assertOpen(state: IChannelState): void {\n if (!state.open) throw new Error(`Channel \"${state.descriptor.name}\" is closed`);\n }\n\n private createHandle<TEvents extends TChannelEventMap>(\n descriptor: IChannelDescriptor<TEvents>,\n state: IChannelState,\n ): IPayloadChannel<TEvents> {\n return {\n descriptor,\n\n sendEvent: <K extends keyof TEvents & string>(event: K, payload: TEvents[K]): void => {\n PayloadChannelRegistry.assertOpen(state);\n if (!state.declaredEvents.has(event)) {\n throw new Error(`Channel \"${descriptor.name}\" did not declare event \"${event}\"`);\n }\n const seq = state.seq;\n state.seq += 1;\n this.broadcast(\n encodeChannelEventFrame({\n kind: 'event',\n channel: descriptor.name,\n seq,\n event,\n payload,\n }),\n );\n },\n\n sendBinary: (payload: Uint8Array): void => {\n PayloadChannelRegistry.assertOpen(state);\n if (descriptor.binary !== true) {\n throw new Error(`Channel \"${descriptor.name}\" did not declare binary payload support`);\n }\n const seq = state.seq;\n state.seq += 1;\n this.broadcast(\n encodeBinaryFrame({ kind: 'binary', channel: descriptor.name, seq, payload }),\n );\n },\n\n onEvent: <K extends keyof TEvents & string>(\n event: K,\n handler: (payload: TEvents[K], frame: IChannelEventFrame<TEvents[K]>) => void,\n ): (() => void) => {\n PayloadChannelRegistry.assertOpen(state);\n if (!state.declaredEvents.has(event)) {\n throw new Error(`Channel \"${descriptor.name}\" did not declare event \"${event}\"`);\n }\n const set = state.eventHandlers.get(event) ?? new Set<TEventHandler>();\n const typed: TEventHandler = handler;\n set.add(typed);\n state.eventHandlers.set(event, set);\n return (): void => {\n set.delete(typed);\n };\n },\n\n onBinary: (handler: (frame: IBinaryFrame) => void): (() => void) => {\n PayloadChannelRegistry.assertOpen(state);\n state.binaryHandlers.add(handler);\n return (): void => {\n state.binaryHandlers.delete(handler);\n };\n },\n\n close: (): void => {\n state.open = false;\n state.eventHandlers.clear();\n state.binaryHandlers.clear();\n this.channels.delete(descriptor.name);\n },\n };\n }\n}\n","/**\n * Connection-admission guards for the WS transport (SEC-001 / GUI-002).\n *\n * Pure, socket-free predicates over the HTTP upgrade request — token minting/comparison plus the\n * `Host` and `Origin` allow-lists. Split out of `ws-transport-configurable.ts` so the transport file\n * carries lifecycle and frame routing only; the admission policy is independently readable and the\n * transport module stays within the file-size ratchet.\n */\n\nimport { timingSafeEqual } from 'node:crypto';\n\nimport { resolveAdmission } from '@robota-sdk/agent-transport/node';\n\nimport type { ITransportAdmission } from '@robota-sdk/agent-interface-transport';\nimport type { IncomingMessage } from 'node:http';\n\n/**\n * Host names that are always loopback (port stripped by the caller). IPv4 bind only, so `[::1]`\n * inbound is moot, but accepting it is harmless.\n */\nconst LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);\n\n/** Strip the `:port` suffix from a Host/authority (port-agnostic — `bindWithRetry` can walk the port). */\nfunction hostname(hostHeader: string | undefined): string | null {\n if (!hostHeader) return null;\n if (hostHeader.startsWith('[')) return hostHeader.slice(0, hostHeader.indexOf(']') + 1) || null;\n const colon = hostHeader.lastIndexOf(':');\n return colon === -1 ? hostHeader : hostHeader.slice(0, colon);\n}\n\n/**\n * The upgrade `Host` must be a loopback name (port-agnostic) or an explicitly allowed host — closes\n * DNS rebinding. A missing `Host` is rejected (a well-formed HTTP/1.1 client always sends one).\n */\nexport function hostAllowed(req: IncomingMessage, allowedHosts: ReadonlySet<string>): boolean {\n const host = hostname(req.headers.host);\n if (host === null) return false;\n return LOOPBACK_HOSTS.has(host) || allowedHosts.has(host);\n}\n\n/**\n * A browser sends an unforgeable `Origin`; require it to be loopback or explicitly allowed. A\n * non-browser client omits `Origin` (allowed here — the token is its gate). Closes the browser\n * drive-by hole.\n */\nexport function originAllowed(req: IncomingMessage, allowedOrigins: ReadonlySet<string>): boolean {\n const origin = req.headers.origin;\n if (origin === undefined) return true; // non-browser client; token still required\n if (allowedOrigins.has(origin)) return true;\n try {\n return LOOPBACK_HOSTS.has(new URL(origin).hostname);\n } catch {\n return false; // malformed Origin → reject\n }\n}\n\n/**\n * Constant-time token comparison. Returns false on any length mismatch (never throws), so an\n * absent/short/long presented token is a plain reject, not an error.\n */\nexport function tokenMatches(expected: string, presented: string | null | undefined): boolean {\n if (!presented) return false;\n const a = Buffer.from(expected);\n const b = Buffer.from(presented);\n if (a.length !== b.length) return false;\n return timingSafeEqual(a, b);\n}\n\n/** Read the presented token from the upgrade request: `?token=` query param, else the WS subprotocol. */\nexport function presentedToken(req: IncomingMessage): string | null {\n try {\n const url = new URL(req.url ?? '', 'ws://127.0.0.1');\n const q = url.searchParams.get('token');\n if (q) return q;\n } catch {\n // malformed URL → fall through to the subprotocol header\n }\n const proto = req.headers['sec-websocket-protocol'];\n return typeof proto === 'string' ? proto.split(',')[0]?.trim() || null : null;\n}\n\n/**\n * This transport's admission decision, asked of the shared seam.\n *\n * Lives here rather than in the transport file because this module already owns the admission\n * policy — token comparison and the Host/Origin allow-lists — and the transport file carries\n * lifecycle and frame routing. The split is also what keeps that file inside its size ratchet.\n */\nexport function resolveWsAdmission(config: {\n token?: string;\n open?: boolean;\n openReason?: string;\n}): ITransportAdmission {\n // No fabricated reason. The first version filled one in for any caller that omitted it, which made\n // WS the one transport where `{ open: true }` alone was accepted — HTTP and WebRTC both refuse it.\n // Review found that: a reason invented on the caller's behalf reads, to the next person, as a\n // decision somebody made. The whole point of requiring it is that nobody can produce one by\n // accident, so the seam is asked the question exactly as it was given.\n return resolveAdmission({\n ...(config.token !== undefined ? { token: config.token } : {}),\n ...(config.open === true ? { open: true } : {}),\n ...(config.openReason !== undefined ? { openReason: config.openReason } : {}),\n });\n}\n","import type { RawData } from 'ws';\n\n/** Normalize the `ws` inbound payload variants to one binary view. */\nexport function toBytes(data: RawData): Uint8Array {\n if (Array.isArray(data)) return new Uint8Array(Buffer.concat(data));\n if (data instanceof ArrayBuffer) return new Uint8Array(data);\n return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);\n}\n","import {\n createOutboundDelivery,\n createPendingStallClock,\n DEFAULT_MAX_PENDING_BYTES,\n isOverPendingBudget,\n} from '@robota-sdk/agent-transport';\nimport { WebSocket } from 'ws';\n\nimport type {\n IPendingStallClock,\n TOutboundDeliver,\n TServerMessage,\n} from '@robota-sdk/agent-transport';\n\n/**\n * Connection-scoped session delivery lifecycle shared by sync and async WebSocket failures.\n *\n * ARCH-030: {@link deliver} is the ONLY way out of this class. The raw sink — the `readyState` check\n * plus `socket.send` — is private, so no caller can put a frame on this socket outside the connection's\n * one outbound boundary. That is not stylistic: the previous shape exposed a public `send` that threw\n * on a closed socket, and every reply the protocol handler produced went through it unguarded.\n */\nexport class WsSessionDelivery {\n private cleanupProtocol = (): void => undefined;\n private detachSink = (): void => undefined;\n private closed = false;\n /**\n * Issue #2306: ONE drain clock for the socket. `bufferedAmount` does not distinguish text from\n * binary, so both halves observe the same clock — a slow reader of payload frames is caught when a\n * JSON reply is attempted, and the reverse.\n */\n private readonly stallClock: IPendingStallClock = createPendingStallClock();\n\n /**\n * The connection's outbound boundary. Built here, from this class's own sink and its own `close`\n * policy, and passed DOWN into `createSessionMessageHandler` — the carrier owns both halves, so it builds the\n * boundary rather than handing the protocol layer a raw sink to wrap.\n */\n readonly deliver: TOutboundDeliver;\n\n constructor(private readonly socket: WebSocket) {\n this.deliver = createOutboundDelivery(\n (message) => this.rawSend(message),\n () => this.close(),\n // ARCH-030 / issue #1734: the socket's own count of what it has accepted and not yet written.\n // Read at call time — it changes underneath.\n () => this.socket.bufferedAmount,\n DEFAULT_MAX_PENDING_BYTES,\n this.stallClock,\n );\n }\n\n /**\n * Put a TRANS-001 payload frame on this socket, under the SAME budget and the same close policy as\n * the text protocol (ARCH-030 / issue #1734).\n *\n * A WebSocket carries two kinds of outbound frame over one socket, and until this existed the\n * binary half went straight to `socket.send` — outside the boundary, with no budget, and with a\n * failure that could only surface as a throw inside a listener. `bufferedAmount` does not\n * distinguish the two, so a non-reading peer accumulating payload frames was invisible to a budget\n * that only guarded the other half.\n *\n * It is not `deliver`: that boundary is typed for `TServerMessage` and its brand is what makes a\n * raw send unusable where a protocol frame is required. Widening it to accept bytes would remove\n * the property the brand exists for.\n */\n readonly deliverBinary = (frame: Uint8Array): void => {\n if (this.closed) return;\n if (this.socket.readyState !== WebSocket.OPEN) return;\n if (\n isOverPendingBudget(this.socket.bufferedAmount) ||\n this.stallClock.observe(this.socket.bufferedAmount) !== undefined\n ) {\n this.close();\n return;\n }\n this.socket.send(frame, { binary: true }, (error) => {\n if (error) this.close();\n });\n };\n\n private rawSend(message: TServerMessage): void {\n if (this.socket.readyState !== WebSocket.OPEN) throw new Error('WebSocket is not open');\n this.socket.send(JSON.stringify(message), (error) => {\n if (error) this.close();\n });\n }\n\n bindProtocolCleanup(cleanup: () => void): void {\n this.cleanupProtocol = cleanup;\n }\n\n bindSinkDetach(detach: () => void): void {\n this.detachSink = detach;\n }\n\n readonly close = (): void => {\n if (this.closed) return;\n this.closed = true;\n this.detachSink();\n this.cleanupProtocol();\n if (\n this.socket.readyState === WebSocket.OPEN ||\n this.socket.readyState === WebSocket.CONNECTING\n ) {\n this.socket.close(1011, 'session event delivery failed');\n }\n };\n}\n","/**\n * The construction contract for `WsTransport` — what a caller decides before the server exists.\n *\n * Split from `ws-transport-configurable.ts` under the file-size ceiling, and the seam is the\n * honest one: everything here is a decision the CALLER makes (port, retries, admission — SEC-008's\n * `token`/`open`/`openReason` contract lives in these doc comments), while the class next door is\n * what the transport DOES with those decisions.\n */\n\nimport type { TUsageSurface } from '@robota-sdk/agent-interface-analytics';\nimport type { ISessionBinder, TDriverId } from '@robota-sdk/agent-interface-session';\nimport type { ITransportLifecycleError } from '@robota-sdk/agent-interface-transport';\nimport type { IProtocolSession, ISessionMessageHandlerOptions } from '@robota-sdk/agent-transport';\n\nexport const DEFAULT_PORT = 7070;\nexport const DEFAULT_MAX_RETRIES = 20;\n\nexport interface IWsTransportConfig {\n /** Trusted server-assigned identity for turns submitted over this product surface. */\n driverId?: TDriverId;\n /** Trusted product surface, independent from the connection's driver identity. */\n surface?: TUsageSurface;\n /** Host-owned cross-session usage read model, forwarded unchanged to the protocol handler. */\n personalUsageReporter?: ISessionMessageHandlerOptions['personalUsageReporter'];\n /** Host-owned current-session trace/cost producer, forwarded unchanged to the protocol handler. */\n usageReporter?: ISessionMessageHandlerOptions['usageReporter'];\n /** Host-owned stored-session trace/cost producer for personal-usage drill-down. */\n storedSessionUsageReporter?: ISessionMessageHandlerOptions['storedSessionUsageReporter'];\n /** Host-owned session directory (#3189): lets every connection list, start and switch sessions. */\n sessionDirectory?: ISessionMessageHandlerOptions['sessionDirectory'];\n /** #3282 §4a: host-owned read/write for the GUI Settings screen, forwarded unchanged. */\n settingsReporter?: ISessionMessageHandlerOptions['settingsReporter'];\n /**\n * #3189: gives each connection a binding of its own — its own session and its own directory view —\n * so one connection's switch moves only that connection. Released when the connection closes. When\n * set, a connection uses its binding's session and directory instead of the attached session and\n * `sessionDirectory`; without it every connection shares the attached session.\n */\n sessionBinder?: ISessionBinder<IProtocolSession>;\n port?: number;\n maxRetries?: number;\n /**\n * OPTIONAL explicit loopback auth token. When set, every connection MUST present a matching token\n * (query param `?token=` or the `Sec-WebSocket-Protocol` subprotocol) or the socket is closed BEFORE any\n * session data is emitted (GUI-002; the GUI sidecar sets `ROBOTA_WS_TOKEN`). SEC-001: when this is unset\n * AND `open` is not `true`, the transport AUTO-MINTS a random per-launch token (secure by default) —\n * `resolvedToken` exposes it so the surface can deliver it to the co-located client (a `0600` connection\n * file / the served monitor's injected `ws-url`). An explicit token here wins over the auto-mint — but NOT\n * over `open`: SEC-008 makes a token together with `open: true` a contradiction that throws, rather\n * than a precedence one side quietly wins.\n */\n token?: string;\n /**\n * SEC-001 discouraged opt-out: when `true`, run WITHOUT auth (no token, no auto-mint) — the pre-SEC-001\n * open loopback behavior. NOT RECOMMENDED (any local process or browser page can then drive+authorize the\n * session); mirrors Jupyter's `c.ServerApp.token = ''`.\n *\n * SEC-008: `open: true` together with a non-empty `token` is REJECTED, not resolved by precedence. The\n * two ask for opposite things, and picking a winner silently is how a caller ends up with the admission\n * it did not choose. `resolveAdmission` decides that for every transport, so they cannot differ.\n */\n open?: boolean;\n /** SEC-008: why running with no credential is correct here. Required when `open` is true. */\n openReason?: string;\n /**\n * SEC-001 defense-in-depth: extra host names (beyond `localhost`/`127.0.0.1`/`::1`) accepted in the\n * upgrade `Host` header. The `Host` allow-list closes DNS-rebinding independently of the token.\n */\n allowedHosts?: readonly string[];\n /**\n * SEC-001 defense-in-depth: extra browser `Origin`s (beyond loopback) accepted on the upgrade — e.g. the\n * `apps/agent-web` app origin. A browser sends an unforgeable `Origin`; a non-browser client omits it (and\n * is gated by the token instead). Closes the \"any web page in any browser\" hole before history is emitted.\n */\n allowedOrigins?: readonly string[];\n}\n\ntype TUsageReporters = Pick<\n ISessionMessageHandlerOptions,\n 'personalUsageReporter' | 'usageReporter' | 'storedSessionUsageReporter'\n>;\n\n/** Remove absent reporters so exact-optional handler options remain truthful. */\nfunction configuredUsageReporters(config: IWsTransportConfig): TUsageReporters {\n return {\n ...(config.personalUsageReporter\n ? { personalUsageReporter: config.personalUsageReporter }\n : {}),\n ...(config.usageReporter ? { usageReporter: config.usageReporter } : {}),\n ...(config.storedSessionUsageReporter\n ? { storedSessionUsageReporter: config.storedSessionUsageReporter }\n : {}),\n };\n}\n\ntype TConfiguredHandlerOptions = Pick<\n ISessionMessageHandlerOptions,\n | 'driverId'\n | 'surface'\n | 'personalUsageReporter'\n | 'usageReporter'\n | 'storedSessionUsageReporter'\n | 'sessionDirectory'\n | 'settingsReporter'\n>;\n\n/** Project immutable transport attribution/reporters into each protocol handler. */\nexport function configuredWsHandlerOptions(config: IWsTransportConfig): TConfiguredHandlerOptions {\n return {\n ...(config.driverId ? { driverId: config.driverId } : {}),\n ...(config.surface ? { surface: config.surface } : {}),\n ...configuredUsageReporters(config),\n ...(config.sessionDirectory ? { sessionDirectory: config.sessionDirectory } : {}),\n ...(config.settingsReporter ? { settingsReporter: config.settingsReporter } : {}),\n };\n}\n\nexport function validTransportOptions(options: Readonly<Record<string, unknown>>): boolean {\n const { port, maxRetries } = options;\n if (port !== undefined && (typeof port !== 'number' || port < 1 || port > 65535)) return false;\n return maxRetries === undefined || (typeof maxRetries === 'number' && maxRetries >= 0);\n}\n\nexport function transportLifecycleError(\n transportName: string,\n code: ITransportLifecycleError['code'],\n): ITransportLifecycleError {\n return Object.assign(new Error(`${transportName} ${code}.`), {\n name: 'TransportLifecycleError' as const,\n code,\n transportName,\n });\n}\n","/**\n * Self-contained WS transport implementing IConfigurableTransport.\n * Owns the WebSocket server lifecycle (ws package), started/stopped via the transport registry.\n */\n\nimport { createServer, type IncomingMessage, type Server } from 'node:http';\n\nimport { createSessionMessageHandler } from '@robota-sdk/agent-transport';\nimport { WebSocketServer } from 'ws';\n\nimport { PayloadChannelRegistry } from './payload-channels.js';\nimport {\n hostAllowed,\n originAllowed,\n presentedToken,\n resolveWsAdmission,\n tokenMatches,\n} from './ws-connection-guards.js';\nimport { toBytes } from './ws-message-data.js';\nimport { WsSessionDelivery } from './ws-session-delivery.js';\nimport {\n DEFAULT_MAX_RETRIES,\n DEFAULT_PORT,\n configuredWsHandlerOptions,\n transportLifecycleError,\n validTransportOptions,\n} from './ws-transport-config.js';\n\nimport type { IWsTransportConfig } from './ws-transport-config.js';\nimport type { TUniversalValue } from '@robota-sdk/agent-core';\nimport type { ISessionBinder, ISessionBinding } from '@robota-sdk/agent-interface-session';\nimport type {\n IChannelDescriptor,\n IConfigurableTransport,\n IPayloadChannel,\n IPayloadChannelHost,\n TChannelEventMap,\n} from '@robota-sdk/agent-interface-transport';\nimport type { IProtocolSession } from '@robota-sdk/agent-transport';\nimport type { RawData, WebSocket } from 'ws';\n\n/**\n * RUNTIME-13: forced-terminate deadline for `stop()`. `WebSocketServer.close()` fires its callback only after\n * every client socket is gone, so a still-connected client would hang `stop()` forever. We send a close frame\n * (1001 \"going away\") to each client, then `terminate()` any socket still open at this deadline — the RFC 6455\n * + `ws` + drain-then-force convention (a WS close handshake completes in well under a second; 5s is generous).\n */\nconst WS_STOP_TERMINATE_DEADLINE_MS = 5000;\n\n/**\n * TRANS-001: the WS transport is a payload-agnostic CARRIER that routes by WebSocket frame opcode —\n * TEXT frames go to the text-agent protocol profile (`createSessionMessageHandler`), BINARY frames go to the\n * consumer-declared channels. The two profiles share one connection and never constrain each other.\n */\nexport class WsTransport implements IConfigurableTransport<IProtocolSession>, IPayloadChannelHost {\n readonly name = 'ws';\n readonly lifecycle = Object.freeze({ kind: 'service' as const });\n readonly defaultEnabled = true;\n readonly optionsSchema = {\n port: { type: 'number', description: 'WebSocket server port', default: DEFAULT_PORT },\n maxRetries: {\n type: 'number',\n description: 'Port retry attempts when port is occupied',\n default: DEFAULT_MAX_RETRIES,\n },\n };\n\n private session: IProtocolSession | null = null;\n private stopFn: (() => Promise<void>) | null = null;\n private state: 'detached' | 'attached' | 'starting' | 'ready' | 'stopping' = 'detached';\n private startOperation: Promise<{ stop: () => Promise<void>; port: number }> | undefined;\n private startCancelled = false;\n private port: number;\n private maxRetries: number;\n private readonly token?: string;\n private readonly allowedHosts: ReadonlySet<string>;\n private readonly allowedOrigins: ReadonlySet<string>;\n private readonly handlerOptions: ReturnType<typeof configuredWsHandlerOptions>;\n private readonly sessionBinder: ISessionBinder<IProtocolSession> | undefined;\n private readonly channels = new PayloadChannelRegistry();\n private resolvedPort?: number;\n\n constructor(config: IWsTransportConfig = {}) {\n this.port = config.port ?? DEFAULT_PORT;\n this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;\n // SEC-001/SEC-008: secure by default, decided by the shared seam rather than a copy here — see\n // SPEC § Transport Admission. A failed mint throws out of the constructor (fail-closed).\n const admission = resolveWsAdmission(config);\n if (admission.token !== null) this.token = admission.token;\n this.allowedHosts = new Set(config.allowedHosts ?? []);\n this.allowedOrigins = new Set(config.allowedOrigins ?? []);\n this.handlerOptions = configuredWsHandlerOptions(config);\n this.sessionBinder = config.sessionBinder;\n }\n\n attach(session: IProtocolSession): void {\n this.session = session;\n this.state = 'attached';\n }\n\n /**\n * TRANS-001: declare an app-level channel carried on this transport's connections. The channel's\n * events and opaque binary frames ride alongside the text-agent protocol on the SAME socket — the\n * transport never inspects the payloads. Register before or after `start()`; a channel opened\n * later is served by every already-connected client.\n */\n registerChannel<TEvents extends TChannelEventMap>(\n descriptor: IChannelDescriptor<TEvents>,\n ): IPayloadChannel<TEvents> {\n return this.channels.registerChannel(descriptor);\n }\n\n /** Actually-bound port after start; undefined before start. */\n get boundPort(): number | undefined {\n return this.resolvedPort;\n }\n\n /**\n * SEC-001: the resolved auth token (explicit or auto-minted), or `undefined` in the discouraged `open`\n * mode. The surface reads this to deliver the token to the co-located client (a `0600` connection file /\n * the served monitor's injected `ws-url`). Never logged by the transport itself.\n */\n get resolvedToken(): string | undefined {\n return this.token;\n }\n\n async start(): Promise<void> {\n if (!this.session) throw transportLifecycleError(this.name, 'not-attached');\n if (this.state === 'starting' || this.state === 'ready' || this.state === 'stopping') {\n throw transportLifecycleError(this.name, 'already-started');\n }\n this.state = 'starting';\n this.startCancelled = false;\n const operation = this.bindWithRetry(this.session, this.port, this.maxRetries);\n this.startOperation = operation;\n let handle: Awaited<typeof operation>;\n try {\n handle = await operation;\n } catch (error) {\n if (this.state === 'starting' && this.startOperation === operation) {\n this.startOperation = undefined;\n this.state = this.session ? 'attached' : 'detached';\n }\n throw error;\n }\n if (this.startCancelled || this.startOperation !== operation) {\n await handle.stop();\n this.state = this.session ? 'attached' : 'detached';\n throw new Error('WsTransport startup was stopped.');\n }\n this.stopFn = handle.stop;\n this.resolvedPort = handle.port;\n this.startOperation = undefined;\n this.state = 'ready';\n }\n\n async stop(): Promise<void> {\n if (this.state === 'starting') {\n this.state = 'stopping';\n this.startCancelled = true;\n try {\n await this.startOperation;\n } catch {\n // The start caller owns bind failure; stop still clears lifecycle state.\n }\n }\n await this.stopFn?.();\n this.stopFn = null;\n this.startOperation = undefined;\n this.resolvedPort = undefined;\n this.session = null;\n this.state = 'detached';\n }\n\n /** TRANS-002 (issue #2480): persisted `port`/`maxRetries` reach the server through here, before start. */\n configure(options: Record<string, TUniversalValue>): void {\n if (this.state !== 'detached' && this.state !== 'attached') {\n throw transportLifecycleError(this.name, 'already-started');\n }\n if (!this.validateOptions(options)) {\n throw new TypeError('WsTransport options are invalid (port 1-65535, maxRetries >= 0).');\n }\n if (typeof options['port'] === 'number') this.port = options['port'];\n if (typeof options['maxRetries'] === 'number') this.maxRetries = options['maxRetries'];\n }\n\n validateOptions(options: Record<string, TUniversalValue>): boolean {\n return validTransportOptions(options);\n }\n\n /**\n * The connection's binding, `{ binding: undefined }` without a binder, or `null` when the host\n * would not bind it (shutting down, say): the socket is then closed before any session data.\n */\n private bindConnection(\n ws: WebSocket,\n ): { binding: ISessionBinding<IProtocolSession> | undefined } | null {\n if (!this.sessionBinder) return { binding: undefined };\n try {\n return { binding: this.sessionBinder.bind('drive') };\n } catch {\n ws.close(1013, 'session unavailable');\n return null;\n }\n }\n\n private bindWithRetry(\n session: IProtocolSession,\n port: number,\n retriesLeft: number,\n ): Promise<{ stop: () => Promise<void>; port: number }> {\n return this.tryBind(session, port).catch((err: NodeJS.ErrnoException) => {\n if (err.code === 'EADDRINUSE' && retriesLeft > 0)\n return this.bindWithRetry(session, port + 1, retriesLeft - 1);\n throw err;\n });\n }\n\n private tryBind(\n session: IProtocolSession,\n port: number,\n ): Promise<{ stop: () => Promise<void>; port: number }> {\n return new Promise((resolve, reject) => {\n const httpServer: Server = createServer((_, res) => {\n res.writeHead(400).end('WebSocket endpoint');\n });\n\n httpServer.on('error', (err: NodeJS.ErrnoException) => {\n httpServer.close();\n reject(err);\n });\n\n const expectedToken = this.token;\n const allowedHosts = this.allowedHosts;\n const allowedOrigins = this.allowedOrigins;\n const channels = this.channels;\n httpServer.listen(port, '127.0.0.1', () => {\n const wss = new WebSocketServer({\n server: httpServer,\n // Reject disallowed Host/Origin before the protocol upgrade or any session data.\n verifyClient: (\n info: { origin: string; secure: boolean; req: IncomingMessage },\n cb: (res: boolean, code?: number, message?: string) => void,\n ) => {\n if (!hostAllowed(info.req, allowedHosts)) {\n cb(false, 403, 'Forbidden host');\n return;\n }\n if (!originAllowed(info.req, allowedOrigins)) {\n cb(false, 403, 'Forbidden origin');\n return;\n }\n cb(true);\n },\n });\n\n wss.on('connection', (ws: WebSocket, req: IncomingMessage) => {\n // Reject unauthenticated peers before sending history or accepting prompt responses.\n if (expectedToken !== undefined && !tokenMatches(expectedToken, presentedToken(req))) {\n ws.close(1008, 'unauthorized');\n return;\n }\n\n // #3189: with a binder, this connection gets its own session and directory, so its\n // switch (and the `session_switched` that follows) stays on this connection. Every WS\n // connection drives: this carrier has no observe role.\n const bound = this.bindConnection(ws);\n if (bound === null) return;\n const { binding } = bound;\n const connectionSession = binding?.session ?? session;\n const delivery = new WsSessionDelivery(ws);\n const handler = createSessionMessageHandler({\n session: connectionSession,\n deliver: delivery.deliver,\n ...this.handlerOptions,\n ...(binding ? { sessionDirectory: binding.directory } : {}),\n });\n delivery.bindProtocolCleanup(() => {\n handler.cleanup();\n binding?.release();\n });\n\n delivery.bindSinkDetach(\n // ARCH-030: through the connection's own delivery, so payload frames share the text\n // protocol's backpressure budget and its close policy. The bare `readyState` check plus\n // `ws.send` that stood here was the last outbound path on this socket outside the\n // boundary.\n channels.addSink((frame: Uint8Array) => delivery.deliverBinary(frame)),\n );\n\n // Route by frame opcode: TEXT → the text-agent protocol profile, BINARY → the\n // payload-agnostic channels. An unroutable channel frame is answered with an explicit\n // `protocol_error` rather than silently dropped.\n ws.on('message', (data: RawData, isBinary: boolean) => {\n if (!isBinary) {\n handler.onMessage(String(data));\n return;\n }\n const result = channels.receive(toBytes(data));\n if (!result.ok) delivery.deliver({ type: 'protocol_error', message: result.error });\n });\n ws.on('close', delivery.close);\n ws.on('error', delivery.close);\n\n delivery.deliver({\n type: 'messages',\n messages: connectionSession.getMessages(),\n ...(this.handlerOptions.driverId ? { driverId: this.handlerOptions.driverId } : {}),\n });\n delivery.deliver({\n type: 'execution_workspace_event',\n snapshot: connectionSession.getExecutionWorkspaceSnapshot(),\n });\n });\n\n resolve({\n port,\n stop: () =>\n new Promise<void>((res) => {\n // RUNTIME-13: graceful-then-forced shutdown. Send a close frame to each client so well-behaved\n // peers close cleanly; terminate any socket still open at the deadline so `wss.close()`'s\n // all-clients-gone callback can never hang. `verifyClient`/token gates are connection-time and\n // untouched here (SEC-001 preserved).\n for (const client of wss.clients) client.close(1001, 'server shutting down');\n const deadline = setTimeout(() => {\n for (const client of wss.clients) client.terminate();\n }, WS_STOP_TERMINATE_DEADLINE_MS);\n wss.close(() => {\n clearTimeout(deadline);\n httpServer.close(() => res());\n });\n }),\n });\n });\n });\n }\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@robota-sdk/agent-transport-ws",
|
|
3
|
-
"version": "3.0.0-beta.
|
|
3
|
+
"version": "3.0.0-beta.84",
|
|
4
4
|
"description": "WebSocket carrier for the Robota SDK",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/node/index.js",
|
|
@@ -33,9 +33,10 @@
|
|
|
33
33
|
},
|
|
34
34
|
"repository": {
|
|
35
35
|
"type": "git",
|
|
36
|
-
"url": "https://github.com/woojubb/robota.git"
|
|
36
|
+
"url": "https://github.com/woojubb/robota.git",
|
|
37
|
+
"directory": "packages/agent-transport-ws"
|
|
37
38
|
},
|
|
38
|
-
"homepage": "https://robota.io/",
|
|
39
|
+
"homepage": "https://docs.robota.io/en/packages/agent-transport-ws/",
|
|
39
40
|
"bugs": {
|
|
40
41
|
"url": "https://github.com/woojubb/robota/issues"
|
|
41
42
|
},
|
|
@@ -49,11 +50,11 @@
|
|
|
49
50
|
],
|
|
50
51
|
"dependencies": {
|
|
51
52
|
"ws": "^8.21.1",
|
|
52
|
-
"@robota-sdk/agent-core": "3.0.0-beta.
|
|
53
|
-
"@robota-sdk/agent-interface-analytics": "3.0.0-beta.
|
|
54
|
-
"@robota-sdk/agent-interface-session": "3.0.0-beta.
|
|
55
|
-
"@robota-sdk/agent-interface-transport": "3.0.0-beta.
|
|
56
|
-
"@robota-sdk/agent-transport": "3.0.0-beta.
|
|
53
|
+
"@robota-sdk/agent-core": "3.0.0-beta.84",
|
|
54
|
+
"@robota-sdk/agent-interface-analytics": "3.0.0-beta.84",
|
|
55
|
+
"@robota-sdk/agent-interface-session": "3.0.0-beta.84",
|
|
56
|
+
"@robota-sdk/agent-interface-transport": "3.0.0-beta.84",
|
|
57
|
+
"@robota-sdk/agent-transport": "3.0.0-beta.84"
|
|
57
58
|
},
|
|
58
59
|
"devDependencies": {
|
|
59
60
|
"@types/ws": "^8.18.1",
|
|
@@ -68,6 +68,8 @@ function fakeBinder(overrides: Partial<ISessionDirectory> = {}): {
|
|
|
68
68
|
listSessions: () => ({ currentSessionId: 's', sessions: [], unreadableSessionIds: [] }),
|
|
69
69
|
switchSession: async (sessionId) => session.emit('session_switched', { sessionId }),
|
|
70
70
|
newSession: async () => session.emit('session_switched', { sessionId: 'new' }),
|
|
71
|
+
renameSession: async () => undefined,
|
|
72
|
+
deleteSession: async () => undefined,
|
|
71
73
|
...overrides,
|
|
72
74
|
};
|
|
73
75
|
const binding: IFakeBinding = {
|
|
@@ -24,6 +24,8 @@ function createDirectory(): ISessionDirectory {
|
|
|
24
24
|
listSessions: vi.fn(() => listing),
|
|
25
25
|
switchSession: vi.fn(async () => undefined),
|
|
26
26
|
newSession: vi.fn(async () => undefined),
|
|
27
|
+
renameSession: vi.fn(async () => undefined),
|
|
28
|
+
deleteSession: vi.fn(async () => undefined),
|
|
27
29
|
};
|
|
28
30
|
}
|
|
29
31
|
|
|
@@ -133,6 +133,39 @@ describe('WsTransport loopback auth (GUI-002 TC-03)', () => {
|
|
|
133
133
|
await closed;
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
+
it('carries the driver id on the connection-open messages frame, so a client can learn its own id (#3289 §3)', async () => {
|
|
137
|
+
// The crux of #3289 §3's own-driver-id feature: a client's first `messages` frame is where it
|
|
138
|
+
// learns which of every subsequent `authored` message is its own. This is the one place that
|
|
139
|
+
// sets it — decode-shape validation and the client reducer are covered elsewhere, but neither
|
|
140
|
+
// would catch this specific line being broken or reverted.
|
|
141
|
+
const port = 17660;
|
|
142
|
+
const transport = new WsTransport({
|
|
143
|
+
port,
|
|
144
|
+
maxRetries: 30,
|
|
145
|
+
open: true,
|
|
146
|
+
openReason: 'own-driver-id regression test',
|
|
147
|
+
driverId: 'browser',
|
|
148
|
+
surface: 'browser',
|
|
149
|
+
});
|
|
150
|
+
transport.attach(mockSession());
|
|
151
|
+
await transport.start();
|
|
152
|
+
started.push(transport);
|
|
153
|
+
const ws = new WebSocket(`ws://127.0.0.1:${transport.boundPort}`);
|
|
154
|
+
const firstMessagesFrame = await new Promise<{ type: string; driverId?: string }>(
|
|
155
|
+
(resolve, reject) => {
|
|
156
|
+
ws.on('error', reject);
|
|
157
|
+
ws.on('message', (data) => {
|
|
158
|
+
const msg = JSON.parse(String(data)) as { type: string; driverId?: string };
|
|
159
|
+
if (msg.type === 'messages') resolve(msg);
|
|
160
|
+
});
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
expect(firstMessagesFrame.driverId).toBe('browser');
|
|
164
|
+
const closed = new Promise<void>((resolve) => ws.once('close', () => resolve()));
|
|
165
|
+
ws.close();
|
|
166
|
+
await closed;
|
|
167
|
+
});
|
|
168
|
+
|
|
136
169
|
it('accepts a connection presenting the correct token via query param', async () => {
|
|
137
170
|
const { port } = await startOn({ token: 'secret-nonce' });
|
|
138
171
|
expect(await probe(port, { token: 'secret-nonce' })).toBe('messages');
|
|
@@ -28,6 +28,8 @@ export interface IWsTransportConfig {
|
|
|
28
28
|
storedSessionUsageReporter?: ISessionMessageHandlerOptions['storedSessionUsageReporter'];
|
|
29
29
|
/** Host-owned session directory (#3189): lets every connection list, start and switch sessions. */
|
|
30
30
|
sessionDirectory?: ISessionMessageHandlerOptions['sessionDirectory'];
|
|
31
|
+
/** #3282 §4a: host-owned read/write for the GUI Settings screen, forwarded unchanged. */
|
|
32
|
+
settingsReporter?: ISessionMessageHandlerOptions['settingsReporter'];
|
|
31
33
|
/**
|
|
32
34
|
* #3189: gives each connection a binding of its own — its own session and its own directory view —
|
|
33
35
|
* so one connection's switch moves only that connection. Released when the connection closes. When
|
|
@@ -99,6 +101,7 @@ type TConfiguredHandlerOptions = Pick<
|
|
|
99
101
|
| 'usageReporter'
|
|
100
102
|
| 'storedSessionUsageReporter'
|
|
101
103
|
| 'sessionDirectory'
|
|
104
|
+
| 'settingsReporter'
|
|
102
105
|
>;
|
|
103
106
|
|
|
104
107
|
/** Project immutable transport attribution/reporters into each protocol handler. */
|
|
@@ -108,6 +111,7 @@ export function configuredWsHandlerOptions(config: IWsTransportConfig): TConfigu
|
|
|
108
111
|
...(config.surface ? { surface: config.surface } : {}),
|
|
109
112
|
...configuredUsageReporters(config),
|
|
110
113
|
...(config.sessionDirectory ? { sessionDirectory: config.sessionDirectory } : {}),
|
|
114
|
+
...(config.settingsReporter ? { settingsReporter: config.settingsReporter } : {}),
|
|
111
115
|
};
|
|
112
116
|
}
|
|
113
117
|
|
|
@@ -302,7 +302,11 @@ export class WsTransport implements IConfigurableTransport<IProtocolSession>, IP
|
|
|
302
302
|
ws.on('close', delivery.close);
|
|
303
303
|
ws.on('error', delivery.close);
|
|
304
304
|
|
|
305
|
-
delivery.deliver({
|
|
305
|
+
delivery.deliver({
|
|
306
|
+
type: 'messages',
|
|
307
|
+
messages: connectionSession.getMessages(),
|
|
308
|
+
...(this.handlerOptions.driverId ? { driverId: this.handlerOptions.driverId } : {}),
|
|
309
|
+
});
|
|
306
310
|
delivery.deliver({
|
|
307
311
|
type: 'execution_workspace_event',
|
|
308
312
|
snapshot: connectionSession.getExecutionWorkspaceSnapshot(),
|
package/src/ws-transport.ts
CHANGED
|
@@ -28,6 +28,7 @@ export interface IWsTransportOptions {
|
|
|
28
28
|
usageReporter?: ISessionMessageHandlerOptions['usageReporter'];
|
|
29
29
|
storedSessionUsageReporter?: ISessionMessageHandlerOptions['storedSessionUsageReporter'];
|
|
30
30
|
sessionDirectory?: ISessionMessageHandlerOptions['sessionDirectory'];
|
|
31
|
+
settingsReporter?: ISessionMessageHandlerOptions['settingsReporter'];
|
|
31
32
|
driverId?: TDriverId;
|
|
32
33
|
surface?: TUsageSurface;
|
|
33
34
|
}
|
|
@@ -77,6 +78,7 @@ export function createWsTransport(options: IWsTransportOptions): IWsTransport {
|
|
|
77
78
|
? { storedSessionUsageReporter: options.storedSessionUsageReporter }
|
|
78
79
|
: {}),
|
|
79
80
|
...(options.sessionDirectory ? { sessionDirectory: options.sessionDirectory } : {}),
|
|
81
|
+
...(options.settingsReporter ? { settingsReporter: options.settingsReporter } : {}),
|
|
80
82
|
});
|
|
81
83
|
cleanup = handler.cleanup;
|
|
82
84
|
this.onMessage = handler.onMessage;
|