Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## Unreleased

### Fixed

- Web projects no longer ship an agent-browser skill stub without its runtime: `aginit init` now installs the `agent-browser` CLI (`npm i -g agent-browser`) and its browser binaries (`agent-browser install`) when the skill is configured or already on disk, and reports a failed provisioning as an incomplete setup (#4).
- `aginit init` and `aginit doctor` detect the Linux restriction that blocks Chrome's sandbox (`kernel.apparmor_restrict_unprivileged_userns=1`) and report the persisted sysctl fix plus the narrower `--no-sandbox` alternative instead of failing later with `No usable sandbox!`.

### Added

- `aginit doctor` verifies the agent-browser runtime when the project ships the skill: CLI presence, upstream `agent-browser doctor --quick --offline --json` findings, and Chrome sandbox prerequisites.

## 0.2.0 — 2026-10-09

### Breaking
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ The web preset supports `--framework none` (default), `vite`, `next`, or `existi
### Agent configuration

- `AGENTS.md`: project instructions shared by agents.
- `.agents/skills/`: the 27 stable skills from Matt Pocock (engineering + productivity), and `humanizer` from Blake Robbins. Web projects also include Impeccable and agent-browser.
- `.agents/skills/`: the 27 stable skills from Matt Pocock (engineering + productivity), and `humanizer` from Blake Robbins. Web projects also include Impeccable and agent-browser; for those, `aginit init` also installs the `agent-browser` CLI and its browser binaries so the skill works out of the box.
- Graft: a codebase graph for `graft map` and `graft ask`.
- `aginit.config.json`: saved preset, framework, package manager, agents, and skill sources.

Expand All @@ -87,15 +87,16 @@ aginit doctor
aginit update
```

`doctor` checks Node.js, the project package manager, Git, Graft, Codex, the Skills CLI, and project configuration. It may download the Skills CLI through npx.
`doctor` checks Node.js, the project package manager, Git, Graft, Codex, the Skills CLI, the agent-browser runtime (CLI, browser binaries, Chrome sandbox prerequisites) when the project ships that skill, and project configuration. It may download the Skills CLI through npx.

`update` runs project-scoped `skills update` and rebuilds the Graft index. Disabled integrations are skipped; failed updates return a nonzero exit status.

## Behavior

- Rerunning the same initialization is idempotent. Manifest additions and explicit configuration changes are applied without replacing application code.
- Framework scaffold failures stop the command.
- Missing Graft or failed skill installation returns a nonzero exit status. The scaffold is retained so you can fix the prerequisite and rerun `aginit init`.
- Missing Graft, failed skill installation, or a failed agent-browser runtime setup returns a nonzero exit status. The scaffold is retained so you can fix the prerequisite and rerun `aginit init`.
- Linux kernels that restrict unprivileged user namespaces block Chrome's sandbox (`No usable sandbox!`). aginit reports the sysctl fix and the narrower `--no-sandbox` alternative; it never changes system settings on your behalf.
- `--dry-run` lists planned operations without writing files or downloading framework tools. Files produced by upstream scaffolders are only known after they run.
- Playwright's starter test is skipped until you add an application URL and assertions. Generated test harnesses still need application-specific tests.

Expand Down
162 changes: 162 additions & 0 deletions src/adapters/agent-browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import path from 'node:path';
import { fileExists, readTextFile } from '../utils/fs.js';
import { commandExists, runExecutable } from '../utils/shell.js';
import { logger } from '../utils/logger.js';
import { SkillSourceConfig } from '../config/schema.js';

const AGENT_BROWSER_SKILL = 'agent-browser';
const AGENT_BROWSER_CLI = 'agent-browser';
const AGENT_BROWSER_PACKAGE = 'vercel-labs/agent-browser';

/**
* Whether a configured skill source will install the agent-browser skill stub
* whose instructions assume the `agent-browser` CLI exists.
*/
export function agentBrowserConfigured(sources: SkillSourceConfig[]): boolean {
return sources.some(
(source) =>
source.skills.includes(AGENT_BROWSER_SKILL) ||
(source.package === AGENT_BROWSER_PACKAGE &&
(source.skills.length === 0 || source.skills.includes('*')))
);
}

/** Whether the agent-browser skill stub is already on disk. */
export function agentBrowserStubInstalled(targetDir: string): boolean {
return fileExists(path.join(targetDir, '.agents', 'skills', AGENT_BROWSER_SKILL, 'SKILL.md'));
}

export interface UsernsRestriction {
sysctl: string;
value: string;
fixValue: string;
}

/**
* Detect the Linux sysctls that block Chrome's sandbox when set to their
* restrictive value: Chrome's renderer sandbox runs on unprivileged user
* namespaces, so agent-browser launches die with "No usable sandbox!".
*
* Returns null when the kernel does not expose the knobs (other distros,
* macOS, Windows) or none of them is restrictive.
*/
export function findUsernsRestriction(procRoot = '/proc'): UsernsRestriction | null {
const knobs: Array<[sysctl: string, blockingValue: string, fixValue: string]> = [
['kernel.apparmor_restrict_unprivileged_userns', '1', '0'],
['kernel.unprivileged_userns_clone', '0', '1']
];
for (const [sysctl, blockingValue, fixValue] of knobs) {
const raw = readTextFile(path.join(procRoot, 'sys', ...sysctl.split('.')));
if (raw !== null && raw.trim() === blockingValue) {
return { sysctl, value: raw.trim(), fixValue };
}
}
return null;
}

export interface AgentBrowserSetupOptions {
dryRun?: boolean;
silent?: boolean;
/** Working directory for the provisioning commands (defaults to cwd). */
cwd?: string;
/** Root of the proc filesystem; overridable for tests. */
procRoot?: string;
}

/**
* Provision the runtime the agent-browser skill stub depends on: the globally
* installed CLI (`npm i -g agent-browser`, matching the skill's own install
* line — npm ships with Node and works regardless of the project's package
* manager) and its browser binaries (`agent-browser install`, idempotent).
*
* A blocked Chrome sandbox is reported as a warning only: it needs a system
* change aginit must not make on the user's behalf. Returns false when the
* CLI or the browser binaries could not be provisioned.
*/
export async function setupAgentBrowser(options: AgentBrowserSetupOptions = {}): Promise<boolean> {
const { dryRun = false, silent = false, cwd = process.cwd(), procRoot = '/proc' } = options;

const cliPresent = await commandExists('agent-browser');
if (!cliPresent) {
if (!silent) logger.dim('Installing agent-browser CLI globally (npm -g)...');
const cli = await runExecutable('npm', ['install', '-g', AGENT_BROWSER_SKILL], {
cwd,
dryRun,
silent: true,
timeout: 600_000
});
if (!cli.ok && !dryRun) {
if (!silent) {
logger.warn(`Failed installing agent-browser CLI: ${cli.stderr || cli.stdout}`);
logger.dim(' Install it manually: npm i -g agent-browser && agent-browser install');
}
return false;
}
}

if (!silent) logger.dim('Ensuring agent-browser browser binaries...');
const browser = await runExecutable(AGENT_BROWSER_CLI, ['install'], {
cwd,
dryRun,
silent: true,
timeout: 600_000
});
if (!browser.ok && !dryRun) {
if (!silent) {
logger.warn(`Failed downloading agent-browser browsers: ${browser.stderr || browser.stdout}`);
logger.dim(' Retry manually: agent-browser install');
}
return false;
}

const restriction = findUsernsRestriction(procRoot);
if (restriction && !silent) {
logger.warn(
`Chrome's sandbox is blocked: ${restriction.sysctl}=${restriction.value} restricts unprivileged user namespaces, so agent-browser launches fail with "No usable sandbox!".`
);
logger.dim(
` → Fix (machine-wide): echo '${restriction.sysctl}=${restriction.fixValue}' | sudo tee /etc/sysctl.d/99-agent-browser-userns.conf && sudo sysctl --system`
);
logger.dim(
' → Narrower option: pass --args "--no-sandbox" to agent-browser for that launch (Chrome sandbox disabled for that session)'
);
}
return true;
}

export interface AgentBrowserDiagnostic {
message: string;
fix?: string;
}

/**
* Run upstream's own diagnostics (`agent-browser doctor --quick --offline
* --json`) and return the failing checks, so aginit reports browser health
* instead of reimplementing it. Returns null when the report cannot be
* parsed (older CLI, unexpected output) — callers should then skip the check
* rather than guess.
*/
export async function runAgentBrowserDiagnostics(): Promise<AgentBrowserDiagnostic[] | null> {
const res = await runExecutable(
AGENT_BROWSER_CLI,
['doctor', '--quick', '--offline', '--json'],
{
silent: true,
timeout: 60_000
}
);
try {
const parsed = JSON.parse(res.stdout) as {
checks?: Array<{ status?: string; message?: string; fix?: string }>;
};
if (!Array.isArray(parsed.checks)) return null;
return parsed.checks
.filter((check) => check?.status === 'fail')
.map((check) => ({
message: String(check?.message || 'check failed'),
...(check?.fix ? { fix: String(check.fix) } : {})
}));
} catch {
return null;
}
}
73 changes: 73 additions & 0 deletions src/cli/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ import {
} from '../config/schema.js';
import { detectPackageManager } from '../adapters/package-manager.js';
import { getInstalledSkills } from '../adapters/skills.js';
import {
agentBrowserConfigured,
agentBrowserStubInstalled,
findUsernsRestriction,
runAgentBrowserDiagnostics
} from '../adapters/agent-browser.js';
import { readProjectConfig } from '../config/validation.js';
import { logger } from '../utils/logger.js';

Expand Down Expand Up @@ -214,6 +220,73 @@ export async function runDoctor(targetDir: string = process.cwd()): Promise<void
: 'Run `aginit init` or `npx skills add ...` to install skills'
});

