feat(browser): add sandbox-aware browser agent initialization (#24419)

This commit is contained in:
Gaurav
2026-04-02 01:18:17 +08:00
committed by GitHub
parent a3ef87e6e2
commit bf3ac20da0
5 changed files with 311 additions and 9 deletions

View File

@@ -222,6 +222,61 @@ the `click_at` tool for precise, coordinate-based interactions.
> The visual agent requires API key or Vertex AI authentication. It is
> not available when using "Sign in with Google".
#### Sandbox support
The browser agent adjusts its behavior automatically when running inside a
sandbox.
##### macOS seatbelt (`sandbox-exec`)
When the CLI runs under the macOS seatbelt sandbox, `persistent` and `isolated`
session modes are forced to `isolated` with `headless` enabled. This avoids
permission errors caused by seatbelt file-system restrictions on persistent
browser profiles. If `sessionMode` is set to `existing`, no override is applied.
##### Container sandboxes (Docker / Podman)
Chrome is not available inside the container, so the browser agent is
**disabled** unless `sessionMode` is set to `"existing"`. When enabled with
`existing` mode, the agent automatically connects to Chrome on the host via the
resolved IP of `host.docker.internal:9222` instead of using local pipe
discovery. Port `9222` is currently hardcoded and cannot be customized.
To use the browser agent in a Docker sandbox:
1. Start Chrome on the host with remote debugging enabled:
```bash
# Option A: Launch Chrome from the command line
google-chrome --remote-debugging-port=9222
# Option B: Enable in Chrome settings
# Navigate to chrome://inspect/#remote-debugging and enable
```
2. Configure `sessionMode` and allowed domains in your project's
`.gemini/settings.json`:
```json
{
"agents": {
"overrides": {
"browser_agent": { "enabled": true }
},
"browser": {
"sessionMode": "existing",
"allowedDomains": ["example.com"]
}
}
}
```
3. Launch the CLI with port forwarding:
```bash
GEMINI_SANDBOX=docker SANDBOX_PORTS=9222 gemini
```
## Creating custom subagents
You can create your own subagents to automate specific workflows or enforce

View File

@@ -1067,4 +1067,96 @@ describe('BrowserManager', () => {
);
});
});
describe('sandbox behavior', () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it('should force --isolated and --headless when in seatbelt sandbox with persistent mode', async () => {
vi.stubEnv('SANDBOX', 'sandbox-exec');
const feedbackSpy = vi
.spyOn(coreEvents, 'emitFeedback')
.mockImplementation(() => {});
const manager = new BrowserManager(mockConfig); // default persistent mode
await manager.ensureConnection();
const args = vi.mocked(StdioClientTransport).mock.calls[0]?.[0]
?.args as string[];
expect(args).toContain('--isolated');
expect(args).toContain('--headless');
expect(args).not.toContain('--userDataDir');
expect(args).not.toContain('--autoConnect');
expect(feedbackSpy).toHaveBeenCalledWith(
'info',
expect.stringContaining('isolated browser session'),
);
});
it('should preserve --autoConnect when in seatbelt sandbox with existing mode', async () => {
vi.stubEnv('SANDBOX', 'sandbox-exec');
const existingConfig = makeFakeConfig({
agents: {
overrides: { browser_agent: { enabled: true } },
browser: { sessionMode: 'existing' },
},
});
const manager = new BrowserManager(existingConfig);
await manager.ensureConnection();
const args = vi.mocked(StdioClientTransport).mock.calls[0]?.[0]
?.args as string[];
expect(args).toContain('--autoConnect');
expect(args).not.toContain('--isolated');
// Headless should NOT be forced for existing mode in seatbelt
expect(args).not.toContain('--headless');
});
it('should use --browser-url with resolved IP for container sandbox with existing mode', async () => {
vi.stubEnv('SANDBOX', 'docker-container-0');
// Mock DNS resolution of host.docker.internal
const dns = await import('node:dns');
vi.spyOn(dns.promises, 'lookup').mockResolvedValue({
address: '192.168.127.254',
family: 4,
});
const feedbackSpy = vi
.spyOn(coreEvents, 'emitFeedback')
.mockImplementation(() => {});
const existingConfig = makeFakeConfig({
agents: {
overrides: { browser_agent: { enabled: true } },
browser: { sessionMode: 'existing' },
},
});
const manager = new BrowserManager(existingConfig);
await manager.ensureConnection();
const args = vi.mocked(StdioClientTransport).mock.calls[0]?.[0]
?.args as string[];
expect(args).toContain('--browser-url');
expect(args).toContain('http://192.168.127.254:9222');
expect(args).not.toContain('--autoConnect');
expect(feedbackSpy).toHaveBeenCalledWith(
'info',
expect.stringContaining('192.168.127.254:9222'),
);
});
it('should not override session mode when not in sandbox', async () => {
vi.stubEnv('SANDBOX', '');
const manager = new BrowserManager(mockConfig);
await manager.ensureConnection();
const args = vi.mocked(StdioClientTransport).mock.calls[0]?.[0]
?.args as string[];
// Default persistent mode: no --isolated, no --autoConnect
expect(args).not.toContain('--isolated');
expect(args).not.toContain('--autoConnect');
expect(args).toContain('--userDataDir');
});
});
});

