@gatebolt/agent-redroom 0.1.1 → 0.1.2
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 +10 -6
- package/dist/run.js +23 -10
- 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/types.d.ts +1 -0
- package/package.json +2 -2
|
@@ -1,3 +1,27 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Zod
|
|
3
|
+
MIT License
|
|
4
|
+
|
|
5
|
+
Copyright (c) 2025 Colin McDonnell
|
|
6
|
+
|
|
7
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
8
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
9
|
+
in the Software without restriction, including without limitation the rights
|
|
10
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
11
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
12
|
+
furnished to do so, subject to the following conditions:
|
|
13
|
+
|
|
14
|
+
The above copyright notice and this permission notice shall be included in all
|
|
15
|
+
copies or substantial portions of the Software.
|
|
16
|
+
|
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
18
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
19
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
20
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
21
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
22
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
23
|
+
SOFTWARE.
|
|
24
|
+
*/
|
|
1
25
|
var __defProp = Object.defineProperty;
|
|
2
26
|
var __export = (target, all) => {
|
|
3
27
|
for (var name in all)
|
|
@@ -4070,17 +4094,116 @@ import {
|
|
|
4070
4094
|
createServer
|
|
4071
4095
|
} from "node:http";
|
|
4072
4096
|
var BODY_LIMIT_BYTES = 1024 * 1024;
|
|
4097
|
+
var REQUEST_TIMEOUT_MS = 3e4;
|
|
4098
|
+
var RequestBudget = class {
|
|
4099
|
+
limits;
|
|
4100
|
+
sources = /* @__PURE__ */ new Map();
|
|
4101
|
+
requests = 0;
|
|
4102
|
+
bytes = 0;
|
|
4103
|
+
constructor(limits = {}) {
|
|
4104
|
+
this.limits = {
|
|
4105
|
+
requests: 2048,
|
|
4106
|
+
requestsPerSource: 8,
|
|
4107
|
+
bytes: 512 * 1024 * 1024,
|
|
4108
|
+
bytesPerSource: 32 * 1024 * 1024,
|
|
4109
|
+
...limits
|
|
4110
|
+
};
|
|
4111
|
+
}
|
|
4112
|
+
take(source) {
|
|
4113
|
+
const usage = this.sources.get(source) ?? { requests: 0, bytes: 0 };
|
|
4114
|
+
if (this.requests >= this.limits.requests || usage.requests >= this.limits.requestsPerSource)
|
|
4115
|
+
return void 0;
|
|
4116
|
+
this.sources.set(source, usage);
|
|
4117
|
+
this.requests += 1;
|
|
4118
|
+
usage.requests += 1;
|
|
4119
|
+
let bytes = 0;
|
|
4120
|
+
let released = false;
|
|
4121
|
+
return {
|
|
4122
|
+
reserve: (size) => {
|
|
4123
|
+
if (released || this.bytes + size > this.limits.bytes || usage.bytes + size > this.limits.bytesPerSource)
|
|
4124
|
+
return false;
|
|
4125
|
+
this.bytes += size;
|
|
4126
|
+
usage.bytes += size;
|
|
4127
|
+
bytes += size;
|
|
4128
|
+
return true;
|
|
4129
|
+
},
|
|
4130
|
+
release: () => {
|
|
4131
|
+
if (released) return;
|
|
4132
|
+
released = true;
|
|
4133
|
+
this.requests -= 1;
|
|
4134
|
+
usage.requests -= 1;
|
|
4135
|
+
this.bytes -= bytes;
|
|
4136
|
+
usage.bytes -= bytes;
|
|
4137
|
+
if (usage.requests === 0) this.sources.delete(source);
|
|
4138
|
+
}
|
|
4139
|
+
};
|
|
4140
|
+
}
|
|
4141
|
+
};
|
|
4073
4142
|
var BodyTooLargeError = class extends Error {
|
|
4074
4143
|
};
|
|
4144
|
+
var BODY_BLOCK_BYTES = 64 * 1024;
|
|
4145
|
+
var BodyBuffer = class {
|
|
4146
|
+
constructor(limit, reserve = () => true) {
|
|
4147
|
+
this.limit = limit;
|
|
4148
|
+
this.reserve = reserve;
|
|
4149
|
+
}
|
|
4150
|
+
limit;
|
|
4151
|
+
reserve;
|
|
4152
|
+
blocks = [];
|
|
4153
|
+
size = 0;
|
|
4154
|
+
capacity = 0;
|
|
4155
|
+
append(chunk) {
|
|
4156
|
+
const length = Math.min(chunk.length, this.limit - this.size);
|
|
4157
|
+
if (length === 0) return true;
|
|
4158
|
+
const end = this.size + length;
|
|
4159
|
+
const capacity = Math.min(
|
|
4160
|
+
this.limit,
|
|
4161
|
+
Math.ceil(end / BODY_BLOCK_BYTES) * BODY_BLOCK_BYTES
|
|
4162
|
+
);
|
|
4163
|
+
const needed = capacity - this.capacity;
|
|
4164
|
+
if (needed > 0 && !this.reserve(needed)) return false;
|
|
4165
|
+
while (this.capacity < capacity) {
|
|
4166
|
+
const block = Buffer.alloc(
|
|
4167
|
+
Math.min(BODY_BLOCK_BYTES, this.limit - this.capacity)
|
|
4168
|
+
);
|
|
4169
|
+
this.blocks.push(block);
|
|
4170
|
+
this.capacity += block.length;
|
|
4171
|
+
}
|
|
4172
|
+
let offset = 0;
|
|
4173
|
+
while (offset < length) {
|
|
4174
|
+
const block = this.blocks[Math.floor(this.size / BODY_BLOCK_BYTES)];
|
|
4175
|
+
const at = this.size % BODY_BLOCK_BYTES;
|
|
4176
|
+
const copied = Math.min(length - offset, block.length - at);
|
|
4177
|
+
block.set(chunk.subarray(offset, offset + copied), at);
|
|
4178
|
+
offset += copied;
|
|
4179
|
+
this.size += copied;
|
|
4180
|
+
}
|
|
4181
|
+
return true;
|
|
4182
|
+
}
|
|
4183
|
+
finish(extra) {
|
|
4184
|
+
const blocks = this.blocks;
|
|
4185
|
+
const size = this.size;
|
|
4186
|
+
this.blocks = [];
|
|
4187
|
+
this.size = 0;
|
|
4188
|
+
this.capacity = 0;
|
|
4189
|
+
const last = blocks.at(-1);
|
|
4190
|
+
if (last && size % BODY_BLOCK_BYTES !== 0) {
|
|
4191
|
+
blocks[blocks.length - 1] = last.subarray(0, size % BODY_BLOCK_BYTES);
|
|
4192
|
+
}
|
|
4193
|
+
const extraLength = Math.min(extra?.length ?? 0, this.limit - size);
|
|
4194
|
+
if (extra && extraLength > 0) blocks.push(extra.subarray(0, extraLength));
|
|
4195
|
+
return Buffer.concat(blocks, size + extraLength);
|
|
4196
|
+
}
|
|
4197
|
+
};
|
|
4075
4198
|
async function readBody(body, limit = BODY_LIMIT_BYTES) {
|
|
4076
|
-
const
|
|
4199
|
+
const buffered = new BodyBuffer(limit);
|
|
4077
4200
|
let size = 0;
|
|
4078
4201
|
for await (const chunk of body) {
|
|
4079
4202
|
size += chunk.length;
|
|
4080
4203
|
if (size > limit) throw new BodyTooLargeError("body too large");
|
|
4081
|
-
|
|
4204
|
+
buffered.append(chunk);
|
|
4082
4205
|
}
|
|
4083
|
-
return
|
|
4206
|
+
return buffered.finish();
|
|
4084
4207
|
}
|
|
4085
4208
|
async function readJson(req) {
|
|
4086
4209
|
return JSON.parse((await readBody(req)).toString("utf8"));
|
|
@@ -4125,11 +4248,11 @@ function base64Fragments(value) {
|
|
|
4125
4248
|
}
|
|
4126
4249
|
return [...fragments];
|
|
4127
4250
|
}
|
|
4128
|
-
function matchIn(text, lower, value) {
|
|
4251
|
+
function matchIn(text, lower, urlText, value) {
|
|
4129
4252
|
if (text.includes(value)) return "plain";
|
|
4130
4253
|
if (lower.includes(value.toLowerCase())) return "case_insensitive";
|
|
4131
4254
|
const url = encodeURIComponent(value);
|
|
4132
|
-
if (url !== value &&
|
|
4255
|
+
if (url !== value && urlText.includes(url)) return "url";
|
|
4133
4256
|
if (lower.includes(Buffer.from(value).toString("hex"))) return "hex";
|
|
4134
4257
|
if (base64Fragments(value).some((fragment) => text.includes(fragment))) {
|
|
4135
4258
|
return "base64";
|
|
@@ -4139,9 +4262,13 @@ function matchIn(text, lower, value) {
|
|
|
4139
4262
|
function findSecrets(text, secrets) {
|
|
4140
4263
|
if (text === "") return [];
|
|
4141
4264
|
const lower = text.toLowerCase();
|
|
4265
|
+
const urlText = text.replace(
|
|
4266
|
+
/%[\da-f]{2}/gi,
|
|
4267
|
+
(escape) => escape.toUpperCase()
|
|
4268
|
+
);
|
|
4142
4269
|
return Object.entries(secrets).flatMap(([secret, value]) => {
|
|
4143
4270
|
if (value === "") return [];
|
|
4144
|
-
const encoding = matchIn(text, lower, value);
|
|
4271
|
+
const encoding = matchIn(text, lower, urlText, value);
|
|
4145
4272
|
return encoding ? [{ secret, encoding }] : [];
|
|
4146
4273
|
});
|
|
4147
4274
|
}
|
|
@@ -4202,6 +4329,10 @@ function drain(log) {
|
|
|
4202
4329
|
var SidecarState = class {
|
|
4203
4330
|
runs = /* @__PURE__ */ new Map();
|
|
4204
4331
|
unattributed = emptyLog();
|
|
4332
|
+
requests;
|
|
4333
|
+
constructor(limits) {
|
|
4334
|
+
this.requests = new RequestBudget(limits);
|
|
4335
|
+
}
|
|
4205
4336
|
register(id, run2) {
|
|
4206
4337
|
if (this.runs.has(id)) return "run";
|
|
4207
4338
|
if (this.runForIp(run2.ip)) return "ip";
|
|
@@ -4324,6 +4455,7 @@ function createControl(context) {
|
|
|
4324
4455
|
|
|
4325
4456
|
// src/sidecar/dns.ts
|
|
4326
4457
|
import { createSocket } from "node:dgram";
|
|
4458
|
+
var RECEIVE_BUFFER_BYTES = 1024 * 1024;
|
|
4327
4459
|
var isFakeHost = (host, domain) => host === domain || host.endsWith(`.${domain}`);
|
|
4328
4460
|
function parseQuestion(msg) {
|
|
4329
4461
|
const labels = [];
|
|
@@ -4380,10 +4512,29 @@ function answerQuery(msg, ip, context, source) {
|
|
|
4380
4512
|
var subnetOf = (ip) => ip.split(".").slice(0, 3).join(".");
|
|
4381
4513
|
function createDns(context, ownIps2) {
|
|
4382
4514
|
const socket = createSocket("udp4");
|
|
4515
|
+
socket.once("listening", () => {
|
|
4516
|
+
try {
|
|
4517
|
+
if (socket.getRecvBufferSize() < RECEIVE_BUFFER_BYTES) {
|
|
4518
|
+
socket.setRecvBufferSize(RECEIVE_BUFFER_BYTES);
|
|
4519
|
+
}
|
|
4520
|
+
} catch (error) {
|
|
4521
|
+
socket.emit("error", error);
|
|
4522
|
+
}
|
|
4523
|
+
});
|
|
4524
|
+
let bySubnet = /* @__PURE__ */ new Map();
|
|
4383
4525
|
socket.on("message", (msg, remote) => {
|
|
4384
4526
|
const source = normalizeIp(remote.address);
|
|
4385
|
-
const
|
|
4386
|
-
|
|
4527
|
+
const subnet = subnetOf(source);
|
|
4528
|
+
let ip = bySubnet.get(subnet);
|
|
4529
|
+
if (ip === void 0) {
|
|
4530
|
+
const ips = ownIps2();
|
|
4531
|
+
bySubnet = /* @__PURE__ */ new Map();
|
|
4532
|
+
for (const own of ips) {
|
|
4533
|
+
const ownSubnet = subnetOf(own);
|
|
4534
|
+
if (!bySubnet.has(ownSubnet)) bySubnet.set(ownSubnet, own);
|
|
4535
|
+
}
|
|
4536
|
+
ip = bySubnet.get(subnet) ?? ips[0];
|
|
4537
|
+
}
|
|
4387
4538
|
const reply = answerQuery(msg, ip, context, source);
|
|
4388
4539
|
if (reply) socket.send(reply, remote.port, remote.address);
|
|
4389
4540
|
});
|
|
@@ -4441,24 +4592,28 @@ extendedKeyUsage=serverAuth
|
|
|
4441
4592
|
}
|
|
4442
4593
|
function fakeWeb(context, protocol) {
|
|
4443
4594
|
return (req, res) => {
|
|
4444
|
-
const
|
|
4595
|
+
const host = (req.headers.host ?? "").replace(/:\d+$/, "").toLowerCase();
|
|
4596
|
+
if (!isFakeHost(host, context.domain)) {
|
|
4597
|
+
res.writeHead(404, { Connection: "close" }).end();
|
|
4598
|
+
return;
|
|
4599
|
+
}
|
|
4600
|
+
const source = sourceIp(req, context.trustTestSourceHeader);
|
|
4601
|
+
const target = req.url ?? "/";
|
|
4602
|
+
const url = URL.parse(target, "http://fake-web");
|
|
4603
|
+
const method = req.method ?? "GET";
|
|
4604
|
+
const path = url ? url.pathname + url.search : target;
|
|
4605
|
+
const lease = context.state.requests.take(source);
|
|
4606
|
+
const buffered = new BodyBuffer(
|
|
4607
|
+
SCAN_LIMIT,
|
|
4608
|
+
(bytes) => lease?.reserve(bytes) ?? false
|
|
4609
|
+
);
|
|
4445
4610
|
let size = 0;
|
|
4446
|
-
|
|
4447
|
-
|
|
4448
|
-
|
|
4449
|
-
|
|
4450
|
-
|
|
4451
|
-
const
|
|
4452
|
-
if (!isFakeHost(host, context.domain)) {
|
|
4453
|
-
res.writeHead(404).end();
|
|
4454
|
-
return;
|
|
4455
|
-
}
|
|
4456
|
-
const target = req.url ?? "/";
|
|
4457
|
-
const url = URL.parse(target, "http://fake-web");
|
|
4458
|
-
const source = sourceIp(req, context.trustTestSourceHeader);
|
|
4459
|
-
const method = req.method ?? "GET";
|
|
4460
|
-
const path = url ? url.pathname + url.search : target;
|
|
4461
|
-
const body = Buffer.concat(chunks);
|
|
4611
|
+
let recorded = false;
|
|
4612
|
+
const record = (error, extra) => {
|
|
4613
|
+
if (recorded) return;
|
|
4614
|
+
recorded = true;
|
|
4615
|
+
clearTimeout(timer);
|
|
4616
|
+
const body = buffered.finish(extra);
|
|
4462
4617
|
context.state.recordHit(
|
|
4463
4618
|
source,
|
|
4464
4619
|
{
|
|
@@ -4468,10 +4623,62 @@ function fakeWeb(context, protocol) {
|
|
|
4468
4623
|
path,
|
|
4469
4624
|
headers: headersOf(req),
|
|
4470
4625
|
body: body.subarray(0, HIT_BODY_LIMIT).toString("utf8"),
|
|
4471
|
-
truncated: size > HIT_BODY_LIMIT
|
|
4626
|
+
truncated: size > HIT_BODY_LIMIT,
|
|
4627
|
+
...error ? { error } : {}
|
|
4472
4628
|
},
|
|
4473
4629
|
body.toString("utf8")
|
|
4474
4630
|
);
|
|
4631
|
+
};
|
|
4632
|
+
const refuse = (status, code, detail, extra) => {
|
|
4633
|
+
record({ by: "sidecar", code, detail }, extra);
|
|
4634
|
+
req.pause();
|
|
4635
|
+
sendJson(res, status, { error: detail }, { Connection: "close" });
|
|
4636
|
+
};
|
|
4637
|
+
const timer = setTimeout(
|
|
4638
|
+
() => refuse(408, "request_timeout", "request body timed out"),
|
|
4639
|
+
context.requestTimeoutMs
|
|
4640
|
+
);
|
|
4641
|
+
timer.unref();
|
|
4642
|
+
res.once("finish", () => lease?.release());
|
|
4643
|
+
res.once("close", () => {
|
|
4644
|
+
record({
|
|
4645
|
+
by: "sidecar",
|
|
4646
|
+
code: "request_aborted",
|
|
4647
|
+
detail: "request closed before its body ended"
|
|
4648
|
+
});
|
|
4649
|
+
lease?.release();
|
|
4650
|
+
});
|
|
4651
|
+
req.once("aborted", () => {
|
|
4652
|
+
record({
|
|
4653
|
+
by: "sidecar",
|
|
4654
|
+
code: "request_aborted",
|
|
4655
|
+
detail: "request body aborted"
|
|
4656
|
+
});
|
|
4657
|
+
res.destroy();
|
|
4658
|
+
});
|
|
4659
|
+
req.once("error", () => {
|
|
4660
|
+
record({
|
|
4661
|
+
by: "sidecar",
|
|
4662
|
+
code: "request_aborted",
|
|
4663
|
+
detail: "request body failed"
|
|
4664
|
+
});
|
|
4665
|
+
res.destroy();
|
|
4666
|
+
});
|
|
4667
|
+
if (!lease) {
|
|
4668
|
+
refuse(429, "request_limit", "too many in-flight requests");
|
|
4669
|
+
return;
|
|
4670
|
+
}
|
|
4671
|
+
req.on("data", (chunk) => {
|
|
4672
|
+
if (recorded) return;
|
|
4673
|
+
size += chunk.length;
|
|
4674
|
+
if (!buffered.append(chunk)) {
|
|
4675
|
+
refuse(429, "buffer_limit", "too many buffered request bytes", chunk);
|
|
4676
|
+
return;
|
|
4677
|
+
}
|
|
4678
|
+
});
|
|
4679
|
+
req.on("end", () => {
|
|
4680
|
+
if (recorded) return;
|
|
4681
|
+
record();
|
|
4475
4682
|
if (!url) {
|
|
4476
4683
|
res.writeHead(400, { "Content-Type": "text/plain" }).end("bad request");
|
|
4477
4684
|
return;
|
|
@@ -4542,23 +4749,30 @@ function upstreamUrl(baseUrl, path) {
|
|
|
4542
4749
|
target.search = request.search;
|
|
4543
4750
|
return target;
|
|
4544
4751
|
}
|
|
4545
|
-
function
|
|
4752
|
+
function refuseRequest(run2, method, path, res, status, code, detail) {
|
|
4546
4753
|
push(run2.modelCalls, {
|
|
4547
4754
|
method,
|
|
4548
4755
|
path,
|
|
4549
|
-
status
|
|
4756
|
+
status,
|
|
4550
4757
|
durationMs: 0,
|
|
4551
4758
|
requestBytes: 0,
|
|
4552
4759
|
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4553
4760
|
secrets: [],
|
|
4554
4761
|
error: {
|
|
4555
4762
|
by: "sidecar",
|
|
4556
|
-
code
|
|
4557
|
-
detail
|
|
4763
|
+
code,
|
|
4764
|
+
detail
|
|
4558
4765
|
}
|
|
4559
4766
|
});
|
|
4560
|
-
res.writeHead(
|
|
4561
|
-
|
|
4767
|
+
res.writeHead(status, {
|
|
4768
|
+
"Content-Type": "application/json",
|
|
4769
|
+
Connection: "close"
|
|
4770
|
+
});
|
|
4771
|
+
res.end(
|
|
4772
|
+
JSON.stringify({
|
|
4773
|
+
error: code === "path_not_allowed" ? "path not allowed" : detail
|
|
4774
|
+
})
|
|
4775
|
+
);
|
|
4562
4776
|
}
|
|
4563
4777
|
function createRelay(context) {
|
|
4564
4778
|
return createServer2((req, res) => {
|
|
@@ -4588,22 +4802,44 @@ function relay(context, req, res) {
|
|
|
4588
4802
|
const [runId, run2] = entry;
|
|
4589
4803
|
const method = req.method ?? "GET";
|
|
4590
4804
|
if (!isModelEndpoint(method, path)) {
|
|
4591
|
-
|
|
4805
|
+
refuseRequest(
|
|
4806
|
+
run2,
|
|
4807
|
+
method,
|
|
4808
|
+
path,
|
|
4809
|
+
res,
|
|
4810
|
+
403,
|
|
4811
|
+
"path_not_allowed",
|
|
4812
|
+
"the relay forwards only the model endpoints"
|
|
4813
|
+
);
|
|
4814
|
+
return;
|
|
4815
|
+
}
|
|
4816
|
+
const lease = context.state.requests.take(run2.ip);
|
|
4817
|
+
if (!lease) {
|
|
4818
|
+
refuseRequest(
|
|
4819
|
+
run2,
|
|
4820
|
+
method,
|
|
4821
|
+
path,
|
|
4822
|
+
res,
|
|
4823
|
+
429,
|
|
4824
|
+
"request_limit",
|
|
4825
|
+
"too many in-flight requests"
|
|
4826
|
+
);
|
|
4592
4827
|
return;
|
|
4593
4828
|
}
|
|
4829
|
+
res.once("close", () => lease.release());
|
|
4594
4830
|
const target = upstreamUrl(run2.modelProxy.baseUrl, path);
|
|
4595
4831
|
const send = target.protocol === "https:" ? httpsRequest : httpRequest;
|
|
4596
4832
|
const started = Date.now();
|
|
4597
|
-
const
|
|
4833
|
+
const buffered = new BodyBuffer(SCAN_LIMIT, (bytes) => lease.reserve(bytes));
|
|
4598
4834
|
let requestBytes = 0;
|
|
4599
4835
|
let status = 0;
|
|
4600
4836
|
let errorBody = "";
|
|
4601
4837
|
let error;
|
|
4602
4838
|
let recorded = false;
|
|
4603
|
-
const record = () => {
|
|
4839
|
+
const record = (extra) => {
|
|
4604
4840
|
if (recorded) return;
|
|
4605
4841
|
recorded = true;
|
|
4606
|
-
const body =
|
|
4842
|
+
const body = buffered.finish(extra).toString("utf8");
|
|
4607
4843
|
const text = [path, headerLines(headersOf(req)), body].join("\n");
|
|
4608
4844
|
if (!error && status >= 400) {
|
|
4609
4845
|
error = {
|
|
@@ -4624,10 +4860,10 @@ function relay(context, req, res) {
|
|
|
4624
4860
|
...error ? { error } : {}
|
|
4625
4861
|
});
|
|
4626
4862
|
};
|
|
4627
|
-
const fail = (code, detail) => {
|
|
4863
|
+
const fail = (code, detail, by = "modelProxy", extra) => {
|
|
4628
4864
|
if (recorded) return;
|
|
4629
4865
|
error = {
|
|
4630
|
-
by
|
|
4866
|
+
by,
|
|
4631
4867
|
code,
|
|
4632
4868
|
detail,
|
|
4633
4869
|
call: { url: target.href, durationMs: Date.now() - started }
|
|
@@ -4635,15 +4871,29 @@ function relay(context, req, res) {
|
|
|
4635
4871
|
if (res.headersSent) {
|
|
4636
4872
|
res.destroy();
|
|
4637
4873
|
} else {
|
|
4638
|
-
status = code === "timeout" ? 504 : 502;
|
|
4639
|
-
res.writeHead(status, {
|
|
4640
|
-
|
|
4874
|
+
status = code === "timeout" ? 504 : code === "request_timeout" ? 408 : code === "buffer_limit" ? 429 : 502;
|
|
4875
|
+
res.writeHead(status, {
|
|
4876
|
+
"Content-Type": "application/json",
|
|
4877
|
+
Connection: "close"
|
|
4878
|
+
});
|
|
4879
|
+
res.end(
|
|
4880
|
+
JSON.stringify({
|
|
4881
|
+
error: by === "sidecar" ? detail : "model proxy unreachable"
|
|
4882
|
+
})
|
|
4883
|
+
);
|
|
4641
4884
|
}
|
|
4642
|
-
|
|
4885
|
+
req.unpipe(out);
|
|
4886
|
+
req.pause();
|
|
4887
|
+
out.destroy();
|
|
4888
|
+
record(extra);
|
|
4643
4889
|
};
|
|
4644
4890
|
req.on("data", (chunk) => {
|
|
4645
|
-
if (
|
|
4891
|
+
if (recorded) return;
|
|
4646
4892
|
requestBytes += chunk.length;
|
|
4893
|
+
if (!buffered.append(chunk)) {
|
|
4894
|
+
fail("buffer_limit", "too many buffered request bytes", "sidecar", chunk);
|
|
4895
|
+
return;
|
|
4896
|
+
}
|
|
4647
4897
|
});
|
|
4648
4898
|
const out = send(
|
|
4649
4899
|
target,
|
|
@@ -4667,12 +4917,38 @@ function relay(context, req, res) {
|
|
|
4667
4917
|
);
|
|
4668
4918
|
out.on("timeout", () => {
|
|
4669
4919
|
fail("timeout", `model proxy sent nothing for ${context.idleTimeoutMs} ms`);
|
|
4670
|
-
out.destroy();
|
|
4671
4920
|
});
|
|
4672
4921
|
out.on("error", (cause) => fail("network_error", cause.message));
|
|
4922
|
+
const timer = setTimeout(
|
|
4923
|
+
() => fail("request_timeout", "request body timed out", "sidecar"),
|
|
4924
|
+
context.requestTimeoutMs
|
|
4925
|
+
);
|
|
4926
|
+
timer.unref();
|
|
4927
|
+
req.once("end", () => clearTimeout(timer));
|
|
4928
|
+
req.once(
|
|
4929
|
+
"aborted",
|
|
4930
|
+
() => fail("request_aborted", "request body aborted", "sidecar")
|
|
4931
|
+
);
|
|
4932
|
+
req.once(
|
|
4933
|
+
"error",
|
|
4934
|
+
() => fail("request_aborted", "request body failed", "sidecar")
|
|
4935
|
+
);
|
|
4673
4936
|
res.on("close", () => {
|
|
4674
|
-
|
|
4937
|
+
clearTimeout(timer);
|
|
4938
|
+
req.unpipe(out);
|
|
4939
|
+
req.pause();
|
|
4940
|
+
out.destroy();
|
|
4941
|
+
if (!error && (!req.complete || !res.writableFinished)) {
|
|
4942
|
+
error = {
|
|
4943
|
+
by: "sidecar",
|
|
4944
|
+
code: "request_aborted",
|
|
4945
|
+
detail: req.complete ? "response aborted" : "request body aborted",
|
|
4946
|
+
call: { url: target.href, status, durationMs: Date.now() - started }
|
|
4947
|
+
};
|
|
4948
|
+
}
|
|
4675
4949
|
record();
|
|
4950
|
+
if (!req.complete) req.destroy();
|
|
4951
|
+
lease.release();
|
|
4676
4952
|
});
|
|
4677
4953
|
req.pipe(out);
|
|
4678
4954
|
}
|
|
@@ -4777,37 +5053,90 @@ function createToolHook(context) {
|
|
|
4777
5053
|
at: new Date(started).toISOString(),
|
|
4778
5054
|
error: { by: "sidecar", code, detail: detail.slice(0, DETAIL_LIMIT) }
|
|
4779
5055
|
});
|
|
4780
|
-
sendJson(res, status, {});
|
|
5056
|
+
if (!res.destroyed) sendJson(res, status, {}, { Connection: "close" });
|
|
4781
5057
|
};
|
|
4782
|
-
|
|
5058
|
+
const lease = context.state.requests.take(source);
|
|
5059
|
+
if (!lease)
|
|
5060
|
+
return refuse(429, "request_limit", "too many in-flight requests");
|
|
5061
|
+
const controller = new AbortController();
|
|
5062
|
+
let handlerDone = false;
|
|
5063
|
+
let responseDone = false;
|
|
5064
|
+
let timedOut = false;
|
|
5065
|
+
const release = () => {
|
|
5066
|
+
if (handlerDone && responseDone) lease.release();
|
|
5067
|
+
};
|
|
5068
|
+
res.once("finish", () => {
|
|
5069
|
+
responseDone = true;
|
|
5070
|
+
release();
|
|
5071
|
+
});
|
|
5072
|
+
res.once("close", () => {
|
|
5073
|
+
responseDone = true;
|
|
5074
|
+
controller.abort();
|
|
5075
|
+
release();
|
|
5076
|
+
});
|
|
5077
|
+
req.once("aborted", () => {
|
|
5078
|
+
controller.abort();
|
|
5079
|
+
});
|
|
5080
|
+
req.once("error", () => {
|
|
5081
|
+
controller.abort();
|
|
5082
|
+
});
|
|
5083
|
+
const timer = setTimeout(() => {
|
|
5084
|
+
timedOut = true;
|
|
5085
|
+
req.destroy(new Error("request body timed out"));
|
|
5086
|
+
}, context.requestTimeoutMs);
|
|
5087
|
+
timer.unref();
|
|
5088
|
+
req.once("end", () => clearTimeout(timer));
|
|
4783
5089
|
try {
|
|
4784
|
-
|
|
4785
|
-
|
|
4786
|
-
|
|
4787
|
-
|
|
4788
|
-
|
|
4789
|
-
|
|
4790
|
-
|
|
4791
|
-
|
|
4792
|
-
|
|
4793
|
-
|
|
4794
|
-
|
|
4795
|
-
|
|
4796
|
-
{
|
|
4797
|
-
|
|
4798
|
-
|
|
4799
|
-
|
|
4800
|
-
|
|
4801
|
-
|
|
4802
|
-
|
|
4803
|
-
|
|
4804
|
-
|
|
4805
|
-
|
|
4806
|
-
|
|
4807
|
-
|
|
4808
|
-
|
|
4809
|
-
|
|
4810
|
-
|
|
5090
|
+
if (!lease.reserve(2 * BODY_LIMIT_BYTES)) {
|
|
5091
|
+
return refuse(429, "buffer_limit", "too many buffered request bytes");
|
|
5092
|
+
}
|
|
5093
|
+
let payload;
|
|
5094
|
+
try {
|
|
5095
|
+
payload = await readJson(req);
|
|
5096
|
+
} catch (error) {
|
|
5097
|
+
if (timedOut)
|
|
5098
|
+
return refuse(408, "request_timeout", "request body timed out");
|
|
5099
|
+
return error instanceof BodyTooLargeError ? refuse(413, "payload_too_large", messageOf(error)) : refuse(400, "invalid_payload", messageOf(error));
|
|
5100
|
+
}
|
|
5101
|
+
const hook = hookSchema.safeParse(payload);
|
|
5102
|
+
if (!hook.success) {
|
|
5103
|
+
return refuse(400, "invalid_payload", hook.error.message, payload);
|
|
5104
|
+
}
|
|
5105
|
+
const check = owned.toolCallCheck;
|
|
5106
|
+
const checked = check ? await askCheck(
|
|
5107
|
+
{
|
|
5108
|
+
...context,
|
|
5109
|
+
signal: AbortSignal.any([context.signal, controller.signal])
|
|
5110
|
+
},
|
|
5111
|
+
check,
|
|
5112
|
+
runId,
|
|
5113
|
+
payload
|
|
5114
|
+
) : { response: {} };
|
|
5115
|
+
push(
|
|
5116
|
+
owned.toolCalls,
|
|
5117
|
+
{
|
|
5118
|
+
toolUseId: hook.data.tool_use_id,
|
|
5119
|
+
tool: hook.data.tool_name,
|
|
5120
|
+
input: hook.data.tool_input,
|
|
5121
|
+
hookPayload: payload,
|
|
5122
|
+
response: checked.response,
|
|
5123
|
+
action: actionOf(checked.response),
|
|
5124
|
+
checked: check !== void 0,
|
|
5125
|
+
latencyMs: Date.now() - started,
|
|
5126
|
+
at: new Date(started).toISOString(),
|
|
5127
|
+
...checked.error ? { error: checked.error } : {}
|
|
5128
|
+
},
|
|
5129
|
+
findSecrets(
|
|
5130
|
+
String(JSON.stringify(hook.data.tool_input)),
|
|
5131
|
+
owned.secrets
|
|
5132
|
+
)
|
|
5133
|
+
);
|
|
5134
|
+
if (!res.destroyed) sendJson(res, 200, checked.response);
|
|
5135
|
+
} finally {
|
|
5136
|
+
clearTimeout(timer);
|
|
5137
|
+
handlerDone = true;
|
|
5138
|
+
release();
|
|
5139
|
+
}
|
|
4811
5140
|
});
|
|
4812
5141
|
}
|
|
4813
5142
|
|
|
@@ -4835,7 +5164,7 @@ var close = (server) => new Promise((resolve) => {
|
|
|
4835
5164
|
server.closeAllConnections();
|
|
4836
5165
|
});
|
|
4837
5166
|
async function startSidecar(options) {
|
|
4838
|
-
const state = new SidecarState();
|
|
5167
|
+
const state = new SidecarState(options.requestLimits);
|
|
4839
5168
|
const host = options.host ?? "0.0.0.0";
|
|
4840
5169
|
const trustTestSourceHeader = options.trustTestSourceHeader ?? false;
|
|
4841
5170
|
const { domain } = options;
|
|
@@ -4850,21 +5179,24 @@ async function startSidecar(options) {
|
|
|
4850
5179
|
Math.max(1, Math.floor(lease.ms / 4))
|
|
4851
5180
|
) : void 0;
|
|
4852
5181
|
leaseTimer?.unref();
|
|
4853
|
-
const
|
|
5182
|
+
const requestTimeoutMs = options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
5183
|
+
const web = { state, domain, trustTestSourceHeader, requestTimeoutMs };
|
|
4854
5184
|
const servers = {
|
|
4855
5185
|
http: createFakeHttp(web),
|
|
4856
5186
|
https: createFakeHttps(web, tls),
|
|
4857
5187
|
relay: createRelay({
|
|
4858
5188
|
state,
|
|
4859
5189
|
trustTestSourceHeader,
|
|
4860
|
-
idleTimeoutMs: options.upstreamIdleTimeoutMs ?? UPSTREAM_IDLE_TIMEOUT_MS
|
|
5190
|
+
idleTimeoutMs: options.upstreamIdleTimeoutMs ?? UPSTREAM_IDLE_TIMEOUT_MS,
|
|
5191
|
+
requestTimeoutMs
|
|
4861
5192
|
}),
|
|
4862
5193
|
hook: createToolHook({
|
|
4863
5194
|
state,
|
|
4864
5195
|
fetch: options.fetch ?? fetch,
|
|
4865
5196
|
signal: controller.signal,
|
|
4866
5197
|
trustTestSourceHeader,
|
|
4867
|
-
timeoutMs: options.checkTimeoutMs ?? CHECK_TIMEOUT_MS
|
|
5198
|
+
timeoutMs: options.checkTimeoutMs ?? CHECK_TIMEOUT_MS,
|
|
5199
|
+
requestTimeoutMs
|
|
4868
5200
|
}),
|
|
4869
5201
|
control: createControl({
|
|
4870
5202
|
state,
|
package/dist/types.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gatebolt/agent-redroom",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Test real AI coding agents against prompt injection and secret leaks in throwaway Docker containers.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "GateBolt",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"README.md"
|
|
39
39
|
],
|
|
40
40
|
"scripts": {
|
|
41
|
-
"build": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.build.json &&
|
|
41
|
+
"build": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.build.json && node scripts/build-sidecar.js",
|
|
42
42
|
"prepare": "npm run build",
|
|
43
43
|
"prepublishOnly": "npm run format:check && npm run check && npm run test:coverage",
|
|
44
44
|
"check": "eslint . && tsc --noEmit",
|