@robota-sdk/agent-transport-http 3.0.0-beta.78 → 3.0.0-beta.81
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/README.md +4 -0
- package/dist/node/index.cjs +1 -1
- package/dist/node/index.d.cts +106 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +81 -8
- 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 +30 -14
- package/src/__tests__/admission-loopback-scenario.test.ts +198 -0
- package/src/__tests__/http-base-path.test.ts +33 -0
- package/src/__tests__/http-transport.test.ts +125 -20
- package/src/__tests__/routes-streamsse-throw.test.ts +49 -0
- package/src/__tests__/routes.test.ts +793 -13
- package/src/__tests__/submit-stream.test.ts +126 -0
- package/src/__tests__/turn-claims.test.ts +102 -0
- package/src/http-session.ts +20 -0
- package/src/http-transport.ts +60 -9
- package/src/index.ts +3 -1
- package/src/routes.ts +86 -69
- package/src/submit-route.ts +162 -0
- package/src/submit-stream.ts +233 -0
- package/src/turn-claims.ts +62 -0
|
@@ -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
|
+
});
|
|
@@ -1,26 +1,57 @@
|
|
|
1
|
-
import {
|
|
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';
|
|
2
5
|
import { createHttpTransport } from '../http-transport.js';
|
|
3
|
-
import type {
|
|
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';
|
|
4
9
|
|
|
5
|
-
function createMockSession():
|
|
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
|
+
getMessages,
|
|
22
|
+
getContextState,
|
|
23
|
+
isExecuting,
|
|
24
|
+
getPendingPrompt,
|
|
25
|
+
getPendingCount,
|
|
26
|
+
} = full;
|
|
6
27
|
return {
|
|
7
|
-
submit
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
28
|
+
submit,
|
|
29
|
+
on,
|
|
30
|
+
off,
|
|
31
|
+
abort,
|
|
32
|
+
cancelQueue,
|
|
33
|
+
getSession,
|
|
34
|
+
executeCommand,
|
|
35
|
+
listCommands,
|
|
36
|
+
getMessages,
|
|
37
|
+
getContextState,
|
|
38
|
+
isExecuting,
|
|
39
|
+
getPendingPrompt,
|
|
40
|
+
getPendingCount,
|
|
41
|
+
};
|
|
21
42
|
}
|
|
22
43
|
|
|
23
44
|
describe('createHttpTransport', () => {
|
|
45
|
+
it('requires exactly the HTTP session port without a broad attach overload', () => {
|
|
46
|
+
const transport = createHttpTransport();
|
|
47
|
+
expectTypeOf(transport).toMatchTypeOf<ITransportAdapter<IHttpTransportSession>>();
|
|
48
|
+
expectTypeOf<IHttpTransport['attach']>().toEqualTypeOf<
|
|
49
|
+
(session: IHttpTransportSession) => void
|
|
50
|
+
>();
|
|
51
|
+
expectTypeOf<IHttpTransportSession>().not.toHaveProperty('listRuntimeTools');
|
|
52
|
+
expect(Object.keys(createMockSession())).toHaveLength(13);
|
|
53
|
+
});
|
|
54
|
+
|
|
24
55
|
it('returns an adapter with name "http"', () => {
|
|
25
56
|
const transport = createHttpTransport();
|
|
26
57
|
expect(transport.name).toBe('http');
|
|
@@ -28,7 +59,10 @@ describe('createHttpTransport', () => {
|
|
|
28
59
|
|
|
29
60
|
it('throws if start() is called without attach()', async () => {
|
|
30
61
|
const transport = createHttpTransport();
|
|
31
|
-
await expect(transport.start()).rejects.
|
|
62
|
+
await expect(transport.start()).rejects.toMatchObject({
|
|
63
|
+
name: 'TransportLifecycleError',
|
|
64
|
+
code: 'not-attached',
|
|
65
|
+
});
|
|
32
66
|
});
|
|
33
67
|
|
|
34
68
|
it('throws if getApp() is called before start()', () => {
|
|
@@ -38,7 +72,7 @@ describe('createHttpTransport', () => {
|
|
|
38
72
|
|
|
39
73
|
it('creates a Hono app after attach + start', async () => {
|
|
40
74
|
const transport = createHttpTransport();
|
|
41
|
-
transport.attach(createMockSession()
|
|
75
|
+
transport.attach(createMockSession());
|
|
42
76
|
await transport.start();
|
|
43
77
|
const app = transport.getApp();
|
|
44
78
|
expect(app).toBeDefined();
|
|
@@ -47,9 +81,80 @@ describe('createHttpTransport', () => {
|
|
|
47
81
|
|
|
48
82
|
it('nullifies app after stop()', async () => {
|
|
49
83
|
const transport = createHttpTransport();
|
|
50
|
-
transport.attach(createMockSession()
|
|
84
|
+
transport.attach(createMockSession());
|
|
51
85
|
await transport.start();
|
|
52
86
|
await transport.stop();
|
|
53
87
|
expect(() => transport.getApp()).toThrow('Transport not started');
|
|
54
88
|
});
|
|
89
|
+
|
|
90
|
+
it('invokes the shared lifecycle conformance suite', async () => {
|
|
91
|
+
await runTransportLifecycleConformance({
|
|
92
|
+
subjectId: '@robota-sdk/agent-transport-http#createHttpTransport',
|
|
93
|
+
kind: 'service',
|
|
94
|
+
createAdapter: () =>
|
|
95
|
+
createHttpTransport({
|
|
96
|
+
admission: { open: true, openReason: 'ARCH-011 lifecycle conformance' },
|
|
97
|
+
}),
|
|
98
|
+
createSession: createMockSession,
|
|
99
|
+
assertReady: (transport) => {
|
|
100
|
+
if (typeof transport.getApp().fetch !== 'function') throw new Error('HTTP app not ready');
|
|
101
|
+
},
|
|
102
|
+
assertStopped: (transport) => {
|
|
103
|
+
try {
|
|
104
|
+
transport.getApp();
|
|
105
|
+
throw new Error('HTTP app still ready');
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (!(error instanceof Error) || error.message === 'HTTP app still ready') throw error;
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('SEC-008: the decision is resolved once', () => {
|
|
115
|
+
it('requires the credential it minted, not a second one', async () => {
|
|
116
|
+
// The transport resolved admission at construction and then rebuilt a CONFIG for the routes to
|
|
117
|
+
// resolve again. Two resolutions of one decision: harmless while a token was given, and a fresh
|
|
118
|
+
// mint on the second pass if the first had opened without a reason — so the credential the host
|
|
119
|
+
// was handed by `getAdmissionToken()` would not be the one the routes required.
|
|
120
|
+
const transport = createHttpTransport();
|
|
121
|
+
transport.attach(createMockSession());
|
|
122
|
+
await transport.start();
|
|
123
|
+
const token = transport.getAdmissionToken();
|
|
124
|
+
|
|
125
|
+
expect(token).not.toBeNull();
|
|
126
|
+
const refused = await transport.getApp().request('/executing');
|
|
127
|
+
const admitted = await transport
|
|
128
|
+
.getApp()
|
|
129
|
+
.request('/executing', { headers: { authorization: `Bearer ${token}` } });
|
|
130
|
+
|
|
131
|
+
expect(refused.status).toBe(401);
|
|
132
|
+
expect(admitted.status, 'the token the host was handed does not open the door').toBe(200);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('MINTS for `token: ""`, and the host is handed what it must present', async () => {
|
|
136
|
+
// `{ token: '' }` is documented as "mint a fresh one". This pins the outcome end to end.
|
|
137
|
+
//
|
|
138
|
+
// It does NOT red-prove the discriminator defect, and saying so is the point of this comment:
|
|
139
|
+
// `createHttpTransport` resolves at construction, so the routes it builds receive an
|
|
140
|
+
// already-resolved admission and the discriminator handled that case correctly. The defect was
|
|
141
|
+
// only reachable through `createAgentRoutes` DIRECTLY — measured, this case passes either way.
|
|
142
|
+
//
|
|
143
|
+
// Through that direct path the defect has no black-box symptom either: "requires the empty
|
|
144
|
+
// string" and "requires a token you were not given" are both 401 forever, because
|
|
145
|
+
// `bearerCredential` requires at least one character after `Bearer ` and so a presented
|
|
146
|
+
// credential is never the empty string. It is a LOCKOUT, not a bypass. That is exactly why the
|
|
147
|
+
// fix deletes the discriminator instead of testing around it, and why the case that IS red on
|
|
148
|
+
// it lives at the seam (`agent-transport/src/__tests__/admission.test.ts`).
|
|
149
|
+
const transport = createHttpTransport({ admission: { token: '' } });
|
|
150
|
+
transport.attach(createMockSession());
|
|
151
|
+
await transport.start();
|
|
152
|
+
const token = transport.getAdmissionToken();
|
|
153
|
+
|
|
154
|
+
expect(token).toMatch(/^[0-9a-f]{64}$/);
|
|
155
|
+
const admitted = await transport
|
|
156
|
+
.getApp()
|
|
157
|
+
.request('/executing', { headers: { authorization: `Bearer ${token}` } });
|
|
158
|
+
expect(admitted.status, 'the token the host was handed does not open the door').toBe(200);
|
|
159
|
+
});
|
|
55
160
|
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The catch around `streamSSE` itself — reached by making the boundary throw.
|
|
3
|
+
*
|
|
4
|
+
* The branch guards the claim when Hono throws BEFORE ever running the relay callback, and for two
|
|
5
|
+
* rounds it shipped as honestly-labelled unverified code: a throw from inside the callback lands in
|
|
6
|
+
* the inner `finally` (measured — a case built that way was green with this catch removed), and the
|
|
7
|
+
* real `streamSSE` has no synchronous throw path today. Review asked for the third option: a
|
|
8
|
+
* synthetic stub AT the module boundary. Mocking `hono/streaming` tests OUR handler's ordering —
|
|
9
|
+
* hold, then release on the boundary's throw — without asserting anything about Hono's internals,
|
|
10
|
+
* which is exactly what the branch is written against.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
14
|
+
|
|
15
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
16
|
+
|
|
17
|
+
vi.mock('hono/streaming', () => ({
|
|
18
|
+
streamSSE: () => {
|
|
19
|
+
throw new Error('the boundary threw before the callback ran');
|
|
20
|
+
},
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
const { createAgentRoutes } = await import('../routes.js');
|
|
24
|
+
|
|
25
|
+
describe('a streamSSE that throws synchronously', () => {
|
|
26
|
+
it('does not leave the session claimed forever', async () => {
|
|
27
|
+
const session = createTestInteractiveSession();
|
|
28
|
+
const app = createAgentRoutes({
|
|
29
|
+
sessionFactory: () => session,
|
|
30
|
+
// SEC-008: admission is not under test here, and the reason it is open says so.
|
|
31
|
+
admission: { open: true, openReason: 'SEC-008: this case is about routing, not admission' },
|
|
32
|
+
});
|
|
33
|
+
const post = () =>
|
|
34
|
+
app.request('/submit', {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
body: JSON.stringify({ prompt: 'x' }),
|
|
37
|
+
headers: { 'content-type': 'application/json' },
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const first = await post();
|
|
41
|
+
const second = await post();
|
|
42
|
+
|
|
43
|
+
// Both requests fail on the thrown boundary — a 500, not this route's business. What IS this
|
|
44
|
+
// route's business: the second must not be 409. A leaked claim turns every later request into
|
|
45
|
+
// "session busy" forever, and that difference is the whole reason the catch exists.
|
|
46
|
+
expect(first.status).toBe(500);
|
|
47
|
+
expect(second.status, 'the first request took the claim with it').not.toBe(409);
|
|
48
|
+
});
|
|
49
|
+
});
|