@maestro-js/agent-container 1.0.0-alpha.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 ADDED
@@ -0,0 +1,233 @@
1
+ # @maestro-js/agent-container
2
+
3
+ Docker-based isolated agent execution environment with patch-based sync.
4
+
5
+ Creates sandboxed Docker containers pre-loaded with Node.js 22, pnpm, Claude CLI, Playwright, git, and zsh. Your repo is copied in (excluding `node_modules`), dependencies are installed, and a `maestro-base` git tag marks the starting point so `pull` can extract only the agent's changes as a patch.
6
+
7
+ This is a **standalone utility** — it does not use the service-registry provider pattern.
8
+
9
+ ## Quick Setup
10
+
11
+ ```ts
12
+ import { AgentContainer } from '@maestro-js/agent-container'
13
+
14
+ const container = await AgentContainer.create({
15
+ repoPath: '/path/to/my-project',
16
+ env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! }
17
+ })
18
+
19
+ await container.exec('npx vitest run')
20
+
21
+ const { applied, conflicts } = await container.pull() // patches container changes onto host working tree
22
+ await container.stop()
23
+ ```
24
+
25
+ ## Container Lifecycle
26
+
27
+ ```
28
+ create(config)
29
+
30
+ ├─ Build/cache Docker image (base + user)
31
+ ├─ Start container
32
+ ├─ Copy repo files (via git ls-files, excluding node_modules)
33
+ ├─ Run pnpm install
34
+ ├─ git init + commit + tag "maestro-base"
35
+
36
+
37
+ Container ready
38
+
39
+ ├─ exec(command) Run commands programmatically
40
+ ├─ shell(name) Open interactive bash session
41
+ ├─ open(name) Attach VS Code to container
42
+
43
+
44
+ pull()
45
+
46
+ ├─ Stage pending changes in container (git add -A)
47
+ ├─ git diff --binary maestro-base → patch file
48
+ ├─ git apply --3way onto host working tree
49
+
50
+
51
+ stop()
52
+ └─ docker rm -f
53
+ ```
54
+
55
+ ## Configuration
56
+
57
+ Options for `AgentContainer.create()`. Only `repoPath` is required.
58
+
59
+ | Field | Type | Default | Description |
60
+ | ------------ | ------------------------- | ---------------- | ------------------------------------------------------------------------------- |
61
+ | `repoPath` | `string` | — | Absolute path to the host repo to copy into the container |
62
+ | `name` | `string?` | auto-incremented | Custom container name suffix (e.g. `'my-task'` → `maestro-agent-my-task`) |
63
+ | `dockerfile` | `string?` | built-in | Custom Dockerfile content. Must use `ARG MAESTRO_BASE` / `FROM ${MAESTRO_BASE}` |
64
+ | `workDir` | `string?` | `'/workspace'` | Working directory inside the container |
65
+ | `env` | `Record<string, string>?` | `{}` | Extra environment variables passed to `docker run -e` |
66
+ | `memory` | `string?` | `'8gb'` | Docker memory limit |
67
+ | `cpus` | `number?` | `2` | Docker CPU limit |
68
+ | `volume` | `string?` | per-repo hash | Docker volume name for `/home/node`. Defaults to `maestro-claude-<sha256>` |
69
+
70
+ ## API Reference
71
+
72
+ ### `create(config)`
73
+
74
+ ```ts
75
+ function create(config: AgentContainer.Config): Promise<AgentContainer.Container>
76
+ ```
77
+
78
+ Creates a new isolated container from the given repo. Builds/caches the Docker image, copies repo files, installs dependencies, and initializes git.
79
+
80
+ ```ts
81
+ const container = await AgentContainer.create({
82
+ repoPath: '/path/to/repo',
83
+ name: 'feature-work',
84
+ env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! }
85
+ })
86
+ ```
87
+
88
+ ### `list()`
89
+
90
+ ```ts
91
+ function list(): Promise<AgentContainer.ContainerInfo[]>
92
+ ```
93
+
94
+ Lists all running agent containers (filtered by the `maestro-agent` prefix).
95
+
96
+ ```ts
97
+ const containers = await AgentContainer.list()
98
+ // [{ id: 'abc123', name: 'maestro-agent-1', status: 'Up 2 hours' }]
99
+ ```
100
+
101
+ ### `kill(name)`
102
+
103
+ ```ts
104
+ function kill(name: string): Promise<void>
105
+ ```
106
+
107
+ Force-removes a container by name (`docker rm -f`).
108
+
109
+ ```ts
110
+ await AgentContainer.kill('maestro-agent-1')
111
+ ```
112
+
113
+ ### `pull(name, repoPath, workDir?)`
114
+
115
+ ```ts
116
+ function pull(name: string, repoPath: string, workDir?: string): Promise<AgentContainer.PatchResult>
117
+ ```
118
+
119
+ Patches container changes onto the host working tree. Stages all container changes, generates a binary diff since `maestro-base`, and applies it to the host via `git apply --3way`. Host changes are temporarily staged so three-way merge can handle conflicts.
120
+
121
+ ```ts
122
+ const { applied, conflicts } = await AgentContainer.pull(
123
+ 'maestro-agent-1',
124
+ '/path/to/repo'
125
+ )
126
+ if (applied && !conflicts) console.log('Clean patch applied')
127
+ if (conflicts) console.log('Patch applied with conflict markers')
128
+ ```
129
+
130
+ ### `shell(name)`
131
+
132
+ ```ts
133
+ function shell(name: string): Promise<void>
134
+ ```
135
+
136
+ Opens an interactive bash session inside the container. Inherits stdio — not suitable for programmatic use.
137
+
138
+ ### `open(name, workDir?)`
139
+
140
+ ```ts
141
+ function open(name: string, workDir?: string): Promise<void>
142
+ ```
143
+
144
+ Opens VS Code attached to the container via the `vscode-remote://attached-container` URI scheme.
145
+
146
+ ## Container Handle
147
+
148
+ `AgentContainer.create()` returns a `Container` object scoped to that container:
149
+
150
+ ```ts
151
+ interface Container {
152
+ id: string // Container name (e.g. 'maestro-agent-1')
153
+ exec(command: string): Promise<ExecResult> // Run a command
154
+ stop(): Promise<void> // Force-remove container
155
+ pull(): Promise<PatchResult> // Patch container changes onto host
156
+ }
157
+ ```
158
+
159
+ The handle methods use the config from `create()`, so you don't need to pass `repoPath` or `workDir` again:
160
+
161
+ ```ts
162
+ const container = await AgentContainer.create({ repoPath: '.' })
163
+ const { stdout } = await container.exec('node --version')
164
+ const { applied } = await container.pull() // false if no changes
165
+ await container.stop()
166
+ ```
167
+
168
+ ### `PatchResult`
169
+
170
+ ```ts
171
+ interface PatchResult {
172
+ applied: boolean // true if a non-empty patch was applied
173
+ conflicts: boolean // true if patch applied with merge conflict markers
174
+ }
175
+ ```
176
+
177
+ ## Common Patterns
178
+
179
+ ### Agent Task Runner
180
+
181
+ ```ts
182
+ const container = await AgentContainer.create({
183
+ repoPath: '/path/to/repo',
184
+ env: { ANTHROPIC_API_KEY: key }
185
+ })
186
+
187
+ try {
188
+ await container.exec('claude "Fix the failing tests"')
189
+ const { applied, conflicts } = await container.pull()
190
+ if (applied && !conflicts) console.log('Agent changes applied cleanly')
191
+ } finally {
192
+ await container.stop()
193
+ }
194
+ ```
195
+
196
+ ### Multiple Agents in Parallel
197
+
198
+ ```ts
199
+ const tasks = ['Fix auth bug', 'Add input validation', 'Write tests']
200
+
201
+ const results = await Promise.all(
202
+ tasks.map(async (task) => {
203
+ const container = await AgentContainer.create({
204
+ repoPath: '/path/to/repo',
205
+ env: { ANTHROPIC_API_KEY: key }
206
+ })
207
+ try {
208
+ await container.exec(`claude "${task}"`)
209
+ return await container.pull()
210
+ } finally {
211
+ await container.stop()
212
+ }
213
+ })
214
+ )
215
+ ```
216
+
217
+ ## Warnings and Gotchas
218
+
219
+ > **Warning:** Docker must be running — all operations shell out to `docker` and will fail if the daemon is not available.
220
+
221
+ > **Warning:** `pull()` applies patches directly to the host working tree — not to a branch. Stage or commit host changes before pulling to avoid losing work.
222
+
223
+ > **Warning:** `pull()` temporarily runs `git add -A` on the host repo (then `git reset` after) so `--3way` can resolve conflicts. Unstaged host changes are preserved but briefly staged during the operation.
224
+
225
+ > **Note:** `pnpm install` runs during `create()` — can be slow on first run or large repos. Dependencies are installed inside the container, not on the host.
226
+
227
+ > **Note:** `node_modules` is excluded from copy — uses `git ls-files` to enumerate tracked/untracked files, so gitignored paths are never copied.
228
+
229
+ > **Note:** `shell()` and `open()` inherit stdio — they are interactive and block the process. Not suitable for programmatic use.
230
+
231
+ > **Note:** Image builds are cached by content hash — Dockerfile changes trigger rebuilds. The base image and user image are tagged with a SHA-256 prefix of their Dockerfile content.
232
+
233
+ > **Note:** `/home/node` is a Docker volume — a per-repo volume (e.g. `maestro-claude-a1b2c3d4`) persists Claude CLI config and zsh history across containers for the same repo. Pass `volume` to `create()` to override.
@@ -0,0 +1,102 @@
1
+ declare const AgentContainer: {
2
+ create: typeof create;
3
+ list: typeof list;
4
+ kill: typeof kill;
5
+ shell: typeof shell;
6
+ open: typeof open;
7
+ pull: typeof pull;
8
+ };
9
+ /**
10
+ * Docker-based isolated agent execution environment with patch-based sync.
11
+ *
12
+ * Creates sandboxed Docker containers pre-loaded with Node.js 22, pnpm,
13
+ * Claude CLI, Playwright, git, and zsh. Your repo is copied in (excluding
14
+ * `node_modules`), dependencies are installed, and a `maestro-base` git tag
15
+ * marks the starting point so {@link pull} can extract only the agent's changes
16
+ * as a patch.
17
+ *
18
+ * This is a standalone utility — it does **not** use the service-registry
19
+ * provider pattern. Import `AgentContainer` and call methods directly.
20
+ *
21
+ * @example
22
+ * ```ts
23
+ * import { AgentContainer } from '@maestro-js/agent-container'
24
+ *
25
+ * const container = await AgentContainer.create({ repoPath: '/path/to/repo' })
26
+ * await container.exec('npx vitest run')
27
+ * const { applied } = await container.pull() // patches container changes onto host
28
+ * await container.stop()
29
+ * ```
30
+ */
31
+ declare namespace AgentContainer {
32
+ /** Options for {@link AgentContainer.create}. Only `repoPath` is required. */
33
+ interface Config {
34
+ /** Custom container name suffix. Auto-incremented if omitted. */
35
+ name?: string;
36
+ /** Absolute path to the host repo to copy into the container. */
37
+ repoPath: string;
38
+ /** Custom Dockerfile content. Defaults to the built-in image with Node.js 22, pnpm, Claude CLI, and Playwright. */
39
+ dockerfile?: string;
40
+ /** Working directory inside the container. @default '/workspace' */
41
+ workDir?: string;
42
+ /** Extra environment variables passed to `docker run -e`. */
43
+ env?: Record<string, string>;
44
+ /** Docker memory limit (e.g. `'8gb'`). @default '8gb' */
45
+ memory?: string;
46
+ /** Docker CPU limit. @default 2 */
47
+ cpus?: number;
48
+ /** Docker volume name for persisting `/home/node` (Claude CLI config, zsh history). Defaults to a per-repo name based on `repoPath`. */
49
+ volume?: string;
50
+ }
51
+ /** Result of running a command inside the container via {@link Container.exec}. */
52
+ interface ExecResult {
53
+ stdout: string;
54
+ stderr: string;
55
+ exitCode: number;
56
+ }
57
+ /**
58
+ * Handle to a running agent container returned by {@link AgentContainer.create}.
59
+ *
60
+ * @example
61
+ * ```ts
62
+ * const container = await AgentContainer.create({ repoPath: '.' })
63
+ * const result = await container.exec('echo hello')
64
+ * await container.pull()
65
+ * await container.stop()
66
+ * ```
67
+ */
68
+ interface Container {
69
+ /** Docker container name (e.g. `maestro-agent-1`). */
70
+ id: string;
71
+ /** Runs a shell command inside the container and returns stdout, stderr, and exit code. */
72
+ exec(command: string): Promise<ExecResult>;
73
+ /** Force-removes the container (`docker rm -f`). */
74
+ stop(): Promise<void>;
75
+ /** Patches container changes (since `maestro-base`) onto the host working tree. */
76
+ pull(): Promise<PatchResult>;
77
+ }
78
+ /** Result of a {@link Container.pull} call. */
79
+ interface PatchResult {
80
+ /** `true` if a non-empty patch was applied, `false` if there was nothing to apply. */
81
+ applied: boolean;
82
+ /** `true` if the patch applied with merge conflicts (conflict markers left in files). */
83
+ conflicts: boolean;
84
+ }
85
+ /** Metadata for a running agent container, returned by {@link AgentContainer.list}. */
86
+ interface ContainerInfo {
87
+ /** Docker container ID (short hash). */
88
+ id: string;
89
+ /** Container name (e.g. `maestro-agent-1`). */
90
+ name: string;
91
+ /** Docker status string (e.g. `Up 2 hours`). */
92
+ status: string;
93
+ }
94
+ }
95
+ declare function create(config: AgentContainer.Config): Promise<AgentContainer.Container>;
96
+ declare function list(): Promise<AgentContainer.ContainerInfo[]>;
97
+ declare function kill(name: string): Promise<void>;
98
+ declare function shell(name: string): Promise<void>;
99
+ declare function open(name: string, workDir?: string): Promise<void>;
100
+ declare function pull(name: string, repoPath: string, workDir?: string): Promise<AgentContainer.PatchResult>;
101
+
102
+ export { AgentContainer };
package/dist/index.js ADDED
@@ -0,0 +1,318 @@
1
+ // src/index.ts
2
+ import { spawn } from "child_process";
3
+ import { mkdtemp, writeFile, rm } from "fs/promises";
4
+ import { tmpdir } from "os";
5
+ import { join } from "path";
6
+
7
+ // src/docker.ts
8
+ var BASE_DOCKERFILE = `
9
+ FROM node:22
10
+
11
+ ARG TZ
12
+ ENV TZ="$TZ"
13
+
14
+ # Install basic development tools and iptables/ipset
15
+ RUN apt-get update && apt-get install -y --no-install-recommends less git procps sudo fzf zsh man-db unzip gnupg2 gh iptables ipset iproute2 dnsutils aggregate jq nano vim && apt-get clean && rm -rf /var/lib/apt/lists/*
16
+
17
+ # Persist bash history.
18
+ RUN SNIPPET="export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" && mkdir /commandhistory && touch /commandhistory/.bash_history
19
+
20
+ # Set \`DEVCONTAINER\` environment variable to help with orientation
21
+ ENV DEVCONTAINER=true
22
+
23
+ WORKDIR /workspace
24
+
25
+ ARG GIT_DELTA_VERSION=0.18.2
26
+ RUN ARCH=$(dpkg --print-architecture) && wget "https://github.com/dandavison/delta/releases/download/\${GIT_DELTA_VERSION}/git-delta_\${GIT_DELTA_VERSION}_\${ARCH}.deb" && sudo dpkg -i "git-delta_\${GIT_DELTA_VERSION}_\${ARCH}.deb" && rm "git-delta_\${GIT_DELTA_VERSION}_\${ARCH}.deb"
27
+
28
+ # Install global packages
29
+ ENV NPM_CONFIG_PREFIX=/usr/local
30
+
31
+ # Set the default shell to zsh rather than sh
32
+ ENV SHELL=/bin/zsh
33
+
34
+ # Set the default editor and visual
35
+ ENV EDITOR=nano
36
+ ENV VISUAL=nano
37
+
38
+ # Default powerline10k theme
39
+ ARG ZSH_IN_DOCKER_VERSION=1.2.0
40
+ RUN sh -c "$(wget -O- https://github.com/deluan/zsh-in-docker/releases/download/v\${ZSH_IN_DOCKER_VERSION}/zsh-in-docker.sh)" -- -p git -p fzf -a "source /usr/share/doc/fzf/examples/key-bindings.zsh" -a "source /usr/share/doc/fzf/examples/completion.zsh" -a "export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" -x
41
+
42
+ # Save default zshrc outside /root/ so it can be seeded into the volume on first use
43
+ RUN cp /root/.zshrc /usr/local/share/zshrc.default
44
+
45
+ # Give node user ownership of dirs it needs at runtime
46
+ RUN chown -R node:node /usr/local /workspace /commandhistory
47
+
48
+ USER node
49
+ `;
50
+ var DEFAULT_DOCKERFILE = `ARG MAESTRO_BASE
51
+ FROM \${MAESTRO_BASE}
52
+
53
+ # Install pnpm
54
+ ARG PNPM_VERSION
55
+ RUN npm install -g pnpm@\${PNPM_VERSION}
56
+
57
+ # Playwright needs root for apt-get system deps
58
+ USER root
59
+ RUN npx -y playwright@1.58.2 install --with-deps
60
+ USER node
61
+
62
+ # Install Claude
63
+ RUN npm install -g @anthropic-ai/claude-code@latest
64
+ `;
65
+
66
+ // src/helpers.ts
67
+ import { createHash } from "crypto";
68
+ var CONTAINER_PREFIX = "maestro-agent";
69
+ function getImageTag(prefix, content) {
70
+ const hash = createHash("sha256").update(content).digest("hex").slice(0, 8);
71
+ return `${prefix}:${hash}`;
72
+ }
73
+ function parseContainerList(stdout) {
74
+ if (!stdout.trim()) return [];
75
+ return stdout.trim().split("\n").map((line) => {
76
+ const [id, name, status] = line.split(" ");
77
+ return { id: id ?? "", name: name ?? "", status: status ?? "" };
78
+ });
79
+ }
80
+ function resolveContainerName(customName, existing, prefix) {
81
+ if (customName !== void 0) {
82
+ const name = `${prefix}-${customName}`;
83
+ if (existing.some((c) => c.name === name)) {
84
+ throw new Error(`Agent container name "${customName}" is already in use`);
85
+ }
86
+ return name;
87
+ }
88
+ const usedNumbers = existing.map((c) => parseInt(c.name.replace(`${prefix}-`, ""), 10)).filter(Number.isFinite);
89
+ const next = usedNumbers.length > 0 ? Math.max(...usedNumbers) + 1 : 1;
90
+ return `${prefix}-${next}`;
91
+ }
92
+ function getVolumeName(volume, repoPath) {
93
+ if (volume) return volume;
94
+ const hash = createHash("sha256").update(repoPath).digest("hex").slice(0, 8);
95
+ return `maestro-claude-${hash}`;
96
+ }
97
+ function buildVscodeUri(containerName, workDir) {
98
+ const hex = Buffer.from(JSON.stringify({ containerName })).toString("hex");
99
+ return `vscode-remote://attached-container+${hex}${workDir}`;
100
+ }
101
+
102
+ // src/index.ts
103
+ var BASE_IMAGE_TAG = getImageTag("maestro-agent-base", BASE_DOCKERFILE);
104
+ var AgentContainer = { create, list, kill, shell, open, pull };
105
+ async function create(config) {
106
+ const {
107
+ name: customName,
108
+ repoPath,
109
+ dockerfile,
110
+ workDir = "/workspace",
111
+ env = {},
112
+ memory = "8gb",
113
+ cpus = 2,
114
+ volume
115
+ } = config;
116
+ const dockerfileContent = dockerfile ?? DEFAULT_DOCKERFILE;
117
+ const userImageTag = await ensureImage(dockerfileContent);
118
+ const existing = await list();
119
+ const name = resolveContainerName(customName, existing, CONTAINER_PREFIX);
120
+ const envFlags = Object.entries(env).flatMap(([k, v]) => ["-e", `${k}=${v}`]);
121
+ const memoryFlags = memory ? ["--memory", memory] : [];
122
+ const cpuFlags = cpus !== void 0 ? ["--cpus", String(cpus)] : [];
123
+ const runResult = await runDocker([
124
+ "run",
125
+ "-d",
126
+ "--name",
127
+ name,
128
+ "-v",
129
+ `${getVolumeName(volume, repoPath)}:/home/node`,
130
+ ...envFlags,
131
+ ...memoryFlags,
132
+ ...cpuFlags,
133
+ userImageTag,
134
+ "tail",
135
+ "-f",
136
+ "/dev/null"
137
+ ]);
138
+ if (runResult.exitCode !== 0) {
139
+ throw new Error(runResult.stderr || `docker run failed with exit code ${runResult.exitCode}`);
140
+ }
141
+ await exec(name, "/home/node", "test -f /home/node/.zshrc || cp /usr/local/share/zshrc.default /home/node/.zshrc");
142
+ const cpResult = await copyExcludingNodeModules(repoPath, name, workDir);
143
+ if (cpResult.exitCode !== 0) {
144
+ await runDocker(["rm", "-f", name]);
145
+ throw new Error(cpResult.stderr || `docker cp failed with exit code ${cpResult.exitCode}`);
146
+ }
147
+ await runDocker(["exec", "-u", "root", name, "chown", "-R", "node:node", workDir]);
148
+ const installResult = await exec(name, workDir, "pnpm install");
149
+ if (installResult.exitCode !== 0) {
150
+ throw new Error(installResult.stderr || `pnpm install failed with exit code ${installResult.exitCode}`);
151
+ }
152
+ const headResult = await runGit(["-C", repoPath, "rev-parse", "HEAD"]);
153
+ const hostBase = headResult.stdout.trim();
154
+ const initResult = await exec(
155
+ name,
156
+ workDir,
157
+ `git init && echo '._*' >> .git/info/exclude && git add -A && git -c user.email="agent@maestro" -c user.name="Maestro Agent" commit -m "Initial" -m "host-base:${hostBase}" && git tag maestro-base`
158
+ );
159
+ if (initResult.exitCode !== 0) {
160
+ await runDocker(["rm", "-f", name]);
161
+ throw new Error(initResult.stderr || "git init failed in container");
162
+ }
163
+ return {
164
+ id: name,
165
+ exec: (command) => exec(name, workDir, command),
166
+ stop: () => stop(name),
167
+ pull: () => pull(name, repoPath, workDir)
168
+ };
169
+ }
170
+ async function list() {
171
+ const result = await runDocker([
172
+ "ps",
173
+ "--filter",
174
+ `name=${CONTAINER_PREFIX}`,
175
+ "--format",
176
+ "{{.ID}} {{.Names}} {{.Status}}"
177
+ ]);
178
+ if (result.exitCode !== 0) return [];
179
+ return parseContainerList(result.stdout);
180
+ }
181
+ async function kill(name) {
182
+ await runDocker(["rm", "-f", name]);
183
+ }
184
+ function shell(name) {
185
+ return new Promise((resolve, reject) => {
186
+ const proc = spawn("docker", ["exec", "-it", name, "bash"], { stdio: "inherit" });
187
+ proc.on("error", reject);
188
+ proc.on("close", () => resolve());
189
+ });
190
+ }
191
+ function open(name, workDir = "/workspace") {
192
+ const uri = buildVscodeUri(name, workDir);
193
+ return new Promise((resolve, reject) => {
194
+ const proc = spawn("code", ["--folder-uri", uri], { stdio: "inherit" });
195
+ proc.on("error", reject);
196
+ proc.on("close", () => resolve());
197
+ });
198
+ }
199
+ async function pull(name, repoPath, workDir = "/workspace") {
200
+ await exec(name, workDir, "git add -A");
201
+ const diffResult = await exec(name, workDir, "git diff --binary --no-color maestro-base");
202
+ if (!diffResult.stdout.trim()) return { applied: false, conflicts: false };
203
+ await runGit(["-C", repoPath, "add", "-A"]);
204
+ const hostTmpDir = await mkdtemp(join(tmpdir(), "maestro-patch-"));
205
+ const patchPath = join(hostTmpDir, "maestro.patch");
206
+ try {
207
+ await writeFile(patchPath, stripAppleDoublePatchSections(diffResult.stdout));
208
+ const applyResult = await runGit(["-C", repoPath, "apply", "--3way", "--whitespace=nowarn", patchPath]);
209
+ if (applyResult.exitCode !== 0) {
210
+ if (applyResult.stderr.includes("with conflicts")) return { applied: true, conflicts: true };
211
+ throw new Error(applyResult.stderr || "git apply failed on host");
212
+ }
213
+ } finally {
214
+ await runGit(["-C", repoPath, "reset"]);
215
+ await rm(hostTmpDir, { recursive: true, force: true });
216
+ }
217
+ return { applied: true, conflicts: false };
218
+ }
219
+ async function exec(name, workDir, command) {
220
+ return runDocker(["exec", "-w", workDir, name, "sh", "-c", command]);
221
+ }
222
+ async function stop(name) {
223
+ await runDocker(["rm", "-f", name]);
224
+ }
225
+ async function ensureImage(dockerfileContent) {
226
+ const userImageTag = getImageTag("maestro-agent", dockerfileContent);
227
+ const baseResult = await runDocker(["image", "inspect", BASE_IMAGE_TAG]);
228
+ if (baseResult.exitCode !== 0) await buildBaseImage();
229
+ const userResult = await runDocker(["image", "inspect", userImageTag]);
230
+ if (userResult.exitCode !== 0) await buildUserImage(dockerfileContent, userImageTag);
231
+ return userImageTag;
232
+ }
233
+ async function buildBaseImage() {
234
+ const pnpmVersionResult = await run("pnpm", ["--version"]);
235
+ if (pnpmVersionResult.exitCode !== 0) throw new Error("Could not detect pnpm version");
236
+ const pnpmVersion = pnpmVersionResult.stdout.trim();
237
+ const tmpDir = await mkdtemp(join(tmpdir(), "maestro-agent-base-"));
238
+ try {
239
+ await writeFile(join(tmpDir, "Dockerfile"), BASE_DOCKERFILE);
240
+ const result = await runDocker(["build", "-t", BASE_IMAGE_TAG, "--build-arg", `PNPM_VERSION=${pnpmVersion}`, tmpDir]);
241
+ if (result.exitCode !== 0) {
242
+ throw new Error(result.stderr || `docker build failed with exit code ${result.exitCode}`);
243
+ }
244
+ } finally {
245
+ await rm(tmpDir, { recursive: true, force: true });
246
+ }
247
+ }
248
+ async function buildUserImage(dockerfileContent, userImageTag) {
249
+ const tmpDir = await mkdtemp(join(tmpdir(), "maestro-agent-"));
250
+ try {
251
+ const dockerfilePath = join(tmpDir, "Dockerfile");
252
+ await writeFile(dockerfilePath, dockerfileContent);
253
+ const result = await runDocker([
254
+ "build",
255
+ "-t",
256
+ userImageTag,
257
+ "--build-arg",
258
+ `MAESTRO_BASE=${BASE_IMAGE_TAG}`,
259
+ "-f",
260
+ dockerfilePath,
261
+ tmpDir
262
+ ]);
263
+ if (result.exitCode !== 0) {
264
+ throw new Error(result.stderr || `docker build failed with exit code ${result.exitCode}`);
265
+ }
266
+ } finally {
267
+ await rm(tmpDir, { recursive: true, force: true });
268
+ }
269
+ }
270
+ function copyExcludingNodeModules(repoPath, containerName, workDir) {
271
+ return new Promise((resolve) => {
272
+ const gitLsFiles = spawn("git", ["-C", repoPath, "ls-files", "--cached", "--others", "--exclude-standard"]);
273
+ const tar = spawn("tar", ["-cf", "-", "-C", repoPath, "--files-from", "-"]);
274
+ const dockerCp = spawn("docker", ["cp", "-", `${containerName}:${workDir}`]);
275
+ const stderrChunks = [];
276
+ gitLsFiles.stdout.pipe(tar.stdin);
277
+ tar.stdout.pipe(dockerCp.stdin);
278
+ gitLsFiles.stderr.on("data", (chunk) => stderrChunks.push(chunk));
279
+ tar.stderr.on("data", (chunk) => stderrChunks.push(chunk));
280
+ dockerCp.stderr.on("data", (chunk) => stderrChunks.push(chunk));
281
+ gitLsFiles.on("error", (err) => resolve({ stdout: "", stderr: err.message, exitCode: 1 }));
282
+ tar.on("error", (err) => resolve({ stdout: "", stderr: err.message, exitCode: 1 }));
283
+ dockerCp.on("error", (err) => resolve({ stdout: "", stderr: err.message, exitCode: 1 }));
284
+ dockerCp.on(
285
+ "close",
286
+ (code) => resolve({ stdout: "", stderr: Buffer.concat(stderrChunks).toString(), exitCode: code ?? 1 })
287
+ );
288
+ });
289
+ }
290
+ function stripAppleDoublePatchSections(patch) {
291
+ const sections = patch.split(/(?=^diff --git )/m);
292
+ return sections.filter((section) => !section.match(/^diff --git a\/(?:.*\/)?\._/m)).join("");
293
+ }
294
+ function runGit(args) {
295
+ return run("git", args);
296
+ }
297
+ function runDocker(args) {
298
+ return run("docker", args);
299
+ }
300
+ function run(cmd, args) {
301
+ return new Promise((resolve) => {
302
+ const proc = spawn(cmd, args);
303
+ const stdoutChunks = [];
304
+ const stderrChunks = [];
305
+ proc.stdout.on("data", (chunk) => stdoutChunks.push(chunk));
306
+ proc.stderr.on("data", (chunk) => stderrChunks.push(chunk));
307
+ proc.on("close", (code) => {
308
+ resolve({
309
+ stdout: Buffer.concat(stdoutChunks).toString(),
310
+ stderr: Buffer.concat(stderrChunks).toString(),
311
+ exitCode: code ?? 1
312
+ });
313
+ });
314
+ });
315
+ }
316
+ export {
317
+ AgentContainer
318
+ };
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@maestro-js/agent-container",
3
+ "type": "module",
4
+ "exports": {
5
+ ".": {
6
+ "types": "./dist/index.d.ts",
7
+ "default": "./dist/index.js"
8
+ }
9
+ },
10
+ "devDependencies": {
11
+ "@types/node": "^22.19.11"
12
+ },
13
+ "description": "Docker-based isolated agent execution environment with patch-based sync. Use when you need to create sandboxed containers for AI agents, run code in isolated Docker environments, pull container changes back to the host working tree as patches, or manage container lifecycles. Provides create, list, kill, shell, open (VS Code), and pull operations. Containers include Node.js 22, pnpm, Claude CLI, Playwright, git, and zsh.",
14
+ "version": "1.0.0-alpha.0",
15
+ "publishConfig": {
16
+ "access": "restricted"
17
+ },
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "license": "UNLICENSED",
22
+ "engines": {
23
+ "node": ">=22.18.0"
24
+ },
25
+ "repository": "https://github.com/Marcato-Partners/maestro-js",
26
+ "scripts": {
27
+ "build": "tsup --config ../../tsup.config.ts",
28
+ "test": "beartest ./tests/**/*",
29
+ "typecheck": "tsc --noEmit",
30
+ "format": "prettier --write src/",
31
+ "lint": "prettier --check src/",
32
+ "watch": "tsup --watch --config ../../tsup.config.ts"
33
+ }
34
+ }