@gatebolt/agent-redroom 0.1.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.
Files changed (61) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +4 -0
  3. package/README.md +387 -0
  4. package/dist/agents/agent.d.ts +37 -0
  5. package/dist/agents/agent.js +1 -0
  6. package/dist/agents/claude-code.d.ts +39 -0
  7. package/dist/agents/claude-code.js +91 -0
  8. package/dist/agents/claude-stream.d.ts +2 -0
  9. package/dist/agents/claude-stream.js +64 -0
  10. package/dist/deadline.d.ts +1 -0
  11. package/dist/deadline.js +17 -0
  12. package/dist/docker/cli.d.ts +28 -0
  13. package/dist/docker/cli.js +102 -0
  14. package/dist/docker/plan.d.ts +42 -0
  15. package/dist/docker/plan.js +80 -0
  16. package/dist/docker/session.d.ts +14 -0
  17. package/dist/docker/session.js +118 -0
  18. package/dist/hooks.d.ts +5 -0
  19. package/dist/hooks.js +13 -0
  20. package/dist/index.d.ts +9 -0
  21. package/dist/index.js +9 -0
  22. package/dist/paths.d.ts +2 -0
  23. package/dist/paths.js +3 -0
  24. package/dist/planting.d.ts +6 -0
  25. package/dist/planting.js +57 -0
  26. package/dist/redact.d.ts +3 -0
  27. package/dist/redact.js +24 -0
  28. package/dist/redroom.d.ts +37 -0
  29. package/dist/redroom.js +438 -0
  30. package/dist/run.d.ts +39 -0
  31. package/dist/run.js +251 -0
  32. package/dist/secrets.d.ts +2 -0
  33. package/dist/secrets.js +44 -0
  34. package/dist/sidecar/config.d.ts +25 -0
  35. package/dist/sidecar/config.js +15 -0
  36. package/dist/sidecar/control.d.ts +83 -0
  37. package/dist/sidecar/control.js +92 -0
  38. package/dist/sidecar/dns.d.ts +10 -0
  39. package/dist/sidecar/dns.js +71 -0
  40. package/dist/sidecar/fake-web.d.ts +15 -0
  41. package/dist/sidecar/fake-web.js +87 -0
  42. package/dist/sidecar/http.d.ts +9 -0
  43. package/dist/sidecar/http.js +37 -0
  44. package/dist/sidecar/main.d.ts +1 -0
  45. package/dist/sidecar/main.js +22 -0
  46. package/dist/sidecar/relay.d.ts +9 -0
  47. package/dist/sidecar/relay.js +174 -0
  48. package/dist/sidecar/sidecar.d.ts +24 -0
  49. package/dist/sidecar/sidecar.js +106 -0
  50. package/dist/sidecar/state.d.ts +45 -0
  51. package/dist/sidecar/state.js +93 -0
  52. package/dist/sidecar/tool-hook.d.ts +13 -0
  53. package/dist/sidecar/tool-hook.js +147 -0
  54. package/dist/sidecar-bundle/sidecar.mjs +4929 -0
  55. package/dist/sightings.d.ts +10 -0
  56. package/dist/sightings.js +13 -0
  57. package/dist/types.d.ts +139 -0
  58. package/dist/types.js +32 -0
  59. package/images/agent/Dockerfile +18 -0
  60. package/images/sidecar/Dockerfile +10 -0
  61. package/package.json +73 -0
