@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/dist/router.js ADDED
@@ -0,0 +1,354 @@
1
+ import { z } from 'zod';
2
+ import { readRequestBody, bearerTokenMatches } from '@moxxy/sdk/server';
3
+ export const turnRequestSchema = z.object({
4
+ prompt: z.string().min(1),
5
+ model: z.string().optional(),
6
+ systemPrompt: z.string().optional(),
7
+ });
8
+ /**
9
+ * Counting semaphore gating how many turns run concurrently on the single
10
+ * shared session. The HTTP server otherwise accepts unlimited parallel
11
+ * requests, each calling `session.runTurn` on the SAME session — N concurrent
12
+ * clients would spawn N provider streams (cost/memory/connection blowup) and
13
+ * interleave one shared conversation history. `tryAcquire` is non-blocking so
14
+ * excess requests get a fast 429 instead of queueing unbounded.
15
+ */
16
+ export class TurnLimiter {
17
+ max;
18
+ inUse = 0;
19
+ constructor(max) {
20
+ this.max = max;
21
+ }
22
+ tryAcquire() {
23
+ if (this.inUse >= this.max)
24
+ return false;
25
+ this.inUse += 1;
26
+ return true;
27
+ }
28
+ release() {
29
+ if (this.inUse > 0)
30
+ this.inUse -= 1;
31
+ }
32
+ get active() {
33
+ return this.inUse;
34
+ }
35
+ }
36
+ /** Default write-side idle timeout for an SSE turn. A live-but-slow consumer
37
+ * resets this on every flushed write; only a truly stalled (open-but-silent)
38
+ * reader trips it, at which point we abort the turn — the same outcome as a
39
+ * clean disconnect, just without waiting for a TCP close that may never come.
40
+ * Generous so normal inter-event gaps (model thinking, tool calls) don't trip
41
+ * it. */
42
+ export const DEFAULT_STREAM_STALL_MS = 120_000;
43
+ /** Shape an internal/server-side error for the wire: log the full detail
44
+ * server-side and return a stable, generic message so filesystem paths,
45
+ * provider internals, or system-prompt fragments embedded in `err` never leak
46
+ * to a (possibly remote) caller. Client-input validation errors (400) are NOT
47
+ * routed through this — echoing the caller's own malformed input is safe. */
48
+ function publicError(ctx, logMsg, err) {
49
+ ctx.logger?.warn(logMsg, { err: err instanceof Error ? err.message : String(err) });
50
+ return 'internal error';
51
+ }
52
+ /** Match HTTP request to a handler. Returns null if no route matches. */
53
+ export function routeRequest(req) {
54
+ const rawUrl = req.url ?? '/';
55
+ // Strip the query string before matching — `/v1/turn/audio?model=...`
56
+ // is the same route as `/v1/turn/audio`. The handler reads query
57
+ // params off req.url itself.
58
+ const pathname = rawUrl.split('?')[0] ?? rawUrl;
59
+ if (req.method === 'GET' && pathname === '/v1/health')
60
+ return handleHealth;
61
+ if (req.method === 'POST' && pathname === '/v1/turn')
62
+ return handleTurn;
63
+ if (req.method === 'POST' && pathname === '/v1/turn/stream')
64
+ return handleTurnStream;
65
+ if (req.method === 'POST' && pathname === '/v1/turn/audio')
66
+ return handleTurnAudio;
67
+ return null;
68
+ }
69
+ export async function handleHealth(_req, res) {
70
+ res.writeHead(200, { 'content-type': 'application/json' });
71
+ res.end(JSON.stringify({ status: 'ok' }));
72
+ }
73
+ function checkAuth(req, expected) {
74
+ if (!expected)
75
+ return true;
76
+ // Constant-time compare of the full `Bearer <token>` header so the token
77
+ // isn't recoverable byte-by-byte via response timing.
78
+ return bearerTokenMatches(req.headers.authorization, `Bearer ${expected}`);
79
+ }
80
+ async function readBody(req, max = 64 * 1024) {
81
+ return (await readRequestBody(req, max)).toString('utf8');
82
+ }
83
+ /** Audio uploads need a much larger cap than JSON; 10 MB covers a few
84
+ * minutes of Opus voice (Telegram caps voice notes at 50 MB, but
85
+ * realistic notes are well under that). */
86
+ const DEFAULT_AUDIO_MAX = 10 * 1024 * 1024;
87
+ function reply(res, status, body) {
88
+ res.writeHead(status, { 'content-type': 'application/json' });
89
+ res.end(JSON.stringify(body));
90
+ }
91
+ /** Hard cap on events buffered for a single non-streaming turn. The buffered
92
+ * path holds every event in memory and returns them in one JSON blob; a
93
+ * runaway/tool-looping turn could otherwise grow this without bound. Once
94
+ * exceeded we abort the turn rather than keep allocating. Generous enough that
95
+ * no normal turn hits it. */
96
+ export const MAX_BUFFERED_EVENTS = 50_000;
97
+ /**
98
+ * Drive a single buffered (non-streaming) turn to completion: drain every
99
+ * event, abort the turn if the client hangs up (so the model stops billing
100
+ * with nobody listening), and pull out the final assistant message. Shared by
101
+ * `handleTurn` and `handleTurnAudio` so the abort wiring and event/assistant
102
+ * extraction live in exactly one place. Re-throws on turn failure so each
103
+ * caller can shape its own error reply.
104
+ */
105
+ export async function driveTurn(session, prompt, runOptions, res) {
106
+ const controller = new AbortController();
107
+ const onClose = () => controller.abort();
108
+ res.on('close', onClose);
109
+ const events = [];
110
+ try {
111
+ for await (const event of session.runTurn(prompt, { ...runOptions, signal: controller.signal })) {
112
+ events.push(event);
113
+ if (events.length >= MAX_BUFFERED_EVENTS) {
114
+ controller.abort();
115
+ break;
116
+ }
117
+ }
118
+ }
119
+ finally {
120
+ res.off('close', onClose);
121
+ }
122
+ const finalAssistant = events.findLast((e) => e.type === 'assistant_message');
123
+ const assistant = finalAssistant && finalAssistant.type === 'assistant_message' ? finalAssistant.content : '';
124
+ return { events, assistant };
125
+ }
126
+ export async function handleTurn(req, res, ctx) {
127
+ if (!checkAuth(req, ctx.authToken)) {
128
+ reply(res, 401, { error: 'unauthorized' });
129
+ return;
130
+ }
131
+ let body;
132
+ try {
133
+ const raw = await readBody(req);
134
+ body = turnRequestSchema.parse(JSON.parse(raw));
135
+ }
136
+ catch (err) {
137
+ reply(res, 400, { error: 'bad_request', message: err instanceof Error ? err.message : String(err) });
138
+ return;
139
+ }
140
+ if (ctx.turnLimiter && !ctx.turnLimiter.tryAcquire()) {
141
+ reply(res, 429, { error: 'too_many_turns', message: 'concurrent turn limit reached; retry shortly' });
142
+ return;
143
+ }
144
+ let result;
145
+ try {
146
+ result = await driveTurn(ctx.session, body.prompt, {
147
+ ...(body.model ? { model: body.model } : {}),
148
+ ...(body.systemPrompt ? { systemPrompt: body.systemPrompt } : {}),
149
+ }, res);
150
+ }
151
+ catch (err) {
152
+ reply(res, 500, { error: 'turn_failed', message: publicError(ctx, 'http turn failed', err) });
153
+ return;
154
+ }
155
+ finally {
156
+ ctx.turnLimiter?.release();
157
+ }
158
+ reply(res, 200, { events: result.events, assistant: result.assistant });
159
+ }
160
+ /**
161
+ * Audio-in turn. Designed for iOS Shortcuts and curl: the client POSTs
162
+ * raw audio bytes with `Content-Type: audio/<format>`. Optional query
163
+ * params (`model`, `language`, `systemPrompt`) tune the run.
164
+ *
165
+ * The session must have an active Transcriber registered (e.g. via
166
+ * `@moxxy/plugin-stt-whisper`); without one the endpoint returns 503
167
+ * rather than transparently dropping the audio.
168
+ */
169
+ export async function handleTurnAudio(req, res, ctx) {
170
+ if (!checkAuth(req, ctx.authToken)) {
171
+ reply(res, 401, { error: 'unauthorized' });
172
+ return;
173
+ }
174
+ const transcriber = ctx.session.transcribers.tryGetActive();
175
+ if (!transcriber) {
176
+ reply(res, 503, {
177
+ error: 'no_transcriber',
178
+ message: 'No active Transcriber on this session. Install @moxxy/plugin-stt-whisper (or another transcriber plugin) and activate it before POSTing audio.',
179
+ });
180
+ return;
181
+ }
182
+ const contentType = (req.headers['content-type'] ?? '').toLowerCase();
183
+ if (!contentType.startsWith('audio/')) {
184
+ reply(res, 415, {
185
+ error: 'unsupported_media_type',
186
+ message: "Expected Content-Type: audio/* (e.g. audio/ogg, audio/m4a, audio/mpeg).",
187
+ });
188
+ return;
189
+ }
190
+ let bytes;
191
+ try {
192
+ bytes = await readRequestBody(req, DEFAULT_AUDIO_MAX);
193
+ }
194
+ catch (err) {
195
+ reply(res, 413, { error: 'payload_too_large', message: err instanceof Error ? err.message : String(err) });
196
+ return;
197
+ }
198
+ if (bytes.length === 0) {
199
+ reply(res, 400, { error: 'empty_body', message: 'audio body is empty' });
200
+ return;
201
+ }
202
+ // Pull tuning params off the query string — keeping them out of the
203
+ // body lets the payload remain raw audio (cleanest curl / Shortcut flow).
204
+ const url = new URL(req.url ?? '/', 'http://localhost');
205
+ const model = url.searchParams.get('model') ?? undefined;
206
+ const language = url.searchParams.get('language') ?? undefined;
207
+ const promptHint = url.searchParams.get('prompt') ?? undefined;
208
+ const systemPrompt = url.searchParams.get('systemPrompt') ?? undefined;
209
+ let transcript;
210
+ try {
211
+ const result = await transcriber.transcribe(new Uint8Array(bytes), {
212
+ mimeType: contentType,
213
+ ...(language ? { language } : {}),
214
+ ...(promptHint ? { prompt: promptHint } : {}),
215
+ });
216
+ transcript = result.text.trim();
217
+ }
218
+ catch (err) {
219
+ reply(res, 502, { error: 'transcription_failed', message: publicError(ctx, 'http audio transcription failed', err) });
220
+ return;
221
+ }
222
+ if (!transcript) {
223
+ reply(res, 422, { error: 'empty_transcript', message: 'transcriber returned empty text' });
224
+ return;
225
+ }
226
+ if (ctx.turnLimiter && !ctx.turnLimiter.tryAcquire()) {
227
+ reply(res, 429, { error: 'too_many_turns', message: 'concurrent turn limit reached; retry shortly' });
228
+ return;
229
+ }
230
+ let result;
231
+ try {
232
+ result = await driveTurn(ctx.session, transcript, {
233
+ ...(model ? { model } : {}),
234
+ ...(systemPrompt ? { systemPrompt } : {}),
235
+ }, res);
236
+ }
237
+ catch (err) {
238
+ reply(res, 500, { error: 'turn_failed', message: publicError(ctx, 'http audio turn failed', err) });
239
+ return;
240
+ }
241
+ finally {
242
+ ctx.turnLimiter?.release();
243
+ }
244
+ reply(res, 200, { transcript, events: result.events, assistant: result.assistant });
245
+ }
246
+ export async function handleTurnStream(req, res, ctx) {
247
+ if (!checkAuth(req, ctx.authToken)) {
248
+ reply(res, 401, { error: 'unauthorized' });
249
+ return;
250
+ }
251
+ let body;
252
+ try {
253
+ const raw = await readBody(req);
254
+ body = turnRequestSchema.parse(JSON.parse(raw));
255
+ }
256
+ catch (err) {
257
+ reply(res, 400, { error: 'bad_request', message: err instanceof Error ? err.message : String(err) });
258
+ return;
259
+ }
260
+ if (ctx.turnLimiter && !ctx.turnLimiter.tryAcquire()) {
261
+ reply(res, 429, { error: 'too_many_turns', message: 'concurrent turn limit reached; retry shortly' });
262
+ return;
263
+ }
264
+ res.writeHead(200, {
265
+ 'content-type': 'text/event-stream',
266
+ 'cache-control': 'no-cache',
267
+ connection: 'keep-alive',
268
+ });
269
+ // Abort the turn when the client hangs up — without this the model keeps
270
+ // generating (and billing) with nothing consuming the SSE stream.
271
+ const controller = new AbortController();
272
+ const onClose = () => controller.abort();
273
+ res.on('close', onClose);
274
+ // Degrade, never crash: an unhandled 'error' on a ServerResponse is fatal
275
+ // (uncaughtException). Treat any response-socket error like a disconnect —
276
+ // abort the turn and let the finally clean up — instead of letting it
277
+ // propagate and take down the whole runner process.
278
+ const onError = (err) => {
279
+ ctx.logger?.warn('http stream response error', {
280
+ err: err instanceof Error ? err.message : String(err),
281
+ });
282
+ controller.abort();
283
+ };
284
+ res.on('error', onError);
285
+ // A stalled-but-open consumer (reads nothing, never closes the socket) would
286
+ // otherwise park `safeWrite` on 'drain' indefinitely, keeping the provider
287
+ // stream — and billing — alive forever. The request/keep-alive timeouts cover
288
+ // header/body reads, NOT an in-progress response. `res.setTimeout` fires on
289
+ // write-side socket inactivity; a live-but-slow consumer resets it on every
290
+ // flushed write, so only a true stall trips it. We abort (same as a close).
291
+ const stallMs = ctx.streamStallMs ?? DEFAULT_STREAM_STALL_MS;
292
+ if (stallMs > 0) {
293
+ res.setTimeout(stallMs, () => {
294
+ ctx.logger?.warn('http stream consumer stalled; aborting turn', { stallMs });
295
+ controller.abort();
296
+ });
297
+ }
298
+ // Respect TCP backpressure: a slow/stalled-but-open consumer would otherwise
299
+ // let Node's internal write queue grow unbounded (a tool-heavy turn emits
300
+ // thousands of large events) until OOM. When `write` returns false we pause
301
+ // until 'drain', escaping early if the turn is aborted (client gone/stalled).
302
+ const safeWrite = async (chunk) => {
303
+ if (res.writableEnded || res.destroyed)
304
+ return;
305
+ if (res.write(chunk))
306
+ return;
307
+ if (controller.signal.aborted)
308
+ return;
309
+ await new Promise((resolve) => {
310
+ const done = () => {
311
+ res.off('drain', done);
312
+ controller.signal.removeEventListener('abort', done);
313
+ resolve();
314
+ };
315
+ res.once('drain', done);
316
+ controller.signal.addEventListener('abort', done, { once: true });
317
+ });
318
+ };
319
+ try {
320
+ for await (const event of ctx.session.runTurn(body.prompt, {
321
+ ...(body.model ? { model: body.model } : {}),
322
+ ...(body.systemPrompt ? { systemPrompt: body.systemPrompt } : {}),
323
+ signal: controller.signal,
324
+ })) {
325
+ await safeWrite(`data: ${JSON.stringify(event)}\n\n`);
326
+ }
327
+ await safeWrite('data: [DONE]\n\n');
328
+ }
329
+ catch (err) {
330
+ await safeWrite(`event: error\ndata: ${JSON.stringify({ message: publicError(ctx, 'http stream turn failed', err) })}\n\n`);
331
+ }
332
+ finally {
333
+ res.off('close', onClose);
334
+ res.off('error', onError);
335
+ // Disarm the stall timer so it can't fire (and abort) after the stream has
336
+ // already settled, and so the timer doesn't keep a handle alive.
337
+ if (stallMs > 0) {
338
+ try {
339
+ res.setTimeout(0);
340
+ }
341
+ catch { /* response already torn down */ }
342
+ }
343
+ ctx.turnLimiter?.release();
344
+ // The client may have hung up mid-stream; end() on an already-ended/
345
+ // destroyed response throws ERR_STREAM_WRITE_AFTER_END.
346
+ if (!res.writableEnded && !res.destroyed) {
347
+ try {
348
+ res.end();
349
+ }
350
+ catch { /* socket already gone */ }
351
+ }
352
+ }
353
+ }
354
+ //# sourceMappingURL=router.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"router.js","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@moxxy/plugin-channel-http",
3
+ "version": "0.26.0",
4
+ "description": "Request-response HTTP channel for moxxy. POST /v1/turn drives one turn synchronously; POST /v1/turn/stream returns Server-Sent Events. Bearer-token auth + allow-list permission flow (no human in the loop).",
5
+ "keywords": [
6
+ "moxxy",
7
+ "agent",
8
+ "channel",
9
+ "http",
10
+ "api"
11
+ ],
12
+ "homepage": "https://moxxy.ai",
13
+ "bugs": {
14
+ "url": "https://github.com/moxxy-ai/moxxy/issues"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/moxxy-ai/moxxy.git",
19
+ "directory": "packages/plugin-channel-http"
20
+ },
21
+ "author": "Michal Makowski <michal.makowski97@gmail.com>",
22
+ "license": "MIT",
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "type": "module",
27
+ "main": "./dist/index.js",
28
+ "types": "./dist/index.d.ts",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "import": "./dist/index.js"
33
+ }
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "src"
38
+ ],
39
+ "moxxy": {
40
+ "plugin": {
41
+ "entry": "./dist/index.js",
42
+ "kind": "channel"
43
+ }
44
+ },
45
+ "dependencies": {
46
+ "zod": "^3.24.0",
47
+ "@moxxy/sdk": "0.26.0"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^22.10.0",
51
+ "typescript": "^5.7.3",
52
+ "vitest": "^2.1.8",
53
+ "@moxxy/core": "0.26.0",
54
+ "@moxxy/mode-default": "0.26.0",
55
+ "@moxxy/testing": "0.0.44",
56
+ "@moxxy/runner": "0.2.30",
57
+ "@moxxy/tools-builtin": "0.0.39",
58
+ "@moxxy/tsconfig": "0.0.0",
59
+ "@moxxy/vitest-preset": "0.0.0"
60
+ },
61
+ "scripts": {
62
+ "build": "tsc -p tsconfig.json",
63
+ "typecheck": "tsc -p tsconfig.json --noEmit",
64
+ "test": "vitest run",
65
+ "clean": "rm -rf dist .turbo"
66
+ }
67
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Channel-as-thin-client test: run the HttpChannel against a RemoteSession
3
+ * instead of a local Session. The session (provider + loop) lives in a
4
+ * RunnerServer; the HTTP channel is a separate "process" that attaches over a
5
+ * unix socket and proxies turns to the runner. This is the `moxxy http
6
+ * --attach` shape, end-to-end.
7
+ */
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import { afterEach, describe, expect, it } from 'vitest';
11
+ import { Session, autoAllowResolver, silentLogger } from '@moxxy/core';
12
+ import { defineProvider, definePlugin } from '@moxxy/sdk';
13
+ import { FakeProvider, textReply } from '@moxxy/testing';
14
+ import { defaultModePlugin } from '@moxxy/mode-default';
15
+ import {
16
+ startRunnerServer,
17
+ connectRemoteSession,
18
+ type RunnerServer,
19
+ type RemoteSession,
20
+ } from '@moxxy/runner';
21
+ import { HttpChannel } from './channel.js';
22
+ import type { ChannelHandle } from '@moxxy/sdk';
23
+
24
+ function buildRunnerSession(): Session {
25
+ const provider = new FakeProvider({ script: [textReply('hello via the runner')] });
26
+ const session = new Session({
27
+ cwd: process.cwd(),
28
+ logger: silentLogger,
29
+ permissionResolver: autoAllowResolver,
30
+ });
31
+ session.pluginHost.registerStatic(
32
+ definePlugin({
33
+ name: 'http-attach-shim',
34
+ providers: [
35
+ defineProvider({
36
+ name: provider.name,
37
+ models: [...provider.models],
38
+ createClient: () => provider,
39
+ }),
40
+ ],
41
+ }),
42
+ );
43
+ session.providers.setActive(provider.name);
44
+ session.pluginHost.registerStatic(defaultModePlugin);
45
+ return session;
46
+ }
47
+
48
+ function tmpSocket(): string {
49
+ return path.join(os.tmpdir(), `moxxy-http-attach-${Math.random().toString(36).slice(2, 10)}.sock`);
50
+ }
51
+
52
+ let server: RunnerServer | null = null;
53
+ let remote: RemoteSession | null = null;
54
+ let handle: ChannelHandle | null = null;
55
+
56
+ afterEach(async () => {
57
+ await handle?.stop();
58
+ await remote?.close();
59
+ await server?.close();
60
+ handle = null;
61
+ remote = null;
62
+ server = null;
63
+ });
64
+
65
+ describe('HttpChannel attached to a runner', () => {
66
+ it('POST /v1/turn drives a turn on the remote runner session', async () => {
67
+ const socketPath = tmpSocket();
68
+ server = await startRunnerServer(buildRunnerSession(), { socketPath });
69
+ remote = await connectRemoteSession({ socketPath, role: 'http' });
70
+
71
+ const channel = new HttpChannel({ port: 0, host: '127.0.0.1', allowedTools: [] });
72
+ handle = await channel.start({ session: remote });
73
+
74
+ const res = await fetch(`http://127.0.0.1:${channel.boundPort}/v1/turn`, {
75
+ method: 'POST',
76
+ headers: { 'content-type': 'application/json' },
77
+ body: JSON.stringify({ prompt: 'say hi' }),
78
+ });
79
+ expect(res.status).toBe(200);
80
+ const body = (await res.json()) as { events: Array<{ type: string }>; assistant: string };
81
+ expect(body.assistant).toContain('hello via the runner');
82
+ expect(body.events.some((e) => e.type === 'user_prompt')).toBe(true);
83
+ expect(body.events.some((e) => e.type === 'assistant_message')).toBe(true);
84
+ });
85
+ });