@agentswf/sandbox 0.0.0-stage → 0.1.0-beta.1
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/docker/Dockerfile +45 -0
- package/docker/proxy.js +45 -0
- package/docker/relay.js +36 -0
- package/package.json +34 -4
- package/src/docker/args.ts +65 -0
- package/src/docker/index.ts +865 -0
- package/src/git.ts +152 -0
- package/src/groups.ts +81 -0
- package/src/index.ts +3 -0
- package/src/once.ts +16 -0
- package/src/pane.ts +39 -0
- package/src/resolve.ts +352 -0
- package/src/seam.ts +214 -0
- package/src/secrets.ts +67 -0
- package/src/srt/index.ts +427 -0
- package/src/srt/profile.ts +199 -0
- package/src/testing/conformance.ts +437 -0
- package/src/testing/fake.ts +134 -0
- package/src/testing/index.ts +2 -0
- package/README.md +0 -3
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# The docker provider's default image (story 004): node 22 for pi, the proxy and the door's relay,
|
|
2
|
+
# bun for the bundled wf, git, the four harnesses, and Herdr. Its tag is a hash of this file, so
|
|
3
|
+
# every input is pinned here: a change to one is a new image, not a stale one under the old tag.
|
|
4
|
+
# The proxy and the relay are run from their sources beside this file, not copied in.
|
|
5
|
+
ARG BUN_VERSION=1.4.0
|
|
6
|
+
FROM oven/bun:${BUN_VERSION}-debian AS bun
|
|
7
|
+
|
|
8
|
+
FROM node:22.20.0-bookworm-slim
|
|
9
|
+
ARG CLAUDE_CODE_VERSION=2.1.283
|
|
10
|
+
ARG CODEX_VERSION=0.159.3
|
|
11
|
+
ARG PI_VERSION=0.87.1
|
|
12
|
+
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
|
|
13
|
+
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates git curl procps zsh \
|
|
14
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
15
|
+
# The Herdr the adapter drives (`HERDR_VERSION` in its herdr.ts), by release and checksum: a box
|
|
16
|
+
# whose Herdr differs is refused panes. The checksums are this release's; a new one needs its own.
|
|
17
|
+
ENV HERDR_VERSION=0.9.1
|
|
18
|
+
ARG TARGETARCH
|
|
19
|
+
RUN case "${TARGETARCH}" in \
|
|
20
|
+
amd64) arch=x86_64; sum=2a02fed16beb651ef006e1d43f048f652ca4dc58ad053cd2d44450563d5c54b7 ;; \
|
|
21
|
+
arm64) arch=aarch64; sum=f4ccf4de745f2cb9a39a983e9ba3703dad50ec2a58dea83026ceab721bbd8d9e ;; \
|
|
22
|
+
*) echo "no Herdr for ${TARGETARCH}" >&2; exit 1 ;; \
|
|
23
|
+
esac \
|
|
24
|
+
&& curl -fsSL -o /usr/local/bin/herdr \
|
|
25
|
+
"https://github.com/herdrdev/herdr/releases/download/v${HERDR_VERSION}/herdr-linux-${arch}" \
|
|
26
|
+
&& echo "${sum} /usr/local/bin/herdr" | sha256sum -c - \
|
|
27
|
+
&& chmod 755 /usr/local/bin/herdr
|
|
28
|
+
# cursor-agent, a release directory holding its own node, by version and checksum, as Herdr is.
|
|
29
|
+
ARG CURSOR_AGENT_VERSION=2026.10.01-14929f9
|
|
30
|
+
RUN case "${TARGETARCH}" in \
|
|
31
|
+
amd64) arch=x64; sum=ba9a855f8f813c91b9f2707127572d2dc9ae5a62818e1c36719625d0fb8bd452 ;; \
|
|
32
|
+
arm64) arch=arm64; sum=c31ef0ba6b827fdf8053919de57abae4a7bd71afefdf2cab061e276faac42b3a ;; \
|
|
33
|
+
*) echo "no cursor-agent for ${TARGETARCH}" >&2; exit 1 ;; \
|
|
34
|
+
esac \
|
|
35
|
+
&& curl -fsSL -o /tmp/cursor-agent.tgz \
|
|
36
|
+
"https://downloads.cursor.com/lab/${CURSOR_AGENT_VERSION}/linux/${arch}/agent-cli-package.tar.gz" \
|
|
37
|
+
&& echo "${sum} /tmp/cursor-agent.tgz" | sha256sum -c - \
|
|
38
|
+
&& mkdir -p /opt/cursor-agent \
|
|
39
|
+
&& tar --strip-components=1 -xzf /tmp/cursor-agent.tgz -C /opt/cursor-agent \
|
|
40
|
+
&& rm /tmp/cursor-agent.tgz \
|
|
41
|
+
&& ln -s /opt/cursor-agent/cursor-agent /usr/local/bin/cursor-agent
|
|
42
|
+
ENV SHELL=/bin/bash
|
|
43
|
+
RUN npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" "@openai/codex@${CODEX_VERSION}" \
|
|
44
|
+
"@earendil-works/pi-coding-agent@${PI_VERSION}" \
|
|
45
|
+
&& npm cache clean --force
|
package/docker/proxy.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// A CONNECT-only filtering proxy, run by `node -e` in a sandbox's proxy container (story 004, X8).
|
|
2
|
+
// The allowlist file is re-read on every request, so it grows as agents are admitted. Domain
|
|
3
|
+
// grammar: exact host, or `*.` and a name for its subdomains only. An address is never allowed.
|
|
4
|
+
const net = require("node:net"),
|
|
5
|
+
http = require("node:http"),
|
|
6
|
+
fs = require("node:fs");
|
|
7
|
+
const [list, port] = [process.argv[1], Number(process.argv[2] || 3128)];
|
|
8
|
+
// The sandbox's internal network is its first: listening there alone keeps other containers on
|
|
9
|
+
// the default bridge, which it joins later, from borrowing it.
|
|
10
|
+
const internal = Object.values(require("node:os").networkInterfaces())
|
|
11
|
+
.flat()
|
|
12
|
+
.find((address) => address && address.family === "IPv4" && !address.internal);
|
|
13
|
+
const allowed = (host) =>
|
|
14
|
+
fs
|
|
15
|
+
.readFileSync(list, "utf8")
|
|
16
|
+
.split("\n")
|
|
17
|
+
.map((l) => l.trim())
|
|
18
|
+
.filter(Boolean)
|
|
19
|
+
.some((d) => (d.startsWith("*.") ? host.endsWith(d.slice(1)) : host === d));
|
|
20
|
+
const server = http.createServer((req, res) => {
|
|
21
|
+
console.log("deny plain", req.url);
|
|
22
|
+
res.writeHead(403).end();
|
|
23
|
+
});
|
|
24
|
+
server.on("connect", (req, client, head) => {
|
|
25
|
+
const [raw, p] = req.url.split(":");
|
|
26
|
+
const host = raw.toLowerCase().replace(/\.$/, "");
|
|
27
|
+
// A client that resets a refused connection must not take the proxy down with it.
|
|
28
|
+
client.on("error", () => {});
|
|
29
|
+
// TLS on 443 only: an allowed name's other ports are not what was allowed.
|
|
30
|
+
if (net.isIP(host) || Number(p || 443) !== 443 || !allowed(host)) {
|
|
31
|
+
console.log("deny", req.url);
|
|
32
|
+
client.end("HTTP/1.1 403 Forbidden\r\n\r\n");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
console.log("allow", req.url);
|
|
36
|
+
const up = net.connect(Number(p || 443), host, () => {
|
|
37
|
+
client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
38
|
+
up.write(head);
|
|
39
|
+
up.pipe(client);
|
|
40
|
+
client.pipe(up);
|
|
41
|
+
});
|
|
42
|
+
up.on("error", () => client.destroy());
|
|
43
|
+
client.on("error", () => up.destroy());
|
|
44
|
+
});
|
|
45
|
+
server.listen(port, internal ? internal.address : "0.0.0.0");
|
package/docker/relay.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// The door's half in a box (story 004, X6): listen at the door's path; each connection's request
|
|
2
|
+
// goes out on stdout as one line {id, b64}, and its reply comes back on stdin the same way. The
|
|
3
|
+
// socket is the agent's to connect to, so it is made connectable by any uid in the box.
|
|
4
|
+
const net = require("node:net"),
|
|
5
|
+
fs = require("node:fs");
|
|
6
|
+
const path = process.argv[1];
|
|
7
|
+
try {
|
|
8
|
+
fs.unlinkSync(path);
|
|
9
|
+
} catch {}
|
|
10
|
+
const open = new Map();
|
|
11
|
+
let next = 0;
|
|
12
|
+
net
|
|
13
|
+
.createServer({ allowHalfOpen: true }, (c) => {
|
|
14
|
+
const id = next++,
|
|
15
|
+
chunks = [];
|
|
16
|
+
open.set(id, c);
|
|
17
|
+
c.on("data", (d) => chunks.push(d));
|
|
18
|
+
c.on("end", () =>
|
|
19
|
+
process.stdout.write(
|
|
20
|
+
`${JSON.stringify({ id, b64: Buffer.concat(chunks).toString("base64") })}\n`,
|
|
21
|
+
),
|
|
22
|
+
);
|
|
23
|
+
c.on("error", () => open.delete(id));
|
|
24
|
+
})
|
|
25
|
+
.listen(path, () => {
|
|
26
|
+
fs.chmodSync(path, 0o666);
|
|
27
|
+
process.stderr.write("ready\n");
|
|
28
|
+
});
|
|
29
|
+
const lines = require("node:readline").createInterface({ input: process.stdin });
|
|
30
|
+
lines.on("line", (line) => {
|
|
31
|
+
const { id, b64 } = JSON.parse(line);
|
|
32
|
+
const c = open.get(id);
|
|
33
|
+
open.delete(id);
|
|
34
|
+
c?.end(Buffer.from(b64, "base64"));
|
|
35
|
+
});
|
|
36
|
+
lines.on("close", () => process.exit(0));
|
package/package.json
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentswf/sandbox",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-beta.1",
|
|
4
|
+
"description": "agents.wf: the sandboxes a workflow opens, and their providers",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://github.com/romanlv/agentswf",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/romanlv/agentswf.git",
|
|
10
|
+
"directory": "packages/sandbox"
|
|
11
|
+
},
|
|
12
|
+
"type": "module",
|
|
13
|
+
"engines": {
|
|
14
|
+
"bun": ">=1.4.0"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./src/index.ts",
|
|
18
|
+
"./testing": "./src/testing/index.ts",
|
|
19
|
+
"./testing/fake": "./src/testing/fake.ts",
|
|
20
|
+
"./srt": "./src/srt/index.ts",
|
|
21
|
+
"./docker": "./src/docker/index.ts"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"src",
|
|
25
|
+
"!src/**/*.test.ts",
|
|
26
|
+
"!src/**/*.eval.ts",
|
|
27
|
+
"!src/**/fixtures",
|
|
28
|
+
"docker"
|
|
29
|
+
],
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@agentswf/contract": "0.1.0-beta.0"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { RECORD_LEADER } from "../groups";
|
|
2
|
+
import { writableIn } from "../resolve";
|
|
3
|
+
import type { ResolvedSandbox } from "../seam";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The box's mounts, at the host's paths: the working directory and `read` read-only, `write`
|
|
7
|
+
* writable, `homes/` and `quarantine/`, the gitdirs, read-only unless writable, and an empty tmpfs
|
|
8
|
+
* over each `hidden` path. The most specific path decides, so mounts go shortest first and a
|
|
9
|
+
* nested one lies over its parent. `guarded` names the protected gitdir paths that exist, each
|
|
10
|
+
* mounted read-only over a writable gitdir. `--mount`, not `-v`, which would make a missing source
|
|
11
|
+
* as root rather than refuse it.
|
|
12
|
+
*/
|
|
13
|
+
export function mountArgs(
|
|
14
|
+
spec: ResolvedSandbox<unknown>,
|
|
15
|
+
directory: string,
|
|
16
|
+
guarded: readonly string[],
|
|
17
|
+
): string[] {
|
|
18
|
+
const mounts = new Map<string, boolean | "empty">();
|
|
19
|
+
for (const path of [spec.cwd, ...spec.read, ...spec.write]) {
|
|
20
|
+
mounts.set(path, !writableIn(spec, path));
|
|
21
|
+
}
|
|
22
|
+
for (const gitdir of spec.gitdirs) {
|
|
23
|
+
if (!mounts.has(gitdir.path)) mounts.set(gitdir.path, !gitdir.writable);
|
|
24
|
+
}
|
|
25
|
+
for (const path of guarded) mounts.set(path, true);
|
|
26
|
+
mounts.set(`${directory}/homes`, false);
|
|
27
|
+
mounts.set(`${directory}/quarantine`, false);
|
|
28
|
+
for (const path of spec.hidden) mounts.set(path, "empty");
|
|
29
|
+
return [...mounts]
|
|
30
|
+
.sort(([a], [b]) => a.length - b.length || a.localeCompare(b))
|
|
31
|
+
.flatMap(([path, readOnly]) => {
|
|
32
|
+
if (path.includes(",")) throw new Error(`docker: cannot mount ${path}, which holds a comma`);
|
|
33
|
+
if (readOnly === "empty") return ["--mount", `type=tmpfs,target=${path}`];
|
|
34
|
+
return ["--mount", `type=bind,source=${path},target=${path}${readOnly ? ",readonly" : ""}`];
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The `docker exec` that runs `argv` for an agent: in its working directory, recording its pid in
|
|
40
|
+
* `pidFile` (the exec'd process leads its own group, as runc makes it a session leader), with its
|
|
41
|
+
* variables set by `-e`. A secret passes by name alone, its value in the client's environment, so
|
|
42
|
+
* it never shows in the host's `ps`.
|
|
43
|
+
*/
|
|
44
|
+
export function execArgs(options: {
|
|
45
|
+
box: string;
|
|
46
|
+
cwd: string | undefined;
|
|
47
|
+
env: Readonly<Record<string, string>>;
|
|
48
|
+
secrets: readonly string[];
|
|
49
|
+
pidFile: string;
|
|
50
|
+
argv: readonly string[];
|
|
51
|
+
}): string[] {
|
|
52
|
+
return [
|
|
53
|
+
"exec",
|
|
54
|
+
"-i",
|
|
55
|
+
...(options.cwd ? ["-w", options.cwd] : []),
|
|
56
|
+
...Object.entries(options.env).flatMap(([name, value]) => ["-e", `${name}=${value}`]),
|
|
57
|
+
...options.secrets.flatMap((name) => ["-e", name]),
|
|
58
|
+
options.box,
|
|
59
|
+
"sh",
|
|
60
|
+
"-c",
|
|
61
|
+
RECORD_LEADER,
|
|
62
|
+
options.pidFile,
|
|
63
|
+
...options.argv,
|
|
64
|
+
];
|
|
65
|
+
}
|