// 10. agent-browser runtime (only when the project ships the skill)
const agentBrowserWanted =
projectConfig !== null &&
(agentBrowserConfigured(projectConfig.skills.sources) || agentBrowserStubInstalled(targetDir));
if (agentBrowserWanted) {
const abExists = await commandExists('agent-browser');
let abVersion = '';
if (abExists) {
const av = await runExecutable('agent-browser', ['--version'], { silent: true });
abVersion = av.ok ? av.stdout : '';
}
checks.push({
name: 'agent-browser CLI',
category: 'AI Tooling',
ok: abExists,
version: abVersion,
detail: abExists
? `Installed globally (${abVersion || 'version unknown'})`
: 'agent-browser skill is installed but the CLI is not in PATH',
suggestion: abExists
? undefined
: 'Install the runtime: `npm i -g agent-browser && agent-browser install`'
});

if (abExists) {
const diagnostics = await runAgentBrowserDiagnostics();
if (diagnostics !== null) {
checks.push({
name: 'agent-browser browser runtime',
category: 'AI Tooling',
ok: diagnostics.length === 0,
detail:
diagnostics.length === 0
? 'Upstream doctor reports browser and launch environment healthy'
: diagnostics.map((d) => d.message).join('; '),
suggestion:
diagnostics.length === 0
? undefined
: (diagnostics.find((d) => d.fix)?.fix ?? 'Run `agent-browser doctor` for details')
});
}
}
}

