mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-07 05:56:41 +02:00
c0f3c3a91a
* fix: check for bun before running setup (#147) Users without bun installed got a cryptic "command not found" error. Now prints a clear message with install instructions. Closes #147 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: block SSRF via URL validation in browse commands (#17) Adds validateNavigationUrl() that blocks non-HTTP(S) schemes (file://, javascript:, data:) and cloud metadata endpoints (169.254.169.254, metadata.google.internal). Applied to goto, diff, and newTab commands. Localhost and private IPs remain allowed for local dev QA. Closes #17 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: replace eval $(gstack-slug) with source <(...) (#133) Eliminates unnecessary use of eval across all skill templates and generated files. source <(...) has identical behavior without the shell injection surface. Also hardens gstack-diff-scope usage. Closes #133 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: rename /debug to /investigate to avoid Claude Code conflict (#190) Claude Code has a built-in /debug command that shadows the gstack skill. Renaming to /investigate which better reflects the systematic root-cause investigation methodology. Closes #190 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add unit tests for path validation helpers validateOutputPath() and validateReadPath() are security-critical functions with zero test coverage. Adds 14 tests covering safe paths, traversal attacks, and prefix collision edge cases. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.8.3) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: update /debug → /investigate references in docs CLAUDE.md, README.md, and docs/skills.md still referenced the old /debug skill name after the rename. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: harden URL validation against hostname bypasses (Codex P1) Codex review found that metadata IPs could be reached via hex (0xA9FEA9FE), decimal (2852039166), octal, trailing dot, and IPv6 bracket forms. Now normalizes hostnames before checking the blocklist and probes numeric IP representations via URL constructor. Also moves URL validation before page allocation in newTab() to prevent zombie tabs on rejection (Codex P3). 5 new test cases for bypass variants. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
269 lines
9.6 KiB
TypeScript
269 lines
9.6 KiB
TypeScript
/**
|
|
* Meta commands — tabs, server control, screenshots, chain, diff, snapshot
|
|
*/
|
|
|
|
import type { BrowserManager } from './browser-manager';
|
|
import { handleSnapshot } from './snapshot';
|
|
import { getCleanText } from './read-commands';
|
|
import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands';
|
|
import { validateNavigationUrl } from './url-validation';
|
|
import * as Diff from 'diff';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
|
|
// Security: Path validation to prevent path traversal attacks
|
|
const SAFE_DIRECTORIES = ['/tmp', process.cwd()];
|
|
|
|
export function validateOutputPath(filePath: string): void {
|
|
const resolved = path.resolve(filePath);
|
|
const isSafe = SAFE_DIRECTORIES.some(dir => resolved === dir || resolved.startsWith(dir + '/'));
|
|
if (!isSafe) {
|
|
throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
|
|
}
|
|
}
|
|
|
|
export async function handleMetaCommand(
|
|
command: string,
|
|
args: string[],
|
|
bm: BrowserManager,
|
|
shutdown: () => Promise<void> | void
|
|
): Promise<string> {
|
|
switch (command) {
|
|
// ─── Tabs ──────────────────────────────────────────
|
|
case 'tabs': {
|
|
const tabs = await bm.getTabListWithTitles();
|
|
return tabs.map(t =>
|
|
`${t.active ? '→ ' : ' '}[${t.id}] ${t.title || '(untitled)'} — ${t.url}`
|
|
).join('\n');
|
|
}
|
|
|
|
case 'tab': {
|
|
const id = parseInt(args[0], 10);
|
|
if (isNaN(id)) throw new Error('Usage: browse tab <id>');
|
|
bm.switchTab(id);
|
|
return `Switched to tab ${id}`;
|
|
}
|
|
|
|
case 'newtab': {
|
|
const url = args[0];
|
|
const id = await bm.newTab(url);
|
|
return `Opened tab ${id}${url ? ` → ${url}` : ''}`;
|
|
}
|
|
|
|
case 'closetab': {
|
|
const id = args[0] ? parseInt(args[0], 10) : undefined;
|
|
await bm.closeTab(id);
|
|
return `Closed tab${id ? ` ${id}` : ''}`;
|
|
}
|
|
|
|
// ─── Server Control ────────────────────────────────
|
|
case 'status': {
|
|
const page = bm.getPage();
|
|
const tabs = bm.getTabCount();
|
|
return [
|
|
`Status: healthy`,
|
|
`URL: ${page.url()}`,
|
|
`Tabs: ${tabs}`,
|
|
`PID: ${process.pid}`,
|
|
].join('\n');
|
|
}
|
|
|
|
case 'url': {
|
|
return bm.getCurrentUrl();
|
|
}
|
|
|
|
case 'stop': {
|
|
await shutdown();
|
|
return 'Server stopped';
|
|
}
|
|
|
|
case 'restart': {
|
|
// Signal that we want a restart — the CLI will detect exit and restart
|
|
console.log('[browse] Restart requested. Exiting for CLI to restart.');
|
|
await shutdown();
|
|
return 'Restarting...';
|
|
}
|
|
|
|
// ─── Visual ────────────────────────────────────────
|
|
case 'screenshot': {
|
|
// Parse priority: flags (--viewport, --clip) → selector (@ref, CSS) → output path
|
|
const page = bm.getPage();
|
|
let outputPath = '/tmp/browse-screenshot.png';
|
|
let clipRect: { x: number; y: number; width: number; height: number } | undefined;
|
|
let targetSelector: string | undefined;
|
|
let viewportOnly = false;
|
|
|
|
const remaining: string[] = [];
|
|
for (let i = 0; i < args.length; i++) {
|
|
if (args[i] === '--viewport') {
|
|
viewportOnly = true;
|
|
} else if (args[i] === '--clip') {
|
|
const coords = args[++i];
|
|
if (!coords) throw new Error('Usage: screenshot --clip x,y,w,h [path]');
|
|
const parts = coords.split(',').map(Number);
|
|
if (parts.length !== 4 || parts.some(isNaN))
|
|
throw new Error('Usage: screenshot --clip x,y,width,height — all must be numbers');
|
|
clipRect = { x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
|
|
} else if (args[i].startsWith('--')) {
|
|
throw new Error(`Unknown screenshot flag: ${args[i]}`);
|
|
} else {
|
|
remaining.push(args[i]);
|
|
}
|
|
}
|
|
|
|
// Separate target (selector/@ref) from output path
|
|
for (const arg of remaining) {
|
|
if (arg.startsWith('@e') || arg.startsWith('@c') || arg.startsWith('.') || arg.startsWith('#') || arg.includes('[')) {
|
|
targetSelector = arg;
|
|
} else {
|
|
outputPath = arg;
|
|
}
|
|
}
|
|
|
|
validateOutputPath(outputPath);
|
|
|
|
if (clipRect && targetSelector) {
|
|
throw new Error('Cannot use --clip with a selector/ref — choose one');
|
|
}
|
|
if (viewportOnly && clipRect) {
|
|
throw new Error('Cannot use --viewport with --clip — choose one');
|
|
}
|
|
|
|
if (targetSelector) {
|
|
const resolved = await bm.resolveRef(targetSelector);
|
|
const locator = 'locator' in resolved ? resolved.locator : page.locator(resolved.selector);
|
|
await locator.screenshot({ path: outputPath, timeout: 5000 });
|
|
return `Screenshot saved (element): ${outputPath}`;
|
|
}
|
|
|
|
if (clipRect) {
|
|
await page.screenshot({ path: outputPath, clip: clipRect });
|
|
return `Screenshot saved (clip ${clipRect.x},${clipRect.y},${clipRect.width},${clipRect.height}): ${outputPath}`;
|
|
}
|
|
|
|
await page.screenshot({ path: outputPath, fullPage: !viewportOnly });
|
|
return `Screenshot saved${viewportOnly ? ' (viewport)' : ''}: ${outputPath}`;
|
|
}
|
|
|
|
case 'pdf': {
|
|
const page = bm.getPage();
|
|
const pdfPath = args[0] || '/tmp/browse-page.pdf';
|
|
validateOutputPath(pdfPath);
|
|
await page.pdf({ path: pdfPath, format: 'A4' });
|
|
return `PDF saved: ${pdfPath}`;
|
|
}
|
|
|
|
case 'responsive': {
|
|
const page = bm.getPage();
|
|
const prefix = args[0] || '/tmp/browse-responsive';
|
|
validateOutputPath(prefix);
|
|
const viewports = [
|
|
{ name: 'mobile', width: 375, height: 812 },
|
|
{ name: 'tablet', width: 768, height: 1024 },
|
|
{ name: 'desktop', width: 1280, height: 720 },
|
|
];
|
|
const originalViewport = page.viewportSize();
|
|
const results: string[] = [];
|
|
|
|
for (const vp of viewports) {
|
|
await page.setViewportSize({ width: vp.width, height: vp.height });
|
|
const path = `${prefix}-${vp.name}.png`;
|
|
await page.screenshot({ path, fullPage: true });
|
|
results.push(`${vp.name} (${vp.width}x${vp.height}): ${path}`);
|
|
}
|
|
|
|
// Restore original viewport
|
|
if (originalViewport) {
|
|
await page.setViewportSize(originalViewport);
|
|
}
|
|
|
|
return results.join('\n');
|
|
}
|
|
|
|
// ─── Chain ─────────────────────────────────────────
|
|
case 'chain': {
|
|
// Read JSON array from args[0] (if provided) or expect it was passed as body
|
|
const jsonStr = args[0];
|
|
if (!jsonStr) throw new Error('Usage: echo \'[["goto","url"],["text"]]\' | browse chain');
|
|
|
|
let commands: string[][];
|
|
try {
|
|
commands = JSON.parse(jsonStr);
|
|
} catch {
|
|
throw new Error('Invalid JSON. Expected: [["command", "arg1", "arg2"], ...]');
|
|
}
|
|
|
|
if (!Array.isArray(commands)) throw new Error('Expected JSON array of commands');
|
|
|
|
const results: string[] = [];
|
|
const { handleReadCommand } = await import('./read-commands');
|
|
const { handleWriteCommand } = await import('./write-commands');
|
|
|
|
for (const cmd of commands) {
|
|
const [name, ...cmdArgs] = cmd;
|
|
try {
|
|
let result: string;
|
|
if (WRITE_COMMANDS.has(name)) result = await handleWriteCommand(name, cmdArgs, bm);
|
|
else if (READ_COMMANDS.has(name)) result = await handleReadCommand(name, cmdArgs, bm);
|
|
else if (META_COMMANDS.has(name)) result = await handleMetaCommand(name, cmdArgs, bm, shutdown);
|
|
else throw new Error(`Unknown command: ${name}`);
|
|
results.push(`[${name}] ${result}`);
|
|
} catch (err: any) {
|
|
results.push(`[${name}] ERROR: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
return results.join('\n\n');
|
|
}
|
|
|
|
// ─── Diff ──────────────────────────────────────────
|
|
case 'diff': {
|
|
const [url1, url2] = args;
|
|
if (!url1 || !url2) throw new Error('Usage: browse diff <url1> <url2>');
|
|
|
|
const page = bm.getPage();
|
|
validateNavigationUrl(url1);
|
|
await page.goto(url1, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
const text1 = await getCleanText(page);
|
|
|
|
validateNavigationUrl(url2);
|
|
await page.goto(url2, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
const text2 = await getCleanText(page);
|
|
|
|
const changes = Diff.diffLines(text1, text2);
|
|
const output: string[] = [`--- ${url1}`, `+++ ${url2}`, ''];
|
|
|
|
for (const part of changes) {
|
|
const prefix = part.added ? '+' : part.removed ? '-' : ' ';
|
|
const lines = part.value.split('\n').filter(l => l.length > 0);
|
|
for (const line of lines) {
|
|
output.push(`${prefix} ${line}`);
|
|
}
|
|
}
|
|
|
|
return output.join('\n');
|
|
}
|
|
|
|
// ─── Snapshot ─────────────────────────────────────
|
|
case 'snapshot': {
|
|
return await handleSnapshot(args, bm);
|
|
}
|
|
|
|
// ─── Handoff ────────────────────────────────────
|
|
case 'handoff': {
|
|
const message = args.join(' ') || 'User takeover requested';
|
|
return await bm.handoff(message);
|
|
}
|
|
|
|
case 'resume': {
|
|
bm.resume();
|
|
// Re-snapshot to capture current page state after human interaction
|
|
const snapshot = await handleSnapshot(['-i'], bm);
|
|
return `RESUMED\n${snapshot}`;
|
|
}
|
|
|
|
default:
|
|
throw new Error(`Unknown meta command: ${command}`);
|
|
}
|
|
}
|