mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-01 19:25:10 +02:00
c15b805cd8
* feat(browse): TabSession loadedHtml + command aliases + DX polish primitives
Adds the foundation layer for Puppeteer-parity features:
- TabSession.loadedHtml + setTabContent/getLoadedHtml/clearLoadedHtml —
enables load-html content to survive context recreation (viewport --scale)
via in-memory replay. ASCII lifecycle diagram in the source explains the
clear-before-navigation contract.
- COMMAND_ALIASES + canonicalizeCommand() helper — single source of truth
for name aliases (setcontent / set-content / setContent → load-html),
consumed by server dispatch and chain prevalidation.
- buildUnknownCommandError() pure function — rich error messages with
Levenshtein-based "Did you mean" suggestions (distance ≤ 2, input
length ≥ 4 to skip 2-letter noise) and NEW_IN_VERSION upgrade hints.
- load-html registered in WRITE_COMMANDS + SCOPE_WRITE so scoped write
tokens can use it.
- screenshot and viewport descriptions updated for upcoming flags.
- New browse/test/dx-polish.test.ts (15 tests): alias canonicalization,
Levenshtein threshold + alphabetical tiebreak, short-input guard,
NEW_IN_VERSION upgrade hint, alias + scope integration invariants.
No consumers yet — pure additive foundation. Safe to bisect on its own.
* feat(browse): accept file:// in goto with smart cwd/home-relative parsing
Extends validateNavigationUrl to accept file:// URLs scoped to safe dirs
(cwd + TEMP_DIR) via the existing validateReadPath policy. The workhorse is a
new normalizeFileUrl() helper that handles non-standard relative forms BEFORE
the WHATWG URL parser sees them:
file:///abs/path.html → unchanged
file://./docs/page.html → file://<cwd>/docs/page.html
file://~/Documents/page.html → file://<HOME>/Documents/page.html
file://docs/page.html → file://<cwd>/docs/page.html
file://localhost/abs/path → unchanged
file://host.example.com/... → rejected (UNC/network)
file:// and file:/// → rejected (would list a directory)
Host heuristic rejects segments with '.', ':', '\\', '%', IPv6 brackets, or
Windows drive-letter patterns — so file://docs.v1/page.html, file://127.0.0.1/x,
file://[::1]/x, and file://C:/Users/x are explicit errors.
Uses fileURLToPath() + pathToFileURL() from node:url (never string-concat) so
URL escapes like %20 decode correctly and Node rejects encoded-slash traversal
(%2F..%2F) outright.
Signature change: validateNavigationUrl now returns Promise<string> (the
normalized URL) instead of Promise<void>. Existing callers that ignore the
return value still compile — they just don't benefit from smart-parsing until
updated in follow-up commits. Callers will be migrated in the next few commits
(goto, diff, newTab, restoreState).
Rewrites the url-validation test file: updates existing tests for the new
return type, adds 20+ new tests covering every normalizeFileUrl shape variant,
URL-encoding edge cases, and path-traversal rejection.
References: codex consult v3 P1 findings on URL parser semantics and fileURLToPath.
* feat(browse): BrowserManager deviceScaleFactor + setContent replay + file:// plumbing
Three tightly-coupled changes to BrowserManager, all in service of the
Puppeteer-parity workflow:
1. deviceScaleFactor + currentViewport tracking. New private fields (default
scale=1, viewport=1280x720) + setDeviceScaleFactor(scale, w, h) method.
deviceScaleFactor is a context-level Playwright option — changing it
requires recreateContext(). The method validates (finite number, 1-3 cap,
headed-mode rejected), stores new values, calls recreateContext(), and
rolls back the fields on failure so a bad call doesn't leave inconsistent
state. Context options at all three sites (launch, recreate happy path,
recreate fallback) now honor the stored values instead of hardcoding
1280x720.
2. BrowserState.loadedHtml + loadedHtmlWaitUntil. saveState captures per-tab
loadedHtml from the session; restoreState replays it via newSession.
setTabContent() — NOT bare page.setContent() — so TabSession.loadedHtml
is rehydrated and survives *subsequent* scale changes. In-memory only,
never persisted to disk (HTML may contain secrets or customer data).
3. newTab + restoreState now consume validateNavigationUrl's normalized
return value. file://./x, file://~/x, and bare-segment forms now take
effect at every navigation site, not just the top-level goto command.
Together these enable: load-html → viewport --scale 2 → viewport --scale 1.5
→ screenshot, with content surviving both context recreations. Codex v2 P0
flagged that bare page.setContent in restoreState would lose content on the
second scale change — this commit implements the rehydration path.
References: codex v2 P0 (TabSession rehydration), codex v3 P1 (4-caller
return value), plan Feature 3 + Feature 4.
* feat(browse): load-html, screenshot --selector, viewport --scale, alias dispatch
Wires the new handlers and dispatch logic that the previous commits made
possible:
write-commands.ts
- New 'load-html' case: validateReadPath for safe-dir scoping, stat-based
actionable errors (not found, directory, oversize), extension allowlist
(.html/.htm/.xhtml/.svg), magic-byte sniff with UTF-8 BOM strip accepting
any <[a-zA-Z!?] markup opener (not just <!doctype — bare fragments like
<div>...</div> work for setContent), 50MB cap via GSTACK_BROWSE_MAX_HTML_BYTES
override, frame-context rejection. Calls session.setTabContent() so replay
metadata is rehydrated.
- viewport command extended: optional [<WxH>], optional [--scale <n>],
scale-only variant reads current size via page.viewportSize(). Invalid
scale (NaN, Infinity, empty, out of 1-3) throws with named value. Headed
mode rejected explicitly.
- clearLoadedHtml() called BEFORE goto/back/forward/reload navigation
(not after) so a timed-out goto post-commit doesn't leave stale metadata
that could resurrect on a later context recreation. Codex v2 P1 catch.
- goto uses validateNavigationUrl's normalized return value.
meta-commands.ts
- screenshot --selector <css> flag: explicit element-screenshot form.
Rejects alongside positional selector (both = error), preserves --clip
conflict at line 161, composes with --base64 at lines 168-174.
- chain canonicalizes each step with canonicalizeCommand — step shape is
now { rawName, name, args } so prevalidation, dispatch, WRITE_COMMANDS.has,
watch blocking, and result labels all use canonical names while audit
labels show 'rawName→name' when aliased. Codex v3 P2 catch — prior shape
only canonicalized at prevalidation and diverged everywhere else.
- diff command consumes validateNavigationUrl return value for both URLs.
server.ts
- Command canonicalization inserted immediately after parse, before scope /
watch / tab-ownership / content-wrapping checks. rawCommand preserved for
future audit (not wired into audit log in this commit — follow-up).
- Unknown-command handler replaced with buildUnknownCommandError() from
commands.ts — produces 'Unknown command: X. Did you mean Y?' with optional
upgrade hint for NEW_IN_VERSION entries.
security-audit-r2.test.ts
- Updated chain-loop marker from 'for (const cmd of commands)' to
'for (const c of commands)' to match the new chain step shape. Same
isWatching + BLOCKED invariants still asserted.
* chore: bump version and changelog (v1.1.0.0)
- VERSION: 1.0.0.0 → 1.1.0.0 (MINOR bump — new user-facing commands)
- package.json: matching version bump
- CHANGELOG.md: new 1.1.0.0 entry describing load-html, screenshot --selector,
viewport --scale, file:// support, setContent replay, and DX polish in user
voice with a dedicated Security section for file:// safe-dirs policy
- browse/SKILL.md.tmpl: adds pattern #12 "Render local HTML", pattern #13
"Retina screenshots", and a full Puppeteer → browse cheatsheet with side-by-
side API mapping and a worked tweet-renderer migration example
- browse/SKILL.md + SKILL.md: regenerated from templates via `bun run gen:skill-docs`
to reflect the new command descriptions
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: pre-landing review fixes (9 findings from specialist + adversarial review)
Adversarial review (Claude subagent + Codex) surfaced 9 bugs across
CRITICAL/HIGH severity. All fixed:
1. tab-session.ts:setTabContent — state mutation moved AFTER the setContent
await. Prior order left phantom HTML in replay metadata if setContent
threw (timeout, browser crash), which a later viewport --scale would
silently replay. Now loadedHtml is only recorded on successful load.
2. browser-manager.ts:setDeviceScaleFactor — rollback now forces a second
recreateContext after restoring the old fields. The fallback path in
the original recreateContext builds a blank context using whatever
this.deviceScaleFactor/currentViewport hold at that moment (which were
the NEW values we were trying to apply). Rolling back the fields without
a second recreate left the live context at new-scale while state tracked
old-scale. Now: restore fields, force re-recreate with old values, only
if that ALSO fails do we return a combined error.
3. commands.ts:buildUnknownCommandError — Levenshtein tiebreak simplified
to 'd <= 2 && d < bestDist' (strict less). Candidates are pre-sorted
alphabetically, so first equal-distance wins by default. The prior
'(d === bestDist && best !== undefined && cand < best)' clause was dead
code.
4. tab-session.ts:onMainFrameNavigated — now clears loadedHtml, not just
refs + frame. Without this, a user who load-html'd then clicked a link
(or had a form submit / JS redirect / OAuth flow) would retain the stale
replay metadata. The next viewport --scale would silently revert the
tab to the ORIGINAL loaded HTML, losing whatever the post-navigation
content was. Silent data corruption. Browser-emitted navigations trigger
this path via wirePageEvents.
5. browser-manager.ts:saveState + restoreState — tab ownership now flows
through BrowserState.owner. Without this, a scoped agent's viewport
--scale would strand them: tab IDs change during recreate, ownership
map held stale IDs, owner lookup failed. New IDs had no owner, so
writes without tabId were denied (DoS). Worse, if the agent sent a
stale tabId the server's swallowed-tab-switch-error path would let the
command hit whatever tab was currently active (cross-tab authz bypass).
Now: clear ownership before restore, re-add per-tab with new IDs.
6. meta-commands.ts:state load — disk-loaded state.pages is now explicit
allowlist (url, isActive, storage:null) instead of object spread.
Spreading accepted loadedHtml, loadedHtmlWaitUntil, and owner from a
user-writable state file, letting a tampered state.json smuggle HTML
past load-html's safe-dirs / extension / magic-byte / 50MB-cap
validators, or forge tab ownership. Now stripped at the boundary.
7. url-validation.ts:normalizeFileUrl — preserves query string + fragment
across normalization. file://./app.html?route=home#login previously
resolved to a filesystem path that URL-encoded '?' as %3F and '#' as
%23, or (for absolute forms) pathToFileURL dropped them entirely. SPAs
and fixture URLs with query params 404'd or loaded the wrong route.
Now: split on ?/# before path resolution, reattach after.
8. url-validation.ts:validateNavigationUrl — reattaches parsed.search +
parsed.hash to the normalized file:// URL. Same fix at the main
validator for absolute paths that go through fileURLToPath round-trip.
9. server.ts:writeAuditEntry — audit entries now include aliasOf when the
user typed an alias ('setcontent' → cmd: 'load-html', aliasOf:
'setcontent'). Previously the isAliased variable was computed but
dropped, losing the raw input from the forensic trail. Completes the
plan's codex v3 P2 requirement.
Also added bm.getCurrentViewport() and switched 'viewport --scale'-
without-size to read from it (more reliable than page.viewportSize() on
headed/transition contexts).
Tests pass: exit 0, no failures. Build clean.
* test: integration coverage for load-html, screenshot --selector, viewport --scale, replay, aliases
Adds 28 Playwright-integration tests that close the coverage gap flagged
by the ship-workflow coverage audit (50% → expected ~80%+).
**load-html (12 tests):**
- happy path loads HTML file, page text matches
- bare HTML fragments (<div>...</div>) accepted, not just full documents
- missing file arg throws usage
- non-.html extension rejected by allowlist
- /etc/passwd.html rejected by safe-dirs policy
- ENOENT path rejected with actionable "not found" error
- directory target rejected
- binary file (PNG magic bytes) disguised as .html rejected by magic-byte check
- UTF-8 BOM stripped before magic-byte check — BOM-prefixed HTML accepted
- --wait-until networkidle exercises non-default branch
- invalid --wait-until value rejected
- unknown flag rejected
**screenshot --selector (5 tests):**
- --selector flag captures element, validates Screenshot saved (element)
- conflicts with positional selector (both = error)
- conflicts with --clip (mutually exclusive)
- composes with --base64 (returns data:image/png;base64,...)
- missing value throws usage
**viewport --scale (5 tests):**
- WxH --scale 2 produces PNG with 2x element dimensions (parses IHDR bytes 16-23)
- --scale without WxH keeps current size + applies scale
- non-finite value (abc) throws "not a finite number"
- out-of-range (4, 0.5) throws "between 1 and 3"
- missing value throws
**setContent replay across context recreation (3 tests):**
- load-html → viewport --scale 2: content survives (hits setTabContent replay path)
- double cycle 2x → 1.5x: content still survives (proves TabSession rehydration)
- goto after load-html clears replay: subsequent viewport --scale does NOT
resurrect the stale HTML (validates the onMainFrameNavigated fix)
**Command aliases (2 tests):**
- setcontent routes to load-html via chain canonicalization
- set-content (hyphenated) also routes — both end-to-end through chain dispatch
Fixture paths use /tmp (SAFE_DIRECTORIES entry) instead of $TMPDIR which is
/var/folders/... on macOS and outside the safe-dirs boundary. Chain result
labels use rawName→name format when an alias is resolved (matches the
meta-commands.ts chain refactor).
Full suite: exit 0, 223/223 pass.
* docs: update BROWSER.md + CHANGELOG for v1.1.0.0
BROWSER.md:
- Command reference table updated: goto now lists file:// support,
load-html added to Navigate row, viewport flagged with --scale
option, screenshot row shows --selector + --base64 flags
- Screenshot modes table adds the fifth mode (element crop via
--selector flag) and notes the tag-selector-not-caught-positionally
gotcha
- New "Retina screenshots — viewport --scale" subsection explains
deviceScaleFactor mechanics, context recreation side effects, and
headed-mode rejection
- New "Loading local HTML — goto file:// vs load-html" subsection
explains the two paths, their tradeoffs (URL state, relative asset
resolution), the safe-dirs policy, extension allowlist + magic-byte
sniff, 50MB cap, setContent replay across recreateContext, and the
alias routing (setcontent → load-html before scope check)
CHANGELOG.md (v1.1.0.0 security section expanded, no existing content
removed):
- State files cannot smuggle HTML or forge tab ownership (allowlist
on disk-loaded page fields)
- Audit log records aliasOf when a canonical command was reached via
an alias (setcontent → load-html)
- load-html content clears on real navigations (clicks, form submits,
JS redirects) — not just explicit goto. Also notes SPA query/fragment
preservation for goto file://
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
719 lines
32 KiB
TypeScript
719 lines
32 KiB
TypeScript
/**
|
|
* Security audit round-2 tests — static source checks + behavioral verification.
|
|
*
|
|
* These tests verify that security fixes are present at the source level and
|
|
* behave correctly at runtime. Source-level checks guard against regressions
|
|
* that could silently remove a fix without breaking compilation.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
|
|
// ─── Shared source reads (used across multiple test sections) ───────────────
|
|
const META_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/meta-commands.ts'), 'utf-8');
|
|
const WRITE_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/write-commands.ts'), 'utf-8');
|
|
const SERVER_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/server.ts'), 'utf-8');
|
|
const AGENT_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/sidebar-agent.ts'), 'utf-8');
|
|
const SNAPSHOT_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/snapshot.ts'), 'utf-8');
|
|
const PATH_SECURITY_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/path-security.ts'), 'utf-8');
|
|
|
|
// ─── Helper ─────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Extract the source text between two string markers.
|
|
*/
|
|
function sliceBetween(src: string, startMarker: string, endMarker: string): string {
|
|
const start = src.indexOf(startMarker);
|
|
if (start === -1) return '';
|
|
const end = src.indexOf(endMarker, start + startMarker.length);
|
|
if (end === -1) return src.slice(start);
|
|
return src.slice(start, end + endMarker.length);
|
|
}
|
|
|
|
/**
|
|
* Extract a function body by name — finds `function name(` or `export function name(`
|
|
* and returns the full balanced-brace block.
|
|
*/
|
|
function extractFunction(src: string, name: string): string {
|
|
const pattern = new RegExp(`(?:export\\s+)?function\\s+${name}\\s*\\(`);
|
|
const match = pattern.exec(src);
|
|
if (!match) return '';
|
|
let depth = 0;
|
|
let inBody = false;
|
|
const start = match.index;
|
|
for (let i = start; i < src.length; i++) {
|
|
if (src[i] === '{') { depth++; inBody = true; }
|
|
else if (src[i] === '}') { depth--; }
|
|
if (inBody && depth === 0) return src.slice(start, i + 1);
|
|
}
|
|
return src.slice(start);
|
|
}
|
|
|
|
// ─── Task 4: Agent queue poisoning — full schema validation + permissions ───
|
|
|
|
describe('Agent queue security', () => {
|
|
it('server queue directory must use restricted permissions', () => {
|
|
const queueSection = SERVER_SRC.slice(SERVER_SRC.indexOf('agentQueue'), SERVER_SRC.indexOf('agentQueue') + 2000);
|
|
expect(queueSection).toMatch(/0o700/);
|
|
});
|
|
|
|
it('sidebar-agent queue directory must use restricted permissions', () => {
|
|
// The mkdirSync for the queue dir lives in main() — search the main() body
|
|
const mainStart = AGENT_SRC.indexOf('async function main');
|
|
const queueSection = AGENT_SRC.slice(mainStart);
|
|
expect(queueSection).toMatch(/0o700/);
|
|
});
|
|
|
|
it('cli.ts queue file creation must use restricted permissions', () => {
|
|
const CLI_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/cli.ts'), 'utf-8');
|
|
const queueSection = CLI_SRC.slice(CLI_SRC.indexOf('queue') || 0, CLI_SRC.indexOf('queue') + 2000);
|
|
expect(queueSection).toMatch(/0o700|0o600|mode/);
|
|
});
|
|
|
|
it('queue reader must have a validator function covering all fields', () => {
|
|
// Extract ONLY the validator function body by walking braces
|
|
const validatorStart = AGENT_SRC.indexOf('function isValidQueueEntry');
|
|
expect(validatorStart).toBeGreaterThan(-1);
|
|
let depth = 0;
|
|
let bodyStart = AGENT_SRC.indexOf('{', validatorStart);
|
|
let bodyEnd = bodyStart;
|
|
for (let i = bodyStart; i < AGENT_SRC.length; i++) {
|
|
if (AGENT_SRC[i] === '{') depth++;
|
|
if (AGENT_SRC[i] === '}') depth--;
|
|
if (depth === 0) { bodyEnd = i + 1; break; }
|
|
}
|
|
const validatorBlock = AGENT_SRC.slice(validatorStart, bodyEnd);
|
|
|
|
expect(validatorBlock).toMatch(/prompt.*string/);
|
|
expect(validatorBlock).toMatch(/Array\.isArray/);
|
|
expect(validatorBlock).toMatch(/\.\./);
|
|
expect(validatorBlock).toContain('stateFile');
|
|
expect(validatorBlock).toContain('tabId');
|
|
expect(validatorBlock).toMatch(/number/);
|
|
expect(validatorBlock).toContain('null');
|
|
expect(validatorBlock).toContain('message');
|
|
expect(validatorBlock).toContain('pageUrl');
|
|
expect(validatorBlock).toContain('sessionId');
|
|
});
|
|
});
|
|
|
|
// ─── Shared source reads for CSS validator tests ────────────────────────────
|
|
const CDP_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/cdp-inspector.ts'), 'utf-8');
|
|
const EXTENSION_SRC = fs.readFileSync(
|
|
path.join(import.meta.dir, '../../extension/inspector.js'),
|
|
'utf-8'
|
|
);
|
|
|
|
// ─── Task 2: Shared CSS value validator ─────────────────────────────────────
|
|
|
|
describe('Task 2: CSS value validator blocks dangerous patterns', () => {
|
|
describe('source-level checks', () => {
|
|
it('write-commands.ts style handler contains DANGEROUS_CSS url check', () => {
|
|
const styleBlock = sliceBetween(WRITE_SRC, "case 'style':", 'case \'cleanup\'');
|
|
expect(styleBlock).toMatch(/url\\s\*\\\(/);
|
|
});
|
|
|
|
it('write-commands.ts style handler blocks expression()', () => {
|
|
const styleBlock = sliceBetween(WRITE_SRC, "case 'style':", "case 'cleanup'");
|
|
expect(styleBlock).toMatch(/expression\\s\*\\\(/);
|
|
});
|
|
|
|
it('write-commands.ts style handler blocks @import', () => {
|
|
const styleBlock = sliceBetween(WRITE_SRC, "case 'style':", "case 'cleanup'");
|
|
expect(styleBlock).toContain('@import');
|
|
});
|
|
|
|
it('cdp-inspector.ts modifyStyle contains DANGEROUS_CSS url check', () => {
|
|
const fn = extractFunction(CDP_SRC, 'modifyStyle');
|
|
expect(fn).toBeTruthy();
|
|
expect(fn).toMatch(/url\\s\*\\\(/);
|
|
});
|
|
|
|
it('cdp-inspector.ts modifyStyle blocks @import', () => {
|
|
const fn = extractFunction(CDP_SRC, 'modifyStyle');
|
|
expect(fn).toContain('@import');
|
|
});
|
|
|
|
it('extension injectCSS validates id format', () => {
|
|
const fn = extractFunction(EXTENSION_SRC, 'injectCSS');
|
|
expect(fn).toBeTruthy();
|
|
// Should contain a regex test for valid id characters
|
|
expect(fn).toMatch(/\^?\[a-zA-Z0-9_-\]/);
|
|
});
|
|
|
|
it('extension injectCSS blocks dangerous CSS patterns', () => {
|
|
const fn = extractFunction(EXTENSION_SRC, 'injectCSS');
|
|
expect(fn).toMatch(/url\\s\*\\\(/);
|
|
});
|
|
|
|
it('extension toggleClass validates className format', () => {
|
|
const fn = extractFunction(EXTENSION_SRC, 'toggleClass');
|
|
expect(fn).toBeTruthy();
|
|
expect(fn).toMatch(/\^?\[a-zA-Z0-9_-\]/);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── Task 1: Harden validateOutputPath to use realpathSync ──────────────────
|
|
|
|
describe('Task 1: validateOutputPath uses realpathSync', () => {
|
|
describe('source-level checks', () => {
|
|
it('path-security.ts validateOutputPath contains realpathSync', () => {
|
|
const fn = extractFunction(PATH_SECURITY_SRC, 'validateOutputPath');
|
|
expect(fn).toBeTruthy();
|
|
expect(fn).toContain('realpathSync');
|
|
});
|
|
|
|
it('path-security.ts SAFE_DIRECTORIES resolves with realpathSync', () => {
|
|
const safeBlock = sliceBetween(PATH_SECURITY_SRC, 'const SAFE_DIRECTORIES', ';');
|
|
expect(safeBlock).toContain('realpathSync');
|
|
});
|
|
|
|
it('meta-commands.ts re-exports validateOutputPath from path-security', () => {
|
|
expect(META_SRC).toContain("from './path-security'");
|
|
expect(META_SRC).toContain('validateOutputPath');
|
|
});
|
|
|
|
it('write-commands.ts imports validateOutputPath from path-security', () => {
|
|
expect(WRITE_SRC).toContain("from './path-security'");
|
|
expect(WRITE_SRC).toContain('validateOutputPath');
|
|
});
|
|
});
|
|
|
|
describe('behavioral checks', () => {
|
|
let tmpDir: string;
|
|
let symlinkPath: string;
|
|
|
|
beforeAll(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-sec-test-'));
|
|
symlinkPath = path.join(tmpDir, 'evil-link');
|
|
try {
|
|
fs.symlinkSync('/etc', symlinkPath);
|
|
} catch {
|
|
symlinkPath = '';
|
|
}
|
|
});
|
|
|
|
afterAll(() => {
|
|
try {
|
|
if (symlinkPath) fs.unlinkSync(symlinkPath);
|
|
fs.rmdirSync(tmpDir);
|
|
} catch {
|
|
// best-effort cleanup
|
|
}
|
|
});
|
|
|
|
it('meta-commands validateOutputPath rejects path through /etc symlink', async () => {
|
|
if (!symlinkPath) {
|
|
console.warn('Skipping: symlink creation failed');
|
|
return;
|
|
}
|
|
const mod = await import('../src/meta-commands.ts');
|
|
const attackPath = path.join(symlinkPath, 'passwd');
|
|
expect(() => mod.validateOutputPath(attackPath)).toThrow();
|
|
});
|
|
|
|
it('realpathSync on symlink-to-/etc resolves to /etc (out of safe dirs)', () => {
|
|
if (!symlinkPath) {
|
|
console.warn('Skipping: symlink creation failed');
|
|
return;
|
|
}
|
|
const resolvedLink = fs.realpathSync(symlinkPath);
|
|
// macOS: /etc -> /private/etc
|
|
expect(resolvedLink).toBe(fs.realpathSync('/etc'));
|
|
const TEMP_DIR_VAL = process.platform === 'win32' ? os.tmpdir() : '/tmp';
|
|
const safeDirs = [TEMP_DIR_VAL, process.cwd()].map(d => {
|
|
try { return fs.realpathSync(d); } catch { return d; }
|
|
});
|
|
const passwdReal = path.join(resolvedLink, 'passwd');
|
|
const isSafe = safeDirs.some(d => passwdReal === d || passwdReal.startsWith(d + path.sep));
|
|
expect(isSafe).toBe(false);
|
|
});
|
|
|
|
it('meta-commands validateOutputPath accepts legitimate tmpdir paths', async () => {
|
|
const mod = await import('../src/meta-commands.ts');
|
|
// Use /tmp (which resolves to /private/tmp on macOS) — matches SAFE_DIRECTORIES
|
|
const tmpBase = process.platform === 'darwin' ? '/tmp' : os.tmpdir();
|
|
const legitimatePath = path.join(tmpBase, 'gstack-screenshot.png');
|
|
expect(() => mod.validateOutputPath(legitimatePath)).not.toThrow();
|
|
});
|
|
|
|
it('meta-commands validateOutputPath accepts paths in cwd', async () => {
|
|
const mod = await import('../src/meta-commands.ts');
|
|
const cwdPath = path.join(process.cwd(), 'output.png');
|
|
expect(() => mod.validateOutputPath(cwdPath)).not.toThrow();
|
|
});
|
|
|
|
it('meta-commands validateOutputPath rejects paths outside safe dirs', async () => {
|
|
const mod = await import('../src/meta-commands.ts');
|
|
expect(() => mod.validateOutputPath('/home/user/secret.png')).toThrow(/Path must be within/);
|
|
expect(() => mod.validateOutputPath('/var/log/access.log')).toThrow(/Path must be within/);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── Round-2 review findings: applyStyle CSS check ──────────────────────────
|
|
|
|
describe('Round-2 finding 1: extension applyStyle blocks dangerous CSS values', () => {
|
|
const INSPECTOR_SRC = fs.readFileSync(
|
|
path.join(import.meta.dir, '../../extension/inspector.js'),
|
|
'utf-8'
|
|
);
|
|
|
|
it('applyStyle function exists in inspector.js', () => {
|
|
const fn = extractFunction(INSPECTOR_SRC, 'applyStyle');
|
|
expect(fn).toBeTruthy();
|
|
});
|
|
|
|
it('applyStyle validates CSS value with url() block', () => {
|
|
const fn = extractFunction(INSPECTOR_SRC, 'applyStyle');
|
|
// Source contains literal regex /url\s*\(/ — match the source-level escape sequence
|
|
expect(fn).toMatch(/url\\s\*\\\(/);
|
|
});
|
|
|
|
it('applyStyle blocks expression()', () => {
|
|
const fn = extractFunction(INSPECTOR_SRC, 'applyStyle');
|
|
expect(fn).toMatch(/expression\\s\*\\\(/);
|
|
});
|
|
|
|
it('applyStyle blocks @import', () => {
|
|
const fn = extractFunction(INSPECTOR_SRC, 'applyStyle');
|
|
expect(fn).toContain('@import');
|
|
});
|
|
|
|
it('applyStyle blocks javascript: scheme', () => {
|
|
const fn = extractFunction(INSPECTOR_SRC, 'applyStyle');
|
|
expect(fn).toContain('javascript:');
|
|
});
|
|
|
|
it('applyStyle blocks data: scheme', () => {
|
|
const fn = extractFunction(INSPECTOR_SRC, 'applyStyle');
|
|
expect(fn).toContain('data:');
|
|
});
|
|
|
|
it('applyStyle value check appears before setProperty call', () => {
|
|
const fn = extractFunction(INSPECTOR_SRC, 'applyStyle');
|
|
// Check that the CSS value guard (url\s*\() appears before setProperty
|
|
const valueCheckIdx = fn.search(/url\\s\*\\\(/);
|
|
const setPropIdx = fn.indexOf('setProperty');
|
|
expect(valueCheckIdx).toBeGreaterThan(-1);
|
|
expect(setPropIdx).toBeGreaterThan(-1);
|
|
expect(valueCheckIdx).toBeLessThan(setPropIdx);
|
|
});
|
|
});
|
|
|
|
// ─── Round-2 finding 2: snapshot.ts annotated path uses realpathSync ────────
|
|
|
|
describe('Round-2 finding 2: snapshot.ts annotated path uses realpathSync', () => {
|
|
it('snapshot.ts annotated screenshot section contains realpathSync', () => {
|
|
// Slice the annotated screenshot block from the source
|
|
const annotateStart = SNAPSHOT_SRC.indexOf('opts.annotate');
|
|
expect(annotateStart).toBeGreaterThan(-1);
|
|
const annotateBlock = SNAPSHOT_SRC.slice(annotateStart, annotateStart + 2000);
|
|
expect(annotateBlock).toContain('realpathSync');
|
|
});
|
|
|
|
it('snapshot.ts annotated path validation resolves safe dirs with realpathSync', () => {
|
|
const annotateStart = SNAPSHOT_SRC.indexOf('opts.annotate');
|
|
const annotateBlock = SNAPSHOT_SRC.slice(annotateStart, annotateStart + 2000);
|
|
// safeDirs array must be built with .map() that calls realpathSync
|
|
// Pattern: [TEMP_DIR, process.cwd()].map(...realpathSync...)
|
|
expect(annotateBlock).toContain('[TEMP_DIR, process.cwd()].map');
|
|
expect(annotateBlock).toContain('realpathSync');
|
|
});
|
|
});
|
|
|
|
// ─── Round-2 finding 3: stateFile path traversal check in isValidQueueEntry ─
|
|
|
|
describe('Round-2 finding 3: isValidQueueEntry checks stateFile for path traversal', () => {
|
|
it('isValidQueueEntry checks stateFile for .. traversal sequences', () => {
|
|
const fn = extractFunction(AGENT_SRC, 'isValidQueueEntry');
|
|
expect(fn).toBeTruthy();
|
|
// Must check stateFile for '..' — find the stateFile block and look for '..' string
|
|
const stateFileIdx = fn.indexOf('stateFile');
|
|
expect(stateFileIdx).toBeGreaterThan(-1);
|
|
const stateFileBlock = fn.slice(stateFileIdx, stateFileIdx + 200);
|
|
// The block must contain a check for the two-dot traversal sequence
|
|
expect(stateFileBlock).toMatch(/'\.\.'|"\.\."|\.\./);
|
|
});
|
|
|
|
it('isValidQueueEntry stateFile block contains both type check and traversal check', () => {
|
|
const fn = extractFunction(AGENT_SRC, 'isValidQueueEntry');
|
|
const stateFileIdx = fn.indexOf('stateFile');
|
|
const stateBlock = fn.slice(stateFileIdx, stateFileIdx + 300);
|
|
// Must contain the type check
|
|
expect(stateBlock).toContain('typeof obj.stateFile');
|
|
// Must contain the includes('..') call
|
|
expect(stateBlock).toMatch(/includes\s*\(\s*['"]\.\.['"]\s*\)/);
|
|
});
|
|
});
|
|
|
|
// ─── Task 5: /health endpoint must not expose sensitive fields ───────────────
|
|
|
|
describe('/health endpoint security', () => {
|
|
it('must not expose currentMessage', () => {
|
|
const block = sliceBetween(SERVER_SRC, "url.pathname === '/health'", "url.pathname === '/refs'");
|
|
expect(block).not.toContain('currentMessage');
|
|
});
|
|
it('must not expose currentUrl', () => {
|
|
const block = sliceBetween(SERVER_SRC, "url.pathname === '/health'", "url.pathname === '/refs'");
|
|
expect(block).not.toContain('currentUrl');
|
|
});
|
|
});
|
|
|
|
// ─── Task 6: frame --url ReDoS fix ──────────────────────────────────────────
|
|
|
|
describe('frame --url ReDoS fix', () => {
|
|
it('frame --url section does not pass raw user input to new RegExp()', () => {
|
|
const block = sliceBetween(META_SRC, "target === '--url'", 'else {');
|
|
expect(block).not.toMatch(/new RegExp\(args\[/);
|
|
});
|
|
|
|
it('frame --url section uses escapeRegExp before constructing RegExp', () => {
|
|
const block = sliceBetween(META_SRC, "target === '--url'", 'else {');
|
|
expect(block).toContain('escapeRegExp');
|
|
});
|
|
|
|
it('escapeRegExp neutralizes catastrophic patterns (behavioral)', async () => {
|
|
const mod = await import('../src/meta-commands.ts');
|
|
const { escapeRegExp } = mod as any;
|
|
expect(typeof escapeRegExp).toBe('function');
|
|
const evil = '(a+)+$';
|
|
const escaped = escapeRegExp(evil);
|
|
const start = Date.now();
|
|
new RegExp(escaped).test('aaaaaaaaaaaaaaaaaaaaaaaaaaa!');
|
|
expect(Date.now() - start).toBeLessThan(100);
|
|
});
|
|
});
|
|
|
|
// ─── Task 7: watch-mode guard in chain command ───────────────────────────────
|
|
|
|
describe('chain command watch-mode guard', () => {
|
|
it('chain loop contains isWatching() guard before write dispatch', () => {
|
|
// Post-alias refactor: loop iterates over canonicalized `c of commands`.
|
|
const block = sliceBetween(META_SRC, 'for (const c of commands)', 'Wait for network to settle');
|
|
expect(block).toContain('isWatching');
|
|
});
|
|
|
|
it('chain loop BLOCKED message appears for write commands in watch mode', () => {
|
|
const block = sliceBetween(META_SRC, 'for (const c of commands)', 'Wait for network to settle');
|
|
expect(block).toContain('BLOCKED: write commands disabled in watch mode');
|
|
});
|
|
});
|
|
|
|
// ─── Task 8: Cookie domain validation ───────────────────────────────────────
|
|
|
|
describe('cookie-import domain validation', () => {
|
|
it('cookie-import handler validates cookie domain against page domain', () => {
|
|
const block = sliceBetween(WRITE_SRC, "case 'cookie-import':", "case 'cookie-import-browser':");
|
|
expect(block).toContain('cookieDomain');
|
|
expect(block).toContain('defaultDomain');
|
|
expect(block).toContain('does not match current page domain');
|
|
});
|
|
|
|
it('cookie-import-browser handler validates --domain against page hostname', () => {
|
|
const block = sliceBetween(WRITE_SRC, "case 'cookie-import-browser':", "case 'style':");
|
|
expect(block).toContain('normalizedDomain');
|
|
expect(block).toContain('pageHostname');
|
|
expect(block).toContain('does not match current page domain');
|
|
});
|
|
});
|
|
|
|
// ─── Task 9: loadSession ID validation ──────────────────────────────────────
|
|
|
|
describe('loadSession session ID validation', () => {
|
|
it('loadSession validates session ID format before using it in a path', () => {
|
|
const fn = extractFunction(SERVER_SRC, 'loadSession');
|
|
expect(fn).toBeTruthy();
|
|
// Must contain the alphanumeric regex guard
|
|
expect(fn).toMatch(/\[a-zA-Z0-9_-\]/);
|
|
});
|
|
|
|
it('loadSession returns null on invalid session ID', () => {
|
|
const fn = extractFunction(SERVER_SRC, 'loadSession');
|
|
const block = fn.slice(fn.indexOf('activeData.id'));
|
|
// Must warn and return null
|
|
expect(block).toContain('Invalid session ID');
|
|
expect(block).toContain('return null');
|
|
});
|
|
});
|
|
|
|
// ─── Task 10: Responsive screenshot path validation ──────────────────────────
|
|
|
|
describe('Task 10: responsive screenshot path validation', () => {
|
|
it('responsive loop contains validateOutputPath before page.screenshot()', () => {
|
|
// Extract the responsive case block
|
|
const block = sliceBetween(META_SRC, "case 'responsive':", 'Restore original viewport');
|
|
expect(block).toBeTruthy();
|
|
expect(block).toContain('validateOutputPath');
|
|
});
|
|
|
|
it('responsive loop calls validateOutputPath on the per-viewport path, not just the prefix', () => {
|
|
const block = sliceBetween(META_SRC, 'for (const vp of viewports)', 'Restore original viewport');
|
|
expect(block).toContain('validateOutputPath');
|
|
});
|
|
|
|
it('validateOutputPath appears before page.screenshot() in the loop', () => {
|
|
const block = sliceBetween(META_SRC, 'for (const vp of viewports)', 'Restore original viewport');
|
|
const validateIdx = block.indexOf('validateOutputPath');
|
|
const screenshotIdx = block.indexOf('page.screenshot');
|
|
expect(validateIdx).toBeGreaterThan(-1);
|
|
expect(screenshotIdx).toBeGreaterThan(-1);
|
|
expect(validateIdx).toBeLessThan(screenshotIdx);
|
|
});
|
|
|
|
it('results.push is present in the loop block (loop structure intact)', () => {
|
|
const block = sliceBetween(META_SRC, 'for (const vp of viewports)', 'Restore original viewport');
|
|
expect(block).toContain('results.push');
|
|
});
|
|
});
|
|
|
|
// ─── Task 11: State load — cookie + page URL validation ──────────────────────
|
|
|
|
const BROWSER_MANAGER_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/browser-manager.ts'), 'utf-8');
|
|
|
|
describe('Task 11: state load cookie validation', () => {
|
|
it('state load block filters cookies by domain and type', () => {
|
|
const block = sliceBetween(META_SRC, "action === 'load'", "throw new Error('Usage: state save|load");
|
|
expect(block).toContain('cookie');
|
|
expect(block).toContain('domain');
|
|
expect(block).toContain('filter');
|
|
});
|
|
|
|
it('state load block checks for localhost and .internal in cookie domains', () => {
|
|
const block = sliceBetween(META_SRC, "action === 'load'", "throw new Error('Usage: state save|load");
|
|
expect(block).toContain('localhost');
|
|
expect(block).toContain('.internal');
|
|
});
|
|
|
|
it('state load block uses validatedCookies when calling restoreState', () => {
|
|
const block = sliceBetween(META_SRC, "action === 'load'", "throw new Error('Usage: state save|load");
|
|
expect(block).toContain('validatedCookies');
|
|
// Must pass validatedCookies to restoreState, not the raw data.cookies
|
|
const restoreIdx = block.indexOf('restoreState');
|
|
const restoreBlock = block.slice(restoreIdx, restoreIdx + 200);
|
|
expect(restoreBlock).toContain('validatedCookies');
|
|
});
|
|
|
|
it('browser-manager restoreState validates page URL before goto', () => {
|
|
// restoreState is a class method — use sliceBetween to extract the method body
|
|
const restoreFn = sliceBetween(BROWSER_MANAGER_SRC, 'async restoreState(', 'async recreateContext(');
|
|
expect(restoreFn).toBeTruthy();
|
|
expect(restoreFn).toContain('validateNavigationUrl');
|
|
});
|
|
|
|
it('browser-manager restoreState skips invalid URLs with a warning', () => {
|
|
const restoreFn = sliceBetween(BROWSER_MANAGER_SRC, 'async restoreState(', 'async recreateContext(');
|
|
expect(restoreFn).toContain('Skipping invalid URL');
|
|
expect(restoreFn).toContain('continue');
|
|
});
|
|
|
|
it('validateNavigationUrl call appears before page.goto in restoreState', () => {
|
|
const restoreFn = sliceBetween(BROWSER_MANAGER_SRC, 'async restoreState(', 'async recreateContext(');
|
|
const validateIdx = restoreFn.indexOf('validateNavigationUrl');
|
|
const gotoIdx = restoreFn.indexOf('page.goto');
|
|
expect(validateIdx).toBeGreaterThan(-1);
|
|
expect(gotoIdx).toBeGreaterThan(-1);
|
|
expect(validateIdx).toBeLessThan(gotoIdx);
|
|
});
|
|
});
|
|
|
|
// ─── Task 12: Validate activeTabUrl before syncActiveTabByUrl ─────────────────
|
|
|
|
describe('Task 12: activeTabUrl sanitized before syncActiveTabByUrl', () => {
|
|
it('sidebar-tabs route sanitizes activeUrl before syncActiveTabByUrl', () => {
|
|
const block = sliceBetween(SERVER_SRC, "url.pathname === '/sidebar-tabs'", "url.pathname === '/sidebar-tabs/switch'");
|
|
expect(block).toContain('sanitizeExtensionUrl');
|
|
expect(block).toContain('syncActiveTabByUrl');
|
|
const sanitizeIdx = block.indexOf('sanitizeExtensionUrl');
|
|
const syncIdx = block.indexOf('syncActiveTabByUrl');
|
|
expect(sanitizeIdx).toBeLessThan(syncIdx);
|
|
});
|
|
|
|
it('sidebar-command route sanitizes extensionUrl before syncActiveTabByUrl', () => {
|
|
const block = sliceBetween(SERVER_SRC, "url.pathname === '/sidebar-command'", "url.pathname === '/sidebar-chat/clear'");
|
|
expect(block).toContain('sanitizeExtensionUrl');
|
|
expect(block).toContain('syncActiveTabByUrl');
|
|
const sanitizeIdx = block.indexOf('sanitizeExtensionUrl');
|
|
const syncIdx = block.indexOf('syncActiveTabByUrl');
|
|
expect(sanitizeIdx).toBeLessThan(syncIdx);
|
|
});
|
|
|
|
it('direct unsanitized syncActiveTabByUrl calls are not present (all calls go through sanitize)', () => {
|
|
// Every syncActiveTabByUrl call should be preceded by sanitizeExtensionUrl in the nearby code
|
|
// We verify there are no direct browserManager.syncActiveTabByUrl(activeUrl) or
|
|
// browserManager.syncActiveTabByUrl(extensionUrl) patterns (without sanitize wrapper)
|
|
const block1 = sliceBetween(SERVER_SRC, "url.pathname === '/sidebar-tabs'", "url.pathname === '/sidebar-tabs/switch'");
|
|
// Should NOT contain direct call with raw activeUrl
|
|
expect(block1).not.toMatch(/syncActiveTabByUrl\(activeUrl\)/);
|
|
|
|
const block2 = sliceBetween(SERVER_SRC, "url.pathname === '/sidebar-command'", "url.pathname === '/sidebar-chat/clear'");
|
|
// Should NOT contain direct call with raw extensionUrl
|
|
expect(block2).not.toMatch(/syncActiveTabByUrl\(extensionUrl\)/);
|
|
});
|
|
});
|
|
|
|
// ─── Task 13: Inbox output wrapped as untrusted ──────────────────────────────
|
|
|
|
describe('Task 13: inbox output wrapped as untrusted content', () => {
|
|
it('inbox handler wraps userMessage with wrapUntrustedContent', () => {
|
|
const block = sliceBetween(META_SRC, "case 'inbox':", "case 'state':");
|
|
expect(block).toContain('wrapUntrustedContent');
|
|
});
|
|
|
|
it('inbox handler applies wrapUntrustedContent to userMessage', () => {
|
|
const block = sliceBetween(META_SRC, "case 'inbox':", "case 'state':");
|
|
// Should wrap userMessage
|
|
expect(block).toMatch(/wrapUntrustedContent.*userMessage|userMessage.*wrapUntrustedContent/);
|
|
});
|
|
|
|
it('inbox handler applies wrapUntrustedContent to url', () => {
|
|
const block = sliceBetween(META_SRC, "case 'inbox':", "case 'state':");
|
|
// Should also wrap url
|
|
expect(block).toMatch(/wrapUntrustedContent.*msg\.url|msg\.url.*wrapUntrustedContent/);
|
|
});
|
|
|
|
it('wrapUntrustedContent calls appear in the message formatting loop', () => {
|
|
const block = sliceBetween(META_SRC, 'for (const msg of messages)', 'Handle --clear flag');
|
|
expect(block).toContain('wrapUntrustedContent');
|
|
});
|
|
});
|
|
|
|
// ─── Task 14: DOM serialization round-trip replaced with DocumentFragment ─────
|
|
|
|
const SIDEPANEL_SRC = fs.readFileSync(path.join(import.meta.dir, '../../extension/sidepanel.js'), 'utf-8');
|
|
|
|
describe('Task 14: switchChatTab uses DocumentFragment, not innerHTML round-trip', () => {
|
|
it('switchChatTab does NOT use innerHTML to restore chat (string-based re-parse removed)', () => {
|
|
const fn = extractFunction(SIDEPANEL_SRC, 'switchChatTab');
|
|
expect(fn).toBeTruthy();
|
|
// Must NOT have the dangerous pattern of assigning chatDomByTab value back to innerHTML
|
|
expect(fn).not.toMatch(/chatMessages\.innerHTML\s*=\s*chatDomByTab/);
|
|
});
|
|
|
|
it('switchChatTab uses createDocumentFragment to save chat DOM', () => {
|
|
const fn = extractFunction(SIDEPANEL_SRC, 'switchChatTab');
|
|
expect(fn).toContain('createDocumentFragment');
|
|
});
|
|
|
|
it('switchChatTab moves nodes via appendChild/firstChild (not innerHTML assignment)', () => {
|
|
const fn = extractFunction(SIDEPANEL_SRC, 'switchChatTab');
|
|
// Must use appendChild to restore nodes from fragment
|
|
expect(fn).toContain('chatMessages.appendChild');
|
|
});
|
|
|
|
it('chatDomByTab comment documents that values are DocumentFragments, not strings', () => {
|
|
// Check module-level comment on chatDomByTab
|
|
const commentIdx = SIDEPANEL_SRC.indexOf('chatDomByTab');
|
|
const commentLine = SIDEPANEL_SRC.slice(commentIdx, commentIdx + 120);
|
|
expect(commentLine).toMatch(/DocumentFragment|fragment/i);
|
|
});
|
|
|
|
it('welcome screen is built with DOM methods in the else branch (not innerHTML)', () => {
|
|
const fn = extractFunction(SIDEPANEL_SRC, 'switchChatTab');
|
|
// The else branch must use createElement, not innerHTML template literal
|
|
expect(fn).toContain('createElement');
|
|
// The specific innerHTML template with chat-welcome must be gone
|
|
expect(fn).not.toMatch(/innerHTML\s*=\s*`[\s\S]*?chat-welcome/);
|
|
});
|
|
});
|
|
|
|
// ─── Task 15: pollChat/switchChatTab reentrancy guard ────────────────────────
|
|
|
|
describe('Task 15: pollChat reentrancy guard and deferred call in switchChatTab', () => {
|
|
it('pollInProgress guard variable is declared at module scope', () => {
|
|
// Must be declared before any function definitions (within first 2000 chars)
|
|
const moduleTop = SIDEPANEL_SRC.slice(0, 2000);
|
|
expect(moduleTop).toContain('pollInProgress');
|
|
});
|
|
|
|
it('pollChat function checks and sets pollInProgress', () => {
|
|
const fn = extractFunction(SIDEPANEL_SRC, 'pollChat');
|
|
expect(fn).toBeTruthy();
|
|
expect(fn).toContain('pollInProgress');
|
|
});
|
|
|
|
it('pollChat resets pollInProgress in finally block', () => {
|
|
const fn = extractFunction(SIDEPANEL_SRC, 'pollChat');
|
|
// The finally block must contain the reset
|
|
const finallyIdx = fn.indexOf('finally');
|
|
expect(finallyIdx).toBeGreaterThan(-1);
|
|
const finallyBlock = fn.slice(finallyIdx, finallyIdx + 60);
|
|
expect(finallyBlock).toContain('pollInProgress');
|
|
});
|
|
|
|
it('switchChatTab calls pollChat via setTimeout (not directly)', () => {
|
|
const fn = extractFunction(SIDEPANEL_SRC, 'switchChatTab');
|
|
// Must use setTimeout to defer pollChat — no direct call at the end
|
|
expect(fn).toMatch(/setTimeout\s*\(\s*pollChat/);
|
|
// Must NOT have a bare direct call `pollChat()` at the end (outside setTimeout)
|
|
// We check that there is no standalone `pollChat()` call (outside setTimeout wrapper)
|
|
const withoutSetTimeout = fn.replace(/setTimeout\s*\(\s*pollChat[^)]*\)/g, '');
|
|
expect(withoutSetTimeout).not.toMatch(/\bpollChat\s*\(\s*\)/);
|
|
});
|
|
});
|
|
|
|
// ─── Task 16: SIGKILL escalation in sidebar-agent timeout ────────────────────
|
|
|
|
describe('Task 16: sidebar-agent timeout handler uses SIGTERM→SIGKILL escalation', () => {
|
|
it('timeout block sends SIGTERM first', () => {
|
|
// Slice from "Timed out" / setTimeout block to processingTabs.delete
|
|
const timeoutStart = AGENT_SRC.indexOf("SIDEBAR_AGENT_TIMEOUT");
|
|
expect(timeoutStart).toBeGreaterThan(-1);
|
|
const timeoutBlock = AGENT_SRC.slice(timeoutStart, timeoutStart + 600);
|
|
expect(timeoutBlock).toContain('SIGTERM');
|
|
});
|
|
|
|
it('timeout block escalates to SIGKILL after delay', () => {
|
|
const timeoutStart = AGENT_SRC.indexOf("SIDEBAR_AGENT_TIMEOUT");
|
|
const timeoutBlock = AGENT_SRC.slice(timeoutStart, timeoutStart + 600);
|
|
expect(timeoutBlock).toContain('SIGKILL');
|
|
});
|
|
|
|
it('SIGTERM appears before SIGKILL in timeout block', () => {
|
|
const timeoutStart = AGENT_SRC.indexOf("SIDEBAR_AGENT_TIMEOUT");
|
|
const timeoutBlock = AGENT_SRC.slice(timeoutStart, timeoutStart + 600);
|
|
const sigtermIdx = timeoutBlock.indexOf('SIGTERM');
|
|
const sigkillIdx = timeoutBlock.indexOf('SIGKILL');
|
|
expect(sigtermIdx).toBeGreaterThan(-1);
|
|
expect(sigkillIdx).toBeGreaterThan(-1);
|
|
expect(sigtermIdx).toBeLessThan(sigkillIdx);
|
|
});
|
|
});
|
|
|
|
// ─── Task 17: viewport and wait bounds clamping ──────────────────────────────
|
|
|
|
describe('Task 17: viewport dimensions and wait timeouts are clamped', () => {
|
|
it('viewport case clamps width and height with Math.min/Math.max', () => {
|
|
const block = sliceBetween(WRITE_SRC, "case 'viewport':", "case 'cookie':");
|
|
expect(block).toBeTruthy();
|
|
expect(block).toMatch(/Math\.min|Math\.max/);
|
|
});
|
|
|
|
it('viewport case uses rawW/rawH before clamping (not direct destructure)', () => {
|
|
const block = sliceBetween(WRITE_SRC, "case 'viewport':", "case 'cookie':");
|
|
expect(block).toContain('rawW');
|
|
expect(block).toContain('rawH');
|
|
});
|
|
|
|
it('wait case (networkidle branch) clamps timeout with MAX_WAIT_MS', () => {
|
|
const block = sliceBetween(WRITE_SRC, "case 'wait':", "case 'viewport':");
|
|
expect(block).toBeTruthy();
|
|
expect(block).toMatch(/MAX_WAIT_MS/);
|
|
});
|
|
|
|
it('wait case (element branch) also clamps timeout', () => {
|
|
const block = sliceBetween(WRITE_SRC, "case 'wait':", "case 'viewport':");
|
|
// Both the networkidle and element branches declare MAX_WAIT_MS
|
|
const maxWaitCount = (block.match(/MAX_WAIT_MS/g) || []).length;
|
|
expect(maxWaitCount).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
it('wait case uses MIN_WAIT_MS as a floor', () => {
|
|
const block = sliceBetween(WRITE_SRC, "case 'wait':", "case 'viewport':");
|
|
expect(block).toContain('MIN_WAIT_MS');
|
|
});
|
|
});
|