// 11. Chrome sandbox prerequisites — agent-browser and Playwright both
// launch Chrome from the same sandbox on Linux.
const sandboxRelevant = agentBrowserWanted || projectConfig?.browser.playwright === true;
if (sandboxRelevant) {
const restriction = findUsernsRestriction();
if (restriction) {
checks.push({
name: 'Chrome sandbox (unprivileged user namespaces)',
category: 'Environment',
ok: false,
detail: `${restriction.sysctl}=${restriction.value} blocks Chrome's sandbox: browser launches fail with "No usable sandbox!"`,
suggestion: `Lift it: echo '${restriction.sysctl}=${restriction.fixValue}' | sudo tee /etc/sysctl.d/99-agent-browser-userns.conf && sudo sysctl --system — or pass --args "--no-sandbox" per launch`
});
} else if (process.platform === 'linux') {
checks.push({
name: 'Chrome sandbox (unprivileged user namespaces)',
category: 'Environment',
ok: true,
detail: 'Unprivileged user namespaces available to Chrome'
});
}
}

// Print grouped results
const categories: Array<'Environment' | 'AI Tooling' | 'Project Config'> = [
'Environment',
Expand Down
21 changes: 19 additions & 2 deletions src/cli/new.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import { setupGit } from '../adapters/git.js';
import { setupAgentsMarkdown } from '../adapters/agents-md.js';
import { installAllSkills } from '../adapters/skills.js';
import { initGraft } from '../adapters/graft.js';
import {
agentBrowserConfigured,
agentBrowserStubInstalled,
setupAgentBrowser
} from '../adapters/agent-browser.js';
import { ensureDir, writeJsonFile } from '../utils/fs.js';
import {
readProjectConfig,
Expand Down Expand Up @@ -100,7 +105,11 @@ export async function createProject(
);
let complete = true;

const totalSteps = 6;
// The agent-browser skill stub is inert without its CLI and browser
// binaries, so provisioning them gets its own step when the skill applies.
const agentBrowserWanted =
agentBrowserConfigured(config.skills.sources) || agentBrowserStubInstalled(targetDir);
const totalSteps = agentBrowserWanted ? 7 : 6;
let currentStep = 1;

// Scaffold into the clean directory before Git adds files that upstream tools reject.
Expand Down Expand Up @@ -151,7 +160,15 @@ export async function createProject(
logger.dim('Skills installation skipped (--no-skills or empty configuration)');
}

// Step 6: Codebase intelligence (Graft)
// Provision the agent-browser runtime the skill stub drives
if (agentBrowserWanted) {
if (!silent) logger.step(currentStep++, totalSteps, 'Provisioning agent-browser runtime');
const abOk = await setupAgentBrowser({ dryRun, silent, cwd: targetDir });
complete &&= abOk;
if (!silent && abOk) logger.success('agent-browser runtime ready (CLI + browser)');
}

// Codebase intelligence (Graft)
if (!silent) logger.step(currentStep++, totalSteps, 'Configuring codebase intelligence');
if (config.codebase.graft && options.graft !== false) {
const graftOk = await initGraft(targetDir, { dryRun, silent });
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export * from './adapters/skills.js';
export * from './adapters/graft.js';
export * from './adapters/agents-md.js';
export * from './adapters/browser.js';
export * from './adapters/agent-browser.js';
export * from './cli/new.js';
export * from './cli/init.js';
export * from './cli/doctor.js';
Expand Down
Loading
Loading