@robota-sdk/agent-transport-http 3.0.0-beta.79 → 3.0.0-beta.82
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,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The relay's own failure modes — the ones an HTTP request cannot conveniently reach.
|
|
3
|
+
*
|
|
4
|
+
* `reportStreamFailure` runs after the headers are out, and `onAbort` runs when the client is
|
|
5
|
+
* already gone; both are places where "what does the other side learn" and "does the claim come
|
|
6
|
+
* back" have to be asked directly.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
10
|
+
|
|
11
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
12
|
+
|
|
13
|
+
import { relayTurn, reportStreamFailure } from '../submit-stream.js';
|
|
14
|
+
|
|
15
|
+
import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
|
|
16
|
+
import type { SSEStreamingApi } from 'hono/streaming';
|
|
17
|
+
|
|
18
|
+
function fakeStream(overrides: Partial<SSEStreamingApi> = {}): {
|
|
19
|
+
stream: SSEStreamingApi;
|
|
20
|
+
written: Array<{ event?: string; data: string }>;
|
|
21
|
+
} {
|
|
22
|
+
const written: Array<{ event?: string; data: string }> = [];
|
|
23
|
+
const stream = {
|
|
24
|
+
writeSSE: (message: { event?: string; data: string }) => {
|
|
25
|
+
written.push(message);
|
|
26
|
+
return Promise.resolve();
|
|
27
|
+
},
|
|
28
|
+
onAbort: () => {},
|
|
29
|
+
...overrides,
|
|
30
|
+
} as unknown as SSEStreamingApi;
|
|
31
|
+
return { stream, written };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('what a client learns from a stream that failed after it opened', () => {
|
|
35
|
+
it('is a generic line, not the exception', async () => {
|
|
36
|
+
// Review, twice: this route can be mounted outside a trust boundary, and an exception ESCAPING
|
|
37
|
+
// the stream callback is not a message anything composed for a client — it can carry provider
|
|
38
|
+
// internals, paths, stack fragments. The detail goes to the package logger; the client learns
|
|
39
|
+
// that the stream failed, which is the part it can act on.
|
|
40
|
+
const { stream, written } = fakeStream();
|
|
41
|
+
|
|
42
|
+
await reportStreamFailure(new Error('ENOENT /home/op/.robota/providers/key.json'), stream);
|
|
43
|
+
|
|
44
|
+
expect(written).toHaveLength(1);
|
|
45
|
+
expect(written[0].data).not.toContain('key.json');
|
|
46
|
+
expect(written[0].data).toContain('the stream failed on the server');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('swallows a write to a stream that is already gone', async () => {
|
|
50
|
+
const { stream } = fakeStream({
|
|
51
|
+
writeSSE: () => Promise.reject(new Error('closed')),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
await expect(reportStreamFailure(new Error('x'), stream)).resolves.toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('a failure in the callback never escapes to the runner', () => {
|
|
59
|
+
it('reports generically, hands the detail to the injected listener, and does not throw', async () => {
|
|
60
|
+
// Hono's runner follows any `onError` by writing the raw `e.message` to the stream — measured:
|
|
61
|
+
// the body carried the generic line AND the leak. So the boundary holds only if the callback
|
|
62
|
+
// catches its own failures, which is what this pins.
|
|
63
|
+
const detail = vi.fn();
|
|
64
|
+
const session = createTestInteractiveSession({
|
|
65
|
+
on: ((event: string) => {
|
|
66
|
+
if (event === 'text_delta') throw new Error('ENOENT /home/op/.robota/key.json');
|
|
67
|
+
}) as IInteractiveSession['on'],
|
|
68
|
+
});
|
|
69
|
+
const { stream, written } = fakeStream();
|
|
70
|
+
|
|
71
|
+
await expect(relayTurn(session, 'p', () => {}, detail)(stream)).resolves.toBeUndefined();
|
|
72
|
+
|
|
73
|
+
expect(detail).toHaveBeenCalledTimes(1);
|
|
74
|
+
expect(detail.mock.calls[0][0].message).toContain('key.json');
|
|
75
|
+
expect(written.map((w) => w.data).join('\n')).not.toContain('key.json');
|
|
76
|
+
expect(written.map((w) => w.data).join('\n')).toContain('the stream failed on the server');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('still tells the client when the listener itself throws', async () => {
|
|
80
|
+
const { stream, written } = fakeStream();
|
|
81
|
+
|
|
82
|
+
await reportStreamFailure(new Error('detail'), stream, () => {
|
|
83
|
+
throw new Error('the host listener is broken');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(written).toHaveLength(1);
|
|
87
|
+
expect(written[0].data).toContain('the stream failed on the server');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('a client disconnect releases the claim even when abort throws', () => {
|
|
92
|
+
it('settles and releases', async () => {
|
|
93
|
+
// Review: before the claim registry this cost a listener leak; now an `abort()` that throws
|
|
94
|
+
// would hold the claim forever and 409 every future /submit to this session. `abort()` is typed
|
|
95
|
+
// as a synchronous void and the shipped one does not throw — this case is what turns that from
|
|
96
|
+
// a load-bearing assumption into an implementation detail.
|
|
97
|
+
const release = vi.fn();
|
|
98
|
+
// The conformant double, not a cast — the contract-cast ratchet refuses another one, and it is
|
|
99
|
+
// right to. `submit` resolves at once so the relay parks on `done`, which only the abort path
|
|
100
|
+
// settles here.
|
|
101
|
+
const session = createTestInteractiveSession({
|
|
102
|
+
abort: () => {
|
|
103
|
+
throw new Error('abort blew up');
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
let abortHandler!: () => void;
|
|
108
|
+
const { stream } = fakeStream({
|
|
109
|
+
onAbort: (handler: () => void) => {
|
|
110
|
+
abortHandler = handler;
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const detail = vi.fn();
|
|
115
|
+
const relaying = relayTurn(session, 'prompt', release, detail)(stream);
|
|
116
|
+
// The abort handler runs inside Hono's abort dispatch, OUTSIDE the relay's try/catch — review
|
|
117
|
+
// traced where a rethrow lands (an unhandled rejection, not a reported failure). So it does not
|
|
118
|
+
// throw: the detail goes to the injected listener and the relay still settles.
|
|
119
|
+
expect(() => abortHandler()).not.toThrow();
|
|
120
|
+
await relaying;
|
|
121
|
+
|
|
122
|
+
expect(release).toHaveBeenCalledTimes(1);
|
|
123
|
+
expect(detail).toHaveBeenCalledTimes(1);
|
|
124
|
+
expect(detail.mock.calls[0][0].message).toBe('abort blew up');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RUNTIME-38 — the claim registry's own contract.
|
|
3
|
+
*
|
|
4
|
+
* It was exercised only through `routes.test.ts`, which asks HTTP questions and gets HTTP answers.
|
|
5
|
+
* Review asked for these directly, and the reason is the one this repository keeps rediscovering: a
|
|
6
|
+
* branch reached only through a wrapper is a branch whose behaviour is inferred. `keyFor` has three
|
|
7
|
+
* outcomes and two of them are about a session that misbehaves — the shapes an HTTP case would have
|
|
8
|
+
* to build a broken session to reach at all.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from 'vitest';
|
|
12
|
+
|
|
13
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
14
|
+
|
|
15
|
+
import { createTurnClaims } from '../turn-claims.js';
|
|
16
|
+
|
|
17
|
+
import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A session that names itself however the case needs it to.
|
|
21
|
+
*
|
|
22
|
+
* Built on the PUBLISHED conformant double rather than cast to the contract — `contract-cast-ratchet`
|
|
23
|
+
* refuses another cast, and it is right to: a cast is a partial re-implementation nothing checks
|
|
24
|
+
* against the real interface, so a member the contract gains later is silently absent from it.
|
|
25
|
+
*/
|
|
26
|
+
function sessionNamed(id: string): IInteractiveSession {
|
|
27
|
+
return createTestInteractiveSession({
|
|
28
|
+
getSession: () => ({ getSessionId: () => id }) as ReturnType<IInteractiveSession['getSession']>,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('the key a session is claimed under', () => {
|
|
33
|
+
it('is the id the session declares', () => {
|
|
34
|
+
expect(createTurnClaims().keyFor(sessionNamed('session_a'))).toBe('session_a');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('does NOT depend on object identity', () => {
|
|
38
|
+
// The defect the id-keying replaced: a `sessionFactory` returning a fresh wrapper per call for
|
|
39
|
+
// one logical session made every request look unclaimed, and the guard was silently absent.
|
|
40
|
+
const claims = createTurnClaims();
|
|
41
|
+
|
|
42
|
+
expect(claims.keyFor(sessionNamed('same'))).toBe(claims.keyFor(sessionNamed('same')));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('is undefined when the session names itself with an empty string', () => {
|
|
46
|
+
// Not `''`, which is a key: every unnameable session would collide into it and 409 each other.
|
|
47
|
+
expect(createTurnClaims().keyFor(sessionNamed(''))).toBeUndefined();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('is undefined when the session THROWS while naming itself', () => {
|
|
51
|
+
const throwing = createTestInteractiveSession({
|
|
52
|
+
getSession: () => {
|
|
53
|
+
throw new Error('no session bound');
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
expect(createTurnClaims().keyFor(throwing)).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('holding and releasing a claim', () => {
|
|
62
|
+
it('reports a key as held only between hold and release', () => {
|
|
63
|
+
const claims = createTurnClaims();
|
|
64
|
+
|
|
65
|
+
expect(claims.isHeld('a')).toBe(false);
|
|
66
|
+
claims.hold('a');
|
|
67
|
+
expect(claims.isHeld('a')).toBe(true);
|
|
68
|
+
claims.release('a');
|
|
69
|
+
expect(claims.isHeld('a')).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('holds each session separately', () => {
|
|
73
|
+
// PER SESSION, not one flag for the router: a busy neighbour is not a reason to refuse you, and
|
|
74
|
+
// a single flag made one tenant's turn refuse every other tenant's.
|
|
75
|
+
const claims = createTurnClaims();
|
|
76
|
+
|
|
77
|
+
claims.hold('tenant_a');
|
|
78
|
+
|
|
79
|
+
expect(claims.isHeld('tenant_a')).toBe(true);
|
|
80
|
+
expect(claims.isHeld('tenant_b')).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('releases a key that was never held without complaint', () => {
|
|
84
|
+
// The teardown path runs in a `finally` and cannot know whether the claim was taken — a throw
|
|
85
|
+
// there would replace the error being unwound with one about bookkeeping.
|
|
86
|
+
const claims = createTurnClaims();
|
|
87
|
+
|
|
88
|
+
expect(() => claims.release('never-held')).not.toThrow();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('is idempotent on a second hold, so one release still frees it', () => {
|
|
92
|
+
// The route holds once per request and releases once per request. Stating it here means a
|
|
93
|
+
// future counting implementation has to decide deliberately rather than by accident.
|
|
94
|
+
const claims = createTurnClaims();
|
|
95
|
+
|
|
96
|
+
claims.hold('a');
|
|
97
|
+
claims.hold('a');
|
|
98
|
+
claims.release('a');
|
|
99
|
+
|
|
100
|
+
expect(claims.isHeld('a')).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ISessionCommands,
|
|
3
|
+
ISessionConversationRead,
|
|
4
|
+
ISessionEvents,
|
|
5
|
+
ISessionExecutionState,
|
|
6
|
+
ISessionIdentity,
|
|
7
|
+
ISessionTurnControl,
|
|
8
|
+
ISessionTurnSubmission,
|
|
9
|
+
} from '@robota-sdk/agent-interface-session';
|
|
10
|
+
|
|
11
|
+
/** The exact session capabilities consumed by the public HTTP transport. */
|
|
12
|
+
export interface IHttpTransportSession
|
|
13
|
+
extends
|
|
14
|
+
ISessionTurnSubmission,
|
|
15
|
+
ISessionEvents,
|
|
16
|
+
ISessionTurnControl,
|
|
17
|
+
ISessionIdentity,
|
|
18
|
+
ISessionCommands,
|
|
19
|
+
ISessionConversationRead,
|
|
20
|
+
ISessionExecutionState {}
|
package/src/http-transport.ts
CHANGED
|
@@ -5,37 +5,88 @@
|
|
|
5
5
|
* while exposing the underlying Hono app via getApp().
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { resolveAdmission } from '@robota-sdk/agent-transport/node';
|
|
9
|
+
import { Hono } from 'hono';
|
|
10
|
+
|
|
8
11
|
import { createAgentRoutes } from './routes.js';
|
|
9
12
|
|
|
10
|
-
import type {
|
|
11
|
-
import type {
|
|
13
|
+
import type { IHttpTransportSession } from './http-session.js';
|
|
14
|
+
import type { TStreamFailureListener } from './submit-stream.js';
|
|
15
|
+
import type {
|
|
16
|
+
ITransportAdapter,
|
|
17
|
+
ITransportAdmissionConfig,
|
|
18
|
+
ITransportLifecycleError,
|
|
19
|
+
} from '@robota-sdk/agent-interface-transport';
|
|
12
20
|
|
|
13
21
|
export interface IHttpTransportOptions {
|
|
14
22
|
/** Optional: base path prefix for routes. */
|
|
15
23
|
basePath?: string;
|
|
24
|
+
/**
|
|
25
|
+
* SEC-008: what a peer must present. Omitted means SECURE — a credential is minted and
|
|
26
|
+
* `getAdmissionToken()` returns it for the host to hand to its client.
|
|
27
|
+
*
|
|
28
|
+
* This transport previously had no gate at all, so omitting the option used to mean "anyone may
|
|
29
|
+
* execute anything". Omission now means the safe thing, and running open takes saying so.
|
|
30
|
+
*/
|
|
31
|
+
admission?: ITransportAdmissionConfig;
|
|
32
|
+
/**
|
|
33
|
+
* Where the DETAIL of a post-headers stream failure goes — forwarded to `createAgentRoutes`.
|
|
34
|
+
* Review found this seam missing: the option existed on the routes and not on the entry point
|
|
35
|
+
* README and the examples actually use, so "the host decides the destination" was a decision no
|
|
36
|
+
* host on this path could make. Same forwarding rule as `admission`, which sat beside it.
|
|
37
|
+
*/
|
|
38
|
+
onStreamFailure?: TStreamFailureListener;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface IHttpTransport extends ITransportAdapter<IHttpTransportSession> {
|
|
42
|
+
getApp(): Hono;
|
|
43
|
+
getAdmissionToken(): string | null;
|
|
16
44
|
}
|
|
17
45
|
|
|
18
|
-
export function createHttpTransport(
|
|
19
|
-
|
|
20
|
-
): ITransportAdapter<IInteractiveSession> & { getApp(): Hono } {
|
|
21
|
-
let session: IInteractiveSession | null = null;
|
|
46
|
+
export function createHttpTransport(options?: IHttpTransportOptions): IHttpTransport {
|
|
47
|
+
let session: IHttpTransportSession | null = null;
|
|
22
48
|
let app: Hono | null = null;
|
|
49
|
+
// Resolved at CONSTRUCTION, not at start: a host needs the credential to hand to its client, and
|
|
50
|
+
// a transport that cannot mint one must fail before anything is served.
|
|
51
|
+
const admission = resolveAdmission(options?.admission);
|
|
52
|
+
const lifecycleError = (code: ITransportLifecycleError['code']): ITransportLifecycleError =>
|
|
53
|
+
Object.assign(new Error(`HTTP transport ${code}.`), {
|
|
54
|
+
name: 'TransportLifecycleError' as const,
|
|
55
|
+
code,
|
|
56
|
+
transportName: 'http',
|
|
57
|
+
});
|
|
23
58
|
|
|
24
59
|
return {
|
|
25
60
|
name: 'http',
|
|
26
|
-
|
|
61
|
+
lifecycle: Object.freeze({ kind: 'service' }),
|
|
62
|
+
attach(s: IHttpTransportSession) {
|
|
27
63
|
session = s;
|
|
28
64
|
},
|
|
29
65
|
async start() {
|
|
30
|
-
if (!session) throw
|
|
31
|
-
|
|
66
|
+
if (!session) throw lifecycleError('not-attached');
|
|
67
|
+
if (app) throw lifecycleError('already-started');
|
|
68
|
+
const routes = createAgentRoutes({
|
|
69
|
+
sessionFactory: () => session!,
|
|
70
|
+
// The decision already made, passed through — not taken apart and rebuilt for the routes to
|
|
71
|
+
// resolve a second time.
|
|
72
|
+
admission,
|
|
73
|
+
onStreamFailure: options?.onStreamFailure,
|
|
74
|
+
});
|
|
75
|
+
// TRANS-002 (issue #2480): `basePath` was declared and advertised but never read, so routes
|
|
76
|
+
// always mounted at root. It is honored here — or absent, in which case the routes ARE the app.
|
|
77
|
+
app = options?.basePath ? new Hono().route(options.basePath, routes) : routes;
|
|
32
78
|
},
|
|
33
79
|
async stop() {
|
|
34
80
|
app = null;
|
|
81
|
+
session = null;
|
|
35
82
|
},
|
|
36
83
|
getApp() {
|
|
37
84
|
if (!app) throw new Error('Transport not started. Call start() first.');
|
|
38
85
|
return app;
|
|
39
86
|
},
|
|
87
|
+
/** The credential this transport requires, or `null` when the host explicitly opened it. */
|
|
88
|
+
getAdmissionToken() {
|
|
89
|
+
return admission.token;
|
|
90
|
+
},
|
|
40
91
|
};
|
|
41
92
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { createAgentRoutes } from './routes.js';
|
|
2
2
|
export type { IAgentRoutesOptions, TSessionFactory } from './routes.js';
|
|
3
|
+
export type { TStreamFailureListener } from './submit-stream.js';
|
|
3
4
|
export { createHttpTransport } from './http-transport.js';
|
|
4
|
-
export type { IHttpTransportOptions } from './http-transport.js';
|
|
5
|
+
export type { IHttpTransport, IHttpTransportOptions } from './http-transport.js';
|
|
6
|
+
export type { IHttpTransportSession } from './http-session.js';
|
package/src/routes.ts
CHANGED
|
@@ -1,22 +1,49 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* HTTP transport adapter — exposes
|
|
2
|
+
* HTTP transport adapter — exposes the declared session capabilities over REST API.
|
|
3
3
|
*
|
|
4
4
|
* Built on Hono for Cloudflare Workers + Node.js + AWS Lambda compatibility.
|
|
5
|
-
*
|
|
5
|
+
* Exposes the core session methods (a subset; background-task, job-group, and
|
|
6
|
+
* execution-workspace methods are WS-only).
|
|
6
7
|
*/
|
|
7
8
|
|
|
9
|
+
import {
|
|
10
|
+
bearerCredential,
|
|
11
|
+
credentialMatches,
|
|
12
|
+
resolveAdmission,
|
|
13
|
+
} from '@robota-sdk/agent-transport/node';
|
|
8
14
|
import { Hono } from 'hono';
|
|
9
|
-
import { streamSSE } from 'hono/streaming';
|
|
10
15
|
|
|
11
|
-
import
|
|
12
|
-
import
|
|
16
|
+
import { submitHandler } from './submit-route.js';
|
|
17
|
+
import { createTurnClaims } from './turn-claims.js';
|
|
13
18
|
|
|
14
|
-
|
|
15
|
-
|
|
19
|
+
import type { TSessionFactory } from './submit-route.js';
|
|
20
|
+
import type { TStreamFailureListener } from './submit-stream.js';
|
|
21
|
+
import type {
|
|
22
|
+
ITransportAdmission,
|
|
23
|
+
ITransportAdmissionConfig,
|
|
24
|
+
} from '@robota-sdk/agent-interface-transport';
|
|
25
|
+
|
|
26
|
+
// Re-exported so existing importers keep their path; the definition and its rationale live with
|
|
27
|
+
// the code that enforces them, in `submit-route.ts`.
|
|
28
|
+
export type { TSessionFactory } from './submit-route.js';
|
|
16
29
|
|
|
17
30
|
export interface IAgentRoutesOptions {
|
|
18
|
-
/** Resolve an
|
|
31
|
+
/** Resolve an HTTP session port per request (e.g., by auth token, session ID). */
|
|
19
32
|
sessionFactory: TSessionFactory;
|
|
33
|
+
/**
|
|
34
|
+
* Where the DETAIL of a post-headers stream failure goes (the client gets only a generic line —
|
|
35
|
+
* see `submit-stream.ts`). Injected per the side-concern rule: the host decides the destination,
|
|
36
|
+
* and absent means the host chose to drop it.
|
|
37
|
+
*/
|
|
38
|
+
onStreamFailure?: TStreamFailureListener;
|
|
39
|
+
/**
|
|
40
|
+
* SEC-008: what a peer must present to reach the session. REQUIRED — there is no shape of this
|
|
41
|
+
* option that means "I did not think about it", which is the state these routes shipped in.
|
|
42
|
+
*
|
|
43
|
+
* `{ open: true, openReason: '…' }` still runs with no credential, and that is a legitimate answer
|
|
44
|
+
* for a host that has its own boundary in front. It just has to be written down.
|
|
45
|
+
*/
|
|
46
|
+
admission: ITransportAdmissionConfig | ITransportAdmission;
|
|
20
47
|
}
|
|
21
48
|
|
|
22
49
|
/**
|
|
@@ -30,70 +57,49 @@ export interface IAgentRoutesOptions {
|
|
|
30
57
|
* ```
|
|
31
58
|
*/
|
|
32
59
|
export function createAgentRoutes(options: IAgentRoutesOptions): Hono {
|
|
33
|
-
const { sessionFactory } = options;
|
|
60
|
+
const { sessionFactory, onStreamFailure } = options;
|
|
34
61
|
const app = new Hono();
|
|
35
62
|
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
63
|
+
// RUNTIME-38: one turn at a time, per session — `turn-claims.ts` owns what that means and why it
|
|
64
|
+
// is keyed by the session's declared ID rather than by object identity.
|
|
65
|
+
const claims = createTurnClaims();
|
|
66
|
+
|
|
67
|
+
// SEC-008: resolved ONCE, at construction, so a transport that cannot mint a credential fails to
|
|
68
|
+
// build rather than serving without one. Resolving per request would also mint a new token per
|
|
69
|
+
// request, which no peer could ever present.
|
|
70
|
+
// Either shape goes in: `resolveAdmission` is idempotent, so an already-resolved admission comes
|
|
71
|
+
// back unchanged and a config is resolved. `http-transport.ts` therefore does not have to take
|
|
72
|
+
// its resolved admission apart and rebuild a config for this to resolve again — two resolutions
|
|
73
|
+
// of one decision, and a mint on the second if the first had opened without a reason (review).
|
|
74
|
+
//
|
|
75
|
+
// This used to pick between the two with `'token' in options.admission`, and review showed that
|
|
76
|
+
// cannot work: BOTH interfaces declare a `token`, so the shapes differ only by VALUE. A config of
|
|
77
|
+
// `{ token: '' }` — documented as "mint a fresh one" — was read as pre-resolved and installed the
|
|
78
|
+
// EMPTY STRING as the required credential, which a peer sending an empty bearer would match. The
|
|
79
|
+
// discriminator is gone rather than repaired; there is nothing here left to get wrong.
|
|
80
|
+
const admission = resolveAdmission(options.admission);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The trust boundary, installed BEFORE every route rather than checked inside each one.
|
|
84
|
+
*
|
|
85
|
+
* Before this, `POST /submit` reached `session.submit` and `POST /command` reached
|
|
86
|
+
* `session.executeCommand` with nothing in between — remote arbitrary execution with no gate, and
|
|
87
|
+
* an unauthenticated request looked exactly like an authorised one in both directions.
|
|
88
|
+
*
|
|
89
|
+
* A missing credential and a wrong one get the same answer, deliberately: telling them apart tells
|
|
90
|
+
* a caller which half they got right.
|
|
91
|
+
*/
|
|
92
|
+
app.use('*', async (c, next) => {
|
|
93
|
+
if (admission.token === null) return next();
|
|
94
|
+
if (credentialMatches(admission.token, bearerCredential(c.req.header('authorization')))) {
|
|
95
|
+
return next();
|
|
43
96
|
}
|
|
44
|
-
|
|
45
|
-
return streamSSE(c, async (stream) => {
|
|
46
|
-
const cleanup: Array<() => void> = [];
|
|
47
|
-
|
|
48
|
-
const subscribe = <T>(event: string, handler: (data: T) => void): void => {
|
|
49
|
-
session.on(event as 'text_delta', handler as () => void);
|
|
50
|
-
cleanup.push(() => session.off(event as 'text_delta', handler as () => void));
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
let completed = false;
|
|
54
|
-
const done = new Promise<void>((resolve) => {
|
|
55
|
-
subscribe('text_delta', (delta: string) => {
|
|
56
|
-
stream.writeSSE({ event: 'text_delta', data: JSON.stringify({ delta }) });
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
subscribe('tool_start', (state) => {
|
|
60
|
-
stream.writeSSE({ event: 'tool_start', data: JSON.stringify(state) });
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
subscribe('tool_end', (state) => {
|
|
64
|
-
stream.writeSSE({ event: 'tool_end', data: JSON.stringify(state) });
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
subscribe('thinking', (isThinking: boolean) => {
|
|
68
|
-
stream.writeSSE({ event: 'thinking', data: JSON.stringify({ isThinking }) });
|
|
69
|
-
if (!isThinking && completed) {
|
|
70
|
-
resolve();
|
|
71
|
-
}
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
subscribe('complete', (result) => {
|
|
75
|
-
completed = true;
|
|
76
|
-
stream.writeSSE({ event: 'complete', data: JSON.stringify(result) });
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
subscribe('interrupted', (result) => {
|
|
80
|
-
completed = true;
|
|
81
|
-
stream.writeSSE({ event: 'interrupted', data: JSON.stringify(result) });
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
subscribe('error', (error: Error) => {
|
|
85
|
-
completed = true;
|
|
86
|
-
stream.writeSSE({ event: 'error', data: JSON.stringify({ message: error.message }) });
|
|
87
|
-
});
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
await session.submit(body.prompt);
|
|
91
|
-
await done;
|
|
92
|
-
|
|
93
|
-
for (const fn of cleanup) fn();
|
|
94
|
-
});
|
|
97
|
+
return c.json({ error: 'unauthorized' }, 401);
|
|
95
98
|
});
|
|
96
99
|
|
|
100
|
+
// POST /submit — execute prompt, stream events via SSE
|
|
101
|
+
app.post('/submit', submitHandler(sessionFactory, claims, onStreamFailure));
|
|
102
|
+
|
|
97
103
|
// POST /command — execute system command
|
|
98
104
|
app.post('/command', async (c) => {
|
|
99
105
|
const session = await sessionFactory(c);
|
|
@@ -103,7 +109,11 @@ export function createAgentRoutes(options: IAgentRoutesOptions): Hono {
|
|
|
103
109
|
return c.json({ error: 'name is required' }, 400);
|
|
104
110
|
}
|
|
105
111
|
|
|
106
|
-
|
|
112
|
+
// SEC-008: 'remote', not the default 'user'. A peer over HTTP is not the person at the keyboard,
|
|
113
|
+
// and defaulting to the local operator both mis-attributes the call and skips the 'remote' policy
|
|
114
|
+
// seam that exists to treat the two differently. Admission decided WHO may reach the session; it
|
|
115
|
+
// does not say who they are. (The MCP adapter had the same defect; this is its sibling.)
|
|
116
|
+
const result = await session.executeCommand(body.name, body.args ?? '', 'remote');
|
|
107
117
|
if (!result) {
|
|
108
118
|
return c.json({ error: `Unknown command: ${body.name}` }, 404);
|
|
109
119
|
}
|
|
@@ -137,9 +147,16 @@ export function createAgentRoutes(options: IAgentRoutesOptions): Hono {
|
|
|
137
147
|
});
|
|
138
148
|
|
|
139
149
|
// GET /executing — check if currently executing
|
|
150
|
+
//
|
|
151
|
+
// Reports the SAME "busy" that `/submit` refuses on: the route's own claim OR the session's
|
|
152
|
+
// `isExecuting()`. Asking only the session lets a client polling here see `executing: false` and
|
|
153
|
+
// still get a 409 from `/submit`, and two endpoints disagreeing about one word is a worse answer
|
|
154
|
+
// than either of them alone.
|
|
140
155
|
app.get('/executing', async (c) => {
|
|
141
156
|
const session = await sessionFactory(c);
|
|
142
|
-
|
|
157
|
+
const claim = claims.keyFor(session);
|
|
158
|
+
const claimed = claim !== undefined && claims.isHeld(claim);
|
|
159
|
+
return c.json({ executing: claimed || session.isExecuting() });
|
|
143
160
|
});
|
|
144
161
|
|
|
145
162
|
// GET /pending — get pending queued prompt
|