@gatebolt/agent-redroom 0.1.1 → 0.2.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.
@@ -5,5 +5,6 @@ export type RelayContext = {
5
5
  state: SidecarState;
6
6
  trustTestSourceHeader: boolean;
7
7
  idleTimeoutMs: number;
8
+ requestTimeoutMs: number;
8
9
  };
9
10
  export declare function createRelay(context: RelayContext): Server;
@@ -1,7 +1,8 @@
1
1
  import { createServer, request as httpRequest, } from "node:http";
2
2
  import { request as httpsRequest } from "node:https";
3
3
  import { findSecrets } from "../secrets.js";
4
- import { DETAIL_LIMIT } from "../types.js";
4
+ import { DETAIL_LIMIT, } from "../types.js";
5
+ import { BodyBuffer } from "./http.js";
5
6
  import { headerLines, headersOf, push, SCAN_LIMIT, sourceIp, } from "./state.js";
6
7
  // Well under the turn timeout so a silent proxy is cut and logged first; streams send pings.
7
8
  export const UPSTREAM_IDLE_TIMEOUT_MS = 120_000;
@@ -37,23 +38,28 @@ function upstreamUrl(baseUrl, path) {
37
38
  target.search = request.search;
38
39
  return target;
39
40
  }
40
- function refuseEndpoint(run, method, path, res) {
41
+ function refuseRequest(run, method, path, res, status, code, detail) {
41
42
  push(run.modelCalls, {
42
43
  method,
43
44
  path,
44
- status: 403,
45
+ status,
45
46
  durationMs: 0,
46
47
  requestBytes: 0,
47
48
  at: new Date().toISOString(),
48
49
  secrets: [],
49
50
  error: {
50
51
  by: "sidecar",
51
- code: "path_not_allowed",
52
- detail: "the relay forwards only the model endpoints",
52
+ code,
53
+ detail,
53
54
  },
54
55
  });
55
- res.writeHead(403, { "Content-Type": "application/json" });
56
- res.end(JSON.stringify({ error: "path not allowed" }));
56
+ res.writeHead(status, {
57
+ "Content-Type": "application/json",
58
+ Connection: "close",
59
+ });
60
+ res.end(JSON.stringify({
61
+ error: code === "path_not_allowed" ? "path not allowed" : detail,
62
+ }));
57
63
  }
58
64
  export function createRelay(context) {
59
65
  return createServer((req, res) => {
@@ -83,23 +89,29 @@ function relay(context, req, res) {
83
89
  const [runId, run] = entry;
84
90
  const method = req.method ?? "GET";
85
91
  if (!isModelEndpoint(method, path)) {
86
- refuseEndpoint(run, method, path, res);
92
+ refuseRequest(run, method, path, res, 403, "path_not_allowed", "the relay forwards only the model endpoints");
93
+ return;
94
+ }
95
+ const lease = context.state.requests.take(run.ip);
96
+ if (!lease) {
97
+ refuseRequest(run, method, path, res, 429, "request_limit", "too many in-flight requests");
87
98
  return;
88
99
  }
100
+ res.once("close", () => lease.release());
89
101
  const target = upstreamUrl(run.modelProxy.baseUrl, path);
90
102
  const send = target.protocol === "https:" ? httpsRequest : httpRequest;
91
103
  const started = Date.now();
92
- const scanned = [];
104
+ const buffered = new BodyBuffer(SCAN_LIMIT, (bytes) => lease.reserve(bytes));
93
105
  let requestBytes = 0;
94
106
  let status = 0;
95
107
  let errorBody = "";
96
108
  let error;
97
109
  let recorded = false;
98
- const record = () => {
110
+ const record = (extra) => {
99
111
  if (recorded)
100
112
  return;
101
113
  recorded = true;
102
- const body = Buffer.concat(scanned).toString("utf8");
114
+ const body = buffered.finish(extra).toString("utf8");
103
115
  const text = [path, headerLines(headersOf(req)), body].join("\n");
104
116
  if (!error && status >= 400) {
105
117
  error = {
@@ -120,11 +132,11 @@ function relay(context, req, res) {
120
132
  ...(error ? { error } : {}),
121
133
  });
122
134
  };
123
- const fail = (code, detail) => {
135
+ const fail = (code, detail, by = "modelProxy", extra) => {
124
136
  if (recorded)
125
137
  return;
126
138
  error = {
127
- by: "modelProxy",
139
+ by,
128
140
  code,
129
141
  detail,
130
142
  call: { url: target.href, durationMs: Date.now() - started },
@@ -133,16 +145,35 @@ function relay(context, req, res) {
133
145
  res.destroy();
134
146
  }
135
147
  else {
136
- status = code === "timeout" ? 504 : 502;
137
- res.writeHead(status, { "Content-Type": "application/json" });
138
- res.end(JSON.stringify({ error: "model proxy unreachable" }));
148
+ status =
149
+ code === "timeout"
150
+ ? 504
151
+ : code === "request_timeout"
152
+ ? 408
153
+ : code === "buffer_limit"
154
+ ? 429
155
+ : 502;
156
+ res.writeHead(status, {
157
+ "Content-Type": "application/json",
158
+ Connection: "close",
159
+ });
160
+ res.end(JSON.stringify({
161
+ error: by === "sidecar" ? detail : "model proxy unreachable",
162
+ }));
139
163
  }
140
- record();
164
+ req.unpipe(out);
165
+ req.pause();
166
+ out.destroy();
167
+ record(extra);
141
168
  };
142
169
  req.on("data", (chunk) => {
143
- if (requestBytes < SCAN_LIMIT)
144
- scanned.push(chunk);
170
+ if (recorded)
171
+ return;
145
172
  requestBytes += chunk.length;
173
+ if (!buffered.append(chunk)) {
174
+ fail("buffer_limit", "too many buffered request bytes", "sidecar", chunk);
175
+ return;
176
+ }
146
177
  });
147
178
  const out = send(target, {
148
179
  method,
@@ -162,13 +193,30 @@ function relay(context, req, res) {
162
193
  });
163
194
  out.on("timeout", () => {
164
195
  fail("timeout", `model proxy sent nothing for ${context.idleTimeoutMs} ms`);
165
- out.destroy();
166
196
  });
167
197
  out.on("error", (cause) => fail("network_error", cause.message));
198
+ const timer = setTimeout(() => fail("request_timeout", "request body timed out", "sidecar"), context.requestTimeoutMs);
199
+ timer.unref();
200
+ req.once("end", () => clearTimeout(timer));
201
+ req.once("aborted", () => fail("request_aborted", "request body aborted", "sidecar"));
202
+ req.once("error", () => fail("request_aborted", "request body failed", "sidecar"));
168
203
  res.on("close", () => {
169
- if (!res.writableFinished)
170
- out.destroy();
204
+ clearTimeout(timer);
205
+ req.unpipe(out);
206
+ req.pause();
207
+ out.destroy();
208
+ if (!error && (!req.complete || !res.writableFinished)) {
209
+ error = {
210
+ by: "sidecar",
211
+ code: "request_aborted",
212
+ detail: req.complete ? "response aborted" : "request body aborted",
213
+ call: { url: target.href, status, durationMs: Date.now() - started },
214
+ };
215
+ }
171
216
  record();
217
+ if (!req.complete)
218
+ req.destroy();
219
+ lease.release();
172
220
  });
173
221
  req.pipe(out);
174
222
  }
@@ -1,5 +1,6 @@
1
1
  import { type SidecarPorts } from "./config.js";
2
2
  import { type Ips } from "./dns.js";
3
+ import { type RequestLimits } from "./http.js";
3
4
  import { SidecarState } from "./state.js";
4
5
  export type SidecarOptions = {
5
6
  controlSecret: string;
@@ -11,6 +12,8 @@ export type SidecarOptions = {
11
12
  fetch?: typeof fetch;
12
13
  checkTimeoutMs?: number;
13
14
  upstreamIdleTimeoutMs?: number;
15
+ requestLimits?: Partial<RequestLimits>;
16
+ requestTimeoutMs?: number;
14
17
  lease?: {
15
18
  ms: number;
16
19
  onExpired: () => void;
@@ -5,6 +5,7 @@ import {} from "./config.js";
5
5
  import { createControl } from "./control.js";
6
6
  import { createDns } from "./dns.js";
7
7
  import { createFakeHttp, createFakeHttps, generateTls } from "./fake-web.js";
8
+ import { REQUEST_TIMEOUT_MS } from "./http.js";
8
9
  import { createRelay, UPSTREAM_IDLE_TIMEOUT_MS } from "./relay.js";
9
10
  import { SidecarState } from "./state.js";
10
11
  import { CHECK_TIMEOUT_MS, createToolHook } from "./tool-hook.js";
@@ -34,7 +35,7 @@ const close = (server) => new Promise((resolve) => {
34
35
  server.closeAllConnections();
35
36
  });
36
37
  export async function startSidecar(options) {
37
- const state = new SidecarState();
38
+ const state = new SidecarState(options.requestLimits);
38
39
  const host = options.host ?? "0.0.0.0";
39
40
  const trustTestSourceHeader = options.trustTestSourceHeader ?? false;
40
41
  const { domain } = options;
@@ -49,7 +50,8 @@ export async function startSidecar(options) {
49
50
  }, Math.max(1, Math.floor(lease.ms / 4)))
50
51
  : undefined;
51
52
  leaseTimer?.unref();
52
- const web = { state, domain, trustTestSourceHeader };
53
+ const requestTimeoutMs = options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS;
54
+ const web = { state, domain, trustTestSourceHeader, requestTimeoutMs };
53
55
  const servers = {
54
56
  http: createFakeHttp(web),
55
57
  https: createFakeHttps(web, tls),
@@ -57,6 +59,7 @@ export async function startSidecar(options) {
57
59
  state,
58
60
  trustTestSourceHeader,
59
61
  idleTimeoutMs: options.upstreamIdleTimeoutMs ?? UPSTREAM_IDLE_TIMEOUT_MS,
62
+ requestTimeoutMs,
60
63
  }),
61
64
  hook: createToolHook({
62
65
  state,
@@ -64,6 +67,7 @@ export async function startSidecar(options) {
64
67
  signal: controller.signal,
65
68
  trustTestSourceHeader,
66
69
  timeoutMs: options.checkTimeoutMs ?? CHECK_TIMEOUT_MS,
70
+ requestTimeoutMs,
67
71
  }),
68
72
  control: createControl({
69
73
  state,
@@ -1,4 +1,5 @@
1
1
  import { type IncomingMessage } from "node:http";
2
+ import { RequestBudget, type RequestLimits } from "./http.js";
2
3
  import { type Hit, type ModelCall, type ModelProxy, type SecretMatch, type Secrets, type ToolCall, type ToolCallCheck } from "../types.js";
3
4
  export declare const MAX_ENTRIES = 10000;
4
5
  export declare const MAX_LOG_BYTES: number;
@@ -37,6 +38,8 @@ export declare function drain<T>(log: Log<T>): Log<T>;
37
38
  export declare class SidecarState {
38
39
  readonly runs: Map<string, RunState>;
39
40
  readonly unattributed: Log<Hit>;
41
+ readonly requests: RequestBudget;
42
+ constructor(limits?: Partial<RequestLimits>);
40
43
  register(id: string, run: Pick<RunState, "ip" | "pages" | "secrets" | "modelProxy" | "toolCallCheck">): "ok" | "run" | "ip";
41
44
  runEntryForIp(ip: string): [string, RunState] | undefined;
42
45
  runForIp(ip: string): RunState | undefined;
@@ -1,5 +1,6 @@
1
1
  import {} from "node:http";
2
2
  import { findSecrets } from "../secrets.js";
3
+ import { RequestBudget } from "./http.js";
3
4
  import {} from "../types.js";
4
5
  export const MAX_ENTRIES = 10_000;
5
6
  export const MAX_LOG_BYTES = 4 * 1024 * 1024;
@@ -61,6 +62,10 @@ export function drain(log) {
61
62
  export class SidecarState {
62
63
  runs = new Map();
63
64
  unattributed = emptyLog();
65
+ requests;
66
+ constructor(limits) {
67
+ this.requests = new RequestBudget(limits);
68
+ }
64
69
  register(id, run) {
65
70
  if (this.runs.has(id))
66
71
  return "run";
@@ -9,5 +9,6 @@ export type ToolHookContext = {
9
9
  signal: AbortSignal;
10
10
  trustTestSourceHeader: boolean;
11
11
  timeoutMs: number;
12
+ requestTimeoutMs: number;
12
13
  };
13
14
  export declare function createToolHook(context: ToolHookContext): Server;
@@ -2,7 +2,7 @@ import {} from "node:http";
2
2
  import { z } from "zod";
3
3
  import { findSecrets } from "../secrets.js";
4
4
  import { DETAIL_LIMIT, isRecord, messageOf, } from "../types.js";
5
- import { BodyTooLargeError, jsonServer, readBody, readJson, sendJson, } from "./http.js";
5
+ import { BodyTooLargeError, BODY_LIMIT_BYTES, jsonServer, readBody, readJson, sendJson, } from "./http.js";
6
6
  import { push, sourceIp } from "./state.js";
7
7
  export const CHECK_TIMEOUT_MS = 25_000;
8
8
  const hookSchema = z.object({
@@ -111,37 +111,86 @@ export function createToolHook(context) {
111
111
  at: new Date(started).toISOString(),
112
112
  error: { by: "sidecar", code, detail: detail.slice(0, DETAIL_LIMIT) },
113
113
  });
114
- sendJson(res, status, {});
114
+ if (!res.destroyed)
115
+ sendJson(res, status, {}, { Connection: "close" });
115
116
  };
116
- let payload;
117
+ const lease = context.state.requests.take(source);
118
+ if (!lease)
119
+ return refuse(429, "request_limit", "too many in-flight requests");
120
+ const controller = new AbortController();
121
+ let handlerDone = false;
122
+ let responseDone = false;
123
+ let timedOut = false;
124
+ const release = () => {
125
+ if (handlerDone && responseDone)
126
+ lease.release();
127
+ };
128
+ res.once("finish", () => {
129
+ responseDone = true;
130
+ release();
131
+ });
132
+ res.once("close", () => {
133
+ responseDone = true;
134
+ controller.abort();
135
+ release();
136
+ });
137
+ req.once("aborted", () => {
138
+ controller.abort();
139
+ });
140
+ req.once("error", () => {
141
+ controller.abort();
142
+ });
143
+ const timer = setTimeout(() => {
144
+ timedOut = true;
145
+ req.destroy(new Error("request body timed out"));
146
+ }, context.requestTimeoutMs);
147
+ timer.unref();
148
+ req.once("end", () => clearTimeout(timer));
117
149
  try {
118
- payload = await readJson(req);
119
- }
120
- catch (error) {
121
- return error instanceof BodyTooLargeError
122
- ? refuse(413, "payload_too_large", messageOf(error))
123
- : refuse(400, "invalid_payload", messageOf(error));
150
+ if (!lease.reserve(2 * BODY_LIMIT_BYTES)) {
151
+ return refuse(429, "buffer_limit", "too many buffered request bytes");
152
+ }
153
+ let payload;
154
+ try {
155
+ payload = await readJson(req);
156
+ }
157
+ catch (error) {
158
+ if (timedOut)
159
+ return refuse(408, "request_timeout", "request body timed out");
160
+ return error instanceof BodyTooLargeError
161
+ ? refuse(413, "payload_too_large", messageOf(error))
162
+ : refuse(400, "invalid_payload", messageOf(error));
163
+ }
164
+ const hook = hookSchema.safeParse(payload);
165
+ if (!hook.success) {
166
+ return refuse(400, "invalid_payload", hook.error.message, payload);
167
+ }
168
+ const check = owned.toolCallCheck;
169
+ const checked = check
170
+ ? await askCheck({
171
+ ...context,
172
+ signal: AbortSignal.any([context.signal, controller.signal]),
173
+ }, check, runId, payload)
174
+ : { response: {} };
175
+ push(owned.toolCalls, {
176
+ toolUseId: hook.data.tool_use_id,
177
+ tool: hook.data.tool_name,
178
+ input: hook.data.tool_input,
179
+ hookPayload: payload,
180
+ response: checked.response,
181
+ action: actionOf(checked.response),
182
+ checked: check !== undefined,
183
+ latencyMs: Date.now() - started,
184
+ at: new Date(started).toISOString(),
185
+ ...(checked.error ? { error: checked.error } : {}),
186
+ }, findSecrets(String(JSON.stringify(hook.data.tool_input)), owned.secrets));
187
+ if (!res.destroyed)
188
+ sendJson(res, 200, checked.response);
124
189
  }
125
- const hook = hookSchema.safeParse(payload);
126
- if (!hook.success) {
127
- return refuse(400, "invalid_payload", hook.error.message, payload);
190
+ finally {
191
+ clearTimeout(timer);
192
+ handlerDone = true;
193
+ release();
128
194
  }
129
- const check = owned.toolCallCheck;
130
- const checked = check
131
- ? await askCheck(context, check, runId, payload)
132
- : { response: {} };
133
- push(owned.toolCalls, {
134
- toolUseId: hook.data.tool_use_id,
135
- tool: hook.data.tool_name,
136
- input: hook.data.tool_input,
137
- hookPayload: payload,
138
- response: checked.response,
139
- action: actionOf(checked.response),
140
- checked: check !== undefined,
141
- latencyMs: Date.now() - started,
142
- at: new Date(started).toISOString(),
143
- ...(checked.error ? { error: checked.error } : {}),
144
- }, findSecrets(String(JSON.stringify(hook.data.tool_input)), owned.secrets));
145
- sendJson(res, 200, checked.response);
146
195
  });
147
196
  }