@robota-sdk/agent-transport-http 3.0.0-beta.100

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/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "@robota-sdk/agent-transport-http",
3
+ "version": "3.0.0-beta.100",
4
+ "description": "HTTP (Hono) transport for the Robota SDK",
5
+ "type": "module",
6
+ "main": "dist/node/index.js",
7
+ "types": "dist/node/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "source": "./src/index.ts",
11
+ "node": {
12
+ "import": {
13
+ "types": "./dist/node/index.d.ts",
14
+ "default": "./dist/node/index.js"
15
+ },
16
+ "require": {
17
+ "types": "./dist/node/index.d.cts",
18
+ "default": "./dist/node/index.cjs"
19
+ }
20
+ },
21
+ "default": {
22
+ "import": {
23
+ "types": "./dist/node/index.d.ts",
24
+ "default": "./dist/node/index.js"
25
+ },
26
+ "require": {
27
+ "types": "./dist/node/index.d.cts",
28
+ "default": "./dist/node/index.cjs"
29
+ }
30
+ }
31
+ },
32
+ "./package.json": "./package.json"
33
+ },
34
+ "engines": {
35
+ "node": ">=22.12.0"
36
+ },
37
+ "files": [
38
+ "dist",
39
+ "src",
40
+ "CHANGELOG.md"
41
+ ],
42
+ "dependencies": {
43
+ "hono": "^4.12.32",
44
+ "@robota-sdk/agent-interface-session": "3.0.0-beta.100",
45
+ "@robota-sdk/agent-interface-transport": "3.0.0-beta.100",
46
+ "@robota-sdk/agent-transport": "3.0.0-beta.100"
47
+ },
48
+ "devDependencies": {
49
+ "rimraf": "^5.0.10",
50
+ "tsdown": "^0.22.14",
51
+ "typescript": "^6.0.3",
52
+ "vitest": "^3.2.6"
53
+ },
54
+ "license": "AGPL-3.0-only OR LicenseRef-Commercial",
55
+ "publishConfig": {
56
+ "access": "public"
57
+ },
58
+ "volta": {
59
+ "extends": "../../package.json"
60
+ },
61
+ "repository": {
62
+ "type": "git",
63
+ "url": "https://github.com/woojubb/robota"
64
+ },
65
+ "bugs": {
66
+ "url": "https://github.com/woojubb/robota/issues"
67
+ },
68
+ "homepage": "https://robota.io/",
69
+ "scripts": {
70
+ "build": "rimraf dist && tsdown",
71
+ "build:js": "pnpm run build",
72
+ "build:types": "pnpm run build",
73
+ "typecheck": "tsgo --noEmit",
74
+ "test": "vitest run --passWithNoTests",
75
+ "test:coverage": "vitest run --coverage --passWithNoTests",
76
+ "clean": "rimraf dist"
77
+ }
78
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * SEC-008 — the user-execution scenario, as CODE.
3
+ *
4
+ * The closure of SEC-008 first carried this evidence as prose: a report that a run had happened
5
+ * once, with the runner not checked in. Review named the irony — SEC-008 is precisely about "the
6
+ * trust boundary being documentation rather than code", and the evidence for closing it was
7
+ * documentation rather than code. So the scenario lives here, where anyone can re-run it.
8
+ *
9
+ * What makes this different from the sibling route suites, which already cover the same code paths:
10
+ * it is a REAL HTTP round-trip over a REAL loopback socket, not an in-process `app.fetch()`. The
11
+ * thing being demonstrated is what an unauthenticated remote request DOES to a served transport, so
12
+ * the request has to actually travel.
13
+ *
14
+ * The observable is the session TRANSCRIPT — what reached `submit` — because "rejected" and "never
15
+ * executed" are different claims and only the second one is the security property.
16
+ */
17
+
18
+ import { createServer, type Server } from 'node:http';
19
+
20
+ import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
21
+ import { afterEach, describe, expect, it } from 'vitest';
22
+
23
+ import { createHttpTransport } from '../http-transport.js';
24
+
25
+ import type { IInteractiveSession, ITurnHandle } from '@robota-sdk/agent-interface-session';
26
+
27
+ /**
28
+ * A CONFORMANT turn handle. Not `{ status: 'started' }` behind a cast: `ITurnHandle` is
29
+ * `{ turnId, completed }`, and a double that answers a different shape is a partial
30
+ * re-implementation the compiler was told to stop checking. The route only awaits the terminal
31
+ * event today, so the difference is invisible — until a call site reads `.completed`, at which
32
+ * point the double would misrepresent the contract silently. (#1684 review)
33
+ */
34
+ function stubHandle(turnId: string): ITurnHandle {
35
+ return {
36
+ turnId,
37
+ completed: Promise.resolve({
38
+ response: '',
39
+ history: [],
40
+ toolSummaries: [],
41
+ contextState: { usedTokens: 0, maxTokens: 0, usedPercentage: 0, remainingPercentage: 100 },
42
+ }),
43
+ };
44
+ }
45
+
46
+ /** A session that RECORDS what actually reached it — the transcript the scenario asserts on. */
47
+ function createRecordingSession(): { session: IInteractiveSession; transcript: string[] } {
48
+ const transcript: string[] = [];
49
+ const listeners = new Map<string, Set<(data: unknown) => void>>();
50
+ const emit = (event: string, data: unknown): void => {
51
+ for (const handler of listeners.get(event) ?? []) handler(data);
52
+ };
53
+ const session = createTestInteractiveSession({
54
+ isExecuting: () => false,
55
+ submit: (async (prompt: string) => {
56
+ transcript.push(prompt);
57
+ // The route's SSE stream ends on the terminal event; a session that never emits one leaves
58
+ // the request hanging, so the case would fail on a timeout — a red for the wrong reason.
59
+ queueMicrotask(() => emit('complete', { success: true, content: 'done' }));
60
+ return stubHandle('scenario-turn');
61
+ }) as IInteractiveSession['submit'],
62
+ on: ((event: string, handler: (data: unknown) => void) => {
63
+ if (!listeners.has(event)) listeners.set(event, new Set());
64
+ listeners.get(event)?.add(handler);
65
+ }) as IInteractiveSession['on'],
66
+ off: ((event: string, handler: (data: unknown) => void) => {
67
+ listeners.get(event)?.delete(handler);
68
+ }) as IInteractiveSession['off'],
69
+ });
70
+ return { session, transcript };
71
+ }
72
+
73
+ const servers: Server[] = [];
74
+ afterEach(async () => {
75
+ while (servers.length > 0) {
76
+ const server = servers.pop();
77
+ await new Promise<void>((resolve) => server?.close(() => resolve()));
78
+ }
79
+ });
80
+
81
+ /**
82
+ * Serve a transport's app on a real loopback port and return its base URL.
83
+ *
84
+ * `fetch` is typed as `Response | Promise<Response>` on Hono — it answers synchronously for some
85
+ * routes — so the parameter accepts both and the call site awaits, rather than narrowing the app to
86
+ * a shape it does not have.
87
+ */
88
+ async function serve(app: {
89
+ fetch: (request: Request) => Response | Promise<Response>;
90
+ }): Promise<string> {
91
+ const server = createServer((req, res) => {
92
+ void (async () => {
93
+ // Everything inside is guarded: an unhandled rejection here would leave `res.end()` uncalled,
94
+ // so the client-side `fetch` below would hang to the test timeout instead of failing with a
95
+ // cause. In a file whose whole subject is a gate that refuses LEGIBLY, an illegible hang is
96
+ // the wrong failure mode to build the harness on. (#1684 review)
97
+ try {
98
+ const chunks: Buffer[] = [];
99
+ for await (const chunk of req) chunks.push(chunk as Buffer);
100
+ const address = server.address();
101
+ const port = typeof address === 'object' && address ? address.port : 0;
102
+ // Node's `IncomingHttpHeaders` values are `string | string[] | undefined`, which is not
103
+ // `HeadersInit` — casting across that gap works for this fixture's simple headers and
104
+ // silently mangles an array-valued one (`set-cookie`, a repeated header) the day the
105
+ // harness is reused. Built explicitly instead, so a repeated header stays repeated.
106
+ // (#1684 review)
107
+ const headers = new Headers();
108
+ for (const [name, value] of Object.entries(req.headers)) {
109
+ if (Array.isArray(value)) for (const item of value) headers.append(name, item);
110
+ else if (value !== undefined) headers.set(name, value);
111
+ }
112
+ const response = await app.fetch(
113
+ new Request(`http://127.0.0.1:${port}${req.url ?? '/'}`, {
114
+ method: req.method,
115
+ headers,
116
+ body: req.method === 'GET' || req.method === 'HEAD' ? undefined : Buffer.concat(chunks),
117
+ }),
118
+ );
119
+ res.statusCode = response.status;
120
+ res.end(await response.text());
121
+ } catch (error) {
122
+ // 500 with the cause in the body, so a broken harness reads as a broken harness rather than
123
+ // as a transport that answered nothing.
124
+ res.statusCode = 500;
125
+ res.end(
126
+ `scenario harness failed: ${error instanceof Error ? error.message : String(error)}`,
127
+ );
128
+ }
129
+ })();
130
+ });
131
+ servers.push(server);
132
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
133
+ const address = server.address();
134
+ const port = typeof address === 'object' && address ? address.port : 0;
135
+ return `http://127.0.0.1:${port}`;
136
+ }
137
+
138
+ async function submit(base: string, prompt: string, headers: Record<string, string> = {}) {
139
+ const res = await fetch(`${base}/submit`, {
140
+ method: 'POST',
141
+ headers: { 'content-type': 'application/json', ...headers },
142
+ body: JSON.stringify({ prompt }),
143
+ });
144
+ return { status: res.status, body: await res.text() };
145
+ }
146
+
147
+ describe('SEC-008 scenario — an unauthenticated remote request reaches no session', () => {
148
+ it('rejects POST /submit with no credential, and the prompt is NEVER executed', async () => {
149
+ // `admission` omitted is the shipped default and means SECURE: a credential is minted.
150
+ const transport = createHttpTransport();
151
+ const { session, transcript } = createRecordingSession();
152
+ transport.attach(session);
153
+ await transport.start();
154
+ const base = await serve(transport.getApp());
155
+
156
+ const res = await submit(base, 'hello from an unauthenticated caller');
157
+
158
+ expect(res.status, `expected a rejection, got ${res.status} ${res.body}`).toBe(401);
159
+ // The security property is not "answered 401" — it is that nothing ran.
160
+ expect(transcript, 'the unauthenticated prompt reached the session').toEqual([]);
161
+ });
162
+
163
+ it('accepts the same request with the minted credential, and only that prompt runs', async () => {
164
+ const transport = createHttpTransport();
165
+ const { session, transcript } = createRecordingSession();
166
+ transport.attach(session);
167
+ await transport.start();
168
+ const base = await serve(transport.getApp());
169
+ const token = transport.getAdmissionToken();
170
+
171
+ expect(token, 'omitting admission must MINT a credential, not run open').toBeTruthy();
172
+
173
+ const rejected = await submit(base, 'unauthenticated');
174
+ const accepted = await submit(base, 'authenticated', { authorization: `Bearer ${token}` });
175
+
176
+ expect(rejected.status).toBe(401);
177
+ expect(accepted.status, `expected success, got ${accepted.status} ${accepted.body}`).toBe(200);
178
+ expect(transcript).toEqual(['authenticated']);
179
+ });
180
+
181
+ it('CONTRAST: an explicitly OPEN transport executes the same unauthenticated prompt', async () => {
182
+ // What the absent gate used to do for every caller. Reaching this state now takes asking for it
183
+ // AND writing down why — `openReason` is required, and `open` with a token is refused outright.
184
+ const transport = createHttpTransport({
185
+ admission: { open: true, openReason: 'SEC-008 scenario: the contrast this closure rests on' },
186
+ });
187
+ const { session, transcript } = createRecordingSession();
188
+ transport.attach(session);
189
+ await transport.start();
190
+ const base = await serve(transport.getApp());
191
+
192
+ const res = await submit(base, 'unauthenticated against an open transport');
193
+
194
+ expect(transport.getAdmissionToken(), 'an open transport holds no credential').toBeNull();
195
+ expect(res.status).toBe(200);
196
+ expect(transcript).toEqual(['unauthenticated against an open transport']);
197
+ });
198
+ });
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { createHttpTransport } from '../http-transport.js';
4
+
5
+ import type { IHttpTransportSession } from '../http-session.js';
6
+
7
+ /**
8
+ * Issue #2480 (TRANS-002): `basePath` was declared and advertised but never read, so routes always
9
+ * mounted at root. It is honored now.
10
+ */
11
+ describe('createHttpTransport basePath', () => {
12
+ it('mounts every route under basePath when one is given', async () => {
13
+ const transport = createHttpTransport({
14
+ basePath: '/api',
15
+ admission: { open: true, openReason: 'basePath test' },
16
+ });
17
+ transport.attach({} as IHttpTransportSession);
18
+ await transport.start();
19
+ const paths = transport.getApp().routes.map((route) => route.path);
20
+ expect(paths.length).toBeGreaterThan(0);
21
+ expect(paths.every((path) => path.startsWith('/api'))).toBe(true);
22
+ });
23
+
24
+ it('mounts at root when basePath is absent', async () => {
25
+ const transport = createHttpTransport({
26
+ admission: { open: true, openReason: 'basePath test' },
27
+ });
28
+ transport.attach({} as IHttpTransportSession);
29
+ await transport.start();
30
+ const paths = transport.getApp().routes.map((route) => route.path);
31
+ expect(paths.some((path) => path.startsWith('/api'))).toBe(false);
32
+ });
33
+ });
@@ -0,0 +1,174 @@
1
+ import { runTransportLifecycleConformance } from '@robota-sdk/agent-interface-transport/testing';
2
+ import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
3
+
4
+ import { describe, it, expect, expectTypeOf } from 'vitest';
5
+ import { createHttpTransport } from '../http-transport.js';
6
+ import type { IHttpTransportSession } from '../http-session.js';
7
+ import type { IHttpTransport } from '../http-transport.js';
8
+ import type { ITransportAdapter } from '@robota-sdk/agent-interface-transport';
9
+
10
+ function createMockSession(): IHttpTransportSession {
11
+ const full = createTestInteractiveSession();
12
+ const {
13
+ submit,
14
+ on,
15
+ off,
16
+ abort,
17
+ cancelQueue,
18
+ getSession,
19
+ executeCommand,
20
+ listCommands,
21
+ listSkills,
22
+ listModels,
23
+ getMessages,
24
+ getFullHistory,
25
+ getMessagesDisplay,
26
+ getContextState,
27
+ isExecuting,
28
+ getPendingPrompt,
29
+ getPendingCount,
30
+ resolvePermission,
31
+ resolveAsk,
32
+ } = full;
33
+ return {
34
+ submit,
35
+ on,
36
+ off,
37
+ abort,
38
+ cancelQueue,
39
+ getSession,
40
+ executeCommand,
41
+ listCommands,
42
+ listSkills,
43
+ listModels,
44
+ getMessages,
45
+ getFullHistory,
46
+ getMessagesDisplay,
47
+ getContextState,
48
+ isExecuting,
49
+ getPendingPrompt,
50
+ getPendingCount,
51
+ resolvePermission,
52
+ resolveAsk,
53
+ };
54
+ }
55
+
56
+ describe('createHttpTransport', () => {
57
+ it('requires exactly the HTTP session port without a broad attach overload', () => {
58
+ const transport = createHttpTransport();
59
+ expectTypeOf(transport).toMatchTypeOf<ITransportAdapter<IHttpTransportSession>>();
60
+ expectTypeOf<IHttpTransport['attach']>().toEqualTypeOf<
61
+ (session: IHttpTransportSession) => void
62
+ >();
63
+ expectTypeOf<IHttpTransportSession>().not.toHaveProperty('listRuntimeTools');
64
+ // #3282 §2 added `listModels` to `ISessionCommands` (15 + 1); #3288 §2 added `getMessagesDisplay`
65
+ // to `ISessionConversationRead` (16 + 1); #3449 added `ISessionPromptResolution` (17 + 2).
66
+ expect(Object.keys(createMockSession())).toHaveLength(19);
67
+ });
68
+
69
+ it('returns an adapter with name "http"', () => {
70
+ const transport = createHttpTransport();
71
+ expect(transport.name).toBe('http');
72
+ });
73
+
74
+ it('throws if start() is called without attach()', async () => {
75
+ const transport = createHttpTransport();
76
+ await expect(transport.start()).rejects.toMatchObject({
77
+ name: 'TransportLifecycleError',
78
+ code: 'not-attached',
79
+ });
80
+ });
81
+
82
+ it('throws if getApp() is called before start()', () => {
83
+ const transport = createHttpTransport();
84
+ expect(() => transport.getApp()).toThrow('Transport not started');
85
+ });
86
+
87
+ it('creates a Hono app after attach + start', async () => {
88
+ const transport = createHttpTransport();
89
+ transport.attach(createMockSession());
90
+ await transport.start();
91
+ const app = transport.getApp();
92
+ expect(app).toBeDefined();
93
+ expect(typeof app.fetch).toBe('function');
94
+ });
95
+
96
+ it('nullifies app after stop()', async () => {
97
+ const transport = createHttpTransport();
98
+ transport.attach(createMockSession());
99
+ await transport.start();
100
+ await transport.stop();
101
+ expect(() => transport.getApp()).toThrow('Transport not started');
102
+ });
103
+
104
+ it('invokes the shared lifecycle conformance suite', async () => {
105
+ await runTransportLifecycleConformance({
106
+ subjectId: '@robota-sdk/agent-transport-http#createHttpTransport',
107
+ kind: 'service',
108
+ createAdapter: () =>
109
+ createHttpTransport({
110
+ admission: { open: true, openReason: 'ARCH-011 lifecycle conformance' },
111
+ }),
112
+ createSession: createMockSession,
113
+ assertReady: (transport) => {
114
+ if (typeof transport.getApp().fetch !== 'function') throw new Error('HTTP app not ready');
115
+ },
116
+ assertStopped: (transport) => {
117
+ try {
118
+ transport.getApp();
119
+ throw new Error('HTTP app still ready');
120
+ } catch (error) {
121
+ if (!(error instanceof Error) || error.message === 'HTTP app still ready') throw error;
122
+ }
123
+ },
124
+ });
125
+ });
126
+ });
127
+
128
+ describe('SEC-008: the decision is resolved once', () => {
129
+ it('requires the credential it minted, not a second one', async () => {
130
+ // The transport resolved admission at construction and then rebuilt a CONFIG for the routes to
131
+ // resolve again. Two resolutions of one decision: harmless while a token was given, and a fresh
132
+ // mint on the second pass if the first had opened without a reason — so the credential the host
133
+ // was handed by `getAdmissionToken()` would not be the one the routes required.
134
+ const transport = createHttpTransport();
135
+ transport.attach(createMockSession());
136
+ await transport.start();
137
+ const token = transport.getAdmissionToken();
138
+
139
+ expect(token).not.toBeNull();
140
+ const refused = await transport.getApp().request('/executing');
141
+ const admitted = await transport
142
+ .getApp()
143
+ .request('/executing', { headers: { authorization: `Bearer ${token}` } });
144
+
145
+ expect(refused.status).toBe(401);
146
+ expect(admitted.status, 'the token the host was handed does not open the door').toBe(200);
147
+ });
148
+
149
+ it('MINTS for `token: ""`, and the host is handed what it must present', async () => {
150
+ // `{ token: '' }` is documented as "mint a fresh one". This pins the outcome end to end.
151
+ //
152
+ // It does NOT red-prove the discriminator defect, and saying so is the point of this comment:
153
+ // `createHttpTransport` resolves at construction, so the routes it builds receive an
154
+ // already-resolved admission and the discriminator handled that case correctly. The defect was
155
+ // only reachable through `createAgentRoutes` DIRECTLY — measured, this case passes either way.
156
+ //
157
+ // Through that direct path the defect has no black-box symptom either: "requires the empty
158
+ // string" and "requires a token you were not given" are both 401 forever, because
159
+ // `bearerCredential` requires at least one character after `Bearer ` and so a presented
160
+ // credential is never the empty string. It is a LOCKOUT, not a bypass. That is exactly why the
161
+ // fix deletes the discriminator instead of testing around it, and why the case that IS red on
162
+ // it lives at the seam (`agent-transport/src/__tests__/admission.test.ts`).
163
+ const transport = createHttpTransport({ admission: { token: '' } });
164
+ transport.attach(createMockSession());
165
+ await transport.start();
166
+ const token = transport.getAdmissionToken();
167
+
168
+ expect(token).toMatch(/^[0-9a-f]{64}$/);
169
+ const admitted = await transport
170
+ .getApp()
171
+ .request('/executing', { headers: { authorization: `Bearer ${token}` } });
172
+ expect(admitted.status, 'the token the host was handed does not open the door').toBe(200);
173
+ });
174
+ });