mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-16 01:45:29 +02:00
fix(security): delete dead exports the ripped chat path left behind
Three-way split by importer class: (a) Zero importers, deleted: the whole attack-attempt logging cluster in security.ts (logAttempt, AttemptRecord, salted hashPayload + device-salt, attempts.jsonl rotation, telemetry spawn plumbing incl. buildTelemetrySpawnCommand/resolveBashBinary — the LIVE attempts.jsonl writer is tunnel-denial-log.ts with its own rotation); the decision-file handshake (writeDecision/readDecision/clearDecision/excerptForReview — written for sidebar-agent's poll loop, which no longer exists); sidebar-utils.ts (whole module — its sanitizeExtensionUrl 'sanitized before embedding in a prompt' for the deleted prompt builder); 8 dead server.ts imports (sanitizeExtensionUrl, generateCanary, injectCanary, writeDecision, rotateRoot, serializeRegistry, restoreRegistry, clearAgentRecord); buildPtyClearCookie + buildSseClearCookie; WEBDRIVER_MASK_SCRIPT (orphaned by the D7 stealth narrowing — applyStealth never used it). (b) Dead-pin tests edited with their exports: the 'still exported' pin in stealth-layer-c, the string-content describe in stealth-webdriver (its live applyStealth behavioral coverage untouched), the clear-cookie assertions, security-review-flow.test.ts deleted whole (all 4 describes exercised the dead decision mechanism, incl. a 'simulated sidebar-agent poll loop'). (c) KEPT deliberately: leaseCount (live behavioral coverage), extractPtyCookie + validatePtySessionToken (extractPtyCookie is adopted by the terminal-agent cookie-parse unification later in this wave), resetSessionMarker + clearContentFilters (test-support API for the live content-security layer). Also fixes two pre-existing red pins found while here, invisible until the free suite got a CI job: the v1.44 spawnClaude->maybeSpawnPty rename in terminal-agent.test.ts, and a cross-file test-isolation bug where content-security.test.ts's clearContentFilters() wiped the auto-registered url-blocklist filter for every later file in the same bun process (security-integration.test.ts failed on co-run; afterAll now restores it). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
44d58aa6ee
commit
ef186cccb3
@@ -124,6 +124,16 @@ describe('Content filter hooks', () => {
|
||||
clearContentFilters();
|
||||
});
|
||||
|
||||
// clearContentFilters() wipes MODULE state shared across every test file in
|
||||
// the same bun process — without restoring the built-in registration,
|
||||
// security-integration.test.ts (which asserts the auto-registered blocklist
|
||||
// pipeline) fails whenever the two files co-run. Pre-existing co-run bug,
|
||||
// invisible until the free suite got a CI job.
|
||||
afterAll(() => {
|
||||
clearContentFilters();
|
||||
registerContentFilter(urlBlocklistFilter);
|
||||
});
|
||||
|
||||
test('URL blocklist detects requestbin', () => {
|
||||
const result = urlBlocklistFilter('', 'https://requestbin.com/r/abc', 'text');
|
||||
expect(result.safe).toBe(false);
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
/**
|
||||
* Review-on-BLOCK regression tests.
|
||||
*
|
||||
* Covers the user-in-the-loop path added to resolve false positives on
|
||||
* benign developer content (e.g., HN comments discussing a prompt injection
|
||||
* incident getting flagged as prompt injection). Instead of hard-stopping
|
||||
* the session on a tool-output BLOCK, the agent emits a reviewable
|
||||
* security_event and polls for the user's decision via a per-tab file.
|
||||
*
|
||||
* These tests pin the file-based handshake and the excerpt sanitization.
|
||||
*/
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
writeDecision,
|
||||
readDecision,
|
||||
clearDecision,
|
||||
decisionFileForTab,
|
||||
excerptForReview,
|
||||
type Verdict,
|
||||
} from '../src/security';
|
||||
|
||||
const ORIG_HOME = process.env.HOME;
|
||||
let tmpHome = '';
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sec-review-'));
|
||||
process.env.HOME = tmpHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.HOME = ORIG_HOME;
|
||||
try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch {}
|
||||
});
|
||||
|
||||
describe('security decision file handshake', () => {
|
||||
test('writeDecision + readDecision round-trips', () => {
|
||||
// SECURITY_DIR is computed at module load time from the original HOME.
|
||||
// The function writes relative to its own SECURITY_DIR constant, so we
|
||||
// verify the API shape rather than the exact path. The file lives where
|
||||
// decisionFileForTab says it does.
|
||||
const file = decisionFileForTab(42);
|
||||
expect(file.endsWith('/tab-42.json')).toBe(true);
|
||||
|
||||
// Ensure the directory exists (writeDecision creates it).
|
||||
writeDecision({ tabId: 42, decision: 'allow', ts: new Date().toISOString(), reason: 'user' });
|
||||
const rec = readDecision(42);
|
||||
expect(rec).not.toBeNull();
|
||||
expect(rec?.tabId).toBe(42);
|
||||
expect(rec?.decision).toBe('allow');
|
||||
expect(rec?.reason).toBe('user');
|
||||
});
|
||||
|
||||
test('clearDecision removes the file', () => {
|
||||
writeDecision({ tabId: 7, decision: 'block', ts: new Date().toISOString() });
|
||||
expect(readDecision(7)).not.toBeNull();
|
||||
clearDecision(7);
|
||||
expect(readDecision(7)).toBeNull();
|
||||
});
|
||||
|
||||
test('readDecision returns null for a tab with no decision', () => {
|
||||
expect(readDecision(99999)).toBeNull();
|
||||
});
|
||||
|
||||
test('writeDecision + readDecision handles both values', () => {
|
||||
writeDecision({ tabId: 1, decision: 'allow', ts: '2026-04-20T12:00:00Z' });
|
||||
writeDecision({ tabId: 2, decision: 'block', ts: '2026-04-20T12:00:01Z' });
|
||||
expect(readDecision(1)?.decision).toBe('allow');
|
||||
expect(readDecision(2)?.decision).toBe('block');
|
||||
});
|
||||
|
||||
test('atomic write: temp file is cleaned up after rename', () => {
|
||||
writeDecision({ tabId: 10, decision: 'allow', ts: new Date().toISOString() });
|
||||
const file = decisionFileForTab(10);
|
||||
const dir = path.dirname(file);
|
||||
const leftover = fs.readdirSync(dir).filter((f) => f.startsWith('tab-10.json.tmp'));
|
||||
expect(leftover.length).toBe(0);
|
||||
});
|
||||
|
||||
test('file perms are 0600 on the decision file', () => {
|
||||
writeDecision({ tabId: 3, decision: 'allow', ts: new Date().toISOString() });
|
||||
const stat = fs.statSync(decisionFileForTab(3));
|
||||
// mode & 0o777 = lower 9 bits of permission
|
||||
const perms = stat.mode & 0o777;
|
||||
// On some filesystems the sticky/group bits may vary; we assert the
|
||||
// owner-only pattern.
|
||||
expect(perms & 0o077).toBe(0); // no group/other read or write
|
||||
});
|
||||
});
|
||||
|
||||
describe('excerptForReview sanitization', () => {
|
||||
test('passes short clean text through', () => {
|
||||
expect(excerptForReview('hello world')).toBe('hello world');
|
||||
});
|
||||
|
||||
test('truncates at the default max with ellipsis', () => {
|
||||
const long = 'a'.repeat(800);
|
||||
const out = excerptForReview(long);
|
||||
expect(out.length).toBe(501); // 500 chars + ellipsis
|
||||
expect(out.endsWith('…')).toBe(true);
|
||||
});
|
||||
|
||||
test('strips control chars that would break the UI', () => {
|
||||
const input = 'before\x00\x01\x02\x1Fafter';
|
||||
expect(excerptForReview(input)).toBe('beforeafter');
|
||||
});
|
||||
|
||||
test('collapses whitespace for compact display', () => {
|
||||
expect(excerptForReview('foo \n\n\t bar')).toBe('foo bar');
|
||||
});
|
||||
|
||||
test('returns empty string for empty input', () => {
|
||||
expect(excerptForReview('')).toBe('');
|
||||
expect(excerptForReview(null as any)).toBe('');
|
||||
});
|
||||
|
||||
test('custom max parameter', () => {
|
||||
expect(excerptForReview('abcdefghij', 5)).toBe('abcde…');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Verdict type includes user_overrode', () => {
|
||||
test('user_overrode is a valid Verdict value', () => {
|
||||
// TypeScript compile-time check that the type accepts the value.
|
||||
// If 'user_overrode' were removed from the Verdict union, this file
|
||||
// would fail to type-check.
|
||||
const v: Verdict = 'user_overrode';
|
||||
expect(v).toBe('user_overrode');
|
||||
});
|
||||
});
|
||||
|
||||
describe('review-flow smoke — simulated sidebar-agent poll loop', () => {
|
||||
test('agent-side poll sees user allow decision', async () => {
|
||||
const tabId = 123;
|
||||
clearDecision(tabId);
|
||||
|
||||
// Simulate the sidepanel POST happening after a short delay.
|
||||
setTimeout(() => {
|
||||
writeDecision({ tabId, decision: 'allow', ts: new Date().toISOString(), reason: 'user' });
|
||||
}, 50);
|
||||
|
||||
// Simulate the sidebar-agent poll loop.
|
||||
const deadline = Date.now() + 2000;
|
||||
let decision: 'allow' | 'block' | null = null;
|
||||
while (Date.now() < deadline) {
|
||||
const rec = readDecision(tabId);
|
||||
if (rec?.decision) {
|
||||
decision = rec.decision;
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
expect(decision).toBe('allow');
|
||||
});
|
||||
|
||||
test('agent-side poll sees user block decision', async () => {
|
||||
const tabId = 456;
|
||||
clearDecision(tabId);
|
||||
setTimeout(() => {
|
||||
writeDecision({ tabId, decision: 'block', ts: new Date().toISOString() });
|
||||
}, 50);
|
||||
|
||||
const deadline = Date.now() + 2000;
|
||||
let decision: 'allow' | 'block' | null = null;
|
||||
while (Date.now() < deadline) {
|
||||
const rec = readDecision(tabId);
|
||||
if (rec?.decision) {
|
||||
decision = rec.decision;
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
expect(decision).toBe('block');
|
||||
});
|
||||
|
||||
test('poll times out when no decision arrives', async () => {
|
||||
const tabId = 789;
|
||||
clearDecision(tabId);
|
||||
|
||||
const deadline = Date.now() + 200;
|
||||
let decision: 'allow' | 'block' | null = null;
|
||||
while (Date.now() < deadline) {
|
||||
const rec = readDecision(tabId);
|
||||
if (rec?.decision) {
|
||||
decision = rec.decision;
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
expect(decision).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -14,14 +14,10 @@ import {
|
||||
generateCanary,
|
||||
injectCanary,
|
||||
checkCanaryInStructure,
|
||||
hashPayload,
|
||||
logAttempt,
|
||||
writeSessionState,
|
||||
readSessionState,
|
||||
getStatus,
|
||||
extractDomain,
|
||||
buildTelemetrySpawnCommand,
|
||||
resolveBashBinary,
|
||||
type LayerSignal,
|
||||
} from '../src/security';
|
||||
|
||||
@@ -239,46 +235,9 @@ describe('canary', () => {
|
||||
|
||||
// ─── Payload hashing ─────────────────────────────────────────
|
||||
|
||||
describe('hashPayload', () => {
|
||||
test('same payload produces same hash (deterministic with persistent salt)', () => {
|
||||
const h1 = hashPayload('attack string');
|
||||
const h2 = hashPayload('attack string');
|
||||
expect(h1).toBe(h2);
|
||||
});
|
||||
|
||||
test('different payloads produce different hashes', () => {
|
||||
expect(hashPayload('a')).not.toBe(hashPayload('b'));
|
||||
});
|
||||
|
||||
test('hash is sha256 hex (64 chars)', () => {
|
||||
const h = hashPayload('test');
|
||||
expect(h).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Attack log + rotation ───────────────────────────────────
|
||||
|
||||
describe('logAttempt', () => {
|
||||
test('writes attempts.jsonl with correct shape', () => {
|
||||
const ok = logAttempt({
|
||||
ts: '2026-04-19T12:34:56Z',
|
||||
urlDomain: 'example.com',
|
||||
payloadHash: 'deadbeef',
|
||||
confidence: 0.9,
|
||||
layer: 'testsavant_content',
|
||||
verdict: 'block',
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
|
||||
const logPath = path.join(os.homedir(), '.gstack', 'security', 'attempts.jsonl');
|
||||
const content = fs.readFileSync(logPath, 'utf8');
|
||||
const lines = content.split('\n').filter(Boolean);
|
||||
const last = JSON.parse(lines[lines.length - 1]);
|
||||
expect(last.urlDomain).toBe('example.com');
|
||||
expect(last.payloadHash).toBe('deadbeef');
|
||||
expect(last.verdict).toBe('block');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Session state (cross-process, atomic) ───────────────────
|
||||
|
||||
@@ -330,74 +289,6 @@ describe('extractDomain', () => {
|
||||
|
||||
// ─── Bash binary resolution (Windows shebang-script invocation) ─────
|
||||
|
||||
describe('resolveBashBinary', () => {
|
||||
test('on POSIX, returns the system bash via Bun.which', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
const out = resolveBashBinary({ PATH: process.env.PATH ?? '' });
|
||||
expect(out).toBeTruthy();
|
||||
expect(out!.endsWith('bash')).toBe(true);
|
||||
});
|
||||
|
||||
test('honors GSTACK_BASH_BIN absolute-path override', () => {
|
||||
// Construct a synthetic absolute path; the helper short-circuits on
|
||||
// path.isAbsolute and never touches the filesystem, so this is portable.
|
||||
const fake = process.platform === 'win32' ? 'C:\\opt\\bash.exe' : '/opt/custom/bash';
|
||||
const out = resolveBashBinary({ GSTACK_BASH_BIN: fake, PATH: '' });
|
||||
expect(out).toBe(fake);
|
||||
});
|
||||
|
||||
test('strips wrapping double quotes from override values', () => {
|
||||
const fake = process.platform === 'win32' ? 'C:\\opt\\bash.exe' : '/opt/custom/bash';
|
||||
const out = resolveBashBinary({ GSTACK_BASH_BIN: `"${fake}"`, PATH: '' });
|
||||
expect(out).toBe(fake);
|
||||
});
|
||||
|
||||
test('BASH_BIN works as a fallback when GSTACK_BASH_BIN is unset', () => {
|
||||
const fake = process.platform === 'win32' ? 'C:\\opt\\bash.exe' : '/opt/custom/bash';
|
||||
const out = resolveBashBinary({ BASH_BIN: fake, PATH: '' });
|
||||
expect(out).toBe(fake);
|
||||
});
|
||||
|
||||
test('returns null when nothing resolves (override is unset and PATH is empty)', () => {
|
||||
// Empty PATH means Bun.which finds nothing.
|
||||
const out = resolveBashBinary({ PATH: '' });
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Telemetry spawn command (Windows bash wrapper, v1.24-aligned) ──
|
||||
|
||||
describe('buildTelemetrySpawnCommand', () => {
|
||||
const bin = '/home/user/.claude/skills/gstack/bin/gstack-telemetry-log';
|
||||
const args = ['--event-type', 'attack_attempt', '--confidence', '0.95'];
|
||||
|
||||
test('on POSIX, returns the binary path and args unchanged', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
const out = buildTelemetrySpawnCommand(bin, args);
|
||||
expect(out).not.toBeNull();
|
||||
expect(out!.cmd).toBe(bin);
|
||||
expect(out!.cmdArgs).toEqual(args);
|
||||
});
|
||||
|
||||
test('on win32 with bash resolvable, wraps the call in bash with the script as first arg', () => {
|
||||
if (process.platform !== 'win32') return;
|
||||
const fakeBash = 'C:\\Program Files\\Git\\bin\\bash.exe';
|
||||
const out = buildTelemetrySpawnCommand(bin, args, { GSTACK_BASH_BIN: fakeBash, PATH: '' });
|
||||
expect(out).not.toBeNull();
|
||||
expect(out!.cmd).toBe(fakeBash);
|
||||
expect(out!.cmdArgs).toEqual([bin, ...args]);
|
||||
});
|
||||
|
||||
test('on win32 with bash unresolvable, returns null so caller skips spawn', () => {
|
||||
if (process.platform !== 'win32') return;
|
||||
// No override, empty PATH — Bun.which finds nothing on Windows.
|
||||
const out = buildTelemetrySpawnCommand(bin, args, { PATH: '' });
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
|
||||
test('does not mutate the caller-supplied args array', () => {
|
||||
const originalArgs = [...args];
|
||||
buildTelemetrySpawnCommand(bin, args);
|
||||
expect(args).toEqual(originalArgs);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* Layer 1: Unit tests for sidebar utilities.
|
||||
* Tests pure functions — no server, no processes, no network.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { sanitizeExtensionUrl } from '../src/sidebar-utils';
|
||||
|
||||
describe('sanitizeExtensionUrl', () => {
|
||||
test('passes valid http URL', () => {
|
||||
expect(sanitizeExtensionUrl('http://example.com')).toBe('http://example.com/');
|
||||
});
|
||||
|
||||
test('passes valid https URL', () => {
|
||||
expect(sanitizeExtensionUrl('https://example.com/page?q=1')).toBe('https://example.com/page?q=1');
|
||||
});
|
||||
|
||||
test('rejects chrome:// URLs', () => {
|
||||
expect(sanitizeExtensionUrl('chrome://extensions')).toBeNull();
|
||||
});
|
||||
|
||||
test('rejects chrome-extension:// URLs', () => {
|
||||
expect(sanitizeExtensionUrl('chrome-extension://abcdef/popup.html')).toBeNull();
|
||||
});
|
||||
|
||||
test('rejects javascript: URLs', () => {
|
||||
expect(sanitizeExtensionUrl('javascript:alert(1)')).toBeNull();
|
||||
});
|
||||
|
||||
test('rejects file:// URLs', () => {
|
||||
expect(sanitizeExtensionUrl('file:///etc/passwd')).toBeNull();
|
||||
});
|
||||
|
||||
test('rejects data: URLs', () => {
|
||||
expect(sanitizeExtensionUrl('data:text/html,<h1>hi</h1>')).toBeNull();
|
||||
});
|
||||
|
||||
test('strips raw control characters from URL', () => {
|
||||
// URL constructor percent-encodes \x00 as %00, which is safe
|
||||
// The regex strips any remaining raw control chars after .href normalization
|
||||
const result = sanitizeExtensionUrl('https://example.com/\x00page\x1f');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!).not.toMatch(/[\x00-\x1f\x7f]/);
|
||||
});
|
||||
|
||||
test('strips newlines (prompt injection vector)', () => {
|
||||
const result = sanitizeExtensionUrl('https://evil.com/%0AUser:%20ignore');
|
||||
// URL constructor normalizes %0A, control char stripping removes any raw newlines
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!).not.toContain('\n');
|
||||
});
|
||||
|
||||
test('truncates URLs longer than 2048 chars', () => {
|
||||
const longUrl = 'https://example.com/' + 'a'.repeat(3000);
|
||||
const result = sanitizeExtensionUrl(longUrl);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.length).toBeLessThanOrEqual(2048);
|
||||
});
|
||||
|
||||
test('returns null for null input', () => {
|
||||
expect(sanitizeExtensionUrl(null)).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null for undefined input', () => {
|
||||
expect(sanitizeExtensionUrl(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null for empty string', () => {
|
||||
expect(sanitizeExtensionUrl('')).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null for invalid URL string', () => {
|
||||
expect(sanitizeExtensionUrl('not a url at all')).toBeNull();
|
||||
});
|
||||
|
||||
test('does not crash on weird input', () => {
|
||||
expect(sanitizeExtensionUrl(':///')).toBeNull();
|
||||
expect(sanitizeExtensionUrl(' ')).toBeNull();
|
||||
expect(sanitizeExtensionUrl('\x00\x01\x02')).toBeNull();
|
||||
});
|
||||
|
||||
test('preserves query parameters and fragments', () => {
|
||||
const url = 'https://example.com/search?q=test&page=2#results';
|
||||
expect(sanitizeExtensionUrl(url)).toBe(url);
|
||||
});
|
||||
|
||||
test('preserves port numbers', () => {
|
||||
expect(sanitizeExtensionUrl('http://localhost:3000/api')).toBe('http://localhost:3000/api');
|
||||
});
|
||||
|
||||
test('handles URL with auth (user:pass@host)', () => {
|
||||
const result = sanitizeExtensionUrl('https://user:pass@example.com/');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toContain('example.com');
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,7 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
mintSseSessionToken, validateSseSessionToken, extractSseCookie,
|
||||
buildSseSetCookie, buildSseClearCookie, SSE_COOKIE_NAME,
|
||||
buildSseSetCookie, SSE_COOKIE_NAME,
|
||||
__resetSseSessions,
|
||||
} from '../src/sse-session-cookie';
|
||||
|
||||
@@ -106,11 +106,6 @@ describe('SSE session cookie: cookie flag invariants', () => {
|
||||
// add Secure then.
|
||||
expect(buildSseSetCookie(token)).not.toContain('Secure');
|
||||
});
|
||||
|
||||
test('Clear-Cookie has Max-Age=0', () => {
|
||||
expect(buildSseClearCookie()).toContain('Max-Age=0');
|
||||
expect(buildSseClearCookie()).toContain('HttpOnly');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SSE session cookie: extract from request', () => {
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
buildGStackLaunchArgs,
|
||||
readHostProfile,
|
||||
AUTOMATION_ARTIFACT_CLEANUP_SCRIPT,
|
||||
WEBDRIVER_MASK_SCRIPT,
|
||||
STEALTH_LAUNCH_ARGS,
|
||||
STEALTH_IGNORE_DEFAULT_ARGS,
|
||||
} from '../src/stealth';
|
||||
@@ -235,10 +234,6 @@ describe('buildGStackLaunchArgs — Pack 1 cmdline-switch construction', () => {
|
||||
});
|
||||
|
||||
describe('backwards-compat exports', () => {
|
||||
test('WEBDRIVER_MASK_SCRIPT still exported', () => {
|
||||
expect(WEBDRIVER_MASK_SCRIPT).toContain("'webdriver'");
|
||||
expect(WEBDRIVER_MASK_SCRIPT).toContain('false');
|
||||
});
|
||||
test('STEALTH_LAUNCH_ARGS still includes blink-features=AutomationControlled', () => {
|
||||
expect(STEALTH_LAUNCH_ARGS).toContain('--disable-blink-features=AutomationControlled');
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { chromium, type Browser, type BrowserContext } from 'playwright';
|
||||
import { applyStealth, WEBDRIVER_MASK_SCRIPT, STEALTH_LAUNCH_ARGS } from '../src/stealth';
|
||||
import { applyStealth, STEALTH_LAUNCH_ARGS } from '../src/stealth';
|
||||
|
||||
let browser: Browser;
|
||||
|
||||
@@ -18,20 +18,6 @@ describe('STEALTH_LAUNCH_ARGS', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('WEBDRIVER_MASK_SCRIPT', () => {
|
||||
test('contains a single Object.defineProperty for navigator.webdriver', () => {
|
||||
expect(WEBDRIVER_MASK_SCRIPT).toContain('navigator');
|
||||
expect(WEBDRIVER_MASK_SCRIPT).toContain('webdriver');
|
||||
expect(WEBDRIVER_MASK_SCRIPT).toContain('false');
|
||||
});
|
||||
|
||||
test('does NOT touch plugins, languages, or window.chrome (D7 narrowing)', () => {
|
||||
expect(WEBDRIVER_MASK_SCRIPT).not.toMatch(/plugins/i);
|
||||
expect(WEBDRIVER_MASK_SCRIPT).not.toMatch(/languages/i);
|
||||
expect(WEBDRIVER_MASK_SCRIPT).not.toMatch(/window\.chrome/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyStealth — context level', () => {
|
||||
let context: BrowserContext;
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
mintPtySessionToken, validatePtySessionToken, revokePtySessionToken,
|
||||
extractPtyCookie, buildPtySetCookie, buildPtyClearCookie,
|
||||
extractPtyCookie, buildPtySetCookie,
|
||||
PTY_COOKIE_NAME, __resetPtySessions,
|
||||
} from '../src/pty-session-cookie';
|
||||
|
||||
@@ -61,10 +61,6 @@ describe('pty-session-cookie: mint/validate/revoke', () => {
|
||||
expect(cookie).not.toContain('Secure');
|
||||
});
|
||||
|
||||
test('clear-cookie has Max-Age=0', () => {
|
||||
expect(buildPtyClearCookie()).toContain('Max-Age=0');
|
||||
});
|
||||
|
||||
test('extractPtyCookie reads gstack_pty from a Cookie header', () => {
|
||||
const { token } = mintPtySessionToken();
|
||||
const req = new Request('http://127.0.0.1/ws', {
|
||||
@@ -150,10 +146,13 @@ describe('Source-level guard: terminal-agent', () => {
|
||||
AGENT_SRC.indexOf("if (url.pathname === '/ws')"),
|
||||
AGENT_SRC.indexOf("websocket: {"),
|
||||
);
|
||||
expect(upgradeBlock).not.toContain('spawnClaude(');
|
||||
// v1.44 renamed spawnClaude -> maybeSpawnPty (explicit `start` frame +
|
||||
// lazy first-byte spawn share one helper). Pin was stale from then until
|
||||
// the free suite got a CI job.
|
||||
expect(upgradeBlock).not.toContain('maybeSpawnPty(');
|
||||
// Spawn must be invoked from the message handler (lazy on first byte).
|
||||
const messageHandler = AGENT_SRC.slice(AGENT_SRC.indexOf('message(ws, raw)'));
|
||||
expect(messageHandler).toContain('spawnClaude(');
|
||||
expect(messageHandler).toContain('maybeSpawnPty(');
|
||||
expect(messageHandler).toContain('!session.spawned');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user