View File

@@ -486,7 +486,32 @@ export class BrowserManager {
// Build args for chrome-devtools-mcp
const browserConfig = this.config.getBrowserAgentConfig();
const sessionMode = browserConfig.customConfig.sessionMode ?? 'persistent';
let sessionMode = browserConfig.customConfig.sessionMode ?? 'persistent';
// Detect sandbox environment.
// SANDBOX env var is set to 'sandbox-exec' (seatbelt) or the container
// name (Docker/Podman/gVisor/LXC) when running inside a sandbox.
// CI uses 'sandbox:none' as a metadata label — not a real sandbox.
const sandboxType = process.env['SANDBOX'];
const isContainerSandbox =
!!sandboxType &&
sandboxType !== 'sandbox-exec' &&
sandboxType !== 'sandbox:none';
const isSeatbeltSandbox =
sandboxType === 'sandbox-exec' && sessionMode !== 'existing';
// Seatbelt sandbox: force isolated + headless for filesystem compatibility.
// Chrome exists on the host, but persistent profiles may conflict with
// seatbelt restrictions. Isolated mode uses tmpdir (always writable).
if (isSeatbeltSandbox) {
if (sessionMode !== 'isolated') {
sessionMode = 'isolated';
coreEvents.emitFeedback(
'info',
'🔒 Sandbox: Using isolated browser session for compatibility.',
);
}
}
const mcpArgs = ['--experimental-vision'];
@@ -498,15 +523,42 @@ export class BrowserManager {
if (sessionMode === 'isolated') {
mcpArgs.push('--isolated');
} else if (sessionMode === 'existing') {
mcpArgs.push('--autoConnect');
const message =
'🔒 Browsing with your signed-in Chrome profile — cookies and saved logins will be visible to the agent.';
coreEvents.emitFeedback('info', message);
coreEvents.emitConsoleLog('info', message);
if (isContainerSandbox) {
// In container sandboxes, --autoConnect can't discover Chrome on the
// host (it uses local pipes/sockets). Use --browser-url with the
// resolved IP of host.docker.internal instead of the hostname, because
// Chrome's DevTools protocol rejects HTTP requests where the Host
// header is not 'localhost' or an IP address.
const dns = await import('node:dns');
let browserHost = 'host.docker.internal';
try {
const { address } = await dns.promises.lookup(browserHost);
browserHost = address;
} catch {
// Fallback: use hostname as-is if DNS resolution fails
debugLogger.log(
`Could not resolve host.docker.internal, using hostname directly`,
);
}
const browserUrl = `http://${browserHost}:9222`;
mcpArgs.push('--browser-url', browserUrl);
coreEvents.emitFeedback(
'info',
`🔒 Container sandbox: Connecting to Chrome via ${browserHost}:9222.`,
);
} else {
mcpArgs.push('--autoConnect');
const message =
'🔒 Browsing with your signed-in Chrome profile — cookies and saved logins will be visible to the agent.';
coreEvents.emitFeedback('info', message);
coreEvents.emitConsoleLog('info', message);
}
}
// Add optional settings from config
if (browserConfig.customConfig.headless) {
// Add optional settings from config.
// Force headless in seatbelt sandbox since Chrome profile/display access
// may be restricted, and the user is running in a sandboxed environment.
if (browserConfig.customConfig.headless || isSeatbeltSandbox) {
mcpArgs.push('--headless');
}
if (browserConfig.customConfig.profilePath) {

View File

@@ -1534,4 +1534,87 @@ describe('AgentRegistry', () => {
expect(getterCalled).toBe(true); // Getter should have been called now
});
});
describe('browser agent sandbox registration', () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it('should NOT register browser agent in container sandbox without existing mode', async () => {
vi.stubEnv('SANDBOX', 'docker-container-0');
const feedbackSpy = vi
.spyOn(coreEvents, 'emitFeedback')
.mockImplementation(() => {});
const config = makeMockedConfig({
agents: {
overrides: {
browser_agent: { enabled: true },
},
browser: {
sessionMode: 'persistent',
},
},
});
const registry = new TestableAgentRegistry(config);
await registry.initialize();
expect(registry.getDefinition('browser_agent')).toBeUndefined();
expect(feedbackSpy).toHaveBeenCalledWith(
'info',
expect.stringContaining('Browser agent disabled in container sandbox'),
);
});
it('should register browser agent in container sandbox with existing mode', async () => {
vi.stubEnv('SANDBOX', 'docker-container-0');
const config = makeMockedConfig({
agents: {
overrides: {
browser_agent: { enabled: true },
},
browser: {
sessionMode: 'existing',
},
},
});
const registry = new TestableAgentRegistry(config);
await registry.initialize();
expect(registry.getDefinition('browser_agent')).toBeDefined();
});
it('should register browser agent normally in seatbelt sandbox', async () => {
vi.stubEnv('SANDBOX', 'sandbox-exec');
const config = makeMockedConfig({
agents: {
overrides: {
browser_agent: { enabled: true },
},
},
});
const registry = new TestableAgentRegistry(config);
await registry.initialize();
expect(registry.getDefinition('browser_agent')).toBeDefined();
});
it('should register browser agent normally when not in sandbox', async () => {
vi.stubEnv('SANDBOX', '');
const config = makeMockedConfig({
agents: {
overrides: {
browser_agent: { enabled: true },
},
},
});
const registry = new TestableAgentRegistry(config);
await registry.initialize();
expect(registry.getDefinition('browser_agent')).toBeDefined();
});
});
});

View File

@@ -257,7 +257,27 @@ export class AgentRegistry {
// Tools are configured dynamically at invocation time via browserAgentFactory.
const browserConfig = this.config.getBrowserAgentConfig();
if (browserConfig.enabled) {
this.registerLocalAgent(BrowserAgentDefinition(this.config));
// In container sandboxes (Docker/Podman/gVisor/LXC), Chrome is not
// available inside the container. The browser agent can only work with
// sessionMode "existing" (connecting to a host Chrome instance).
const sandboxType = process.env['SANDBOX'];
const isContainerSandbox =
!!sandboxType &&
sandboxType !== 'sandbox-exec' &&
sandboxType !== 'sandbox:none';
const sessionMode =
browserConfig.customConfig.sessionMode ?? 'persistent';
if (isContainerSandbox && sessionMode !== 'existing') {
coreEvents.emitFeedback(
'info',
'Browser agent disabled in container sandbox. ' +
'To use it, set sessionMode to "existing" in settings and start Chrome ' +
'with --remote-debugging-port=9222 on the host.',
);
} else {
this.registerLocalAgent(BrowserAgentDefinition(this.config));
}
}
// Register the memory manager agent as a replacement for the save_memory tool.