@@ -0,0 +1,57 @@
1
+ import {} from "./docker/cli.js";
2
+ import { isFakeHost } from "./sidecar/dns.js";
3
+ import {} from "./sidecar/state.js";
4
+ import { RedroomError } from "./types.js";
5
+ const HTML = "text/html; charset=utf-8";
6
+ const EXECUTABLE = 0o755;
7
+ const invalid = (code, detail) => new RedroomError(code, detail, "redroom");
8
+ export function servedPages(pages, domain) {
9
+ return Object.fromEntries(Object.entries(pages).map(([url, page]) => {
10
+ let parsed;
11
+ try {
12
+ parsed = new URL(url);
13
+ }
14
+ catch {
15
+ throw invalid("invalid_page", `${url} is not a URL`);
16
+ }
17
+ if (!["http:", "https:"].includes(parsed.protocol) ||
18
+ !isFakeHost(parsed.hostname, domain)) {
19
+ throw invalid("invalid_page", `${url} must be an http or https URL on ${domain} or a subdomain`);
20
+ }
21
+ const served = typeof page === "string"
22
+ ? { body: page, contentType: HTML, status: 200 }
23
+ : {
24
+ body: page.body,
25
+ contentType: page.contentType ?? HTML,
26
+ status: page.status ?? 200,
27
+ };
28
+ return [`${parsed.hostname}${parsed.pathname}${parsed.search}`, served];
29
+ }));
30
+ }
31
+ function entryOf(path, file, owner) {
32
+ if (typeof file === "string" || Buffer.isBuffer(file)) {
33
+ return { path, body: file, owner };
34
+ }
35
+ return {
36
+ path,
37
+ body: file.body,
38
+ owner,
39
+ ...(file.executable ? { mode: EXECUTABLE } : {}),
40
+ };
41
+ }
42
+ export function workspaceEntries(workspace, owner) {
43
+ return Object.entries(workspace).map(([path, file]) => {
44
+ if (path.startsWith("/")) {
45
+ throw invalid("invalid_file", `workspace path ${path} must be relative to the workspace`);
46
+ }
47
+ return entryOf(path, file, owner);
48
+ });
49
+ }
50
+ export function absoluteEntries(files, owner) {
51
+ return Object.entries(files).map(([path, file]) => {
52
+ if (!path.startsWith("/")) {
53
+ throw invalid("invalid_file", `file path ${path} must be absolute`);
54
+ }
55
+ return entryOf(path.slice(1), file, owner);
56
+ });
57
+ }
@@ -0,0 +1,3 @@
1
+ export declare const REDACTED = "[redacted]";
2
+ export declare function redactSecrets(text: string, secrets: readonly (string | undefined)[]): string;
3
+ export declare function redactDeep(value: unknown, redact: (text: string) => string): unknown;
package/dist/redact.js ADDED
@@ -0,0 +1,24 @@
1
+ export const REDACTED = "[redacted]";
2
+ export function redactSecrets(text, secrets) {
3
+ let redacted = text;
4
+ const longestFirst = secrets
5
+ .filter((secret) => Boolean(secret))
6
+ .sort((a, b) => b.length - a.length);
7
+ for (const secret of longestFirst) {
8
+ redacted = redacted.replaceAll(secret, REDACTED);
9
+ }
10
+ return redacted;
11
+ }
12
+ export function redactDeep(value, redact) {
13
+ if (typeof value === "string")
14
+ return redact(value);
15
+ if (Array.isArray(value))
16
+ return value.map((item) => redactDeep(item, redact));
17
+ if (typeof value === "object" && value !== null) {
18
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
19
+ key,
20
+ redactDeep(item, redact),
21
+ ]));
22
+ }
23
+ return value;
24
+ }
@@ -0,0 +1,37 @@
1
+ import { type Agent } from "./agents/agent.js";
2
+ import { type Docker } from "./docker/cli.js";
3
+ import { type Run } from "./run.js";
4
+ import { type Hit, type Limits, type ModelProxy, type RunOptions, type ToolCallCheck } from "./types.js";
5
+ export type RedroomOptions = {
6
+ agent: Agent;
7
+ modelProxy?: ModelProxy;
8
+ toolCallCheck?: ToolCallCheck;
9
+ parallel?: number;
10
+ limits?: Partial<Limits>;
11
+ fakeDomain?: string;
12
+ network?: string;
13
+ log?: (line: string) => void;
14
+ };
15
+ export interface Redroom {
16
+ readonly session: string;
17
+ readonly agentVersion: string;
18
+ readonly controlUrl: string;
19
+ startRun(options?: RunOptions): Promise<Run>;
20
+ unattributedHits(): Promise<Hit[]>;
21
+ stop(): Promise<void>;
22
+ }
23
+ export type RedroomDeps = {
24
+ docker: Docker;
25
+ fetch: typeof fetch;
26
+ sleep: (ms: number) => Promise<void>;
27
+ randomHex: (bytes: number) => string;
28
+ pickSubnet: () => string;
29
+ newRunId: () => string;
30
+ leaseRenewMs: number;
31
+ readyAttempts: number;
32
+ };
33
+ export declare const DEFAULT_DOMAIN = "redroom.test";
34
+ export declare const CONTROL_TIMEOUT_MS: number;
35
+ export declare const defaultDeps: () => RedroomDeps;
36
+ export declare function startRedroom(options: RedroomOptions): Promise<Redroom>;
37
+ export declare function createRedroom(options: RedroomOptions, deps: RedroomDeps): Promise<Redroom>;
@@ -0,0 +1,438 @@
1
+ import { randomBytes, randomInt, randomUUID } from "node:crypto";
2
+ import {} from "./agents/agent.js";
3
+ import { dockerCli, MAX_OUTPUT_MB, tarOf, withDefaultTimeouts, } from "./docker/cli.js";
4
+ import { AGENT_HOST, agentArgs, CA_DIR, CA_FILE, DEFAULT_LIMITS, GIT_INIT, sessionPlan, SIDECAR_HOST, SIDECAR_IMAGE, sidecarArgs, SYSTEM_CA_DIR, SYSTEM_CA_FILE, WORKSPACE, } from "./docker/plan.js";
5
+ import { createInternalNetwork, LEASE_MS, LEASE_RENEW_MS, networkIp, removeLeftovers, tearDownSession, } from "./docker/session.js";
6
+ import { withDeadline } from "./deadline.js";
7
+ import { headerValues, wiring } from "./hooks.js";
8
+ import { packagePath } from "./paths.js";
9
+ import { absoluteEntries, servedPages, workspaceEntries } from "./planting.js";
10
+ import { redactDeep, redactSecrets } from "./redact.js";
11
+ import { openRun } from "./run.js";
12
+ import { CONFIG_ENV, PORTS } from "./sidecar/config.js";
13
+ import { DETAIL_LIMIT, messageOf, RedroomError, } from "./types.js";
14
+ export const DEFAULT_DOMAIN = "redroom.test";
15
+ export const CONTROL_TIMEOUT_MS = 30 * 1000;
16
+ const DEFAULT_PARALLEL = 4;
17
+ const READY_DELAY_MS = 500;
18
+ const START_FAILED = "start_failed";
19
+ const RUN_START_FAILED = "run_start_failed";
20
+ const MAX_PARALLEL = 245;
21
+ // Docker 28 added the isolated gateway mode; older engines ignore or reject it.
22
+ const MIN_ENGINE_MAJOR = 28;
23
+ const DOMAIN = /^[a-z0-9-]+(\.[a-z0-9-]+)*$/;
24
+ export const defaultDeps = () => ({
25
+ docker: dockerCli(),
26
+ fetch: globalThis.fetch,
27
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
28
+ randomHex: (bytes) => randomBytes(bytes).toString("hex"),
29
+ pickSubnet: () => `10.${200 + randomInt(55)}.${randomInt(256)}`,
30
+ newRunId: () => randomUUID(),
31
+ leaseRenewMs: LEASE_RENEW_MS,
32
+ readyAttempts: 120,
33
+ });
34
+ function stoppedError() {
35
+ return new RedroomError("stopped", "this redroom has stopped", "redroom");
36
+ }
37
+ function slots(count) {
38
+ let free = count;
39
+ let closed = false;
40
+ const waiting = [];
41
+ return {
42
+ async take() {
43
+ if (closed)
44
+ throw stoppedError();
45
+ if (free > 0) {
46
+ free -= 1;
47
+ return;
48
+ }
49
+ await new Promise((resolve, reject) => waiting.push({ resolve, reject }));
50
+ },
51
+ release() {
52
+ const next = waiting.shift();
53
+ if (next)
54
+ next.resolve();
55
+ else
56
+ free += 1;
57
+ },
58
+ close() {
59
+ closed = true;
60
+ for (const next of waiting.splice(0))
61
+ next.reject(stoppedError());
62
+ },
63
+ };
64
+ }
65
+ function validate(options) {
66
+ const parallel = options.parallel ?? DEFAULT_PARALLEL;
67
+ if (!Number.isInteger(parallel) || parallel < 1 || parallel > MAX_PARALLEL) {
68
+ throw new RedroomError("invalid_option", `parallel must be a whole number from 1 to ${MAX_PARALLEL}, got ${parallel}`, "redroom");
69
+ }
70
+ const domain = options.fakeDomain ?? DEFAULT_DOMAIN;
71
+ if (!DOMAIN.test(domain)) {
72
+ throw new RedroomError("invalid_option", `fakeDomain must be a lowercase DNS name, got ${domain}`, "redroom");
73
+ }
74
+ return {
75
+ parallel,
76
+ domain,
77
+ limits: { ...DEFAULT_LIMITS, ...options.limits },
78
+ };
79
+ }
80
+ export async function startRedroom(options) {
81
+ return createRedroom(options, defaultDeps());
82
+ }
83
+ export async function createRedroom(options, deps) {
84
+ const { agent } = options;
85
+ const docker = withDefaultTimeouts(deps.docker);
86
+ const log = options.log ?? (() => undefined);
87
+ const { parallel, domain, limits } = validate(options);
88
+ const directModel = agent.defaultModelProxy();
89
+ const defaultModel = options.modelProxy ?? directModel;
90
+ const controlSecret = deps.randomHex(24);
91
+ const hidden = new Set([
92
+ controlSecret,
93
+ ...headerValues(defaultModel, directModel, options.toolCallCheck),
94
+ ]);
95
+ const conceal = (text) => redactSecrets(text, [...hidden]);
96
+ const redact = (text) => conceal(text).slice(-DETAIL_LIMIT);
97
+ async function must(code, args, dockerOptions) {
98
+ const result = await docker(args, dockerOptions);
99
+ const call = {
100
+ command: `docker ${args.slice(0, 2).join(" ")}`,
101
+ exitCode: result.exitCode,
102
+ };
103
+ if (result.outputTooLarge) {
104
+ throw new RedroomError("output_too_large", `docker ${args[0]} wrote more than ${MAX_OUTPUT_MB} MB of output`, "docker", call);
105
+ }
106
+ if (result.exitCode !== 0) {
107
+ throw new RedroomError(code, `docker ${args[0]} failed (${result.exitCode}): ${redact(result.stderr.trim())}`, "docker", call);
108
+ }
109
+ return result;
110
+ }
111
+ function fetchControl(base, path, init) {
112
+ const timeoutError = () => new RedroomError("sidecar_timeout", `${path} did not answer within ${CONTROL_TIMEOUT_MS} ms`, "sidecar", { url: path });
113
+ return withDeadline(CONTROL_TIMEOUT_MS, timeoutError, async (signal) => {
114
+ const response = await deps.fetch(`${base}${path}`, { ...init, signal });
115
+ return new Response(await response.arrayBuffer(), response);
116
+ });
117
+ }
118
+ const version = await docker(["version", "--format", "{{.Server.Version}}"]);
119
+ if (version.exitCode !== 0) {
120
+ throw new RedroomError("docker_unavailable", `the Docker engine did not answer: ${redact(version.stderr.trim())}`, "docker", { command: "docker version", exitCode: version.exitCode });
121
+ }
122
+ const engine = version.stdout.trim();
123
+ if (Number.parseInt(engine, 10) < MIN_ENGINE_MAJOR) {
124
+ throw new RedroomError("docker_unavailable", `Docker Engine ${MIN_ENGINE_MAJOR} or newer is required to keep agents off host services, found ${engine}`, "docker", { command: "docker version", exitCode: version.exitCode });
125
+ }
126
+ const leftovers = await removeLeftovers(docker);
127
+ if (leftovers.containers.length + leftovers.networks.length > 0) {
128
+ log(`agent-redroom: removed ${leftovers.containers.length} containers and ${leftovers.networks.length} networks left by stopped sessions`);
129
+ }
130
+ await must("image_build_failed", [
131
+ ...["build", "-t", agent.image.tag],
132
+ ...Object.entries(agent.image.buildArgs).flatMap(([key, value]) => [
133
+ "--build-arg",
134
+ `${key}=${value}`,
135
+ ]),
136
+ agent.image.context,
137
+ ]);
138
+ const sidecarBuild = await docker([
139
+ ...["build", "-t", SIDECAR_IMAGE],
140
+ ...["-f", packagePath("images", "sidecar", "Dockerfile")],
141
+ packagePath("dist", "sidecar-bundle"),
142
+ ]);
143
+ if (sidecarBuild.exitCode !== 0) {
144
+ throw new RedroomError("image_build_failed", `could not build ${SIDECAR_IMAGE}; build the package so dist/sidecar-bundle/sidecar.mjs exists (${redact(sidecarBuild.stderr.trim())})`, "docker", { command: "docker build", exitCode: sidecarBuild.exitCode });
145
+ }
146
+ const reported = await must("version_failed", [
147
+ ...["run", "--rm", "--network", "none", agent.image.tag],
148
+ ...agent.versionCommand(),
149
+ ]);
150
+ const agentVersion = reported.stdout.trim().split(/\s+/)[0] ?? "";
151
+ if (agentVersion !== agent.version) {
152
+ throw new RedroomError("version_mismatch", `${agent.image.tag} reports version ${agentVersion}, expected ${agent.version}`, "agent");
153
+ }
154
+ const session = deps.randomHex(4);
155
+ const plan = sessionPlan(session);
156
+ const sidecar = (path, init = {}) => {
157
+ const hasBody = init.body !== undefined;
158
+ return fetchControl(controlUrl, path, {
159
+ method: init.method ?? "GET",
160
+ headers: {
161
+ authorization: `Bearer ${controlSecret}`,
162
+ ...(hasBody ? { "content-type": "application/json" } : {}),
163
+ },
164
+ ...(hasBody ? { body: JSON.stringify(init.body) } : {}),
165
+ }).catch((error) => {
166
+ if (error instanceof RedroomError)
167
+ throw error;
168
+ throw new RedroomError("sidecar_unreachable", `${path}: ${redact(messageOf(error))}`, "sidecar", { url: path });
169
+ });
170
+ };
171
+ let controlUrl;
172
+ let caPem;
173
+ try {
174
+ await must(START_FAILED, [
175
+ "network",
176
+ "create",
177
+ ...plan.labels,
178
+ plan.egress,
179
+ ]);
180
+ controlUrl = await startSidecar(plan);
181
+ caPem = await readCa();
182
+ }
183
+ catch (error) {
184
+ await tearDownSession(docker, plan);
185
+ throw error;
186
+ }
187
+ async function readCa() {
188
+ const response = await sidecar("/ca");
189
+ if (!response.ok) {
190
+ throw new RedroomError("sidecar_failed", `sidecar answered ${response.status} for its CA`, "sidecar", { url: "/ca", status: response.status });
191
+ }
192
+ return response.text();
193
+ }
194
+ async function startSidecar(plan) {
195
+ const config = {
196
+ controlSecret,
197
+ domain,
198
+ leaseMs: LEASE_MS,
199
+ };
200
+ const env = { [CONFIG_ENV]: JSON.stringify(config) };
201
+ await must(START_FAILED, sidecarArgs(plan, env, !options.network), { env });
202
+ if (options.network) {
203
+ await must(START_FAILED, [
204
+ "network",
205
+ "connect",
206
+ options.network,
207
+ plan.sidecar,
208
+ ]);
209
+ }
210
+ await must(START_FAILED, ["start", plan.sidecar]);
211
+ const base = await controlBase(plan);
212
+ for (let attempt = 0; attempt < deps.readyAttempts; attempt += 1) {
213
+ const ready = await fetchControl(base, "/lease", {
214
+ headers: { authorization: `Bearer ${controlSecret}` },
215
+ })
216
+ .then((response) => response.ok)
217
+ .catch(() => false);
218
+ if (ready)
219
+ return base;
220
+ await deps.sleep(READY_DELAY_MS);
221
+ }
222
+ const logs = await docker(["logs", "--tail", "30", plan.sidecar]);
223
+ throw new RedroomError("sidecar_not_ready", `the sidecar did not answer at ${base}: ${redact(`${logs.stdout}${logs.stderr}`)}`, "sidecar", { url: `${base}/lease` });
224
+ }
225
+ async function controlBase(plan) {
226
+ if (options.network) {
227
+ const inspected = await must(START_FAILED, [
228
+ ...["inspect", "--format", "{{json .NetworkSettings.Networks}}"],
229
+ plan.sidecar,
230
+ ]);
231
+ const ip = networkIp(inspected.stdout, options.network);
232
+ if (!ip) {
233
+ throw new RedroomError(START_FAILED, `the sidecar has no address on network ${options.network}`, "docker");
234
+ }
235
+ return `http://${ip}:${PORTS.control}`;
236
+ }
237
+ const port = await must(START_FAILED, [
238
+ "port",
239
+ plan.sidecar,
240
+ `${PORTS.control}/tcp`,
241
+ ]);
242
+ const address = port.stdout
243
+ .split("\n")
244
+ .map((line) => line.trim())
245
+ .find((line) => line.startsWith("127.0.0.1:"));
246
+ if (!address) {
247
+ throw new RedroomError(START_FAILED, `the sidecar has no published control port: ${port.stdout.trim()}`, "docker");
248
+ }
249
+ return `http://${address}`;
250
+ }
251
+ const lease = setInterval(() => {
252
+ sidecar("/lease").catch((error) => {
253
+ log(`agent-redroom: lease renewal failed: ${redact(messageOf(error))}`);
254
+ });
255
+ }, deps.leaseRenewMs);
256
+ lease.unref();
257
+ const runSlots = slots(parallel);
258
+ const starting = new Set();
259
+ const unattributed = [];
260
+ let stopping = null;
261
+ async function startRun(runOptions = {}) {
262
+ if (stopping)
263
+ throw stoppedError();
264
+ const pages = servedPages(runOptions.pages ?? {}, domain);
265
+ const modelProxy = runOptions.modelProxy === undefined
266
+ ? defaultModel
267
+ : (runOptions.modelProxy ?? directModel);
268
+ if (!modelProxy) {
269
+ throw new RedroomError("missing_model_access", `${agent.name} needs a modelProxy or ANTHROPIC_API_KEY in the environment`, "redroom");
270
+ }
271
+ const toolCallCheck = runOptions.toolCallCheck === undefined
272
+ ? options.toolCallCheck
273
+ : (runOptions.toolCallCheck ?? undefined);
274
+ for (const value of headerValues(modelProxy, toolCallCheck)) {
275
+ hidden.add(value);
276
+ }
277
+ let workspaceTar;
278
+ let rootTar;
279
+ const id = deps.newRunId();
280
+ try {
281
+ workspaceTar = tarOf(workspaceEntries(runOptions.workspace ?? {}, agent.uid));
282
+ rootTar = tarOf([
283
+ ...absoluteEntries(runOptions.files ?? {}, agent.uid),
284
+ { path: `${CA_DIR.slice(1)}/${CA_FILE}`, body: caPem },
285
+ { path: `${SYSTEM_CA_DIR.slice(1)}/${SYSTEM_CA_FILE}`, body: caPem },
286
+ ], { dirs: false });
287
+ }
288
+ catch (error) {
289
+ if (error instanceof RedroomError)
290
+ throw error;
291
+ throw new RedroomError("invalid_file", messageOf(error), "redroom");
292
+ }
293
+ await runSlots.take();
294
+ if (stopping) {
295
+ runSlots.release();
296
+ throw stoppedError();
297
+ }
298
+ const secrets = runOptions.secrets ?? {};
299
+ const launch = async () => {
300
+ const name = plan.agent(id);
301
+ const network = plan.internal(id);
302
+ let subnet;
303
+ try {
304
+ subnet = await createInternalNetwork(docker, plan.labels, network, deps.pickSubnet, redact);
305
+ }
306
+ catch (error) {
307
+ runSlots.release();
308
+ throw error;
309
+ }
310
+ const ip = `${subnet}.${AGENT_HOST}`;
311
+ const sidecarIp = `${subnet}.${SIDECAR_HOST}`;
312
+ const wired = wiring(sidecarIp, id);
313
+ let registered = false;
314
+ let baseline = "";
315
+ let removed = false;
316
+ const remove = async () => {
317
+ if (removed)
318
+ return;
319
+ removed = true;
320
+ try {
321
+ await must("remove_failed", ["rm", "-f", name]);
322
+ }
323
+ finally {
324
+ if (registered) {
325
+ await sidecar(`/runs/${id}`, { method: "DELETE" }).catch(() => undefined);
326
+ }
327
+ try {
328
+ await docker(["network", "disconnect", network, plan.sidecar]);
329
+ await docker(["network", "rm", network]);
330
+ }
331
+ finally {
332
+ runSlots.release();
333
+ }
334
+ }
335
+ };
336
+ try {
337
+ await must(RUN_START_FAILED, [
338
+ ...["network", "connect", "--ip", sidecarIp, network, plan.sidecar],
339
+ ]);
340
+ const response = await sidecar("/runs", {
341
+ method: "POST",
342
+ body: { id, ip, pages, secrets, modelProxy, toolCallCheck },
343
+ });
344
+ if (!response.ok) {
345
+ throw new RedroomError(RUN_START_FAILED, `sidecar refused run ${id}: ${redact(await response.text())}`, "sidecar", { url: "/runs", status: response.status });
346
+ }
347
+ registered = true;
348
+ await must(RUN_START_FAILED, agentArgs(plan, {
349
+ name,
350
+ runId: id,
351
+ ip,
352
+ sidecarIp,
353
+ image: agent.image.tag,
354
+ limits,
355
+ env: agent.env(wired),
356
+ }));
357
+ await must(RUN_START_FAILED, ["cp", "-", `${name}:${WORKSPACE}`], {
358
+ input: workspaceTar,
359
+ });
360
+ await must(RUN_START_FAILED, ["cp", "-", `${name}:/`], {
361
+ input: rootTar,
362
+ });
363
+ const wiringFiles = agent
364
+ .rootFiles(wired)
365
+ .map((file) => ({ path: file.path.slice(1), body: file.body }));
366
+ await must(RUN_START_FAILED, ["cp", "-", `${name}:/`], {
367
+ input: tarOf(wiringFiles, { dirs: false }),
368
+ });
369
+ await must(RUN_START_FAILED, ["start", name]);
370
+ await must(RUN_START_FAILED, [
371
+ ...["exec", "-u", "root", name, "update-ca-certificates"],
372
+ ]);
373
+ await must(RUN_START_FAILED, [
374
+ ...["exec", "-u", agent.user, "-w", WORKSPACE, name],
375
+ ...["sh", "-c", GIT_INIT],
376
+ ]);
377
+ const head = await must(RUN_START_FAILED, [
378
+ ...["exec", "-u", agent.user, "-w", WORKSPACE, name],
379
+ ...["git", "rev-parse", "HEAD"],
380
+ ]);
381
+ baseline = head.stdout.trim();
382
+ if (stopping)
383
+ throw stoppedError();
384
+ }
385
+ catch (error) {
386
+ await remove().catch((removeError) => log(`agent-redroom: ${redact(messageOf(removeError))}`));
387
+ throw error instanceof RedroomError
388
+ ? error
389
+ : new RedroomError(RUN_START_FAILED, redact(messageOf(error)), "redroom");
390
+ }
391
+ return { name, network, ip, baseline, remove };
392
+ };
393
+ const launching = launch();
394
+ starting.add(launching);
395
+ const { name, network, ip, baseline, remove } = await launching.finally(() => starting.delete(launching));
396
+ return openRun({
397
+ docker,
398
+ agent,
399
+ agentVersion,
400
+ turnTimeoutMs: limits.turnTimeoutMs,
401
+ redact: conceal,
402
+ sidecar,
403
+ must,
404
+ remove,
405
+ }, { id, ip, name, network, secrets, baseline });
406
+ }
407
+ return {
408
+ session,
409
+ agentVersion,
410
+ controlUrl,
411
+ startRun,
412
+ async unattributedHits() {
413
+ const response = await sidecar("/hits/unattributed");
414
+ if (!response.ok) {
415
+ throw new RedroomError("sidecar_failed", `sidecar answered ${response.status} for unattributed hits`, "sidecar", { url: "/hits/unattributed", status: response.status });
416
+ }
417
+ const dropped = Number(response.headers.get("x-dropped") ?? "0");
418
+ if (dropped > 0) {
419
+ log(`agent-redroom: ${dropped} unattributed hits were dropped over the cap`);
420
+ }
421
+ const hits = redactDeep(await response.json(), conceal);
422
+ unattributed.push(...hits);
423
+ return [...unattributed];
424
+ },
425
+ stop() {
426
+ stopping ??= (async () => {
427
+ clearInterval(lease);
428
+ runSlots.close();
429
+ await Promise.allSettled(starting);
430
+ const problems = await tearDownSession(docker, plan);
431
+ for (const problem of problems) {
432
+ log(`agent-redroom: teardown: ${redact(problem)}`);
433
+ }
434
+ })();
435
+ return stopping;
436
+ },
437
+ };
438
+ }
package/dist/run.d.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { type Agent } from "./agents/agent.js";
2
+ import { type Docker, type DockerOptions, type DockerResult } from "./docker/cli.js";
3
+ import { type Hit, type ModelCall, type RunResult, type Secrets, type ToolCall, type Turn } from "./types.js";
4
+ export type ControlInit = {
5
+ method?: string;
6
+ body?: unknown;
7
+ };
8
+ export type RunDeps = {
9
+ docker: Docker;
10
+ agent: Agent;
11
+ agentVersion: string;
12
+ turnTimeoutMs: number;
13
+ redact: (text: string) => string;
14
+ sidecar: (path: string, init?: ControlInit) => Promise<Response>;
15
+ must: (code: string, args: string[], options?: DockerOptions) => Promise<DockerResult>;
16
+ remove: () => Promise<void>;
17
+ };
18
+ export interface Run {
19
+ readonly id: string;
20
+ readonly ip: string;
21
+ send(message: string): Promise<Turn>;
22
+ exec(command: string): Promise<{
23
+ exitCode: number;
24
+ output: string;
25
+ }>;
26
+ readFile(path: string): Promise<string | null>;
27
+ hits(): Promise<Hit[]>;
28
+ toolCalls(): Promise<ToolCall[]>;
29
+ modelCalls(): Promise<ModelCall[]>;
30
+ finish(): Promise<RunResult>;
31
+ }
32
+ export declare function openRun(deps: RunDeps, run: {
33
+ id: string;
34
+ ip: string;
35
+ name: string;
36
+ network: string;
37
+ secrets: Secrets;
38
+ baseline: string;
39
+ }): Run;