@agentmeshhq/agent 0.1.12 → 0.1.13

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.
@@ -0,0 +1,435 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { DockerSandbox, type SandboxConfig, type SandboxMountPolicy } from "../core/sandbox.js";
5
+
6
+ // Mock child_process
7
+ vi.mock("node:child_process", () => ({
8
+ execSync: vi.fn(),
9
+ spawn: vi.fn(() => ({
10
+ on: vi.fn(),
11
+ unref: vi.fn(),
12
+ pid: 12345,
13
+ })),
14
+ spawnSync: vi.fn(() => ({
15
+ status: 0,
16
+ stdout: "mock-container-id\n",
17
+ stderr: "",
18
+ })),
19
+ }));
20
+
21
+ // Mock fs
22
+ vi.mock("node:fs", () => ({
23
+ default: {
24
+ existsSync: vi.fn(() => true),
25
+ },
26
+ }));
27
+
28
+ describe("DockerSandbox", () => {
29
+ const defaultConfig: SandboxConfig = {
30
+ agentName: "test-agent",
31
+ image: "agentmesh/agent-sandbox:latest",
32
+ workspacePath: "/tmp/workspace",
33
+ };
34
+
35
+ beforeEach(() => {
36
+ vi.clearAllMocks();
37
+ });
38
+
39
+ describe("constructor", () => {
40
+ it("should create sandbox with default values", () => {
41
+ const sandbox = new DockerSandbox(defaultConfig);
42
+
43
+ expect(sandbox.getContainerName()).toMatch(/^agentmesh-sandbox-test-agent-[a-f0-9]{8}$/);
44
+ });
45
+
46
+ it("should merge config with defaults", () => {
47
+ const config: SandboxConfig = {
48
+ ...defaultConfig,
49
+ cpuLimit: "0.5",
50
+ memoryLimit: "1g",
51
+ };
52
+
53
+ const sandbox = new DockerSandbox(config);
54
+ expect(sandbox).toBeDefined();
55
+ });
56
+ });
57
+
58
+ describe("validateMountPolicy", () => {
59
+ it("should deny sensitive paths by default", () => {
60
+ const sandbox = new DockerSandbox({
61
+ ...defaultConfig,
62
+ workspacePath: path.join(os.homedir(), ".ssh"),
63
+ });
64
+
65
+ expect(() => sandbox.validateMountPolicy()).toThrow(/Mount denied.*\.ssh/);
66
+ });
67
+
68
+ it("should deny ~/.gnupg", () => {
69
+ const sandbox = new DockerSandbox({
70
+ ...defaultConfig,
71
+ workspacePath: path.join(os.homedir(), ".gnupg"),
72
+ });
73
+
74
+ expect(() => sandbox.validateMountPolicy()).toThrow(/Mount denied.*\.gnupg/);
75
+ });
76
+
77
+ it("should deny ~/.aws", () => {
78
+ const sandbox = new DockerSandbox({
79
+ ...defaultConfig,
80
+ workspacePath: path.join(os.homedir(), ".aws"),
81
+ });
82
+
83
+ expect(() => sandbox.validateMountPolicy()).toThrow(/Mount denied.*\.aws/);
84
+ });
85
+
86
+ it("should deny ~/.config/gcloud", () => {
87
+ const sandbox = new DockerSandbox({
88
+ ...defaultConfig,
89
+ workspacePath: path.join(os.homedir(), ".config/gcloud"),
90
+ });
91
+
92
+ expect(() => sandbox.validateMountPolicy()).toThrow(/Mount denied.*gcloud/);
93
+ });
94
+
95
+ it("should deny ~/.kube", () => {
96
+ const sandbox = new DockerSandbox({
97
+ ...defaultConfig,
98
+ workspacePath: path.join(os.homedir(), ".kube"),
99
+ });
100
+
101
+ expect(() => sandbox.validateMountPolicy()).toThrow(/Mount denied.*\.kube/);
102
+ });
103
+
104
+ it("should deny ~/.docker", () => {
105
+ const sandbox = new DockerSandbox({
106
+ ...defaultConfig,
107
+ workspacePath: path.join(os.homedir(), ".docker"),
108
+ });
109
+
110
+ expect(() => sandbox.validateMountPolicy()).toThrow(/Mount denied.*\.docker/);
111
+ });
112
+
113
+ it("should deny /var/run/docker.sock", () => {
114
+ const sandbox = new DockerSandbox({
115
+ ...defaultConfig,
116
+ workspacePath: "/var/run/docker.sock",
117
+ });
118
+
119
+ expect(() => sandbox.validateMountPolicy()).toThrow(/Mount denied.*docker\.sock/);
120
+ });
121
+
122
+ it("should allow regular workspace paths", () => {
123
+ const sandbox = new DockerSandbox({
124
+ ...defaultConfig,
125
+ workspacePath: "/tmp/workspace",
126
+ });
127
+
128
+ expect(() => sandbox.validateMountPolicy()).not.toThrow();
129
+ });
130
+
131
+ it("should enforce custom allowed paths when specified", () => {
132
+ const sandbox = new DockerSandbox({
133
+ ...defaultConfig,
134
+ workspacePath: "/home/user/random",
135
+ });
136
+
137
+ const policy: SandboxMountPolicy = {
138
+ allowedPaths: ["/home/user/allowed-only"],
139
+ deniedPaths: [],
140
+ };
141
+
142
+ expect(() => sandbox.validateMountPolicy(policy)).toThrow(/not in allowed paths/);
143
+ });
144
+
145
+ it("should allow path within custom allowed paths", () => {
146
+ const sandbox = new DockerSandbox({
147
+ ...defaultConfig,
148
+ workspacePath: "/home/user/projects/myrepo",
149
+ });
150
+
151
+ const policy: SandboxMountPolicy = {
152
+ allowedPaths: ["/home/user/projects"],
153
+ deniedPaths: [],
154
+ };
155
+
156
+ expect(() => sandbox.validateMountPolicy(policy)).not.toThrow();
157
+ });
158
+ });
159
+
160
+ describe("checkDockerAvailable", () => {
161
+ it("should return true when docker is available", async () => {
162
+ const { spawnSync } = await import("node:child_process");
163
+ vi.mocked(spawnSync).mockReturnValueOnce({
164
+ status: 0,
165
+ stdout: "Docker version 24.0.0",
166
+ stderr: "",
167
+ pid: 1,
168
+ output: [],
169
+ signal: null,
170
+ });
171
+
172
+ expect(DockerSandbox.checkDockerAvailable()).toBe(true);
173
+ });
174
+
175
+ it("should return false when docker is not available", async () => {
176
+ const { spawnSync } = await import("node:child_process");
177
+ vi.mocked(spawnSync).mockReturnValueOnce({
178
+ status: 1,
179
+ stdout: "",
180
+ stderr: "docker: command not found",
181
+ pid: 1,
182
+ output: [],
183
+ signal: null,
184
+ });
185
+
186
+ expect(DockerSandbox.checkDockerAvailable()).toBe(false);
187
+ });
188
+ });
189
+
190
+ describe("listAll", () => {
191
+ it("should parse docker ps output correctly", async () => {
192
+ const { spawnSync } = await import("node:child_process");
193
+ vi.mocked(spawnSync).mockReturnValueOnce({
194
+ status: 0,
195
+ stdout:
196
+ "abc123|agentmesh-sandbox-agent1-12345678|Up 5 minutes|agentmesh/agent-sandbox:latest\ndef456|agentmesh-sandbox-agent2-87654321|Exited (0)|agentmesh/agent-sandbox:latest",
197
+ stderr: "",
198
+ pid: 1,
199
+ output: [],
200
+ signal: null,
201
+ });
202
+
203
+ const containers = DockerSandbox.listAll();
204
+
205
+ expect(containers).toHaveLength(2);
206
+ expect(containers[0]).toEqual({
207
+ id: "abc123",
208
+ name: "agentmesh-sandbox-agent1-12345678",
209
+ status: "Up 5 minutes",
210
+ image: "agentmesh/agent-sandbox:latest",
211
+ });
212
+ });
213
+
214
+ it("should return empty array when no containers found", async () => {
215
+ const { spawnSync } = await import("node:child_process");
216
+ vi.mocked(spawnSync).mockReturnValueOnce({
217
+ status: 0,
218
+ stdout: "",
219
+ stderr: "",
220
+ pid: 1,
221
+ output: [],
222
+ signal: null,
223
+ });
224
+
225
+ const containers = DockerSandbox.listAll();
226
+ expect(containers).toEqual([]);
227
+ });
228
+ });
229
+
230
+ describe("findExisting", () => {
231
+ it("should find existing container for agent", async () => {
232
+ const { spawnSync } = await import("node:child_process");
233
+ vi.mocked(spawnSync).mockReturnValueOnce({
234
+ status: 0,
235
+ stdout: "abc123def456\n",
236
+ stderr: "",
237
+ pid: 1,
238
+ output: [],
239
+ signal: null,
240
+ });
241
+
242
+ const containerId = DockerSandbox.findExisting("test-agent");
243
+ expect(containerId).toBe("abc123def456");
244
+ });
245
+
246
+ it("should return null when no container exists", async () => {
247
+ const { spawnSync } = await import("node:child_process");
248
+ vi.mocked(spawnSync).mockReturnValueOnce({
249
+ status: 0,
250
+ stdout: "\n",
251
+ stderr: "",
252
+ pid: 1,
253
+ output: [],
254
+ signal: null,
255
+ });
256
+
257
+ const containerId = DockerSandbox.findExisting("nonexistent");
258
+ expect(containerId).toBeNull();
259
+ });
260
+ });
261
+
262
+ describe("container lifecycle", () => {
263
+ it("should build correct docker run arguments", async () => {
264
+ const { spawnSync } = await import("node:child_process");
265
+ const mockSpawnSync = vi.mocked(spawnSync);
266
+
267
+ // Mock image inspect (image exists)
268
+ mockSpawnSync.mockReturnValueOnce({
269
+ status: 0,
270
+ stdout: "{}",
271
+ stderr: "",
272
+ pid: 1,
273
+ output: [],
274
+ signal: null,
275
+ });
276
+
277
+ // Mock docker run
278
+ mockSpawnSync.mockReturnValueOnce({
279
+ status: 0,
280
+ stdout: "container-id-12345\n",
281
+ stderr: "",
282
+ pid: 1,
283
+ output: [],
284
+ signal: null,
285
+ });
286
+
287
+ const sandbox = new DockerSandbox({
288
+ agentName: "test",
289
+ image: "agentmesh/agent-sandbox:latest",
290
+ workspacePath: "/tmp/workspace",
291
+ cpuLimit: "2",
292
+ memoryLimit: "4g",
293
+ env: {
294
+ AGENT_TOKEN: "test-token",
295
+ },
296
+ });
297
+
298
+ await sandbox.pullImage();
299
+ await sandbox.start();
300
+
301
+ // Find the docker run call
302
+ const runCall = mockSpawnSync.mock.calls.find(
303
+ (call) => call[0] === "docker" && call[1]?.[0] === "run",
304
+ );
305
+
306
+ expect(runCall).toBeDefined();
307
+ const args = runCall![1] as string[];
308
+
309
+ // Check key arguments
310
+ expect(args).toContain("-d");
311
+ expect(args).toContain("--cpus");
312
+ expect(args).toContain("2");
313
+ expect(args).toContain("--memory");
314
+ expect(args).toContain("4g");
315
+ expect(args).toContain("--user");
316
+ expect(args).toContain("1000:1000");
317
+ expect(args).toContain("-e");
318
+ expect(args).toContain("AGENT_TOKEN=test-token");
319
+ });
320
+
321
+ it("should get container status", async () => {
322
+ const { spawnSync } = await import("node:child_process");
323
+ vi.mocked(spawnSync).mockReturnValueOnce({
324
+ status: 0,
325
+ stdout: "true|running|healthy",
326
+ stderr: "",
327
+ pid: 1,
328
+ output: [],
329
+ signal: null,
330
+ });
331
+
332
+ const sandbox = new DockerSandbox(defaultConfig);
333
+ // @ts-expect-error - accessing private property for testing
334
+ sandbox.containerId = "test-container";
335
+
336
+ const status = sandbox.getStatus();
337
+
338
+ expect(status).toEqual({
339
+ running: true,
340
+ status: "running",
341
+ health: "healthy",
342
+ });
343
+ });
344
+
345
+ it("should return not found status when container not started", async () => {
346
+ const { spawnSync } = await import("node:child_process");
347
+ vi.mocked(spawnSync).mockReturnValueOnce({
348
+ status: 1, // docker inspect fails for non-existent container
349
+ stdout: "",
350
+ stderr: "Error: No such container",
351
+ pid: 1,
352
+ output: [],
353
+ signal: null,
354
+ });
355
+
356
+ const sandbox = new DockerSandbox(defaultConfig);
357
+ const status = sandbox.getStatus();
358
+
359
+ expect(status).toEqual({
360
+ running: false,
361
+ status: "not found",
362
+ });
363
+ });
364
+ });
365
+
366
+ describe("resource limits", () => {
367
+ it("should apply default CPU limit of 1", () => {
368
+ const sandbox = new DockerSandbox(defaultConfig);
369
+ // The default is applied in constructor
370
+ expect(sandbox).toBeDefined();
371
+ });
372
+
373
+ it("should apply default memory limit of 2g", () => {
374
+ const sandbox = new DockerSandbox(defaultConfig);
375
+ expect(sandbox).toBeDefined();
376
+ });
377
+
378
+ it("should allow custom resource limits", () => {
379
+ const sandbox = new DockerSandbox({
380
+ ...defaultConfig,
381
+ cpuLimit: "0.5",
382
+ memoryLimit: "512m",
383
+ });
384
+ expect(sandbox).toBeDefined();
385
+ });
386
+ });
387
+
388
+ describe("serve mode", () => {
389
+ it("should configure serve mode with port", async () => {
390
+ const { spawnSync } = await import("node:child_process");
391
+ const mockSpawnSync = vi.mocked(spawnSync);
392
+
393
+ // Mock image inspect
394
+ mockSpawnSync.mockReturnValueOnce({
395
+ status: 0,
396
+ stdout: "{}",
397
+ stderr: "",
398
+ pid: 1,
399
+ output: [],
400
+ signal: null,
401
+ });
402
+
403
+ // Mock docker run
404
+ mockSpawnSync.mockReturnValueOnce({
405
+ status: 0,
406
+ stdout: "container-id\n",
407
+ stderr: "",
408
+ pid: 1,
409
+ output: [],
410
+ signal: null,
411
+ });
412
+
413
+ const sandbox = new DockerSandbox({
414
+ ...defaultConfig,
415
+ serveMode: true,
416
+ servePort: 3001,
417
+ });
418
+
419
+ await sandbox.pullImage();
420
+ await sandbox.start();
421
+
422
+ const runCall = mockSpawnSync.mock.calls.find(
423
+ (call) => call[0] === "docker" && call[1]?.[0] === "run",
424
+ );
425
+
426
+ expect(runCall).toBeDefined();
427
+ const args = runCall![1] as string[];
428
+
429
+ expect(args).toContain("-p");
430
+ expect(args).toContain("3001:3001");
431
+ expect(args).toContain("opencode");
432
+ expect(args).toContain("serve");
433
+ });
434
+ });
435
+ });
package/src/cli/index.ts CHANGED
@@ -10,7 +10,7 @@ import { contextCmd } from "./context.js";
10
10
  import { deploy } from "./deploy.js";
