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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
node_modules/
*.enc
sessions/
.deps-installing
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## [Unreleased]

### Added
- Auto-install missing dependencies (playwright and Chromium) on first browser operation - eliminates manual setup step. Lockfile-based coordination prevents race conditions. Set `WEB_CTL_SKIP_AUTO_INSTALL=1` to disable in CI/sandboxed environments
- `--ensure-auth` flag for goto action - polls for auth completion at 2s intervals using URL-change heuristic instead of a static timed checkpoint. On success, closes headed browser, relaunches headless, and loads the original URL. Overrides `--no-auth-wall-detect` so auth detection runs even when wall detection is disabled
- Auto-detect authentication walls after goto navigation - uses three-heuristic detection (domain cookies, URL auth patterns, DOM login elements) and automatically opens headed checkpoint. Disable with `--no-auth-wall-detect` flag
- Smart default snapshot scoping - snapshots automatically scope to `<main>` element (then `[role="main"]`, fallback to `<body>`), reducing output size by excluding navigation, headers, and footers. Use `--snapshot-full` to capture full page body when needed
Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ Each invocation is a single Node.js process. No daemon, no MCP server, no IPC. S
# Claude Code
agentsys install web-ctl

# Peer dependency
npm install playwright
npx playwright install chromium
# Dependencies auto-install on first use
# To disable auto-install (CI/sandboxed environments):
# export WEB_CTL_SKIP_AUTO_INSTALL=1
```

## Commands
Expand Down Expand Up @@ -275,8 +275,8 @@ Can be invoked by:
## Requirements

- Node.js 18+
- Playwright (`npm install playwright`)
- Chromium (`npx playwright install chromium`)
- Playwright and Chromium (auto-installed on first browser operation)
- Set `WEB_CTL_SKIP_AUTO_INSTALL=1` to disable auto-install in CI/sandboxed environments

## License

Expand Down
9 changes: 3 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"test": "node --test tests/*.test.js",
"validate": "node scripts/validate.js"
},
"peerDependencies": {
"dependencies": {
"playwright": ">=1.40.0"
},
"author": {
Expand Down
3 changes: 3 additions & 0 deletions scripts/browser-launcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
const fs = require('fs');
const path = require('path');
const sessionStore = require('./session-store');
const { ensurePlaywright } = require('./ensure-deps');

const IN_WSL = isWSL();

Expand Down Expand Up @@ -63,6 +64,7 @@ function cleanSingletonLock(profileDir) {
* @returns {{ context, page }}
*/
async function launchBrowser(sessionName, options = {}) {
ensurePlaywright();
const { chromium } = require('playwright');

const profileDir = sessionStore.getProfileDir(sessionName);
Expand Down Expand Up @@ -150,6 +152,7 @@ async function canLaunchHeaded() {
const maxAttempts = 2;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
ensurePlaywright();
const { chromium } = require('playwright');
const ctx = await chromium.launchPersistentContext('', {
headless: false,
Expand Down
179 changes: 179 additions & 0 deletions scripts/ensure-deps.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
'use strict';

const path = require('path');
const fs = require('fs');
const { execSync } = require('child_process');

const PLUGIN_ROOT = path.resolve(__dirname, '..');
const LOCKFILE = path.join(PLUGIN_ROOT, '.deps-installing');
const LOCK_TIMEOUT_MS = 120_000;
const LOCK_POLL_MS = 1000;

let _playwrightVerified = false;

/**
* Check whether a process with the given PID is still running.
*/
function isProcessAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}

/**
* Synchronous sleep that yields CPU (no busy-wait spin).
*/
function syncSleep(ms) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}

/**
* Acquire an exclusive lockfile for dependency installation.
* Waits up to LOCK_TIMEOUT_MS if another process holds the lock.
* Returns true if lock was acquired, false if lock disappeared
* (another process finished installing).
*/
function acquireLock() {
const start = Date.now();

while (true) {
try {
fs.writeFileSync(LOCKFILE, String(process.pid), { flag: 'wx' });
return true;
} catch {
// Lock exists - check if holder is still alive
let holderPid;
try {
holderPid = parseInt(fs.readFileSync(LOCKFILE, 'utf8').trim(), 10);
} catch {
// Lock file disappeared between check and read
return false;
}

if (!holderPid || isNaN(holderPid) || holderPid <= 0 || !isProcessAlive(holderPid)) {
// Stale or invalid lock - remove and retry
try { fs.unlinkSync(LOCKFILE); } catch { /* race with another cleaner */ }
continue;
}

// Another process is installing - wait
if (Date.now() - start > LOCK_TIMEOUT_MS) {
throw new Error(
`Timed out waiting for dependency installation by PID ${holderPid}. ` +
`Remove ${LOCKFILE} if the process is no longer running.`
);
}

syncSleep(LOCK_POLL_MS);
}
}
}

/**
* Release the installation lockfile.
*/
function releaseLock() {
try { fs.unlinkSync(LOCKFILE); } catch { /* already removed */ }
}

/**
* Ensure playwright is available. Installs it automatically if missing.
*
* This function is synchronous. It uses a module-level cache so repeated
* calls within the same process are free after the first successful check.
*
* Set WEB_CTL_SKIP_AUTO_INSTALL=1 to disable auto-install (for CI/sandboxed
* environments). When set, throws an error with manual install instructions
* if playwright is missing.
*/
function ensurePlaywright() {
if (_playwrightVerified) return;

try {
require.resolve('playwright', { paths: [PLUGIN_ROOT] });
_playwrightVerified = true;
return;
} catch {
// playwright not found - proceed to install
}

if (process.env.WEB_CTL_SKIP_AUTO_INSTALL === '1') {
throw new Error(
`Required dependency 'playwright' is not installed.\n` +
`Auto-install is disabled (WEB_CTL_SKIP_AUTO_INSTALL=1).\n` +
`Run manually:\n` +
` cd ${PLUGIN_ROOT} && npm install && npx playwright install chromium`
);
}

const acquired = acquireLock();
if (!acquired) {
// Another process finished installing - verify
try {
require.resolve('playwright', { paths: [PLUGIN_ROOT] });
_playwrightVerified = true;
return;
} catch {
throw new Error(
`Dependency installation by another process did not resolve playwright.\n` +
`Run manually: cd ${PLUGIN_ROOT} && npm install && npx playwright install chromium`
);
}
}

try {
process.stderr.write('[web-ctl] Installing dependencies...\n');

execSync('npm install --production', {
cwd: PLUGIN_ROOT,
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 60_000
});

process.stderr.write('[web-ctl] Installing Chromium browser...\n');

execSync('npx playwright install chromium', {
cwd: PLUGIN_ROOT,
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 120_000
});

process.stderr.write('[web-ctl] Dependencies installed.\n');
} catch (err) {
releaseLock();
throw new Error(
`Automatic dependency installation failed: ${err.message}\n` +
`Run manually: cd ${PLUGIN_ROOT} && npm install && npx playwright install chromium`
);
}

releaseLock();

// Verify installation succeeded
try {
// Clear require cache so Node picks up newly installed module
delete require.cache[require.resolve('playwright', { paths: [PLUGIN_ROOT] })];
} catch { /* ignore if cache clear fails */ }

try {
require.resolve('playwright', { paths: [PLUGIN_ROOT] });
_playwrightVerified = true;
} catch {
throw new Error(
`Playwright installed but still not resolvable from ${PLUGIN_ROOT}.\n` +
`Run manually: cd ${PLUGIN_ROOT} && npm install && npx playwright install chromium`
);
}
}

/**
* Reset the module-level cache. Exposed for testing only.
*/
function _resetCache() {
_playwrightVerified = false;
}

module.exports = { ensurePlaywright, _resetCache };
11 changes: 11 additions & 0 deletions scripts/web-ctl.js
Original file line number Diff line number Diff line change
Expand Up @@ -822,6 +822,17 @@ function classifyError(err, { action, selector, snapshot } = {}) {
};
}

// Missing dependency (must be before 'not found' check for element_not_found)
if (msg.includes('Cannot find module')) {
const moduleName = msg.match(/Cannot find module '([^']+)'/)?.[1] || 'unknown';
const pluginDir = path.resolve(__dirname, '..');
return {
error: 'missing_dependency',
message: `Required dependency not found: ${moduleName}`,
suggestion: `Run: cd ${pluginDir} && npm install && npx playwright install chromium`
};
}

// Element not found / strict mode violation
if (msg.includes('not found') || msg.includes('waiting for locator') ||
msg.includes('strict mode violation') || msg.includes('resolved to') ||
Expand Down
2 changes: 1 addition & 1 deletion skills/web-auth/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ If verification fails (`ok: false`), the auth flow still succeeds - the verifica
On timeout: Ask the user if they want to retry with a longer timeout.

On error: Check the error message. Common issues:
- Browser not found: User needs to install Chrome or Playwright (`npx playwright install chromium`)
- Browser not found: Dependencies should auto-install on first run. If disabled (`WEB_CTL_SKIP_AUTO_INSTALL=1`), install manually: `npm install && npx playwright install chromium`
- Session locked: Another process is using this session

### 5. Verify Auth
Expand Down
Loading
Loading