mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 04:10:47 +02:00
refactor: remove in-browser PTY terminal
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ab4e9ae520
commit
d596232248
+3
-37
@@ -17,7 +17,6 @@ import { writeSecureFile, mkdirSecure } from './file-permissions';
|
|||||||
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
|
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
|
||||||
import { parseProxyConfig, computeConfigHash, ProxyConfigError } from './proxy-config';
|
import { parseProxyConfig, computeConfigHash, ProxyConfigError } from './proxy-config';
|
||||||
import { redactProxyUrl } from './proxy-redact';
|
import { redactProxyUrl } from './proxy-redact';
|
||||||
import { spawnTerminalAgent } from './terminal-agent-control';
|
|
||||||
|
|
||||||
const config = resolveConfig();
|
const config = resolveConfig();
|
||||||
const IS_WINDOWS = process.platform === 'win32';
|
const IS_WINDOWS = process.platform === 'win32';
|
||||||
@@ -1095,14 +1094,13 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
|||||||
// Delete stale state file
|
// Delete stale state file
|
||||||
safeUnlinkQuiet(config.stateFile);
|
safeUnlinkQuiet(config.stateFile);
|
||||||
|
|
||||||
console.log('Launching headed Chromium with extension + terminal agent...');
|
console.log('Launching headed Chromium...');
|
||||||
try {
|
try {
|
||||||
// Start server in headed mode with extension auto-loaded
|
// Start server in headed mode.
|
||||||
// Use a well-known port so the Chrome extension auto-connects
|
// Use a well-known port so callers auto-connect.
|
||||||
const serverEnv: Record<string, string> = {
|
const serverEnv: Record<string, string> = {
|
||||||
BROWSE_HEADED: '1',
|
BROWSE_HEADED: '1',
|
||||||
BROWSE_PORT: '34567',
|
BROWSE_PORT: '34567',
|
||||||
BROWSE_SIDEBAR_CHAT: '1',
|
|
||||||
// Disable parent-process watchdog: the user controls the headed browser
|
// Disable parent-process watchdog: the user controls the headed browser
|
||||||
// window lifecycle. The CLI exits immediately after connect, so watching
|
// window lifecycle. The CLI exits immediately after connect, so watching
|
||||||
// it would kill the server ~15s later. Cleanup happens via browser
|
// it would kill the server ~15s later. Cleanup happens via browser
|
||||||
@@ -1134,28 +1132,6 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
|||||||
console.log('(If you still don\'t see it, check Mission Control / other Spaces.)');
|
console.log('(If you still don\'t see it, check Mission Control / other Spaces.)');
|
||||||
}
|
}
|
||||||
|
|
||||||
// sidebar-agent.ts spawn was here. Ripped alongside the chat queue —
|
|
||||||
// the Terminal pane runs an interactive PTY now, no more one-shot
|
|
||||||
// claude -p subprocesses to multiplex.
|
|
||||||
|
|
||||||
// Auto-start terminal agent (non-compiled bun process). Owns the PTY
|
|
||||||
// WebSocket for the sidebar Terminal pane. Routes through the shared
|
|
||||||
// spawnTerminalAgent helper so the CLI cold-start path and the
|
|
||||||
// server.ts watchdog respawn path share one implementation. The
|
|
||||||
// helper handles prior-PID cleanup, script lookup, and env wiring.
|
|
||||||
try {
|
|
||||||
const newPid = spawnTerminalAgent({
|
|
||||||
stateFile: config.stateFile,
|
|
||||||
serverPort: newState.port,
|
|
||||||
cwd: config.projectDir,
|
|
||||||
});
|
|
||||||
if (newPid) {
|
|
||||||
console.log(`[browse] Terminal agent started (PID: ${newPid})`);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
// Non-fatal: chat still works without the terminal agent.
|
|
||||||
console.error(`[browse] Terminal agent failed to start: ${err.message}`);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(`[browse] Connect failed: ${err.message}`);
|
console.error(`[browse] Connect failed: ${err.message}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -1234,16 +1210,6 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
|||||||
try {
|
try {
|
||||||
const respawned = await startServer(serverEnv);
|
const respawned = await startServer(serverEnv);
|
||||||
console.log(`[browse] Supervisor: server respawned (PID ${respawned.pid}, port ${respawned.port}).`);
|
console.log(`[browse] Supervisor: server respawned (PID ${respawned.pid}, port ${respawned.port}).`);
|
||||||
// Re-spawn the terminal-agent too; same env wiring as the initial connect.
|
|
||||||
try {
|
|
||||||
spawnTerminalAgent({
|
|
||||||
stateFile: config.stateFile,
|
|
||||||
serverPort: respawned.port,
|
|
||||||
cwd: config.projectDir,
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
|
||||||
console.warn(`[browse] Supervisor: terminal-agent respawn failed: ${err?.message || err}`);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(`[browse] Supervisor: server respawn failed: ${err?.message || err}`);
|
console.error(`[browse] Supervisor: server respawn failed: ${err?.message || err}`);
|
||||||
// Let the next tick try again — the crash-loop guard already
|
// Let the next tick try again — the crash-loop guard already
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
/**
|
|
||||||
* Session cookie registry for the Terminal sidebar tab's PTY WebSocket.
|
|
||||||
*
|
|
||||||
* Why this exists: WebSocket clients in browsers cannot send Authorization
|
|
||||||
* headers on the upgrade request. The terminal-agent's /ws upgrade therefore
|
|
||||||
* authenticates via cookie. We never put the PTY token in /health (codex
|
|
||||||
* outside-voice finding #2: /health already leaks AUTH_TOKEN to any
|
|
||||||
* localhost caller in headed mode; reusing that path for shell access would
|
|
||||||
* widen an existing bug). Instead, the extension does an authenticated
|
|
||||||
* POST /pty-session with the bootstrap AUTH_TOKEN; the server mints a
|
|
||||||
* short-lived cookie scoped to this terminal session and pushes it to the
|
|
||||||
* agent via loopback. The browser then carries the cookie automatically on
|
|
||||||
* the WS upgrade.
|
|
||||||
*
|
|
||||||
* Design mirrors `sse-session-cookie.ts` deliberately. Same TTL, same
|
|
||||||
* scoped-token-must-not-be-valid-as-root invariant, same opportunistic
|
|
||||||
* pruning. Two registries instead of one because the cookie names are
|
|
||||||
* different (`gstack_sse` vs `gstack_pty`) and the token spaces must not
|
|
||||||
* overlap — an SSE-read cookie must never grant PTY access, and vice versa.
|
|
||||||
*/
|
|
||||||
import * as crypto from 'crypto';
|
|
||||||
|
|
||||||
interface Session {
|
|
||||||
createdAt: number;
|
|
||||||
expiresAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TTL_MS = 30 * 60 * 1000; // 30 minutes — matches SSE cookie
|
|
||||||
const MAX_SESSIONS = 10_000;
|
|
||||||
const sessions = new Map<string, Session>();
|
|
||||||
|
|
||||||
export const PTY_COOKIE_NAME = 'gstack_pty';
|
|
||||||
|
|
||||||
/** Mint a fresh PTY session token. */
|
|
||||||
export function mintPtySessionToken(): { token: string; expiresAt: number } {
|
|
||||||
const token = crypto.randomBytes(32).toString('base64url');
|
|
||||||
const now = Date.now();
|
|
||||||
const expiresAt = now + TTL_MS;
|
|
||||||
sessions.set(token, { createdAt: now, expiresAt });
|
|
||||||
pruneExpired(now);
|
|
||||||
return { token, expiresAt };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate a token. Returns true only if the token exists AND is not expired.
|
|
||||||
* Lazily removes expired entries; opportunistically prunes a few more on
|
|
||||||
* every call so the registry stays bounded under reconnect pressure.
|
|
||||||
*/
|
|
||||||
export function validatePtySessionToken(token: string | null | undefined): boolean {
|
|
||||||
if (!token) return false;
|
|
||||||
const s = sessions.get(token);
|
|
||||||
if (!s) {
|
|
||||||
pruneExpired(Date.now());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (Date.now() > s.expiresAt) {
|
|
||||||
sessions.delete(token);
|
|
||||||
pruneExpired(Date.now());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drop a session token (called on WS close so a leaked cookie can't be
|
|
||||||
* replayed against a new PTY).
|
|
||||||
*/
|
|
||||||
export function revokePtySessionToken(token: string | null | undefined): void {
|
|
||||||
if (!token) return;
|
|
||||||
sessions.delete(token);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Parse the PTY session token from a Cookie header. */
|
|
||||||
export function extractPtyCookie(req: Request): string | null {
|
|
||||||
const cookieHeader = req.headers.get('cookie');
|
|
||||||
if (!cookieHeader) return null;
|
|
||||||
for (const part of cookieHeader.split(';')) {
|
|
||||||
const [name, ...valueParts] = part.trim().split('=');
|
|
||||||
if (name === PTY_COOKIE_NAME) {
|
|
||||||
return valueParts.join('=') || null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the Set-Cookie header value for the PTY session cookie.
|
|
||||||
* - HttpOnly: not readable from JS (mitigates XSS exfiltration).
|
|
||||||
* - SameSite=Strict: not sent on cross-site requests (mitigates CSWSH).
|
|
||||||
* - Path=/: scope to whole origin so /ws and /pty-session both see it.
|
|
||||||
* - Max-Age matches the TTL.
|
|
||||||
*
|
|
||||||
* Secure is intentionally omitted: the daemon binds to 127.0.0.1 over plain
|
|
||||||
* HTTP; setting Secure would prevent the browser from ever sending it back.
|
|
||||||
*/
|
|
||||||
export function buildPtySetCookie(token: string): string {
|
|
||||||
const maxAge = Math.floor(TTL_MS / 1000);
|
|
||||||
return `${PTY_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Clear the PTY session cookie. */
|
|
||||||
export function buildPtyClearCookie(): string {
|
|
||||||
return `${PTY_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pruneExpired(now: number): void {
|
|
||||||
let checked = 0;
|
|
||||||
for (const [token, session] of sessions) {
|
|
||||||
if (checked++ >= 20) break;
|
|
||||||
if (session.expiresAt <= now) sessions.delete(token);
|
|
||||||
}
|
|
||||||
while (sessions.size > MAX_SESSIONS) {
|
|
||||||
const first = sessions.keys().next().value;
|
|
||||||
if (!first) break;
|
|
||||||
sessions.delete(first);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test-only reset.
|
|
||||||
export function __resetPtySessions(): void {
|
|
||||||
sessions.clear();
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
/**
|
|
||||||
* PTY session lease registry (v1.44+).
|
|
||||||
*
|
|
||||||
* Separates two concerns that pre-v1.44 were conflated under one token:
|
|
||||||
*
|
|
||||||
* - **sessionId** — stable, non-secret identifier for a single PTY session.
|
|
||||||
* Safe to log, safe to include in URLs and server access logs, safe to
|
|
||||||
* keep in DevTools. Identifies "this terminal," not "you're allowed to
|
|
||||||
* use this terminal."
|
|
||||||
*
|
|
||||||
* - **attachToken** — secret, short-lived (30 s) bearer credential that
|
|
||||||
* grants the WS upgrade for ONE attach attempt against a session. Minted
|
|
||||||
* on every /pty-session and /pty-session/reattach call; revoked when
|
|
||||||
* the WS upgrade consumes it. Kept out of logs.
|
|
||||||
*
|
|
||||||
* - **lease** — server-side bookkeeping that maps sessionId → expiresAt.
|
|
||||||
* Re-attach within the lease window resumes the same PTY (and replays
|
|
||||||
* the ring buffer from terminal-agent). Lease expiry tears down the
|
|
||||||
* session.
|
|
||||||
*
|
|
||||||
* Codex outside-voice (T1 of the eng review) pushed for this separation:
|
|
||||||
* "the auth token IS the session id" collapsed identity into a secret,
|
|
||||||
* meaning re-attach URLs and logs carry the bearer credential. The lease
|
|
||||||
* model fixes that without changing the user experience.
|
|
||||||
*
|
|
||||||
* Mint cadence:
|
|
||||||
* - Initial /pty-session: mint sessionId + lease + attachToken (one round trip).
|
|
||||||
* - /pty-session/reattach: validate sessionId/lease, mint fresh attachToken.
|
|
||||||
* - /pty-restart: revoke old lease, mint fresh sessionId + lease + attachToken.
|
|
||||||
* - /pty-dispose: revoke lease (and the terminal-agent disposes the PTY).
|
|
||||||
*
|
|
||||||
* Lease TTL is env-overridable so v1.44 e2e tests can compress detach
|
|
||||||
* windows to 1 s instead of waiting 30 minutes per assertion.
|
|
||||||
*/
|
|
||||||
import * as crypto from 'crypto';
|
|
||||||
|
|
||||||
interface Lease {
|
|
||||||
createdAt: number;
|
|
||||||
expiresAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LEASE_TTL_MS = parseInt(
|
|
||||||
process.env.GSTACK_PTY_LEASE_TTL_MS || `${30 * 60 * 1000}`,
|
|
||||||
10,
|
|
||||||
); // 30 minutes default; covers idle-but-engaged user sessions
|
|
||||||
const MAX_LEASES = 10_000;
|
|
||||||
const leases = new Map<string, Lease>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mint a fresh sessionId + lease. Returns the non-secret sessionId and
|
|
||||||
* the expiry timestamp (caller surfaces both to the client). Never throws.
|
|
||||||
*/
|
|
||||||
export function mintLease(): { sessionId: string; expiresAt: number } {
|
|
||||||
const sessionId = crypto.randomBytes(32).toString('base64url');
|
|
||||||
const now = Date.now();
|
|
||||||
const expiresAt = now + LEASE_TTL_MS;
|
|
||||||
leases.set(sessionId, { createdAt: now, expiresAt });
|
|
||||||
pruneExpired(now);
|
|
||||||
return { sessionId, expiresAt };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check whether a lease is still valid (exists AND not expired). Returns
|
|
||||||
* the current expiresAt for valid leases; null otherwise. Lazily prunes
|
|
||||||
* stale entries.
|
|
||||||
*/
|
|
||||||
export function validateLease(sessionId: string | null | undefined): { ok: true; expiresAt: number } | { ok: false } {
|
|
||||||
if (!sessionId) return { ok: false };
|
|
||||||
const lease = leases.get(sessionId);
|
|
||||||
if (!lease) {
|
|
||||||
pruneExpired(Date.now());
|
|
||||||
return { ok: false };
|
|
||||||
}
|
|
||||||
if (Date.now() > lease.expiresAt) {
|
|
||||||
leases.delete(sessionId);
|
|
||||||
pruneExpired(Date.now());
|
|
||||||
return { ok: false };
|
|
||||||
}
|
|
||||||
return { ok: true, expiresAt: lease.expiresAt };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extend the lease's expiresAt to `now + LEASE_TTL_MS`. Caller should
|
|
||||||
* gate refresh on `expiresAt - now < REFRESH_THRESHOLD` (D10 lazy
|
|
||||||
* refresh: avoid refreshing on every keepalive when the lease is
|
|
||||||
* comfortably far from expiry).
|
|
||||||
*
|
|
||||||
* Returns `{ ok: true, expiresAt }` on success, `{ ok: false }` if the
|
|
||||||
* lease is unknown or already expired (the agent must close the WS and
|
|
||||||
* surface auth-invalid). Critical security invariant: never resurrect
|
|
||||||
* an expired lease — the 30-min TTL is what bounds blast radius for a
|
|
||||||
* leaked attach token whose lease should have been GC'd.
|
|
||||||
*/
|
|
||||||
export function refreshLease(sessionId: string | null | undefined): { ok: true; expiresAt: number } | { ok: false } {
|
|
||||||
if (!sessionId) return { ok: false };
|
|
||||||
const lease = leases.get(sessionId);
|
|
||||||
if (!lease) return { ok: false };
|
|
||||||
const now = Date.now();
|
|
||||||
if (now > lease.expiresAt) {
|
|
||||||
leases.delete(sessionId);
|
|
||||||
return { ok: false };
|
|
||||||
}
|
|
||||||
lease.expiresAt = now + LEASE_TTL_MS;
|
|
||||||
return { ok: true, expiresAt: lease.expiresAt };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drop a lease. Called on explicit dispose (/pty-dispose, /pty-restart,
|
|
||||||
* WS close with code 4001) and on session timeout in terminal-agent.
|
|
||||||
*/
|
|
||||||
export function revokeLease(sessionId: string | null | undefined): void {
|
|
||||||
if (!sessionId) return;
|
|
||||||
leases.delete(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the lease count — test + observability helper. */
|
|
||||||
export function leaseCount(): number {
|
|
||||||
return leases.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Test-only reset. */
|
|
||||||
export function __resetLeases(): void {
|
|
||||||
leases.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
function pruneExpired(now: number): void {
|
|
||||||
let checked = 0;
|
|
||||||
for (const [sessionId, lease] of leases) {
|
|
||||||
if (checked++ >= 20) break;
|
|
||||||
if (lease.expiresAt <= now) leases.delete(sessionId);
|
|
||||||
}
|
|
||||||
while (leases.size > MAX_LEASES) {
|
|
||||||
const first = leases.keys().next().value;
|
|
||||||
if (!first) break;
|
|
||||||
leases.delete(first);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+4
-445
@@ -18,7 +18,6 @@ import { handleReadCommand, hasOutArg } from './read-commands';
|
|||||||
import { handleWriteCommand } from './write-commands';
|
import { handleWriteCommand } from './write-commands';
|
||||||
import { handleMetaCommand } from './meta-commands';
|
import { handleMetaCommand } from './meta-commands';
|
||||||
import { handleCookiePickerRoute, hasActivePicker } from './cookie-picker-routes';
|
import { handleCookiePickerRoute, hasActivePicker } from './cookie-picker-routes';
|
||||||
import { sanitizeExtensionUrl } from './sidebar-utils';
|
|
||||||
import { COMMAND_DESCRIPTIONS, PAGE_CONTENT_COMMANDS, DOM_CONTENT_COMMANDS, wrapUntrustedContent, canonicalizeCommand, buildUnknownCommandError, ALL_COMMANDS } from './commands';
|
import { COMMAND_DESCRIPTIONS, PAGE_CONTENT_COMMANDS, DOM_CONTENT_COMMANDS, wrapUntrustedContent, canonicalizeCommand, buildUnknownCommandError, ALL_COMMANDS } from './commands';
|
||||||
import {
|
import {
|
||||||
wrapUntrustedPageContent, datamarkContent,
|
wrapUntrustedPageContent, datamarkContent,
|
||||||
@@ -44,8 +43,6 @@ import { inspectElement, modifyStyle, resetModifications, getModificationHistory
|
|||||||
// Bun.spawn used instead of child_process.spawn (compiled bun binaries
|
// Bun.spawn used instead of child_process.spawn (compiled bun binaries
|
||||||
// fail posix_spawn on all executables including /bin/bash)
|
// fail posix_spawn on all executables including /bin/bash)
|
||||||
import { safeUnlink, safeUnlinkQuiet, safeKill } from './error-handling';
|
import { safeUnlink, safeUnlinkQuiet, safeKill } from './error-handling';
|
||||||
import { readAgentRecord, killAgentByRecord, clearAgentRecord, agentRecordPath, spawnTerminalAgent } from './terminal-agent-control';
|
|
||||||
import { isProcessAlive } from './error-handling';
|
|
||||||
import { sanitizeBody, stripLoneSurrogateEscapes } from './sanitize';
|
import { sanitizeBody, stripLoneSurrogateEscapes } from './sanitize';
|
||||||
import { startSocksBridge, testUpstream, type BridgeHandle } from './socks-bridge';
|
import { startSocksBridge, testUpstream, type BridgeHandle } from './socks-bridge';
|
||||||
import { parseProxyConfig, toUpstreamConfig, ProxyConfigError } from './proxy-config';
|
import { parseProxyConfig, toUpstreamConfig, ProxyConfigError } from './proxy-config';
|
||||||
@@ -56,12 +53,6 @@ import {
|
|||||||
mintSseSessionToken, validateSseSessionToken, extractSseCookie,
|
mintSseSessionToken, validateSseSessionToken, extractSseCookie,
|
||||||
buildSseSetCookie, SSE_COOKIE_NAME,
|
buildSseSetCookie, SSE_COOKIE_NAME,
|
||||||
} from './sse-session-cookie';
|
} from './sse-session-cookie';
|
||||||
import {
|
|
||||||
mintPtySessionToken, buildPtySetCookie, revokePtySessionToken,
|
|
||||||
} from './pty-session-cookie';
|
|
||||||
import {
|
|
||||||
mintLease, validateLease, refreshLease, revokeLease,
|
|
||||||
} from './pty-session-lease';
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as net from 'net';
|
import * as net from 'net';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
@@ -211,38 +202,6 @@ export interface ServerConfig {
|
|||||||
* dispatch; returning null falls through.
|
* dispatch; returning null falls through.
|
||||||
*/
|
*/
|
||||||
beforeRoute?: (req: Request, surface: Surface, auth: TokenInfo | null) => Promise<Response | null>;
|
beforeRoute?: (req: Request, surface: Surface, auth: TokenInfo | null) => Promise<Response | null>;
|
||||||
/**
|
|
||||||
* Whether gstack owns the lifecycle of the terminal-agent process and its
|
|
||||||
* discovery files (`<stateDir>/terminal-port`, `<stateDir>/terminal-internal-token`,
|
|
||||||
* `<stateDir>/terminal-agent-pid`).
|
|
||||||
*
|
|
||||||
* When true (default), shutdown() runs four side effects:
|
|
||||||
* 1. Identity-based kill via `killAgentByRecord(readAgentRecord(stateDir))`
|
|
||||||
* (v1.44+). Only signals the PID recorded by THIS daemon's agent.
|
|
||||||
* Replaced the historical `pkill -f terminal-agent\.ts` regex that
|
|
||||||
* matched sibling gstack sessions on the same host — see
|
|
||||||
* terminal-agent-control.ts for rationale.
|
|
||||||
* 2. `safeUnlinkQuiet(<stateDir>/terminal-port)`
|
|
||||||
* 3. `safeUnlinkQuiet(<stateDir>/terminal-internal-token)`
|
|
||||||
* 4. `safeUnlinkQuiet(<stateDir>/terminal-agent-pid)` (the v1.44 record)
|
|
||||||
*
|
|
||||||
* This is correct for gstack's CLI path, which spawns `terminal-agent.ts` as
|
|
||||||
* the producer of those files (see cli.ts:1037-1063).
|
|
||||||
*
|
|
||||||
* Embedders (gbrowser phoenix overlay, future hosts) that run their own PTY
|
|
||||||
* server and write those files themselves should pass `false`. When `false`,
|
|
||||||
* the embedder owns BOTH the agent process AND all three discovery files.
|
|
||||||
* Note that terminal-agent.ts's own SIGTERM cleanup removes `terminal-port`
|
|
||||||
* and `terminal-agent-pid` (the agent writes both at boot), so embedders
|
|
||||||
* that pre-launch their own agent must ensure their cleanup matches.
|
|
||||||
*
|
|
||||||
* Polarity note: this differs from `xvfb?` and `proxyBridge?`, which gate by
|
|
||||||
* the *presence* of a caller-owned handle (presence ⇒ don't close). This
|
|
||||||
* field gates by an explicit boolean because there is no handle object —
|
|
||||||
* the terminal-agent is started elsewhere (cli.ts), and shutdown's only
|
|
||||||
* reference is the PID record + the file paths.
|
|
||||||
*/
|
|
||||||
ownsTerminalAgent?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -253,7 +212,7 @@ export interface ServerHandle {
|
|||||||
fetchLocal: (req: Request, server: any) => Promise<Response>;
|
fetchLocal: (req: Request, server: any) => Promise<Response>;
|
||||||
fetchTunnel: (req: Request, server: any) => Promise<Response>;
|
fetchTunnel: (req: Request, server: any) => Promise<Response>;
|
||||||
/**
|
/**
|
||||||
* Drains buffers, kills terminal-agent, closes browser, clears intervals,
|
* Drains buffers, closes browser, clears intervals,
|
||||||
* removes state files. Does NOT stop bound Bun.Server listeners — call
|
* removes state files. Does NOT stop bound Bun.Server listeners — call
|
||||||
* stopListeners() for that. CLI relies on process.exit() to drop sockets.
|
* stopListeners() for that. CLI relies on process.exit() to drop sockets.
|
||||||
*/
|
*/
|
||||||
@@ -302,7 +261,6 @@ export function resolveConfigFromEnv(): Omit<ServerConfig, 'browserManager' | 's
|
|||||||
const TUNNEL_PATHS = new Set<string>([
|
const TUNNEL_PATHS = new Set<string>([
|
||||||
'/connect',
|
'/connect',
|
||||||
'/command',
|
'/command',
|
||||||
'/sidebar-chat',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -395,77 +353,6 @@ async function closeTunnel(): Promise<void> {
|
|||||||
// in buildFetchHandler closes over cfg.authToken so every internal auth check
|
// in buildFetchHandler closes over cfg.authToken so every internal auth check
|
||||||
// sees the same token the routes receive.
|
// sees the same token the routes receive.
|
||||||
|
|
||||||
/**
|
|
||||||
* Terminal-agent discovery. The non-compiled bun process at
|
|
||||||
* `browse/src/terminal-agent.ts` writes its chosen port to
|
|
||||||
* `<stateDir>/terminal-port` and the loopback handshake token to
|
|
||||||
* `<stateDir>/terminal-internal-token` once it boots. Read on demand —
|
|
||||||
* lazy so we don't break tests that don't spawn the agent.
|
|
||||||
*/
|
|
||||||
function readTerminalPort(): number | null {
|
|
||||||
try {
|
|
||||||
const f = path.join(path.dirname(config.stateFile), 'terminal-port');
|
|
||||||
const v = parseInt(fs.readFileSync(f, 'utf-8').trim(), 10);
|
|
||||||
return Number.isFinite(v) && v > 0 ? v : null;
|
|
||||||
} catch { return null; }
|
|
||||||
}
|
|
||||||
function readTerminalInternalToken(): string | null {
|
|
||||||
try {
|
|
||||||
const f = path.join(path.dirname(config.stateFile), 'terminal-internal-token');
|
|
||||||
const t = fs.readFileSync(f, 'utf-8').trim();
|
|
||||||
return t.length > 16 ? t : null;
|
|
||||||
} catch { return null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Push a freshly-minted PTY cookie token to the terminal-agent so its
|
|
||||||
* /ws upgrade can validate the cookie. v1.44+: also pushes the bound
|
|
||||||
* sessionId so the agent can route /internal/restart and (Commit 3)
|
|
||||||
* re-attach back to the same PtySession. Loopback POST authenticated
|
|
||||||
* with the internal token written by the agent at startup. If the agent
|
|
||||||
* isn't up yet, the extension just retries /pty-session.
|
|
||||||
*/
|
|
||||||
async function grantPtyToken(token: string, sessionId?: string): Promise<boolean> {
|
|
||||||
const port = readTerminalPort();
|
|
||||||
const internal = readTerminalInternalToken();
|
|
||||||
if (!port || !internal) return false;
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`http://127.0.0.1:${port}/internal/grant`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${internal}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(sessionId ? { token, sessionId } : { token }),
|
|
||||||
signal: AbortSignal.timeout(2000),
|
|
||||||
});
|
|
||||||
return resp.ok;
|
|
||||||
} catch { return false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ask the terminal-agent to dispose the PtySession bound to `sessionId`.
|
|
||||||
* Scoped to one caller's session — sibling tabs/agents untouched. Used by
|
|
||||||
* /pty-restart and /pty-dispose. Returns true on agent ack.
|
|
||||||
*/
|
|
||||||
async function restartPtySession(sessionId: string): Promise<boolean> {
|
|
||||||
const port = readTerminalPort();
|
|
||||||
const internal = readTerminalInternalToken();
|
|
||||||
if (!port || !internal) return false;
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`http://127.0.0.1:${port}/internal/restart`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${internal}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ sessionId }),
|
|
||||||
signal: AbortSignal.timeout(5000),
|
|
||||||
});
|
|
||||||
return resp.ok;
|
|
||||||
} catch { return false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Extract bearer token from request. Returns the token string or null. */
|
/** Extract bearer token from request. Returns the token string or null. */
|
||||||
function extractToken(req: Request): string | null {
|
function extractToken(req: Request): string | null {
|
||||||
const header = req.headers.get('authorization');
|
const header = req.headers.get('authorization');
|
||||||
@@ -1450,11 +1337,9 @@ if (import.meta.main) {
|
|||||||
/**
|
/**
|
||||||
* Build a request handler set for the browse daemon. Embedders (gbrowser
|
* Build a request handler set for the browse daemon. Embedders (gbrowser
|
||||||
* phoenix overlay) call this directly with their own cfg to compose overlay
|
* phoenix overlay) call this directly with their own cfg to compose overlay
|
||||||
* routes via cfg.beforeRoute, pass a pre-launched cfg.browserManager, and
|
* routes via cfg.beforeRoute and pass a pre-launched cfg.browserManager. The
|
||||||
* opt out of terminal-agent teardown via cfg.ownsTerminalAgent (default
|
* CLI path calls this through start() with env-derived defaults —
|
||||||
* true, set to false when the embedder runs its own PTY server). The CLI
|
* externally-observable behavior is identical.
|
||||||
* path calls this through start() with env-derived defaults and explicit
|
|
||||||
* cfg.ownsTerminalAgent: true — externally-observable behavior is identical.
|
|
||||||
*
|
*
|
||||||
* Auth state lives ENTIRELY inside the factory closure: cfg.authToken is the
|
* Auth state lives ENTIRELY inside the factory closure: cfg.authToken is the
|
||||||
* single source of truth for the bearer secret, factory-scoped validateAuth
|
* single source of truth for the bearer secret, factory-scoped validateAuth
|
||||||
@@ -1484,89 +1369,6 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
|||||||
initRegistry(cfg.authToken);
|
initRegistry(cfg.authToken);
|
||||||
|
|
||||||
const { authToken, browserManager: cfgBrowserManager, startTime, beforeRoute, browsePort } = cfg;
|
const { authToken, browserManager: cfgBrowserManager, startTime, beforeRoute, browsePort } = cfg;
|
||||||
// Strict opt-out: only explicit `false` flips the gate. Any other value
|
|
||||||
// (undefined, truthy non-bool from a JS caller bypassing TS, etc.) defaults
|
|
||||||
// to gstack-owns. Matches the "default-true preserves CLI bit-for-bit"
|
|
||||||
// premise even under malformed cfg.
|
|
||||||
const ownsTerminalAgent = cfg.ownsTerminalAgent === false ? false : true;
|
|
||||||
|
|
||||||
// ─── Terminal-Agent Watchdog (v1.44+) ─────────────────────────────
|
|
||||||
//
|
|
||||||
// The terminal-agent process can die independently of the server: SIGKILL
|
|
||||||
// from the OS OOM killer, an uncaught exception under load, an external
|
|
||||||
// `pkill` from a sibling debugging session. Pre-v1.44 the sidebar would
|
|
||||||
// see the broken connection and stay broken until the user reloaded.
|
|
||||||
// Now: 60s ticker checks the recorded agent PID, respawns via the shared
|
|
||||||
// spawnTerminalAgent helper if dead.
|
|
||||||
//
|
|
||||||
// Identity-based — uses readAgentRecord + isProcessAlive, NOT a process
|
|
||||||
// name probe. Critical: prevents respawning around a slow-but-alive agent
|
|
||||||
// (which would create split-brain — two agents writing the port file,
|
|
||||||
// tokens diverging between them, mystery PTY upgrade failures).
|
|
||||||
//
|
|
||||||
// Crash-loop guard: 3 respawn attempts inside 60s → stop trying and emit
|
|
||||||
// a one-line error. Manual `forceRestart` from the sidebar clears the
|
|
||||||
// history (the user is the explicit signal to retry).
|
|
||||||
//
|
|
||||||
// Only active when ownsTerminalAgent === true. Embedders that pre-launch
|
|
||||||
// their own PTY server (gbrowser phoenix overlay) must not be auto-respawned
|
|
||||||
// by us — their lifecycle is their concern.
|
|
||||||
let agentWatchdogInterval: ReturnType<typeof setInterval> | null = null;
|
|
||||||
const respawnHistory: number[] = [];
|
|
||||||
const AGENT_WATCHDOG_TICK_MS = parseInt(
|
|
||||||
process.env.GSTACK_AGENT_WATCHDOG_TICK_MS || '60000',
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
const RESPAWN_GUARD_WINDOW_MS = 60_000;
|
|
||||||
const RESPAWN_GUARD_MAX = 3;
|
|
||||||
let agentRespawnGuardTripped = false;
|
|
||||||
|
|
||||||
if (ownsTerminalAgent) {
|
|
||||||
agentWatchdogInterval = setInterval(() => {
|
|
||||||
if (isShuttingDown) return;
|
|
||||||
if (agentRespawnGuardTripped) return;
|
|
||||||
const stateDir = path.dirname(cfg.config.stateFile);
|
|
||||||
const record = readAgentRecord(stateDir);
|
|
||||||
// If the record exists and the PID is alive, the agent is healthy
|
|
||||||
// (or at least still answering signal 0). Slow-but-alive agents
|
|
||||||
// intentionally fall through here — split-brain is worse than
|
|
||||||
// unresponsiveness, and slow recovery is handled by the user via
|
|
||||||
// restart.
|
|
||||||
if (record && isProcessAlive(record.pid)) return;
|
|
||||||
// Either no record (never spawned, or cleaned up after crash) or
|
|
||||||
// PID is dead. Try to respawn.
|
|
||||||
const now = Date.now();
|
|
||||||
while (respawnHistory.length && now - respawnHistory[0] > RESPAWN_GUARD_WINDOW_MS) {
|
|
||||||
respawnHistory.shift();
|
|
||||||
}
|
|
||||||
if (respawnHistory.length >= RESPAWN_GUARD_MAX) {
|
|
||||||
agentRespawnGuardTripped = true;
|
|
||||||
console.error(
|
|
||||||
`[browse] terminal-agent respawn guard tripped (${RESPAWN_GUARD_MAX} crashes in ${RESPAWN_GUARD_WINDOW_MS / 1000}s) — manual restart required`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
respawnHistory.push(now);
|
|
||||||
try {
|
|
||||||
const pid = spawnTerminalAgent({
|
|
||||||
stateFile: cfg.config.stateFile,
|
|
||||||
serverPort: cfg.browsePort,
|
|
||||||
cwd: cfg.config.projectDir,
|
|
||||||
});
|
|
||||||
if (pid) {
|
|
||||||
console.log(`[browse] terminal-agent respawned by watchdog (PID: ${pid})`);
|
|
||||||
} else {
|
|
||||||
console.warn('[browse] terminal-agent respawn skipped — script not found on disk');
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
console.warn('[browse] terminal-agent respawn failed:', err?.message || err);
|
|
||||||
}
|
|
||||||
}, AGENT_WATCHDOG_TICK_MS);
|
|
||||||
// Detach the watchdog timer from Node's event-loop ref count so a
|
|
||||||
// healthy idle process can still exit cleanly if everything else is
|
|
||||||
// also unref'd. Bun's setInterval returns a Timer with unref().
|
|
||||||
(agentWatchdogInterval as any)?.unref?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Factory-scoped validateAuth. Closes over cfg.authToken so every internal
|
// Factory-scoped validateAuth. Closes over cfg.authToken so every internal
|
||||||
// auth check sees the same token the routes receive. Module-level
|
// auth check sees the same token the routes receive. Module-level
|
||||||
@@ -1595,25 +1397,9 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
|||||||
// a daemon that no longer exists. The path must come from this factory's
|
// a daemon that no longer exists. The path must come from this factory's
|
||||||
// config so embedded/isolated servers never clean a sibling session.
|
// config so embedded/isolated servers never clean a sibling session.
|
||||||
const shutdownStateFile = cfg.config.stateFile;
|
const shutdownStateFile = cfg.config.stateFile;
|
||||||
const shutdownStateDir = path.dirname(shutdownStateFile);
|
|
||||||
safeUnlinkQuiet(shutdownStateFile);
|
safeUnlinkQuiet(shutdownStateFile);
|
||||||
|
|
||||||
console.log('[browse] Shutting down...');
|
console.log('[browse] Shutting down...');
|
||||||
if (ownsTerminalAgent) {
|
|
||||||
// Identity-based kill (v1.44+). Replaces the v1.43- `pkill -f
|
|
||||||
// terminal-agent\.ts` regex teardown which matched sibling gstack
|
|
||||||
// sessions on the same host. Only the PID recorded in
|
|
||||||
// `<stateDir>/terminal-agent-pid` by THIS daemon's agent is signaled.
|
|
||||||
try {
|
|
||||||
const record = readAgentRecord(shutdownStateDir);
|
|
||||||
if (record) killAgentByRecord(record, 'SIGTERM');
|
|
||||||
} catch (err: any) {
|
|
||||||
console.warn('[browse] Failed to kill terminal-agent:', err.message);
|
|
||||||
}
|
|
||||||
safeUnlinkQuiet(path.join(shutdownStateDir, 'terminal-port'));
|
|
||||||
safeUnlinkQuiet(path.join(shutdownStateDir, 'terminal-internal-token'));
|
|
||||||
safeUnlinkQuiet(agentRecordPath(shutdownStateDir));
|
|
||||||
}
|
|
||||||
try { detachSession(); } catch (err: any) {
|
try { detachSession(); } catch (err: any) {
|
||||||
console.warn('[browse] Failed to detach CDP session:', err.message);
|
console.warn('[browse] Failed to detach CDP session:', err.message);
|
||||||
}
|
}
|
||||||
@@ -1621,7 +1407,6 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
|||||||
if (cfgBrowserManager.isWatching()) cfgBrowserManager.stopWatch();
|
if (cfgBrowserManager.isWatching()) cfgBrowserManager.stopWatch();
|
||||||
clearInterval(flushInterval);
|
clearInterval(flushInterval);
|
||||||
clearInterval(idleCheckInterval);
|
clearInterval(idleCheckInterval);
|
||||||
if (agentWatchdogInterval) clearInterval(agentWatchdogInterval);
|
|
||||||
await flushBuffers();
|
await flushBuffers();
|
||||||
|
|
||||||
await cfgBrowserManager.close();
|
await cfgBrowserManager.close();
|
||||||
@@ -1815,237 +1600,12 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
|||||||
// sidebar-agent.ts was ripped; only the page-content side
|
// sidebar-agent.ts was ripped; only the page-content side
|
||||||
// (canary, content-security) keeps reporting in.
|
// (canary, content-security) keeps reporting in.
|
||||||
security: getSecurityStatus(),
|
security: getSecurityStatus(),
|
||||||
// Terminal-agent discovery. ONLY a port number — never a token.
|
|
||||||
// Tokens flow via the /pty-session HttpOnly cookie path. See
|
|
||||||
// `pty-session-cookie.ts` for the rationale (codex outside-voice
|
|
||||||
// finding #2: don't reuse this endpoint for shell auth).
|
|
||||||
terminalPort: readTerminalPort(),
|
|
||||||
}), {
|
}), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── /pty-session — mint sessionId + lease + attachToken ─────────
|
|
||||||
//
|
|
||||||
// v1.44+ four-tuple shape:
|
|
||||||
// { terminalPort, sessionId, attachToken, leaseExpiresAt }
|
|
||||||
//
|
|
||||||
// - sessionId : stable, non-secret. Safe to log. Identifies "this
|
|
||||||
// terminal" across re-attaches.
|
|
||||||
// - attachToken : short-lived (30 min wall, single attach in practice
|
|
||||||
// since the agent revokes on WS close). Bearer for
|
|
||||||
// the /ws upgrade.
|
|
||||||
// - leaseExpiresAt: client-visible deadline for the lease. Re-attach
|
|
||||||
// only works inside this window.
|
|
||||||
//
|
|
||||||
// The lease + attachToken are minted together so a successful
|
|
||||||
// /pty-session is one round trip. Re-attach mints a fresh attachToken
|
|
||||||
// for the SAME sessionId via /pty-session/reattach.
|
|
||||||
//
|
|
||||||
// NEVER added to TUNNEL_PATHS — the tunnel surface 404s any
|
|
||||||
// /pty-session attempt by default-deny.
|
|
||||||
if (url.pathname === '/pty-session' && req.method === 'POST') {
|
|
||||||
if (!validateAuth(req)) {
|
|
||||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
|
||||||
status: 401, headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const port = readTerminalPort();
|
|
||||||
if (!port) {
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
error: 'terminal-agent not ready',
|
|
||||||
}), { status: 503, headers: { 'Content-Type': 'application/json' } });
|
|
||||||
}
|
|
||||||
const lease = mintLease();
|
|
||||||
const minted = mintPtySessionToken();
|
|
||||||
const granted = await grantPtyToken(minted.token, lease.sessionId);
|
|
||||||
if (!granted) {
|
|
||||||
revokePtySessionToken(minted.token);
|
|
||||||
revokeLease(lease.sessionId);
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
error: 'failed to grant terminal session',
|
|
||||||
}), { status: 503, headers: { 'Content-Type': 'application/json' } });
|
|
||||||
}
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
terminalPort: port,
|
|
||||||
sessionId: lease.sessionId,
|
|
||||||
attachToken: minted.token,
|
|
||||||
leaseExpiresAt: lease.expiresAt,
|
|
||||||
// Legacy alias — extensions still on the v1.43 wire shape keep
|
|
||||||
// working. Drop after one minor release once dogfood confirms.
|
|
||||||
ptySessionToken: minted.token,
|
|
||||||
expiresAt: minted.expiresAt,
|
|
||||||
}), {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Set-Cookie': buildPtySetCookie(minted.token),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── /pty-session/reattach — mint fresh attachToken for existing sessionId
|
|
||||||
//
|
|
||||||
// Used by Commit 3's re-attach loop on the client. Validates the
|
|
||||||
// lease (rejects unknown/expired sessionId with 410 Gone), mints a
|
|
||||||
// fresh short-lived attachToken bound to the same sessionId, and
|
|
||||||
// pushes it to the agent. The client opens a new WS with the new
|
|
||||||
// token; the agent matches the sessionId binding and re-attaches
|
|
||||||
// to the existing PtySession (kept alive for the 60s detach
|
|
||||||
// window — Commit 3 wires that side).
|
|
||||||
if (url.pathname === '/pty-session/reattach' && req.method === 'POST') {
|
|
||||||
if (!validateAuth(req)) {
|
|
||||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
|
||||||
status: 401, headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const port = readTerminalPort();
|
|
||||||
if (!port) {
|
|
||||||
return new Response(JSON.stringify({ error: 'terminal-agent not ready' }), {
|
|
||||||
status: 503, headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let body: any;
|
|
||||||
try { body = await req.json(); } catch { body = null; }
|
|
||||||
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : null;
|
|
||||||
const v = sessionId ? validateLease(sessionId) : { ok: false };
|
|
||||||
if (!v.ok) {
|
|
||||||
// 410 Gone — session window has closed (lease expired or never
|
|
||||||
// existed). Client must fall back to /pty-session for a brand-new
|
|
||||||
// session.
|
|
||||||
return new Response(JSON.stringify({ error: 'lease expired or unknown' }), {
|
|
||||||
status: 410, headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const minted = mintPtySessionToken();
|
|
||||||
const granted = await grantPtyToken(minted.token, sessionId!);
|
|
||||||
if (!granted) {
|
|
||||||
revokePtySessionToken(minted.token);
|
|
||||||
return new Response(JSON.stringify({ error: 'failed to grant attach token' }), {
|
|
||||||
status: 503, headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
terminalPort: port,
|
|
||||||
sessionId,
|
|
||||||
attachToken: minted.token,
|
|
||||||
leaseExpiresAt: v.ok ? v.expiresAt : 0,
|
|
||||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── /pty-restart — one-transaction kill + fresh mint ────────────
|
|
||||||
//
|
|
||||||
// The Restart button. Synchronously disposes the caller's existing
|
|
||||||
// PtySession on the agent, revokes the old lease, mints a fresh
|
|
||||||
// sessionId + lease + attachToken, and returns the new 4-tuple in
|
|
||||||
// one response. Zero race window between kill and mint (codex T2
|
|
||||||
// + D8 of the eng review).
|
|
||||||
if (url.pathname === '/pty-restart' && req.method === 'POST') {
|
|
||||||
if (!validateAuth(req)) {
|
|
||||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
|
||||||
status: 401, headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const port = readTerminalPort();
|
|
||||||
if (!port) {
|
|
||||||
return new Response(JSON.stringify({ error: 'terminal-agent not ready' }), {
|
|
||||||
status: 503, headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let body: any;
|
|
||||||
try { body = await req.json(); } catch { body = null; }
|
|
||||||
const oldSessionId = typeof body?.sessionId === 'string' ? body.sessionId : null;
|
|
||||||
// Best-effort dispose. Missing/unknown sessionId is non-fatal —
|
|
||||||
// the client may be doing a "restart from scratch" with no prior
|
|
||||||
// session (e.g. ENDED state). The fresh mint always proceeds.
|
|
||||||
if (oldSessionId) {
|
|
||||||
await restartPtySession(oldSessionId);
|
|
||||||
revokeLease(oldSessionId);
|
|
||||||
}
|
|
||||||
const lease = mintLease();
|
|
||||||
const minted = mintPtySessionToken();
|
|
||||||
const granted = await grantPtyToken(minted.token, lease.sessionId);
|
|
||||||
if (!granted) {
|
|
||||||
revokePtySessionToken(minted.token);
|
|
||||||
revokeLease(lease.sessionId);
|
|
||||||
return new Response(JSON.stringify({ error: 'failed to grant terminal session' }), {
|
|
||||||
status: 503, headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
terminalPort: port,
|
|
||||||
sessionId: lease.sessionId,
|
|
||||||
attachToken: minted.token,
|
|
||||||
leaseExpiresAt: lease.expiresAt,
|
|
||||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── /pty-dispose — explicit teardown (pagehide / browser quit) ──
|
|
||||||
//
|
|
||||||
// sendBeacon-compatible: accepts the auth token in the BODY so the
|
|
||||||
// extension's pagehide handler can fire it without setting headers
|
|
||||||
// (sendBeacon doesn't support custom headers). Codex T3 fix —
|
|
||||||
// without this, every browser quit + sidebar close leaves a zombie
|
|
||||||
// PTY alive for the 60s detach window (Commit 3).
|
|
||||||
if (url.pathname === '/pty-dispose' && req.method === 'POST') {
|
|
||||||
let body: any;
|
|
||||||
try { body = await req.json(); } catch { body = null; }
|
|
||||||
const authTokenFromBody = typeof body?.authToken === 'string' ? body.authToken : null;
|
|
||||||
// Accept either header bearer OR body authToken. Both must match
|
|
||||||
// the root auth token; otherwise reject.
|
|
||||||
const headerToken = extractToken(req);
|
|
||||||
const authedByHeader = headerToken !== null && headerToken === authToken;
|
|
||||||
const authedByBody = authTokenFromBody !== null && authTokenFromBody === authToken;
|
|
||||||
if (!authedByHeader && !authedByBody) {
|
|
||||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
|
||||||
status: 401, headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : null;
|
|
||||||
if (sessionId) {
|
|
||||||
await restartPtySession(sessionId);
|
|
||||||
revokeLease(sessionId);
|
|
||||||
}
|
|
||||||
return new Response(JSON.stringify({ ok: true }), {
|
|
||||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── /internal/lease-refresh — loopback from terminal-agent on keepalive
|
|
||||||
//
|
|
||||||
// T6 PTY-only idle reset (codex outside-voice fix): the headless
|
|
||||||
// daemon's idle timer must reset only on active PTY usage, not on
|
|
||||||
// every passive SSE consumer. Terminal-agent calls this endpoint
|
|
||||||
// (lazily, only when its cached lease is within 5 min of expiry)
|
|
||||||
// on its 25s keepalive cycle. Refreshing the lease here also bumps
|
|
||||||
// lastActivity so the daemon stays alive while a sidebar terminal
|
|
||||||
// is actively in use.
|
|
||||||
//
|
|
||||||
// INTERNAL endpoint — bound to the root authToken so an external
|
|
||||||
// caller can't refresh another user's lease. Body: {sessionId}.
|
|
||||||
if (url.pathname === '/internal/lease-refresh' && req.method === 'POST') {
|
|
||||||
if (!validateAuth(req)) {
|
|
||||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
|
||||||
status: 401, headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let body: any;
|
|
||||||
try { body = await req.json(); } catch { body = null; }
|
|
||||||
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : null;
|
|
||||||
const r = sessionId ? refreshLease(sessionId) : { ok: false };
|
|
||||||
if (!r.ok) {
|
|
||||||
return new Response(JSON.stringify({ error: 'lease expired or unknown' }), {
|
|
||||||
status: 410, headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// T6: PTY activity resets the daemon idle timer.
|
|
||||||
resetIdleTimer();
|
|
||||||
return new Response(JSON.stringify({ ok: true, expiresAt: r.expiresAt }), {
|
|
||||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── /pty-inject-scan — pre-inject prompt-injection scan for the
|
// ─── /pty-inject-scan — pre-inject prompt-injection scan for the
|
||||||
// extension's gstackInjectToTerminal callers. The extension routes
|
// extension's gstackInjectToTerminal callers. The extension routes
|
||||||
// every page-derived text through this endpoint BEFORE writing to
|
// every page-derived text through this endpoint BEFORE writing to
|
||||||
@@ -3007,7 +2567,6 @@ export async function start() {
|
|||||||
xvfb,
|
xvfb,
|
||||||
proxyBridge,
|
proxyBridge,
|
||||||
startTime,
|
startTime,
|
||||||
ownsTerminalAgent: true, // CLI spawns terminal-agent.ts itself (see cli.ts:1037-1063)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
/**
|
|
||||||
* Shared sidebar utilities — extracted for testability.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize a URL from the Chrome extension before embedding in a prompt.
|
|
||||||
* Only accepts http/https, strips control characters, truncates to 2048 chars.
|
|
||||||
* Returns null if the URL is invalid or uses a non-http scheme.
|
|
||||||
*/
|
|
||||||
export function sanitizeExtensionUrl(url: string | null | undefined): string | null {
|
|
||||||
if (!url) return null;
|
|
||||||
try {
|
|
||||||
const u = new URL(url);
|
|
||||||
if (u.protocol === 'http:' || u.protocol === 'https:') {
|
|
||||||
return u.href.replace(/[\x00-\x1f\x7f]/g, '').slice(0, 2048);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
/**
|
|
||||||
* terminal-agent process-control primitives shared by cli.ts spawn site,
|
|
||||||
* server.ts shutdown teardown, and the v1.44 watchdog/respawn loop.
|
|
||||||
*
|
|
||||||
* Why this exists: pre-v1.44 used `pkill -f terminal-agent\.ts`, which
|
|
||||||
* matches any process whose argv contains the string and would kill
|
|
||||||
* sibling gstack sessions on the same host. The agent now writes a
|
|
||||||
* structured `terminal-agent-pid` record (`{pid, gen, startedAt}`) and
|
|
||||||
* every kill site routes through `killAgentByRecord` here — identity-based,
|
|
||||||
* no regex.
|
|
||||||
*
|
|
||||||
* The `gen` field is a per-boot generation counter. Loopback /internal/*
|
|
||||||
* calls from the parent server include `X-Browse-Gen` so a slow agent that
|
|
||||||
* the watchdog respawned around can't accidentally service a stale grant
|
|
||||||
* from the old generation.
|
|
||||||
*/
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { safeUnlink, safeKill, isProcessAlive } from './error-handling';
|
|
||||||
import { writeSecureFile, mkdirSecure } from './file-permissions';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Locate the terminal-agent script on disk. In dev (cli.ts running via
|
|
||||||
* `bun run`), it lives next to this file in browse/src. In a compiled
|
|
||||||
* binary, Bun's --compile bakes the source into the executable and
|
|
||||||
* exposes it relative to process.execPath. Either path must work or
|
|
||||||
* the agent can't be spawned at all.
|
|
||||||
*/
|
|
||||||
export function resolveTerminalAgentScript(searchHints: { metaDir?: string; execPath?: string } = {}): string | null {
|
|
||||||
const meta = searchHints.metaDir || __dirname;
|
|
||||||
const exec = searchHints.execPath || process.execPath;
|
|
||||||
const candidates = [
|
|
||||||
path.resolve(meta, 'terminal-agent.ts'),
|
|
||||||
path.resolve(path.dirname(exec), '..', 'src', 'terminal-agent.ts'),
|
|
||||||
];
|
|
||||||
for (const c of candidates) {
|
|
||||||
if (fs.existsSync(c)) return c;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Spawn a fresh terminal-agent as a detached child. Handles the standard
|
|
||||||
* three steps: kill any prior agent recorded at `<stateDir>/terminal-agent-pid`,
|
|
||||||
* clear the stale record, then `Bun.spawn(['bun', 'run', script], ...)` with
|
|
||||||
* env wiring. Returns the PID of the new agent on success, null when the
|
|
||||||
* agent script can't be located.
|
|
||||||
*
|
|
||||||
* Used by both the CLI cold-start path (cli.ts) and the v1.44 watchdog in
|
|
||||||
* server.ts. Centralizing here removes a copy-paste between them and means
|
|
||||||
* future spawn-env additions (e.g. BROWSE_OWNER_PID for the generation
|
|
||||||
* counter rollout) land in one place.
|
|
||||||
*/
|
|
||||||
export function spawnTerminalAgent(opts: {
|
|
||||||
stateFile: string;
|
|
||||||
serverPort: number;
|
|
||||||
cwd?: string;
|
|
||||||
/** Optional extra env vars to add to the agent's process env. */
|
|
||||||
extraEnv?: Record<string, string>;
|
|
||||||
/** Override script lookup for tests. */
|
|
||||||
scriptPath?: string;
|
|
||||||
}): number | null {
|
|
||||||
const stateDir = path.dirname(opts.stateFile);
|
|
||||||
const prior = readAgentRecord(stateDir);
|
|
||||||
if (prior) {
|
|
||||||
killAgentByRecord(prior, 'SIGTERM');
|
|
||||||
clearAgentRecord(stateDir);
|
|
||||||
}
|
|
||||||
const script = opts.scriptPath || resolveTerminalAgentScript();
|
|
||||||
if (!script || !fs.existsSync(script)) return null;
|
|
||||||
const proc = (Bun as any).spawn(['bun', 'run', script], {
|
|
||||||
cwd: opts.cwd || process.cwd(),
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
BROWSE_STATE_FILE: opts.stateFile,
|
|
||||||
BROWSE_SERVER_PORT: String(opts.serverPort),
|
|
||||||
...(opts.extraEnv || {}),
|
|
||||||
},
|
|
||||||
stdio: ['ignore', 'ignore', 'ignore'],
|
|
||||||
});
|
|
||||||
proc.unref?.();
|
|
||||||
return proc.pid ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AgentRecord {
|
|
||||||
pid: number;
|
|
||||||
/** Random per-boot identifier. Loopback /internal/* sees X-Browse-Gen: <gen>. */
|
|
||||||
gen: string;
|
|
||||||
/** ms since epoch. Reserved for future PID-reuse guards. */
|
|
||||||
startedAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function agentRecordPath(stateDir: string): string {
|
|
||||||
return path.join(stateDir, 'terminal-agent-pid');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read the current record. Returns null on missing/malformed file. */
|
|
||||||
export function readAgentRecord(stateDir: string): AgentRecord | null {
|
|
||||||
try {
|
|
||||||
const raw = fs.readFileSync(agentRecordPath(stateDir), 'utf-8');
|
|
||||||
const j = JSON.parse(raw);
|
|
||||||
if (typeof j?.pid === 'number' && typeof j?.gen === 'string' && typeof j?.startedAt === 'number') {
|
|
||||||
return j as AgentRecord;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Atomic write. Caller must ensure stateDir exists; agent does this at boot. */
|
|
||||||
export function writeAgentRecord(stateDir: string, record: AgentRecord): void {
|
|
||||||
try { mkdirSecure(stateDir); } catch {}
|
|
||||||
const target = agentRecordPath(stateDir);
|
|
||||||
const tmp = `${target}.tmp-${process.pid}`;
|
|
||||||
writeSecureFile(tmp, JSON.stringify(record));
|
|
||||||
fs.renameSync(tmp, target);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearAgentRecord(stateDir: string): void {
|
|
||||||
safeUnlink(agentRecordPath(stateDir));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Kill the agent identified by `record`. Signal defaults to SIGTERM (give
|
|
||||||
* the agent a chance to run its own SIGTERM cleanup). Returns true if a
|
|
||||||
* signal was actually sent to a live PID; false if the PID was already
|
|
||||||
* dead (no-op). Never throws — ESRCH is swallowed by safeKill.
|
|
||||||
*
|
|
||||||
* Validates liveness BEFORE signaling so a PID-reuse race (the recorded
|
|
||||||
* PID was reaped and a brand-new unrelated process now holds it) can't
|
|
||||||
* cause us to kill the wrong process. This is a best-effort defense:
|
|
||||||
* Linux/macOS don't expose process-start-time cheaply, and the gap
|
|
||||||
* between record-write and watchdog-tick is small (60s max).
|
|
||||||
*/
|
|
||||||
export function killAgentByRecord(
|
|
||||||
record: AgentRecord,
|
|
||||||
signal: NodeJS.Signals = 'SIGTERM',
|
|
||||||
): boolean {
|
|
||||||
if (!isProcessAlive(record.pid)) return false;
|
|
||||||
safeKill(record.pid, signal);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -48,9 +48,9 @@ describe('Dual-listener surface types', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Tunnel path allowlist', () => {
|
describe('Tunnel path allowlist', () => {
|
||||||
test('TUNNEL_PATHS is a closed set containing exactly /connect, /command, /sidebar-chat', () => {
|
test('TUNNEL_PATHS is a closed set containing exactly /connect, /command', () => {
|
||||||
const paths = extractSetContents(SERVER_SRC, 'TUNNEL_PATHS');
|
const paths = extractSetContents(SERVER_SRC, 'TUNNEL_PATHS');
|
||||||
expect(paths).toEqual(new Set(['/connect', '/command', '/sidebar-chat']));
|
expect(paths).toEqual(new Set(['/connect', '/command']));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('TUNNEL_PATHS does NOT contain bootstrap or admin paths', () => {
|
test('TUNNEL_PATHS does NOT contain bootstrap or admin paths', () => {
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
import { describe, test, expect, beforeEach } from 'bun:test';
|
|
||||||
|
|
||||||
// pty-session-lease registers a sessionId space distinct from the pre-v1.44
|
|
||||||
// attach-token space (browse/src/pty-session-cookie.ts). These tests pin
|
|
||||||
// the validate-first contract that codex outside-voice flagged as critical:
|
|
||||||
// refreshLease MUST NOT resurrect expired leases, otherwise the 30-min TTL
|
|
||||||
// stops bounding leaked-token blast radius.
|
|
||||||
|
|
||||||
import {
|
|
||||||
mintLease,
|
|
||||||
validateLease,
|
|
||||||
refreshLease,
|
|
||||||
revokeLease,
|
|
||||||
leaseCount,
|
|
||||||
__resetLeases,
|
|
||||||
} from '../src/pty-session-lease';
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
__resetLeases();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('pty-session-lease: mint/validate/revoke', () => {
|
|
||||||
test('mintLease returns a fresh non-secret sessionId + future expiresAt', () => {
|
|
||||||
const a = mintLease();
|
|
||||||
const b = mintLease();
|
|
||||||
expect(a.sessionId).toBeTruthy();
|
|
||||||
expect(b.sessionId).toBeTruthy();
|
|
||||||
expect(a.sessionId).not.toBe(b.sessionId);
|
|
||||||
expect(a.expiresAt).toBeGreaterThan(Date.now());
|
|
||||||
// base64url alphabet: characters in [A-Za-z0-9_-].
|
|
||||||
expect(a.sessionId).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
||||||
expect(leaseCount()).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('validateLease ok for fresh lease, false for unknown', () => {
|
|
||||||
const { sessionId } = mintLease();
|
|
||||||
const ok = validateLease(sessionId);
|
|
||||||
expect(ok.ok).toBe(true);
|
|
||||||
if (ok.ok) expect(ok.expiresAt).toBeGreaterThan(Date.now());
|
|
||||||
expect(validateLease('not-a-real-session-id').ok).toBe(false);
|
|
||||||
expect(validateLease(null).ok).toBe(false);
|
|
||||||
expect(validateLease(undefined).ok).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('revokeLease removes the lease; subsequent validate returns false', () => {
|
|
||||||
const { sessionId } = mintLease();
|
|
||||||
expect(validateLease(sessionId).ok).toBe(true);
|
|
||||||
revokeLease(sessionId);
|
|
||||||
expect(validateLease(sessionId).ok).toBe(false);
|
|
||||||
expect(leaseCount()).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('revokeLease tolerates unknown sessionId without throwing', () => {
|
|
||||||
expect(() => revokeLease('phantom')).not.toThrow();
|
|
||||||
expect(() => revokeLease(null)).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('pty-session-lease: refresh contract (validate-first)', () => {
|
|
||||||
test('refreshLease extends expiresAt for a valid lease', () => {
|
|
||||||
const { sessionId, expiresAt: initial } = mintLease();
|
|
||||||
// Sleep micro-tick — Date.now() is ms-grain so a synchronous extend
|
|
||||||
// may not move the integer. Use a tight async wait instead.
|
|
||||||
return new Promise<void>((resolve) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
const r = refreshLease(sessionId);
|
|
||||||
expect(r.ok).toBe(true);
|
|
||||||
if (r.ok) expect(r.expiresAt).toBeGreaterThan(initial);
|
|
||||||
resolve();
|
|
||||||
}, 5);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('refreshLease rejects unknown sessionId (validate-first invariant)', () => {
|
|
||||||
const r = refreshLease('never-minted');
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('refreshLease never resurrects an expired lease', async () => {
|
|
||||||
// Force TTL down to 5ms for this assertion by minting + waiting past expiry.
|
|
||||||
// Lease internals use Date.now() so the easiest way to expire one is
|
|
||||||
// to artificially backdate via revoke+remint cycle. Simpler: mint, then
|
|
||||||
// wait for the registry's own expiry check to trip.
|
|
||||||
//
|
|
||||||
// We can't backdate without breaking encapsulation, so this test exercises
|
|
||||||
// the negative-validate path: minted lease, then prove that refresh after
|
|
||||||
// explicit revoke still returns ok:false (same as expired-and-pruned).
|
|
||||||
const { sessionId } = mintLease();
|
|
||||||
revokeLease(sessionId);
|
|
||||||
const r = refreshLease(sessionId);
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('refreshLease tolerates null / undefined sessionId', () => {
|
|
||||||
expect(refreshLease(null).ok).toBe(false);
|
|
||||||
expect(refreshLease(undefined).ok).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
/**
|
|
||||||
* Real-Chromium regression coverage for the sidepanel's current security UI.
|
|
||||||
*
|
|
||||||
* The classifier-backed chat queue was removed when the primary surface
|
|
||||||
* became a terminal PTY. Until classifier status is wired to that surface,
|
|
||||||
* the honest contract is deliberately negative:
|
|
||||||
*
|
|
||||||
* - /health.security.status must not light the hidden SEC shield.
|
|
||||||
* - retired /sidebar-chat security_event data must not render a banner or
|
|
||||||
* leak attacker-controlled text into the terminal surface.
|
|
||||||
*
|
|
||||||
* Every HTTP, SSE, WebSocket, and beacon primitive is replaced before the
|
|
||||||
* sidepanel scripts load, so this test never reaches a real browse server.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { chromium, type Browser, type Page } from 'playwright';
|
|
||||||
|
|
||||||
const EXTENSION_DIR = path.resolve(import.meta.dir, '..', '..', 'extension');
|
|
||||||
const SIDEPANEL_URL = `file://${EXTENSION_DIR}/sidepanel.html`;
|
|
||||||
|
|
||||||
const CHROMIUM_AVAILABLE = (() => {
|
|
||||||
try {
|
|
||||||
const executable = chromium.executablePath();
|
|
||||||
return Boolean(executable && fs.existsSync(executable));
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
type Scenario = {
|
|
||||||
healthSecurity: {
|
|
||||||
status: 'protected' | 'degraded' | 'inactive';
|
|
||||||
layers?: Record<string, string>;
|
|
||||||
};
|
|
||||||
securityEntries?: unknown[];
|
|
||||||
};
|
|
||||||
|
|
||||||
async function installStubsBeforeLoad(page: Page, scenario: Scenario): Promise<void> {
|
|
||||||
await page.addInitScript((params: Scenario) => {
|
|
||||||
const requests: Array<{ url: string; method: string }> = [];
|
|
||||||
(window as any).__gstackTestRequests = requests;
|
|
||||||
|
|
||||||
(window as any).chrome = {
|
|
||||||
runtime: {
|
|
||||||
sendMessage: (_request: unknown, callback?: (value: unknown) => void) => {
|
|
||||||
// Omit a token so sidepanel.js exercises the direct /health
|
|
||||||
// bootstrap path whose security payload is under test.
|
|
||||||
const payload = { connected: true, port: 34567 };
|
|
||||||
if (typeof callback === 'function') {
|
|
||||||
setTimeout(() => callback(payload), 0);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return Promise.resolve(payload);
|
|
||||||
},
|
|
||||||
lastError: null,
|
|
||||||
onMessage: { addListener: () => {} },
|
|
||||||
},
|
|
||||||
tabs: {
|
|
||||||
query: (_query: unknown, callback: (tabs: unknown[]) => void) =>
|
|
||||||
setTimeout(() => callback([{ id: 1, url: 'https://example.com' }]), 0),
|
|
||||||
onActivated: { addListener: () => {} },
|
|
||||||
onUpdated: { addListener: () => {} },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
(window as any).EventSource = class StubEventSource {
|
|
||||||
static CONNECTING = 0;
|
|
||||||
static OPEN = 1;
|
|
||||||
static CLOSED = 2;
|
|
||||||
readyState = 1;
|
|
||||||
|
|
||||||
constructor(url: string) {
|
|
||||||
requests.push({ url: String(url), method: 'EVENTSOURCE' });
|
|
||||||
}
|
|
||||||
|
|
||||||
addEventListener() {}
|
|
||||||
close() { this.readyState = 2; }
|
|
||||||
};
|
|
||||||
|
|
||||||
(window as any).WebSocket = class StubWebSocket {
|
|
||||||
static CONNECTING = 0;
|
|
||||||
static OPEN = 1;
|
|
||||||
static CLOSING = 2;
|
|
||||||
static CLOSED = 3;
|
|
||||||
readyState = 0;
|
|
||||||
|
|
||||||
constructor(url: string) {
|
|
||||||
requests.push({ url: String(url), method: 'WEBSOCKET' });
|
|
||||||
}
|
|
||||||
|
|
||||||
addEventListener() {}
|
|
||||||
send() {}
|
|
||||||
close() { this.readyState = 3; }
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.defineProperty(navigator, 'sendBeacon', {
|
|
||||||
configurable: true,
|
|
||||||
value: (url: string) => {
|
|
||||||
requests.push({ url: String(url), method: 'BEACON' });
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
||||||
const url = String(input);
|
|
||||||
requests.push({ url, method: init?.method ?? 'GET' });
|
|
||||||
|
|
||||||
if (url.endsWith('/health')) {
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
status: 'healthy',
|
|
||||||
token: 'test-token',
|
|
||||||
AUTH_TOKEN: 'test-token',
|
|
||||||
mode: 'headed',
|
|
||||||
agent: { status: 'idle', runningFor: null, queueLength: 0 },
|
|
||||||
session: null,
|
|
||||||
security: params.healthSecurity,
|
|
||||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
||||||
}
|
|
||||||
if (url.endsWith('/sse-session')) {
|
|
||||||
return new Response(null, { status: 204 });
|
|
||||||
}
|
|
||||||
if (url.endsWith('/memory')) {
|
|
||||||
return new Response(JSON.stringify({ bunServer: { rss: 0 }, tabs: [] }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (url.endsWith('/pty-session')) {
|
|
||||||
// Keep the terminal bootstrap deterministic and prevent a WebSocket
|
|
||||||
// attempt; this test concerns the pre-session terminal surface.
|
|
||||||
return new Response('terminal disabled in DOM test', { status: 503 });
|
|
||||||
}
|
|
||||||
if (url.includes('/sidebar-chat')) {
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
entries: params.securityEntries ?? [],
|
|
||||||
total: (params.securityEntries ?? []).length,
|
|
||||||
agentStatus: 'idle',
|
|
||||||
security: params.healthSecurity,
|
|
||||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
||||||
}
|
|
||||||
if (url.endsWith('/refs')) {
|
|
||||||
return new Response(JSON.stringify({ refs: [] }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fail closed inside the stub rather than falling through to the real
|
|
||||||
// network. Recording the URL above keeps unexpected bootstrap calls
|
|
||||||
// diagnosable in assertion output.
|
|
||||||
return new Response(JSON.stringify({ error: 'unstubbed test endpoint' }), {
|
|
||||||
status: 404,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}, scenario);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openStubbedSidepanel(
|
|
||||||
scenario: Scenario,
|
|
||||||
assertion: (page: Page) => Promise<void>,
|
|
||||||
): Promise<void> {
|
|
||||||
const context = await browser!.newContext();
|
|
||||||
try {
|
|
||||||
const page = await context.newPage();
|
|
||||||
await installStubsBeforeLoad(page, scenario);
|
|
||||||
await page.goto(SIDEPANEL_URL);
|
|
||||||
await page.waitForFunction(() =>
|
|
||||||
(window as any).gstackAuthToken === 'test-token' &&
|
|
||||||
document.getElementById('footer-dot')?.classList.contains('connected'),
|
|
||||||
);
|
|
||||||
await assertion(page);
|
|
||||||
} finally {
|
|
||||||
await context.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let browser: Browser | null = null;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
if (!CHROMIUM_AVAILABLE) return;
|
|
||||||
browser = await chromium.launch({ headless: true });
|
|
||||||
}, 30_000);
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
if (!browser) return;
|
|
||||||
try {
|
|
||||||
await browser.close();
|
|
||||||
} catch {}
|
|
||||||
browser = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('sidepanel security DOM', () => {
|
|
||||||
test.skipIf(!CHROMIUM_AVAILABLE)(
|
|
||||||
'protected health metadata does not expose an unwired SEC claim',
|
|
||||||
async () => {
|
|
||||||
await openStubbedSidepanel({
|
|
||||||
healthSecurity: {
|
|
||||||
status: 'protected',
|
|
||||||
layers: { testsavant: 'ok', transcript: 'ok', canary: 'ok' },
|
|
||||||
},
|
|
||||||
}, async (page) => {
|
|
||||||
const shield = page.locator('#security-shield');
|
|
||||||
expect(await shield.count()).toBe(1);
|
|
||||||
expect(await shield.isVisible()).toBe(false);
|
|
||||||
expect(await shield.getAttribute('data-status')).toBeNull();
|
|
||||||
expect(await shield.getAttribute('aria-label')).toBe('Security status: unknown');
|
|
||||||
|
|
||||||
const visibleText = await page.locator('body').innerText();
|
|
||||||
expect(visibleText).not.toContain('SEC');
|
|
||||||
expect(visibleText.toLowerCase()).not.toContain('protected');
|
|
||||||
|
|
||||||
const requests = await page.evaluate(() => (window as any).__gstackTestRequests);
|
|
||||||
expect(requests.some((request: { url: string }) => request.url.endsWith('/health'))).toBe(true);
|
|
||||||
expect(requests.some((request: { url: string }) => request.url.endsWith('/sse-session'))).toBe(true);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
15_000,
|
|
||||||
);
|
|
||||||
|
|
||||||
test.skipIf(!CHROMIUM_AVAILABLE)(
|
|
||||||
'retired security_event data is neither polled nor rendered into the terminal',
|
|
||||||
async () => {
|
|
||||||
const attackerMarker = 'ATTACKER-CONTROLLED-TERMINAL-MARKER';
|
|
||||||
const attackerDomain = 'retired-chat.attacker.example';
|
|
||||||
await openStubbedSidepanel({
|
|
||||||
healthSecurity: {
|
|
||||||
status: 'protected',
|
|
||||||
layers: { testsavant: 'ok', transcript: 'ok', canary: 'ok' },
|
|
||||||
},
|
|
||||||
securityEntries: [{
|
|
||||||
id: 1,
|
|
||||||
ts: '2026-04-20T00:00:00Z',
|
|
||||||
role: 'agent',
|
|
||||||
type: 'security_event',
|
|
||||||
verdict: 'block',
|
|
||||||
reason: attackerMarker,
|
|
||||||
layer: 'canary',
|
|
||||||
confidence: 1,
|
|
||||||
domain: attackerDomain,
|
|
||||||
}],
|
|
||||||
}, async (page) => {
|
|
||||||
// Let immediate connection work and the first memory poll settle;
|
|
||||||
// neither may reintroduce the retired chat polling path.
|
|
||||||
await page.waitForTimeout(650);
|
|
||||||
|
|
||||||
const requests = await page.evaluate(() => (window as any).__gstackTestRequests);
|
|
||||||
expect(requests.some((request: { url: string }) => request.url.includes('/sidebar-chat'))).toBe(false);
|
|
||||||
expect(requests.some((request: { url: string }) => request.url.endsWith('/memory'))).toBe(true);
|
|
||||||
expect(requests.some((request: { url: string }) => request.url.startsWith('https://'))).toBe(false);
|
|
||||||
|
|
||||||
expect(await page.locator('#security-banner').count()).toBe(0);
|
|
||||||
expect(await page.locator('.security-banner').count()).toBe(0);
|
|
||||||
const terminalText = await page.locator('#tab-terminal').innerText();
|
|
||||||
expect(terminalText).not.toContain(attackerMarker);
|
|
||||||
expect(terminalText).not.toContain(attackerDomain);
|
|
||||||
expect(await page.locator('#security-shield').isVisible()).toBe(false);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
15_000,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -314,7 +314,7 @@ describe('Server auth security', () => {
|
|||||||
// Regression: connect command crashed with "domains is not defined" because
|
// Regression: connect command crashed with "domains is not defined" because
|
||||||
// a stray `domains,` variable was in the status fetch body (cli.ts:852).
|
// a stray `domains,` variable was in the status fetch body (cli.ts:852).
|
||||||
test('connect command status fetch body has no undefined variable references', () => {
|
test('connect command status fetch body has no undefined variable references', () => {
|
||||||
const connectBlock = sliceBetween(CLI_SRC, 'Launching headed Chromium', 'Terminal agent started');
|
const connectBlock = sliceBetween(CLI_SRC, 'Launching headed Chromium', 'Connect failed');
|
||||||
// The status fetch should use a clean JSON body
|
// The status fetch should use a clean JSON body
|
||||||
expect(connectBlock).toContain("command: 'status'");
|
expect(connectBlock).toContain("command: 'status'");
|
||||||
// Must NOT contain a bare `domains` reference in the fetch body
|
// Must NOT contain a bare `domains` reference in the fetch body
|
||||||
@@ -341,7 +341,7 @@ describe('Server auth security', () => {
|
|||||||
// assigned via object-literal syntax (`BROWSE_PARENT_PID: '0'`)
|
// assigned via object-literal syntax (`BROWSE_PARENT_PID: '0'`)
|
||||||
// inside the `const serverEnv: Record<string, string> = { ... }`
|
// inside the `const serverEnv: Record<string, string> = { ... }`
|
||||||
// declaration. Assert both pieces appear in the connect block.
|
// declaration. Assert both pieces appear in the connect block.
|
||||||
const connectBlock = sliceBetween(CLI_SRC, 'Launching headed Chromium', 'Terminal agent started');
|
const connectBlock = sliceBetween(CLI_SRC, 'Launching headed Chromium', 'Connect failed');
|
||||||
expect(connectBlock).toContain("const serverEnv");
|
expect(connectBlock).toContain("const serverEnv");
|
||||||
expect(connectBlock).toContain("BROWSE_PARENT_PID: '0'");
|
expect(connectBlock).toContain("BROWSE_PARENT_PID: '0'");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,232 +0,0 @@
|
|||||||
import { describe, test, expect, beforeEach, beforeAll, afterAll } from 'bun:test';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
import * as crypto from 'crypto';
|
|
||||||
import {
|
|
||||||
buildFetchHandler,
|
|
||||||
__resetShuttingDown,
|
|
||||||
type ServerConfig,
|
|
||||||
} from '../src/server';
|
|
||||||
import { __resetRegistry } from '../src/token-registry';
|
|
||||||
import { BrowserManager } from '../src/browser-manager';
|
|
||||||
import { resolveConfig } from '../src/config';
|
|
||||||
|
|
||||||
// Tests for the v1.41+ ownsTerminalAgent flag.
|
|
||||||
//
|
|
||||||
// Embedders (gbrowser phoenix overlay) that run their own PTY server and write
|
|
||||||
// terminal-port / terminal-internal-token / terminal-agent-pid themselves were
|
|
||||||
// getting those files clobbered by gstack's shutdown(). The flag (default true)
|
|
||||||
// gates four side effects (v1.44+):
|
|
||||||
// 1. identity-based kill of the PID in <stateDir>/terminal-agent-pid
|
|
||||||
// 2. unlink terminal-port
|
|
||||||
// 3. unlink terminal-internal-token
|
|
||||||
// 4. unlink terminal-agent-pid
|
|
||||||
// False = embedder owns them, gstack stays hands-off.
|
|
||||||
//
|
|
||||||
// Pre-v1.44 used `pkill -f terminal-agent\.ts` which matched sibling gstack
|
|
||||||
// sessions on the same host — see browse/src/terminal-agent-control.ts header.
|
|
||||||
//
|
|
||||||
// CRITICAL: each test stubs process.exit (so shutdown's exit doesn't kill
|
|
||||||
// the test runner). The PID in the test agent-record is a guaranteed-dead
|
|
||||||
// PID (1 = init / launchd — exists but cannot be killed by an unprivileged
|
|
||||||
// process, so safeKill returns ESRCH-equivalent without affecting anything).
|
|
||||||
// Use isProcessAlive's false branch by also testing with a PID that does
|
|
||||||
// not exist (negative PID rejected by the OS).
|
|
||||||
|
|
||||||
const stateDir = resolveConfig().stateDir;
|
|
||||||
const PORT_FILE = path.join(stateDir, 'terminal-port');
|
|
||||||
const TOKEN_FILE = path.join(stateDir, 'terminal-internal-token');
|
|
||||||
const AGENT_RECORD_FILE = path.join(stateDir, 'terminal-agent-pid');
|
|
||||||
const SENTINEL_PORT = 'sentinel-port-65432';
|
|
||||||
const SENTINEL_TOKEN = 'sentinel-token-abcdef1234567890';
|
|
||||||
// PID 2^31-1 is the Linux PID_MAX_LIMIT; macOS uses 99998. Either way, no
|
|
||||||
// real process will ever hold this PID on a developer machine. isProcessAlive
|
|
||||||
// returns false → killAgentByRecord no-ops without sending any signal.
|
|
||||||
const SENTINEL_DEAD_PID = 2147483646;
|
|
||||||
|
|
||||||
function makeMinimalConfig(overrides: Partial<ServerConfig> = {}): ServerConfig {
|
|
||||||
const token = 'embedder-test-' + crypto.randomBytes(16).toString('hex');
|
|
||||||
return {
|
|
||||||
authToken: token,
|
|
||||||
browsePort: 34568,
|
|
||||||
idleTimeoutMs: 1_800_000,
|
|
||||||
config: resolveConfig(),
|
|
||||||
browserManager: new BrowserManager(),
|
|
||||||
startTime: Date.now(),
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeSentinels(): void {
|
|
||||||
fs.mkdirSync(stateDir, { recursive: true });
|
|
||||||
fs.writeFileSync(PORT_FILE, SENTINEL_PORT);
|
|
||||||
fs.writeFileSync(TOKEN_FILE, SENTINEL_TOKEN);
|
|
||||||
fs.writeFileSync(
|
|
||||||
AGENT_RECORD_FILE,
|
|
||||||
JSON.stringify({ pid: SENTINEL_DEAD_PID, gen: 'sentinel-gen', startedAt: Date.now() }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function readIfExists(p: string): string | null {
|
|
||||||
try { return fs.readFileSync(p, 'utf-8'); } catch { return null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stubs process.exit so shutdown()'s process.exit(0) throws an __exit:N
|
|
||||||
* marker the test can swallow instead of killing the runner. Also stubs
|
|
||||||
* process.kill so an accidental kill (regression in killAgentByRecord
|
|
||||||
* that bypassed isProcessAlive) cannot reach a real PID on the developer
|
|
||||||
* machine. Returns the captured kill calls so tests can assert kill
|
|
||||||
* scope.
|
|
||||||
*/
|
|
||||||
async function withStubs(
|
|
||||||
cb: (killCalls: Array<[number, NodeJS.Signals | number]>) => Promise<void>
|
|
||||||
): Promise<Array<[number, NodeJS.Signals | number]>> {
|
|
||||||
const origExit = process.exit;
|
|
||||||
const origKill = process.kill;
|
|
||||||
const killCalls: Array<[number, NodeJS.Signals | number]> = [];
|
|
||||||
(process as any).exit = ((code: number) => {
|
|
||||||
throw new Error(`__exit:${code}`);
|
|
||||||
}) as any;
|
|
||||||
(process as any).kill = ((pid: number, signal: NodeJS.Signals | number) => {
|
|
||||||
killCalls.push([pid, signal ?? 'SIGTERM']);
|
|
||||||
// signal 0 is a liveness probe — keep the existing 'process is dead'
|
|
||||||
// semantics so isProcessAlive(SENTINEL_DEAD_PID) returns false.
|
|
||||||
if (signal === 0) {
|
|
||||||
const err: any = new Error('No such process');
|
|
||||||
err.code = 'ESRCH';
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}) as any;
|
|
||||||
try {
|
|
||||||
await cb(killCalls);
|
|
||||||
} finally {
|
|
||||||
(process as any).exit = origExit;
|
|
||||||
(process as any).kill = origKill;
|
|
||||||
}
|
|
||||||
return killCalls;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runShutdown(handle: { shutdown: (code?: number) => Promise<void> }): Promise<void> {
|
|
||||||
try {
|
|
||||||
await handle.shutdown(0);
|
|
||||||
} catch (err: any) {
|
|
||||||
if (typeof err?.message !== 'string' || !err.message.startsWith('__exit:')) throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter out the signal=0 liveness probes; only count actual termination signals.
|
|
||||||
function terminationCalls(
|
|
||||||
calls: Array<[number, NodeJS.Signals | number]>,
|
|
||||||
): Array<[number, NodeJS.Signals | number]> {
|
|
||||||
return calls.filter(([, sig]) => sig !== 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('buildFetchHandler ownsTerminalAgent gate', () => {
|
|
||||||
// shutdown() reads `path.dirname(config.stateFile)` from module-level config
|
|
||||||
// (composition gap — see TODOS T9). So unlinks target the real state dir,
|
|
||||||
// not a per-test temp dir. If a real gstack daemon is running on this host,
|
|
||||||
// its terminal-port + terminal-internal-token + terminal-agent-pid live
|
|
||||||
// where this test writes. Save + restore real-daemon file contents around
|
|
||||||
// the whole suite so the test never clobbers a developer's running session.
|
|
||||||
let realPortBackup: string | null = null;
|
|
||||||
let realTokenBackup: string | null = null;
|
|
||||||
let realAgentRecordBackup: string | null = null;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
realPortBackup = readIfExists(PORT_FILE);
|
|
||||||
realTokenBackup = readIfExists(TOKEN_FILE);
|
|
||||||
realAgentRecordBackup = readIfExists(AGENT_RECORD_FILE);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
if (realPortBackup !== null) {
|
|
||||||
fs.mkdirSync(stateDir, { recursive: true });
|
|
||||||
fs.writeFileSync(PORT_FILE, realPortBackup);
|
|
||||||
} else {
|
|
||||||
try { fs.unlinkSync(PORT_FILE); } catch {}
|
|
||||||
}
|
|
||||||
if (realTokenBackup !== null) {
|
|
||||||
fs.mkdirSync(stateDir, { recursive: true });
|
|
||||||
fs.writeFileSync(TOKEN_FILE, realTokenBackup);
|
|
||||||
} else {
|
|
||||||
try { fs.unlinkSync(TOKEN_FILE); } catch {}
|
|
||||||
}
|
|
||||||
if (realAgentRecordBackup !== null) {
|
|
||||||
fs.mkdirSync(stateDir, { recursive: true });
|
|
||||||
fs.writeFileSync(AGENT_RECORD_FILE, realAgentRecordBackup);
|
|
||||||
} else {
|
|
||||||
try { fs.unlinkSync(AGENT_RECORD_FILE); } catch {}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
__resetRegistry();
|
|
||||||
__resetShuttingDown();
|
|
||||||
// Clean any leftover sentinels from a prior failed run so the "preserved"
|
|
||||||
// assertion can't pass spuriously off a stale file.
|
|
||||||
try { fs.unlinkSync(PORT_FILE); } catch {}
|
|
||||||
try { fs.unlinkSync(TOKEN_FILE); } catch {}
|
|
||||||
try { fs.unlinkSync(AGENT_RECORD_FILE); } catch {}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('1. ownsTerminalAgent:false preserves all three files and sends no signal', async () => {
|
|
||||||
writeSentinels();
|
|
||||||
const handle = buildFetchHandler(makeMinimalConfig({ ownsTerminalAgent: false }));
|
|
||||||
const calls = await withStubs(async () => {
|
|
||||||
await runShutdown(handle);
|
|
||||||
});
|
|
||||||
expect(readIfExists(PORT_FILE)).toBe(SENTINEL_PORT);
|
|
||||||
expect(readIfExists(TOKEN_FILE)).toBe(SENTINEL_TOKEN);
|
|
||||||
expect(readIfExists(AGENT_RECORD_FILE)).not.toBeNull();
|
|
||||||
expect(terminationCalls(calls).length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('2. ownsTerminalAgent:true deletes all three files; identity-based kill probes the recorded PID', async () => {
|
|
||||||
writeSentinels();
|
|
||||||
const handle = buildFetchHandler(makeMinimalConfig({ ownsTerminalAgent: true }));
|
|
||||||
const calls = await withStubs(async () => {
|
|
||||||
await runShutdown(handle);
|
|
||||||
});
|
|
||||||
expect(readIfExists(PORT_FILE)).toBeNull();
|
|
||||||
expect(readIfExists(TOKEN_FILE)).toBeNull();
|
|
||||||
expect(readIfExists(AGENT_RECORD_FILE)).toBeNull();
|
|
||||||
// isProcessAlive sends signal 0; PID is the sentinel-dead PID, so the
|
|
||||||
// probe returns false and no SIGTERM is sent.
|
|
||||||
const probes = calls.filter(([pid, sig]) => pid === SENTINEL_DEAD_PID && sig === 0);
|
|
||||||
expect(probes.length).toBeGreaterThan(0);
|
|
||||||
expect(terminationCalls(calls).length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('3. ownsTerminalAgent unset defaults to true (deletes all three; probes recorded PID)', async () => {
|
|
||||||
writeSentinels();
|
|
||||||
// Note: no ownsTerminalAgent in the overrides — uses the `?? true` default.
|
|
||||||
const handle = buildFetchHandler(makeMinimalConfig());
|
|
||||||
const calls = await withStubs(async () => {
|
|
||||||
await runShutdown(handle);
|
|
||||||
});
|
|
||||||
expect(readIfExists(PORT_FILE)).toBeNull();
|
|
||||||
expect(readIfExists(TOKEN_FILE)).toBeNull();
|
|
||||||
expect(readIfExists(AGENT_RECORD_FILE)).toBeNull();
|
|
||||||
const probes = calls.filter(([pid, sig]) => pid === SENTINEL_DEAD_PID && sig === 0);
|
|
||||||
expect(probes.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('4. CLI start() call site passes ownsTerminalAgent: true literally (static grep)', () => {
|
|
||||||
// Resolves browse/src/server.ts relative to this test file so the test
|
|
||||||
// works regardless of cwd. import.meta.url is the test file's URL.
|
|
||||||
const serverTsPath = path.resolve(
|
|
||||||
new URL(import.meta.url).pathname,
|
|
||||||
'..',
|
|
||||||
'..',
|
|
||||||
'src',
|
|
||||||
'server.ts',
|
|
||||||
);
|
|
||||||
const source = fs.readFileSync(serverTsPath, 'utf-8');
|
|
||||||
// Match the call site inside start()'s buildFetchHandler({...}) literal.
|
|
||||||
// The pattern looks for the trailing comma and trailing context so the
|
|
||||||
// match cannot be satisfied by the JSDoc reference earlier in the file.
|
|
||||||
expect(source).toMatch(/ownsTerminalAgent:\s*true,\s*\/\/\s*CLI spawns terminal-agent\.ts/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import { describe, test, expect } from 'bun:test';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
// Server-side route shape for the v1.44 lease + restart + dispose +
|
|
||||||
// lease-refresh wiring. Live route exercises require the terminal-agent
|
|
||||||
// loopback to be live (e2e-tier); these static-grep tripwires pin the
|
|
||||||
// load-bearing protocol invariants.
|
|
||||||
|
|
||||||
const SERVER_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'server.ts');
|
|
||||||
|
|
||||||
describe('server: PTY lease routes (v1.44+ Commit 2)', () => {
|
|
||||||
test('1. /pty-session returns the 4-tuple shape (sessionId, attachToken, leaseExpiresAt)', () => {
|
|
||||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
|
||||||
const block = sliceBetween(src, "url.pathname === '/pty-session' &&", "url.pathname === '/pty-session/reattach'");
|
|
||||||
expect(block).toContain('mintLease()');
|
|
||||||
expect(block).toContain('grantPtyToken(minted.token, lease.sessionId)');
|
|
||||||
expect(block).toContain('sessionId: lease.sessionId');
|
|
||||||
expect(block).toContain('attachToken: minted.token');
|
|
||||||
expect(block).toContain('leaseExpiresAt: lease.expiresAt');
|
|
||||||
// Backward compat: legacy ptySessionToken alias preserved for one release.
|
|
||||||
expect(block).toContain('ptySessionToken: minted.token');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('2. /pty-session/reattach validates lease + mints fresh attachToken', () => {
|
|
||||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
|
||||||
const block = sliceBetween(src, "url.pathname === '/pty-session/reattach'", "url.pathname === '/pty-restart'");
|
|
||||||
// Validate-first: rejects unknown/expired sessionId with 410 Gone so
|
|
||||||
// the client knows to fall back to a fresh /pty-session.
|
|
||||||
expect(block).toContain('validateLease(sessionId)');
|
|
||||||
expect(block).toContain('status: 410');
|
|
||||||
// Mint fresh token bound to SAME sessionId.
|
|
||||||
expect(block).toContain('grantPtyToken(minted.token, sessionId!)');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('3. /pty-restart is one transaction — dispose + revoke + fresh mint', () => {
|
|
||||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
|
||||||
const block = sliceBetween(src, "url.pathname === '/pty-restart'", "url.pathname === '/pty-dispose'");
|
|
||||||
// Disposes old session (best-effort — missing sessionId is non-fatal).
|
|
||||||
expect(block).toContain('restartPtySession(oldSessionId)');
|
|
||||||
expect(block).toContain('revokeLease(oldSessionId)');
|
|
||||||
// Then mints fresh sessionId + lease + attachToken in the same handler.
|
|
||||||
expect(block).toContain('mintLease()');
|
|
||||||
expect(block).toContain('grantPtyToken(minted.token, lease.sessionId)');
|
|
||||||
// Returns the same 4-tuple shape so the client doesn't need a
|
|
||||||
// separate /pty-session round-trip.
|
|
||||||
expect(block).toContain('attachToken: minted.token');
|
|
||||||
expect(block).toContain('leaseExpiresAt: lease.expiresAt');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('4. /pty-dispose accepts body-token (sendBeacon-compatible)', () => {
|
|
||||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
|
||||||
const block = sliceBetween(src, "url.pathname === '/pty-dispose'", "url.pathname === '/internal/lease-refresh'");
|
|
||||||
// sendBeacon can't set custom headers, so the route MUST accept the
|
|
||||||
// auth token in the request body. Otherwise pagehide cleanup fails
|
|
||||||
// silently every time the user closes the browser.
|
|
||||||
expect(block).toContain('body?.authToken');
|
|
||||||
expect(block).toContain('authedByBody');
|
|
||||||
// Both auth paths must validate against authToken — never just trust
|
|
||||||
// a body-supplied token without the equality check.
|
|
||||||
expect(block).toContain('authTokenFromBody === authToken');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('5. /internal/lease-refresh resets the daemon idle timer (T6)', () => {
|
|
||||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
|
||||||
const block = sliceBetween(src, "url.pathname === '/internal/lease-refresh'", '─── /pty-inject-scan');
|
|
||||||
expect(block).toContain('refreshLease(sessionId)');
|
|
||||||
expect(block).toContain('resetIdleTimer()');
|
|
||||||
// Refresh failure (unknown / expired) MUST 410, not 200, so the
|
|
||||||
// agent knows to close the WS and force a clean re-auth.
|
|
||||||
expect(block).toContain('status: 410');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('6. grantPtyToken loopback carries sessionId binding', () => {
|
|
||||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
|
||||||
expect(src).toMatch(/grantPtyToken\(token: string, sessionId\?: string\)/);
|
|
||||||
expect(src).toContain('sessionId ? { token, sessionId } : { token }');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('7. restartPtySession helper exists and POSTs the agent /internal/restart', () => {
|
|
||||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
|
||||||
expect(src).toMatch(/async function restartPtySession\(sessionId: string\)/);
|
|
||||||
expect(src).toContain('/internal/restart');
|
|
||||||
expect(src).toContain('JSON.stringify({ sessionId })');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function sliceBetween(source: string, start: string, end: string): string {
|
|
||||||
const i = source.indexOf(start);
|
|
||||||
if (i === -1) throw new Error(`marker not found: ${start}`);
|
|
||||||
const j = source.indexOf(end, i + start.length);
|
|
||||||
if (j === -1) throw new Error(`end marker not found: ${end}`);
|
|
||||||
return source.slice(i, j);
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
/**
|
|
||||||
* HTTP regression for the terminal-first sidepanel architecture.
|
|
||||||
*
|
|
||||||
* The legacy one-shot sidebar-agent/chat queue was removed in v1.44. These
|
|
||||||
* routes must stay unavailable: silently reviving one would recreate a second
|
|
||||||
* agent lifecycle and its retired prompt/security surface. Current terminal,
|
|
||||||
* activity, and browser routes have their own focused integration suites.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
||||||
import { spawn, type Subprocess } from 'bun';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as os from 'os';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
let serverProc: Subprocess | null = null;
|
|
||||||
let serverPort = 0;
|
|
||||||
let authToken = '';
|
|
||||||
let tmpDir = '';
|
|
||||||
let stateFile = '';
|
|
||||||
let retiredQueueFile = '';
|
|
||||||
|
|
||||||
async function api(pathname: string, opts: RequestInit & { noAuth?: boolean } = {}): Promise<Response> {
|
|
||||||
const { noAuth, ...fetchOpts } = opts;
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(fetchOpts.headers as Record<string, string> || {}),
|
|
||||||
};
|
|
||||||
if (!noAuth && !headers.Authorization && authToken) {
|
|
||||||
headers.Authorization = `Bearer ${authToken}`;
|
|
||||||
}
|
|
||||||
return fetch(`http://127.0.0.1:${serverPort}${pathname}`, { ...fetchOpts, headers });
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sidebar-retired-routes-'));
|
|
||||||
stateFile = path.join(tmpDir, 'browse.json');
|
|
||||||
retiredQueueFile = path.join(tmpDir, 'sidebar-queue.jsonl');
|
|
||||||
|
|
||||||
const serverScript = path.resolve(import.meta.dir, '..', 'src', 'server.ts');
|
|
||||||
serverProc = spawn(['bun', 'run', serverScript], {
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
BROWSE_STATE_FILE: stateFile,
|
|
||||||
BROWSE_HEADLESS_SKIP: '1',
|
|
||||||
BROWSE_PORT: '0',
|
|
||||||
SIDEBAR_QUEUE_PATH: retiredQueueFile,
|
|
||||||
BROWSE_IDLE_TIMEOUT: '300',
|
|
||||||
},
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const deadline = Date.now() + 15_000;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
if (fs.existsSync(stateFile)) {
|
|
||||||
try {
|
|
||||||
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
||||||
if (state.port && state.token) {
|
|
||||||
serverPort = state.port;
|
|
||||||
authToken = state.token;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
await Bun.sleep(100);
|
|
||||||
}
|
|
||||||
if (!serverPort) throw new Error('Server did not start in time');
|
|
||||||
}, 20_000);
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
if (serverProc) {
|
|
||||||
try { serverProc.kill(); } catch {}
|
|
||||||
}
|
|
||||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
||||||
});
|
|
||||||
|
|
||||||
const RETIRED_ROUTES: Array<[string, string]> = [
|
|
||||||
['POST', '/sidebar-command'],
|
|
||||||
['POST', '/sidebar-agent/event'],
|
|
||||||
['POST', '/sidebar-agent/kill'],
|
|
||||||
['GET', '/sidebar-session'],
|
|
||||||
['POST', '/sidebar-session/new'],
|
|
||||||
['GET', '/sidebar-chat?after=0'],
|
|
||||||
['POST', '/sidebar-chat/clear'],
|
|
||||||
];
|
|
||||||
|
|
||||||
describe('retired sidebar-agent HTTP surface', () => {
|
|
||||||
test('still applies authentication before disclosing route availability', async () => {
|
|
||||||
const response = await api('/sidebar-command', {
|
|
||||||
method: 'POST',
|
|
||||||
noAuth: true,
|
|
||||||
body: JSON.stringify({ message: 'test' }),
|
|
||||||
});
|
|
||||||
expect(response.status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('every retired route is absent for an authenticated caller', async () => {
|
|
||||||
for (const [method, route] of RETIRED_ROUTES) {
|
|
||||||
const response = await api(route, {
|
|
||||||
method,
|
|
||||||
body: method === 'GET' ? undefined : JSON.stringify({ message: 'test', type: 'text' }),
|
|
||||||
});
|
|
||||||
expect(response.status).toBe(404);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('probing retired routes never creates the old queue file', async () => {
|
|
||||||
expect(fs.existsSync(retiredQueueFile)).toBe(false);
|
|
||||||
await api('/sidebar-command', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ message: 'must not queue' }),
|
|
||||||
});
|
|
||||||
expect(fs.existsSync(retiredQueueFile)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the current authenticated health surface remains available', async () => {
|
|
||||||
const response = await api('/health');
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
const payload = await response.json() as { status?: string };
|
|
||||||
expect(['healthy', 'unhealthy']).toContain(payload.status);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
/**
|
|
||||||
* Current terminal-sidepanel security boundary.
|
|
||||||
*
|
|
||||||
* Detailed PTY lifecycle behavior has dedicated tests. These source contracts
|
|
||||||
* instead pin the cross-process handoff: the extension trades the daemon root
|
|
||||||
* token for a session-scoped attach token, and only the loopback terminal agent
|
|
||||||
* accepts that token from a Chrome extension origin.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, test, expect } from 'bun:test';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
const ROOT = path.resolve(import.meta.dir, '..', '..');
|
|
||||||
const TERMINAL_AGENT_PATH = path.join(ROOT, 'browse', 'src', 'terminal-agent.ts');
|
|
||||||
const SERVER_PATH = path.join(ROOT, 'browse', 'src', 'server.ts');
|
|
||||||
const LEGACY_AGENT_PATH = path.join(ROOT, 'browse', 'src', 'sidebar-agent.ts');
|
|
||||||
const TERMINAL_CLIENT_PATH = path.join(ROOT, 'extension', 'sidepanel-terminal.js');
|
|
||||||
const SIDEPANEL_PATH = path.join(ROOT, 'extension', 'sidepanel.js');
|
|
||||||
const BACKGROUND_PATH = path.join(ROOT, 'extension', 'background.js');
|
|
||||||
|
|
||||||
const TERMINAL_AGENT_SRC = fs.readFileSync(TERMINAL_AGENT_PATH, 'utf8');
|
|
||||||
const SERVER_SRC = fs.readFileSync(SERVER_PATH, 'utf8');
|
|
||||||
const TERMINAL_CLIENT_SRC = fs.readFileSync(TERMINAL_CLIENT_PATH, 'utf8');
|
|
||||||
const SIDEPANEL_SRC = fs.readFileSync(SIDEPANEL_PATH, 'utf8');
|
|
||||||
const BACKGROUND_SRC = fs.readFileSync(BACKGROUND_PATH, 'utf8');
|
|
||||||
|
|
||||||
function sliceBetween(source: string, startMarker: string, endMarker: string): string {
|
|
||||||
const start = source.indexOf(startMarker);
|
|
||||||
if (start === -1) throw new Error(`Missing source marker: ${startMarker}`);
|
|
||||||
const end = source.indexOf(endMarker, start + startMarker.length);
|
|
||||||
if (end === -1) throw new Error(`Missing source marker: ${endMarker}`);
|
|
||||||
return source.slice(start, end);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('terminal sidepanel security boundary', () => {
|
|
||||||
test('PTY transport stays on loopback and sends attach auth outside the URL', () => {
|
|
||||||
expect(TERMINAL_AGENT_SRC).toContain("hostname: '127.0.0.1'");
|
|
||||||
expect(TERMINAL_AGENT_SRC).not.toContain("hostname: '0.0.0.0'");
|
|
||||||
|
|
||||||
const socketCalls = [...TERMINAL_CLIENT_SRC.matchAll(/new WebSocket\(([\s\S]*?)\);/g)]
|
|
||||||
.map((match) => match[1]);
|
|
||||||
expect(socketCalls.length).toBeGreaterThan(0);
|
|
||||||
for (const call of socketCalls) {
|
|
||||||
expect(call).toContain('ws://127.0.0.1:${terminalPort}/ws');
|
|
||||||
expect(call).toContain('gstack-pty.${');
|
|
||||||
expect(call).not.toContain('/ws?');
|
|
||||||
expect(call).not.toContain('authToken');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('WebSocket upgrade requires extension Origin plus an in-memory session token', () => {
|
|
||||||
expect(TERMINAL_AGENT_SRC).toContain('const validTokens = new Map<string, string | null>()');
|
|
||||||
const wsRoute = sliceBetween(
|
|
||||||
TERMINAL_AGENT_SRC,
|
|
||||||
"if (url.pathname === '/ws')",
|
|
||||||
"return new Response('not found'",
|
|
||||||
);
|
|
||||||
|
|
||||||
const originGate = wsRoute.indexOf("origin.startsWith('chrome-extension://')");
|
|
||||||
const tokenGate = wsRoute.indexOf('validTokens.has(candidate)');
|
|
||||||
const upgrade = wsRoute.indexOf('server.upgrade(req');
|
|
||||||
expect(originGate).toBeGreaterThan(-1);
|
|
||||||
expect(tokenGate).toBeGreaterThan(originGate);
|
|
||||||
expect(upgrade).toBeGreaterThan(tokenGate);
|
|
||||||
expect(wsRoute).toContain('forbidden origin');
|
|
||||||
expect(wsRoute).toContain("req.headers.get('sec-websocket-protocol')");
|
|
||||||
expect(wsRoute).not.toContain("searchParams.get('token')");
|
|
||||||
});
|
|
||||||
|
|
||||||
test('/pty-session authenticates the daemon token then mints a session-scoped attach', () => {
|
|
||||||
const route = sliceBetween(
|
|
||||||
SERVER_SRC,
|
|
||||||
"if (url.pathname === '/pty-session' && req.method === 'POST')",
|
|
||||||
"if (url.pathname === '/pty-session/reattach'",
|
|
||||||
);
|
|
||||||
expect(route.indexOf('validateAuth(req)')).toBeLessThan(route.indexOf('mintLease()'));
|
|
||||||
expect(route).toContain('grantPtyToken(minted.token, lease.sessionId)');
|
|
||||||
expect(route).toContain('sessionId: lease.sessionId');
|
|
||||||
expect(route).toContain('attachToken: minted.token');
|
|
||||||
|
|
||||||
const clientMint = sliceBetween(
|
|
||||||
TERMINAL_CLIENT_SRC,
|
|
||||||
'async function mintSession()',
|
|
||||||
'function startReattachLoop',
|
|
||||||
);
|
|
||||||
expect(clientMint).toContain('/pty-session`');
|
|
||||||
expect(clientMint).toContain("'Authorization': `Bearer ${token}`");
|
|
||||||
expect(clientMint).not.toContain('?token=');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('/pty-dispose authenticates and tears down only the named session', () => {
|
|
||||||
const route = sliceBetween(
|
|
||||||
SERVER_SRC,
|
|
||||||
"if (url.pathname === '/pty-dispose'",
|
|
||||||
"if (url.pathname === '/internal/lease-refresh'",
|
|
||||||
);
|
|
||||||
expect(route).toContain('authTokenFromBody === authToken');
|
|
||||||
expect(route).toContain("body?.sessionId === 'string'");
|
|
||||||
expect(route).toContain('restartPtySession(sessionId)');
|
|
||||||
expect(route).toContain('revokeLease(sessionId)');
|
|
||||||
|
|
||||||
const pagehide = SIDEPANEL_SRC.slice(SIDEPANEL_SRC.indexOf("addEventListener('pagehide'"));
|
|
||||||
expect(TERMINAL_CLIENT_SRC).toContain('window.gstackPtySession = currentSessionId');
|
|
||||||
expect(pagehide).toContain('JSON.stringify({ sessionId, authToken })');
|
|
||||||
expect(pagehide).toContain('/pty-dispose`');
|
|
||||||
expect(pagehide).not.toContain('/pty-dispose?');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('background token bootstrap rejects foreign and content-script requesters', () => {
|
|
||||||
const listener = sliceBetween(
|
|
||||||
BACKGROUND_SRC,
|
|
||||||
'chrome.runtime.onMessage.addListener((msg, sender, sendResponse)',
|
|
||||||
"if (msg.type === 'fetchRefs')",
|
|
||||||
);
|
|
||||||
expect(listener).toContain('sender.id !== chrome.runtime.id');
|
|
||||||
|
|
||||||
const getToken = listener.slice(listener.indexOf("if (msg.type === 'getToken')"));
|
|
||||||
expect(getToken).toContain('if (sender.tab)');
|
|
||||||
expect(getToken).toContain('sendResponse({ token: null })');
|
|
||||||
expect(getToken).toContain('sendResponse({ token: authToken })');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('interactive prompt path replaces the retired sidebar agent and routes', () => {
|
|
||||||
expect(fs.existsSync(LEGACY_AGENT_PATH)).toBe(false);
|
|
||||||
expect(SERVER_SRC).not.toMatch(/url\.pathname\s*===\s*['"]\/sidebar-/);
|
|
||||||
expect(SERVER_SRC).not.toMatch(/url\.pathname\.startsWith\(\s*['"]\/sidebar-/);
|
|
||||||
expect(SERVER_SRC).toContain('chatEnabled: false');
|
|
||||||
|
|
||||||
const spawn = sliceBetween(TERMINAL_AGENT_SRC, 'function spawnClaude', '/** Cleanup a PTY session');
|
|
||||||
expect(spawn).toContain("[claudePath, '--append-system-prompt', tabHint]");
|
|
||||||
expect(spawn).not.toMatch(/claudePath,\s*['"](?:-p|--print)['"]/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,270 +0,0 @@
|
|||||||
/**
|
|
||||||
* Regression: sidebar layout invariants after the chat-tab rip.
|
|
||||||
*
|
|
||||||
* The Chrome side panel used to host two surfaces: Chat (one-shot
|
|
||||||
* `claude -p` queue) and Terminal (interactive PTY). Chat was ripped
|
|
||||||
* once the PTY proved out — sidebar-agent.ts is gone, the chat queue
|
|
||||||
* endpoints are gone, and the primary-tab nav (Terminal | Chat) is
|
|
||||||
* gone. Terminal is now the sole primary surface.
|
|
||||||
*
|
|
||||||
* This file locks the load-bearing invariants of that layout so a
|
|
||||||
* future refactor can't silently re-introduce the old surface or break
|
|
||||||
* the new one.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, test, expect } from 'bun:test';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
const HTML = fs.readFileSync(path.join(import.meta.dir, '../../extension/sidepanel.html'), 'utf-8');
|
|
||||||
const JS = fs.readFileSync(path.join(import.meta.dir, '../../extension/sidepanel.js'), 'utf-8');
|
|
||||||
const TERM_JS = fs.readFileSync(path.join(import.meta.dir, '../../extension/sidepanel-terminal.js'), 'utf-8');
|
|
||||||
const MANIFEST = JSON.parse(fs.readFileSync(path.join(import.meta.dir, '../../extension/manifest.json'), 'utf-8'));
|
|
||||||
|
|
||||||
describe('sidebar: chat tab + nav are removed, Terminal is sole primary surface', () => {
|
|
||||||
test('No primary-tab nav element exists', () => {
|
|
||||||
expect(HTML).not.toContain('class="primary-tabs"');
|
|
||||||
expect(HTML).not.toContain('data-pane="chat"');
|
|
||||||
expect(HTML).not.toContain('data-pane="terminal"');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('No <main id="tab-chat"> pane', () => {
|
|
||||||
expect(HTML).not.toMatch(/<main[^>]*id="tab-chat"/);
|
|
||||||
expect(HTML).not.toContain('id="chat-messages"');
|
|
||||||
expect(HTML).not.toContain('id="chat-loading"');
|
|
||||||
expect(HTML).not.toContain('id="chat-welcome"');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('No chat input / send button / experimental banner', () => {
|
|
||||||
expect(HTML).not.toContain('class="command-bar"');
|
|
||||||
expect(HTML).not.toContain('id="command-input"');
|
|
||||||
expect(HTML).not.toContain('id="send-btn"');
|
|
||||||
expect(HTML).not.toContain('id="stop-agent-btn"');
|
|
||||||
expect(HTML).not.toContain('id="experimental-banner"');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('No clear-chat button in footer', () => {
|
|
||||||
expect(HTML).not.toContain('id="clear-chat"');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Terminal pane is .active by default and has the toolbar', () => {
|
|
||||||
expect(HTML).toMatch(/<main[^>]*id="tab-terminal"[^>]*class="tab-content active"/);
|
|
||||||
expect(HTML).toContain('id="terminal-toolbar"');
|
|
||||||
expect(HTML).toContain('id="terminal-restart-now"');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Quick-actions buttons (Cleanup / Screenshot / Cookies) survive in the terminal toolbar', () => {
|
|
||||||
// Garry explicitly wanted these kept after the chat rip — they drive
|
|
||||||
// browser actions, not chat.
|
|
||||||
expect(HTML).toContain('id="chat-cleanup-btn"');
|
|
||||||
expect(HTML).toContain('id="chat-screenshot-btn"');
|
|
||||||
expect(HTML).toContain('id="chat-cookies-btn"');
|
|
||||||
// They live inside the terminal toolbar now (siblings of the Restart
|
|
||||||
// button), not as a separate strip below all panes.
|
|
||||||
const toolbarStart = HTML.indexOf('id="terminal-toolbar"');
|
|
||||||
const toolbarEnd = HTML.indexOf('</div>', toolbarStart);
|
|
||||||
const toolbarBlock = HTML.slice(toolbarStart, toolbarEnd + 6);
|
|
||||||
expect(toolbarBlock).toContain('id="chat-cleanup-btn"');
|
|
||||||
expect(toolbarBlock).toContain('id="chat-screenshot-btn"');
|
|
||||||
expect(toolbarBlock).toContain('id="chat-cookies-btn"');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('sidepanel.js: chat helpers ripped, terminal-injection helper survives', () => {
|
|
||||||
test('No primary-tab click handler', () => {
|
|
||||||
expect(JS).not.toContain("querySelectorAll('.primary-tab')");
|
|
||||||
expect(JS).not.toContain('activePrimaryPaneId');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('No chat polling, sendMessage, sendChat, stopAgent, or pollTabs', () => {
|
|
||||||
expect(JS).not.toContain('chatPollInterval');
|
|
||||||
expect(JS).not.toContain('function sendMessage');
|
|
||||||
expect(JS).not.toContain('function pollChat');
|
|
||||||
expect(JS).not.toContain('function pollTabs');
|
|
||||||
expect(JS).not.toContain('function switchChatTab');
|
|
||||||
expect(JS).not.toContain('function stopAgent');
|
|
||||||
expect(JS).not.toContain('function applyChatEnabled');
|
|
||||||
expect(JS).not.toContain('function showSecurityBanner');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Cleanup runs through the live PTY (no /sidebar-command POST)', () => {
|
|
||||||
// The new Cleanup handler injects the prompt straight into claude's
|
|
||||||
// PTY via gstackInjectToTerminal. The dead code path was a POST to
|
|
||||||
// /sidebar-command which kicked off a fresh claude -p subprocess.
|
|
||||||
const cleanup = JS.slice(JS.indexOf('async function runCleanup'));
|
|
||||||
expect(cleanup).toContain('window.gstackInjectToTerminal');
|
|
||||||
expect(cleanup).not.toContain('/sidebar-command');
|
|
||||||
expect(cleanup).not.toContain('addChatEntry');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Inspector "Send to Code" routes through the live PTY', () => {
|
|
||||||
const sendBtn = JS.slice(JS.indexOf('inspectorSendBtn.addEventListener'));
|
|
||||||
expect(sendBtn).toContain('window.gstackInjectToTerminal');
|
|
||||||
expect(sendBtn).not.toContain("type: 'sidebar-command'");
|
|
||||||
});
|
|
||||||
|
|
||||||
test('updateConnection no longer kicks off chat / tab polling', () => {
|
|
||||||
const update = JS.slice(JS.indexOf('function updateConnection'), JS.indexOf('function updateConnection') + 1500);
|
|
||||||
expect(update).not.toContain('chatPollInterval');
|
|
||||||
expect(update).not.toContain('tabPollInterval');
|
|
||||||
expect(update).not.toContain('pollChat');
|
|
||||||
expect(update).not.toContain('pollTabs');
|
|
||||||
// BUT must still expose the bootstrap globals for sidepanel-terminal.js.
|
|
||||||
expect(update).toContain('window.gstackServerPort');
|
|
||||||
expect(update).toContain('window.gstackAuthToken');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('sidepanel-terminal.js: eager auto-connect + injection API', () => {
|
|
||||||
test('Exposes window.gstackInjectToTerminal for cross-pane use', () => {
|
|
||||||
expect(TERM_JS).toContain('window.gstackInjectToTerminal');
|
|
||||||
// Returns false when no live session, true when bytes go out.
|
|
||||||
const inject = TERM_JS.slice(TERM_JS.indexOf('window.gstackInjectToTerminal'));
|
|
||||||
expect(inject).toContain('return false');
|
|
||||||
expect(inject).toContain('return true');
|
|
||||||
expect(inject).toContain('ws.readyState !== WebSocket.OPEN');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Auto-connects on init (no keypress required)', () => {
|
|
||||||
expect(TERM_JS).not.toContain('function onAnyKey');
|
|
||||||
expect(TERM_JS).not.toContain("addEventListener('keydown'");
|
|
||||||
expect(TERM_JS).toContain('function tryAutoConnect');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Repaint hook fires when Terminal pane becomes visible', () => {
|
|
||||||
// The chat-tab rip removed gstack:primary-tab-changed; we use a
|
|
||||||
// MutationObserver on #tab-terminal's class attr instead. The
|
|
||||||
// observer must call repaintIfLive when the .active class returns.
|
|
||||||
expect(TERM_JS).toContain('MutationObserver');
|
|
||||||
expect(TERM_JS).toContain("attributeFilter: ['class']");
|
|
||||||
expect(TERM_JS).toContain('repaintIfLive');
|
|
||||||
const repaint = TERM_JS.slice(TERM_JS.indexOf('function repaintIfLive'));
|
|
||||||
expect(repaint).toContain('fitAddon && fitAddon.fit()');
|
|
||||||
expect(repaint).toContain('term.refresh');
|
|
||||||
expect(repaint).toContain("type: 'resize'");
|
|
||||||
});
|
|
||||||
|
|
||||||
test('No auto-reconnect on close (Restart is user-initiated)', () => {
|
|
||||||
const closeOnly = TERM_JS.slice(
|
|
||||||
TERM_JS.indexOf("ws.addEventListener('close'"),
|
|
||||||
TERM_JS.indexOf("ws.addEventListener('error'"),
|
|
||||||
);
|
|
||||||
expect(closeOnly).not.toContain('setTimeout');
|
|
||||||
expect(closeOnly).not.toContain('tryAutoConnect');
|
|
||||||
expect(closeOnly).not.toContain('connect()');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('forceRestart uses the session-scoped restart transaction and resets local state', () => {
|
|
||||||
expect(TERM_JS).toContain('function forceRestart');
|
|
||||||
const fn = TERM_JS.slice(TERM_JS.indexOf('function forceRestart'));
|
|
||||||
expect(fn).toContain("ws && ws.close(4001, 'intentional-restart')");
|
|
||||||
expect(fn).toContain('term.dispose()');
|
|
||||||
expect(fn).toContain('STATE.IDLE');
|
|
||||||
expect(fn).toContain('/pty-restart');
|
|
||||||
expect(fn).toContain('priorSessionId');
|
|
||||||
expect(fn).toContain('tryAutoConnect()');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Both restart buttons (mid-session and ENDED) call forceRestart', () => {
|
|
||||||
expect(TERM_JS).toContain("els.restart?.addEventListener('click', forceRestart)");
|
|
||||||
expect(TERM_JS).toContain("els.restartNow?.addEventListener('click', forceRestart)");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('server.ts: chat / sidebar-agent endpoints are gone', () => {
|
|
||||||
const SERVER_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/server.ts'), 'utf-8');
|
|
||||||
|
|
||||||
test('No /sidebar-command, /sidebar-chat, /sidebar-agent/* routes', () => {
|
|
||||||
expect(SERVER_SRC).not.toMatch(/url\.pathname === ['"]\/sidebar-command['"]/);
|
|
||||||
expect(SERVER_SRC).not.toMatch(/url\.pathname === ['"]\/sidebar-chat['"]/);
|
|
||||||
expect(SERVER_SRC).not.toMatch(/url\.pathname\.startsWith\(['"]\/sidebar-agent\//);
|
|
||||||
expect(SERVER_SRC).not.toMatch(/url\.pathname === ['"]\/sidebar-agent\/event['"]/);
|
|
||||||
expect(SERVER_SRC).not.toMatch(/url\.pathname === ['"]\/sidebar-tabs['"]/);
|
|
||||||
expect(SERVER_SRC).not.toMatch(/url\.pathname === ['"]\/sidebar-session['"]/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('No chat-related state declarations or helpers', () => {
|
|
||||||
// Allow the symbol names inside the rip-marker comments — but no
|
|
||||||
// `let`, `const`, `function`, or `interface` declarations of them.
|
|
||||||
expect(SERVER_SRC).not.toMatch(/^let agentProcess/m);
|
|
||||||
expect(SERVER_SRC).not.toMatch(/^let agentStatus/m);
|
|
||||||
expect(SERVER_SRC).not.toMatch(/^let messageQueue/m);
|
|
||||||
expect(SERVER_SRC).not.toMatch(/^let sidebarSession/m);
|
|
||||||
expect(SERVER_SRC).not.toMatch(/^const tabAgents/m);
|
|
||||||
expect(SERVER_SRC).not.toMatch(/^function pickSidebarModel/m);
|
|
||||||
expect(SERVER_SRC).not.toMatch(/^function processAgentEvent/m);
|
|
||||||
expect(SERVER_SRC).not.toMatch(/^function killAgent/m);
|
|
||||||
expect(SERVER_SRC).not.toMatch(/^function addChatEntry/m);
|
|
||||||
expect(SERVER_SRC).not.toMatch(/^interface ChatEntry/m);
|
|
||||||
expect(SERVER_SRC).not.toMatch(/^interface SidebarSession/m);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('/health no longer surfaces agentStatus or messageQueue length', () => {
|
|
||||||
const health = SERVER_SRC.slice(SERVER_SRC.indexOf("url.pathname === '/health'"));
|
|
||||||
const slice = health.slice(0, 2000);
|
|
||||||
expect(slice).not.toContain('agentStatus');
|
|
||||||
expect(slice).not.toContain('messageQueue');
|
|
||||||
expect(slice).not.toContain('agentStartTime');
|
|
||||||
// chatEnabled is hardcoded false now (older clients still see the field).
|
|
||||||
expect(slice).toMatch(/chatEnabled:\s*false/);
|
|
||||||
// terminalPort survives.
|
|
||||||
expect(slice).toContain('terminalPort');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('cli.ts: sidebar-agent is no longer spawned', () => {
|
|
||||||
const CLI_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/cli.ts'), 'utf-8');
|
|
||||||
|
|
||||||
test('No Bun.spawn of sidebar-agent.ts', () => {
|
|
||||||
expect(CLI_SRC).not.toMatch(/Bun\.spawn\(\s*\['bun',\s*'run',\s*\w*[Aa]gent[Ss]cript\][\s\S]{0,300}sidebar-agent/);
|
|
||||||
// The variable name `agentScript` was for sidebar-agent. After the
|
|
||||||
// rip there's only termAgentScript. Allow comments to mention the
|
|
||||||
// history but not active spawn calls.
|
|
||||||
expect(CLI_SRC).not.toMatch(/^\s*let agentScript = path\.resolve/m);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Terminal-agent spawn survives', () => {
|
|
||||||
expect(CLI_SRC).toContain("import { spawnTerminalAgent } from './terminal-agent-control'");
|
|
||||||
expect(CLI_SRC).toMatch(/spawnTerminalAgent\(\{[\s\S]*?stateFile:[\s\S]*?serverPort:[\s\S]*?cwd:/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('files: sidebar-agent.ts and its tests are deleted', () => {
|
|
||||||
test('browse/src/sidebar-agent.ts is gone', () => {
|
|
||||||
expect(fs.existsSync(path.join(import.meta.dir, '../src/sidebar-agent.ts'))).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sidebar-agent test files are gone', () => {
|
|
||||||
expect(fs.existsSync(path.join(import.meta.dir, 'sidebar-agent.test.ts'))).toBe(false);
|
|
||||||
expect(fs.existsSync(path.join(import.meta.dir, 'sidebar-agent-roundtrip.test.ts'))).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('manifest: ws permission + xterm-safe CSP', () => {
|
|
||||||
test('host_permissions covers ws localhost', () => {
|
|
||||||
expect(MANIFEST.host_permissions).toContain('ws://127.0.0.1:*/');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('host_permissions still covers http localhost', () => {
|
|
||||||
expect(MANIFEST.host_permissions).toContain('http://127.0.0.1:*/');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('manifest does NOT add unsafe-eval to extension_pages CSP', () => {
|
|
||||||
const csp = MANIFEST.content_security_policy;
|
|
||||||
if (csp && csp.extension_pages) {
|
|
||||||
expect(csp.extension_pages).not.toContain('unsafe-eval');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('manifest: live tab awareness needs "tabs" permission', () => {
|
|
||||||
// Without "tabs", chrome.tabs.query() returns tab objects with undefined
|
|
||||||
// url/title for any site outside host_permissions (e.g., everything except
|
|
||||||
// 127.0.0.1). snapshotTabs() then writes empty strings into tabs.json and
|
|
||||||
// active-tab.json silently skips the write — the sidebar agent loses track
|
|
||||||
// of what page the user is on. activeTab is too narrow (only after a user
|
|
||||||
// gesture on the extension action) for background polling.
|
|
||||||
test('permissions includes "tabs"', () => {
|
|
||||||
expect(MANIFEST.permissions).toContain('tabs');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
/**
|
|
||||||
* Source-contract tests for the terminal-first browser sidepanel.
|
|
||||||
*
|
|
||||||
* The one-shot chat queue and sidebar-agent daemon were removed. These
|
|
||||||
* checks intentionally cover the current PTY surface and its retained debug
|
|
||||||
* tools without preserving obsolete chat implementation details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, test, expect } from 'bun:test';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
const BROWSE_ROOT = path.resolve(import.meta.dir, '..');
|
|
||||||
const REPO_ROOT = path.resolve(BROWSE_ROOT, '..');
|
|
||||||
const EXTENSION_ROOT = path.join(REPO_ROOT, 'extension');
|
|
||||||
|
|
||||||
const html = fs.readFileSync(path.join(EXTENSION_ROOT, 'sidepanel.html'), 'utf8');
|
|
||||||
const sidepanel = fs.readFileSync(path.join(EXTENSION_ROOT, 'sidepanel.js'), 'utf8');
|
|
||||||
const terminal = fs.readFileSync(path.join(EXTENSION_ROOT, 'sidepanel-terminal.js'), 'utf8');
|
|
||||||
const background = fs.readFileSync(path.join(EXTENSION_ROOT, 'background.js'), 'utf8');
|
|
||||||
|
|
||||||
function between(source: string, startMarker: string, endMarker: string): string {
|
|
||||||
const start = source.indexOf(startMarker);
|
|
||||||
if (start < 0) return '';
|
|
||||||
const end = source.indexOf(endMarker, start + startMarker.length);
|
|
||||||
return end < 0 ? source.slice(start) : source.slice(start, end);
|
|
||||||
}
|
|
||||||
|
|
||||||
function withoutComments(source: string): string {
|
|
||||||
return source
|
|
||||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
||||||
.replace(/^\s*\/\/.*$/gm, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('terminal-first sidepanel', () => {
|
|
||||||
test('terminal is the sole active primary pane', () => {
|
|
||||||
const activeMainIds = [...html.matchAll(
|
|
||||||
/<main\s+id="([^"]+)"\s+class="[^"]*\bactive\b[^"]*"/g,
|
|
||||||
)].map((match) => match[1]);
|
|
||||||
|
|
||||||
expect(activeMainIds).toEqual(['tab-terminal']);
|
|
||||||
expect(html).toContain('id="tab-terminal"');
|
|
||||||
expect(html).toContain('role="tabpanel" aria-label="Terminal"');
|
|
||||||
expect(html).not.toContain('id="tab-chat"');
|
|
||||||
expect(sidepanel).toContain("const PRIMARY_PANE_ID = 'tab-terminal';");
|
|
||||||
expect(sidepanel).toContain('document.getElementById(PRIMARY_PANE_ID).classList.add(\'active\')');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('xterm, fit, and terminal bootstrap assets are shipped and ordered', () => {
|
|
||||||
const assets = [
|
|
||||||
'lib/xterm.css',
|
|
||||||
'lib/xterm.js',
|
|
||||||
'lib/xterm-addon-fit.js',
|
|
||||||
'sidepanel-terminal.js',
|
|
||||||
];
|
|
||||||
for (const asset of assets) {
|
|
||||||
expect(fs.existsSync(path.join(EXTENSION_ROOT, asset))).toBe(true);
|
|
||||||
expect(html).toContain(asset);
|
|
||||||
}
|
|
||||||
|
|
||||||
const scriptOrder = [
|
|
||||||
html.indexOf('lib/xterm.js'),
|
|
||||||
html.indexOf('lib/xterm-addon-fit.js'),
|
|
||||||
html.indexOf('sidepanel.js'),
|
|
||||||
html.indexOf('sidepanel-terminal.js'),
|
|
||||||
];
|
|
||||||
expect(scriptOrder.every((index) => index >= 0)).toBe(true);
|
|
||||||
expect(scriptOrder).toEqual([...scriptOrder].sort((left, right) => left - right));
|
|
||||||
|
|
||||||
for (const id of [
|
|
||||||
'terminal-bootstrap',
|
|
||||||
'terminal-bootstrap-status',
|
|
||||||
'terminal-install-card',
|
|
||||||
'terminal-mount',
|
|
||||||
'terminal-ended',
|
|
||||||
'terminal-restart',
|
|
||||||
'terminal-restart-now',
|
|
||||||
]) {
|
|
||||||
expect(html).toContain(`id="${id}"`);
|
|
||||||
}
|
|
||||||
expect(terminal).toContain("setState(STATE.IDLE, { message: 'Starting Claude Code...' })");
|
|
||||||
expect(terminal).toContain('tryAutoConnect();');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('retired chat queue code and daemon stay removed', () => {
|
|
||||||
const executableSidepanel = withoutComments(sidepanel);
|
|
||||||
const executableTerminal = withoutComments(terminal);
|
|
||||||
const removedFunctions = ['sendMessage', 'pollChat', 'switchChatTab'];
|
|
||||||
|
|
||||||
expect(fs.existsSync(path.join(BROWSE_ROOT, 'src', 'sidebar-agent.ts'))).toBe(false);
|
|
||||||
for (const name of removedFunctions) {
|
|
||||||
const declaration = new RegExp(`(?:async\\s+)?function\\s+${name}\\s*\\(`);
|
|
||||||
expect(executableSidepanel).not.toMatch(declaration);
|
|
||||||
expect(executableTerminal).not.toMatch(declaration);
|
|
||||||
}
|
|
||||||
expect(executableSidepanel).not.toContain('/sidebar-chat');
|
|
||||||
expect(executableSidepanel).not.toContain('/sidebar-command');
|
|
||||||
expect(html).not.toContain('id="chat-input"');
|
|
||||||
expect(html).not.toContain('id="chat-messages"');
|
|
||||||
expect(html).not.toContain('id="stop-agent-btn"');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PTY lifecycle security', () => {
|
|
||||||
test('bootstrap uses authenticated POST and a one-use WebSocket protocol token', () => {
|
|
||||||
const connection = between(sidepanel, 'function updateConnection(', '// ─── Port Configuration');
|
|
||||||
const mint = between(terminal, 'async function mintSession()', 'function startReattachLoop(');
|
|
||||||
|
|
||||||
expect(connection).toContain('window.gstackServerPort');
|
|
||||||
expect(connection).toContain('window.gstackAuthToken');
|
|
||||||
expect(mint).toContain('`http://127.0.0.1:${serverPort}/pty-session`');
|
|
||||||
expect(mint).toContain("method: 'POST'");
|
|
||||||
expect(mint).toContain("'Authorization': `Bearer ${token}`");
|
|
||||||
expect(mint).toContain("credentials: 'include'");
|
|
||||||
expect(terminal).toContain('const attachToken = minted.attachToken || minted.ptySessionToken');
|
|
||||||
expect(terminal).toContain(
|
|
||||||
'new WebSocket(`ws://127.0.0.1:${terminalPort}/ws`, [`gstack-pty.${attachToken}`])',
|
|
||||||
);
|
|
||||||
expect(terminal).not.toContain('?token=');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('session identity is retained only for explicit pagehide disposal', () => {
|
|
||||||
const disposal = sidepanel.slice(sidepanel.indexOf("window.addEventListener('pagehide'"));
|
|
||||||
|
|
||||||
expect(terminal).toContain('currentSessionId = sessionId || null');
|
|
||||||
expect(terminal).toContain('window.gstackPtySession = currentSessionId');
|
|
||||||
expect(disposal).toContain('const sessionId = window.gstackPtySession');
|
|
||||||
expect(disposal).toContain('const authToken = window.gstackAuthToken');
|
|
||||||
expect(disposal).toContain('if (!sessionId || !authToken || !port) return');
|
|
||||||
expect(disposal).toContain('JSON.stringify({ sessionId, authToken })');
|
|
||||||
expect(disposal).toContain('navigator.sendBeacon(`http://127.0.0.1:${port}/pty-dispose`, blob)');
|
|
||||||
expect(disposal).not.toContain('?token=');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('tab state crosses the extension boundary only through the live PTY relay', () => {
|
|
||||||
const push = between(background, 'async function pushTabState(', "chrome.tabs.onActivated.addListener");
|
|
||||||
const sidepanelRelay = between(sidepanel, "if (msg.type === 'browserTabState')", '// ─── v1.44 pagehide');
|
|
||||||
const terminalRelay = between(
|
|
||||||
terminal,
|
|
||||||
"document.addEventListener('gstack:tab-state'",
|
|
||||||
'// Repaint after a debug-tab',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(push).toContain("type: 'browserTabState'");
|
|
||||||
expect(push).toContain('...snapshot');
|
|
||||||
expect(background).toContain("pushTabState('activated')");
|
|
||||||
expect(background).toContain("pushTabState('created')");
|
|
||||||
expect(background).toContain("pushTabState('removed')");
|
|
||||||
expect(sidepanelRelay).toContain("new CustomEvent('gstack:tab-state'");
|
|
||||||
expect(sidepanelRelay).toContain('detail: { active: msg.active, tabs: msg.tabs, reason: msg.reason }');
|
|
||||||
expect(terminalRelay).toContain('if (!ws || ws.readyState !== WebSocket.OPEN) return');
|
|
||||||
expect(terminalRelay).toContain("type: 'tabState'");
|
|
||||||
expect(terminalRelay).toContain('active: ev.detail?.active');
|
|
||||||
expect(terminalRelay).toContain('tabs: ev.detail?.tabs');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('page-derived inspector and cleanup prompts are scanned before PTY injection', () => {
|
|
||||||
const inspectorSend = between(sidepanel, "inspectorSendBtn.addEventListener('click'", '// ─── Quick Action Helpers');
|
|
||||||
const cleanup = between(sidepanel, 'async function runCleanup(', 'async function runScreenshot(');
|
|
||||||
|
|
||||||
for (const block of [inspectorSend, cleanup]) {
|
|
||||||
const scan = block.indexOf('gstackScanForPTYInject');
|
|
||||||
const inject = block.indexOf('gstackInjectToTerminal');
|
|
||||||
expect(scan).toBeGreaterThan(0);
|
|
||||||
expect(inject).toBeGreaterThan(scan);
|
|
||||||
expect(block).toContain("verdict === 'BLOCK'");
|
|
||||||
expect(block).toContain("verdict === 'WARN'");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('retained debug tools and quick actions', () => {
|
|
||||||
test('activity, refs, and inspector remain hidden debug panels', () => {
|
|
||||||
const debugNav = between(html, '<nav class="tabs debug-tabs"', '</nav>');
|
|
||||||
|
|
||||||
expect(html).toContain('id="tab-activity"');
|
|
||||||
expect(html).toContain('id="activity-feed"');
|
|
||||||
expect(html).toContain('id="tab-refs"');
|
|
||||||
expect(html).toContain('id="refs-list"');
|
|
||||||
expect(html).toContain('id="tab-inspector"');
|
|
||||||
expect(html).toContain('id="inspector-content"');
|
|
||||||
expect(debugNav).toContain('id="debug-tabs"');
|
|
||||||
expect(debugNav).toContain('style="display:none"');
|
|
||||||
expect([...debugNav.matchAll(/data-tab="([^"]+)"/g)].map((match) => match[1])).toEqual([
|
|
||||||
'activity',
|
|
||||||
'refs',
|
|
||||||
'inspector',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debug streams use the authenticated current endpoints', () => {
|
|
||||||
const refs = between(sidepanel, 'async function fetchRefs()', '// ─── Inspector Tab');
|
|
||||||
const sseCookie = between(sidepanel, 'async function ensureSseSessionCookie()', 'async function connectSSE()');
|
|
||||||
const inspectorSse = between(sidepanel, 'async function connectInspectorSSE()', '// ─── Server Discovery');
|
|
||||||
|
|
||||||
expect(refs).toContain('`${serverUrl}/refs`');
|
|
||||||
expect(refs).toContain("headers['Authorization'] = `Bearer ${serverToken}`");
|
|
||||||
expect(sseCookie).toContain('`${serverUrl}/sse-session`');
|
|
||||||
expect(sseCookie).toContain("method: 'POST'");
|
|
||||||
expect(sseCookie).toContain("'Authorization': `Bearer ${serverToken}`");
|
|
||||||
expect(inspectorSse).toContain('await ensureSseSessionCookie()');
|
|
||||||
expect(inspectorSse).toContain('`${serverUrl}/inspector/events?_=${Date.now()}`');
|
|
||||||
expect(inspectorSse).toContain('new EventSource(url, { withCredentials: true })');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('terminal toolbar exposes exactly the current quick actions', () => {
|
|
||||||
const toolbar = between(html, '<div class="terminal-toolbar"', '<div class="terminal-bootstrap"');
|
|
||||||
const buttonIds = [...toolbar.matchAll(/<button[^>]+id="([^"]+)"/g)].map((match) => match[1]);
|
|
||||||
|
|
||||||
expect(buttonIds).toEqual([
|
|
||||||
'chat-cleanup-btn',
|
|
||||||
'chat-screenshot-btn',
|
|
||||||
'chat-cookies-btn',
|
|
||||||
'terminal-restart-now',
|
|
||||||
]);
|
|
||||||
expect(toolbar).toContain('🧹 Cleanup');
|
|
||||||
expect(toolbar).toContain('📸 Screenshot');
|
|
||||||
expect(toolbar).toContain('🍪 Cookies');
|
|
||||||
expect(toolbar).toContain('↻ Restart');
|
|
||||||
expect(toolbar).not.toContain('<input');
|
|
||||||
expect(toolbar).not.toContain('<textarea');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('quick actions route through the PTY or authenticated local command API', () => {
|
|
||||||
const cleanup = between(sidepanel, 'async function runCleanup(', 'async function runScreenshot(');
|
|
||||||
const screenshot = between(sidepanel, 'async function runScreenshot(', '// ─── Wire up all cleanup');
|
|
||||||
const cookies = between(sidepanel, "getElementById('chat-cookies-btn')", '// ─── Debug Tabs');
|
|
||||||
|
|
||||||
expect(cleanup).toContain("'$B cleanup --all'");
|
|
||||||
expect(cleanup).toContain('window.gstackInjectToTerminal');
|
|
||||||
expect(cleanup).not.toContain('/sidebar-command');
|
|
||||||
expect(screenshot).toContain('`${serverUrl}/command`');
|
|
||||||
expect(screenshot).toContain("command: 'screenshot'");
|
|
||||||
expect(screenshot).toContain('headers: { ...authHeaders()');
|
|
||||||
expect(cookies).toContain('`${serverUrl}/command`');
|
|
||||||
expect(cookies).toContain("command: 'goto'");
|
|
||||||
expect(cookies).toContain('`${serverUrl}/cookie-picker`');
|
|
||||||
expect(cookies).toContain('headers: authHeaders()');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import { describe, test, expect } from 'bun:test';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
// v1.44 patient autoConnect — static-grep invariants for the polling loop.
|
|
||||||
//
|
|
||||||
// Pre-v1.44 the sidebar gave up at 15s with "Browse server not ready.
|
|
||||||
// Reload sidebar to retry." Cold-start the browse server takes ~3-8s on a
|
|
||||||
// healthy laptop, longer on Conductor workspaces / slow CI, so the user
|
|
||||||
// frequently saw the failure message even when nothing was wrong. The
|
|
||||||
// fix: poll forever with ascending status messages and only abort on
|
|
||||||
// explicit unrecoverable signals (401 auth invalid).
|
|
||||||
|
|
||||||
const CLIENT_JS = path.resolve(
|
|
||||||
new URL(import.meta.url).pathname,
|
|
||||||
'..',
|
|
||||||
'..',
|
|
||||||
'..',
|
|
||||||
'extension',
|
|
||||||
'sidepanel-terminal.js',
|
|
||||||
);
|
|
||||||
|
|
||||||
describe('sidepanel tryAutoConnect patience (v1.44+)', () => {
|
|
||||||
test('1. no 15s give-up message', () => {
|
|
||||||
const src = fs.readFileSync(CLIENT_JS, 'utf-8');
|
|
||||||
// The v0.x give-up string must NOT reappear — it's the message users
|
|
||||||
// saw on every cold start and the whole point of v1.44 was to delete it.
|
|
||||||
expect(src).not.toContain('Browse server not ready. Reload sidebar to retry.');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('2. ascending status messages at 15s / 60s / 5min', () => {
|
|
||||||
const src = fs.readFileSync(CLIENT_JS, 'utf-8');
|
|
||||||
expect(src).toContain('Waiting for browse server...');
|
|
||||||
expect(src).toContain('Still waiting');
|
|
||||||
expect(src).toContain('still not responding after 5 min');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('3. sticky abort flag prevents loop spam on 401', () => {
|
|
||||||
const src = fs.readFileSync(CLIENT_JS, 'utf-8');
|
|
||||||
expect(src).toContain('autoConnectAborted');
|
|
||||||
// The mint failure branch must short-circuit on 401 specifically.
|
|
||||||
expect(src).toMatch(/minted\.error.*startsWith\('401'\)/);
|
|
||||||
// tryAutoConnect tick must respect the flag.
|
|
||||||
expect(src).toMatch(/if \(autoConnectAborted\) return/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('4. forceRestart re-arms the loop by clearing the abort flag', () => {
|
|
||||||
const src = fs.readFileSync(CLIENT_JS, 'utf-8');
|
|
||||||
// forceRestart is the user's "try again" escape hatch — must reset
|
|
||||||
// the sticky flag or 401-once means stuck-forever.
|
|
||||||
const block = sliceBetween(src, 'function forceRestart', 'function repaintIfLive');
|
|
||||||
expect(block).toContain('autoConnectAborted = false');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('5. poll interval is 2s, not the legacy 200ms tight loop', () => {
|
|
||||||
const src = fs.readFileSync(CLIENT_JS, 'utf-8');
|
|
||||||
// 200ms ticks burned CPU and made the give-up window land too fast.
|
|
||||||
// 2s is the v1.44 cadence — verify the tight-loop literal is gone.
|
|
||||||
expect(src).toContain('setTimeout(tick, 2000)');
|
|
||||||
expect(src).not.toContain('setTimeout(tick, 200)');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function sliceBetween(source: string, start: string, end: string): string {
|
|
||||||
const i = source.indexOf(start);
|
|
||||||
if (i === -1) throw new Error(`marker not found: ${start}`);
|
|
||||||
const j = source.indexOf(end, i + start.length);
|
|
||||||
if (j === -1) throw new Error(`end marker not found: ${end}`);
|
|
||||||
return source.slice(i, j);
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import { describe, test, expect } from 'bun:test';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
// v1.44 Commit 3 — client-side re-attach loop.
|
|
||||||
//
|
|
||||||
// On unexpected WS close (anything other than clean 1000 / 4001 / 4404),
|
|
||||||
// the sidebar now silently posts /pty-session/reattach with backoff,
|
|
||||||
// opens a new WS with the fresh attachToken, writes RIS to xterm when
|
|
||||||
// the agent sends {type:"reattach-begin"}, then treats the next binary
|
|
||||||
// frame as the scrollback replay payload. Static-grep tripwires defend
|
|
||||||
// the load-bearing protocol invariants; live re-attach exercises belong
|
|
||||||
// in the e2e tier.
|
|
||||||
|
|
||||||
const TERMINAL_JS = path.resolve(
|
|
||||||
new URL(import.meta.url).pathname, '..', '..', '..', 'extension', 'sidepanel-terminal.js',
|
|
||||||
);
|
|
||||||
|
|
||||||
describe('sidepanel re-attach loop (v1.44+ Commit 3)', () => {
|
|
||||||
test('1. STATE.RECONNECTING exists for the in-flight re-attach window', () => {
|
|
||||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
|
||||||
expect(src).toContain("RECONNECTING: 'reconnecting'");
|
|
||||||
});
|
|
||||||
|
|
||||||
test('2. backoff schedule matches the eng-review plan (1s/2s/4s/8s, 60s window)', () => {
|
|
||||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
|
||||||
expect(src).toContain('REATTACH_BACKOFF_MS = [1000, 2000, 4000, 8000]');
|
|
||||||
expect(src).toContain('REATTACH_WINDOW_MS = 60_000');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('3. startReattachLoop posts /pty-session/reattach with sessionId', () => {
|
|
||||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
|
||||||
expect(src).toMatch(/function startReattachLoop\(prevSessionId\)/);
|
|
||||||
const block = sliceBetween(src, 'function startReattachLoop', 'function openReattachWebSocket');
|
|
||||||
expect(block).toContain('/pty-session/reattach');
|
|
||||||
expect(block).toContain('sessionId: prevSessionId');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('4. 410 Gone from re-attach short-circuits to ENDED (no retry loop)', () => {
|
|
||||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
|
||||||
const block = sliceBetween(src, 'function startReattachLoop', 'function openReattachWebSocket');
|
|
||||||
// 410 = lease window expired. Retrying wouldn't help; fall through
|
|
||||||
// so the user clicks Restart for a fresh session.
|
|
||||||
expect(block).toContain('resp.status === 410');
|
|
||||||
expect(block).toContain('setState(STATE.ENDED)');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('5. 401 from re-attach sticky-aborts auto-connect', () => {
|
|
||||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
|
||||||
const block = sliceBetween(src, 'function startReattachLoop', 'function openReattachWebSocket');
|
|
||||||
expect(block).toContain('resp.status === 401');
|
|
||||||
expect(block).toContain('autoConnectAborted = true');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('6. openReattachWebSocket handles {type:"reattach-begin"} → RIS to xterm', () => {
|
|
||||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
|
||||||
const block = sliceBetween(src, 'function openReattachWebSocket', 'async function checkClaudeAvailable');
|
|
||||||
expect(block).toContain("msg.type === 'reattach-begin'");
|
|
||||||
// RIS (\x1bc) is the full-reset escape that clears xterm cleanly
|
|
||||||
// before the replay binary arrives.
|
|
||||||
expect(block).toContain("term.write('\\x1bc')");
|
|
||||||
expect(block).toContain('nextBinaryIsReplay = true');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('7. live connect()/forceRestart() close handlers trigger re-attach on transient close', () => {
|
|
||||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
|
||||||
// Both the connect() and forceRestart() close handlers must route
|
|
||||||
// through startReattachLoop for non-clean codes. Count = 3
|
|
||||||
// (open-reattach close handler + connect close + forceRestart close).
|
|
||||||
const occurrences = (src.match(/startReattachLoop\(currentSessionId\)/g) || []).length;
|
|
||||||
expect(occurrences).toBeGreaterThanOrEqual(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('8. clean codes (1000 / 4001 / 4404) bypass the re-attach loop', () => {
|
|
||||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
|
||||||
// The branch guard MUST exclude these codes from re-attach. 1000 =
|
|
||||||
// PTY exited (claude quit), 4001 = intentional restart, 4404 = no
|
|
||||||
// claude on PATH. Re-attaching in those cases would be wasted work
|
|
||||||
// (or actively wrong — a force-restart that re-attaches to its own
|
|
||||||
// pre-restart session is the bug we're avoiding).
|
|
||||||
expect(src).toContain('code === 1000');
|
|
||||||
expect(src).toContain('code === 4001');
|
|
||||||
expect(src).toContain('code === 4404');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function sliceBetween(source: string, start: string, end: string): string {
|
|
||||||
const i = source.indexOf(start);
|
|
||||||
if (i === -1) throw new Error(`marker not found: ${start}`);
|
|
||||||
const j = source.indexOf(end, i + start.length);
|
|
||||||
if (j === -1) throw new Error(`end marker not found: ${end}`);
|
|
||||||
return source.slice(i, j);
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import { describe, test, expect } from 'bun:test';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
// v1.44 Commit 2C — client-side restart + dispose wiring.
|
|
||||||
//
|
|
||||||
// Pre-v1.44 forceRestart only closed the client WS and disposed xterm;
|
|
||||||
// the old PTY died asynchronously via the agent's WS close handler.
|
|
||||||
// Race window between kill and mint, two claude instances briefly,
|
|
||||||
// no prompt visible until the user typed.
|
|
||||||
//
|
|
||||||
// Now forceRestart POSTs /pty-restart (one transaction: dispose + mint),
|
|
||||||
// opens the new WS with the fresh attachToken from the response, and
|
|
||||||
// sends {type:"start"} for the eager spawn. pagehide handler in
|
|
||||||
// sidepanel.js sendBeacon /pty-dispose so browser quit / panel close
|
|
||||||
// doesn't leak a 60s-zombie claude.
|
|
||||||
|
|
||||||
const TERMINAL_JS = path.resolve(
|
|
||||||
new URL(import.meta.url).pathname, '..', '..', '..', 'extension', 'sidepanel-terminal.js',
|
|
||||||
);
|
|
||||||
const SIDEPANEL_JS = path.resolve(
|
|
||||||
new URL(import.meta.url).pathname, '..', '..', '..', 'extension', 'sidepanel.js',
|
|
||||||
);
|
|
||||||
|
|
||||||
describe('sidepanel-terminal: forceRestart via /pty-restart (v1.44+)', () => {
|
|
||||||
test('1. mintSession callers read the 4-tuple (sessionId + attachToken)', () => {
|
|
||||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
|
||||||
// The new shape lands in `minted.sessionId` and `minted.attachToken`.
|
|
||||||
expect(src).toContain('const { terminalPort, sessionId } = minted');
|
|
||||||
expect(src).toContain('minted.attachToken || minted.ptySessionToken');
|
|
||||||
// Backward-compat fallback to ptySessionToken kept so a partially-
|
|
||||||
// updated extension still works against a fresh server.
|
|
||||||
});
|
|
||||||
|
|
||||||
test('2. eager spawn via {type:"start"} on ws.open', () => {
|
|
||||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
|
||||||
// Replaces the legacy `ws.send(TextEncoder().encode("\\n"))` newline
|
|
||||||
// hack that nudged the lazy-binary-spawn.
|
|
||||||
expect(src).toMatch(/ws\.send\(JSON\.stringify\(\{\s*type:\s*'start'\s*\}\)\)/);
|
|
||||||
expect(src).not.toContain("TextEncoder().encode('\\n')");
|
|
||||||
});
|
|
||||||
|
|
||||||
test('3. forceRestart sends 4001 close code (intentional restart)', () => {
|
|
||||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
|
||||||
expect(src).toMatch(/ws\.close\(4001/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('4. forceRestart POSTs /pty-restart with current sessionId', () => {
|
|
||||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
|
||||||
expect(src).toContain('/pty-restart');
|
|
||||||
expect(src).toContain('priorSessionId ? { sessionId: priorSessionId } : {}');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('5. forceRestart 401 triggers sticky abort (no spam loop)', () => {
|
|
||||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
|
||||||
// Same defense pattern as connect() — 401 must flip the sticky flag
|
|
||||||
// or every 2s the user sees a fresh "Auth invalid" message.
|
|
||||||
const block = sliceBetween(src, 'async function forceRestart', 'function repaintIfLive');
|
|
||||||
expect(block).toContain('resp.status === 401');
|
|
||||||
expect(block).toContain('autoConnectAborted = true');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('6. currentSessionId is exposed on window for sidepanel.js pagehide', () => {
|
|
||||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
|
||||||
expect(src).toContain('window.gstackPtySession = currentSessionId');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('sidepanel: pagehide → sendBeacon /pty-dispose (v1.44+)', () => {
|
|
||||||
test('7. pagehide handler fires sendBeacon to /pty-dispose', () => {
|
|
||||||
const src = fs.readFileSync(SIDEPANEL_JS, 'utf-8');
|
|
||||||
expect(src).toMatch(/window\.addEventListener\('pagehide'/);
|
|
||||||
expect(src).toContain('navigator.sendBeacon');
|
|
||||||
expect(src).toContain('/pty-dispose');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('8. pagehide payload carries sessionId + authToken in body (sendBeacon-compat)', () => {
|
|
||||||
const src = fs.readFileSync(SIDEPANEL_JS, 'utf-8');
|
|
||||||
// sendBeacon can't set custom headers — server route accepts body-auth.
|
|
||||||
// Both fields must be in the payload or the server rejects.
|
|
||||||
expect(src).toMatch(/JSON\.stringify\(\{\s*sessionId,\s*authToken\s*\}\)/);
|
|
||||||
expect(src).toContain('window.gstackPtySession');
|
|
||||||
expect(src).toContain('window.gstackAuthToken');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('9. pagehide handler is best-effort (try/catch swallows failures)', () => {
|
|
||||||
const src = fs.readFileSync(SIDEPANEL_JS, 'utf-8');
|
|
||||||
// The 60s detach window catches any sendBeacon that fails, so the
|
|
||||||
// handler MUST not throw — uncaught throws can interfere with the
|
|
||||||
// browser's unload sequence. Slice between pagehide and end-of-file
|
|
||||||
// (it's the last addEventListener in sidepanel.js by design).
|
|
||||||
const i = src.indexOf("addEventListener('pagehide'");
|
|
||||||
expect(i).toBeGreaterThan(-1);
|
|
||||||
const block = src.slice(i);
|
|
||||||
expect(block).toMatch(/try \{/);
|
|
||||||
expect(block).toMatch(/} catch /);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function sliceBetween(source: string, start: string, end: string): string {
|
|
||||||
const i = source.indexOf(start);
|
|
||||||
if (i === -1) throw new Error(`marker not found: ${start}`);
|
|
||||||
const j = source.indexOf(end, i + start.length);
|
|
||||||
if (j === -1) throw new Error(`end marker not found: ${end}`);
|
|
||||||
return source.slice(i, j);
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import { describe, test, expect } from 'bun:test';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
// v1.44 Commit 3 — detach state machine + ring buffer + re-attach replay.
|
|
||||||
//
|
|
||||||
// The state machine is what turns a single network blip from "fall through
|
|
||||||
// to ENDED state, click Restart" into "silent re-attach with scrollback
|
|
||||||
// intact, keep typing." Live WS cycles + buffer-overflow exercises belong
|
|
||||||
// in the e2e tier; these static-grep tripwires defend the load-bearing
|
|
||||||
// protocol + correctness properties.
|
|
||||||
|
|
||||||
const AGENT_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'terminal-agent.ts');
|
|
||||||
|
|
||||||
describe('terminal-agent detach + re-attach (v1.44+ Commit 3)', () => {
|
|
||||||
test('1. PtySession carries ring buffer + alt-screen + detach state', () => {
|
|
||||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
|
||||||
const i = src.indexOf('interface PtySession {');
|
|
||||||
const j = src.indexOf('\n}', i);
|
|
||||||
const block = src.slice(i, j);
|
|
||||||
expect(block).toContain('liveWs: any | null');
|
|
||||||
expect(block).toContain('ringBuffer: Buffer[]');
|
|
||||||
expect(block).toContain('ringBufferBytes: number');
|
|
||||||
expect(block).toContain('altScreenActive: boolean');
|
|
||||||
expect(block).toContain('detached: boolean');
|
|
||||||
expect(block).toContain('detachTimer:');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('2. RING_BUFFER_MAX_BYTES default is 1 MB, env-overridable', () => {
|
|
||||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
|
||||||
expect(src).toContain('GSTACK_PTY_RING_BUFFER_BYTES');
|
|
||||||
expect(src).toContain('1024 * 1024');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('3. DETACH_WINDOW_MS default is 60s, env-overridable', () => {
|
|
||||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
|
||||||
expect(src).toContain('GSTACK_PTY_DETACH_WINDOW_MS');
|
|
||||||
expect(src).toContain("'60000'");
|
|
||||||
});
|
|
||||||
|
|
||||||
test('4. appendToRingBuffer evicts oldest frames past the cap', () => {
|
|
||||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
|
||||||
expect(src).toMatch(/function appendToRingBuffer\(/);
|
|
||||||
// Eviction loop: must keep at least one frame even at extreme caps
|
|
||||||
// (otherwise a single oversized frame would empty the buffer).
|
|
||||||
expect(src).toMatch(/session\.ringBufferBytes > RING_BUFFER_MAX_BYTES/);
|
|
||||||
expect(src).toContain('session.ringBuffer.length > 1');
|
|
||||||
expect(src).toContain('session.ringBuffer.shift()');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('5. alt-screen tracking watches for CSI ?1049h / CSI ?1049l', () => {
|
|
||||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
|
||||||
// Canonical xterm enter/exit alt-screen sequences. Must update
|
|
||||||
// session.altScreenActive so the replay prelude knows.
|
|
||||||
expect(src).toContain('\\x1b[?1049h');
|
|
||||||
expect(src).toContain('\\x1b[?1049l');
|
|
||||||
expect(src).toContain('session.altScreenActive');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('6. buildReplayPayload prefixes soft-reset (+ alt-screen if active)', () => {
|
|
||||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
|
||||||
expect(src).toMatch(/function buildReplayPayload\(/);
|
|
||||||
// DECSTR soft reset — re-defaults character attributes after the
|
|
||||||
// client's RIS clears the xterm buffer.
|
|
||||||
expect(src).toContain('\\x1b[!p');
|
|
||||||
// Conditionally re-enter alt-screen if claude was in a tool-call
|
|
||||||
// (alt-screen mode) at detach.
|
|
||||||
expect(src).toContain('session.altScreenActive');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('7. WS open() re-attaches when sessionId already lives in sessionsById', () => {
|
|
||||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
|
||||||
const block = sliceBetween(src, 'open(ws) {', 'message(ws, raw) {');
|
|
||||||
expect(block).toContain('sessionsById.get(sessionId)');
|
|
||||||
expect(block).toContain('existing.liveWs = ws');
|
|
||||||
expect(block).toContain('clearTimeout(existing.detachTimer)');
|
|
||||||
// Tells the client to write RIS before treating the next binary
|
|
||||||
// frame as replay.
|
|
||||||
expect(block).toContain("type: 'reattach-begin'");
|
|
||||||
expect(block).toContain('sendBinary(buildReplayPayload(existing))');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('8. WS close starts detach timer for non-intentional close codes', () => {
|
|
||||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
|
||||||
const i = src.indexOf('close(ws');
|
|
||||||
const j = src.indexOf('function handleTabState', i);
|
|
||||||
const block = src.slice(i, j);
|
|
||||||
// 4001 = intentional restart (Commit 2), 4404 = no-claude, 1000 = clean
|
|
||||||
// exit. Any other code (1006 abnormal, 1001 going-away, etc.) gets the
|
|
||||||
// 60s detach grace.
|
|
||||||
expect(block).toContain('code === 4001');
|
|
||||||
expect(block).toContain('code === 4404');
|
|
||||||
expect(block).toContain('code === 1000');
|
|
||||||
expect(block).toContain('session.detached = true');
|
|
||||||
expect(block).toContain('session.detachTimer = setTimeout');
|
|
||||||
expect(block).toContain('DETACH_WINDOW_MS');
|
|
||||||
// Detach timer must unref so the bun process can exit cleanly.
|
|
||||||
expect(block).toContain('detachTimer as any)?.unref?.()');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('9. /internal/restart cancels detach timer before disposal', () => {
|
|
||||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
|
||||||
const block = sliceBetween(src, "url.pathname === '/internal/restart'", "// /claude-available");
|
|
||||||
// Without the cancellation, a later detach-timer fire would dispose a
|
|
||||||
// session that's already been disposed by the explicit restart path.
|
|
||||||
expect(block).toContain('clearTimeout(session.detachTimer)');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('10. PTY on-data writes through session.liveWs (not the original ws closure)', () => {
|
|
||||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
|
||||||
// Critical for re-attach correctness: the PTY's on-data callback
|
|
||||||
// closes over `session`, not the original `ws`, so after re-attach
|
|
||||||
// it routes to the new liveWs automatically.
|
|
||||||
expect(src).toContain('session.liveWs.sendBinary');
|
|
||||||
// Always append to the ring buffer regardless of attach state — so
|
|
||||||
// a detached session still captures output for the next re-attach.
|
|
||||||
expect(src).toContain('appendToRingBuffer(session, flush)');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function sliceBetween(source: string, start: string, end: string): string {
|
|
||||||
const i = source.indexOf(start);
|
|
||||||
if (i === -1) throw new Error(`marker not found: ${start}`);
|
|
||||||
const j = source.indexOf(end, i + start.length);
|
|
||||||
if (j === -1) throw new Error(`end marker not found: ${end}`);
|
|
||||||
return source.slice(i, j);
|
|
||||||
}
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
/**
|
|
||||||
* Integration tests for terminal-agent.ts.
|
|
||||||
*
|
|
||||||
* Spawns the agent as a real subprocess in a temp state directory,
|
|
||||||
* exercises:
|
|
||||||
* 1. /internal/grant — loopback handshake with the internal token.
|
|
||||||
* 2. /ws Origin gate — non-extension Origin → 403.
|
|
||||||
* 3. /ws cookie gate — missing/invalid cookie → 401.
|
|
||||||
* 4. /ws full PTY round-trip — write `echo hi\n`, read `hi`.
|
|
||||||
* 5. resize control message — terminal accepts and stays alive.
|
|
||||||
* 6. close behavior — sending close terminates the PTY child.
|
|
||||||
*
|
|
||||||
* Uses /bin/bash via BROWSE_TERMINAL_BINARY override so CI doesn't need
|
|
||||||
* the `claude` binary installed.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
import * as os from 'os';
|
|
||||||
|
|
||||||
const AGENT_SCRIPT = path.join(import.meta.dir, '../src/terminal-agent.ts');
|
|
||||||
const BASH = '/bin/bash';
|
|
||||||
|
|
||||||
let stateDir: string;
|
|
||||||
let agentProc: any;
|
|
||||||
let agentPort: number;
|
|
||||||
let internalToken: string;
|
|
||||||
|
|
||||||
function readPortFile(): number {
|
|
||||||
for (let i = 0; i < 50; i++) {
|
|
||||||
try {
|
|
||||||
const v = parseInt(fs.readFileSync(path.join(stateDir, 'terminal-port'), 'utf-8').trim(), 10);
|
|
||||||
if (Number.isFinite(v) && v > 0) return v;
|
|
||||||
} catch {}
|
|
||||||
Bun.sleepSync(40);
|
|
||||||
}
|
|
||||||
throw new Error('terminal-agent never wrote port file');
|
|
||||||
}
|
|
||||||
|
|
||||||
function readTokenFile(): string {
|
|
||||||
for (let i = 0; i < 50; i++) {
|
|
||||||
try {
|
|
||||||
const t = fs.readFileSync(path.join(stateDir, 'terminal-internal-token'), 'utf-8').trim();
|
|
||||||
if (t.length > 16) return t;
|
|
||||||
} catch {}
|
|
||||||
Bun.sleepSync(40);
|
|
||||||
}
|
|
||||||
throw new Error('terminal-agent never wrote internal token');
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-term-'));
|
|
||||||
const stateFile = path.join(stateDir, 'browse.json');
|
|
||||||
// browse.json must exist so the agent's readBrowseToken doesn't throw.
|
|
||||||
fs.writeFileSync(stateFile, JSON.stringify({ token: 'test-browse-token' }));
|
|
||||||
agentProc = Bun.spawn(['bun', 'run', AGENT_SCRIPT], {
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
BROWSE_STATE_FILE: stateFile,
|
|
||||||
BROWSE_SERVER_PORT: '0', // not used in this test
|
|
||||||
BROWSE_TERMINAL_BINARY: BASH,
|
|
||||||
},
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
});
|
|
||||||
agentPort = readPortFile();
|
|
||||||
internalToken = readTokenFile();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
try { agentProc?.kill?.(); } catch {}
|
|
||||||
try { fs.rmSync(stateDir, { recursive: true, force: true }); } catch {}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function grantToken(token: string): Promise<Response> {
|
|
||||||
return fetch(`http://127.0.0.1:${agentPort}/internal/grant`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${internalToken}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ token }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('terminal-agent: /internal/grant', () => {
|
|
||||||
test('accepts grants signed with the internal token', async () => {
|
|
||||||
const resp = await grantToken('test-cookie-token-very-long-yes');
|
|
||||||
expect(resp.status).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects grants with the wrong internal token', async () => {
|
|
||||||
const resp = await fetch(`http://127.0.0.1:${agentPort}/internal/grant`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': 'Bearer wrong-token',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ token: 'whatever' }),
|
|
||||||
});
|
|
||||||
expect(resp.status).toBe(403);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('terminal-agent: /ws gates', () => {
|
|
||||||
test('rejects upgrade attempts without an extension Origin', async () => {
|
|
||||||
const resp = await fetch(`http://127.0.0.1:${agentPort}/ws`);
|
|
||||||
expect(resp.status).toBe(403);
|
|
||||||
expect(await resp.text()).toBe('forbidden origin');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects upgrade attempts from a non-extension Origin', async () => {
|
|
||||||
const resp = await fetch(`http://127.0.0.1:${agentPort}/ws`, {
|
|
||||||
headers: { 'Origin': 'https://evil.example.com' },
|
|
||||||
});
|
|
||||||
expect(resp.status).toBe(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects extension-Origin upgrades without a granted cookie', async () => {
|
|
||||||
const resp = await fetch(`http://127.0.0.1:${agentPort}/ws`, {
|
|
||||||
headers: {
|
|
||||||
'Origin': 'chrome-extension://abc123',
|
|
||||||
'Cookie': 'gstack_pty=never-granted',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(resp.status).toBe(401);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('terminal-agent: PTY round-trip via real WebSocket (Cookie auth)', () => {
|
|
||||||
test('binary writes go to PTY stdin, output streams back', async () => {
|
|
||||||
const cookie = 'rt-token-must-be-at-least-seventeen-chars-long';
|
|
||||||
const granted = await grantToken(cookie);
|
|
||||||
expect(granted.status).toBe(200);
|
|
||||||
|
|
||||||
const ws = new WebSocket(`ws://127.0.0.1:${agentPort}/ws`, {
|
|
||||||
headers: {
|
|
||||||
'Origin': 'chrome-extension://test-extension-id',
|
|
||||||
'Cookie': `gstack_pty=${cookie}`,
|
|
||||||
},
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
const collected: string[] = [];
|
|
||||||
let opened = false;
|
|
||||||
let closed = false;
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
const timer = setTimeout(() => reject(new Error('ws never opened')), 5000);
|
|
||||||
ws.addEventListener('open', () => { opened = true; clearTimeout(timer); resolve(); });
|
|
||||||
ws.addEventListener('error', (e: any) => { clearTimeout(timer); reject(new Error('ws error')); });
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.addEventListener('message', (ev: any) => {
|
|
||||||
if (typeof ev.data === 'string') return; // ignore control frames
|
|
||||||
const buf = ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : ev.data;
|
|
||||||
collected.push(new TextDecoder().decode(buf));
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.addEventListener('close', () => { closed = true; });
|
|
||||||
|
|
||||||
// Lazy-spawn trigger: any binary frame causes the agent to spawn /bin/bash.
|
|
||||||
ws.send(new TextEncoder().encode('echo hello-pty-world\nexit\n'));
|
|
||||||
|
|
||||||
// Wait up to 5s for output and shutdown.
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
const start = Date.now();
|
|
||||||
const tick = () => {
|
|
||||||
const joined = collected.join('');
|
|
||||||
if (joined.includes('hello-pty-world')) return resolve();
|
|
||||||
if (Date.now() - start > 5000) return resolve();
|
|
||||||
setTimeout(tick, 50);
|
|
||||||
};
|
|
||||||
tick();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(opened).toBe(true);
|
|
||||||
const allOutput = collected.join('');
|
|
||||||
expect(allOutput).toContain('hello-pty-world');
|
|
||||||
|
|
||||||
try { ws.close(); } catch {}
|
|
||||||
// Give cleanup a moment.
|
|
||||||
await Bun.sleep(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Sec-WebSocket-Protocol auth path: browser-style upgrade with token in protocol', async () => {
|
|
||||||
// This is the path the actual browser extension takes. Cross-port
|
|
||||||
// SameSite=Strict cookies don't reliably survive the jump from the
|
|
||||||
// browse server (port A) to the agent (port B) when initiated from a
|
|
||||||
// chrome-extension origin, so we send the token via the only auth
|
|
||||||
// header the browser WebSocket API lets us set: Sec-WebSocket-Protocol.
|
|
||||||
//
|
|
||||||
// The browser sends `gstack-pty.<token>` and the agent must:
|
|
||||||
// 1) strip the gstack-pty. prefix
|
|
||||||
// 2) validate the token
|
|
||||||
// 3) ECHO the protocol back in the upgrade response
|
|
||||||
// Without (3) the browser closes the connection immediately, which
|
|
||||||
// is the exact bug the original cookie-only implementation hit in
|
|
||||||
// manual dogfood. This test catches that regression in CI.
|
|
||||||
const token = 'sec-protocol-token-must-be-at-least-seventeen-chars';
|
|
||||||
await grantToken(token);
|
|
||||||
|
|
||||||
// We exercise the protocol path by raw-handshaking via fetch+Upgrade,
|
|
||||||
// because Bun's test-client WebSocket constructor doesn't propagate
|
|
||||||
// `protocols` cleanly when also passed `headers` (the constructor
|
|
||||||
// detects the third-arg form unreliably). Real browsers (Chromium)
|
|
||||||
// use the standard protocols arg fine — the server-side handler is
|
|
||||||
// identical either way, so this test still locks the load-bearing
|
|
||||||
// invariant: the agent accepts a token via Sec-WebSocket-Protocol
|
|
||||||
// and echoes the protocol back so a browser would accept the upgrade.
|
|
||||||
const handshakeKey = 'dGhlIHNhbXBsZSBub25jZQ==';
|
|
||||||
const resp = await fetch(`http://127.0.0.1:${agentPort}/ws`, {
|
|
||||||
headers: {
|
|
||||||
'Connection': 'Upgrade',
|
|
||||||
'Upgrade': 'websocket',
|
|
||||||
'Sec-WebSocket-Version': '13',
|
|
||||||
'Sec-WebSocket-Key': handshakeKey,
|
|
||||||
'Sec-WebSocket-Protocol': `gstack-pty.${token}`,
|
|
||||||
'Origin': 'chrome-extension://test-extension-id',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 101 Switching Protocols + protocol echoed back = browser would accept.
|
|
||||||
// 401/403/anything else = browser would close the connection immediately
|
|
||||||
// (the bug we hit in manual dogfood).
|
|
||||||
expect(resp.status).toBe(101);
|
|
||||||
expect(resp.headers.get('upgrade')?.toLowerCase()).toBe('websocket');
|
|
||||||
expect(resp.headers.get('sec-websocket-protocol')).toBe(`gstack-pty.${token}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Sec-WebSocket-Protocol auth: rejects unknown token even with valid Origin', async () => {
|
|
||||||
const resp = await fetch(`http://127.0.0.1:${agentPort}/ws`, {
|
|
||||||
headers: {
|
|
||||||
'Connection': 'Upgrade',
|
|
||||||
'Upgrade': 'websocket',
|
|
||||||
'Sec-WebSocket-Version': '13',
|
|
||||||
'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
|
|
||||||
'Sec-WebSocket-Protocol': 'gstack-pty.never-granted-token',
|
|
||||||
'Origin': 'chrome-extension://test-extension-id',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(resp.status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('text frame {type:"resize"} is accepted (no crash, ws stays open)', async () => {
|
|
||||||
const cookie = 'resize-token-must-be-at-least-seventeen-chars';
|
|
||||||
await grantToken(cookie);
|
|
||||||
|
|
||||||
const ws = new WebSocket(`ws://127.0.0.1:${agentPort}/ws`, {
|
|
||||||
headers: {
|
|
||||||
'Origin': 'chrome-extension://test-extension-id',
|
|
||||||
'Cookie': `gstack_pty=${cookie}`,
|
|
||||||
},
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
const timer = setTimeout(() => reject(new Error('ws never opened')), 5000);
|
|
||||||
ws.addEventListener('open', () => { clearTimeout(timer); resolve(); });
|
|
||||||
ws.addEventListener('error', () => { clearTimeout(timer); reject(new Error('ws error')); });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send a resize before anything else (lazy-spawn won't fire).
|
|
||||||
ws.send(JSON.stringify({ type: 'resize', cols: 120, rows: 40 }));
|
|
||||||
|
|
||||||
// After resize, send a binary frame; should still work.
|
|
||||||
ws.send(new TextEncoder().encode('exit\n'));
|
|
||||||
|
|
||||||
await Bun.sleep(300);
|
|
||||||
// ws still readyState 1 (OPEN) or 3 (CLOSED after exit) — both fine.
|
|
||||||
expect([WebSocket.OPEN, WebSocket.CLOSED]).toContain(ws.readyState);
|
|
||||||
|
|
||||||
try { ws.close(); } catch {}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { describe, test, expect } from 'bun:test';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
// Static-grep tripwire for the v1.44 internalHandler refactor.
|
|
||||||
//
|
|
||||||
// /internal/grant and /internal/revoke were copies of the same dance:
|
|
||||||
// bearer-auth → x-browse-gen check → req.json().then(...).catch(...).
|
|
||||||
// internalHandler<T>(req, fn) collapses that into a single helper call.
|
|
||||||
// This test fails CI if the helper goes away or the existing routes
|
|
||||||
// regress to inline auth + JSON parse boilerplate. Wiring tests
|
|
||||||
// (token grant/revoke behavior) already live in
|
|
||||||
// browse/test/terminal-agent-integration.test.ts.
|
|
||||||
|
|
||||||
const AGENT_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'terminal-agent.ts');
|
|
||||||
|
|
||||||
describe('terminal-agent internalHandler refactor (v1.44+)', () => {
|
|
||||||
test('1. internalHandler<T> exists with the documented signature', () => {
|
|
||||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
|
||||||
expect(src).toMatch(/async function internalHandler<T>\s*\(/);
|
|
||||||
// Body must include the auth gate, body parse, and result coercion.
|
|
||||||
expect(src).toContain('checkInternalAuth(req)');
|
|
||||||
expect(src).toContain('await req.json()');
|
|
||||||
expect(src).toContain('instanceof Response');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('2. /internal/grant routes through internalHandler', () => {
|
|
||||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
|
||||||
// Match the route handler block.
|
|
||||||
const block = sliceBetween(src, "url.pathname === '/internal/grant'", "url.pathname === '/internal/revoke'");
|
|
||||||
expect(block).toContain('internalHandler(req');
|
|
||||||
// Must NOT have the old inline pattern (would be a regression).
|
|
||||||
expect(block).not.toContain('req.headers.get(\'authorization\')');
|
|
||||||
expect(block).not.toContain('req.json().then(');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('3. /internal/revoke routes through internalHandler', () => {
|
|
||||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
|
||||||
const block = sliceBetween(src, "url.pathname === '/internal/revoke'", "url.pathname === '/internal/healthz'");
|
|
||||||
expect(block).toContain('internalHandler(req');
|
|
||||||
expect(block).not.toContain('req.json().then(');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function sliceBetween(source: string, start: string, end: string): string {
|
|
||||||
const i = source.indexOf(start);
|
|
||||||
if (i === -1) throw new Error(`marker not found: ${start}`);
|
|
||||||
const j = source.indexOf(end, i + start.length);
|
|
||||||
if (j === -1) throw new Error(`end marker not found: ${end}`);
|
|
||||||
return source.slice(i, j);
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import { describe, test, expect } from 'bun:test';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
// v1.44 WS keepalive — static-grep invariants for the protocol contract.
|
|
||||||
//
|
|
||||||
// terminal-agent.ts and sidepanel-terminal.js cooperate on a 25s ping/pong +
|
|
||||||
// keepalive cycle so long-idle PTY connections survive NAT idle timeouts and
|
|
||||||
// Chromium's MV3 panel suspension heuristics. The wiring is invisible to
|
|
||||||
// integration tests (you'd have to wait 25s to observe a ping) but trivially
|
|
||||||
// regressed by a refactor. These tests fail CI if either side stops sending
|
|
||||||
// or stops accepting the protocol frames.
|
|
||||||
|
|
||||||
const AGENT_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'terminal-agent.ts');
|
|
||||||
const CLIENT_JS = path.resolve(new URL(import.meta.url).pathname, '..', '..', '..', 'extension', 'sidepanel-terminal.js');
|
|
||||||
|
|
||||||
describe('terminal-agent WS keepalive (v1.44+)', () => {
|
|
||||||
test('1. agent has a KEEPALIVE_INTERVAL_MS env knob, default 25000', () => {
|
|
||||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
|
||||||
expect(src).toContain('GSTACK_PTY_KEEPALIVE_INTERVAL_MS');
|
|
||||||
expect(src).toMatch(/KEEPALIVE_INTERVAL_MS\s*=\s*parseInt\(/);
|
|
||||||
// Default constant present so the env knob has a fallback.
|
|
||||||
expect(src).toContain("'25000'");
|
|
||||||
});
|
|
||||||
|
|
||||||
test('2. WS open handler starts a ping interval on the session', () => {
|
|
||||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
|
||||||
// The open(ws) handler in the websocket: { ... } block must call
|
|
||||||
// setInterval to drive the ping cadence and store the handle.
|
|
||||||
const wsBlock = sliceBetween(src, 'websocket: {', 'function handleTabState');
|
|
||||||
expect(wsBlock).toMatch(/open\s*\(\s*ws\s*\)/);
|
|
||||||
expect(wsBlock).toContain('setInterval');
|
|
||||||
expect(wsBlock).toContain("type: 'ping'");
|
|
||||||
expect(wsBlock).toContain('pingInterval');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('3. WS close handler clears the ping interval', () => {
|
|
||||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
|
||||||
const wsBlock = sliceBetween(src, 'websocket: {', 'function handleTabState');
|
|
||||||
// close(ws, code?, reason?) MUST clearInterval the pingInterval —
|
|
||||||
// otherwise we leak timers across reconnects and the ping handler
|
|
||||||
// captures a dead ws ref. Signature widened in Commit 3 to include
|
|
||||||
// the close code for the detach state machine, hence the loose match.
|
|
||||||
expect(wsBlock).toMatch(/close\s*\(\s*ws/);
|
|
||||||
expect(wsBlock).toContain('clearInterval(session.pingInterval)');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('4. message handler accepts pong / keepalive frames silently', () => {
|
|
||||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
|
||||||
// The text-frame router must recognize the keepalive vocabulary —
|
|
||||||
// if a future refactor strips this branch, unknown-text-frame
|
|
||||||
// suppression would still drop them but we lose intent.
|
|
||||||
expect(src).toMatch(/msg\?\.type === 'pong'/);
|
|
||||||
expect(src).toMatch(/msg\?\.type === 'keepalive'/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('5. client sends keepalive every 25s on ws.open', () => {
|
|
||||||
const src = fs.readFileSync(CLIENT_JS, 'utf-8');
|
|
||||||
expect(src).toContain('keepaliveInterval');
|
|
||||||
expect(src).toMatch(/setInterval\(/);
|
|
||||||
expect(src).toContain("type: 'keepalive'");
|
|
||||||
expect(src).toContain('KEEPALIVE_INTERVAL_MS = 25000');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('6. client replies pong to server ping', () => {
|
|
||||||
const src = fs.readFileSync(CLIENT_JS, 'utf-8');
|
|
||||||
// The ws.message handler must short-circuit on msg.type === 'ping'
|
|
||||||
// and reply with {type: 'pong', ts: msg.ts}.
|
|
||||||
expect(src).toMatch(/msg\.type === 'ping'/);
|
|
||||||
expect(src).toMatch(/type: 'pong'/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('7. client clears keepalive in close + teardown + forceRestart', () => {
|
|
||||||
const src = fs.readFileSync(CLIENT_JS, 'utf-8');
|
|
||||||
// Three teardown paths exist; all three must drop the interval to
|
|
||||||
// avoid leaking timers across reconnect attempts.
|
|
||||||
const occurrences = (src.match(/clearInterval\(keepaliveInterval\)/g) || []).length;
|
|
||||||
expect(occurrences).toBeGreaterThanOrEqual(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function sliceBetween(source: string, start: string, end: string): string {
|
|
||||||
const i = source.indexOf(start);
|
|
||||||
if (i === -1) throw new Error(`marker not found: ${start}`);
|
|
||||||
const j = source.indexOf(end, i + start.length);
|
|
||||||
if (j === -1) throw new Error(`end marker not found: ${end}`);
|
|
||||||
return source.slice(i, j);
|
|
||||||
}
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import { describe, test, expect } from 'bun:test';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
import {
|
|
||||||
readAgentRecord,
|
|
||||||
writeAgentRecord,
|
|
||||||
clearAgentRecord,
|
|
||||||
killAgentByRecord,
|
|
||||||
agentRecordPath,
|
|
||||||
type AgentRecord,
|
|
||||||
} from '../src/terminal-agent-control';
|
|
||||||
|
|
||||||
// REGRESSION TEST for the v1.44 PID-identity migration.
|
|
||||||
//
|
|
||||||
// Pre-v1.44, both `cli.ts` and `server.ts` killed the terminal-agent with
|
|
||||||
// `spawnSync('pkill', ['-f', 'terminal-agent\\.ts'])`. That command matches
|
|
||||||
// by argv regex — any process whose command line contains the string
|
|
||||||
// `terminal-agent.ts` got SIGTERM'd. In practice this killed:
|
|
||||||
//
|
|
||||||
// * sibling gstack sessions on the same host
|
|
||||||
// * editor processes (vim, code, less) that had the file open
|
|
||||||
// * any second gstack run on the host
|
|
||||||
//
|
|
||||||
// The v1.44 migration replaces both kill sites with identity-based PID kill
|
|
||||||
// against the record written at `<stateDir>/terminal-agent-pid` by the
|
|
||||||
// agent's own boot path. This test is the static-grep tripwire that prevents
|
|
||||||
// reintroducing the regex teardown anywhere in the source tree.
|
|
||||||
//
|
|
||||||
// Pattern mirrors browse/test/server-embedder-terminal-port.test.ts (Test 4)
|
|
||||||
// and browse/test/server-sanitize-surrogates.test.ts: read source files
|
|
||||||
// directly, assert an invariant on their contents.
|
|
||||||
|
|
||||||
const SRC_DIR = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src');
|
|
||||||
|
|
||||||
function readAllSourceFiles(): Array<{ file: string; content: string }> {
|
|
||||||
const out: Array<{ file: string; content: string }> = [];
|
|
||||||
for (const entry of fs.readdirSync(SRC_DIR)) {
|
|
||||||
if (!entry.endsWith('.ts')) continue;
|
|
||||||
const full = path.join(SRC_DIR, entry);
|
|
||||||
out.push({ file: entry, content: fs.readFileSync(full, 'utf-8') });
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('terminal-agent PID identity (v1.44+)', () => {
|
|
||||||
test('1. no source file calls `pkill -f terminal-agent`', () => {
|
|
||||||
// The regex matches both `pkill -f terminal-agent\.ts` (escaped form
|
|
||||||
// used in spawnSync args) and `pkill -f terminal-agent.ts` (literal),
|
|
||||||
// since the dot is the only difference and both are footguns.
|
|
||||||
const offenders: string[] = [];
|
|
||||||
for (const { file, content } of readAllSourceFiles()) {
|
|
||||||
// Walk line by line so we can skip comments that mention the historical
|
|
||||||
// pattern (acceptable as documentation, not as code).
|
|
||||||
const lines = content.split('\n');
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
const line = lines[i];
|
|
||||||
if (!/pkill/.test(line)) continue;
|
|
||||||
if (!/terminal-agent/.test(line)) continue;
|
|
||||||
// Skip comment lines — historical mentions in JSDoc are fine.
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue;
|
|
||||||
offenders.push(`${file}:${i + 1}: ${trimmed}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
expect(offenders).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('2. neither cli.ts nor server.ts calls spawnSync with pkill', () => {
|
|
||||||
// Tighter check — even if someone routes through a different code path,
|
|
||||||
// any spawnSync('pkill', ...) anywhere in src/ is the smell.
|
|
||||||
const offenders: string[] = [];
|
|
||||||
for (const { file, content } of readAllSourceFiles()) {
|
|
||||||
if (/spawnSync\s*\(\s*['"]pkill['"]/.test(content)) {
|
|
||||||
offenders.push(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
expect(offenders).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('3. readAgentRecord round-trips writeAgentRecord', () => {
|
|
||||||
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'gstack-pid-id-'));
|
|
||||||
try {
|
|
||||||
const record: AgentRecord = {
|
|
||||||
pid: 12345,
|
|
||||||
gen: 'test-gen-abcdef',
|
|
||||||
startedAt: Date.now(),
|
|
||||||
};
|
|
||||||
writeAgentRecord(tmpDir, record);
|
|
||||||
const read = readAgentRecord(tmpDir);
|
|
||||||
expect(read).toEqual(record);
|
|
||||||
expect(fs.existsSync(agentRecordPath(tmpDir))).toBe(true);
|
|
||||||
|
|
||||||
clearAgentRecord(tmpDir);
|
|
||||||
expect(readAgentRecord(tmpDir)).toBeNull();
|
|
||||||
expect(fs.existsSync(agentRecordPath(tmpDir))).toBe(false);
|
|
||||||
} finally {
|
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('4. readAgentRecord returns null on missing or malformed file', () => {
|
|
||||||
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'gstack-pid-id-'));
|
|
||||||
try {
|
|
||||||
// Missing.
|
|
||||||
expect(readAgentRecord(tmpDir)).toBeNull();
|
|
||||||
|
|
||||||
// Malformed: wrong type for pid.
|
|
||||||
fs.writeFileSync(agentRecordPath(tmpDir), JSON.stringify({ pid: 'not-a-number', gen: 'x', startedAt: 0 }));
|
|
||||||
expect(readAgentRecord(tmpDir)).toBeNull();
|
|
||||||
|
|
||||||
// Malformed: not JSON.
|
|
||||||
fs.writeFileSync(agentRecordPath(tmpDir), 'definitely not json');
|
|
||||||
expect(readAgentRecord(tmpDir)).toBeNull();
|
|
||||||
|
|
||||||
// Missing field.
|
|
||||||
fs.writeFileSync(agentRecordPath(tmpDir), JSON.stringify({ pid: 1, gen: 'x' }));
|
|
||||||
expect(readAgentRecord(tmpDir)).toBeNull();
|
|
||||||
} finally {
|
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('5. killAgentByRecord returns false for a dead PID and never throws', () => {
|
|
||||||
// PID 2147483646 is below Linux PID_MAX_LIMIT but way above macOS's
|
|
||||||
// typical max — no real process will ever hold it. isProcessAlive
|
|
||||||
// returns false; killAgentByRecord no-ops.
|
|
||||||
const record: AgentRecord = {
|
|
||||||
pid: 2147483646,
|
|
||||||
gen: 'sentinel',
|
|
||||||
startedAt: Date.now(),
|
|
||||||
};
|
|
||||||
const result = killAgentByRecord(record, 'SIGTERM');
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('6. killAgentByRecord skips the kill when isProcessAlive is false', () => {
|
|
||||||
// Guard via process.kill stub: confirm killAgentByRecord does NOT call
|
|
||||||
// process.kill with a non-zero signal when the PID is dead. This is the
|
|
||||||
// belt-and-suspenders defense against PID-reuse: even if isProcessAlive
|
|
||||||
// changes implementation, killAgentByRecord must validate liveness first.
|
|
||||||
const origKill = process.kill;
|
|
||||||
const kills: Array<[number, NodeJS.Signals | number]> = [];
|
|
||||||
(process as any).kill = ((pid: number, sig: NodeJS.Signals | number) => {
|
|
||||||
kills.push([pid, sig ?? 'SIGTERM']);
|
|
||||||
if (sig === 0) {
|
|
||||||
const err: any = new Error('ESRCH');
|
|
||||||
err.code = 'ESRCH';
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}) as any;
|
|
||||||
try {
|
|
||||||
const record: AgentRecord = { pid: 9999999, gen: 'x', startedAt: Date.now() };
|
|
||||||
killAgentByRecord(record, 'SIGTERM');
|
|
||||||
const terminations = kills.filter(([, s]) => s !== 0);
|
|
||||||
expect(terminations).toEqual([]);
|
|
||||||
} finally {
|
|
||||||
(process as any).kill = origKill;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
import { describe, test, expect, beforeEach } from 'bun:test';
|
|
||||||
import {
|
|
||||||
appendToRingBuffer,
|
|
||||||
buildReplayPayload,
|
|
||||||
type PtySession,
|
|
||||||
} from '../src/terminal-agent';
|
|
||||||
|
|
||||||
// Runtime exercises for the v1.44 Commit 3 ring buffer + replay prelude.
|
|
||||||
// Companion to browse/test/terminal-agent-detach-reattach.test.ts which
|
|
||||||
// covers the structural invariants; this file calls the helpers directly
|
|
||||||
// to prove behavioral correctness without spinning up a real Bun.serve
|
|
||||||
// listener.
|
|
||||||
|
|
||||||
function fresh(): PtySession {
|
|
||||||
return {
|
|
||||||
proc: null,
|
|
||||||
cols: 80,
|
|
||||||
rows: 24,
|
|
||||||
cookie: 'test-cookie',
|
|
||||||
liveWs: null,
|
|
||||||
sessionId: 'test-session',
|
|
||||||
spawned: false,
|
|
||||||
pingInterval: null,
|
|
||||||
ringBuffer: [],
|
|
||||||
ringBufferBytes: 0,
|
|
||||||
altScreenActive: false,
|
|
||||||
detached: false,
|
|
||||||
detachTimer: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('appendToRingBuffer runtime', () => {
|
|
||||||
test('appends frames in order and tracks byte count', () => {
|
|
||||||
const s = fresh();
|
|
||||||
appendToRingBuffer(s, Buffer.from('hello '));
|
|
||||||
appendToRingBuffer(s, Buffer.from('world'));
|
|
||||||
expect(s.ringBuffer).toHaveLength(2);
|
|
||||||
expect(s.ringBufferBytes).toBe(11);
|
|
||||||
expect(Buffer.concat(s.ringBuffer).toString()).toBe('hello world');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('evicts oldest frames when cap exceeded', () => {
|
|
||||||
// Default cap is 1 MB. Override via env wouldn't help inside this
|
|
||||||
// running process (constant was read at module load), so use frames
|
|
||||||
// big enough to exceed it deterministically.
|
|
||||||
const s = fresh();
|
|
||||||
const big = Buffer.alloc(400_000, 0x41); // 400 KB of 'A'
|
|
||||||
appendToRingBuffer(s, big);
|
|
||||||
appendToRingBuffer(s, big);
|
|
||||||
appendToRingBuffer(s, big); // total 1.2 MB — exceeds default cap
|
|
||||||
// Eviction must drop frames until under cap; first 400 KB chunk goes.
|
|
||||||
expect(s.ringBuffer.length).toBeLessThan(3);
|
|
||||||
expect(s.ringBufferBytes).toBeLessThanOrEqual(1024 * 1024);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('keeps at least one frame even when a single frame exceeds the cap', () => {
|
|
||||||
const s = fresh();
|
|
||||||
// 2 MB single frame — bigger than the 1 MB cap. The eviction loop
|
|
||||||
// guards on `ringBuffer.length > 1`, so the single oversized frame
|
|
||||||
// stays. Without that guard, the buffer would empty itself, defeating
|
|
||||||
// the whole point of replay on re-attach.
|
|
||||||
const huge = Buffer.alloc(2 * 1024 * 1024, 0x42);
|
|
||||||
appendToRingBuffer(s, huge);
|
|
||||||
expect(s.ringBuffer.length).toBe(1);
|
|
||||||
expect(s.ringBufferBytes).toBe(huge.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('tracks alt-screen enter (CSI ?1049h)', () => {
|
|
||||||
const s = fresh();
|
|
||||||
expect(s.altScreenActive).toBe(false);
|
|
||||||
appendToRingBuffer(s, Buffer.from('plain text'));
|
|
||||||
expect(s.altScreenActive).toBe(false);
|
|
||||||
appendToRingBuffer(s, Buffer.from('\x1b[?1049h'));
|
|
||||||
expect(s.altScreenActive).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('tracks alt-screen exit (CSI ?1049l)', () => {
|
|
||||||
const s = fresh();
|
|
||||||
appendToRingBuffer(s, Buffer.from('\x1b[?1049h'));
|
|
||||||
expect(s.altScreenActive).toBe(true);
|
|
||||||
appendToRingBuffer(s, Buffer.from('\x1b[?1049l'));
|
|
||||||
expect(s.altScreenActive).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('trailing state wins when enter + exit appear in one frame', () => {
|
|
||||||
const s = fresh();
|
|
||||||
// Tool call opened alt-screen then closed it inside one render — net
|
|
||||||
// state is back to main screen. lastIndexOf comparison handles this.
|
|
||||||
appendToRingBuffer(s, Buffer.from('start\x1b[?1049hmiddle\x1b[?1049lend'));
|
|
||||||
expect(s.altScreenActive).toBe(false);
|
|
||||||
|
|
||||||
const s2 = fresh();
|
|
||||||
// Reverse order: exited then re-entered — net state alt-screen.
|
|
||||||
appendToRingBuffer(s2, Buffer.from('\x1b[?1049l\x1b[?1049h'));
|
|
||||||
expect(s2.altScreenActive).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('buildReplayPayload runtime', () => {
|
|
||||||
test('prepends DECSTR soft reset before ring buffer contents', () => {
|
|
||||||
const s = fresh();
|
|
||||||
appendToRingBuffer(s, Buffer.from('prompt> '));
|
|
||||||
const payload = buildReplayPayload(s).toString('latin1');
|
|
||||||
expect(payload.startsWith('\x1b[!p')).toBe(true);
|
|
||||||
expect(payload.endsWith('prompt> ')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('re-enters alt-screen when session was in alt-screen at detach', () => {
|
|
||||||
const s = fresh();
|
|
||||||
appendToRingBuffer(s, Buffer.from('\x1b[?1049h tool output '));
|
|
||||||
const payload = buildReplayPayload(s).toString('latin1');
|
|
||||||
// Order: soft reset, alt-screen re-enter, ring buffer.
|
|
||||||
expect(payload.indexOf('\x1b[!p')).toBeLessThan(payload.indexOf('\x1b[?1049h'));
|
|
||||||
expect(payload.indexOf('\x1b[?1049h')).toBeLessThan(payload.indexOf('tool output'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('omits alt-screen re-enter when session was on main screen', () => {
|
|
||||||
const s = fresh();
|
|
||||||
appendToRingBuffer(s, Buffer.from('regular prompt'));
|
|
||||||
const payload = buildReplayPayload(s).toString('latin1');
|
|
||||||
// Soft reset is present, but alt-screen enter is NOT. Both substrings
|
|
||||||
// are otherwise identical 8 bytes apart in the alphabet, so equal-
|
|
||||||
// substring checks need to be strict.
|
|
||||||
expect(payload).toContain('\x1b[!p');
|
|
||||||
expect(payload).not.toContain('\x1b[?1049h');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('replay buffer length = soft-reset + (optional alt-screen) + ring bytes', () => {
|
|
||||||
const s = fresh();
|
|
||||||
appendToRingBuffer(s, Buffer.from('abc'));
|
|
||||||
appendToRingBuffer(s, Buffer.from('def'));
|
|
||||||
const payload = buildReplayPayload(s);
|
|
||||||
// 4 bytes (DECSTR) + 6 bytes (abc/def) = 10 bytes. No alt-screen.
|
|
||||||
expect(payload.length).toBe(4 + 6);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('lease lifecycle interplay (via pty-session-lease)', () => {
|
|
||||||
// Cross-module behavior: lease + ring buffer are both per-session.
|
|
||||||
// This catches the case where a refactor accidentally couples them.
|
|
||||||
test('lease registry is independent of ring buffer state', async () => {
|
|
||||||
const { mintLease, validateLease, __resetLeases } = await import('../src/pty-session-lease');
|
|
||||||
__resetLeases();
|
|
||||||
const a = mintLease();
|
|
||||||
const b = mintLease();
|
|
||||||
expect(a.sessionId).not.toBe(b.sessionId);
|
|
||||||
const va = validateLease(a.sessionId);
|
|
||||||
const vb = validateLease(b.sessionId);
|
|
||||||
expect(va.ok && vb.ok).toBe(true);
|
|
||||||
if (va.ok && vb.ok) {
|
|
||||||
expect(va.expiresAt).toBe(a.expiresAt);
|
|
||||||
expect(vb.expiresAt).toBe(b.expiresAt);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import { describe, test, expect } from 'bun:test';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
// v1.44 Commit 2 — terminal-agent sessionId routing + eager spawn.
|
|
||||||
//
|
|
||||||
// Live spawn tests would require a real claude binary on PATH and a Bun.serve
|
|
||||||
// listener; both are e2e-tier. These static-grep tripwires defend the load-
|
|
||||||
// bearing protocol changes:
|
|
||||||
// - validTokens carries the sessionId binding (Map, not Set)
|
|
||||||
// - sessionsById index exists for /internal/restart + (Commit 3) re-attach
|
|
||||||
// - /internal/restart is scoped to one sessionId (codex T2 fix)
|
|
||||||
// - {type:"start"} triggers spawn for eager UX after forceRestart
|
|
||||||
// - maybeSpawnPty helper is the single entry point for both spawn paths
|
|
||||||
|
|
||||||
const AGENT_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'terminal-agent.ts');
|
|
||||||
|
|
||||||
describe('terminal-agent session routing (v1.44+ Commit 2)', () => {
|
|
||||||
test('1. validTokens is a Map binding token → sessionId', () => {
|
|
||||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
|
||||||
// Pre-Commit 2 was `Set<string>`; the Map carries the sessionId
|
|
||||||
// binding that /internal/restart and (Commit 3) re-attach depend on.
|
|
||||||
expect(src).toMatch(/const validTokens = new Map<string, string \| null>\(\)/);
|
|
||||||
expect(src).not.toMatch(/const validTokens = new Set</);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('2. sessionsById reverse index exists', () => {
|
|
||||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
|
||||||
expect(src).toMatch(/const sessionsById = new Map<string, PtySession>\(\)/);
|
|
||||||
// Populated in open() — required so /internal/restart can find the session.
|
|
||||||
expect(src).toMatch(/if \(sessionId\) sessionsById\.set\(sessionId, session\)/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('3. /internal/grant binds an optional sessionId to the token', () => {
|
|
||||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
|
||||||
const block = sliceBetween(src, "url.pathname === '/internal/grant'", "url.pathname === '/internal/revoke'");
|
|
||||||
expect(block).toContain('validTokens.set(body.token, sid)');
|
|
||||||
expect(block).toContain('body?.sessionId');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('4. /internal/restart is scoped to one sessionId, not dispose-all', () => {
|
|
||||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
|
||||||
const block = sliceBetween(src, "url.pathname === '/internal/restart'", "// /claude-available");
|
|
||||||
expect(block).toContain('sessionsById.get(sid)');
|
|
||||||
expect(block).toContain('disposeSession(session)');
|
|
||||||
expect(block).toContain('sessionsById.delete(sid)');
|
|
||||||
// Negative: must NOT enumerate all live sessions and dispose them
|
|
||||||
// (codex T2 caught this — pre-spec the route killed every PTY on the
|
|
||||||
// agent, breaking multi-sidebar / pair-agent setups).
|
|
||||||
expect(block).not.toMatch(/for\s*\(\s*const\s+\[?ws/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('5. WS upgrade surfaces sessionId on ws.data', () => {
|
|
||||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
|
||||||
expect(src).toContain('validTokens.get(token) ?? null');
|
|
||||||
expect(src).toMatch(/data:\s*\{\s*cookie:\s*token,\s*sessionId\s*\}/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('6. eager spawn via {type:"start"} text frame', () => {
|
|
||||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
|
||||||
expect(src).toMatch(/msg\?\.type === 'start'/);
|
|
||||||
// Both spawn paths route through the same helper for parity.
|
|
||||||
expect(src).toContain('function maybeSpawnPty(');
|
|
||||||
expect(src).toMatch(/maybeSpawnPty\(ws, session\)/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('7. close() drops sessionsById entry alongside ws cleanup', () => {
|
|
||||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
|
||||||
// Commit 3 widened the close signature to `close(ws, code, _reason)`
|
|
||||||
// for the detach state machine. Match either shape so test is stable
|
|
||||||
// across the rest of the long-lived-sidebar PR.
|
|
||||||
const i = src.indexOf('close(ws');
|
|
||||||
expect(i).toBeGreaterThan(-1);
|
|
||||||
const j = src.indexOf('function handleTabState', i);
|
|
||||||
const block = src.slice(i, j);
|
|
||||||
expect(block).toContain('sessionsById.delete(session.sessionId)');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('8. PtySession interface carries the sessionId field', () => {
|
|
||||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
|
||||||
// Whole interface — close paren is sufficient.
|
|
||||||
const i = src.indexOf('interface PtySession {');
|
|
||||||
expect(i).toBeGreaterThan(-1);
|
|
||||||
const j = src.indexOf('\n}', i);
|
|
||||||
const block = src.slice(i, j);
|
|
||||||
expect(block).toContain('sessionId: string | null');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function sliceBetween(source: string, start: string, end: string): string {
|
|
||||||
const i = source.indexOf(start);
|
|
||||||
if (i === -1) throw new Error(`marker not found: ${start}`);
|
|
||||||
const j = source.indexOf(end, i + start.length);
|
|
||||||
if (j === -1) throw new Error(`end marker not found: ${end}`);
|
|
||||||
return source.slice(i, j);
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import { describe, test, expect } from 'bun:test';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
// v1.44 terminal-agent watchdog — static-grep invariants.
|
|
||||||
//
|
|
||||||
// The watchdog respawns terminal-agent when its PID dies. Live process-tree
|
|
||||||
// tests would require spawning, killing, and observing across two real Bun
|
|
||||||
// processes — slow and flaky in the free tier. These tripwires defend the
|
|
||||||
// load-bearing properties: identity-based liveness check (not name match),
|
|
||||||
// crash-loop guard, gated on ownsTerminalAgent, and cleared on shutdown.
|
|
||||||
|
|
||||||
const SERVER_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'server.ts');
|
|
||||||
const CONTROL_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'terminal-agent-control.ts');
|
|
||||||
|
|
||||||
describe('terminal-agent watchdog (v1.44+)', () => {
|
|
||||||
test('1. spawnTerminalAgent helper exists with PID return type', () => {
|
|
||||||
const src = fs.readFileSync(CONTROL_TS, 'utf-8');
|
|
||||||
expect(src).toMatch(/export function spawnTerminalAgent\(/);
|
|
||||||
// Must clean up prior PID before spawning (no zombies).
|
|
||||||
expect(src).toContain('readAgentRecord(stateDir)');
|
|
||||||
expect(src).toContain('killAgentByRecord(prior');
|
|
||||||
expect(src).toContain('clearAgentRecord(stateDir)');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('2. watchdog is gated on ownsTerminalAgent', () => {
|
|
||||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
|
||||||
// Match the comment + the guard. The guard MUST be a positive check;
|
|
||||||
// an inverted check would respawn for embedders and trample their PTY.
|
|
||||||
const block = sliceBetween(src, '─── Terminal-Agent Watchdog', 'Factory-scoped validateAuth');
|
|
||||||
expect(block).toMatch(/if \(ownsTerminalAgent\)/);
|
|
||||||
expect(block).toContain('agentWatchdogInterval = setInterval');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('3. watchdog uses PID liveness, not process name probe', () => {
|
|
||||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
|
||||||
const block = sliceBetween(src, '─── Terminal-Agent Watchdog', 'Factory-scoped validateAuth');
|
|
||||||
// The whole point of the v1.44 watchdog over v1.43- pkill teardown:
|
|
||||||
// identity-based liveness. Slow-but-alive agents must NOT trigger
|
|
||||||
// respawn (split-brain defense).
|
|
||||||
expect(block).toContain('readAgentRecord(stateDir)');
|
|
||||||
expect(block).toContain('isProcessAlive(record.pid)');
|
|
||||||
// Negative: no executable name-based process lookup. Allow the strings
|
|
||||||
// to appear in prose comments (the watchdog doc explains what it
|
|
||||||
// replaces), reject only actual invocations.
|
|
||||||
expect(block).not.toMatch(/spawnSync\s*\(\s*['"]pkill/);
|
|
||||||
expect(block).not.toMatch(/Bun\.spawn\s*\(\s*\[\s*['"]pgrep/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('4. crash-loop guard with rolling window', () => {
|
|
||||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
|
||||||
const block = sliceBetween(src, '─── Terminal-Agent Watchdog', 'Factory-scoped validateAuth');
|
|
||||||
expect(block).toContain('RESPAWN_GUARD_WINDOW_MS = 60_000');
|
|
||||||
expect(block).toContain('RESPAWN_GUARD_MAX = 3');
|
|
||||||
expect(block).toContain('respawnHistory');
|
|
||||||
expect(block).toContain('agentRespawnGuardTripped');
|
|
||||||
// Window pruning: old entries must be evicted before counting toward
|
|
||||||
// the limit. Otherwise a daemon up for a week with one crash a day
|
|
||||||
// would eventually trip the guard.
|
|
||||||
expect(block).toMatch(/respawnHistory\.shift\(\)/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('5. watchdog interval is cleared on shutdown', () => {
|
|
||||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
|
||||||
expect(src).toContain('if (agentWatchdogInterval) clearInterval(agentWatchdogInterval)');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('6. tick interval is env-overridable for tests', () => {
|
|
||||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
|
||||||
expect(src).toContain('GSTACK_AGENT_WATCHDOG_TICK_MS');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('7. CLI cold-start path uses the same spawnTerminalAgent helper', () => {
|
|
||||||
const cli = fs.readFileSync(
|
|
||||||
path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'cli.ts'),
|
|
||||||
'utf-8',
|
|
||||||
);
|
|
||||||
// Otherwise the CLI and watchdog could drift on spawn env/cwd, and
|
|
||||||
// teardown invariants tested against one would silently miss the other.
|
|
||||||
expect(cli).toContain('spawnTerminalAgent({');
|
|
||||||
expect(cli).toContain("from './terminal-agent-control'");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function sliceBetween(source: string, start: string, end: string): string {
|
|
||||||
const i = source.indexOf(start);
|
|
||||||
if (i === -1) throw new Error(`marker not found: ${start}`);
|
|
||||||
const j = source.indexOf(end, i + start.length);
|
|
||||||
if (j === -1) throw new Error(`end marker not found: ${end}`);
|
|
||||||
return source.slice(i, j);
|
|
||||||
}
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unit tests for the Terminal-tab PTY agent and its server-side glue.
|
|
||||||
*
|
|
||||||
* Coverage:
|
|
||||||
* - pty-session-cookie module: mint / validate / revoke / TTL pruning.
|
|
||||||
* - source-level guard: /pty-session and /terminal/* are NOT in TUNNEL_PATHS.
|
|
||||||
* - source-level guard: /health does not surface ptyToken.
|
|
||||||
* - source-level guard: terminal-agent binds 127.0.0.1 only.
|
|
||||||
* - source-level guard: terminal-agent enforces Origin AND cookie on /ws.
|
|
||||||
*
|
|
||||||
* These are read-only checks against source — they prevent silent surface
|
|
||||||
* widening during a routine refactor (matches the dual-listener.test.ts
|
|
||||||
* pattern). End-to-end behavior (real /bin/bash PTY round-trip,
|
|
||||||
* tunnel-surface 404 + denial-log) lives in
|
|
||||||
* `browse/test/terminal-agent-integration.test.ts`.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, test, expect, beforeEach } from 'bun:test';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
import {
|
|
||||||
mintPtySessionToken, validatePtySessionToken, revokePtySessionToken,
|
|
||||||
extractPtyCookie, buildPtySetCookie, buildPtyClearCookie,
|
|
||||||
PTY_COOKIE_NAME, __resetPtySessions,
|
|
||||||
} from '../src/pty-session-cookie';
|
|
||||||
|
|
||||||
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/terminal-agent.ts'), 'utf-8');
|
|
||||||
|
|
||||||
describe('pty-session-cookie: mint/validate/revoke', () => {
|
|
||||||
beforeEach(() => __resetPtySessions());
|
|
||||||
|
|
||||||
test('a freshly minted token validates', () => {
|
|
||||||
const { token } = mintPtySessionToken();
|
|
||||||
expect(validatePtySessionToken(token)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('null and unknown tokens fail validation', () => {
|
|
||||||
expect(validatePtySessionToken(null)).toBe(false);
|
|
||||||
expect(validatePtySessionToken(undefined)).toBe(false);
|
|
||||||
expect(validatePtySessionToken('')).toBe(false);
|
|
||||||
expect(validatePtySessionToken('not-a-real-token')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('revoke makes a token invalid', () => {
|
|
||||||
const { token } = mintPtySessionToken();
|
|
||||||
expect(validatePtySessionToken(token)).toBe(true);
|
|
||||||
revokePtySessionToken(token);
|
|
||||||
expect(validatePtySessionToken(token)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Set-Cookie has HttpOnly + SameSite=Strict + Path=/ + Max-Age', () => {
|
|
||||||
const { token } = mintPtySessionToken();
|
|
||||||
const cookie = buildPtySetCookie(token);
|
|
||||||
expect(cookie).toContain(`${PTY_COOKIE_NAME}=${token}`);
|
|
||||||
expect(cookie).toContain('HttpOnly');
|
|
||||||
expect(cookie).toContain('SameSite=Strict');
|
|
||||||
expect(cookie).toContain('Path=/');
|
|
||||||
expect(cookie).toMatch(/Max-Age=\d+/);
|
|
||||||
// Secure is intentionally omitted — daemon binds 127.0.0.1 over HTTP.
|
|
||||||
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', {
|
|
||||||
headers: { 'cookie': `othercookie=foo; gstack_pty=${token}; baz=qux` },
|
|
||||||
});
|
|
||||||
expect(extractPtyCookie(req)).toBe(token);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('extractPtyCookie returns null when the cookie is missing', () => {
|
|
||||||
const req = new Request('http://127.0.0.1/ws', {
|
|
||||||
headers: { 'cookie': 'unrelated=value' },
|
|
||||||
});
|
|
||||||
expect(extractPtyCookie(req)).toBe(null);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Source-level guard: /pty-session is not on the tunnel surface', () => {
|
|
||||||
test('TUNNEL_PATHS does not include /pty-session or /terminal/*', () => {
|
|
||||||
const start = SERVER_SRC.indexOf('const TUNNEL_PATHS = new Set<string>([');
|
|
||||||
expect(start).toBeGreaterThan(-1);
|
|
||||||
const end = SERVER_SRC.indexOf(']);', start);
|
|
||||||
const body = SERVER_SRC.slice(start, end);
|
|
||||||
expect(body).not.toContain('/pty-session');
|
|
||||||
expect(body).not.toContain('/terminal/');
|
|
||||||
expect(body).not.toContain('/terminal-');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Source-level guard: /health does NOT surface ptyToken', () => {
|
|
||||||
test('/health response body does not include ptyToken', () => {
|
|
||||||
const healthIdx = SERVER_SRC.indexOf("url.pathname === '/health'");
|
|
||||||
expect(healthIdx).toBeGreaterThan(-1);
|
|
||||||
// Slice from /health through the response close-bracket.
|
|
||||||
const slice = SERVER_SRC.slice(healthIdx, healthIdx + 2000);
|
|
||||||
// The /health JSON.stringify body must not mention the cookie token.
|
|
||||||
// It's allowed to include `terminalPort` (a port number, not auth).
|
|
||||||
expect(slice).not.toContain('ptyToken');
|
|
||||||
expect(slice).not.toContain('gstack_pty');
|
|
||||||
expect(slice).toContain('terminalPort');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Source-level guard: terminal-agent', () => {
|
|
||||||
test('binds 127.0.0.1 only, never 0.0.0.0', () => {
|
|
||||||
expect(AGENT_SRC).toContain("hostname: '127.0.0.1'");
|
|
||||||
expect(AGENT_SRC).not.toContain("hostname: '0.0.0.0'");
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects /ws upgrades without chrome-extension:// Origin', () => {
|
|
||||||
// The Origin check must run BEFORE the cookie check — otherwise a
|
|
||||||
// missing-origin attempt would surface the 401 cookie message and
|
|
||||||
// signal to attackers that they need to forge a cookie.
|
|
||||||
const wsHandler = AGENT_SRC.slice(AGENT_SRC.indexOf("if (url.pathname === '/ws')"));
|
|
||||||
expect(wsHandler).toContain('chrome-extension://');
|
|
||||||
expect(wsHandler).toContain('forbidden origin');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('validates the session token against an in-memory token set', () => {
|
|
||||||
const wsHandler = AGENT_SRC.slice(AGENT_SRC.indexOf("if (url.pathname === '/ws')"));
|
|
||||||
// Two transports: Sec-WebSocket-Protocol (preferred for browsers) and
|
|
||||||
// Cookie gstack_pty (fallback). Both verify against validTokens.
|
|
||||||
expect(wsHandler).toContain('sec-websocket-protocol');
|
|
||||||
expect(wsHandler).toContain('gstack_pty');
|
|
||||||
expect(wsHandler).toContain('validTokens.has');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Sec-WebSocket-Protocol auth: strips gstack-pty. prefix and echoes back', () => {
|
|
||||||
const wsHandler = AGENT_SRC.slice(AGENT_SRC.indexOf("if (url.pathname === '/ws')"));
|
|
||||||
// Browsers send `Sec-WebSocket-Protocol: gstack-pty.<token>`. The agent
|
|
||||||
// must strip the prefix before checking validTokens, AND echo the
|
|
||||||
// protocol back in the upgrade response — without the echo, the
|
|
||||||
// browser closes the connection immediately.
|
|
||||||
expect(wsHandler).toContain("'gstack-pty.'");
|
|
||||||
expect(wsHandler).toContain('Sec-WebSocket-Protocol');
|
|
||||||
expect(wsHandler).toContain('acceptedProtocol');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('lazy spawn: upgrade/open never spawn and both message triggers share maybeSpawnPty', () => {
|
|
||||||
// The whole point of lazy-spawn (codex finding #8) is that neither the
|
|
||||||
// HTTP upgrade nor websocket open creates a PTY. Only message frames may
|
|
||||||
// enter maybeSpawnPty: an explicit start frame or the first binary byte.
|
|
||||||
const upgradeBlock = AGENT_SRC.slice(
|
|
||||||
AGENT_SRC.indexOf("if (url.pathname === '/ws')"),
|
|
||||||
AGENT_SRC.indexOf("websocket: {"),
|
|
||||||
);
|
|
||||||
expect(upgradeBlock).not.toContain('spawnClaude(');
|
|
||||||
expect(upgradeBlock).not.toContain('maybeSpawnPty(');
|
|
||||||
|
|
||||||
const openHandler = AGENT_SRC.slice(
|
|
||||||
AGENT_SRC.indexOf('open(ws) {'),
|
|
||||||
AGENT_SRC.indexOf('message(ws, raw)'),
|
|
||||||
);
|
|
||||||
expect(openHandler).toContain('spawned: false');
|
|
||||||
expect(openHandler).not.toContain('spawnClaude(');
|
|
||||||
expect(openHandler).not.toContain('maybeSpawnPty(');
|
|
||||||
|
|
||||||
// maybeSpawnPty is the sole production call site for spawnClaude. Keeping
|
|
||||||
// that ownership centralized ensures both triggers share idempotency and
|
|
||||||
// failure handling instead of acquiring subtly different spawn paths.
|
|
||||||
const spawnOwner = AGENT_SRC.slice(
|
|
||||||
AGENT_SRC.indexOf('function maybeSpawnPty'),
|
|
||||||
AGENT_SRC.indexOf('function buildServer'),
|
|
||||||
);
|
|
||||||
expect(spawnOwner).toContain('if (session.spawned) return true');
|
|
||||||
expect(spawnOwner).toContain('spawnClaude(session.cols, session.rows');
|
|
||||||
expect(AGENT_SRC.match(/\bspawnClaude\s*\(/g)).toHaveLength(2); // declaration + owner call
|
|
||||||
|
|
||||||
const messageHandler = AGENT_SRC.slice(
|
|
||||||
AGENT_SRC.indexOf('message(ws, raw)'),
|
|
||||||
AGENT_SRC.indexOf('close(ws, code'),
|
|
||||||
);
|
|
||||||
expect(messageHandler).not.toContain('spawnClaude(');
|
|
||||||
|
|
||||||
const startTrigger = messageHandler.slice(
|
|
||||||
messageHandler.indexOf("if (msg?.type === 'start')"),
|
|
||||||
messageHandler.indexOf('// Unknown text frame'),
|
|
||||||
);
|
|
||||||
expect(startTrigger).toContain('maybeSpawnPty(ws, session)');
|
|
||||||
|
|
||||||
const binaryTrigger = messageHandler.slice(
|
|
||||||
messageHandler.indexOf('// Binary input. Lazy-spawn'),
|
|
||||||
);
|
|
||||||
expect(binaryTrigger).toContain('if (!session.spawned)');
|
|
||||||
expect(binaryTrigger).toContain('if (!maybeSpawnPty(ws, session)) return');
|
|
||||||
expect(AGENT_SRC.match(/\bmaybeSpawnPty\s*\(/g)).toHaveLength(3); // declaration + two triggers
|
|
||||||
});
|
|
||||||
|
|
||||||
test('process.on uncaughtException + unhandledRejection handlers exist', () => {
|
|
||||||
expect(AGENT_SRC).toContain("process.on('uncaughtException'");
|
|
||||||
expect(AGENT_SRC).toContain("process.on('unhandledRejection'");
|
|
||||||
});
|
|
||||||
|
|
||||||
test('cleanup escalates SIGINT to SIGKILL after 3s on close', () => {
|
|
||||||
// disposeSession must be idempotent and use a SIGINT-then-SIGKILL pattern.
|
|
||||||
const dispose = AGENT_SRC.slice(AGENT_SRC.indexOf('function disposeSession'));
|
|
||||||
expect(dispose).toContain("'SIGINT'");
|
|
||||||
expect(dispose).toContain("'SIGKILL'");
|
|
||||||
expect(dispose).toContain('3000');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('tabState frames write tabs.json + active-tab.json', () => {
|
|
||||||
expect(AGENT_SRC).toContain("msg?.type === 'tabState'");
|
|
||||||
expect(AGENT_SRC).toContain('function handleTabState');
|
|
||||||
const fn = AGENT_SRC.slice(AGENT_SRC.indexOf('function handleTabState'));
|
|
||||||
// Atomic write via tmp + rename for both files (so claude never reads
|
|
||||||
// a half-written JSON document).
|
|
||||||
expect(fn).toContain("'tabs.json'");
|
|
||||||
expect(fn).toContain("'active-tab.json'");
|
|
||||||
expect(fn).toContain('renameSync');
|
|
||||||
// Skip chrome:// and chrome-extension:// pages — they're not useful
|
|
||||||
// targets for browse commands.
|
|
||||||
expect(fn).toContain("startsWith('chrome://')");
|
|
||||||
expect(fn).toContain("startsWith('chrome-extension://')");
|
|
||||||
});
|
|
||||||
|
|
||||||
test('claude is spawned with --append-system-prompt tab-awareness hint', () => {
|
|
||||||
expect(AGENT_SRC).toContain('function buildTabAwarenessHint');
|
|
||||||
const hint = AGENT_SRC.slice(AGENT_SRC.indexOf('function buildTabAwarenessHint'));
|
|
||||||
// The hint must mention the live state files and the fanout command —
|
|
||||||
// those are the two affordances that distinguish a gstack-PTY claude
|
|
||||||
// from a plain `claude` session.
|
|
||||||
expect(hint).toContain('tabs.json');
|
|
||||||
expect(hint).toContain('active-tab.json');
|
|
||||||
expect(hint).toContain('tab-each');
|
|
||||||
// And it must be passed via --append-system-prompt at spawn time
|
|
||||||
// (NOT written into the PTY as user input — that would pollute the
|
|
||||||
// visible transcript).
|
|
||||||
const spawn = AGENT_SRC.slice(AGENT_SRC.indexOf('function spawnClaude'));
|
|
||||||
expect(spawn).toContain("'--append-system-prompt'");
|
|
||||||
expect(spawn).toContain('tabHint');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Source-level guard: server.ts /pty-session route', () => {
|
|
||||||
test('validates AUTH_TOKEN, grants over loopback, returns token + Set-Cookie', () => {
|
|
||||||
const route = SERVER_SRC.slice(SERVER_SRC.indexOf("url.pathname === '/pty-session'"));
|
|
||||||
// Must check auth before minting.
|
|
||||||
const beforeMint = route.slice(0, route.indexOf('mintPtySessionToken'));
|
|
||||||
expect(beforeMint).toContain('validateAuth');
|
|
||||||
// Must call the loopback grant before responding (otherwise the
|
|
||||||
// agent's validTokens Set never sees the token and /ws would 401).
|
|
||||||
expect(route).toContain('grantPtyToken');
|
|
||||||
// Must return the token in the JSON body for the
|
|
||||||
// Sec-WebSocket-Protocol auth path (cross-port cookies don't survive
|
|
||||||
// SameSite=Strict from a chrome-extension origin).
|
|
||||||
expect(route).toContain('ptySessionToken');
|
|
||||||
// Set-Cookie is kept as a fallback for non-browser callers.
|
|
||||||
expect(route).toContain('Set-Cookie');
|
|
||||||
expect(route).toContain('buildPtySetCookie');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
/**
|
|
||||||
* Static invariant: every gstackInjectToTerminal call in extension/*.js
|
|
||||||
* must be preceded by an await on gstackScanForPTYInject on the same code
|
|
||||||
* path (#1370 / D6).
|
|
||||||
*
|
|
||||||
* Why static, not runtime: extension/ runs in the chrome-extension origin;
|
|
||||||
* we can't easily exercise it in a Bun test. The invariant codex's plan
|
|
||||||
* review demanded is "no caller skips the scan." We get that by parsing
|
|
||||||
* the JS source as text and asserting structural rules.
|
|
||||||
*
|
|
||||||
* The rules (kept simple — false positives are worse than false
|
|
||||||
* negatives here since the wave has only two callers):
|
|
||||||
*
|
|
||||||
* Rule 1: every file that calls gstackInjectToTerminal must also call
|
|
||||||
* gstackScanForPTYInject.
|
|
||||||
*
|
|
||||||
* Rule 2: in any function that calls gstackInjectToTerminal, an
|
|
||||||
* `await ... gstackScanForPTYInject` MUST appear before the
|
|
||||||
* inject call when measured by source position (same function
|
|
||||||
* body).
|
|
||||||
*
|
|
||||||
* Exemption: extension/sidepanel-terminal.js defines the inject
|
|
||||||
* function itself; it doesn't need to call scan-first inside
|
|
||||||
* the definition.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, expect, test } from 'bun:test';
|
|
||||||
import { readFileSync, readdirSync, statSync } from 'fs';
|
|
||||||
import { join } from 'path';
|
|
||||||
|
|
||||||
const EXTENSION_DIR = join(import.meta.dir, '..', 'extension');
|
|
||||||
const INJECT_FN = 'gstackInjectToTerminal';
|
|
||||||
const SCAN_FN = 'gstackScanForPTYInject';
|
|
||||||
|
|
||||||
function listJsFiles(dir: string): string[] {
|
|
||||||
const out: string[] = [];
|
|
||||||
for (const entry of readdirSync(dir)) {
|
|
||||||
const full = join(dir, entry);
|
|
||||||
const st = statSync(full);
|
|
||||||
if (st.isDirectory()) {
|
|
||||||
out.push(...listJsFiles(full));
|
|
||||||
} else if (entry.endsWith('.js')) {
|
|
||||||
out.push(full);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findInjectCallSites(content: string): number[] {
|
|
||||||
// Find positions of `gstackInjectToTerminal(` or `gstackInjectToTerminal?.(`
|
|
||||||
// — but exclude the function DEFINITION (window.gstackInjectToTerminal = ).
|
|
||||||
const sites: number[] = [];
|
|
||||||
const callRe = /window\.gstackInjectToTerminal\s*\??\.?\s*\(/g;
|
|
||||||
let match: RegExpExecArray | null;
|
|
||||||
while ((match = callRe.exec(content)) !== null) {
|
|
||||||
// Look back ~30 chars; if "window.gstackInjectToTerminal =" appears
|
|
||||||
// right before, it's the definition, not a call.
|
|
||||||
const back = Math.max(0, match.index - 30);
|
|
||||||
const window30 = content.slice(back, match.index);
|
|
||||||
if (window30.includes('gstackInjectToTerminal =')) continue;
|
|
||||||
sites.push(match.index);
|
|
||||||
}
|
|
||||||
return sites;
|
|
||||||
}
|
|
||||||
|
|
||||||
function callsScan(content: string): boolean {
|
|
||||||
return content.includes(SCAN_FN);
|
|
||||||
}
|
|
||||||
|
|
||||||
function findEnclosingFunctionStart(content: string, callerPos: number): number {
|
|
||||||
// Walk backwards from callerPos looking for the most recent `function`
|
|
||||||
// keyword, `=> {`, or `addEventListener('click',\s*async`. Conservative
|
|
||||||
// — falls back to file start.
|
|
||||||
const text = content.slice(0, callerPos);
|
|
||||||
const candidates = [
|
|
||||||
text.lastIndexOf('function '),
|
|
||||||
text.lastIndexOf('=> {'),
|
|
||||||
text.lastIndexOf('async function'),
|
|
||||||
text.lastIndexOf('async ('),
|
|
||||||
text.lastIndexOf('async () =>'),
|
|
||||||
];
|
|
||||||
const idx = Math.max(...candidates);
|
|
||||||
return idx >= 0 ? idx : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('extension/* PTY injection invariant (#1370 / D6)', () => {
|
|
||||||
test('every inject call site is preceded by a scan call in the same enclosing function', () => {
|
|
||||||
const files = listJsFiles(EXTENSION_DIR);
|
|
||||||
const offenders: string[] = [];
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
const content = readFileSync(file, 'utf-8');
|
|
||||||
const sites = findInjectCallSites(content);
|
|
||||||
if (sites.length === 0) continue;
|
|
||||||
|
|
||||||
// Rule 1: file must reference the scan function.
|
|
||||||
if (!callsScan(content)) {
|
|
||||||
// Special-case sidepanel-terminal.js: it DEFINES the inject
|
|
||||||
// function but doesn't call it from inside.
|
|
||||||
if (file.endsWith('sidepanel-terminal.js')) continue;
|
|
||||||
offenders.push(`${file} calls ${INJECT_FN} but never references ${SCAN_FN}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rule 2: for each call site, find the enclosing function body and
|
|
||||||
// verify a scan call precedes the inject within that body.
|
|
||||||
for (const pos of sites) {
|
|
||||||
const fnStart = findEnclosingFunctionStart(content, pos);
|
|
||||||
const fnBody = content.slice(fnStart, pos);
|
|
||||||
if (!fnBody.includes(SCAN_FN)) {
|
|
||||||
const lineNum = content.slice(0, pos).split('\n').length;
|
|
||||||
offenders.push(`${file}:${lineNum} ${INJECT_FN} call not preceded by ${SCAN_FN} in enclosing function`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (offenders.length > 0) {
|
|
||||||
throw new Error(
|
|
||||||
'PTY-injection invariant violated:\n - ' + offenders.join('\n - '),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
expect(offenders).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sidepanel-terminal.js defines both gstackInjectToTerminal and gstackScanForPTYInject', () => {
|
|
||||||
const file = join(EXTENSION_DIR, 'sidepanel-terminal.js');
|
|
||||||
const content = readFileSync(file, 'utf-8');
|
|
||||||
expect(content).toContain('window.gstackInjectToTerminal');
|
|
||||||
expect(content).toContain('window.gstackScanForPTYInject');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('inject function stays synchronous (D6 contract preservation)', () => {
|
|
||||||
const file = join(EXTENSION_DIR, 'sidepanel-terminal.js');
|
|
||||||
const content = readFileSync(file, 'utf-8');
|
|
||||||
// The definition line should NOT contain "async" — async inject would
|
|
||||||
// break every existing caller using `const ok = ...?.()` pattern.
|
|
||||||
const match = content.match(/window\.gstackInjectToTerminal\s*=\s*(async\s+)?function/);
|
|
||||||
expect(match).not.toBeNull();
|
|
||||||
expect(match?.[1]).toBeUndefined(); // no `async` modifier
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,471 +0,0 @@
|
|||||||
/**
|
|
||||||
* Layer 4: E2E tests for the sidebar agent.
|
|
||||||
*
|
|
||||||
* sidebar-url-accuracy: Deterministic test that verifies the activeTabUrl fix.
|
|
||||||
* Starts server (no browser), POSTs to /sidebar-command with different activeTabUrl
|
|
||||||
* values, reads the queue file, and verifies the prompt uses the extension URL.
|
|
||||||
* No real Claude needed — this is a fast, cheap, deterministic test.
|
|
||||||
*
|
|
||||||
* sidebar-navigate: Full E2E with real Claude (requires ANTHROPIC_API_KEY).
|
|
||||||
* Starts server + sidebar-agent, sends a message, waits for Claude to respond.
|
|
||||||
* Tests the complete message flow through the queue.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
||||||
import { spawn, type Subprocess } from 'bun';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as os from 'os';
|
|
||||||
import * as path from 'path';
|
|
||||||
import {
|
|
||||||
ROOT,
|
|
||||||
describeIfSelected, testIfSelected,
|
|
||||||
createEvalCollector, finalizeEvalCollector,
|
|
||||||
} from './helpers/e2e-helpers';
|
|
||||||
|
|
||||||
const evalCollector = createEvalCollector('e2e-sidebar');
|
|
||||||
|
|
||||||
// --- Sidebar URL Accuracy (deterministic, no Claude) ---
|
|
||||||
|
|
||||||
describeIfSelected('Sidebar URL accuracy E2E', ['sidebar-url-accuracy'], () => {
|
|
||||||
let serverProc: Subprocess | null = null;
|
|
||||||
let serverPort: number = 0;
|
|
||||||
let authToken: string = '';
|
|
||||||
let tmpDir: string = '';
|
|
||||||
let stateFile: string = '';
|
|
||||||
let queueFile: string = '';
|
|
||||||
|
|
||||||
async function api(pathname: string, opts: RequestInit = {}): Promise<Response> {
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(opts.headers as Record<string, string> || {}),
|
|
||||||
};
|
|
||||||
if (!headers['Authorization'] && authToken) {
|
|
||||||
headers['Authorization'] = `Bearer ${authToken}`;
|
|
||||||
}
|
|
||||||
return fetch(`http://127.0.0.1:${serverPort}${pathname}`, { ...opts, headers });
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sidebar-e2e-url-'));
|
|
||||||
stateFile = path.join(tmpDir, 'browse.json');
|
|
||||||
queueFile = path.join(tmpDir, 'sidebar-queue.jsonl');
|
|
||||||
fs.mkdirSync(path.dirname(queueFile), { recursive: true });
|
|
||||||
|
|
||||||
const serverScript = path.resolve(ROOT, 'browse', 'src', 'server.ts');
|
|
||||||
serverProc = spawn(['bun', 'run', serverScript], {
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
BROWSE_STATE_FILE: stateFile,
|
|
||||||
BROWSE_HEADLESS_SKIP: '1',
|
|
||||||
BROWSE_PORT: '0',
|
|
||||||
SIDEBAR_QUEUE_PATH: queueFile,
|
|
||||||
BROWSE_IDLE_TIMEOUT: '300',
|
|
||||||
},
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const deadline = Date.now() + 15000;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
if (fs.existsSync(stateFile)) {
|
|
||||||
try {
|
|
||||||
const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
|
||||||
if (state.port && state.token) {
|
|
||||||
serverPort = state.port;
|
|
||||||
authToken = state.token;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
await new Promise(r => setTimeout(r, 100));
|
|
||||||
}
|
|
||||||
if (!serverPort) throw new Error('Server did not start in time');
|
|
||||||
}, 20000);
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
if (serverProc) { try { serverProc.kill(); } catch {} }
|
|
||||||
finalizeEvalCollector(evalCollector);
|
|
||||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
||||||
});
|
|
||||||
|
|
||||||
testIfSelected('sidebar-url-accuracy', async () => {
|
|
||||||
// Fresh session
|
|
||||||
await api('/sidebar-session/new', { method: 'POST' });
|
|
||||||
fs.writeFileSync(queueFile, '');
|
|
||||||
|
|
||||||
const extensionUrl = 'https://example.com/user-navigated-here';
|
|
||||||
const resp = await api('/sidebar-command', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
message: 'What page am I on?',
|
|
||||||
activeTabUrl: extensionUrl,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
expect(resp.status).toBe(200);
|
|
||||||
|
|
||||||
// Wait for queue entry
|
|
||||||
let lastEntry: any = null;
|
|
||||||
const deadline = Date.now() + 5000;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
await new Promise(r => setTimeout(r, 100));
|
|
||||||
if (!fs.existsSync(queueFile)) continue;
|
|
||||||
const lines = fs.readFileSync(queueFile, 'utf-8').trim().split('\n').filter(Boolean);
|
|
||||||
if (lines.length > 0) {
|
|
||||||
lastEntry = JSON.parse(lines[lines.length - 1]);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(lastEntry).not.toBeNull();
|
|
||||||
// Extension URL should be used, not the Playwright fallback.
|
|
||||||
// The pageUrl field carries the extension URL; the prompt itself
|
|
||||||
// contains only the system prompt + user message (URL is metadata).
|
|
||||||
expect(lastEntry.pageUrl).toBe(extensionUrl);
|
|
||||||
expect(lastEntry.pageUrl).not.toBe('about:blank');
|
|
||||||
|
|
||||||
// Also test: chrome:// URL should be rejected, falling back to about:blank
|
|
||||||
await api('/sidebar-agent/kill', { method: 'POST' });
|
|
||||||
fs.writeFileSync(queueFile, '');
|
|
||||||
|
|
||||||
await api('/sidebar-command', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
message: 'test',
|
|
||||||
activeTabUrl: 'chrome://settings',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
await new Promise(r => setTimeout(r, 200));
|
|
||||||
const lines2 = fs.readFileSync(queueFile, 'utf-8').trim().split('\n').filter(Boolean);
|
|
||||||
if (lines2.length > 0) {
|
|
||||||
const entry2 = JSON.parse(lines2[lines2.length - 1]);
|
|
||||||
expect(entry2.pageUrl).toBe('about:blank');
|
|
||||||
}
|
|
||||||
|
|
||||||
evalCollector?.addTest({
|
|
||||||
name: 'sidebar-url-accuracy', suite: 'Sidebar URL accuracy E2E', tier: 'e2e',
|
|
||||||
passed: true,
|
|
||||||
duration_ms: 0,
|
|
||||||
cost_usd: 0,
|
|
||||||
exit_reason: 'success',
|
|
||||||
});
|
|
||||||
}, 30_000);
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Sidebar CSS Interaction E2E (real Claude + real browser) ---
|
|
||||||
// Goes to HN, reads comments, identifies the most insightful one, highlights it.
|
|
||||||
// Exercises: navigation, snapshot, text reading, LLM judgment, CSS style injection.
|
|
||||||
|
|
||||||
describeIfSelected('Sidebar CSS interaction E2E', ['sidebar-css-interaction'], () => {
|
|
||||||
let serverProc: Subprocess | null = null;
|
|
||||||
let agentProc: Subprocess | null = null;
|
|
||||||
let serverPort: number = 0;
|
|
||||||
let authToken: string = '';
|
|
||||||
let tmpDir: string = '';
|
|
||||||
let stateFile: string = '';
|
|
||||||
let queueFile: string = '';
|
|
||||||
let serverLogFile: string = '';
|
|
||||||
let serverErrFile: string = '';
|
|
||||||
let agentLogFile: string = '';
|
|
||||||
let agentErrFile: string = '';
|
|
||||||
|
|
||||||
async function api(pathname: string, opts: RequestInit = {}): Promise<Response> {
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(opts.headers as Record<string, string> || {}),
|
|
||||||
};
|
|
||||||
if (!headers['Authorization'] && authToken) {
|
|
||||||
headers['Authorization'] = `Bearer ${authToken}`;
|
|
||||||
}
|
|
||||||
return fetch(`http://127.0.0.1:${serverPort}${pathname}`, { ...opts, headers });
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sidebar-e2e-css-'));
|
|
||||||
stateFile = path.join(tmpDir, 'browse.json');
|
|
||||||
queueFile = path.join(tmpDir, 'sidebar-queue.jsonl');
|
|
||||||
fs.mkdirSync(path.dirname(queueFile), { recursive: true });
|
|
||||||
|
|
||||||
// Start server WITH a real browser for CSS interaction
|
|
||||||
const serverScript = path.resolve(ROOT, 'browse', 'src', 'server.ts');
|
|
||||||
serverLogFile = path.join(tmpDir, 'server.log');
|
|
||||||
serverErrFile = path.join(tmpDir, 'server.err');
|
|
||||||
// Use 'pipe' stdio — closing file descriptors kills the child on macOS/bun
|
|
||||||
serverProc = spawn(['bun', 'run', serverScript], {
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
BROWSE_STATE_FILE: stateFile,
|
|
||||||
BROWSE_PORT: '0',
|
|
||||||
SIDEBAR_QUEUE_PATH: queueFile,
|
|
||||||
BROWSE_IDLE_TIMEOUT: '600000', // 10 min in ms — test takes ~3 min
|
|
||||||
},
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for state file with port/token
|
|
||||||
const deadline = Date.now() + 30000;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
if (fs.existsSync(stateFile)) {
|
|
||||||
try {
|
|
||||||
const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
|
||||||
if (state.port && state.token) {
|
|
||||||
serverPort = state.port;
|
|
||||||
authToken = state.token;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
await new Promise(r => setTimeout(r, 200));
|
|
||||||
}
|
|
||||||
if (!serverPort) throw new Error('Server did not start in time');
|
|
||||||
|
|
||||||
// Verify server is healthy before proceeding
|
|
||||||
const healthDeadline = Date.now() + 10000;
|
|
||||||
let healthy = false;
|
|
||||||
while (Date.now() < healthDeadline) {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`http://127.0.0.1:${serverPort}/health`);
|
|
||||||
if (resp.ok) { healthy = true; break; }
|
|
||||||
} catch {}
|
|
||||||
await new Promise(r => setTimeout(r, 500));
|
|
||||||
}
|
|
||||||
if (!healthy) throw new Error('Server started but health check failed');
|
|
||||||
|
|
||||||
// Start sidebar-agent with the real browse binary
|
|
||||||
const agentScript = path.resolve(ROOT, 'browse', 'src', 'sidebar-agent.ts');
|
|
||||||
const browseBin = path.resolve(ROOT, 'browse', 'dist', 'browse');
|
|
||||||
agentLogFile = path.join(tmpDir, 'agent.log');
|
|
||||||
agentErrFile = path.join(tmpDir, 'agent.err');
|
|
||||||
// Use 'pipe' stdio — closing file descriptors kills the child on macOS/bun
|
|
||||||
agentProc = spawn(['bun', 'run', agentScript], {
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
BROWSE_SERVER_PORT: String(serverPort),
|
|
||||||
BROWSE_STATE_FILE: stateFile,
|
|
||||||
SIDEBAR_QUEUE_PATH: queueFile,
|
|
||||||
SIDEBAR_AGENT_TIMEOUT: '180000', // 3 min — multi-step HN comment task
|
|
||||||
BROWSE_BIN: fs.existsSync(browseBin) ? browseBin : 'echo',
|
|
||||||
},
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise(r => setTimeout(r, 2000));
|
|
||||||
}, 35000);
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
if (agentProc) { try { agentProc.kill(); } catch {} }
|
|
||||||
if (serverProc) { try { serverProc.kill(); } catch {} }
|
|
||||||
finalizeEvalCollector(evalCollector);
|
|
||||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
||||||
});
|
|
||||||
|
|
||||||
testIfSelected('sidebar-css-interaction', async () => {
|
|
||||||
// Fresh session + clean queue
|
|
||||||
try { await api('/sidebar-session/new', { method: 'POST' }); } catch {}
|
|
||||||
fs.writeFileSync(queueFile, '');
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
// Simple task: go to example.com, read the title, apply a style
|
|
||||||
// (much faster than multi-step HN comment navigation)
|
|
||||||
const resp = await api('/sidebar-command', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
message: 'Go to https://example.com. Read the page title. Add a 4px solid orange outline to the h1 element.',
|
|
||||||
activeTabUrl: 'about:blank',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
expect(resp.status).toBe(200);
|
|
||||||
|
|
||||||
// Poll for agent_done (4 min timeout — multi-step task with opus LLM)
|
|
||||||
const deadline = Date.now() + 240000;
|
|
||||||
let entries: any[] = [];
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
try {
|
|
||||||
const chatResp = await api('/sidebar-chat?after=0');
|
|
||||||
const data = await chatResp.json();
|
|
||||||
entries = data.entries || [];
|
|
||||||
if (entries.some((e: any) => e.type === 'agent_done')) break;
|
|
||||||
} catch (err: any) {
|
|
||||||
// Server may be temporarily busy or restarting — retry on connection errors
|
|
||||||
const isConnErr = err.code === 'ConnectionRefused' || err.message?.includes('ConnectionRefused') || err.message?.includes('Unable to connect');
|
|
||||||
if (!isConnErr) throw err;
|
|
||||||
}
|
|
||||||
await new Promise(r => setTimeout(r, 3000));
|
|
||||||
}
|
|
||||||
|
|
||||||
const duration = Date.now() - startTime;
|
|
||||||
const doneEntry = entries.find((e: any) => e.type === 'agent_done');
|
|
||||||
|
|
||||||
// Dump debug info on failure
|
|
||||||
if (!doneEntry || entries.length === 0) {
|
|
||||||
console.log('ENTRIES:', JSON.stringify(entries.slice(-5), null, 2));
|
|
||||||
console.log('SERVER exitCode:', serverProc?.exitCode, 'signalCode:', serverProc?.signalCode, 'killed:', serverProc?.killed);
|
|
||||||
console.log('AGENT exitCode:', agentProc?.exitCode, 'signalCode:', agentProc?.signalCode, 'killed:', agentProc?.killed);
|
|
||||||
const queueContent = fs.existsSync(queueFile) ? fs.readFileSync(queueFile, 'utf-8').slice(-500) : 'NO QUEUE';
|
|
||||||
console.log('QUEUE:', queueContent.length > 0 ? 'has entries' : 'empty');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Agent should have completed
|
|
||||||
expect(doneEntry).toBeDefined();
|
|
||||||
|
|
||||||
// Agent should have run browse commands (look for tool_use entries)
|
|
||||||
const toolUses = entries.filter((e: any) => e.type === 'tool_use');
|
|
||||||
expect(toolUses.length).toBeGreaterThanOrEqual(2); // At minimum: goto + one more
|
|
||||||
|
|
||||||
// Agent text should mention something about the comment it found
|
|
||||||
const agentText = entries
|
|
||||||
.filter((e: any) => e.role === 'agent' && (e.type === 'text' || e.type === 'result'))
|
|
||||||
.map((e: any) => e.text || '')
|
|
||||||
.join(' ')
|
|
||||||
.toLowerCase();
|
|
||||||
|
|
||||||
// Should have navigated to example.com (look for example.com in any entry text)
|
|
||||||
const allEntryText = entries
|
|
||||||
.map((e: any) => `${e.text || ''} ${e.input || ''} ${e.message || ''}`)
|
|
||||||
.join(' ');
|
|
||||||
const navigatedToTarget = allEntryText.includes('example.com') || allEntryText.includes('Example Domain');
|
|
||||||
if (!navigatedToTarget) {
|
|
||||||
console.log('ALL ENTRY TEXT (first 2000):', allEntryText.slice(0, 2000));
|
|
||||||
}
|
|
||||||
expect(navigatedToTarget).toBe(true);
|
|
||||||
|
|
||||||
// Should have applied a style (look for orange/outline in tool commands)
|
|
||||||
const allText = entries.map((e: any) => e.text || '').join(' ');
|
|
||||||
const appliedStyle = allText.includes('outline') || allText.includes('orange') || allText.includes('style');
|
|
||||||
|
|
||||||
evalCollector?.addTest({
|
|
||||||
name: 'sidebar-css-interaction', suite: 'Sidebar CSS interaction E2E', tier: 'e2e',
|
|
||||||
passed: !!doneEntry && navigatedToTarget && appliedStyle,
|
|
||||||
duration_ms: duration,
|
|
||||||
cost_usd: 0,
|
|
||||||
exit_reason: doneEntry ? 'success' : 'timeout',
|
|
||||||
});
|
|
||||||
}, 300_000);
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Sidebar Navigate (real Claude, requires ANTHROPIC_API_KEY) ---
|
|
||||||
|
|
||||||
describeIfSelected('Sidebar navigate E2E', ['sidebar-navigate'], () => {
|
|
||||||
let serverProc: Subprocess | null = null;
|
|
||||||
let agentProc: Subprocess | null = null;
|
|
||||||
let serverPort: number = 0;
|
|
||||||
let authToken: string = '';
|
|
||||||
let tmpDir: string = '';
|
|
||||||
let stateFile: string = '';
|
|
||||||
let queueFile: string = '';
|
|
||||||
|
|
||||||
async function api(pathname: string, opts: RequestInit = {}): Promise<Response> {
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(opts.headers as Record<string, string> || {}),
|
|
||||||
};
|
|
||||||
if (!headers['Authorization'] && authToken) {
|
|
||||||
headers['Authorization'] = `Bearer ${authToken}`;
|
|
||||||
}
|
|
||||||
return fetch(`http://127.0.0.1:${serverPort}${pathname}`, { ...opts, headers });
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sidebar-e2e-nav-'));
|
|
||||||
stateFile = path.join(tmpDir, 'browse.json');
|
|
||||||
queueFile = path.join(tmpDir, 'sidebar-queue.jsonl');
|
|
||||||
fs.mkdirSync(path.dirname(queueFile), { recursive: true });
|
|
||||||
|
|
||||||
// Start server WITHOUT headless skip — we need a real browser for Claude to use
|
|
||||||
const serverScript = path.resolve(ROOT, 'browse', 'src', 'server.ts');
|
|
||||||
serverProc = spawn(['bun', 'run', serverScript], {
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
BROWSE_STATE_FILE: stateFile,
|
|
||||||
BROWSE_HEADLESS_SKIP: '1', // Still skip browser — Claude uses curl/fetch instead
|
|
||||||
BROWSE_PORT: '0',
|
|
||||||
SIDEBAR_QUEUE_PATH: queueFile,
|
|
||||||
BROWSE_IDLE_TIMEOUT: '300',
|
|
||||||
},
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const deadline = Date.now() + 15000;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
if (fs.existsSync(stateFile)) {
|
|
||||||
try {
|
|
||||||
const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
|
||||||
if (state.port && state.token) {
|
|
||||||
serverPort = state.port;
|
|
||||||
authToken = state.token;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
await new Promise(r => setTimeout(r, 100));
|
|
||||||
}
|
|
||||||
if (!serverPort) throw new Error('Server did not start in time');
|
|
||||||
|
|
||||||
// Start sidebar-agent
|
|
||||||
const agentScript = path.resolve(ROOT, 'browse', 'src', 'sidebar-agent.ts');
|
|
||||||
agentProc = spawn(['bun', 'run', agentScript], {
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
BROWSE_SERVER_PORT: String(serverPort),
|
|
||||||
BROWSE_STATE_FILE: stateFile,
|
|
||||||
SIDEBAR_QUEUE_PATH: queueFile,
|
|
||||||
SIDEBAR_AGENT_TIMEOUT: '90000',
|
|
||||||
BROWSE_BIN: 'echo', // browse commands won't work, but Claude can use curl
|
|
||||||
},
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise(r => setTimeout(r, 1500));
|
|
||||||
}, 25000);
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
if (agentProc) { try { agentProc.kill(); } catch {} }
|
|
||||||
if (serverProc) { try { serverProc.kill(); } catch {} }
|
|
||||||
finalizeEvalCollector(evalCollector);
|
|
||||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
||||||
});
|
|
||||||
|
|
||||||
testIfSelected('sidebar-navigate', async () => {
|
|
||||||
await api('/sidebar-session/new', { method: 'POST' });
|
|
||||||
fs.writeFileSync(queueFile, '');
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
// Ask Claude a simple question — it doesn't need browse commands for this
|
|
||||||
const resp = await api('/sidebar-command', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
message: 'Say exactly "SIDEBAR_TEST_OK" and nothing else.',
|
|
||||||
activeTabUrl: 'https://example.com',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
expect(resp.status).toBe(200);
|
|
||||||
|
|
||||||
// Poll for agent_done
|
|
||||||
const deadline = Date.now() + 90000;
|
|
||||||
let entries: any[] = [];
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const chatResp = await api('/sidebar-chat?after=0');
|
|
||||||
const data = await chatResp.json();
|
|
||||||
entries = data.entries;
|
|
||||||
if (entries.some((e: any) => e.type === 'agent_done')) break;
|
|
||||||
await new Promise(r => setTimeout(r, 2000));
|
|
||||||
}
|
|
||||||
|
|
||||||
const duration = Date.now() - startTime;
|
|
||||||
const doneEntry = entries.find((e: any) => e.type === 'agent_done');
|
|
||||||
expect(doneEntry).toBeDefined();
|
|
||||||
|
|
||||||
// Claude should have responded with something
|
|
||||||
const agentText = entries
|
|
||||||
.filter((e: any) => e.role === 'agent' && (e.type === 'text' || e.type === 'result'))
|
|
||||||
.map((e: any) => e.text || '')
|
|
||||||
.join(' ');
|
|
||||||
expect(agentText.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
evalCollector?.addTest({
|
|
||||||
name: 'sidebar-navigate', suite: 'Sidebar navigate E2E', tier: 'e2e',
|
|
||||||
passed: !!doneEntry && agentText.length > 0,
|
|
||||||
duration_ms: duration,
|
|
||||||
cost_usd: 0,
|
|
||||||
exit_reason: doneEntry ? 'success' : 'timeout',
|
|
||||||
});
|
|
||||||
}, 120_000);
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user