Merge remote-tracking branch 'origin/master' into cod-28-security-public-assets

This commit is contained in:
arkon
2026-06-08 18:05:10 +02:00
46 changed files with 6661 additions and 322 deletions
+135 -38
View File
@@ -3,26 +3,60 @@
* 1. Timing-safe password comparison (timingSafeEqual)
* 2. Hook event endpoint restricted to localhost
* 3. Session cookie TTL refresh on access
* 4. Startup warning when no password configured
* 4. Startup fails closed when network-bound without auth
* 5. SSE client limit enforcement
* 6. Logout endpoint invalidates session
* 7. Settings schema rejects unknown fields
*
* Port: 3160 (auth tests), 3161 (no-auth tests)
* Port: 3160 (auth tests), 3161 (loopback no-auth tests), 3162 (network override tests)
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { TmuxManager } from '../src/tmux-manager.js';
import { SettingsUpdateSchema } from '../src/web/schemas.js';
const AUTH_PORT = 3160;
const NOAUTH_PORT = 3161;
const NETWORK_OVERRIDE_PORT = 3162;
const AUTH_RATE_LIMIT_PORT = 3220;
const TEST_USER = 'admin';
const TEST_PASS = 'test-password-12345';
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
function basicAuthHeader(user: string, pass: string): string {
return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
}
async function startAuthServer(port: number): Promise<{ server: WebServer; baseUrl: string }> {
process.env.CODEMAN_PASSWORD = TEST_PASS;
process.env.CODEMAN_USERNAME = TEST_USER;
const server = new WebServer(port, false, true);
await server.start();
return { server, baseUrl: `http://localhost:${port}` };
}
async function getSessionCookie(baseUrl: string): Promise<string> {
const res = await fetch(`${baseUrl}/api/status`, {
headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) },
});
expect(res.status).toBe(200);
const setCookie = res.headers.get('set-cookie');
expect(setCookie).toBeTruthy();
const cookieMatch = setCookie!.match(/codeman_session=([^;]+)/);
expect(cookieMatch).toBeTruthy();
return `codeman_session=${cookieMatch![1]}`;
}
async function exhaustAuthFailures(baseUrl: string, prefix: string): Promise<void> {
for (let i = 0; i < 10; i++) {
const res = await fetch(`${baseUrl}/api/status`, {
headers: { Authorization: basicAuthHeader(TEST_USER, `${prefix}-${i}`) },
});
expect(res.status).toBe(401);
}
}
describe('Auth Security', () => {
let server: WebServer;
let baseUrl: string;
@@ -154,29 +188,63 @@ describe('Auth Security', () => {
});
describe('Rate Limiting', () => {
it('should block after too many failed attempts', async () => {
// Send 10 failed attempts
for (let i = 0; i < 10; i++) {
await fetch(`${baseUrl}/api/status`, {
headers: { Authorization: basicAuthHeader(TEST_USER, 'wrong-' + i) },
});
}
let rateServer: WebServer;
let rateBaseUrl: string;
// 11th attempt should be rate-limited
const res = await fetch(`${baseUrl}/api/status`, {
headers: { Authorization: basicAuthHeader(TEST_USER, 'wrong-again') },
});
expect(res.status).toBe(429);
beforeEach(async () => {
({ server: rateServer, baseUrl: rateBaseUrl } = await startAuthServer(AUTH_RATE_LIMIT_PORT));
});
it('should rate-limit even with correct credentials after lockout', async () => {
// After being rate-limited, even correct credentials should fail
const res = await fetch(`${baseUrl}/api/status`, {
afterEach(async () => {
await rateServer.stop();
});
it('should rate-limit wrong credentials after too many failed attempts', async () => {
await exhaustAuthFailures(rateBaseUrl, 'cod21-wrong');
const res = await fetch(`${rateBaseUrl}/api/status`, {
headers: { Authorization: basicAuthHeader(TEST_USER, 'wrong-again') },
});
expect(res.status).toBe(429);
expect(res.headers.get('retry-after')).toMatch(/^\d+$/);
});
it('should allow an existing valid session cookie during auth failure lockout', async () => {
const cookie = await getSessionCookie(rateBaseUrl);
await exhaustAuthFailures(rateBaseUrl, 'cod21-cookie');
const res = await fetch(`${rateBaseUrl}/api/status`, {
headers: { Cookie: cookie },
});
expect(res.status).toBe(200);
});
it('should allow correct credentials to recover from auth failure lockout', async () => {
await exhaustAuthFailures(rateBaseUrl, 'cod21-recover');
const res = await fetch(`${rateBaseUrl}/api/status`, {
headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) },
});
// Rate limit is per-IP and the previous test used the same IP
// This test verifies rate limiting isn't bypassed by correct creds
expect(res.status).toBe(429);
expect(res.status).toBe(200);
expect(res.headers.get('set-cookie')).toContain('codeman_session=');
});
it('should clear failed attempt count after correct credentials recover access', async () => {
await exhaustAuthFailures(rateBaseUrl, 'cod21-clear');
const recoveryRes = await fetch(`${rateBaseUrl}/api/status`, {
headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) },
});
expect(recoveryRes.status).toBe(200);
const wrongAfterRecovery = await fetch(`${rateBaseUrl}/api/status`, {
headers: { Authorization: basicAuthHeader(TEST_USER, 'wrong-after-recovery') },
});
expect(wrongAfterRecovery.status).toBe(401);
});
});
@@ -220,7 +288,7 @@ describe('Settings Schema Security', () => {
it('should enforce tunnelEnabled as boolean', () => {
const result = SettingsUpdateSchema.safeParse({
tunnelEnabled: 'yes', // truthy string — should be rejected
tunnelEnabled: 'yes', // truthy string — should be rejected
});
expect(result.success).toBe(false);
});
@@ -264,40 +332,69 @@ describe('Settings Schema Security', () => {
expect(validResult.success).toBe(true);
const invalidResult = SettingsUpdateSchema.safeParse({
nice: { enabled: true, niceValue: 100 }, // Out of range
nice: { enabled: true, niceValue: 100 }, // Out of range
});
expect(invalidResult.success).toBe(false);
});
});
describe('No-Auth Server Warning', () => {
describe('No-Auth Server Startup Policy', () => {
let server: WebServer;
let consoleWarnSpy: string[] = [];
const originalWarn = console.warn;
beforeAll(async () => {
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_USERNAME;
consoleWarnSpy = [];
console.warn = (...args: unknown[]) => {
consoleWarnSpy.push(args.map(String).join(' '));
};
server = new WebServer(NOAUTH_PORT, false, true);
delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
server = new WebServer(NOAUTH_PORT, false, true, '127.0.0.1');
await server.start();
});
afterAll(async () => {
console.warn = originalWarn;
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_USERNAME;
delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
await server.stop();
});
it('should warn when no CODEMAN_PASSWORD is set', () => {
const hasWarning = consoleWarnSpy.some(msg => msg.includes('No CODEMAN_PASSWORD set'));
expect(hasWarning).toBe(true);
});
it('should allow requests without auth when no password configured', async () => {
it('allows loopback requests without auth when no password is configured', async () => {
const res = await fetch(`http://localhost:${NOAUTH_PORT}/api/status`);
expect(res.status).toBe(200);
});
it('rejects non-loopback startup without a password or explicit override', async () => {
const networkServer = new WebServer(0, false, true, '0.0.0.0');
await expect(networkServer.start()).rejects.toThrow(/CODEMAN_PASSWORD/);
await networkServer.stop();
});
it('allows non-loopback startup when CODEMAN_PASSWORD is configured', async () => {
process.env.CODEMAN_PASSWORD = TEST_PASS;
const networkServer = new WebServer(0, false, true, '0.0.0.0');
await networkServer.start();
await networkServer.stop();
delete process.env.CODEMAN_PASSWORD;
});
it('allows non-loopback startup with the explicit unauthenticated-network override', async () => {
const networkServer = new WebServer(NETWORK_OVERRIDE_PORT, false, true, '0.0.0.0', undefined, true);
await networkServer.start();
const res = await fetch(`http://localhost:${NETWORK_OVERRIDE_PORT}/api/status`);
expect(res.status).toBe(200);
await networkServer.stop();
});
it('allows non-loopback startup with the explicit unauthenticated-network env override', async () => {
process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK = 'true';
const networkServer = new WebServer(0, false, true, '0.0.0.0');
await networkServer.start();
await networkServer.stop();
delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
});
});
+34 -16
View File
@@ -5,6 +5,7 @@
*/
import { describe, it, expect } from 'vitest';
import { program } from '../src/cli.js';
describe('CLI Command Parsing', () => {
describe('Command Structure', () => {
@@ -72,11 +73,11 @@ describe('CLI Command Parsing', () => {
];
const findCommand = (name: string): Command | undefined => {
return commands.find(c => c.name === name || c.aliases.includes(name));
return commands.find((c) => c.name === name || c.aliases.includes(name));
};
const findSubcommand = (parent: Command, name: string): Command | undefined => {
return parent.subcommands?.find(c => c.name === name || c.aliases.includes(name));
return parent.subcommands?.find((c) => c.name === name || c.aliases.includes(name));
};
it('should find commands by name', () => {
@@ -103,7 +104,7 @@ describe('CLI Command Parsing', () => {
});
it('should have descriptions for all commands', () => {
commands.forEach(cmd => {
commands.forEach((cmd) => {
expect(cmd.description).toBeTruthy();
});
});
@@ -267,13 +268,13 @@ describe('CLI Command Parsing', () => {
});
it('should have defaults for web flags', () => {
webFlags.forEach(flag => {
webFlags.forEach((flag) => {
expect(flag.default).toBeDefined();
});
});
it('should have defaults for tui flags', () => {
tuiFlags.forEach(flag => {
tuiFlags.forEach((flag) => {
expect(flag.default).toBeDefined();
});
});
@@ -322,7 +323,7 @@ describe('CLI Command Parsing', () => {
help += `${description}\n`;
if (options.length > 0) {
help += '\nOptions:\n';
options.forEach(opt => {
options.forEach((opt) => {
help += ` ${opt}\n`;
});
}
@@ -345,6 +346,15 @@ describe('CLI Command Parsing', () => {
expect(help).toContain('--host');
});
it('documents the unauthenticated network override in real web command help', () => {
const webCommand = program.commands.find((command) => command.name() === 'web');
expect(webCommand).toBeDefined();
const help = webCommand!.helpInformation();
expect(help).toContain('--allow-unauthenticated-network');
expect(help).toMatch(/without\s+CODEMAN_PASSWORD/);
});
it('should format properly', () => {
const help = generateHelp('test', 'Test command', ['--flag']);
const lines = help.split('\n');
@@ -474,22 +484,27 @@ describe('CLI Output Formatting', () => {
}
const formatRow = (values: string[], columns: Column[]): string => {
return values.map((val, i) => {
const width = columns[i]?.width || 10;
return val.padEnd(width).substring(0, width);
}).join(' ');
return values
.map((val, i) => {
const width = columns[i]?.width || 10;
return val.padEnd(width).substring(0, width);
})
.join(' ');
};
const formatTable = (headers: string[], rows: string[][], widths: number[]): string => {
const columns = headers.map((h, i) => ({ header: h, width: widths[i] }));
const headerRow = formatRow(headers, columns);
const separator = columns.map(c => '-'.repeat(c.width)).join(' ');
const dataRows = rows.map(row => formatRow(row, columns));
const separator = columns.map((c) => '-'.repeat(c.width)).join(' ');
const dataRows = rows.map((row) => formatRow(row, columns));
return [headerRow, separator, ...dataRows].join('\n');
};
it('should format single row', () => {
const columns = [{ header: 'ID', width: 10 }, { header: 'Status', width: 8 }];
const columns = [
{ header: 'ID', width: 10 },
{ header: 'Status', width: 8 },
];
const row = formatRow(['123', 'active'], columns);
expect(row).toBe('123 active ');
});
@@ -503,7 +518,10 @@ describe('CLI Output Formatting', () => {
it('should format complete table', () => {
const table = formatTable(
['ID', 'Status'],
[['1', 'active'], ['2', 'idle']],
[
['1', 'active'],
['2', 'idle'],
],
[5, 8]
);
expect(table).toContain('ID');
@@ -587,7 +605,7 @@ describe('CLI Output Formatting', () => {
});
it('should format normal costs with 2 decimals', () => {
expect(formatCost(1.50)).toBe('$1.50');
expect(formatCost(1.5)).toBe('$1.50');
expect(formatCost(0.05)).toBe('$0.05');
});
@@ -633,7 +651,7 @@ describe('CLI Output Formatting', () => {
describe('List Formatting', () => {
const formatList = (items: string[], bullet: string = '-'): string => {
return items.map(item => `${bullet} ${item}`).join('\n');
return items.map((item) => `${bullet} ${item}`).join('\n');
};
const formatNumberedList = (items: string[]): string => {
+79
View File
@@ -0,0 +1,79 @@
/**
* Per-instance isolation (src/config/instance.ts): the data dir + tmux socket
* derive from CODEMAN_INSTANCE, defaulting to the production layout so the
* feature branch is safe to merge to master.
*
* instance.ts reads env at module load, so each case re-imports it via
* vi.resetModules() under a controlled env. node:fs mkdirSync is mocked so
* getDataDir() never creates real directories on the test machine.
*
* Port: N/A (no server).
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
import { homedir } from 'node:os';
import { join } from 'node:path';
vi.mock('node:fs', async (orig) => {
const actual = await orig<typeof import('node:fs')>();
return { ...actual, mkdirSync: vi.fn() };
});
const ENV_KEYS = ['CODEMAN_INSTANCE', 'CODEMAN_DATA_DIR'] as const;
const ORIG: Record<string, string | undefined> = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
async function load(env: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>> = {}) {
vi.resetModules();
for (const k of ENV_KEYS) {
const v = env[k];
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
return import('../../src/config/instance.js');
}
afterEach(() => {
for (const k of ENV_KEYS) {
if (ORIG[k] === undefined) delete process.env[k];
else process.env[k] = ORIG[k];
}
vi.resetModules();
});
describe('config/instance', () => {
it('defaults to the production layout when CODEMAN_INSTANCE is unset', async () => {
const m = await load({ CODEMAN_INSTANCE: undefined, CODEMAN_DATA_DIR: undefined });
expect(m.CODEMAN_INSTANCE).toBe('');
expect(m.DEFAULT_TMUX_SOCKET).toBe('codeman');
expect(m.getDataDir()).toBe(join(homedir(), '.codeman'));
expect(m.dataPath('state.json')).toBe(join(homedir(), '.codeman', 'state.json'));
});
it('treats an explicitly-empty CODEMAN_INSTANCE as the production layout', async () => {
const m = await load({ CODEMAN_INSTANCE: '', CODEMAN_DATA_DIR: undefined });
expect(m.CODEMAN_INSTANCE).toBe('');
expect(m.DEFAULT_TMUX_SOCKET).toBe('codeman');
expect(m.getDataDir()).toBe(join(homedir(), '.codeman'));
});
it('scopes BOTH the data dir and the tmux socket for a named instance', async () => {
const m = await load({ CODEMAN_INSTANCE: 'beta', CODEMAN_DATA_DIR: undefined });
expect(m.CODEMAN_INSTANCE).toBe('beta');
expect(m.DEFAULT_TMUX_SOCKET).toBe('codeman-beta');
expect(m.getDataDir()).toBe(join(homedir(), '.codeman-beta'));
expect(m.dataPath('mux-sessions.json')).toBe(join(homedir(), '.codeman-beta', 'mux-sessions.json'));
});
it('supports an arbitrary instance name', async () => {
const m = await load({ CODEMAN_INSTANCE: 'foo', CODEMAN_DATA_DIR: undefined });
expect(m.DEFAULT_TMUX_SOCKET).toBe('codeman-foo');
expect(m.getDataDir()).toBe(join(homedir(), '.codeman-foo'));
});
it('CODEMAN_DATA_DIR overrides the derived data dir (socket still instance-scoped)', async () => {
const m = await load({ CODEMAN_INSTANCE: 'beta', CODEMAN_DATA_DIR: '/tmp/codeman-test-xyz' });
expect(m.getDataDir()).toBe('/tmp/codeman-test-xyz');
expect(m.dataPath('a', 'b')).toBe(join('/tmp/codeman-test-xyz', 'a', 'b'));
// Socket is derived from the instance name, not the data dir override.
expect(m.DEFAULT_TMUX_SOCKET).toBe('codeman-beta');
});
});
+19 -35
View File
@@ -5,16 +5,22 @@ import { PORTS, KEYBOARD, SELECTORS, BODY_CLASSES, WAIT } from './helpers/consta
import { createTestServer, stopTestServer } from './helpers/server.js';
import { createDevicePage, getBrowser, closeAllBrowsers } from './helpers/browser.js';
import {
showKeyboard, hideKeyboard,
showKeyboardViaCDP, hideKeyboardViaCDP,
showKeyboardViaMock, hideKeyboardViaMock,
showKeyboardViaDOM, hideKeyboardViaDOM,
showKeyboard,
hideKeyboard,
showKeyboardViaCDP,
hideKeyboardViaCDP,
showKeyboardViaMock,
hideKeyboardViaMock,
showKeyboardViaDOM,
hideKeyboardViaDOM,
setupViewportMock,
} from './helpers/keyboard-sim.js';
import { getCDP, setVisualViewportHeight } from './helpers/cdp.js';
import {
assertHasClass, assertNotHasClass,
assertVisible, assertHidden,
assertHasClass,
assertNotHasClass,
assertVisible,
assertHidden,
getCSSProperty,
} from './helpers/assertions.js';
import { REPRESENTATIVE_DEVICES } from './devices.js';
@@ -167,9 +173,7 @@ describe('Virtual Keyboard', () => {
const success = await showKeyboardViaMock(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
expect(success).toBe(true);
const hasClass = await page.evaluate(() =>
document.body.classList.contains('keyboard-visible'),
);
const hasClass = await page.evaluate(() => document.body.classList.contains('keyboard-visible'));
expect(hasClass).toBe(true);
} finally {
await context.close();
@@ -280,12 +284,13 @@ describe('Virtual Keyboard', () => {
expect(mainPadding).toBe('');
});
it('accessory bar has 7 action buttons', async () => {
const count = await page.evaluate(() => {
const buttons = document.querySelectorAll('.keyboard-accessory-bar [data-action]');
return buttons.length;
it('accessory bar has the simple-mode action buttons', async () => {
const actions = await page.evaluate(() => {
return Array.from(document.querySelectorAll('.keyboard-accessory-bar [data-action]')).map(
(button) => (button as HTMLElement).dataset.action
);
});
expect(count).toBe(7);
expect(actions).toEqual(['scroll-up', 'scroll-down', 'init', 'clear', 'paste', 'dismiss']);
});
it('double-tap confirm on /clear button', async () => {
@@ -321,27 +326,6 @@ describe('Virtual Keyboard', () => {
expect(text).toBe('Tap again');
});
it('double-tap confirm on /compact button', async () => {
await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
await page.waitForTimeout(WAIT.KEYBOARD_ANIMATION);
await page.evaluate(`
if (typeof app !== 'undefined') app.activeSessionId = 'test-session';
`);
await page.evaluate(() => {
const btn = document.querySelector('[data-action="compact"]') as HTMLElement;
btn?.click();
});
await page.waitForTimeout(100);
const confirming = await page.evaluate(() => {
const btn = document.querySelector('[data-action="compact"]');
return btn?.classList.contains('confirming') ?? false;
});
expect(confirming).toBe(true);
});
it('double-tap expires after 2s', async () => {
await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
await page.waitForTimeout(WAIT.KEYBOARD_ANIMATION);
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { isExplicitlyEnabled, isLoopbackBindHost } from '../src/web/network-auth-policy.js';
describe('network auth policy', () => {
it.each(['localhost', '127.0.0.1', '127.42.0.9', '::1', '[::1]', '0:0:0:0:0:0:0:1', '::ffff:127.0.0.1'])(
'treats %s as loopback',
(host) => {
expect(isLoopbackBindHost(host)).toBe(true);
}
);
it.each(['0.0.0.0', '192.168.1.10', '10.0.0.1', 'example.com', '::', '[::]', '::ffff:192.168.1.10'])(
'treats %s as non-loopback',
(host) => {
expect(isLoopbackBindHost(host)).toBe(false);
}
);
it.each(['1', 'true', 'TRUE', ' yes ', 'on'])('treats %s as an explicit opt-in', (value) => {
expect(isExplicitlyEnabled(value)).toBe(true);
});
it.each([undefined, '', '0', 'false', 'no', 'off', 'enabled'])('does not treat %s as an explicit opt-in', (value) => {
expect(isExplicitlyEnabled(value)).toBe(false);
});
});
+13 -7
View File
@@ -1256,7 +1256,7 @@ describe('Operation Lightspeed', () => {
await Promise.all(ids.map((id) => deleteSession(baseUrl, id)));
});
it('should correctly filter SSE under concurrent session lifecycle', async () => {
it('should broadcast lifecycle events while filtering concurrent session terminal streams', async () => {
// Create 2 sessions
const target = await createSession(baseUrl);
const other = await createSession(baseUrl);
@@ -1309,15 +1309,21 @@ describe('Operation Lightspeed', () => {
const events = parseSSEEvents(receivedData);
// Should see target's rename but not other's events
const targetUpdated = events.find((e) => e.event === 'session:updated' && (e.data as any).id === target);
// session:updated is a lifecycle event broadcast to all clients; the
// subscription filter applies only to high-volume terminal streams.
const updatedEvents = events.filter((e) => e.event === 'session:updated');
const targetUpdated = updatedEvents.find((e) => (e.data as any).id === target);
expect(targetUpdated).toBeDefined();
// Should NOT see other's events
const otherEvents = events.filter(
(e) => ((e.data as any)?.id === other || (e.data as any)?.sessionId === other) && e.event !== 'init'
const otherLifecycleEvents = events.filter(
(e) => e.event !== 'init' && e.event !== 'session:terminal' && (e.data as any)?.id === other
);
expect(otherEvents.length).toBe(0);
expect(otherLifecycleEvents.length).toBeGreaterThan(0);
const otherTerminalEvents = events.filter(
(e) => e.event === 'session:terminal' && (e.data as any)?.sessionId === other
);
expect(otherTerminalEvents.length).toBe(0);
await deleteSession(baseUrl, target);
});
+130 -98
View File
@@ -19,24 +19,24 @@ const BASE_URL = `http://localhost:${PORT}`;
// Thresholds (ms)
const THRESHOLDS = {
PAGE_LOAD: 3000, // Full page load including JS init
DOMContentLoaded: 1500, // HTML parsed
SSE_CONNECT: 2000, // SSE EventSource open
TAB_CREATE_API: 200, // POST /api/sessions response
TAB_RENDER: 300, // Tab element appears in DOM
TAB_SWITCH: 400, // Tab click to active class applied (includes tmux session creation)
TERMINAL_INIT: 500, // xterm.js instance created for tab
INPUT_ROUNDTRIP: 500, // Keystroke sent via API → acknowledged
SETTINGS_OPEN: 300, // Settings modal visible
SETTINGS_CLOSE: 200, // Settings modal hidden
PAGE_LOAD: 3000, // Full page load including JS init
DOMContentLoaded: 1500, // Browser nav timing through deferred script execution
SSE_CONNECT: 2000, // SSE EventSource open
TAB_CREATE_API: 200, // POST /api/sessions response
TAB_RENDER: 300, // Tab element appears in DOM
TAB_SWITCH: 400, // Tab click to active class applied (includes tmux session creation)
TERMINAL_INIT: 500, // xterm.js instance created for tab
INPUT_ROUNDTRIP: 500, // Keystroke sent via API → acknowledged
SETTINGS_OPEN: 300, // Settings modal visible
SETTINGS_CLOSE: 200, // Settings modal hidden
SESSION_OPTIONS_OPEN: 300, // Session options modal visible
SESSION_OPTIONS_TAB: 200, // Modal tab switch
SESSION_OPTIONS_TAB: 200, // Modal tab switch
SUBAGENT_WINDOW_OPEN: 400, // Subagent window rendered
SUBAGENT_WINDOW_CLOSE: 200,
BULK_TAB_CREATE: 3000, // Create 10 sessions
BULK_TAB_SWITCH_AVG: 300, // Average per-tab switch across 10 tabs (includes buffer loads)
MEMORY_HEAP_MB: 200, // Max JS heap after heavy load
BUFFER_LOAD_16KB: 500, // Load a 16KB terminal buffer
BULK_TAB_CREATE: 3000, // Create 10 sessions
BULK_TAB_SWITCH_AVG: 300, // Average per-tab switch across 10 tabs (includes buffer loads)
MEMORY_HEAP_MB: 200, // Max JS heap after heavy load
BUFFER_LOAD_16KB: 500, // Load a 16KB terminal buffer
};
let server: WebServer;
@@ -88,6 +88,25 @@ async function measure(fn: () => Promise<void>): Promise<number> {
return performance.now() - start;
}
type BrowserNavigationTiming = {
domInteractive: number;
domContentLoadedEventEnd: number;
loadEventEnd: number;
};
/** Get the browser's own navigation timing, excluding Playwright harness overhead. */
async function getBrowserNavigationTiming(page: Page): Promise<BrowserNavigationTiming> {
return page.evaluate(() => {
const entry = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined;
if (!entry) throw new Error('Navigation timing entry not available');
return {
domInteractive: entry.domInteractive,
domContentLoadedEventEnd: entry.domContentLoadedEventEnd,
loadEventEnd: entry.loadEventEnd,
};
});
}
/** Get JS heap size in MB (Chromium only) */
async function getHeapMB(page: Page): Promise<number> {
const metrics = await page.evaluate(() => {
@@ -123,12 +142,15 @@ describe('Page load performance', () => {
it('DOMContentLoaded fires within threshold', async () => {
({ context, page } = await freshPage());
const timing = await measure(async () => {
const wallTiming = await measure(async () => {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
});
const navigationTiming = await getBrowserNavigationTiming(page);
console.log(`[page load] DOMContentLoaded: ${timing.toFixed(0)}ms`);
expect(timing).toBeLessThan(THRESHOLDS.DOMContentLoaded);
console.log(
`[page load] DOMContentLoaded: ${navigationTiming.domContentLoadedEventEnd.toFixed(0)}ms (wall ${wallTiming.toFixed(0)}ms)`
);
expect(navigationTiming.domContentLoadedEventEnd).toBeLessThan(THRESHOLDS.DOMContentLoaded);
});
it('full app initialization completes within threshold', async () => {
@@ -157,7 +179,7 @@ describe('Page load performance', () => {
const dot = document.getElementById('connectionDot');
return dot?.classList.contains('connected') || indicator.style.display === 'none';
},
{ timeout: 5000 },
{ timeout: 5000 }
);
});
@@ -225,7 +247,7 @@ describe('Session tab creation', () => {
await page.waitForFunction(
(expected: number) => document.querySelectorAll('.session-tab').length > expected,
tabCountBefore,
{ timeout: 3000 },
{ timeout: 3000 }
);
});
@@ -250,7 +272,7 @@ describe('Session tab creation', () => {
if (!container) return false;
return container.querySelector('.xterm-screen') !== null;
},
{ timeout: 3000 },
{ timeout: 3000 }
);
});
@@ -277,7 +299,7 @@ describe('Tab switching performance', () => {
await page.waitForFunction(
(count: number) => document.querySelectorAll('.session-tab').length >= count,
sessionIds.length,
{ timeout: 5000 },
{ timeout: 5000 }
);
// Select first tab
await page.locator(`.session-tab[data-id="${sessionIds[0]}"]`).click();
@@ -303,7 +325,7 @@ describe('Tab switching performance', () => {
await page.waitForFunction(
(id: string) => document.querySelector(`.session-tab[data-id="${id}"]`)?.classList.contains('active'),
targetId,
{ timeout: 2000 },
{ timeout: 2000 }
);
});
timings.push(timing);
@@ -325,7 +347,7 @@ describe('Tab switching performance', () => {
await page.waitForFunction(
(id: string) => document.querySelector(`.session-tab[data-id="${id}"]`)?.classList.contains('active'),
targetId,
{ timeout: 2000 },
{ timeout: 2000 }
);
});
timings.push(timing);
@@ -362,11 +384,9 @@ describe('Bulk tab operations', () => {
sessionIds.push(id);
}
// Wait for all tabs to render
await page.waitForFunction(
(count: number) => document.querySelectorAll('.session-tab').length >= count,
10,
{ timeout: 5000 },
);
await page.waitForFunction((count: number) => document.querySelectorAll('.session-tab').length >= count, 10, {
timeout: 5000,
});
});
console.log(`[bulk create] 10 sessions: ${timing.toFixed(0)}ms`);
@@ -383,7 +403,7 @@ describe('Bulk tab operations', () => {
await page.waitForFunction(
(id: string) => document.querySelector(`.session-tab[data-id="${id}"]`)?.classList.contains('active'),
targetId,
{ timeout: 2000 },
{ timeout: 2000 }
);
});
timings.push(timing);
@@ -497,7 +517,10 @@ describe('Settings modal performance', () => {
it('closes within threshold', async () => {
// Make sure it's open
const isOpen = await page.locator('#appSettingsModal.active').isVisible().catch(() => false);
const isOpen = await page
.locator('#appSettingsModal.active')
.isVisible()
.catch(() => false);
if (!isOpen) {
await page.locator('.btn-settings').click();
await page.waitForSelector('#appSettingsModal.active', { timeout: 2000 });
@@ -506,10 +529,9 @@ describe('Settings modal performance', () => {
const timing = await measure(async () => {
// Press Escape to close
await page.keyboard.press('Escape');
await page.waitForFunction(
() => !document.querySelector('#appSettingsModal')?.classList.contains('active'),
{ timeout: 2000 },
);
await page.waitForFunction(() => !document.querySelector('#appSettingsModal')?.classList.contains('active'), {
timeout: 2000,
});
});
console.log(`[settings] close: ${timing.toFixed(0)}ms`);
@@ -528,10 +550,9 @@ describe('Settings modal performance', () => {
// Close
const closeTime = await measure(async () => {
await page.keyboard.press('Escape');
await page.waitForFunction(
() => !document.querySelector('#appSettingsModal')?.classList.contains('active'),
{ timeout: 2000 },
);
await page.waitForFunction(() => !document.querySelector('#appSettingsModal')?.classList.contains('active'), {
timeout: 2000,
});
});
timings.push(openTime + closeTime);
}
@@ -575,7 +596,10 @@ describe('Session options modal performance', () => {
it('tab switching within modal is instant', async () => {
// Ensure modal is open
const isOpen = await page.locator('#sessionOptionsModal.active').isVisible().catch(() => false);
const isOpen = await page
.locator('#sessionOptionsModal.active')
.isVisible()
.catch(() => false);
if (!isOpen) {
await page.locator(`.session-tab[data-id="${sessionId}"] .tab-gear`).click();
await page.waitForSelector('#sessionOptionsModal.active', { timeout: 2000 });
@@ -590,7 +614,7 @@ describe('Session options modal performance', () => {
await page.waitForFunction(
(t: string) => document.querySelector(`[data-tab="${t}"]`)?.classList.contains('active'),
tab,
{ timeout: 1000 },
{ timeout: 1000 }
);
});
timings.push(timing);
@@ -634,27 +658,32 @@ describe('Subagent window simulation', () => {
}, sessionId);
const timing = await measure(async () => {
await page.evaluate(({ agentId, cSessionId }: { agentId: string; cSessionId: string }) => {
const app = (window as unknown as {
app: {
subagents: Map<string, Record<string, unknown>>;
openSubagentWindow: (id: string) => void;
}
}).app;
// Inject fake agent data
app.subagents.set(agentId, {
agentId,
sessionId: cSessionId,
status: 'active',
description: 'Performance test agent',
startedAt: Date.now(),
lastActivityAt: Date.now(),
toolCallCount: 0,
entryCount: 0,
fileSize: 0,
});
app.openSubagentWindow(agentId);
}, { agentId: 'perf-agent-1', cSessionId: claudeSessionId });
await page.evaluate(
({ agentId, cSessionId }: { agentId: string; cSessionId: string }) => {
const app = (
window as unknown as {
app: {
subagents: Map<string, Record<string, unknown>>;
openSubagentWindow: (id: string) => void;
};
}
).app;
// Inject fake agent data
app.subagents.set(agentId, {
agentId,
sessionId: cSessionId,
status: 'active',
description: 'Performance test agent',
startedAt: Date.now(),
lastActivityAt: Date.now(),
toolCallCount: 0,
entryCount: 0,
fileSize: 0,
});
app.openSubagentWindow(agentId);
},
{ agentId: 'perf-agent-1', cSessionId: claudeSessionId }
);
await page.waitForSelector('.subagent-window', { timeout: 3000 });
});
@@ -679,7 +708,7 @@ describe('Subagent window simulation', () => {
const el = document.getElementById('subagent-window-perf-agent-1');
return el && el.style.display === 'none';
},
{ timeout: 2000 },
{ timeout: 2000 }
);
});
@@ -698,33 +727,35 @@ describe('Subagent window simulation', () => {
const timing = await measure(async () => {
for (let i = 0; i < 5; i++) {
await page.evaluate(({ idx, cSessionId }: { idx: number; cSessionId: string }) => {
const agentId = `perf-multi-agent-${idx}`;
const app = (window as unknown as {
app: {
subagents: Map<string, Record<string, unknown>>;
openSubagentWindow: (id: string) => void;
}
}).app;
app.subagents.set(agentId, {
agentId,
sessionId: cSessionId,
status: 'active',
description: `Perf agent ${idx}`,
startedAt: Date.now(),
lastActivityAt: Date.now(),
toolCallCount: 0,
entryCount: 0,
fileSize: 0,
});
app.openSubagentWindow(agentId);
}, { idx: i, cSessionId: claudeSessionId });
await page.evaluate(
({ idx, cSessionId }: { idx: number; cSessionId: string }) => {
const agentId = `perf-multi-agent-${idx}`;
const app = (
window as unknown as {
app: {
subagents: Map<string, Record<string, unknown>>;
openSubagentWindow: (id: string) => void;
};
}
).app;
app.subagents.set(agentId, {
agentId,
sessionId: cSessionId,
status: 'active',
description: `Perf agent ${idx}`,
startedAt: Date.now(),
lastActivityAt: Date.now(),
toolCallCount: 0,
entryCount: 0,
fileSize: 0,
});
app.openSubagentWindow(agentId);
},
{ idx: i, cSessionId: claudeSessionId }
);
}
// Wait for all 5
await page.waitForFunction(
() => document.querySelectorAll('.subagent-window').length >= 5,
{ timeout: 5000 },
);
await page.waitForFunction(() => document.querySelectorAll('.subagent-window').length >= 5, { timeout: 5000 });
});
const windowCount = await page.locator('.subagent-window').count();
@@ -772,7 +803,7 @@ describe('SSE event throughput', () => {
await page.waitForFunction(
(count: number) => document.querySelectorAll('.session-tab').length >= count,
sessionIds.length,
{ timeout: 5000 },
{ timeout: 5000 }
);
const elapsed = performance.now() - start;
@@ -797,7 +828,7 @@ describe('SSE event throughput', () => {
await page.waitForFunction(
(count: number) => document.querySelectorAll('.session-tab').length >= count,
sessionIds.length,
{ timeout: 5000 },
{ timeout: 5000 }
);
const start = performance.now();
@@ -813,7 +844,7 @@ describe('SSE event throughput', () => {
}
return true;
},
{ timeout: 5000 },
{ timeout: 5000 }
);
const elapsed = performance.now() - start;
@@ -880,11 +911,9 @@ describe('Memory usage under load', () => {
const id = await createSession(page, `perf-mem-${i}`);
sessionIds.push(id);
}
await page.waitForFunction(
(count: number) => document.querySelectorAll('.session-tab').length >= count,
10,
{ timeout: 5000 },
);
await page.waitForFunction((count: number) => document.querySelectorAll('.session-tab').length >= count, 10, {
timeout: 5000,
});
// Switch through all tabs
for (const id of sessionIds) {
@@ -895,10 +924,13 @@ describe('Memory usage under load', () => {
const heapAfter = await getHeapMB(page);
const heapGrowth = heapAfter - heapBefore;
console.log(`[memory] before: ${heapBefore.toFixed(1)}MB, after: ${heapAfter.toFixed(1)}MB, growth: ${heapGrowth.toFixed(1)}MB`);
console.log(
`[memory] before: ${heapBefore.toFixed(1)}MB, after: ${heapAfter.toFixed(1)}MB, growth: ${heapGrowth.toFixed(1)}MB`
);
// Heap should stay under absolute limit
if (heapAfter > 0) { // memory API may not be available
if (heapAfter > 0) {
// memory API may not be available
expect(heapAfter).toBeLessThan(THRESHOLDS.MEMORY_HEAP_MB);
}
});
+2 -1
View File
@@ -47,7 +47,8 @@ interface PushPayload {
function makeServerWithHost(host: string): WebServer {
// Constructor only assigns fields — no network/disk activity until start().
const server = new WebServer(0, false, true, host);
// 4th arg is the bind host; the title hostname is the 5th arg.
const server = new WebServer(0, false, true, '127.0.0.1', host);
// Stub push store: one subscription with all events enabled.
const fakeSub = {
endpoint: 'https://push.example.com/abc',
+17 -16
View File
@@ -106,7 +106,7 @@ describe('Ralph Integration Tests', () => {
const res = await fetch(`${baseUrl}/api/sessions/non-existent-id`);
const data = await res.json();
expect(res.status).toBe(200);
expect(res.status).toBe(404);
expect(data.success).toBe(false);
expect(data.errorCode).toBe('NOT_FOUND');
});
@@ -136,7 +136,8 @@ describe('Ralph Integration Tests', () => {
// Verify session is gone
const getRes = await fetch(`${baseUrl}/api/sessions/${sessionId}`);
const getData = await getRes.json();
expect(getData.error).toBe('Session not found');
expect(getRes.status).toBe(404);
expect(getData.error).toContain('not found');
});
it('should create shell session', async () => {
@@ -191,7 +192,7 @@ describe('Ralph Integration Tests', () => {
const res = await fetch(`${baseUrl}/api/sessions/fake-session/ralph-state`);
const data = await res.json();
expect(res.status).toBe(200);
expect(res.status).toBe(404);
expect(data.success).toBe(false);
expect(data.error).toContain('not found');
});
@@ -359,7 +360,7 @@ describe('Ralph Integration Tests', () => {
});
const data = await res.json();
expect(res.status).toBe(200);
expect(res.status).toBe(400);
expect(data.success).toBe(false);
expect(data.errorCode).toBe('INVALID_INPUT');
});
@@ -372,7 +373,7 @@ describe('Ralph Integration Tests', () => {
});
const data = await res.json();
expect(res.status).toBe(200);
expect(res.status).toBe(404);
expect(data.success).toBe(false);
expect(data.errorCode).toBe('NOT_FOUND');
});
@@ -395,7 +396,7 @@ describe('Ralph Integration Tests', () => {
createdSessions.push(createData.sessionId);
// Wait for session to be ready
await new Promise(r => setTimeout(r, 200));
await new Promise((r) => setTimeout(r, 200));
const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/resize`, {
method: 'POST',
@@ -422,7 +423,7 @@ describe('Ralph Integration Tests', () => {
createdSessions.push(createData.sessionId);
// Wait for session to be ready
await new Promise(r => setTimeout(r, 200));
await new Promise((r) => setTimeout(r, 200));
const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/resize`, {
method: 'POST',
@@ -431,7 +432,7 @@ describe('Ralph Integration Tests', () => {
});
const data = await res.json();
expect(res.status).toBe(200);
expect(res.status).toBe(400);
expect(data.success).toBe(false);
expect(data.errorCode).toBe('INVALID_INPUT');
});
@@ -472,7 +473,7 @@ describe('Ralph Integration Tests', () => {
});
const data = await res.json();
expect(res.status).toBe(200);
expect(res.status).toBe(404);
expect(data.success).toBe(false);
expect(data.errorCode).toBe('NOT_FOUND');
});
@@ -497,7 +498,7 @@ describe('Ralph Integration Tests', () => {
});
const data = await res.json();
expect(res.status).toBe(200);
expect(res.status).toBe(400);
expect(data.success).toBe(false);
expect(data.errorCode).toBe('INVALID_INPUT');
});
@@ -536,7 +537,7 @@ describe('Ralph Integration Tests', () => {
});
const data = await res.json();
expect(res.status).toBe(200);
expect(res.status).toBe(404);
expect(data.success).toBe(false);
expect(data.errorCode).toBe('NOT_FOUND');
});
@@ -561,7 +562,7 @@ describe('Ralph Integration Tests', () => {
});
const data = await res.json();
expect(res.status).toBe(200);
expect(res.status).toBe(400);
expect(data.success).toBe(false);
expect(data.errorCode).toBe('INVALID_INPUT');
});
@@ -626,7 +627,7 @@ describe('Ralph Integration Tests', () => {
});
const data = await res.json();
expect(res.status).toBe(200);
expect(res.status).toBe(404);
expect(data.success).toBe(false);
expect(data.errorCode).toBe('NOT_FOUND');
});
@@ -907,7 +908,7 @@ describe('Ralph Integration Tests', () => {
});
const data = await res.json();
expect(res.status).toBe(200);
expect(res.status).toBe(404);
expect(data.success).toBe(false);
expect(data.errorCode).toBe('NOT_FOUND');
});
@@ -991,7 +992,7 @@ describe('Ralph Integration Tests', () => {
const res = await fetch(`${baseUrl}/api/sessions/fake-session/output`);
const data = await res.json();
expect(res.status).toBe(200);
expect(res.status).toBe(404);
expect(data.success).toBe(false);
expect(data.errorCode).toBe('NOT_FOUND');
});
@@ -1021,7 +1022,7 @@ describe('Ralph Integration Tests', () => {
const res = await fetch(`${baseUrl}/api/sessions/fake-session/terminal`);
const data = await res.json();
expect(res.status).toBe(200);
expect(res.status).toBe(404);
expect(data.success).toBe(false);
expect(data.errorCode).toBe('NOT_FOUND');
});
+96
View File
@@ -0,0 +1,96 @@
/**
* WebServer.renderIndexHtml — server-side gating of the index shell:
* - multi-monitor button reveal (stable class-marker, not brittle copy match)
* - solo (/session/:id) global injection + escaping, and settings skipped
* - gesture overlay availability vs. enablement (CODEMAN_GESTURE + setting)
* - settings read FRESH so a post-save reload doesn't render stale state
*
* WebServer's constructor only assigns fields (no port bind), so we construct it
* directly, swap in a tiny indexHtmlTemplate, and stub readSettings to avoid disk.
*
* Port: N/A (no server start).
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
import { WebServer } from '../src/web/server.js';
const TEMPLATE = [
'<head>',
'<title>Codeman</title>',
'</head>',
'<body>',
'<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" aria-label="Open Codeman across all displays"></button>',
'</body>',
].join('\n');
function makeServer(settings: Record<string, unknown> = {}) {
const server = new WebServer(0, false, true);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(server as any).indexHtmlTemplate = TEMPLATE;
const readSettings = vi.fn(async () => settings);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(server as any).readSettings = readSettings;
return { server, readSettings };
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const render = (server: WebServer, solo?: string): Promise<string> => (server as any).renderIndexHtml(solo);
const ORIG_GESTURE = process.env.CODEMAN_GESTURE;
afterEach(() => {
if (ORIG_GESTURE === undefined) delete process.env.CODEMAN_GESTURE;
else process.env.CODEMAN_GESTURE = ORIG_GESTURE;
});
describe('WebServer.renderIndexHtml', () => {
it('keeps the multi-monitor button hidden by default and reads settings FRESH', async () => {
const { server, readSettings } = makeServer({});
const html = await render(server);
expect(html).toContain('btn-multimonitor--hidden');
// forceFresh=true — fixes the post-save reload race against the 2s cache.
expect(readSettings).toHaveBeenCalledWith(true);
});
it('reveals the multi-monitor button when showMultiMonitorButton is set', async () => {
const { server } = makeServer({ showMultiMonitorButton: true });
const html = await render(server);
expect(html).not.toContain('btn-multimonitor--hidden');
expect(html).toContain('btn-multimonitor"'); // class list still present, only the marker stripped
});
it('injects the solo global and skips settings for a /session/:id window', async () => {
const { server, readSettings } = makeServer({ showMultiMonitorButton: true });
const html = await render(server, 'sess-123');
expect(html).toContain('window.__CODEMAN_SOLO__="sess-123"');
expect(readSettings).not.toHaveBeenCalled();
// Solo skips settings, so the button is NOT revealed even though the setting is on.
expect(html).toContain('btn-multimonitor--hidden');
});
it('escapes the solo id so it cannot break out of the inline <script>', async () => {
const { server } = makeServer({});
const html = await render(server, 'a</script><b>');
expect(html).not.toContain('</script><b>');
expect(html).toContain('\\u003c');
});
it('exposes gesture availability but injects the bundle only when enabled', async () => {
process.env.CODEMAN_GESTURE = '1';
let { server } = makeServer({ gestureControlEnabled: false });
let html = await render(server);
expect(html).toContain('window.__codemanGestureAvailable=true');
expect(html).not.toContain('gesture-codeman.js');
({ server } = makeServer({ gestureControlEnabled: true }));
html = await render(server);
expect(html).toContain('window.__codemanGestureAvailable=true');
expect(html).toContain('gesture-codeman.js');
});
it('does not expose gesture at all when CODEMAN_GESTURE is unset', async () => {
delete process.env.CODEMAN_GESTURE;
const { server } = makeServer({ gestureControlEnabled: true });
const html = await render(server);
expect(html).not.toContain('__codemanGestureAvailable');
expect(html).not.toContain('gesture-codeman.js');
});
});
+75
View File
@@ -305,6 +305,22 @@ describe('file-routes', () => {
expect(res.headers['content-type']).toBe('image/png');
});
it('serves workspace SVG as an untrusted attachment instead of inline image/svg+xml', async () => {
const content = Buffer.from('<svg><script>alert("xss")</script></svg>');
mockedReadFile.mockResolvedValue(content as never);
mockedStat.mockResolvedValue({ size: content.length } as never);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/file-raw?path=malicious.svg`,
});
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toBe('application/octet-stream');
expect(res.headers['content-disposition']).toContain('attachment; filename="malicious.svg"');
expect(res.headers['x-content-type-options']).toBe('nosniff');
});
it('rejects path traversal in raw file serving', async () => {
mockedRealpathSync.mockReturnValue('/etc/shadow' as never);
@@ -363,4 +379,63 @@ describe('file-routes', () => {
expect(body.success).toBe(false);
});
});
// ========== GET /api/download ==========
describe('GET /api/download', () => {
it('requires a sessionId to scope downloads', async () => {
const res = await harness.app.inject({
method: 'GET',
url: `/api/download?path=${encodeURIComponent('/tmp/test-workdir/report.txt')}`,
});
expect(res.statusCode).toBe(400);
});
it('downloads files scoped to the session working directory', async () => {
const content = Buffer.from('download content');
mockedReadFile.mockResolvedValue(content as never);
mockedStat.mockResolvedValue({ size: content.length, isFile: () => true } as never);
const res = await harness.app.inject({
method: 'GET',
url: `/api/download?sessionId=${harness.ctx._sessionId}&path=report.txt`,
});
expect(res.statusCode).toBe(200);
expect(res.headers['content-disposition']).toContain('filename="report.txt"');
expect(res.body).toBe('download content');
});
it('rejects absolute paths outside the session working directory', async () => {
const res = await harness.app.inject({
method: 'GET',
url: `/api/download?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent('/var/log/app.log')}`,
});
expect(res.statusCode).toBe(404);
});
it('rejects symlink targets that escape the session working directory', async () => {
mockedRealpathSync.mockReturnValue('/tmp/outside-workdir/link.log' as never);
const res = await harness.app.inject({
method: 'GET',
url: `/api/download?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(
'/tmp/test-workdir/link.log'
)}`,
});
expect(res.statusCode).toBe(404);
});
it('blocks sensitive files even when they are inside the session working directory', async () => {
const res = await harness.app.inject({
method: 'GET',
url: `/api/download?sessionId=${harness.ctx._sessionId}&path=.env`,
});
expect(res.statusCode).toBe(403);
});
});
});
+76
View File
@@ -0,0 +1,76 @@
/**
* POST /api/system/span-displays (multi-monitor launcher) + resolveSpanUrl.
*
* The route shells out to scripts/span-codeman.sh, so we mock child_process.spawn
* to avoid actually opening a browser (and to assert the sanitized URL passed to
* it). process.platform is overridden per-case so the macOS-only guard is tested
* deterministically regardless of where the suite runs.
*
* Port: N/A (app.inject).
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
const spawnMock = vi.hoisted(() => vi.fn(() => ({ on: vi.fn(), unref: vi.fn() })));
vi.mock('node:child_process', async (orig) => {
const actual = await orig<typeof import('node:child_process')>();
return { ...actual, spawn: spawnMock };
});
import { createRouteTestHarness } from './_route-test-utils.js';
import { registerSystemRoutes, resolveSpanUrl } from '../../src/web/routes/system-routes.js';
const REAL_PLATFORM = process.platform;
function setPlatform(p: NodeJS.Platform) {
Object.defineProperty(process, 'platform', { value: p, configurable: true });
}
afterEach(() => {
setPlatform(REAL_PLATFORM);
spawnMock.mockClear();
});
describe('resolveSpanUrl', () => {
it('takes a digits-only port from the Host header, pinned to localhost', () => {
expect(resolveSpanUrl('localhost:5000')).toBe('http://localhost:5000');
// Hostname is discarded — always localhost (same machine).
expect(resolveSpanUrl('attacker.example.com:3000')).toBe('http://localhost:3000');
});
it('falls back to the default port for missing / non-numeric ports', () => {
expect(resolveSpanUrl(undefined)).toBe('http://localhost:3000');
expect(resolveSpanUrl('localhost')).toBe('http://localhost:3000');
expect(resolveSpanUrl('localhost:99;rm -rf /')).toBe('http://localhost:3000');
expect(resolveSpanUrl('localhost:80abc')).toBe('http://localhost:3000');
expect(resolveSpanUrl('x', '5000')).toBe('http://localhost:5000');
});
});
describe('POST /api/system/span-displays', () => {
it('returns 400 (macOS-only) on non-darwin and never spawns', async () => {
setPlatform('linux');
const { app } = await createRouteTestHarness(registerSystemRoutes);
const res = await app.inject({ method: 'POST', url: '/api/system/span-displays' });
expect(res.statusCode).toBe(400);
expect(res.json().success).toBe(false);
expect(res.json().error).toMatch(/macOS/i);
expect(spawnMock).not.toHaveBeenCalled();
await app.close();
});
it('spawns the launcher with the sanitized localhost URL on darwin', async () => {
setPlatform('darwin');
const { app } = await createRouteTestHarness(registerSystemRoutes);
const res = await app.inject({
method: 'POST',
url: '/api/system/span-displays',
headers: { host: 'localhost:5000' },
});
expect(res.statusCode).toBe(200);
expect(res.json()).toMatchObject({ success: true, url: 'http://localhost:5000' });
expect(spawnMock).toHaveBeenCalledTimes(1);
const [cmd, args] = spawnMock.mock.calls[0] as [string, string[]];
expect(cmd).toBe('bash');
expect(args[0]).toMatch(/span-codeman\.sh$/);
expect(args[1]).toBe('http://localhost:5000');
await app.close();
});
});
+24 -20
View File
@@ -32,36 +32,38 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const indexHtmlPath = join(__dirname, '..', 'src', 'web', 'public', 'index.html');
const rawTemplate = readFileSync(indexHtmlPath, 'utf-8');
function render(host?: string): string {
const server = new WebServer(0, false, true, host);
return (server as unknown as { renderIndexHtml: () => string }).renderIndexHtml();
async function render(host?: string): Promise<string> {
// 4th arg is the bind host; the title hostname is the 5th arg.
const server = new WebServer(0, false, true, '127.0.0.1', host);
// renderIndexHtml is async (it reads settings.json for the gesture bundle).
return (server as unknown as { renderIndexHtml: () => Promise<string> }).renderIndexHtml();
}
describe('WebServer index.html <title> templating (#82)', () => {
it('substitutes the bare <title>Codeman</title> with codeman:<host>', () => {
const html = render('laptop');
it('substitutes the bare <title>Codeman</title> with codeman:<host>', async () => {
const html = await render('laptop');
expect(html).toContain('<title>codeman:laptop</title>');
expect(html).not.toContain('<title>Codeman</title>');
});
it('defaults to os.hostname() when no titleHostname is supplied', () => {
const html = render();
it('defaults to os.hostname() when no titleHostname is supplied', async () => {
const html = await render();
const expected = `<title>codeman:${osHostname()}</title>`;
expect(html).toContain(expected);
});
it('treats an empty-string titleHostname as "not supplied" and falls back to os.hostname()', () => {
it('treats an empty-string titleHostname as "not supplied" and falls back to os.hostname()', async () => {
// CLI normally guarantees a non-empty string, but the constructor's
// `titleHostname || getHostname()` guard makes empty fall through —
// pin that behavior so a future refactor doesn't accidentally ship
// a `<title>codeman:</title>` to users.
const html = render('');
const html = await render('');
expect(html).toMatch(/<title>codeman:.+<\/title>/);
expect(html).not.toContain('<title>codeman:</title>');
});
it('HTML-escapes < > & in the hostname so it cannot break out of the title tag', () => {
const html = render('<script>alert(1)</script>');
it('HTML-escapes < > & in the hostname so it cannot break out of the title tag', async () => {
const html = await render('<script>alert(1)</script>');
expect(html).toContain('<title>codeman:&lt;script&gt;alert(1)&lt;/script&gt;</title>');
// The raw closing </title> from the injected payload must NOT appear
// outside the actual title element — escape-then-substitute prevents
@@ -69,16 +71,18 @@ describe('WebServer index.html <title> templating (#82)', () => {
expect(html).not.toContain('<script>alert(1)</script></title>');
});
it('escapes an ampersand without double-encoding existing entities', () => {
it('escapes an ampersand without double-encoding existing entities', async () => {
// The escaper replaces & first, then < and >. A hostname that already
// contains a literal `&` should render as `&amp;` once, not `&amp;amp;`.
const html = render('a&b');
const html = await render('a&b');
expect(html).toContain('<title>codeman:a&amp;b</title>');
expect(html).not.toContain('&amp;amp;');
});
it('only substitutes the <title> tag — the rest of the template is byte-for-byte identical', () => {
const html = render('laptop');
it('only substitutes the <title> tag — the rest of the template is identical (modulo asset cache-busting)', async () => {
// renderIndexHtml also appends ?v=<mtime> cache-bust params to same-origin
// .js/.css refs; strip them so the title remains the only other change.
const html = (await render('laptop')).replace(/(\.(?:js|css))\?v=[^"]*/g, '$1');
const beforeTitle = rawTemplate.split('<title>Codeman</title>')[0];
const afterTitle = rawTemplate.split('<title>Codeman</title>')[1];
expect(html.startsWith(beforeTitle)).toBe(true);
@@ -88,8 +92,8 @@ describe('WebServer index.html <title> templating (#82)', () => {
expect(html.length - rawTemplate.length).toBe(expectedDelta);
});
it('replaces the <title> placeholder exactly once', () => {
const html = render('laptop');
it('replaces the <title> placeholder exactly once', async () => {
const html = await render('laptop');
// Defense against a future regression where the template gains a
// second `<title>Codeman</title>` (e.g. inside a <noscript>) and only
// the first gets templated — would leave a stale literal in the served
@@ -100,9 +104,9 @@ describe('WebServer index.html <title> templating (#82)', () => {
expect(occurrencesOfOld).toBe(0);
});
it('two WebServer instances on different hostnames render distinct titles', () => {
const htmlA = render('host-a');
const htmlB = render('host-b');
it('two WebServer instances on different hostnames render distinct titles', async () => {
const htmlA = await render('host-a');
const htmlB = await render('host-b');
expect(htmlA).toContain('<title>codeman:host-a</title>');
expect(htmlB).toContain('<title>codeman:host-b</title>');
expect(htmlA).not.toContain('host-b');
+5 -1
View File
@@ -6,11 +6,15 @@
* This means tests CANNOT kill, create, or interact with real tmux
* sessions regardless of what the test code does.
*
* This setup file only handles mock/timer cleanup between tests.
* This setup file strips shell-level auth configuration that can leak from a
* running Codeman instance, then handles mock/timer cleanup between tests.
*/
import { afterEach, vi } from 'vitest';
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_USERNAME;
afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers();