@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.
- package/README.md +63 -17
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +31 -0
- package/dist/cli.d.ts +17 -0
- package/dist/cli.js +294 -0
- package/dist/docker/cli.d.ts +7 -0
- package/dist/docker/cli.js +21 -1
- package/dist/docker/session.d.ts +4 -2
- package/dist/docker/session.js +12 -10
- package/dist/redroom.d.ts +3 -0
- package/dist/redroom.js +38 -30
- package/dist/run.js +25 -11
- package/dist/scenario.d.ts +12 -0
- package/dist/scenario.js +130 -0
- package/dist/secrets.js +4 -3
- package/dist/sidecar/dns.js +24 -2
- package/dist/sidecar/fake-web.d.ts +1 -0
- package/dist/sidecar/fake-web.js +70 -17
- package/dist/sidecar/http.d.ts +29 -0
- package/dist/sidecar/http.js +105 -3
- package/dist/sidecar/relay.d.ts +1 -0
- package/dist/sidecar/relay.js +70 -22
- package/dist/sidecar/sidecar.d.ts +3 -0
- package/dist/sidecar/sidecar.js +6 -2
- package/dist/sidecar/state.d.ts +3 -0
- package/dist/sidecar/state.js +5 -0
- package/dist/sidecar/tool-hook.d.ts +1 -0
- package/dist/sidecar/tool-hook.js +78 -29
- package/dist/sidecar-bundle/sidecar.mjs +410 -78
- package/dist/text.d.ts +2 -0
- package/dist/text.js +5 -0
- package/dist/types.d.ts +1 -0
- package/package.json +5 -2
package/dist/sidecar/relay.d.ts
CHANGED
package/dist/sidecar/relay.js
CHANGED
|
@@ -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
|
|
41
|
+
function refuseRequest(run, method, path, res, status, code, detail) {
|
|
41
42
|
push(run.modelCalls, {
|
|
42
43
|
method,
|
|
43
44
|
path,
|
|
44
|
-
status
|
|
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
|
|
52
|
-
detail
|
|
52
|
+
code,
|
|
53
|
+
detail,
|
|
53
54
|
},
|
|
54
55
|
});
|
|
55
|
-
res.writeHead(
|
|
56
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
164
|
+
req.unpipe(out);
|
|
165
|
+
req.pause();
|
|
166
|
+
out.destroy();
|
|
167
|
+
record(extra);
|
|
141
168
|
};
|
|
142
169
|
req.on("data", (chunk) => {
|
|
143
|
-
if (
|
|
144
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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;
|
package/dist/sidecar/sidecar.js
CHANGED
|
@@ -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
|
|
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,
|
package/dist/sidecar/state.d.ts
CHANGED
|
@@ -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;
|
package/dist/sidecar/state.js
CHANGED
|
@@ -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";
|
|
@@ -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
|
-
|
|
114
|
+
if (!res.destroyed)
|
|
115
|
+
sendJson(res, status, {}, { Connection: "close" });
|
|
115
116
|
};
|
|
116
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
}
|