mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-30 23:14:32 -07:00
feat(browser): add sandbox-aware browser agent initialization (#24419)
This commit is contained in:
@@ -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
|
> The visual agent requires API key or Vertex AI authentication. It is
|
||||||
> not available when using "Sign in with Google".
|
> 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
|
## Creating custom subagents
|
||||||
|
|
||||||
You can create your own subagents to automate specific workflows or enforce
|
You can create your own subagents to automate specific workflows or enforce
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -486,7 +486,32 @@ export class BrowserManager {
|
|||||||
|
|
||||||
// Build args for chrome-devtools-mcp
|
// Build args for chrome-devtools-mcp
|
||||||
const browserConfig = this.config.getBrowserAgentConfig();
|
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'];
|
const mcpArgs = ['--experimental-vision'];
|
||||||
|
|
||||||
@@ -498,15 +523,42 @@ export class BrowserManager {
|
|||||||
if (sessionMode === 'isolated') {
|
if (sessionMode === 'isolated') {
|
||||||
mcpArgs.push('--isolated');
|
mcpArgs.push('--isolated');
|
||||||
} else if (sessionMode === 'existing') {
|
} else if (sessionMode === 'existing') {
|
||||||
mcpArgs.push('--autoConnect');
|
if (isContainerSandbox) {
|
||||||
const message =
|
// In container sandboxes, --autoConnect can't discover Chrome on the
|
||||||
'🔒 Browsing with your signed-in Chrome profile — cookies and saved logins will be visible to the agent.';
|
// host (it uses local pipes/sockets). Use --browser-url with the
|
||||||
coreEvents.emitFeedback('info', message);
|
// resolved IP of host.docker.internal instead of the hostname, because
|
||||||
coreEvents.emitConsoleLog('info', message);
|
// 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
|
// Add optional settings from config.
|
||||||
if (browserConfig.customConfig.headless) {
|
// 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');
|
mcpArgs.push('--headless');
|
||||||
}
|
}
|
||||||
if (browserConfig.customConfig.profilePath) {
|
if (browserConfig.customConfig.profilePath) {
|
||||||
|
|||||||
@@ -1534,4 +1534,87 @@ describe('AgentRegistry', () => {
|
|||||||
expect(getterCalled).toBe(true); // Getter should have been called now
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -257,7 +257,27 @@ export class AgentRegistry {
|
|||||||
// Tools are configured dynamically at invocation time via browserAgentFactory.
|
// Tools are configured dynamically at invocation time via browserAgentFactory.
|
||||||
const browserConfig = this.config.getBrowserAgentConfig();
|
const browserConfig = this.config.getBrowserAgentConfig();
|
||||||
if (browserConfig.enabled) {
|
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.
|
// Register the memory manager agent as a replacement for the save_memory tool.
|
||||||
|
|||||||
Reference in New Issue
Block a user