@moxxy/plugin-channel-http 0.26.0
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/LICENSE +21 -0
- package/dist/channel.d.ts +50 -0
- package/dist/channel.d.ts.map +1 -0
- package/dist/channel.js +164 -0
- package/dist/channel.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +44 -0
- package/dist/index.js.map +1 -0
- package/dist/router.d.ts +95 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +354 -0
- package/dist/router.js.map +1 -0
- package/package.json +67 -0
- package/src/attach.test.ts +85 -0
- package/src/channel.test.ts +165 -0
- package/src/channel.ts +211 -0
- package/src/index.ts +58 -0
- package/src/integration.test.ts +254 -0
- package/src/router.test.ts +665 -0
- package/src/router.ts +424 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { Server } from 'node:http';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import {
|
|
5
|
+
Session,
|
|
6
|
+
autoAllowResolver,
|
|
7
|
+
silentLogger,
|
|
8
|
+
} from '@moxxy/core';
|
|
9
|
+
import { defineProvider, definePlugin, defineTool } from '@moxxy/sdk';
|
|
10
|
+
import type { LLMProvider, ProviderEvent } from '@moxxy/sdk';
|
|
11
|
+
import { HttpChannel } from './channel.js';
|
|
12
|
+
|
|
13
|
+
class FakeProvider implements LLMProvider {
|
|
14
|
+
readonly name = 'fake';
|
|
15
|
+
readonly models = [{ id: 'fake', contextWindow: 1000, maxOutputTokens: 100, supportsTools: false, supportsStreaming: true }];
|
|
16
|
+
constructor(private readonly scripts: ReadonlyArray<ReadonlyArray<ProviderEvent>>) {}
|
|
17
|
+
private cursor = 0;
|
|
18
|
+
async *stream(): AsyncIterable<ProviderEvent> {
|
|
19
|
+
const reply = this.scripts[this.cursor++];
|
|
20
|
+
if (!reply) throw new Error('out of fake replies');
|
|
21
|
+
for (const e of reply) yield e;
|
|
22
|
+
}
|
|
23
|
+
async countTokens(): Promise<number> { return 0; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function buildSession(): Session {
|
|
27
|
+
const provider = new FakeProvider([
|
|
28
|
+
[
|
|
29
|
+
{ type: 'message_start', model: 'fake' },
|
|
30
|
+
{ type: 'text_delta', delta: 'pong' },
|
|
31
|
+
{ type: 'message_end', stopReason: 'end_turn' },
|
|
32
|
+
],
|
|
33
|
+
]);
|
|
34
|
+
const session = new Session({
|
|
35
|
+
cwd: process.cwd(),
|
|
36
|
+
logger: silentLogger,
|
|
37
|
+
permissionResolver: autoAllowResolver,
|
|
38
|
+
});
|
|
39
|
+
session.pluginHost.registerStatic(
|
|
40
|
+
definePlugin({
|
|
41
|
+
name: 'http-test-shim',
|
|
42
|
+
providers: [
|
|
43
|
+
defineProvider({
|
|
44
|
+
name: provider.name,
|
|
45
|
+
models: [...provider.models],
|
|
46
|
+
createClient: () => provider,
|
|
47
|
+
}),
|
|
48
|
+
],
|
|
49
|
+
tools: [
|
|
50
|
+
defineTool({
|
|
51
|
+
name: 'noop',
|
|
52
|
+
description: 'no-op',
|
|
53
|
+
inputSchema: z.object({}),
|
|
54
|
+
handler: () => null,
|
|
55
|
+
}),
|
|
56
|
+
],
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
session.providers.setActive(provider.name);
|
|
60
|
+
// Register a minimal loop strategy so runTurn works.
|
|
61
|
+
// Easier: register the default loop-tool-use plugin if it's available.
|
|
62
|
+
return session;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('HttpChannel', () => {
|
|
66
|
+
it('starts and stops cleanly on an ephemeral port', async () => {
|
|
67
|
+
const channel = new HttpChannel({ port: 0, authToken: 'test', allowedTools: ['noop'] });
|
|
68
|
+
expect(channel.name).toBe('http');
|
|
69
|
+
expect(channel.permissionResolver.name).toBe('allow-list');
|
|
70
|
+
// Don't actually start (would require a loop strategy registered).
|
|
71
|
+
// Just verify the channel object shape.
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('defaults to deny-by-default resolver when no allow-list given', () => {
|
|
75
|
+
const channel = new HttpChannel({ authToken: 'test' });
|
|
76
|
+
expect(channel.permissionResolver.name).toBe('deny-by-default');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Note: full request/response is exercised through the router-level tests.
|
|
80
|
+
// Verifying the server boots and binds is enough at this level.
|
|
81
|
+
void buildSession;
|
|
82
|
+
|
|
83
|
+
it('rejects the running promise + logs when the server errors after listen (u70-2)', async () => {
|
|
84
|
+
const warn = vi.fn();
|
|
85
|
+
const channel = new HttpChannel({
|
|
86
|
+
port: 0,
|
|
87
|
+
authToken: 'test',
|
|
88
|
+
allowedTools: ['noop'],
|
|
89
|
+
logger: { info: () => {}, warn },
|
|
90
|
+
});
|
|
91
|
+
const session = new Session({
|
|
92
|
+
cwd: process.cwd(),
|
|
93
|
+
logger: silentLogger,
|
|
94
|
+
permissionResolver: autoAllowResolver,
|
|
95
|
+
});
|
|
96
|
+
const handle = await channel.start({ session } as never);
|
|
97
|
+
expect(channel.boundPort).toBeGreaterThan(0);
|
|
98
|
+
|
|
99
|
+
// Reach the bound server (private) to emit a synthetic runtime error — the
|
|
100
|
+
// same surface a real post-listen socket failure would hit.
|
|
101
|
+
const server = (channel as unknown as { server: Server }).server;
|
|
102
|
+
const boom = new Error('socket exploded');
|
|
103
|
+
queueMicrotask(() => server.emit('error', boom));
|
|
104
|
+
|
|
105
|
+
await expect(handle.running).rejects.toThrow('socket exploded');
|
|
106
|
+
expect(warn).toHaveBeenCalledWith('http server error', expect.objectContaining({}));
|
|
107
|
+
await handle.stop();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('refuses to bind a non-loopback host with no authToken', async () => {
|
|
111
|
+
const channel = new HttpChannel({ port: 0, host: '0.0.0.0', allowedTools: ['noop'] });
|
|
112
|
+
const session = new Session({ cwd: process.cwd(), logger: silentLogger, permissionResolver: autoAllowResolver });
|
|
113
|
+
await expect(channel.start({ session } as never)).rejects.toThrow(/non-loopback/);
|
|
114
|
+
expect(channel.boundPort).toBe(0);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('allows a non-loopback bind once an authToken is set', async () => {
|
|
118
|
+
const channel = new HttpChannel({ port: 0, host: '0.0.0.0', authToken: 'tok', allowedTools: ['noop'], logger: { info: () => {}, warn: () => {} } });
|
|
119
|
+
const session = new Session({ cwd: process.cwd(), logger: silentLogger, permissionResolver: autoAllowResolver });
|
|
120
|
+
const handle = await channel.start({ session } as never);
|
|
121
|
+
expect(channel.boundPort).toBeGreaterThan(0);
|
|
122
|
+
await handle.stop();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('a post-listen server error does not escalate to an unhandledRejection when running is unobserved', async () => {
|
|
126
|
+
const channel = new HttpChannel({ port: 0, authToken: 'test', allowedTools: ['noop'], logger: { info: () => {}, warn: () => {} } });
|
|
127
|
+
const session = new Session({ cwd: process.cwd(), logger: silentLogger, permissionResolver: autoAllowResolver });
|
|
128
|
+
const handle = await channel.start({ session } as never);
|
|
129
|
+
|
|
130
|
+
let unhandled: unknown;
|
|
131
|
+
const onUnhandled = (reason: unknown): void => { unhandled = reason; };
|
|
132
|
+
process.on('unhandledRejection', onUnhandled);
|
|
133
|
+
try {
|
|
134
|
+
const server = (channel as unknown as { server: Server }).server;
|
|
135
|
+
// Intentionally do NOT await handle.running (fire-and-forget caller).
|
|
136
|
+
server.emit('error', new Error('boom'));
|
|
137
|
+
// Let the microtask/macrotask queue flush so any unhandled rejection fires.
|
|
138
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
139
|
+
expect(unhandled).toBeUndefined();
|
|
140
|
+
} finally {
|
|
141
|
+
process.off('unhandledRejection', onUnhandled);
|
|
142
|
+
await handle.stop();
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('rejects a double start, and allows a fresh start after stop (u70-5)', async () => {
|
|
147
|
+
const channel = new HttpChannel({ port: 0, authToken: 'test', allowedTools: ['noop'], logger: { info: () => {}, warn: () => {} } });
|
|
148
|
+
const session = new Session({ cwd: process.cwd(), logger: silentLogger, permissionResolver: autoAllowResolver });
|
|
149
|
+
|
|
150
|
+
const handle = await channel.start({ session } as never);
|
|
151
|
+
expect(channel.boundPort).toBeGreaterThan(0);
|
|
152
|
+
|
|
153
|
+
// A second start while running must not silently orphan the first server.
|
|
154
|
+
await expect(channel.start({ session } as never)).rejects.toThrow(/already started/);
|
|
155
|
+
|
|
156
|
+
// After stop(), the handle is cleared and the port no longer considered held.
|
|
157
|
+
await handle.stop();
|
|
158
|
+
expect(channel.boundPort).toBe(0);
|
|
159
|
+
|
|
160
|
+
// A fresh start now succeeds (no leaked handle blocking it).
|
|
161
|
+
const handle2 = await channel.start({ session } as never);
|
|
162
|
+
expect(channel.boundPort).toBeGreaterThan(0);
|
|
163
|
+
await handle2.stop();
|
|
164
|
+
});
|
|
165
|
+
});
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { createServer, type Server } from 'node:http';
|
|
2
|
+
import { createAllowListResolver, denyByDefaultResolver } from '@moxxy/sdk';
|
|
3
|
+
import type { ClientSession as Session } from '@moxxy/sdk';
|
|
4
|
+
import type {
|
|
5
|
+
Channel,
|
|
6
|
+
ChannelHandle,
|
|
7
|
+
ChannelStartOptsBase,
|
|
8
|
+
PermissionResolver,
|
|
9
|
+
} from '@moxxy/sdk';
|
|
10
|
+
import { routeRequest, TurnLimiter, type RouterContext } from './router.js';
|
|
11
|
+
|
|
12
|
+
/** Hosts auth-disabled mode is only safe to bind on. A non-loopback bind with
|
|
13
|
+
* no token would expose an unauthenticated agent endpoint to the network. */
|
|
14
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost', '::ffff:127.0.0.1']);
|
|
15
|
+
|
|
16
|
+
export interface HttpChannelOptions {
|
|
17
|
+
readonly port?: number;
|
|
18
|
+
readonly host?: string;
|
|
19
|
+
/** Bearer token required on every protected route. If unset, auth is disabled (dev-only). */
|
|
20
|
+
readonly authToken?: string;
|
|
21
|
+
/** Max turns running concurrently on the shared session; excess requests get
|
|
22
|
+
* a 429. Bounds provider-stream fan-out and history interleaving. */
|
|
23
|
+
readonly maxConcurrentTurns?: number;
|
|
24
|
+
/** Abort an SSE turn whose consumer has stalled (socket open but not reading)
|
|
25
|
+
* for this many ms, so a half-open client can't keep a provider stream alive
|
|
26
|
+
* forever. A live-but-slow consumer resets the timer on every flushed write.
|
|
27
|
+
* Defaults to 120_000; set <= 0 to disable (not recommended for a network
|
|
28
|
+
* bind). */
|
|
29
|
+
readonly streamStallMs?: number;
|
|
30
|
+
/**
|
|
31
|
+
* Tool names that the model is allowed to call without further interaction.
|
|
32
|
+
* This is the entire permission story for HTTP — there's no human in the
|
|
33
|
+
* loop to click "allow", so the operator declares trust upfront. Anything
|
|
34
|
+
* not in this list is denied.
|
|
35
|
+
*/
|
|
36
|
+
readonly allowedTools?: ReadonlyArray<string>;
|
|
37
|
+
readonly logger?: {
|
|
38
|
+
info?(msg: string, meta?: Record<string, unknown>): void;
|
|
39
|
+
warn?(msg: string, meta?: Record<string, unknown>): void;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface HttpStartOpts extends ChannelStartOptsBase {
|
|
44
|
+
readonly session: Session;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class HttpChannel implements Channel<HttpStartOpts> {
|
|
48
|
+
readonly name = 'http';
|
|
49
|
+
readonly permissionResolver: PermissionResolver;
|
|
50
|
+
private readonly port: number;
|
|
51
|
+
private readonly host: string;
|
|
52
|
+
private readonly authToken: string | null;
|
|
53
|
+
private readonly maxConcurrentTurns: number;
|
|
54
|
+
private readonly streamStallMs: number | undefined;
|
|
55
|
+
private readonly logger: HttpChannelOptions['logger'];
|
|
56
|
+
private server: Server | null = null;
|
|
57
|
+
private boundPortValue = 0;
|
|
58
|
+
|
|
59
|
+
/** The actual TCP port the server bound to after {@link start}. Equals the
|
|
60
|
+
* configured port, or the OS-assigned one when `port: 0` (ephemeral) — so
|
|
61
|
+
* callers (and tests) can address the server without guessing a free port. */
|
|
62
|
+
get boundPort(): number {
|
|
63
|
+
return this.boundPortValue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
constructor(opts: HttpChannelOptions = {}) {
|
|
67
|
+
this.port = opts.port ?? 3737;
|
|
68
|
+
this.host = opts.host ?? '127.0.0.1';
|
|
69
|
+
this.authToken = opts.authToken ?? null;
|
|
70
|
+
this.maxConcurrentTurns =
|
|
71
|
+
typeof opts.maxConcurrentTurns === 'number' && opts.maxConcurrentTurns > 0
|
|
72
|
+
? Math.floor(opts.maxConcurrentTurns)
|
|
73
|
+
: 4;
|
|
74
|
+
// undefined => router uses its DEFAULT_STREAM_STALL_MS; a finite number
|
|
75
|
+
// (including <= 0 to disable) is honored as-is.
|
|
76
|
+
this.streamStallMs =
|
|
77
|
+
typeof opts.streamStallMs === 'number' && Number.isFinite(opts.streamStallMs)
|
|
78
|
+
? opts.streamStallMs
|
|
79
|
+
: undefined;
|
|
80
|
+
this.logger = opts.logger;
|
|
81
|
+
this.permissionResolver = opts.allowedTools && opts.allowedTools.length > 0
|
|
82
|
+
? createAllowListResolver([...opts.allowedTools])
|
|
83
|
+
: denyByDefaultResolver;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async start(startOpts: HttpStartOpts): Promise<ChannelHandle> {
|
|
87
|
+
// Guard against a double-start: unconditionally reassigning `this.server`
|
|
88
|
+
// would orphan the first server (its port stays bound, sockets stay open)
|
|
89
|
+
// since `stop()` only ever closes the most-recently-assigned one.
|
|
90
|
+
if (this.server) {
|
|
91
|
+
throw new Error('HttpChannel is already started — call stop() before starting again.');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Refuse to expose an unauthenticated agent endpoint to the network. With
|
|
95
|
+
// no token the only gate is the deny-by-default tool resolver; a
|
|
96
|
+
// non-loopback bind would still let anyone reach /v1/turn.
|
|
97
|
+
if (this.authToken === null && !LOOPBACK_HOSTS.has(this.host)) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`HttpChannel refuses to bind non-loopback host "${this.host}" without an authToken. ` +
|
|
100
|
+
'Set authToken (or MOXXY_HTTP_TOKEN), or bind 127.0.0.1.',
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const ctx: RouterContext = {
|
|
105
|
+
session: startOpts.session,
|
|
106
|
+
authToken: this.authToken,
|
|
107
|
+
logger: this.logger as RouterContext['logger'],
|
|
108
|
+
turnLimiter: new TurnLimiter(this.maxConcurrentTurns),
|
|
109
|
+
...(this.streamStallMs !== undefined ? { streamStallMs: this.streamStallMs } : {}),
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const server = createServer(async (req, res) => {
|
|
113
|
+
const handler = routeRequest(req);
|
|
114
|
+
if (!handler) {
|
|
115
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
116
|
+
res.end(JSON.stringify({ error: 'not_found', path: req.url }));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
await handler(req, res, ctx);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
// Log the full error server-side; return a generic message so internal
|
|
123
|
+
// paths/provider details can't leak to a (possibly remote) caller.
|
|
124
|
+
this.logger?.warn?.('http handler threw', { err: String(err) });
|
|
125
|
+
if (!res.headersSent) {
|
|
126
|
+
res.writeHead(500, { 'content-type': 'application/json' });
|
|
127
|
+
res.end(JSON.stringify({ error: 'internal' }));
|
|
128
|
+
} else {
|
|
129
|
+
try { res.end(); } catch { /* ignore */ }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Tighten timeouts so a slow-loris client can't tie up sockets by dribbling
|
|
135
|
+
// headers/bodies. Defaults (requestTimeout 300s) are too generous for an
|
|
136
|
+
// agent endpoint. Cap total connections so a flood can't exhaust handles.
|
|
137
|
+
server.headersTimeout = 10_000;
|
|
138
|
+
server.requestTimeout = 30_000;
|
|
139
|
+
server.keepAliveTimeout = 5_000;
|
|
140
|
+
server.maxConnections = 256;
|
|
141
|
+
|
|
142
|
+
this.server = server;
|
|
143
|
+
|
|
144
|
+
// `running` rejects on a post-listen server error and resolves on a clean
|
|
145
|
+
// close. Declared up front so the persistent error handler installed below
|
|
146
|
+
// (after the listen-scoped one is detached) can settle it.
|
|
147
|
+
let rejectRunning!: (err: unknown) => void;
|
|
148
|
+
let resolveRunning!: () => void;
|
|
149
|
+
const running = new Promise<void>((resolve, reject) => {
|
|
150
|
+
resolveRunning = resolve;
|
|
151
|
+
rejectRunning = reject;
|
|
152
|
+
});
|
|
153
|
+
// A fire-and-forget caller may never attach a .catch; without this default
|
|
154
|
+
// observer a post-listen server error escalates to a process-level
|
|
155
|
+
// unhandledRejection (fatal under --unhandled-rejections=strict). Real
|
|
156
|
+
// awaiters still see the rejection — a settled promise fans out to all.
|
|
157
|
+
running.catch(() => {});
|
|
158
|
+
|
|
159
|
+
const listening = new Promise<void>((resolve, reject) => {
|
|
160
|
+
// Scoped to the listen handshake only — detached once we're listening so
|
|
161
|
+
// it can't no-op on the already-settled promise (and swallow runtime
|
|
162
|
+
// errors). The persistent handler below takes over afterwards.
|
|
163
|
+
const onListenError = (err: unknown): void => reject(err);
|
|
164
|
+
server.once('error', onListenError);
|
|
165
|
+
server.listen(this.port, this.host, () => {
|
|
166
|
+
server.off('error', onListenError);
|
|
167
|
+
// A runtime server error after listen would otherwise be unhandled
|
|
168
|
+
// (Node re-throws an 'error' with no listener). Log it and fail the
|
|
169
|
+
// `running` promise so callers awaiting it observe the crash instead of
|
|
170
|
+
// mistaking it for a clean shutdown.
|
|
171
|
+
server.on('error', (err: unknown) => {
|
|
172
|
+
this.logger?.warn?.('http server error', { err: String(err) });
|
|
173
|
+
rejectRunning(err);
|
|
174
|
+
});
|
|
175
|
+
const addr = server.address();
|
|
176
|
+
this.boundPortValue = typeof addr === 'object' && addr ? addr.port : this.port;
|
|
177
|
+
this.logger?.info?.('http channel listening', {
|
|
178
|
+
host: this.host,
|
|
179
|
+
port: this.boundPortValue,
|
|
180
|
+
authEnabled: this.authToken !== null,
|
|
181
|
+
});
|
|
182
|
+
resolve();
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
await listening;
|
|
187
|
+
|
|
188
|
+
server.once('close', () => resolveRunning());
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
running,
|
|
192
|
+
stop: async () => {
|
|
193
|
+
const srv = this.server;
|
|
194
|
+
// Clear the handle first so a concurrent/double stop() is a no-op rather
|
|
195
|
+
// than racing two close() calls on the same server (the second close
|
|
196
|
+
// gets an 'ERR_SERVER_NOT_RUNNING' error in its callback).
|
|
197
|
+
if (this.server === srv) {
|
|
198
|
+
this.server = null;
|
|
199
|
+
this.boundPortValue = 0;
|
|
200
|
+
}
|
|
201
|
+
await new Promise<void>((resolve) => {
|
|
202
|
+
if (!srv) return resolve();
|
|
203
|
+
srv.close((err) => {
|
|
204
|
+
if (err) this.logger?.warn?.('http server close error', { err: String(err) });
|
|
205
|
+
resolve();
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { defineChannel, definePlugin, type Plugin } from '@moxxy/sdk';
|
|
2
|
+
import { HttpChannel } from './channel.js';
|
|
3
|
+
|
|
4
|
+
export { HttpChannel, type HttpChannelOptions, type HttpStartOpts } from './channel.js';
|
|
5
|
+
export {
|
|
6
|
+
routeRequest,
|
|
7
|
+
handleHealth,
|
|
8
|
+
handleTurn,
|
|
9
|
+
handleTurnStream,
|
|
10
|
+
handleTurnAudio,
|
|
11
|
+
turnRequestSchema,
|
|
12
|
+
type TurnRequest,
|
|
13
|
+
type RouterContext,
|
|
14
|
+
type RouteHandler,
|
|
15
|
+
} from './router.js';
|
|
16
|
+
|
|
17
|
+
export const httpChannelDef = defineChannel({
|
|
18
|
+
name: 'http',
|
|
19
|
+
description: 'Request-response HTTP channel. POST /v1/turn + SSE streaming. Bearer-token auth + allow-list perms.',
|
|
20
|
+
create: (deps) => {
|
|
21
|
+
const opts = deps.options ?? {};
|
|
22
|
+
return new HttpChannel({
|
|
23
|
+
port: typeof opts.port === 'number' ? opts.port : undefined,
|
|
24
|
+
host: typeof opts.host === 'string' ? opts.host : undefined,
|
|
25
|
+
authToken: typeof opts.authToken === 'string' ? opts.authToken : process.env.MOXXY_HTTP_TOKEN,
|
|
26
|
+
allowedTools: Array.isArray(opts.allowedTools) ? (opts.allowedTools as string[]) : undefined,
|
|
27
|
+
maxConcurrentTurns: typeof opts.maxConcurrentTurns === 'number' ? opts.maxConcurrentTurns : undefined,
|
|
28
|
+
streamStallMs: typeof opts.streamStallMs === 'number' ? opts.streamStallMs : undefined,
|
|
29
|
+
logger: deps.logger as never,
|
|
30
|
+
});
|
|
31
|
+
},
|
|
32
|
+
isAvailable: async (deps) => {
|
|
33
|
+
const tools = deps.options?.['allowedTools'];
|
|
34
|
+
const authToken = deps.options?.['authToken'] ?? process.env.MOXXY_HTTP_TOKEN;
|
|
35
|
+
if (!authToken) {
|
|
36
|
+
return {
|
|
37
|
+
ok: false,
|
|
38
|
+
reason: "No auth token. Set MOXXY_HTTP_TOKEN or pass channels.http.authToken in moxxy.config.ts.",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (!Array.isArray(tools) || tools.length === 0) {
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
reason:
|
|
45
|
+
'No allowed-tools list. The HTTP channel needs an upfront tool allow-list since there is no human in the loop. Set channels.http.allowedTools in moxxy.config.ts.',
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return { ok: true };
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export const httpChannelPlugin: Plugin = definePlugin({
|
|
53
|
+
name: '@moxxy/plugin-channel-http',
|
|
54
|
+
version: '0.0.0',
|
|
55
|
+
channels: [httpChannelDef],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export default httpChannelPlugin;
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end integration test. Boots a real HttpChannel on an ephemeral port
|
|
3
|
+
* against a Session wired with FakeProvider + tool-use loop, then drives
|
|
4
|
+
* actual HTTP requests via fetch.
|
|
5
|
+
*/
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
7
|
+
import {
|
|
8
|
+
Session,
|
|
9
|
+
autoAllowResolver,
|
|
10
|
+
silentLogger,
|
|
11
|
+
} from '@moxxy/core';
|
|
12
|
+
import { defineProvider, definePlugin, defineTranscriber } from '@moxxy/sdk';
|
|
13
|
+
import { FakeProvider, textReply } from '@moxxy/testing';
|
|
14
|
+
import { defaultModePlugin } from '@moxxy/mode-default';
|
|
15
|
+
import { builtinToolsPlugin } from '@moxxy/tools-builtin';
|
|
16
|
+
import { HttpChannel } from './channel.js';
|
|
17
|
+
import type { ChannelHandle } from '@moxxy/sdk';
|
|
18
|
+
|
|
19
|
+
const TOKEN = 'test-token-123';
|
|
20
|
+
|
|
21
|
+
function buildSession(opts: { withTranscriber?: string } = {}): Session {
|
|
22
|
+
const provider = new FakeProvider({
|
|
23
|
+
script: [textReply('hello from the HTTP channel')],
|
|
24
|
+
});
|
|
25
|
+
const session = new Session({
|
|
26
|
+
cwd: process.cwd(),
|
|
27
|
+
logger: silentLogger,
|
|
28
|
+
permissionResolver: autoAllowResolver,
|
|
29
|
+
});
|
|
30
|
+
const plugins = [
|
|
31
|
+
definePlugin({
|
|
32
|
+
name: 'http-integration-shim',
|
|
33
|
+
providers: [
|
|
34
|
+
defineProvider({
|
|
35
|
+
name: provider.name,
|
|
36
|
+
models: [...provider.models],
|
|
37
|
+
createClient: () => provider,
|
|
38
|
+
}),
|
|
39
|
+
],
|
|
40
|
+
...(opts.withTranscriber !== undefined
|
|
41
|
+
? {
|
|
42
|
+
transcribers: [
|
|
43
|
+
defineTranscriber({
|
|
44
|
+
name: 'fake-stt',
|
|
45
|
+
createClient: () => ({
|
|
46
|
+
name: 'fake-stt',
|
|
47
|
+
transcribe: async () => ({ text: opts.withTranscriber! }),
|
|
48
|
+
}),
|
|
49
|
+
}),
|
|
50
|
+
],
|
|
51
|
+
}
|
|
52
|
+
: {}),
|
|
53
|
+
}),
|
|
54
|
+
];
|
|
55
|
+
for (const p of plugins) session.pluginHost.registerStatic(p);
|
|
56
|
+
session.providers.setActive(provider.name);
|
|
57
|
+
if (opts.withTranscriber !== undefined) session.transcribers.setActive('fake-stt');
|
|
58
|
+
session.pluginHost.registerStatic(builtinToolsPlugin);
|
|
59
|
+
session.pluginHost.registerStatic(defaultModePlugin);
|
|
60
|
+
return session;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function startChannel(channel: HttpChannel): Promise<{ baseUrl: string; handle: ChannelHandle }> {
|
|
64
|
+
// The channel was created with port: 0 — the OS assigns a free ephemeral port,
|
|
65
|
+
// read back from `boundPort`. No random-port guessing, so no EADDRINUSE flake
|
|
66
|
+
// when test files run in parallel.
|
|
67
|
+
const handle = await channel.start({ session: buildSession() });
|
|
68
|
+
return { baseUrl: `http://127.0.0.1:${channel.boundPort}`, handle };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let channel: HttpChannel;
|
|
72
|
+
let handle: ChannelHandle;
|
|
73
|
+
let baseUrl: string;
|
|
74
|
+
|
|
75
|
+
beforeEach(async () => {
|
|
76
|
+
channel = new HttpChannel({
|
|
77
|
+
port: 0,
|
|
78
|
+
authToken: TOKEN,
|
|
79
|
+
allowedTools: ['Read', 'Glob'],
|
|
80
|
+
});
|
|
81
|
+
const started = await startChannel(channel);
|
|
82
|
+
baseUrl = started.baseUrl;
|
|
83
|
+
handle = started.handle;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
afterEach(async () => {
|
|
87
|
+
await handle.stop();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('HttpChannel integration', () => {
|
|
91
|
+
it('GET /v1/health returns 200 ok without auth', async () => {
|
|
92
|
+
const res = await fetch(`${baseUrl}/v1/health`);
|
|
93
|
+
expect(res.status).toBe(200);
|
|
94
|
+
expect(await res.json()).toEqual({ status: 'ok' });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('POST /v1/turn rejects missing Bearer token with 401', async () => {
|
|
98
|
+
const res = await fetch(`${baseUrl}/v1/turn`, {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
headers: { 'content-type': 'application/json' },
|
|
101
|
+
body: JSON.stringify({ prompt: 'hi' }),
|
|
102
|
+
});
|
|
103
|
+
expect(res.status).toBe(401);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('POST /v1/turn rejects wrong Bearer token with 401', async () => {
|
|
107
|
+
const res = await fetch(`${baseUrl}/v1/turn`, {
|
|
108
|
+
method: 'POST',
|
|
109
|
+
headers: {
|
|
110
|
+
'content-type': 'application/json',
|
|
111
|
+
authorization: 'Bearer wrong-token',
|
|
112
|
+
},
|
|
113
|
+
body: JSON.stringify({ prompt: 'hi' }),
|
|
114
|
+
});
|
|
115
|
+
expect(res.status).toBe(401);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('POST /v1/turn rejects malformed body with 400', async () => {
|
|
119
|
+
const res = await fetch(`${baseUrl}/v1/turn`, {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
headers: {
|
|
122
|
+
'content-type': 'application/json',
|
|
123
|
+
authorization: `Bearer ${TOKEN}`,
|
|
124
|
+
},
|
|
125
|
+
body: '{"not-prompt": 1}',
|
|
126
|
+
});
|
|
127
|
+
expect(res.status).toBe(400);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('POST /v1/turn drives a full turn and returns events + assistant text', async () => {
|
|
131
|
+
const res = await fetch(`${baseUrl}/v1/turn`, {
|
|
132
|
+
method: 'POST',
|
|
133
|
+
headers: {
|
|
134
|
+
'content-type': 'application/json',
|
|
135
|
+
authorization: `Bearer ${TOKEN}`,
|
|
136
|
+
},
|
|
137
|
+
body: JSON.stringify({ prompt: 'say hi' }),
|
|
138
|
+
});
|
|
139
|
+
expect(res.status).toBe(200);
|
|
140
|
+
const body = (await res.json()) as { events: Array<{ type: string }>; assistant: string };
|
|
141
|
+
expect(body.assistant).toContain('hello from the HTTP channel');
|
|
142
|
+
expect(body.events.some((e) => e.type === 'user_prompt')).toBe(true);
|
|
143
|
+
expect(body.events.some((e) => e.type === 'assistant_message')).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('POST /v1/turn/stream returns Server-Sent Events with [DONE] terminator', async () => {
|
|
147
|
+
const res = await fetch(`${baseUrl}/v1/turn/stream`, {
|
|
148
|
+
method: 'POST',
|
|
149
|
+
headers: {
|
|
150
|
+
'content-type': 'application/json',
|
|
151
|
+
authorization: `Bearer ${TOKEN}`,
|
|
152
|
+
},
|
|
153
|
+
body: JSON.stringify({ prompt: 'say hi' }),
|
|
154
|
+
});
|
|
155
|
+
expect(res.status).toBe(200);
|
|
156
|
+
expect(res.headers.get('content-type')).toContain('text/event-stream');
|
|
157
|
+
const text = await res.text();
|
|
158
|
+
expect(text).toContain('data: ');
|
|
159
|
+
expect(text).toMatch(/data: \[DONE\]\n\n$/);
|
|
160
|
+
// Each event is a JSON object — find the user_prompt one
|
|
161
|
+
const lines = text.split('\n\n').filter((l) => l.startsWith('data: '));
|
|
162
|
+
const parsed = lines
|
|
163
|
+
.map((l) => l.slice(6))
|
|
164
|
+
.filter((s) => s !== '[DONE]')
|
|
165
|
+
.map((s) => JSON.parse(s) as { type: string });
|
|
166
|
+
expect(parsed.some((e) => e.type === 'user_prompt')).toBe(true);
|
|
167
|
+
expect(parsed.some((e) => e.type === 'assistant_chunk')).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('GET on an unknown path returns 404', async () => {
|
|
171
|
+
const res = await fetch(`${baseUrl}/nonsense`);
|
|
172
|
+
expect(res.status).toBe(404);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('HttpChannel /v1/turn/audio integration', () => {
|
|
177
|
+
let audioChannel: HttpChannel;
|
|
178
|
+
let audioHandle: ChannelHandle;
|
|
179
|
+
let audioBaseUrl: string;
|
|
180
|
+
|
|
181
|
+
beforeEach(async () => {
|
|
182
|
+
audioChannel = new HttpChannel({
|
|
183
|
+
port: 0,
|
|
184
|
+
host: '127.0.0.1',
|
|
185
|
+
authToken: TOKEN,
|
|
186
|
+
allowedTools: ['Read', 'Glob'],
|
|
187
|
+
});
|
|
188
|
+
audioHandle = await audioChannel.start({
|
|
189
|
+
session: buildSession({ withTranscriber: 'transcribed voice content' }),
|
|
190
|
+
});
|
|
191
|
+
audioBaseUrl = `http://127.0.0.1:${audioChannel.boundPort}`;
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
afterEach(async () => {
|
|
195
|
+
await audioHandle.stop();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('POST /v1/turn/audio transcribes the body and runs a full turn', async () => {
|
|
199
|
+
const oggBytes = new Uint8Array([0x4f, 0x67, 0x67, 0x53]); // "OggS"
|
|
200
|
+
const res = await fetch(`${audioBaseUrl}/v1/turn/audio`, {
|
|
201
|
+
method: 'POST',
|
|
202
|
+
headers: {
|
|
203
|
+
'content-type': 'audio/ogg',
|
|
204
|
+
authorization: `Bearer ${TOKEN}`,
|
|
205
|
+
},
|
|
206
|
+
body: oggBytes,
|
|
207
|
+
});
|
|
208
|
+
expect(res.status).toBe(200);
|
|
209
|
+
const body = (await res.json()) as {
|
|
210
|
+
transcript: string;
|
|
211
|
+
assistant: string;
|
|
212
|
+
events: Array<{ type: string }>;
|
|
213
|
+
};
|
|
214
|
+
expect(body.transcript).toBe('transcribed voice content');
|
|
215
|
+
expect(body.assistant).toContain('hello from the HTTP channel');
|
|
216
|
+
expect(body.events.some((e) => e.type === 'user_prompt')).toBe(true);
|
|
217
|
+
expect(body.events.some((e) => e.type === 'assistant_message')).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('POST /v1/turn/audio rejects non-audio Content-Type with 415', async () => {
|
|
221
|
+
const res = await fetch(`${audioBaseUrl}/v1/turn/audio`, {
|
|
222
|
+
method: 'POST',
|
|
223
|
+
headers: {
|
|
224
|
+
'content-type': 'application/octet-stream',
|
|
225
|
+
authorization: `Bearer ${TOKEN}`,
|
|
226
|
+
},
|
|
227
|
+
body: 'bytes',
|
|
228
|
+
});
|
|
229
|
+
expect(res.status).toBe(415);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe('HttpChannel auth-disabled mode', () => {
|
|
234
|
+
it('without authToken, /v1/turn does not require Bearer header', async () => {
|
|
235
|
+
const open = new HttpChannel({
|
|
236
|
+
port: 0,
|
|
237
|
+
allowedTools: ['Read'],
|
|
238
|
+
});
|
|
239
|
+
const port = 50000 + Math.floor(Math.random() * 10000);
|
|
240
|
+
const real = new HttpChannel({ port, allowedTools: ['Read'] });
|
|
241
|
+
Object.assign(open, real);
|
|
242
|
+
const openHandle = await open.start({ session: buildSession() });
|
|
243
|
+
try {
|
|
244
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/turn`, {
|
|
245
|
+
method: 'POST',
|
|
246
|
+
headers: { 'content-type': 'application/json' },
|
|
247
|
+
body: JSON.stringify({ prompt: 'hi' }),
|
|
248
|
+
});
|
|
249
|
+
expect(res.status).toBe(200);
|
|
250
|
+
} finally {
|
|
251
|
+
await openHandle.stop();
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
});
|