11
11
  import { init } from "./init.js";
12
12
  import { list } from "./list.js";
13
- import { localUp, localDown, localStatus, localLogs } from "./local.js";
13
+ import { localDown, localLogs, localStatus, localUp } from "./local.js";
14
14
  import { logs } from "./logs.js";
15
15
  import { migrate } from "./migrate.js";
16
16
  import { nudge } from "./nudge.js";
@@ -57,6 +57,13 @@ program
57
57
  .option("--auto-setup", "Auto-clone repository for project assignments")
58
58
  .option("--serve", "Run opencode serve instead of tmux TUI (for Integration Service)")
59
59
  .option("--serve-port <port>", "Port for opencode serve (default: 3001)", "3001")
60
+ .option("--sandbox", "Run agent in Docker sandbox container with filesystem isolation")
61
+ .option(
62
+ "--sandbox-image <image>",
63
+ "Docker image for sandbox (default: agentmesh/agent-sandbox:latest)",
64
+ )
65
+ .option("--sandbox-cpu <limit>", "CPU limit for sandbox (default: 1)")
66
+ .option("--sandbox-memory <limit>", "Memory limit for sandbox (default: 2g)")
60
67
  .action(async (options) => {
61
68
  try {
62
69
  // Parse serve port as number
package/src/cli/start.ts CHANGED
@@ -18,6 +18,14 @@ export interface StartOptions {
18
18
  serve?: boolean;
19
19
  /** Port for opencode serve (default: 3001) */
20
20
  servePort?: number;
21
+ /** Run agent in Docker sandbox container */
22
+ sandbox?: boolean;
23
+ /** Docker image for sandbox */
24
+ sandboxImage?: string;
25
+ /** CPU limit for sandbox */
26
+ sandboxCpu?: string;
27
+ /** Memory limit for sandbox */
28
+ sandboxMemory?: string;
21
29
  }
22
30
 
23
31
  export async function start(options: StartOptions): Promise<void> {
@@ -78,6 +86,12 @@ export async function start(options: StartOptions): Promise<void> {
78
86
  args.push("--serve");
79
87
  if (options.servePort) args.push("--serve-port", String(options.servePort));
80
88
  }
89
+ if (options.sandbox) {
90
+ args.push("--sandbox");
91
+ if (options.sandboxImage) args.push("--sandbox-image", options.sandboxImage);
92
+ if (options.sandboxCpu) args.push("--sandbox-cpu", options.sandboxCpu);
93
+ if (options.sandboxMemory) args.push("--sandbox-memory", options.sandboxMemory);
94
+ }
81
95
 
82
96
  // Spawn detached background process
83
97
  const child = spawn("node", [cliPath, ...args], {
@@ -13,13 +13,14 @@ import type { AgentConfig, Config } from "../config/schema.js";
13
13
  import { loadContext, loadOrCreateContext, saveContext } from "../context/index.js";
14
14
  import { Heartbeat } from "./heartbeat.js";
15
15
  import { handleWebSocketEvent, injectRestoredContext, injectStartupMessage } from "./injector.js";
16
- import { checkInbox, fetchAssignments, registerAgent } from "./registry.js";
16
+ import { checkInbox, fetchAssignments, registerAgent, type ServerContext } from "./registry.js";
17
17
  import {
18
18
  buildRunnerConfig,
19
19
  detectRunner,
20
20
  getRunnerDisplayName,
21
21
  type RunnerConfig,
22
22
  } from "./runner.js";
23
+ import { DockerSandbox } from "./sandbox.js";
23
24
  import {
24
25
  captureSessionContext,
25
26
  createSession,
@@ -44,6 +45,14 @@ export interface DaemonOptions {
44
45
  serve?: boolean;
45
46
  /** Port for opencode serve (default: 3001) */
46
47
  servePort?: number;
48
+ /** Run agent in Docker sandbox container */
49
+ sandbox?: boolean;
50
+ /** Docker image for sandbox (default: agentmesh/agent-sandbox:latest) */
51
+ sandboxImage?: string;
52
+ /** CPU limit for sandbox (default: 1) */
53
+ sandboxCpu?: string;
54
+ /** Memory limit for sandbox (default: 2g) */
55
+ sandboxMemory?: string;
47
56
  }
48
57
 
49
58
  export class AgentDaemon {
@@ -62,6 +71,12 @@ export class AgentDaemon {
62
71
  private serveMode: boolean;
63
72
  private servePort: number;
64
73
  private serveProcess: ChildProcess | null = null;
74
+ private serverContext: ServerContext | undefined;
75
+ private sandboxMode: boolean;
76
+ private sandboxImage: string;
77
+ private sandboxCpu: string;
78
+ private sandboxMemory: string;
79
+ private sandbox: DockerSandbox | null = null;
65
80
 
66
81
  constructor(options: DaemonOptions) {
67
82
  const config = loadConfig();
@@ -94,6 +109,10 @@ export class AgentDaemon {
94
109
  this.agentConfig = agentConfig;
95
110
  this.serveMode = options.serve === true;
96
111
  this.servePort = options.servePort || 3001;
112
+ this.sandboxMode = options.sandbox === true;
113
+ this.sandboxImage = options.sandboxImage || "agentmesh/agent-sandbox:latest";
114
+ this.sandboxCpu = options.sandboxCpu || "1";
115
+ this.sandboxMemory = options.sandboxMemory || "2g";
97
116
 
98
117
  // Build runner configuration with model resolution
99
118
  this.runnerConfig = buildRunnerConfig({
@@ -128,18 +147,29 @@ export class AgentDaemon {
128
147
  agentId: existingState?.agentId || this.agentConfig.agentId,
129
148
  agentName: this.agentName,
130
149
  model: this.agentConfig.model || this.config.defaults.model,
150
+ restoreContext: this.shouldRestoreContext,
131
151
  });
132
152
 
133
153
  this.agentId = registration.agentId;
134
154
  this.token = registration.token;
135
155
 
136
- console.log(`Registered as: ${this.agentId}`);
156
+ if (registration.status === "re-registered") {
157
+ console.log(`Re-registered as: ${this.agentId}`);
158
+ if (registration.context && Object.keys(registration.context).length > 0) {
159
+ this.serverContext = registration.context;
160
+ console.log(`Server context restored: ${Object.keys(registration.context).join(", ")}`);
161
+ }
162
+ } else {
163
+ console.log(`Registered as: ${this.agentId}`);
164
+ }
137
165
 
138
166
  // Check assignments and auto-setup workdir if needed (before creating tmux session)
139
167
  await this.checkAssignments();
140
168
 
141
- // Serve mode: start opencode serve instead of tmux
142
- if (this.serveMode) {
169
+ // Choose runtime mode: sandbox > serve > tmux
170
+ if (this.sandboxMode) {
171
+ await this.startSandboxMode();
172
+ } else if (this.serveMode) {
143
173
  await this.startServeMode();
144
174
  } else {
145
175
  // Check if session already exists
@@ -337,8 +367,12 @@ Nudge agent:
337
367
  this.ws = null;
338
368
  }
339
369
 
340
- // Stop serve process or destroy tmux session
341
- if (this.serveMode && this.serveProcess) {
370
+ // Stop sandbox, serve process, or destroy tmux session
371
+ if (this.sandboxMode && this.sandbox) {
372
+ console.log("Stopping sandbox container...");
373
+ await this.sandbox.destroy();
374
+ this.sandbox = null;
375
+ } else if (this.serveMode && this.serveProcess) {
342
376
  console.log("Stopping opencode serve...");
343
377
  this.serveProcess.kill("SIGTERM");
344
378
  this.serveProcess = null;
@@ -409,6 +443,80 @@ Nudge agent:
409
443
  console.log(`opencode serve started on http://0.0.0.0:${this.servePort}`);
410
444
  }
411
445
 
446
+ /**
447
+ * Starts agent in Docker sandbox mode
448
+ * Provides filesystem isolation with only workspace mounted
449
+ */
450
+ private async startSandboxMode(): Promise<void> {
451
+ console.log("Starting in Docker sandbox mode...");
452
+
453
+ // Check Docker availability
454
+ if (!DockerSandbox.checkDockerAvailable()) {
455
+ throw new Error(
456
+ "Docker is not available. Install Docker or use --sandbox host to run on host.",
457
+ );
458
+ }
459
+
460
+ const workdir = this.agentConfig.workdir || process.cwd();
461
+
462
+ // Check for existing sandbox container
463
+ const existingContainer = DockerSandbox.findExisting(this.agentName);
464
+ if (existingContainer) {
465
+ console.log(`Found existing sandbox container: ${existingContainer}`);
466
+ console.log("Stop it with: agentmesh stop " + this.agentName);
467
+ throw new Error("Sandbox container already exists");
468
+ }
469
+
470
+ // Create sandbox configuration
471
+ this.sandbox = new DockerSandbox({
472
+ agentName: this.agentName,
473
+ image: this.sandboxImage,
474
+ workspacePath: workdir,
475
+ cpuLimit: this.sandboxCpu,
476
+ memoryLimit: this.sandboxMemory,
477
+ env: {
478
+ ...this.runnerConfig.env,
479
+ AGENT_TOKEN: this.token!,
480
+ AGENTMESH_AGENT_ID: this.agentId!,
481
+ },
482
+ serveMode: this.serveMode,
483
+ servePort: this.servePort,
484
+ networkMode: "bridge",
485
+ });
486
+
487
+ // Validate mount policy (will throw if denied)
488
+ this.sandbox.validateMountPolicy();
489
+
490
+ // Pull image if needed
491
+ await this.sandbox.pullImage();
492
+
493
+ // Start container
494
+ await this.sandbox.start();
495
+
496
+ console.log(`
497
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
498
+ 🐳 SANDBOX MODE ACTIVE
499
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
500
+
501
+ Container: ${this.sandbox.getContainerName()}
502
+ Image: ${this.sandboxImage}
503
+ Workspace: ${workdir} -> /workspace
504
+ CPU: ${this.sandboxCpu} core(s)
505
+ Memory: ${this.sandboxMemory}
506
+
507
+ The agent is running in an isolated Docker container.
508
+ Only the workspace directory is accessible.
509
+
510
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
511
+ `);
512
+
513
+ // Start opencode in the container
514
+ if (!this.serveMode) {
515
+ console.log("Starting opencode in sandbox container...");
516
+ await this.sandbox.spawnOpencode();
517
+ }
518
+ }
519
+
412
520
  /**
413
521
  * Saves the current agent context to disk
414
522
  */