Merge pull request #103 from Ark0N/beta/session-detach

feat(web): session detach/undock + beta instance isolation (port 5000)
This commit is contained in:
Ark0N
2026-06-08 16:36:46 +02:00
committed by GitHub
26 changed files with 5751 additions and 47 deletions
+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');
});
});
+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();
});
});