mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Merge master into PR #157 (session manager polish)
Resolutions (sse-events.ts / constants.js / app.js): unions of the docker/ multi-user event registrations from master with the session-order/pin events from this branch. Additions on top of the merge: - POST /api/sessions/:id/pin now falls back to the persisted store record when no live session exists: COD-142 deliberately preserves pinned records after kill (and cleanupStaleSessions skips them), so without this a pinned-then- killed session could never be unpinned. Owner-scoped in multi-user mode. - SessionOrderUpdateSchema bounds (id <= 100 chars, <= 500 entries) so a buggy client can't persist megabytes into state.json; empty strings still flow to normalizeSessionOrder which drops them. - Route tests for the persisted-record pin fallback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* @fileoverview Phase 5 admin API tests (live server, port 3173).
|
||||
*
|
||||
* Covers the admin user-management endpoints: multi-user gate, requireAdmin,
|
||||
* create (one-time password), patch + last-admin invariant, reset-password,
|
||||
* disable-revokes-sessions, and delete (last-admin refusal + delete-space).
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import { createUser, invalidateUsersCache } from '../src/user-store.js';
|
||||
|
||||
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
|
||||
|
||||
const PORT = 3173;
|
||||
const basic = (u: string, p: string) => 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64');
|
||||
const url = (p: string) => `http://localhost:${PORT}${p}`;
|
||||
const admin = { Authorization: basic('root', 'rootpass123'), 'Content-Type': 'application/json' };
|
||||
const adminNoBody = { Authorization: basic('root', 'rootpass123') };
|
||||
const regular = { Authorization: basic('joe', 'joepass1234'), 'Content-Type': 'application/json' };
|
||||
|
||||
let server: WebServer;
|
||||
let dataDir: string;
|
||||
let spacesDir: string;
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'admin-data-'));
|
||||
spacesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'admin-spaces-'));
|
||||
for (const k of [
|
||||
'CODEMAN_DATA_DIR',
|
||||
'CODEMAN_USER_SPACES_DIR',
|
||||
'CODEMAN_MULTIUSER',
|
||||
'CODEMAN_PASSWORD',
|
||||
'CODEMAN_USERNAME',
|
||||
]) {
|
||||
saved[k] = process.env[k];
|
||||
}
|
||||
process.env.CODEMAN_DATA_DIR = dataDir;
|
||||
process.env.CODEMAN_USER_SPACES_DIR = spacesDir;
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
delete process.env.CODEMAN_PASSWORD;
|
||||
delete process.env.CODEMAN_USERNAME;
|
||||
invalidateUsersCache();
|
||||
await createUser({ username: 'root', role: 'admin', password: 'rootpass123' });
|
||||
await createUser({ username: 'joe', role: 'user', password: 'joepass1234' });
|
||||
server = new WebServer(PORT, false, true);
|
||||
await server.start();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.stop();
|
||||
for (const [k, v] of Object.entries(saved)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
invalidateUsersCache();
|
||||
await fs.rm(dataDir, { recursive: true, force: true }).catch(() => {});
|
||||
await fs.rm(spacesDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
describe('admin API', () => {
|
||||
it('rejects a non-admin (403)', async () => {
|
||||
const res = await fetch(url('/api/admin/users'), { headers: regular });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('lists users for an admin', async () => {
|
||||
const res = await fetch(url('/api/admin/users'), { headers: admin });
|
||||
expect(res.status).toBe(200);
|
||||
const { data } = await res.json();
|
||||
expect(data.map((u: { username: string }) => u.username).sort()).toEqual(['joe', 'root']);
|
||||
expect(data[0]).not.toHaveProperty('password');
|
||||
});
|
||||
|
||||
it('creates a user with a one-time password', async () => {
|
||||
const res = await fetch(url('/api/admin/users'), {
|
||||
method: 'POST',
|
||||
headers: admin,
|
||||
body: JSON.stringify({ username: 'newbie', role: 'user' }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const { data } = await res.json();
|
||||
expect(data.oneTimePassword).toBeTypeOf('string');
|
||||
expect(data.user).toMatchObject({ username: 'newbie', mustChangePassword: true });
|
||||
});
|
||||
|
||||
it('toggles canBypassPermissions via PATCH', async () => {
|
||||
const res = await fetch(url('/api/admin/users/joe'), {
|
||||
method: 'PATCH',
|
||||
headers: admin,
|
||||
body: JSON.stringify({ canBypassPermissions: true }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).data.user.canBypassPermissions).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses to demote the last admin (409)', async () => {
|
||||
const res = await fetch(url('/api/admin/users/root'), {
|
||||
method: 'PATCH',
|
||||
headers: admin,
|
||||
body: JSON.stringify({ role: 'user' }),
|
||||
});
|
||||
expect(res.status).toBe(409);
|
||||
expect((await res.json()).errorCode).toBe('LAST_ADMIN');
|
||||
});
|
||||
|
||||
it('resets a password (one-time) and forces change', async () => {
|
||||
const res = await fetch(url('/api/admin/users/joe/reset-password'), { method: 'POST', headers: adminNoBody });
|
||||
expect(res.status).toBe(200);
|
||||
const { data } = await res.json();
|
||||
expect(data.oneTimePassword).toBeTypeOf('string');
|
||||
// joe must now change password before other actions.
|
||||
const gated = await fetch(url('/api/status'), { headers: { Authorization: basic('joe', data.oneTimePassword) } });
|
||||
expect(gated.status).toBe(403);
|
||||
expect((await gated.json()).errorCode).toBe('PASSWORD_CHANGE_REQUIRED');
|
||||
});
|
||||
|
||||
it('refuses to delete the last admin, deletes a regular user + space', async () => {
|
||||
const del = await fetch(url('/api/admin/users/root'), { method: 'DELETE', headers: adminNoBody });
|
||||
expect(del.status).toBe(409);
|
||||
|
||||
await fs.mkdir(path.join(spacesDir, 'newbie', 'cases'), { recursive: true });
|
||||
const del2 = await fetch(url('/api/admin/users/newbie'), {
|
||||
method: 'DELETE',
|
||||
headers: admin,
|
||||
body: JSON.stringify({ deleteSpace: true }),
|
||||
});
|
||||
expect(del2.status).toBe(200);
|
||||
await expect(fs.stat(path.join(spacesDir, 'newbie'))).rejects.toBeTruthy();
|
||||
});
|
||||
|
||||
it('404s admin routes in single-user mode', async () => {
|
||||
// Flip the flag off for one request path check.
|
||||
process.env.CODEMAN_MULTIUSER = '';
|
||||
try {
|
||||
const res = await fetch(url('/api/admin/users'), { headers: admin });
|
||||
expect(res.status).toBe(404);
|
||||
} finally {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @fileoverview Frontend test for admin-ui.js (multi-user identity boot + admin
|
||||
* Users tab + change-password modal). Builds a JSDOM window in-test under the
|
||||
* default node env (constructing the DOM in-test avoids the vitest environment
|
||||
* comment-directive gotcha) and evaluates the real module against it.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
const ADMIN_UI = readFileSync(new URL('../src/web/public/admin-ui.js', import.meta.url), 'utf-8');
|
||||
const INDEX_HTML = readFileSync(new URL('../src/web/public/index.html', import.meta.url), 'utf-8');
|
||||
|
||||
function resp(status: number, body: unknown) {
|
||||
const r = {
|
||||
status,
|
||||
ok: status >= 200 && status < 300,
|
||||
json: async () => body,
|
||||
clone() {
|
||||
return r;
|
||||
},
|
||||
};
|
||||
return r;
|
||||
}
|
||||
|
||||
async function bootWith(me: Record<string, unknown>) {
|
||||
const dom = new JSDOM(
|
||||
`<!doctype html><body>
|
||||
<div class="modal" id="appSettingsModal"><div class="modal-tabs"></div><div class="modal-body"></div></div>
|
||||
</body>`,
|
||||
{ url: 'http://localhost/', runScripts: 'outside-only' }
|
||||
);
|
||||
const win = dom.window as unknown as Window & typeof globalThis & { __codemanUser?: Record<string, unknown> };
|
||||
win.fetch = (async (path: string) => {
|
||||
if (path === '/api/me') return resp(200, { success: true, data: me });
|
||||
if (path === '/api/admin/users') return resp(200, { success: true, data: [] });
|
||||
return resp(200, { success: true });
|
||||
}) as unknown as typeof fetch;
|
||||
(win as unknown as { eval: (s: string) => void }).eval(ADMIN_UI);
|
||||
// Let the async boot() (fetch /api/me → DOM inject) settle.
|
||||
for (let i = 0; i < 4; i++) await new Promise((r) => setTimeout(r, 0));
|
||||
return { dom, win };
|
||||
}
|
||||
|
||||
describe('admin-ui boot', () => {
|
||||
it('exposes the identity and injects the Users tab for a multi-user admin', async () => {
|
||||
const { win } = await bootWith({ username: 'root', role: 'admin', multiUser: true, mustChangePassword: false });
|
||||
expect(win.__codemanUser).toMatchObject({ username: 'root', role: 'admin', multiUser: true });
|
||||
const btn = win.document.querySelector('[data-tab="settings-users"]');
|
||||
expect(btn).toBeTruthy();
|
||||
expect(win.document.getElementById('settings-users')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does NOT inject the Users tab for a regular user', async () => {
|
||||
const { win } = await bootWith({ username: 'joe', role: 'user', multiUser: true, mustChangePassword: false });
|
||||
expect(win.document.querySelector('[data-tab="settings-users"]')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('does NOT inject the Users tab in single-user mode', async () => {
|
||||
const { win } = await bootWith({ username: 'admin', role: 'admin', multiUser: false, mustChangePassword: false });
|
||||
expect(win.document.querySelector('[data-tab="settings-users"]')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('shows the change-password modal when mustChangePassword is set', async () => {
|
||||
const { win } = await bootWith({ username: 'dave', role: 'user', multiUser: true, mustChangePassword: true });
|
||||
const modal = win.document.getElementById('changePasswordModal') as HTMLElement | null;
|
||||
expect(modal).toBeTruthy();
|
||||
expect(modal!.style.display).toBe('flex');
|
||||
// Forced: the cancel button is hidden.
|
||||
expect((modal!.querySelector('#cpCancel') as HTMLElement).style.display).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('index.html wiring', () => {
|
||||
it('loads admin-ui.js after settings-ui.js and before session-ui.js', () => {
|
||||
const settings = INDEX_HTML.indexOf('settings-ui.js');
|
||||
const admin = INDEX_HTML.indexOf('admin-ui.js');
|
||||
const session = INDEX_HTML.indexOf('session-ui.js');
|
||||
expect(admin).toBeGreaterThan(settings);
|
||||
expect(session).toBeGreaterThan(admin);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @fileoverview Tests for Claude CLI startup permission modes, focused on the
|
||||
* 'auto' mode (`--permission-mode auto`, Anthropic's recommended low-prompt mode)
|
||||
* added alongside the default `--dangerously-skip-permissions`.
|
||||
*
|
||||
* Covers BOTH spawn paths, which build the permission flags independently:
|
||||
* - session-cli-builder.buildInteractiveArgs (direct PTY, non-mux fallback)
|
||||
* - tmux-manager.buildSpawnCommand (tmux pane command string)
|
||||
* The default must stay 'dangerously-skip-permissions' when the setting is unset.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildInteractiveArgs } from '../src/session-cli-builder.js';
|
||||
import { buildSpawnCommand } from '../src/tmux-manager.js';
|
||||
|
||||
describe('buildInteractiveArgs permission modes (direct PTY path)', () => {
|
||||
it('keeps --dangerously-skip-permissions as the skip-mode flag', () => {
|
||||
const args = buildInteractiveArgs('sid-1', 'dangerously-skip-permissions');
|
||||
expect(args).toContain('--dangerously-skip-permissions');
|
||||
expect(args).not.toContain('--permission-mode');
|
||||
});
|
||||
|
||||
it('auto mode emits --permission-mode auto and never the skip flag', () => {
|
||||
const args = buildInteractiveArgs('sid-1', 'auto');
|
||||
const idx = args.indexOf('--permission-mode');
|
||||
expect(idx).toBeGreaterThanOrEqual(0);
|
||||
expect(args[idx + 1]).toBe('auto');
|
||||
expect(args).not.toContain('--dangerously-skip-permissions');
|
||||
});
|
||||
|
||||
it('normal mode emits no permission flag at all', () => {
|
||||
const args = buildInteractiveArgs('sid-1', 'normal');
|
||||
expect(args).not.toContain('--dangerously-skip-permissions');
|
||||
expect(args).not.toContain('--permission-mode');
|
||||
});
|
||||
|
||||
it('allowedTools mode is unchanged by the auto addition', () => {
|
||||
const args = buildInteractiveArgs('sid-1', 'allowedTools', undefined, 'Read,Grep');
|
||||
expect(args).toEqual(expect.arrayContaining(['--allowedTools', 'Read,Grep']));
|
||||
expect(args).not.toContain('--permission-mode');
|
||||
});
|
||||
|
||||
it('auto mode composes with model and effort flags', () => {
|
||||
const args = buildInteractiveArgs('sid-1', 'auto', 'opus', undefined, 'high');
|
||||
expect(args).toEqual(expect.arrayContaining(['--permission-mode', 'auto', '--model', 'opus', '--effort', 'high']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSpawnCommand permission modes (tmux path)', () => {
|
||||
it('unset claudeMode defaults to --dangerously-skip-permissions', () => {
|
||||
const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1' });
|
||||
expect(cmd).toContain('claude --dangerously-skip-permissions --session-id "sid-1"');
|
||||
expect(cmd).not.toContain('--permission-mode');
|
||||
});
|
||||
|
||||
it('auto mode emits --permission-mode auto and never the skip flag', () => {
|
||||
const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1', claudeMode: 'auto' });
|
||||
expect(cmd).toContain('claude --permission-mode auto --session-id "sid-1"');
|
||||
expect(cmd).not.toContain('--dangerously-skip-permissions');
|
||||
});
|
||||
|
||||
it('auto mode carries into BOTH legs of the resume fallback command', () => {
|
||||
const cmd = buildSpawnCommand({
|
||||
mode: 'claude',
|
||||
sessionId: 'sid-1',
|
||||
claudeMode: 'auto',
|
||||
resumeSessionId: 'abc-123',
|
||||
});
|
||||
const [resumeLeg, fallbackLeg] = cmd.split('||');
|
||||
expect(resumeLeg).toContain('--permission-mode auto');
|
||||
expect(resumeLeg).toContain('--resume "abc-123"');
|
||||
expect(fallbackLeg).toContain('--permission-mode auto');
|
||||
expect(fallbackLeg).toContain('--session-id "sid-1"');
|
||||
expect(cmd).not.toContain('--dangerously-skip-permissions');
|
||||
});
|
||||
|
||||
it('normal mode emits no permission flag', () => {
|
||||
const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1', claudeMode: 'normal' });
|
||||
expect(cmd).toContain('claude --session-id "sid-1"');
|
||||
expect(cmd).not.toContain('--permission-mode');
|
||||
expect(cmd).not.toContain('--dangerously-skip-permissions');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Unit tests for the docker launch/kill command builders in tmux-manager.ts
|
||||
* (mirror of test/remote-ssh-options.test.ts). Pure string assertions: the
|
||||
* escaping must survive bash -c -> docker exec -> sh -lc -> tmux.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buildDockerLaunchCommand,
|
||||
buildDockerKillCommand,
|
||||
buildDockerStopCommand,
|
||||
buildDockerRemoveCommand,
|
||||
dockerTmuxSessionName,
|
||||
type DockerLaunchOptions,
|
||||
} from '../src/tmux-manager.js';
|
||||
import { DEFAULT_AGENT_IMAGE, toSessionDocker, type DockerCreateContext } from '../src/docker-hosts.js';
|
||||
import type { DockerCase, DockerHost, SessionMode } from '../src/types.js';
|
||||
|
||||
// The exact adopt-guard the in-container Codeman would use to discover its own sessions.
|
||||
const SAFE_MUX_NAME_PATTERN = /^codeman-[a-f0-9-]+$/;
|
||||
|
||||
const HOST: DockerHost = { id: 'local', label: 'Local', image: DEFAULT_AGENT_IMAGE };
|
||||
const CASE: DockerCase = {
|
||||
name: 'myproj',
|
||||
type: 'docker',
|
||||
hostId: 'local',
|
||||
hostWorkspacePath: '/home/arkon/cases/myproj',
|
||||
};
|
||||
|
||||
function launchOpts(overrides: Partial<DockerLaunchOptions> = {}): DockerLaunchOptions {
|
||||
const docker = overrides.docker ?? toSessionDocker(HOST, CASE);
|
||||
const createContext: DockerCreateContext = {
|
||||
docker,
|
||||
sessionId: '1a2b3c4d5e6f',
|
||||
instance: '',
|
||||
userArgs: ['--user', '1000:0'],
|
||||
credentialMounts: [{ src: '/home/arkon/.claude', dst: '/home/agent/.claude' }],
|
||||
extraMounts: [],
|
||||
envCreate: { HOME: '/home/agent', CODEMAN_API_URL: 'https://host.docker.internal:3000' },
|
||||
addHostGateway: true,
|
||||
gatewayAlias: 'host.docker.internal',
|
||||
};
|
||||
return {
|
||||
mode: 'claude',
|
||||
docker,
|
||||
sessionId: '1a2b3c4d5e6f',
|
||||
createContext,
|
||||
execEnv: { TERM: 'xterm-256color', CODEMAN_SESSION_ID: '1a2b3c4d', CODEMAN_MUX: '1' },
|
||||
execEnvNames: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('dockerTmuxSessionName', () => {
|
||||
it('is stable from the first 8 chars of the sessionId', () => {
|
||||
expect(dockerTmuxSessionName('1a2b3c4d5e6f')).toBe('codeman-dkr-1a2b3c4d');
|
||||
});
|
||||
it('deliberately FAILS the in-container adopt guard', () => {
|
||||
// 'k'/'r' are not hex, so an in-container Codeman never adopts our session
|
||||
expect(SAFE_MUX_NAME_PATTERN.test(dockerTmuxSessionName('1a2b3c4d5e6f'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDockerLaunchCommand', () => {
|
||||
it('image-check precedes ensure precedes start precedes exec', () => {
|
||||
const cmd = buildDockerLaunchCommand(launchOpts());
|
||||
const iImage = cmd.indexOf('docker image inspect');
|
||||
const iEnsure = cmd.indexOf('docker inspect');
|
||||
const iStart = cmd.indexOf('docker start');
|
||||
const iExec = cmd.indexOf('exec docker exec -it');
|
||||
expect(iImage).toBeGreaterThanOrEqual(0);
|
||||
expect(iImage).toBeLessThan(iEnsure);
|
||||
expect(iEnsure).toBeLessThan(iStart);
|
||||
expect(iStart).toBeLessThan(iExec);
|
||||
});
|
||||
|
||||
it('ensures the container idempotently (inspect-or-create) with --pull=never', () => {
|
||||
const cmd = buildDockerLaunchCommand(launchOpts());
|
||||
expect(cmd).toContain("docker inspect 'codeman-case-myproj' >/dev/null 2>&1 || docker create");
|
||||
expect(cmd).toContain('--pull=never');
|
||||
expect(cmd).toContain("docker start 'codeman-case-myproj'");
|
||||
});
|
||||
|
||||
it('execs a TTY into the durable in-container tmux', () => {
|
||||
const cmd = buildDockerLaunchCommand(launchOpts());
|
||||
expect(cmd).toContain("exec docker exec -it --workdir '/home/arkon/cases/myproj'");
|
||||
expect(cmd).toContain('tmux -L codeman-docker setenv -g CODEMAN_SESSION_ID');
|
||||
expect(cmd).toContain('new-session -A -s codeman-dkr-1a2b3c4d');
|
||||
expect(cmd).toContain("sh -lc '");
|
||||
});
|
||||
|
||||
it('pins a deterministic conversation id with a reboot-surviving fallback (fresh launch)', () => {
|
||||
const cmd = buildDockerLaunchCommand(launchOpts());
|
||||
// --session-id first (fresh start), || --resume so a container stop/reboot
|
||||
// relaunch of the SAME session resumes instead of dead-paning on
|
||||
// "Session ID already in use".
|
||||
expect(cmd).toContain(
|
||||
'claude --dangerously-skip-permissions --session-id 1a2b3c4d5e6f || ' +
|
||||
'claude --dangerously-skip-permissions --resume 1a2b3c4d5e6f'
|
||||
);
|
||||
// exec is stripped from the claude pane command — an exec'd first branch could never fall back.
|
||||
expect(cmd).not.toContain('exec claude');
|
||||
});
|
||||
|
||||
it('resumes an explicit id with a --session-id fallback (stale id never dead-panes)', () => {
|
||||
const withResume = buildDockerLaunchCommand(launchOpts({ resumeSessionId: 'abc-123-def' }));
|
||||
expect(withResume).toContain(
|
||||
'claude --dangerously-skip-permissions --resume abc-123-def || ' +
|
||||
'claude --dangerously-skip-permissions --session-id 1a2b3c4d5e6f'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses codex resume syntax and drops an unsafe resume id', () => {
|
||||
const codex = buildDockerLaunchCommand(
|
||||
launchOpts({ mode: 'codex' as SessionMode, resumeSessionId: '01H-codex-id' })
|
||||
);
|
||||
expect(codex).toContain('exec codex resume 01H-codex-id');
|
||||
const unsafe = buildDockerLaunchCommand(launchOpts({ resumeSessionId: 'x; rm -rf /' }));
|
||||
expect(unsafe).not.toContain('x; rm'); // unsafe id dropped entirely
|
||||
expect(unsafe).not.toContain('rm -rf');
|
||||
// falls back to the deterministic fresh-launch chain on the session's own id
|
||||
expect(unsafe).toContain('--session-id 1a2b3c4d5e6f');
|
||||
});
|
||||
|
||||
it('forwards codex/gemini keys NAME-ONLY (no value in argv)', () => {
|
||||
const codex = buildDockerLaunchCommand(
|
||||
launchOpts({ mode: 'codex' as SessionMode, execEnvNames: ['OPENAI_API_KEY', 'CODEX_API_KEY'] })
|
||||
);
|
||||
expect(codex).toContain('--env OPENAI_API_KEY');
|
||||
expect(codex).not.toMatch(/--env OPENAI_API_KEY=/); // never a value
|
||||
});
|
||||
|
||||
it('primes CODEMAN_SESSION_ID / CODEMAN_MUX at exec time', () => {
|
||||
const cmd = buildDockerLaunchCommand(launchOpts());
|
||||
expect(cmd).toContain("--env 'CODEMAN_SESSION_ID=1a2b3c4d'");
|
||||
expect(cmd).toContain("--env 'CODEMAN_MUX=1'");
|
||||
});
|
||||
|
||||
it('keeps a workspace path with spaces a single token through every layer', () => {
|
||||
const docker = toSessionDocker(HOST, { ...CASE, hostWorkspacePath: '/home/arkon/my cases/proj' });
|
||||
const cmd = buildDockerLaunchCommand(launchOpts({ docker }));
|
||||
// workdir single-quoted at the docker exec layer
|
||||
expect(cmd).toContain("--workdir '/home/arkon/my cases/proj'");
|
||||
// and the cd inside the (nested-escaped) paneCommand still references the spaced path
|
||||
expect(cmd).toContain('/home/arkon/my cases/proj');
|
||||
});
|
||||
|
||||
it('honors a per-host command override (exec stripped for the session-id chain)', () => {
|
||||
const docker = { ...toSessionDocker(HOST, CASE), commands: { claude: 'exec claude --model opus' } };
|
||||
const cmd = buildDockerLaunchCommand(launchOpts({ docker }));
|
||||
expect(cmd).toContain('claude --model opus --session-id 1a2b3c4d5e6f');
|
||||
const shellOverride = { ...toSessionDocker(HOST, CASE), commands: { shell: 'exec zsh -l' } };
|
||||
const shellCmd = buildDockerLaunchCommand(launchOpts({ mode: 'shell' as SessionMode, docker: shellOverride }));
|
||||
expect(shellCmd).toContain('exec zsh -l'); // non-claude overrides keep their exec
|
||||
});
|
||||
|
||||
it('seeds writable config (guarded copies, mkdir -p parent) from the read-only seed mounts', () => {
|
||||
const cmd = buildDockerLaunchCommand(
|
||||
launchOpts({
|
||||
seedCopies: [
|
||||
{ from: '/home/agent/.codeman/claude.seed.json', to: '/home/agent/.claude.json' },
|
||||
{ from: '/home/agent/.codeman/claude-creds.seed.json', to: '/home/agent/.claude/.credentials.json' },
|
||||
// whole-dir credential seed → cp -a
|
||||
{ from: '/home/agent/.codeman/cred-seeds/.gemini', to: '/home/agent/.gemini', recursive: true },
|
||||
],
|
||||
})
|
||||
);
|
||||
// Each copy mkdir -p's its parent then is guarded so a reconnect never clobbers config.
|
||||
expect(cmd).toContain(
|
||||
'mkdir -p /home/agent 2>/dev/null; [ -e /home/agent/.claude.json ] || cp /home/agent/.codeman/claude.seed.json /home/agent/.claude.json'
|
||||
);
|
||||
expect(cmd).toContain(
|
||||
'mkdir -p /home/agent/.claude 2>/dev/null; [ -e /home/agent/.claude/.credentials.json ] || cp /home/agent/.codeman/claude-creds.seed.json /home/agent/.claude/.credentials.json'
|
||||
);
|
||||
// recursive whole-dir seed uses cp -a
|
||||
expect(cmd).toContain(
|
||||
'[ -e /home/agent/.gemini ] || cp -a /home/agent/.codeman/cred-seeds/.gemini /home/agent/.gemini'
|
||||
);
|
||||
expect(cmd.indexOf('.claude.json')).toBeLessThan(cmd.indexOf('tmux -L codeman-docker'));
|
||||
});
|
||||
|
||||
it('omits the seed-copy step when there are no seedCopies', () => {
|
||||
const cmd = buildDockerLaunchCommand(launchOpts());
|
||||
expect(cmd).not.toContain('claude.seed.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDockerKillCommand (multi-session safe)', () => {
|
||||
it('kills ONLY this session in-container tmux, never the shared container', () => {
|
||||
const docker = toSessionDocker(HOST, CASE);
|
||||
const cmd = buildDockerKillCommand({ docker, sessionId: '1a2b3c4d5e6f' });
|
||||
expect(cmd).toBe("docker exec 'codeman-case-myproj' tmux -L codeman-docker kill-session -t 'codeman-dkr-1a2b3c4d'");
|
||||
expect(cmd).not.toContain('docker stop');
|
||||
expect(cmd).not.toContain('docker rm');
|
||||
});
|
||||
});
|
||||
|
||||
describe('explicit teardown commands', () => {
|
||||
it('stop and remove target the whole container', () => {
|
||||
const docker = toSessionDocker(HOST, CASE);
|
||||
expect(buildDockerStopCommand(docker)).toBe("docker stop -t 10 'codeman-case-myproj'");
|
||||
expect(buildDockerRemoveCommand(docker)).toBe("docker rm -f 'codeman-case-myproj'");
|
||||
});
|
||||
|
||||
it('uses the podman engine prefix when configured', () => {
|
||||
const docker = toSessionDocker({ ...HOST, engine: 'podman' }, CASE);
|
||||
expect(buildDockerStopCommand(docker)).toBe("podman stop -t 10 'codeman-case-myproj'");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Unit tests for the pure docker export/import helpers (src/docker-export.ts).
|
||||
* The IO paths no-op under VITEST; these cover the naming, tar-traversal guard,
|
||||
* load-output parsing, and the sealed-mode refusal.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
dockerArgv,
|
||||
exportBundleName,
|
||||
exportImageTag,
|
||||
importedImageTag,
|
||||
isSafeTarMember,
|
||||
parseLoadedImageRef,
|
||||
exportDockerCase,
|
||||
validateImportManifest,
|
||||
DOCKER_EXPORT_SCHEMA,
|
||||
type DockerExportManifest,
|
||||
} from '../src/docker-export.js';
|
||||
import { toSessionDocker } from '../src/docker-hosts.js';
|
||||
import type { DockerCase, DockerHost } from '../src/types.js';
|
||||
|
||||
const HOST: DockerHost = { id: 'local', label: 'Local', image: 'codeman/agent:base' };
|
||||
const CASE: DockerCase = {
|
||||
name: 'myproj',
|
||||
type: 'docker',
|
||||
hostId: 'local',
|
||||
hostWorkspacePath: '/home/arkon/cases/myproj',
|
||||
};
|
||||
|
||||
describe('dockerArgv', () => {
|
||||
it('is raw (unescaped) argv for spawn', () => {
|
||||
expect(dockerArgv({ engine: 'docker' })).toEqual(['docker']);
|
||||
expect(dockerArgv({ engine: 'podman', context: 'ctx', daemonHost: 'ssh://h' })).toEqual([
|
||||
'podman',
|
||||
'--context',
|
||||
'ctx',
|
||||
'-H',
|
||||
'ssh://h',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bundle / tag naming', () => {
|
||||
it('names bundles by case + timestamp + mode', () => {
|
||||
expect(exportBundleName('myproj', 1234, 'full')).toBe('myproj-1234.codeman-container.tgz');
|
||||
expect(exportBundleName('myproj', 1234, 'workspace')).toBe('myproj-1234.codeman-workspace.tgz');
|
||||
});
|
||||
it('quarantines imported images and tags export intermediates uniquely', () => {
|
||||
expect(importedImageTag('myproj', 99)).toBe('codeman/imported-myproj:99');
|
||||
expect(exportImageTag('myproj', 99)).toBe('codeman/export-myproj:99');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSafeTarMember (import traversal guard)', () => {
|
||||
it('accepts normal relative members', () => {
|
||||
expect(isSafeTarMember('./')).toBe(true);
|
||||
expect(isSafeTarMember('src/index.ts')).toBe(true);
|
||||
expect(isSafeTarMember('./a/b/c.txt')).toBe(true);
|
||||
});
|
||||
it('rejects absolute and parent-escaping members', () => {
|
||||
expect(isSafeTarMember('/etc/passwd')).toBe(false);
|
||||
expect(isSafeTarMember('../outside')).toBe(false);
|
||||
expect(isSafeTarMember('a/../../b')).toBe(false);
|
||||
expect(isSafeTarMember('./../../x')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateImportManifest (untrusted cross-machine input)', () => {
|
||||
const good = (): DockerExportManifest => ({
|
||||
schemaVersion: DOCKER_EXPORT_SCHEMA,
|
||||
caseName: 'myproj',
|
||||
mode: 'full',
|
||||
engine: 'docker',
|
||||
image: 'codeman/agent:base',
|
||||
containerWorkdir: '/home/arkon/cases/myproj',
|
||||
network: 'bridge',
|
||||
createdAt: 1,
|
||||
codemanVersion: '1.4.1',
|
||||
mountCredentials: true,
|
||||
secretFree: true,
|
||||
checksums: {},
|
||||
});
|
||||
|
||||
it('accepts a well-formed manifest', () => {
|
||||
expect(() => validateImportManifest(good())).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a hostile engine (would select the probe/launch binary)', () => {
|
||||
expect(() => validateImportManifest({ ...good(), engine: 'rm' as never })).toThrow(/engine/);
|
||||
});
|
||||
|
||||
it('rejects shell metacharacters in containerWorkdir', () => {
|
||||
expect(() => validateImportManifest({ ...good(), containerWorkdir: '/w; rm -rf ~' })).toThrow(/containerWorkdir/);
|
||||
expect(() => validateImportManifest({ ...good(), containerWorkdir: 'relative/path' })).toThrow(/containerWorkdir/);
|
||||
});
|
||||
|
||||
it('rejects bad image refs, case names, networks, and schema versions', () => {
|
||||
expect(() => validateImportManifest({ ...good(), image: '-bad$(x)' })).toThrow(/image/);
|
||||
expect(() => validateImportManifest({ ...good(), caseName: '../evil' })).toThrow(/caseName/);
|
||||
expect(() => validateImportManifest({ ...good(), network: 'host' })).toThrow(/network/);
|
||||
expect(() => validateImportManifest({ ...good(), schemaVersion: 99 })).toThrow(/schema version/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseLoadedImageRef', () => {
|
||||
it('parses "Loaded image ID: sha256:..."', () => {
|
||||
expect(parseLoadedImageRef('Loaded image ID: sha256:abc123def')).toBe('sha256:abc123def');
|
||||
});
|
||||
it('parses "Loaded image: repo:tag"', () => {
|
||||
expect(parseLoadedImageRef('Loaded image: codeman/export-x:1234')).toBe('codeman/export-x:1234');
|
||||
});
|
||||
it('returns null on unrecognized output', () => {
|
||||
expect(parseLoadedImageRef('some other text')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('exportDockerCase (VITEST stub)', () => {
|
||||
it('returns a deterministic stub manifest without touching docker', async () => {
|
||||
const docker = toSessionDocker(HOST, CASE);
|
||||
const res = await exportDockerCase({
|
||||
docker,
|
||||
caseName: 'myproj',
|
||||
timestamp: 42,
|
||||
exportsDir: '/tmp/exports',
|
||||
mode: 'full',
|
||||
codemanVersion: '9.9.9',
|
||||
});
|
||||
expect(res.manifest.schemaVersion).toBe(DOCKER_EXPORT_SCHEMA);
|
||||
expect(res.manifest.caseName).toBe('myproj');
|
||||
expect(res.manifest.mode).toBe('full');
|
||||
expect(res.bundlePath).toBe('/tmp/exports/myproj-42.codeman-container.tgz');
|
||||
});
|
||||
|
||||
it('refuses a full-image export for a sealed container', async () => {
|
||||
const docker = toSessionDocker({ ...HOST, mountCredentials: false }, CASE);
|
||||
await expect(
|
||||
exportDockerCase({
|
||||
docker,
|
||||
caseName: 'myproj',
|
||||
timestamp: 42,
|
||||
exportsDir: '/tmp/exports',
|
||||
mode: 'full',
|
||||
codemanVersion: '9.9.9',
|
||||
})
|
||||
).rejects.toThrow(/sealed/);
|
||||
});
|
||||
|
||||
it('allows a workspace-only export for a sealed container', async () => {
|
||||
const docker = toSessionDocker({ ...HOST, mountCredentials: false }, CASE);
|
||||
const res = await exportDockerCase({
|
||||
docker,
|
||||
caseName: 'myproj',
|
||||
timestamp: 42,
|
||||
exportsDir: '/tmp/exports',
|
||||
mode: 'workspace',
|
||||
codemanVersion: '9.9.9',
|
||||
});
|
||||
expect(res.manifest.mode).toBe('workspace');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,465 @@
|
||||
/**
|
||||
* Unit tests for the Docker cases storage + pure command-arg builders + probes
|
||||
* (src/docker-hosts.ts). Mirrors test/remote-hosts.test.ts. All docker IO no-ops
|
||||
* under VITEST, so probes return canned values and never spawn a real daemon.
|
||||
*/
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
agentImageBuildArgs,
|
||||
buildDockerBaseArgs,
|
||||
buildDockerCreateArgs,
|
||||
buildSeamlessClaudeConfig,
|
||||
checkDockerAvailable,
|
||||
checkDockerImagePresent,
|
||||
checkDockerTmuxAvailable,
|
||||
CLAUDE_JSON_SEED,
|
||||
containerApiUrl,
|
||||
DEFAULT_AGENT_IMAGE,
|
||||
DEFAULT_DOCKER_RESOURCES,
|
||||
dockerConfigHash,
|
||||
dockerContainerName,
|
||||
dockerDisplayPath,
|
||||
defaultDockerCommandForMode,
|
||||
ensureAgentBaseImage,
|
||||
hostGatewayAlias,
|
||||
persistDockerCaseClaudeSessionId,
|
||||
probeDockerCliVersion,
|
||||
readDockerCases,
|
||||
readDockerHosts,
|
||||
resolveClaudeJsonSeedMount,
|
||||
resolveDockerClaudeArtifacts,
|
||||
resolveDockerCredentialArtifacts,
|
||||
toSessionDocker,
|
||||
writeDockerCases,
|
||||
writeDockerHosts,
|
||||
type DockerCreateContext,
|
||||
} from '../src/docker-hosts.js';
|
||||
import type { DockerCase, DockerHost, SessionDocker } from '../src/types.js';
|
||||
|
||||
const HOST: DockerHost = { id: 'local', label: 'Local Docker', image: DEFAULT_AGENT_IMAGE };
|
||||
const CASE: DockerCase = {
|
||||
name: 'myproj',
|
||||
type: 'docker',
|
||||
hostId: 'local',
|
||||
hostWorkspacePath: '/home/arkon/cases/myproj',
|
||||
};
|
||||
|
||||
describe('docker-hosts storage', () => {
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'codeman-docker-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('round-trips hosts and cases through JSON storage', async () => {
|
||||
await writeDockerHosts(dir, [HOST]);
|
||||
await writeDockerCases(dir, [{ ...CASE, lastClaudeSessionId: 'abc-123' }]);
|
||||
expect(await readDockerHosts(dir)).toEqual([HOST]);
|
||||
const cases = await readDockerCases(dir);
|
||||
expect(cases[0].lastClaudeSessionId).toBe('abc-123');
|
||||
});
|
||||
|
||||
it('returns [] for a missing file', async () => {
|
||||
expect(await readDockerHosts(dir)).toEqual([]);
|
||||
expect(await readDockerCases(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it('persists the last Claude conversation id keyed by container name', async () => {
|
||||
await writeDockerCases(dir, [CASE, { ...CASE, name: 'other', container: 'custom-name' }]);
|
||||
await persistDockerCaseClaudeSessionId(dir, dockerContainerName(CASE.name), 'conv-1');
|
||||
await persistDockerCaseClaudeSessionId(dir, 'custom-name', 'conv-2');
|
||||
await persistDockerCaseClaudeSessionId(dir, 'no-such-container', 'conv-3'); // no-op
|
||||
const cases = await readDockerCases(dir);
|
||||
expect(cases.find((c) => c.name === 'myproj')?.lastClaudeSessionId).toBe('conv-1');
|
||||
expect(cases.find((c) => c.name === 'other')?.lastClaudeSessionId).toBe('conv-2');
|
||||
expect(cases.some((c) => c.lastClaudeSessionId === 'conv-3')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('naming / display / defaults', () => {
|
||||
it('derives a valid per-case container name', () => {
|
||||
expect(dockerContainerName('myproj')).toBe('codeman-case-myproj');
|
||||
// valid docker name charset: starts alnum, then [a-zA-Z0-9_.-]
|
||||
expect(dockerContainerName('my_proj-2')).toMatch(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/);
|
||||
});
|
||||
|
||||
it('maps each mode to a default pane command', () => {
|
||||
expect(defaultDockerCommandForMode('claude')).toBe('exec claude --dangerously-skip-permissions');
|
||||
expect(defaultDockerCommandForMode('shell')).toBe('exec bash -l');
|
||||
expect(defaultDockerCommandForMode('codex')).toBe('exec codex');
|
||||
expect(defaultDockerCommandForMode('gemini')).toBe('exec gemini');
|
||||
});
|
||||
|
||||
it('formats a container:workdir display path from both shapes', () => {
|
||||
expect(dockerDisplayPath({ container: 'codeman-case-x', path: '/w' })).toBe('codeman-case-x:/w');
|
||||
const sd = toSessionDocker(HOST, CASE);
|
||||
expect(dockerDisplayPath(sd)).toBe('codeman-case-myproj:/home/arkon/cases/myproj');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hostGatewayAlias / containerApiUrl', () => {
|
||||
it('returns the engine-specific gateway alias', () => {
|
||||
expect(hostGatewayAlias('docker')).toBe('host.docker.internal');
|
||||
expect(hostGatewayAlias('podman')).toBe('host.containers.internal');
|
||||
});
|
||||
|
||||
it('swaps only the hostname, preserving scheme and port', () => {
|
||||
expect(containerApiUrl('https://127.0.0.1:3000', 'docker')).toBe('https://host.docker.internal:3000');
|
||||
expect(containerApiUrl('http://127.0.0.1:3000', 'docker')).toBe('http://host.docker.internal:3000');
|
||||
expect(containerApiUrl('https://127.0.0.1:8443', 'docker')).toBe('https://host.docker.internal:8443');
|
||||
expect(containerApiUrl('https://127.0.0.1:3000', 'podman')).toBe('https://host.containers.internal:3000');
|
||||
});
|
||||
|
||||
it('falls back to https://<alias>:3000 for absent or unparseable input', () => {
|
||||
expect(containerApiUrl(undefined, 'docker')).toBe('https://host.docker.internal:3000');
|
||||
expect(containerApiUrl('not a url', 'podman')).toBe('https://host.containers.internal:3000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toSessionDocker / dockerConfigHash', () => {
|
||||
it('resolves every default (convenient, bridge, resume-on-start)', () => {
|
||||
const sd = toSessionDocker(HOST, CASE);
|
||||
expect(sd.engine).toBe('docker');
|
||||
expect(sd.image).toBe(DEFAULT_AGENT_IMAGE);
|
||||
expect(sd.containerName).toBe('codeman-case-myproj');
|
||||
expect(sd.hostWorkspacePath).toBe('/home/arkon/cases/myproj');
|
||||
expect(sd.containerWorkdir).toBe('/home/arkon/cases/myproj'); // mirror
|
||||
expect(sd.network).toBe('bridge');
|
||||
expect(sd.resources).toEqual(DEFAULT_DOCKER_RESOURCES);
|
||||
expect(sd.mountCredentials).toBe(true);
|
||||
expect(sd.hooksEnabled).toBe(true);
|
||||
expect(sd.resumeOnStart).toBe(true);
|
||||
expect(sd.configHash).toMatch(/^[0-9a-f]{12}$/);
|
||||
});
|
||||
|
||||
it('honors host overrides and a custom container workdir', () => {
|
||||
const host: DockerHost = {
|
||||
...HOST,
|
||||
engine: 'podman',
|
||||
network: 'none',
|
||||
mountCredentials: false,
|
||||
hooksEnabled: false,
|
||||
resumeOnStart: false,
|
||||
};
|
||||
const sd = toSessionDocker(host, { ...CASE, containerWorkdir: '/work', container: 'my-box' });
|
||||
expect(sd.engine).toBe('podman');
|
||||
expect(sd.network).toBe('none');
|
||||
expect(sd.mountCredentials).toBe(false);
|
||||
expect(sd.containerName).toBe('my-box');
|
||||
expect(sd.containerWorkdir).toBe('/work');
|
||||
});
|
||||
|
||||
it('hash is stable for equal inputs and changes when a drift field changes', () => {
|
||||
const a = toSessionDocker(HOST, CASE);
|
||||
const b = toSessionDocker(HOST, CASE);
|
||||
expect(a.configHash).toBe(b.configHash);
|
||||
const c = toSessionDocker({ ...HOST, image: 'codeman/agent:other' }, CASE);
|
||||
expect(c.configHash).not.toBe(a.configHash);
|
||||
// lastClaudeSessionId is NOT a drift field
|
||||
expect(dockerConfigHash(a)).toBe(dockerConfigHash({ ...a }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDockerBaseArgs', () => {
|
||||
it('defaults to docker with no extra flags', () => {
|
||||
expect(buildDockerBaseArgs({ engine: 'docker' })).toEqual(['docker']);
|
||||
});
|
||||
it('emits podman + context + daemon host', () => {
|
||||
const args = buildDockerBaseArgs({ engine: 'podman', context: 'remote', daemonHost: 'ssh://u@h' });
|
||||
expect(args[0]).toBe('podman');
|
||||
expect(args.join(' ')).toContain("--context 'remote'");
|
||||
expect(args.join(' ')).toContain("-H 'ssh://u@h'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDockerCreateArgs', () => {
|
||||
function ctx(overrides: Partial<DockerCreateContext> = {}): DockerCreateContext {
|
||||
return {
|
||||
docker: toSessionDocker(HOST, CASE),
|
||||
sessionId: '1a2b3c4d5e6f',
|
||||
instance: '',
|
||||
userArgs: ['--user', '1000:0'],
|
||||
credentialMounts: [{ src: '/home/arkon/.claude', dst: '/home/agent/.claude' }],
|
||||
extraMounts: [
|
||||
{ src: '/home/arkon/.codeman/hook-secret', dst: '/home/agent/.codeman/hook-secret', readonly: true },
|
||||
],
|
||||
envCreate: { HOME: '/home/agent', CODEMAN_API_URL: 'https://host.docker.internal:3000' },
|
||||
addHostGateway: true,
|
||||
gatewayAlias: 'host.docker.internal',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('bakes in the security + lifecycle invariants', () => {
|
||||
const s = buildDockerCreateArgs(ctx()).join(' ');
|
||||
expect(s).toContain('--cap-drop ALL');
|
||||
expect(s).toContain('--security-opt no-new-privileges');
|
||||
expect(s).toContain('--pull=never');
|
||||
expect(s).toContain('--init');
|
||||
expect(s).toContain('--restart no');
|
||||
expect(s).toContain('--memory 4g --memory-swap 4g');
|
||||
expect(s).toContain('--pids-limit 512');
|
||||
expect(s).toContain('--ulimit nofile=4096:8192');
|
||||
expect(s).toContain('codeman.managed=1');
|
||||
expect(s).toContain("'codeman.session=1a2b3c4d'"); // first 8 chars only
|
||||
expect(s).toContain('--network bridge');
|
||||
});
|
||||
|
||||
it('NEVER emits privileged mode or a docker-socket mount', () => {
|
||||
const s = buildDockerCreateArgs(ctx()).join(' ');
|
||||
expect(s).not.toContain('--privileged');
|
||||
expect(s).not.toContain('docker.sock');
|
||||
});
|
||||
|
||||
it('ends with the image then the sleep-infinity CMD', () => {
|
||||
const args = buildDockerCreateArgs(ctx());
|
||||
expect(args.slice(-3)).toEqual([`'${DEFAULT_AGENT_IMAGE}'`, 'sleep', 'infinity']);
|
||||
});
|
||||
|
||||
it('includes the resolved user args and the workspace bind', () => {
|
||||
const s = buildDockerCreateArgs(ctx()).join(' ');
|
||||
expect(s).toContain('--user 1000:0');
|
||||
expect(s).toContain("--mount 'type=bind,src=/home/arkon/cases/myproj,dst=/home/arkon/cases/myproj'");
|
||||
});
|
||||
|
||||
it('shell-escapes a workspace path containing spaces into a single token', () => {
|
||||
const docker = toSessionDocker(HOST, { ...CASE, hostWorkspacePath: '/home/arkon/my cases/proj' });
|
||||
const args = buildDockerCreateArgs(ctx({ docker }));
|
||||
// the whole mount spec (with the space) is ONE single-quoted token
|
||||
expect(args).toContain("'type=bind,src=/home/arkon/my cases/proj,dst=/home/arkon/my cases/proj'");
|
||||
// and the workdir is single-quoted too
|
||||
expect(args).toContain("'/home/arkon/my cases/proj'");
|
||||
});
|
||||
|
||||
it('adds the host-gateway only when requested', () => {
|
||||
expect(buildDockerCreateArgs(ctx({ addHostGateway: true })).join(' ')).toContain(
|
||||
'--add-host host.docker.internal:host-gateway'
|
||||
);
|
||||
expect(buildDockerCreateArgs(ctx({ addHostGateway: false })).join(' ')).not.toContain('--add-host');
|
||||
});
|
||||
|
||||
it('omits credential mounts in sealed mode', () => {
|
||||
const s = buildDockerCreateArgs(ctx({ credentialMounts: [] })).join(' ');
|
||||
expect(s).not.toContain('/home/agent/.claude');
|
||||
});
|
||||
|
||||
it('emits create-time env flags', () => {
|
||||
const s = buildDockerCreateArgs(ctx()).join(' ');
|
||||
expect(s).toContain("--env 'HOME=/home/agent'");
|
||||
expect(s).toContain("--env 'CODEMAN_API_URL=https://host.docker.internal:3000'");
|
||||
});
|
||||
|
||||
it('uses the custom network name for custom mode', () => {
|
||||
const docker: SessionDocker = { ...toSessionDocker(HOST, CASE), network: 'custom', networkName: 'codeman-net-x' };
|
||||
expect(buildDockerCreateArgs(ctx({ docker })).join(' ')).toContain('--network codeman-net-x');
|
||||
});
|
||||
|
||||
it('emits --gpus only when GPUs are requested (and never a storage cap)', () => {
|
||||
const withGpu: SessionDocker = { ...toSessionDocker(HOST, CASE), gpus: 'all' };
|
||||
const s = buildDockerCreateArgs(ctx({ docker: withGpu })).join(' ');
|
||||
expect(s).toContain("--gpus 'all'");
|
||||
// elastic disk: no fixed storage cap is ever emitted
|
||||
expect(s).not.toContain('--storage-opt');
|
||||
expect(buildDockerCreateArgs(ctx()).join(' ')).not.toContain('--gpus');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDockerCredentialArtifacts (isolated codex/gemini/gcloud/opencode)', () => {
|
||||
let home: string;
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'codeman-home-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('NEVER whole-dir RW-mounts a credential store (no host pollution)', () => {
|
||||
mkdirSync(join(home, '.codex'), { recursive: true });
|
||||
mkdirSync(join(home, '.gemini'), { recursive: true });
|
||||
const { mounts } = resolveDockerCredentialArtifacts(home);
|
||||
// no wholesale RW mount at the store's HOME path
|
||||
expect(mounts.some((m) => m.dst === '/home/agent/.codex' && !m.readonly)).toBe(false);
|
||||
expect(mounts.some((m) => m.dst === '/home/agent/.gemini' && !m.readonly)).toBe(false);
|
||||
});
|
||||
|
||||
it('codex: shares sessions/+history.jsonl RW, seeds auth.json/config.toml', () => {
|
||||
mkdirSync(join(home, '.codex', 'sessions'), { recursive: true });
|
||||
writeFileSync(join(home, '.codex', 'history.jsonl'), '');
|
||||
writeFileSync(join(home, '.codex', 'auth.json'), '{}');
|
||||
writeFileSync(join(home, '.codex', 'config.toml'), '');
|
||||
const { mounts, seedCopies } = resolveDockerCredentialArtifacts(home);
|
||||
expect(mounts).toContainEqual({ src: join(home, '.codex', 'sessions'), dst: '/home/agent/.codex/sessions' });
|
||||
expect(mounts).toContainEqual({
|
||||
src: join(home, '.codex', 'history.jsonl'),
|
||||
dst: '/home/agent/.codex/history.jsonl',
|
||||
});
|
||||
const dests = seedCopies.map((s) => s.to);
|
||||
expect(dests).toContain('/home/agent/.codex/auth.json');
|
||||
expect(dests).toContain('/home/agent/.codex/config.toml');
|
||||
// seed copies of individual files are NOT recursive
|
||||
expect(seedCopies.filter((s) => s.to.startsWith('/home/agent/.codex')).every((s) => !s.recursive)).toBe(true);
|
||||
});
|
||||
|
||||
it('gemini/gcloud/opencode: whole-dir seed-copy (cp -a, recursive)', () => {
|
||||
mkdirSync(join(home, '.gemini'), { recursive: true });
|
||||
mkdirSync(join(home, '.config', 'gcloud'), { recursive: true });
|
||||
mkdirSync(join(home, '.config', 'opencode'), { recursive: true });
|
||||
const { mounts, seedCopies } = resolveDockerCredentialArtifacts(home);
|
||||
// each is mounted read-only at a seed staging path and cp -a'd into the container HOME
|
||||
expect(seedCopies).toContainEqual({
|
||||
from: '/home/agent/.codeman/cred-seeds/.gemini',
|
||||
to: '/home/agent/.gemini',
|
||||
recursive: true,
|
||||
});
|
||||
expect(seedCopies).toContainEqual({
|
||||
from: '/home/agent/.codeman/cred-seeds/.config-gcloud',
|
||||
to: '/home/agent/.config/gcloud',
|
||||
recursive: true,
|
||||
});
|
||||
expect(mounts.filter((m) => m.readonly && m.dst.includes('cred-seeds')).length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('gates every artifact on existsSync (absent stores contribute nothing)', () => {
|
||||
const { mounts, seedCopies } = resolveDockerCredentialArtifacts(home);
|
||||
expect(mounts).toEqual([]);
|
||||
expect(seedCopies).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDockerClaudeArtifacts (isolated claude state)', () => {
|
||||
let home: string;
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'codeman-home-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('shares only projects (RW) and seeds .claude.json + credentials + settings + stats-cache', () => {
|
||||
mkdirSync(join(home, '.claude', 'projects'), { recursive: true });
|
||||
writeFileSync(join(home, '.claude.json'), JSON.stringify({ oauthAccount: { id: 1 } }));
|
||||
writeFileSync(join(home, '.claude', '.credentials.json'), '{"claudeAiOauth":{}}');
|
||||
writeFileSync(join(home, '.claude', 'settings.json'), JSON.stringify({ theme: 'dark' }));
|
||||
writeFileSync(join(home, '.claude', 'stats-cache.json'), '{}');
|
||||
|
||||
const art = resolveDockerClaudeArtifacts(home, 'codeman-case-x', '/ws/x');
|
||||
// transcripts shared RW (no readonly), whole ~/.claude never mounted
|
||||
expect(art.mounts).toContainEqual({ src: join(home, '.claude', 'projects'), dst: '/home/agent/.claude/projects' });
|
||||
expect(art.mounts.some((m) => m.dst === '/home/agent/.claude')).toBe(false);
|
||||
// credentials + settings + stats-cache + .claude.json seeded (copied into the container's own HOME)
|
||||
const dests = art.seedCopies.map((s) => s.to);
|
||||
expect(dests).toContain('/home/agent/.claude.json');
|
||||
expect(dests).toContain('/home/agent/.claude/.credentials.json');
|
||||
expect(dests).toContain('/home/agent/.claude/settings.json');
|
||||
expect(dests).toContain('/home/agent/.claude/stats-cache.json'); // restores the model/effort status indicator
|
||||
// the seed mounts are read-only
|
||||
expect(art.mounts.filter((m) => m.readonly).length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('omits mounts/seeds for artifacts that do not exist', () => {
|
||||
const art = resolveDockerClaudeArtifacts(home, 'codeman-case-y', '/ws/y');
|
||||
expect(art.mounts).toEqual([]);
|
||||
expect(art.seedCopies).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveClaudeJsonSeedMount', () => {
|
||||
let home: string;
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'codeman-home-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns a read-only seed mount when ~/.claude.json exists', () => {
|
||||
writeFileSync(join(home, '.claude.json'), '{}');
|
||||
expect(resolveClaudeJsonSeedMount(home)).toEqual({
|
||||
src: join(home, '.claude.json'),
|
||||
dst: CLAUDE_JSON_SEED,
|
||||
readonly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when ~/.claude.json is absent', () => {
|
||||
expect(resolveClaudeJsonSeedMount(home)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSeamlessClaudeConfig', () => {
|
||||
it('forces onboarding-complete + theme + workspace trust while preserving host fields', () => {
|
||||
const merged = buildSeamlessClaudeConfig({ oauthAccount: { id: 1 }, projects: {} }, '/ws/proj');
|
||||
expect(merged.hasCompletedOnboarding).toBe(true);
|
||||
expect(merged.theme).toBe('dark');
|
||||
expect(merged.oauthAccount).toEqual({ id: 1 }); // host auth account preserved
|
||||
const proj = (merged.projects as Record<string, Record<string, unknown>>)['/ws/proj'];
|
||||
expect(proj.hasTrustDialogAccepted).toBe(true);
|
||||
expect(proj.hasCompletedProjectOnboarding).toBe(true);
|
||||
expect(proj.projectOnboardingSeenCount).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps an existing theme and merges into an existing project entry', () => {
|
||||
const merged = buildSeamlessClaudeConfig(
|
||||
{ theme: 'light', projects: { '/ws/proj': { allowedTools: ['a'], projectOnboardingSeenCount: 5 } } },
|
||||
'/ws/proj',
|
||||
'dark'
|
||||
);
|
||||
expect(merged.theme).toBe('light'); // not overwritten when already set
|
||||
const proj = (merged.projects as Record<string, Record<string, unknown>>)['/ws/proj'];
|
||||
expect(proj.allowedTools).toEqual(['a']); // existing project fields kept
|
||||
expect(proj.hasTrustDialogAccepted).toBe(true);
|
||||
expect(proj.projectOnboardingSeenCount).toBe(5); // preserved, not reset to 1
|
||||
});
|
||||
});
|
||||
|
||||
describe('agentImageBuildArgs', () => {
|
||||
it('builds the docker build argv in order', () => {
|
||||
expect(agentImageBuildArgs('/repo/docker/agent.Dockerfile', 'codeman/agent:base', '/repo')).toEqual([
|
||||
'build',
|
||||
'-f',
|
||||
'/repo/docker/agent.Dockerfile',
|
||||
'-t',
|
||||
'codeman/agent:base',
|
||||
'/repo',
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds --no-cache before the context dir when requested', () => {
|
||||
const args = agentImageBuildArgs('/df', 'img', '/ctx', true);
|
||||
expect(args).toContain('--no-cache');
|
||||
expect(args.indexOf('--no-cache')).toBeLessThan(args.indexOf('/ctx'));
|
||||
expect(args[args.length - 1]).toBe('/ctx');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureAgentBaseImage (no-op under VITEST)', () => {
|
||||
it('reports the image as already present without spawning a build', async () => {
|
||||
const r = await ensureAgentBaseImage({ engine: 'docker' }, DEFAULT_AGENT_IMAGE);
|
||||
expect(r).toEqual({ ok: true, built: false, alreadyPresent: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('daemon probes (no-op under VITEST)', () => {
|
||||
it('checkDockerAvailable returns a canned available result', async () => {
|
||||
const a = await checkDockerAvailable();
|
||||
expect(a.ok).toBe(true);
|
||||
expect(a.capsEnforced).toBe(true);
|
||||
expect(a.engine).toBe('docker');
|
||||
});
|
||||
|
||||
it('checkDockerTmuxAvailable + image present are canned-true', async () => {
|
||||
expect((await checkDockerTmuxAvailable({ engine: 'docker', image: DEFAULT_AGENT_IMAGE })).ok).toBe(true);
|
||||
expect(await checkDockerImagePresent({ engine: 'docker' }, DEFAULT_AGENT_IMAGE)).toBe(true);
|
||||
});
|
||||
|
||||
it('probeDockerCliVersion is undefined under test', async () => {
|
||||
expect(
|
||||
await probeDockerCliVersion({ engine: 'docker', containerName: 'codeman-case-x' }, 'claude')
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -221,7 +221,10 @@ describe('Edge Cases and Error Handling', () => {
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.error).toBe('Respawn controller not found');
|
||||
// respawn/stop now owner-gates via findSessionOrFail first (multi-user #18), so a
|
||||
// non-existent session id 404s as "Session ... not found" (same not-found semantics,
|
||||
// matching the sibling start/config/enable handlers).
|
||||
expect(data.error).toContain('not found');
|
||||
});
|
||||
|
||||
it('should handle updating config on non-existent session', async () => {
|
||||
|
||||
@@ -38,7 +38,7 @@ const MOBILE_VISIBLE_ALLOWLIST = new Set<string>([]);
|
||||
// that removes a hide rule fails loudly (not silently). The attachments button is
|
||||
// NOT here: it's opt-in (default-hidden everywhere via its own --hidden marker), so
|
||||
// it's excluded from the default-visible enumeration rather than mobile-hidden.
|
||||
const KNOWN_PHONE_HIDDEN = ['btn-settings', 'btn-lifecycle-log', 'btn-session-manager'];
|
||||
const KNOWN_PHONE_HIDDEN = ['btn-settings', 'btn-lifecycle-log', 'btn-session-manager', 'btn-file-viewer'];
|
||||
|
||||
function attrOf(openTag: string, name: string): string {
|
||||
const m = openTag.match(new RegExp(`${name}="([^"]*)"`));
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* @fileoverview Phase 2 multi-user auth integration tests (live server, port 3170+).
|
||||
*
|
||||
* Verifies the multi-user auth branch end to end: per-user Basic verify, cookie
|
||||
* identity, wrong-password / disabled-user rejection, the mustChangePassword
|
||||
* lockbox + self-service change, per-account rate limiting, and QR identity binding
|
||||
* (tunnel-manager unit level). Single-user auth is covered by auth-security.test.ts.
|
||||
*
|
||||
* Ports: 3170 (multi-user server), 3171 (rate-limit server).
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import { TunnelManager } from '../src/tunnel-manager.js';
|
||||
import { createUser, invalidateUsersCache } from '../src/user-store.js';
|
||||
import { AUTH_FAILURE_MAX } from '../src/config/auth-config.js';
|
||||
|
||||
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
|
||||
|
||||
const PORT = 3170;
|
||||
const RATE_PORT = 3171;
|
||||
|
||||
function basic(user: string, pass: string): string {
|
||||
return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
|
||||
}
|
||||
|
||||
function cookieFrom(res: Response): string | null {
|
||||
const raw = res.headers.get('set-cookie');
|
||||
const m = raw?.match(/codeman_session=([^;]+)/);
|
||||
return m ? `codeman_session=${m[1]}` : null;
|
||||
}
|
||||
|
||||
let server: WebServer;
|
||||
let rateServer: WebServer;
|
||||
let dataDir: string;
|
||||
let spacesDir: string;
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mu-auth-data-'));
|
||||
spacesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mu-auth-spaces-'));
|
||||
for (const k of [
|
||||
'CODEMAN_DATA_DIR',
|
||||
'CODEMAN_USER_SPACES_DIR',
|
||||
'CODEMAN_MULTIUSER',
|
||||
'CODEMAN_PASSWORD',
|
||||
'CODEMAN_USERNAME',
|
||||
]) {
|
||||
saved[k] = process.env[k];
|
||||
}
|
||||
process.env.CODEMAN_DATA_DIR = dataDir;
|
||||
process.env.CODEMAN_USER_SPACES_DIR = spacesDir;
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
delete process.env.CODEMAN_PASSWORD;
|
||||
delete process.env.CODEMAN_USERNAME;
|
||||
invalidateUsersCache();
|
||||
|
||||
await createUser({ username: 'alice', role: 'admin', password: 'alicepass1' });
|
||||
await createUser({ username: 'bob', role: 'user', password: 'bobpass123' });
|
||||
await createUser({ username: 'carol', role: 'user', password: 'carolpass1' });
|
||||
await createUser({ username: 'carol', role: 'user', password: 'x' }).catch(() => {}); // no-op dup guard
|
||||
await createUser({ username: 'dave', role: 'user', password: 'davepass12', mustChangePassword: true });
|
||||
// Disable carol after creation.
|
||||
const { updateUser } = await import('../src/user-store.js');
|
||||
await updateUser('carol', { disabled: true });
|
||||
|
||||
server = new WebServer(PORT, false, true);
|
||||
await server.start();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.stop();
|
||||
await rateServer?.stop().catch(() => {});
|
||||
for (const [k, v] of Object.entries(saved)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
invalidateUsersCache();
|
||||
await fs.rm(dataDir, { recursive: true, force: true }).catch(() => {});
|
||||
await fs.rm(spacesDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
const url = (p: string) => `http://localhost:${PORT}${p}`;
|
||||
|
||||
describe('multi-user auth', () => {
|
||||
it('rejects unauthenticated requests', async () => {
|
||||
const res = await fetch(url('/api/status'));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('authenticates a valid user and issues an identity cookie', async () => {
|
||||
const res = await fetch(url('/api/status'), { headers: { Authorization: basic('alice', 'alicepass1') } });
|
||||
expect(res.status).toBe(200);
|
||||
const cookie = cookieFrom(res);
|
||||
expect(cookie).toBeTruthy();
|
||||
|
||||
const me = await fetch(url('/api/me'), { headers: { Cookie: cookie! } });
|
||||
expect(me.status).toBe(200);
|
||||
const body = await me.json();
|
||||
expect(body.data).toMatchObject({ username: 'alice', role: 'admin', mustChangePassword: false });
|
||||
});
|
||||
|
||||
it('reports role for a regular user', async () => {
|
||||
const res = await fetch(url('/api/me'), { headers: { Authorization: basic('bob', 'bobpass123') } });
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).data).toMatchObject({ username: 'bob', role: 'user' });
|
||||
});
|
||||
|
||||
it('rejects a wrong password', async () => {
|
||||
const res = await fetch(url('/api/status'), { headers: { Authorization: basic('bob', 'wrongwrong') } });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects a disabled user even with the correct password', async () => {
|
||||
const res = await fetch(url('/api/status'), { headers: { Authorization: basic('carol', 'carolpass1') } });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('is case-insensitive on the username', async () => {
|
||||
const res = await fetch(url('/api/status'), { headers: { Authorization: basic('ALICE', 'alicepass1') } });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('enforces the mustChangePassword lockbox and clears it on self-service change', async () => {
|
||||
// Basic auth as dave succeeds (cookie issued) but non-exempt routes 403.
|
||||
const authed = await fetch(url('/api/status'), { headers: { Authorization: basic('dave', 'davepass12') } });
|
||||
expect(authed.status).toBe(403);
|
||||
const body = await authed.json();
|
||||
expect(body.errorCode).toBe('PASSWORD_CHANGE_REQUIRED');
|
||||
const cookie = cookieFrom(authed);
|
||||
expect(cookie).toBeTruthy();
|
||||
|
||||
// /api/me is exempt.
|
||||
const me = await fetch(url('/api/me'), { headers: { Cookie: cookie! } });
|
||||
expect(me.status).toBe(200);
|
||||
expect((await me.json()).data.mustChangePassword).toBe(true);
|
||||
|
||||
// Wrong current password is refused.
|
||||
const bad = await fetch(url('/api/me/password'), {
|
||||
method: 'POST',
|
||||
headers: { Cookie: cookie!, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ currentPassword: 'nope', newPassword: 'brandnew123' }),
|
||||
});
|
||||
expect(bad.status).toBe(403);
|
||||
|
||||
// Correct change clears the flag.
|
||||
const ok = await fetch(url('/api/me/password'), {
|
||||
method: 'POST',
|
||||
headers: { Cookie: cookie!, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ currentPassword: 'davepass12', newPassword: 'brandnew123' }),
|
||||
});
|
||||
expect(ok.status).toBe(200);
|
||||
|
||||
// Same cookie now reaches a non-exempt route.
|
||||
const after = await fetch(url('/api/status'), { headers: { Cookie: cookie! } });
|
||||
expect(after.status).toBe(200);
|
||||
});
|
||||
|
||||
it('verify-first: a correct password is never rate-limited and self-heals failures (#17)', async () => {
|
||||
rateServer = new WebServer(RATE_PORT, false, true);
|
||||
await rateServer.start();
|
||||
const rurl = (p: string) => `http://localhost:${RATE_PORT}${p}`;
|
||||
|
||||
// Nine wrong passwords (one below the cap) are each rejected 401 — not throttled yet.
|
||||
for (let i = 0; i < AUTH_FAILURE_MAX - 1; i++) {
|
||||
const res = await fetch(rurl('/api/status'), { headers: { Authorization: basic('bob', `bad-${i}`) } });
|
||||
expect(res.status).toBe(401);
|
||||
}
|
||||
// Finding #17: the CORRECT password must ALWAYS win (verified BEFORE the per-username
|
||||
// throttle) — the accumulated failures can never lock the account out — and success
|
||||
// clears the failure buckets. Previously this returned 429 (the DoS being fixed).
|
||||
const good = await fetch(rurl('/api/status'), { headers: { Authorization: basic('bob', 'bobpass123') } });
|
||||
expect(good.status).toBe(200);
|
||||
// Self-heal: a fresh wrong attempt is 401 again (the counter was reset by the success).
|
||||
const afterReset = await fetch(rurl('/api/status'), { headers: { Authorization: basic('bob', 'nope') } });
|
||||
expect(afterReset.status).toBe(401);
|
||||
|
||||
// Sustained wrong passwords ARE still throttled: 429 once the cap is reached.
|
||||
let limited = false;
|
||||
for (let i = 0; i < AUTH_FAILURE_MAX + 1 && !limited; i++) {
|
||||
const res = await fetch(rurl('/api/status'), { headers: { Authorization: basic('bob', `x-${i}`) } });
|
||||
limited = res.status === 429;
|
||||
}
|
||||
expect(limited).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('QR token identity (tunnel-manager)', () => {
|
||||
it('binds a minted token to a user and returns it on consume (single-use)', () => {
|
||||
const tm = new TunnelManager();
|
||||
const code = tm.mintUserToken('alice');
|
||||
expect(code).toHaveLength(6);
|
||||
const first = tm.consumeTokenWithIdentity(code);
|
||||
expect(first).toEqual({ ok: true, username: 'alice' });
|
||||
// single-use
|
||||
expect(tm.consumeTokenWithIdentity(code)).toEqual({ ok: false });
|
||||
});
|
||||
|
||||
it('unknown code is rejected', () => {
|
||||
const tm = new TunnelManager();
|
||||
expect(tm.consumeTokenWithIdentity('ZZZZZZ')).toEqual({ ok: false });
|
||||
});
|
||||
});
|
||||
@@ -67,6 +67,13 @@ describe('isAllowedRequestHost — anti-DNS-rebinding', () => {
|
||||
expect(isAllowedRequestHost('eviltrycloudflare.com', loopback)).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts the docker/podman container-to-host gateway aliases (in-container hooks)', () => {
|
||||
expect(isAllowedRequestHost('host.docker.internal:3000', loopback)).toBe(true);
|
||||
expect(isAllowedRequestHost('host.containers.internal:3000', loopback)).toBe(true);
|
||||
// a lookalike is still rejected (exact match only)
|
||||
expect(isAllowedRequestHost('host.docker.internal.evil.com', loopback)).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts the configured bind host when it is a hostname', () => {
|
||||
const policy: HostPolicy = { bindHost: 'mybox.local', allowedHosts: [], tunnelHost: null };
|
||||
expect(isAllowedRequestHost('mybox.local:3000', policy)).toBe(true);
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* @fileoverview Phase 3 ownership-scoping tests (live server, port 3172).
|
||||
*
|
||||
* Verifies multi-user isolation at the API level: case lists are disjoint per user,
|
||||
* a non-admin cannot read/kill another user's session, workingDir confinement +
|
||||
* shell gate + host-CRUD admin gate are enforced, and admins see everything.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import { createUser, invalidateUsersCache } from '../src/user-store.js';
|
||||
import { canAccessOwned, findSessionOrFail, sessionCapacityState } from '../src/web/route-helpers.js';
|
||||
|
||||
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
|
||||
|
||||
const PORT = 3172;
|
||||
const basic = (u: string, p: string) => 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64');
|
||||
|
||||
let server: WebServer;
|
||||
let dataDir: string;
|
||||
let spacesDir: string;
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
const url = (p: string) => `http://localhost:${PORT}${p}`;
|
||||
|
||||
// Route returns are wrapped in the {success,data} envelope; unwrap to the payload.
|
||||
async function getJson(p: string, headers: Record<string, string>): Promise<unknown> {
|
||||
const body = await (await fetch(url(p), { headers })).json();
|
||||
return body && typeof body === 'object' && 'data' in body ? (body as { data: unknown }).data : body;
|
||||
}
|
||||
const alice = { Authorization: basic('alice', 'alicepass1') };
|
||||
const bob = { Authorization: basic('bob', 'bobpass1234') };
|
||||
const admin = { Authorization: basic('root', 'rootpass123') };
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'own-data-'));
|
||||
spacesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'own-spaces-'));
|
||||
for (const k of [
|
||||
'CODEMAN_DATA_DIR',
|
||||
'CODEMAN_USER_SPACES_DIR',
|
||||
'CODEMAN_MULTIUSER',
|
||||
'CODEMAN_PASSWORD',
|
||||
'CODEMAN_USERNAME',
|
||||
]) {
|
||||
saved[k] = process.env[k];
|
||||
}
|
||||
process.env.CODEMAN_DATA_DIR = dataDir;
|
||||
process.env.CODEMAN_USER_SPACES_DIR = spacesDir;
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
delete process.env.CODEMAN_PASSWORD;
|
||||
delete process.env.CODEMAN_USERNAME;
|
||||
invalidateUsersCache();
|
||||
|
||||
await createUser({ username: 'root', role: 'admin', password: 'rootpass123' });
|
||||
await createUser({ username: 'alice', role: 'user', password: 'alicepass1' });
|
||||
await createUser({ username: 'bob', role: 'user', password: 'bobpass1234' });
|
||||
|
||||
server = new WebServer(PORT, false, true);
|
||||
await server.start();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.stop();
|
||||
for (const [k, v] of Object.entries(saved)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
invalidateUsersCache();
|
||||
await fs.rm(dataDir, { recursive: true, force: true }).catch(() => {});
|
||||
await fs.rm(spacesDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
describe('case scoping', () => {
|
||||
it('creates cases in per-user spaces and lists them disjointly', async () => {
|
||||
const mk = await fetch(url('/api/cases'), {
|
||||
method: 'POST',
|
||||
headers: { ...alice, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'aliceproj' }),
|
||||
});
|
||||
expect(mk.status).toBe(200);
|
||||
|
||||
// Case folder is under alice's space.
|
||||
expect(await exists(path.join(spacesDir, 'alice', 'cases', 'aliceproj'))).toBe(true);
|
||||
|
||||
const aliceList = (await getJson('/api/cases', alice)) as Array<{ name: string }>;
|
||||
expect(aliceList.map((c) => c.name)).toContain('aliceproj');
|
||||
|
||||
const bobList = (await getJson('/api/cases', bob)) as Array<{ name: string }>;
|
||||
expect(bobList.map((c) => c.name)).not.toContain('aliceproj');
|
||||
});
|
||||
});
|
||||
|
||||
describe('host CRUD is admin-only', () => {
|
||||
it('rejects a non-admin defining a docker host', async () => {
|
||||
const res = await fetch(url('/api/docker-hosts'), {
|
||||
method: 'POST',
|
||||
headers: { ...bob, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: 'h1', label: 'x', image: 'codeman/agent:base' }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('allows an admin to list docker hosts', async () => {
|
||||
const res = await fetch(url('/api/docker-hosts'), { headers: admin });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('session creation gates', () => {
|
||||
it('confines a non-admin workingDir to their space', async () => {
|
||||
const foreign = path.join(spacesDir, 'alice', 'cases', 'aliceproj');
|
||||
const res = await fetch(url('/api/sessions'), {
|
||||
method: 'POST',
|
||||
headers: { ...bob, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: foreign }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('refuses shell mode for a non-granted user', async () => {
|
||||
const mine = path.join(spacesDir, 'bob', 'cases');
|
||||
await fs.mkdir(mine, { recursive: true });
|
||||
const res = await fetch(url('/api/sessions'), {
|
||||
method: 'POST',
|
||||
headers: { ...bob, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: mine, mode: 'shell' }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// The session-scoping logic (findSessionOrFail owner check, list filter, per-user
|
||||
// cap) is tested directly against the helpers under the same multi-user env, since
|
||||
// real session spawning is no-op'd in test mode and does not durably populate the
|
||||
// live map. These are the exact functions every session route uses.
|
||||
describe('session-scoping helpers (multi-user)', () => {
|
||||
const fakeSession = (owner?: string) => ({ owner }) as unknown as import('../src/session.js').Session;
|
||||
const ctxWith = (map: Map<string, unknown>) => ({ sessions: map }) as never;
|
||||
const reqAs = (username: string, role: 'admin' | 'user') => ({ authUser: { username, role } }) as never;
|
||||
|
||||
it('canAccessOwned isolates non-admins to their own', () => {
|
||||
expect(canAccessOwned({ username: 'alice', role: 'user' }, 'alice')).toBe(true);
|
||||
expect(canAccessOwned({ username: 'alice', role: 'user' }, 'bob')).toBe(false);
|
||||
expect(canAccessOwned({ username: 'alice', role: 'user' }, undefined)).toBe(false);
|
||||
expect(canAccessOwned({ username: 'root', role: 'admin' }, 'bob')).toBe(true);
|
||||
});
|
||||
|
||||
it('findSessionOrFail 404s a foreign session for a non-admin, returns it for owner/admin', () => {
|
||||
const map = new Map<string, unknown>([['s1', fakeSession('alice')]]);
|
||||
expect(() => findSessionOrFail(ctxWith(map), 's1', reqAs('bob', 'user'))).toThrow();
|
||||
expect(findSessionOrFail(ctxWith(map), 's1', reqAs('alice', 'user'))).toBeDefined();
|
||||
expect(findSessionOrFail(ctxWith(map), 's1', reqAs('root', 'admin'))).toBeDefined();
|
||||
});
|
||||
|
||||
it('per-user session cap counts only the owner sessions', () => {
|
||||
const map = new Map<string, unknown>([
|
||||
['a', fakeSession('alice')],
|
||||
['b', fakeSession('alice')],
|
||||
['c', fakeSession('bob')],
|
||||
]);
|
||||
process.env.CODEMAN_MAX_SESSIONS_PER_USER = '2';
|
||||
expect(sessionCapacityState(map as never, 'alice').atUserCap).toBe(true);
|
||||
expect(sessionCapacityState(map as never, 'bob').atUserCap).toBe(false);
|
||||
delete process.env.CODEMAN_MAX_SESSIONS_PER_USER;
|
||||
});
|
||||
});
|
||||
|
||||
async function exists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.stat(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* @fileoverview COD-108 — remote-session auto-reconnect watcher tests.
|
||||
*
|
||||
* Three layers, all tmux-safe (under VITEST TmuxManager no-ops real tmux and
|
||||
* `isPaneDead` returns false, so live behavior is driven via injected stubs):
|
||||
*
|
||||
* (a) PURE backoff schedule — attempt→delay, cap, reset-on-success.
|
||||
* (b) PURE eligibility decision — dead remote pane + due → emit; guarded →
|
||||
* never; non-remote / pane-alive / not-due → skip; over-cap → exhaust.
|
||||
* (c) MANAGER integration — drive ticks with a stubbed pane-death signal +
|
||||
* controllable clock and assert the emit → backoff → exhausted progression,
|
||||
* and that a guarded (intentionally-killed) session emits nothing.
|
||||
*
|
||||
* Port: N/A (no server).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import {
|
||||
BACKOFF_SCHEDULE_MS,
|
||||
MAX_RECONNECT_ATTEMPTS,
|
||||
reconnectDelayForAttempt,
|
||||
isExhausted,
|
||||
freshReconnectState,
|
||||
advanceBackoff,
|
||||
resetReconnectState,
|
||||
decideReconnect,
|
||||
} from '../src/remote-reconnect.js';
|
||||
import type { ReconnectSessionView } from '../src/remote-reconnect.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import type { SessionRemote } from '../src/types.js';
|
||||
|
||||
const REMOTE: SessionRemote = {
|
||||
hostId: 'aa-desktop',
|
||||
label: 'aa-desktop',
|
||||
host: 'aa-desktop',
|
||||
username: 'aakhter',
|
||||
remotePath: '/home/aakhter',
|
||||
owned: true,
|
||||
};
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// (a) PURE backoff schedule
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('reconnect backoff schedule (pure)', () => {
|
||||
it('returns the documented bounded exponential delays per attempt', () => {
|
||||
expect(reconnectDelayForAttempt(1)).toBe(5_000);
|
||||
expect(reconnectDelayForAttempt(2)).toBe(15_000);
|
||||
expect(reconnectDelayForAttempt(3)).toBe(45_000);
|
||||
expect(reconnectDelayForAttempt(4)).toBe(120_000);
|
||||
expect(reconnectDelayForAttempt(5)).toBe(300_000);
|
||||
expect(reconnectDelayForAttempt(6)).toBe(300_000);
|
||||
});
|
||||
|
||||
it('clamps below-range and above-range attempts to the schedule bounds', () => {
|
||||
expect(reconnectDelayForAttempt(0)).toBe(BACKOFF_SCHEDULE_MS[0]);
|
||||
expect(reconnectDelayForAttempt(-3)).toBe(BACKOFF_SCHEDULE_MS[0]);
|
||||
expect(reconnectDelayForAttempt(99)).toBe(BACKOFF_SCHEDULE_MS[BACKOFF_SCHEDULE_MS.length - 1]);
|
||||
});
|
||||
|
||||
it('flags exhaustion only at/after the cap', () => {
|
||||
expect(isExhausted(0)).toBe(false);
|
||||
expect(isExhausted(MAX_RECONNECT_ATTEMPTS - 1)).toBe(false);
|
||||
expect(isExhausted(MAX_RECONNECT_ATTEMPTS)).toBe(true);
|
||||
expect(isExhausted(MAX_RECONNECT_ATTEMPTS + 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('advanceBackoff increments attempts and schedules next-eligible from now (immutable)', () => {
|
||||
const s0 = freshReconnectState();
|
||||
const s1 = advanceBackoff(s0, 1_000);
|
||||
expect(s0.attempts).toBe(0); // input untouched
|
||||
expect(s1.attempts).toBe(1);
|
||||
expect(s1.nextEligibleAt).toBe(1_000 + 5_000);
|
||||
expect(s1.exhausted).toBe(false);
|
||||
|
||||
const s2 = advanceBackoff(s1, 10_000);
|
||||
expect(s2.attempts).toBe(2);
|
||||
expect(s2.nextEligibleAt).toBe(10_000 + 15_000);
|
||||
});
|
||||
|
||||
it('reaches the attempt cap after the scheduled number of advances', () => {
|
||||
let s = freshReconnectState();
|
||||
let now = 0;
|
||||
for (let i = 0; i < MAX_RECONNECT_ATTEMPTS; i++) {
|
||||
s = advanceBackoff(s, now);
|
||||
now += reconnectDelayForAttempt(s.attempts);
|
||||
}
|
||||
expect(s.attempts).toBe(MAX_RECONNECT_ATTEMPTS);
|
||||
// advanceBackoff does not itself set `exhausted`; the watcher decides that
|
||||
// on the following tick via isExhausted(attempts).
|
||||
expect(isExhausted(s.attempts)).toBe(true);
|
||||
});
|
||||
|
||||
it('reset-on-success returns to a fresh, immediately-eligible state', () => {
|
||||
const advanced = advanceBackoff(advanceBackoff(freshReconnectState(), 0), 100);
|
||||
expect(advanced.attempts).toBe(2);
|
||||
const reset = resetReconnectState();
|
||||
expect(reset.attempts).toBe(0);
|
||||
expect(reset.nextEligibleAt).toBe(0);
|
||||
expect(reset.exhausted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// (b) PURE eligibility decision
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('decideReconnect (pure eligibility)', () => {
|
||||
const deadRemote: ReconnectSessionView = { sessionId: 's1', isRemote: true, paneDead: true };
|
||||
|
||||
it('emits for a dead remote pane that is not guarded and is due', () => {
|
||||
const action = decideReconnect({
|
||||
session: deadRemote,
|
||||
state: freshReconnectState(),
|
||||
guarded: false,
|
||||
enabled: true,
|
||||
now: 0,
|
||||
});
|
||||
expect(action).toEqual({ kind: 'emit', attempt: 1 });
|
||||
});
|
||||
|
||||
it('NEVER reconnects a guarded (intentionally killed/detached) session', () => {
|
||||
const action = decideReconnect({
|
||||
session: deadRemote,
|
||||
state: freshReconnectState(),
|
||||
guarded: true,
|
||||
enabled: true,
|
||||
now: 0,
|
||||
});
|
||||
expect(action).toEqual({ kind: 'skip', reason: 'guarded' });
|
||||
});
|
||||
|
||||
it('skips non-remote sessions', () => {
|
||||
const action = decideReconnect({
|
||||
session: { sessionId: 's1', isRemote: false, paneDead: true },
|
||||
state: freshReconnectState(),
|
||||
guarded: false,
|
||||
enabled: true,
|
||||
now: 0,
|
||||
});
|
||||
expect(action).toEqual({ kind: 'skip', reason: 'not-remote' });
|
||||
});
|
||||
|
||||
it('skips when the pane is alive', () => {
|
||||
const action = decideReconnect({
|
||||
session: { sessionId: 's1', isRemote: true, paneDead: false },
|
||||
state: freshReconnectState(),
|
||||
guarded: false,
|
||||
enabled: true,
|
||||
now: 0,
|
||||
});
|
||||
expect(action).toEqual({ kind: 'skip', reason: 'pane-alive' });
|
||||
});
|
||||
|
||||
it('skips when the kill-switch is off', () => {
|
||||
const action = decideReconnect({
|
||||
session: deadRemote,
|
||||
state: freshReconnectState(),
|
||||
guarded: false,
|
||||
enabled: false,
|
||||
now: 0,
|
||||
});
|
||||
expect(action).toEqual({ kind: 'skip', reason: 'disabled' });
|
||||
});
|
||||
|
||||
it('skips when not yet due (within backoff window)', () => {
|
||||
const state = advanceBackoff(freshReconnectState(), 0); // nextEligibleAt = 5000
|
||||
const action = decideReconnect({ session: deadRemote, state, guarded: false, enabled: true, now: 4_999 });
|
||||
expect(action).toEqual({ kind: 'skip', reason: 'not-due' });
|
||||
});
|
||||
|
||||
it('emits again once the backoff window elapses', () => {
|
||||
const state = advanceBackoff(freshReconnectState(), 0); // nextEligibleAt = 5000
|
||||
const action = decideReconnect({ session: deadRemote, state, guarded: false, enabled: true, now: 5_000 });
|
||||
expect(action).toEqual({ kind: 'emit', attempt: 2 });
|
||||
});
|
||||
|
||||
it('skips while a reconnect is already in flight (no stacked respawns)', () => {
|
||||
const state = { ...freshReconnectState(), inFlight: true };
|
||||
const action = decideReconnect({ session: deadRemote, state, guarded: false, enabled: true, now: 10_000 });
|
||||
expect(action).toEqual({ kind: 'skip', reason: 'in-flight' });
|
||||
});
|
||||
|
||||
it('exhausts once the attempt cap is reached', () => {
|
||||
const state = { ...freshReconnectState(), attempts: MAX_RECONNECT_ATTEMPTS, nextEligibleAt: 0 };
|
||||
const action = decideReconnect({ session: deadRemote, state, guarded: false, enabled: true, now: 1_000_000 });
|
||||
expect(action).toEqual({ kind: 'exhaust' });
|
||||
});
|
||||
|
||||
it('stays quiet after exhaustion has been recorded', () => {
|
||||
const state = { ...freshReconnectState(), attempts: MAX_RECONNECT_ATTEMPTS, exhausted: true };
|
||||
const action = decideReconnect({ session: deadRemote, state, guarded: false, enabled: true, now: 1_000_000 });
|
||||
expect(action).toEqual({ kind: 'skip', reason: 'exhausted' });
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// (c) MANAGER integration — drive ticks with a stubbed pane-death + clock
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('TmuxManager remote reconnect watcher (integration)', () => {
|
||||
let manager: TmuxManager;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new TmuxManager();
|
||||
});
|
||||
|
||||
function registerRemote(sessionId: string): void {
|
||||
manager.registerSession({
|
||||
sessionId,
|
||||
muxName: `codeman-${sessionId}`,
|
||||
pid: 4242,
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/home/aakhter',
|
||||
mode: 'shell',
|
||||
attached: true,
|
||||
remote: REMOTE,
|
||||
});
|
||||
}
|
||||
|
||||
it('emits remoteSessionDropped when a dead remote pane is observed, then backs off and exhausts', () => {
|
||||
registerRemote('aaaa1111');
|
||||
// Force the watcher to see a dead pane regardless of test-mode isPaneDead.
|
||||
vi.spyOn(manager, 'isPaneDead').mockReturnValue(true);
|
||||
|
||||
const dropped: Array<{ sessionId: string; attempt: number }> = [];
|
||||
const exhausted: Array<{ sessionId: string }> = [];
|
||||
manager.on('remoteSessionDropped', (d) => dropped.push(d));
|
||||
manager.on('remoteReconnectExhausted', (d) => exhausted.push(d));
|
||||
|
||||
// Drive ticks with a controllable clock. Each emit marks the session
|
||||
// in-flight; a failed reattach (host still down) releases it via
|
||||
// noteRemoteReconnect(false), mirroring the real server loop.
|
||||
let now = 0;
|
||||
for (let i = 0; i < MAX_RECONNECT_ATTEMPTS + 3; i++) {
|
||||
manager.runRemoteReconnectTick(now, /* enabled */ true);
|
||||
manager.noteRemoteReconnect('aaaa1111', false); // reattach failed → clear in-flight
|
||||
// jump the clock past the just-scheduled backoff window
|
||||
now += BACKOFF_SCHEDULE_MS[Math.min(i, BACKOFF_SCHEDULE_MS.length - 1)] + 1;
|
||||
}
|
||||
|
||||
expect(dropped.map((d) => d.attempt)).toEqual([1, 2, 3, 4, 5, 6]);
|
||||
expect(dropped.every((d) => d.sessionId === 'aaaa1111')).toBe(true);
|
||||
expect(exhausted).toEqual([{ sessionId: 'aaaa1111' }]); // fired exactly once
|
||||
});
|
||||
|
||||
it('emits nothing for a guarded (intentionally killed) session', () => {
|
||||
registerRemote('bbbb2222');
|
||||
vi.spyOn(manager, 'isPaneDead').mockReturnValue(true);
|
||||
manager.guardRemoteReconnect('bbbb2222'); // simulate killSession/detach guard
|
||||
|
||||
const dropped: unknown[] = [];
|
||||
manager.on('remoteSessionDropped', (d) => dropped.push(d));
|
||||
|
||||
let now = 0;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
manager.runRemoteReconnectTick(now, true);
|
||||
now += 600_000;
|
||||
}
|
||||
expect(dropped).toEqual([]);
|
||||
});
|
||||
|
||||
it('resets backoff on a successful reattach (noteRemoteReconnect)', () => {
|
||||
registerRemote('cccc3333');
|
||||
vi.spyOn(manager, 'isPaneDead').mockReturnValue(true);
|
||||
|
||||
const dropped: Array<{ attempt: number }> = [];
|
||||
manager.on('remoteSessionDropped', (d) => dropped.push(d));
|
||||
|
||||
manager.runRemoteReconnectTick(0, true); // emit attempt 1
|
||||
manager.noteRemoteReconnect('cccc3333', true); // reattach succeeded → reset
|
||||
manager.runRemoteReconnectTick(1, true); // immediately eligible again → attempt 1
|
||||
|
||||
expect(dropped.map((d) => d.attempt)).toEqual([1, 1]);
|
||||
});
|
||||
|
||||
it('does nothing when the kill-switch (enabled=false) is off', () => {
|
||||
registerRemote('dddd4444');
|
||||
vi.spyOn(manager, 'isPaneDead').mockReturnValue(true);
|
||||
const dropped: unknown[] = [];
|
||||
manager.on('remoteSessionDropped', (d) => dropped.push(d));
|
||||
|
||||
manager.runRemoteReconnectTick(0, false);
|
||||
expect(dropped).toEqual([]);
|
||||
});
|
||||
|
||||
it('clears per-session reconnect/guard state when the session is removed', () => {
|
||||
registerRemote('eeee5555');
|
||||
manager.guardRemoteReconnect('eeee5555');
|
||||
manager.clearRemoteReconnectState('eeee5555');
|
||||
// After clearing the guard, a fresh dead-pane observation should emit again.
|
||||
vi.spyOn(manager, 'isPaneDead').mockReturnValue(true);
|
||||
const dropped: unknown[] = [];
|
||||
manager.on('remoteSessionDropped', (d) => dropped.push(d));
|
||||
manager.runRemoteReconnectTick(0, true);
|
||||
expect(dropped).toEqual([{ sessionId: 'eeee5555', attempt: 1 }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* @fileoverview COD-105 — discover & attach existing remote tmux sessions.
|
||||
*
|
||||
* Phase 2 of the remote-tmux arc (builds on COD-104 durable remote sessions +
|
||||
* COD-107 connection args). These tests are tmux-safe / ssh-safe: they exercise
|
||||
* the PURE parse helper, the pure attach-command builder, and the killSession
|
||||
* ownership gate — none open a real ssh connection or a real tmux server.
|
||||
*
|
||||
* Port: N/A.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseRemoteSessionList } from '../src/remote-hosts.js';
|
||||
import { buildRemoteAttachCommand } from '../src/tmux-manager.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import type { SessionRemote } from '../src/types.js';
|
||||
|
||||
const baseRemote: SessionRemote = {
|
||||
hostId: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
remotePath: '/home/ubuntu/work',
|
||||
};
|
||||
|
||||
describe('COD-105 parseRemoteSessionList', () => {
|
||||
it('parses tab-delimited -F output and coerces fields', () => {
|
||||
const stdout = 'codeman-disco1\t0\t1700000000\t1\n' + 'codeman-abcd1234\t1\t1700000123\t3\n';
|
||||
const list = parseRemoteSessionList(stdout);
|
||||
expect(list).toEqual([
|
||||
{ name: 'codeman-disco1', attached: false, attachedClients: 0, created: 1700000000, windows: 1 },
|
||||
{ name: 'codeman-abcd1234', attached: true, attachedClients: 1, created: 1700000123, windows: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses the LITERAL backslash-t separator the remote tmux actually emits', () => {
|
||||
// tmux next-3.7's `-F "…\t…"` does NOT expand \t — it prints a literal
|
||||
// backslash-t (verified on aa-desktop). The parser must split on that.
|
||||
const stdout = 'codeman-disco1\\t0\\t1781362858\\t1\n' + 'codeman-real\\t1\\t1781329905\\t2\n';
|
||||
const list = parseRemoteSessionList(stdout);
|
||||
expect(list).toEqual([
|
||||
{ name: 'codeman-disco1', attached: false, attachedClients: 0, created: 1781362858, windows: 1 },
|
||||
{ name: 'codeman-real', attached: true, attachedClients: 1, created: 1781329905, windows: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps only codeman-* sessions, dropping foreign tmux sessions', () => {
|
||||
const stdout = 'work\t1\t1700000000\t2\n' + 'codeman-keep\t0\t1700000001\t1\n' + 'scratch\t0\t1700000002\t1\n';
|
||||
const list = parseRemoteSessionList(stdout);
|
||||
expect(list.map((s) => s.name)).toEqual(['codeman-keep']);
|
||||
});
|
||||
|
||||
it('returns [] for empty / whitespace output (the no-sessions case)', () => {
|
||||
expect(parseRemoteSessionList('')).toEqual([]);
|
||||
expect(parseRemoteSessionList(' \n \n')).toEqual([]);
|
||||
});
|
||||
|
||||
it('tolerates malformed lines (missing columns) by skipping them', () => {
|
||||
const stdout = 'codeman-ok\t0\t1700000000\t1\n' + 'codeman-bad\tnotanumber\n';
|
||||
const list = parseRemoteSessionList(stdout);
|
||||
expect(list).toEqual([
|
||||
{ name: 'codeman-ok', attached: false, attachedClients: 0, created: 1700000000, windows: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('COD-105 buildRemoteAttachCommand', () => {
|
||||
it('emits ssh -t <target> tmux -L codeman attach -t <session>', () => {
|
||||
const command = buildRemoteAttachCommand(baseRemote, 'codeman-disco1');
|
||||
expect(command).toContain('ssh');
|
||||
expect(command).toContain('BatchMode=yes');
|
||||
expect(command).toContain('-t');
|
||||
expect(command).toContain('ubuntu@10.0.0.42');
|
||||
// The tmux invocation is nested-quoted (inner session name escaped, whole
|
||||
// invocation re-escaped as one ssh arg). Assert the stable prefix here; the
|
||||
// exact re-parsed token is verified by the argv-reparse test below.
|
||||
expect(command).toContain('tmux -L codeman attach -t ');
|
||||
expect(command).toContain('codeman-disco1');
|
||||
});
|
||||
|
||||
it('threads the COD-107 connection args (port / identity / proxy) into the ssh invocation', () => {
|
||||
const command = buildRemoteAttachCommand(
|
||||
{ ...baseRemote, port: 2222, identityFile: '/keys/id_ed25519', socksProxy: '127.0.0.1:1080' },
|
||||
'codeman-disco1'
|
||||
);
|
||||
expect(command).toContain('-p 2222');
|
||||
expect(command).toContain("-i '/keys/id_ed25519'");
|
||||
expect(command).toContain('ProxyCommand=nc -X 5 -x 127.0.0.1:1080 %h %p');
|
||||
// Port/identity/proxy belong to ssh, ahead of the target.
|
||||
expect(command).toMatch(/ssh[\s\S]*-p 2222[\s\S]*ubuntu@10\.0\.0\.42/);
|
||||
});
|
||||
|
||||
it('shell-escapes the session name so it stays a single token', () => {
|
||||
const command = buildRemoteAttachCommand(baseRemote, 'codeman-disco1');
|
||||
// Re-parse: stub ssh to dump argv, confirm the trailing tmux invocation is one arg.
|
||||
const dumpArgs = (name: string, prefix: string) =>
|
||||
`${name}() { for a in "$@"; do printf '${prefix}:%s\\n' "$a"; done; }`;
|
||||
const out = execFileSync('/bin/sh', ['-c', `${dumpArgs('ssh', 'A')}\n${command}`], { encoding: 'utf8' });
|
||||
const sshArgs = out
|
||||
.split('\n')
|
||||
.filter((l) => l.startsWith('A:'))
|
||||
.map((l) => l.slice(2));
|
||||
expect(sshArgs).toContain('ubuntu@10.0.0.42');
|
||||
const tmuxArg = sshArgs.find((a) => a.includes('attach'));
|
||||
expect(tmuxArg).toBe("tmux -L codeman attach -t 'codeman-disco1'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('COD-105 killSession ownership gate (detach-not-kill)', () => {
|
||||
it('never issues a remote tmux kill-session for a non-owned remote session', async () => {
|
||||
const mgr = new TmuxManager();
|
||||
// Register a discovered+attached (non-owned) remote session.
|
||||
mgr.registerSession({
|
||||
sessionId: 'disco-1',
|
||||
muxName: 'codeman-disco-1',
|
||||
pid: 0,
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/home/ubuntu/work',
|
||||
mode: 'shell',
|
||||
attached: false,
|
||||
remote: { ...baseRemote, owned: false },
|
||||
});
|
||||
|
||||
// killSession under VITEST is in-memory only (IS_TEST_MODE), so it physically
|
||||
// cannot run a remote kill-session. We assert the contract: the session's
|
||||
// ownership flag is the gate, and tearing it down removes only local state.
|
||||
const session = mgr.getSession('disco-1');
|
||||
expect(session?.remote?.owned).toBe(false);
|
||||
|
||||
const ok = await mgr.killSession('disco-1');
|
||||
expect(ok).toBe(true);
|
||||
// Local tracking removed; no remote kill was (or could be) issued.
|
||||
expect(mgr.getSession('disco-1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('treats COD-104 launched remote sessions as owned by default', () => {
|
||||
const mgr = new TmuxManager();
|
||||
mgr.registerSession({
|
||||
sessionId: 'owned-1',
|
||||
muxName: 'codeman-owned-1',
|
||||
pid: 0,
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/home/ubuntu/work',
|
||||
mode: 'shell',
|
||||
attached: false,
|
||||
remote: { ...baseRemote, owned: true },
|
||||
});
|
||||
expect(mgr.getSession('owned-1')?.remote?.owned).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildRemoteKillCommand } from '../src/tmux-manager.js';
|
||||
import type { SessionRemote } from '../src/types.js';
|
||||
|
||||
// COD-109 — terminate an OWNED durable remote tmux session by propagating
|
||||
// `kill-session` to the remote host. Since COD-104 an owned remote session lives
|
||||
// in the dedicated `-L codeman-remote` tmux server on the host and outlives the
|
||||
// local ssh pane, so ending a session we OWN must reach the remote socket.
|
||||
//
|
||||
// The owned-kill command builder was consolidated upstream (PR #145) into the
|
||||
// `{ remote, sessionId }` form, which derives the durable session name and kills
|
||||
// on the dedicated `codeman-remote` socket (matching buildRemoteLaunchCommand).
|
||||
// This suite pins that builder's contract; the killSession integration
|
||||
// (owned-only, after COD-105's non-owned detach-only early-return) is exercised
|
||||
// against the real remote.
|
||||
describe('COD-109 buildRemoteKillCommand (owned durable remote kill)', () => {
|
||||
const base: SessionRemote = {
|
||||
hostId: 'h',
|
||||
label: 'aa',
|
||||
host: '192.168.55.170',
|
||||
username: 'aakht',
|
||||
remotePath: '/tmp',
|
||||
};
|
||||
|
||||
it('kills the durable session on the dedicated codeman-remote socket (no ssh -t — non-interactive)', () => {
|
||||
const cmd = buildRemoteKillCommand({ remote: base, sessionId: 'abc12345def' });
|
||||
expect(cmd.startsWith('ssh -o BatchMode=yes ')).toBe(true);
|
||||
expect(cmd).toContain('aakht@192.168.55.170');
|
||||
// Owned sessions launch on `-L codeman-remote`; the kill MUST target the same socket.
|
||||
expect(cmd).toContain('tmux -L codeman-remote kill-session -t');
|
||||
// Deterministic session name derived from the sessionId (codeman-ssh-<first 8>).
|
||||
expect(cmd).toContain('codeman-ssh-abc12345');
|
||||
// kill-session needs no PTY — must NOT request the ssh `-t` flag (attach uses
|
||||
// `ssh -o BatchMode=yes -t …`; kill must not). The ` -t ` inside the quoted
|
||||
// `kill-session -t <name>` is the tmux target flag, which is expected.
|
||||
expect(cmd).not.toContain('-o BatchMode=yes -t');
|
||||
});
|
||||
|
||||
it('shares the default ConnectTimeout so an unreachable host fails fast (never blocks kill)', () => {
|
||||
const cmd = buildRemoteKillCommand({ remote: base, sessionId: 'abc12345def' });
|
||||
expect(cmd).toContain('-o ConnectTimeout=10');
|
||||
});
|
||||
|
||||
it('reuses the COD-107 connection options (port / identity / SOCKS proxy)', () => {
|
||||
const cmd = buildRemoteKillCommand({
|
||||
remote: { ...base, port: 2222, identityFile: '~/.ssh/remote_ed25519', socksProxy: '127.0.0.1:1080' },
|
||||
sessionId: 'abc12345def',
|
||||
});
|
||||
expect(cmd).toContain('-p 2222');
|
||||
expect(cmd).toMatch(/-i '.*\/\.ssh\/remote_ed25519'/);
|
||||
expect(cmd).toContain("-o 'ProxyCommand=nc -X 5 -x 127.0.0.1:1080 %h %p'");
|
||||
});
|
||||
|
||||
it('shell-escapes the derived session name so metachars stay one token', () => {
|
||||
const cmd = buildRemoteKillCommand({ remote: base, sessionId: "x'; rm -rf /" });
|
||||
expect(cmd).not.toMatch(/rm -rf \/\s*$/); // not a bare trailing command
|
||||
expect(cmd).toContain('kill-session -t');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildRemoteLaunchCommand } from '../src/tmux-manager.js';
|
||||
import { parseRemoteSessionList } from '../src/remote-hosts.js';
|
||||
import type { SessionRemote } from '../src/types.js';
|
||||
|
||||
// COD-106 — shared/collaborative remote sessions: window-size policy so concurrent
|
||||
// clients don't fight, and a client-count surfaced for the "shared · N" badge.
|
||||
describe('COD-106 shared remote sessions', () => {
|
||||
const remote: SessionRemote = {
|
||||
hostId: 'h',
|
||||
label: 'aa',
|
||||
host: '192.168.55.170',
|
||||
username: 'aakht',
|
||||
remotePath: '/tmp',
|
||||
commands: { shell: 'exec bash -l' },
|
||||
};
|
||||
|
||||
it('launch command sets window-size latest (so multi-client attach does not clamp to smallest)', () => {
|
||||
const cmd = buildRemoteLaunchCommand({ mode: 'shell', remote, sessionId: 'cod106aaa' });
|
||||
// Per-session scoped on the dedicated `codeman-remote` socket (PR #145 hardening).
|
||||
expect(cmd).toContain('set -t codeman-ssh-cod106aa window-size latest');
|
||||
// still has the COD-104 config (no regression)
|
||||
expect(cmd).toContain('set -t codeman-ssh-cod106aa status off');
|
||||
expect(cmd).toContain('new-session -A -s codeman-ssh-cod106aa');
|
||||
});
|
||||
|
||||
it('parses session_attached as a CLIENT COUNT (>1 = shared)', () => {
|
||||
const rows = parseRemoteSessionList(
|
||||
['codeman-solo\\t1\\t100\\t1', 'codeman-shared\\t2\\t200\\t3', 'codeman-idle\\t0\\t300\\t1'].join('\n')
|
||||
);
|
||||
const byName = Object.fromEntries(rows.map((r) => [r.name, r]));
|
||||
expect(byName['codeman-solo'].attachedClients).toBe(1);
|
||||
expect(byName['codeman-solo'].attached).toBe(true);
|
||||
expect(byName['codeman-shared'].attachedClients).toBe(2); // shared
|
||||
expect(byName['codeman-shared'].attached).toBe(true);
|
||||
expect(byName['codeman-idle'].attachedClients).toBe(0);
|
||||
expect(byName['codeman-idle'].attached).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -157,6 +157,8 @@ describe('COD-107 buildRemoteLaunchCommand — threads connection args', () => {
|
||||
`set -t ${remoteName} mouse off`,
|
||||
`set -t ${remoteName} prefix C-q`,
|
||||
'set -s escape-time 0',
|
||||
// COD-106 — shared/collaborative sizing, per-session scoped (never -g).
|
||||
`set -t ${remoteName} window-size latest`,
|
||||
].join(' \\; ');
|
||||
// Connection args (with the default -o ConnectTimeout=10) sit after -t.
|
||||
const expected = `ssh -o BatchMode=yes -t -o ConnectTimeout=10 ${remoteSshTarget(baseRemote)} ${sh(tmuxInvocation)}`;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* @fileoverview COD-105 — GET /api/remote-hosts/:hostId/sessions discovery endpoint.
|
||||
*
|
||||
* The endpoint reads the saved host config by id, runs listRemoteCodemanSessions
|
||||
* (ssh-guarded under VITEST), and returns the discovered sessions in the
|
||||
* ApiResponse envelope. We mock the remote-hosts module so the test controls the
|
||||
* host record and the session list WITHOUT any real ssh / filesystem.
|
||||
*
|
||||
* Port: N/A (app.inject()).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { createMockRouteContext } from '../mocks/index.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
|
||||
import type { RemoteHost, RemoteSessionInfo } from '../../src/types.js';
|
||||
|
||||
// Mock the remote-hosts module: control readRemoteHosts + listRemoteCodemanSessions.
|
||||
const mockHosts: RemoteHost[] = [];
|
||||
let mockSessions: RemoteSessionInfo[] = [];
|
||||
let lastListArg: unknown = undefined;
|
||||
|
||||
vi.mock('../../src/remote-hosts.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/remote-hosts.js')>();
|
||||
return {
|
||||
...actual,
|
||||
readRemoteHosts: vi.fn(async () => mockHosts),
|
||||
readRemoteCases: vi.fn(async () => []),
|
||||
listRemoteCodemanSessions: vi.fn(async (remote: unknown) => {
|
||||
lastListArg = remote;
|
||||
return mockSessions;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../src/templates/claude-md.js', () => ({
|
||||
generateClaudeMd: vi.fn(() => '# CLAUDE.md'),
|
||||
}));
|
||||
vi.mock('../../src/hooks-config.js', () => ({ writeHooksConfig: vi.fn(async () => {}) }));
|
||||
|
||||
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
|
||||
|
||||
async function createHarness(): Promise<FastifyInstance> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
|
||||
if (!req.url.startsWith('/api')) return done(null, payload);
|
||||
if (payload === null || typeof payload !== 'object') return done(null, payload);
|
||||
const p = payload as { success?: unknown; errorCode?: unknown };
|
||||
if (p.success === false) {
|
||||
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
|
||||
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
|
||||
}
|
||||
return done(null, payload);
|
||||
}
|
||||
if (p.success === true) return done(null, payload);
|
||||
return done(null, { success: true, data: payload });
|
||||
});
|
||||
const ctx = createMockRouteContext();
|
||||
registerCaseRoutes(app, ctx as never);
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('COD-105 GET /api/remote-hosts/:hostId/sessions', () => {
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await createHarness();
|
||||
mockHosts.length = 0;
|
||||
mockSessions = [];
|
||||
lastListArg = undefined;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('returns discovered sessions for a known host in the envelope', async () => {
|
||||
mockHosts.push({ id: 'aa-desktop', label: 'aa', host: '1.2.3.4', username: 'aakht', port: 2222 });
|
||||
mockSessions = [{ name: 'codeman-disco1', attached: false, created: 1700000000, windows: 1 }];
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: '/api/remote-hosts/aa-desktop/sessions' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.sessions).toEqual([{ name: 'codeman-disco1', attached: false, created: 1700000000, windows: 1 }]);
|
||||
// The host config (incl. port) was threaded to the discovery call.
|
||||
expect((lastListArg as { host?: string; port?: number }).host).toBe('1.2.3.4');
|
||||
expect((lastListArg as { port?: number }).port).toBe(2222);
|
||||
});
|
||||
|
||||
it('404s when the host id is unknown', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/remote-hosts/nope/sessions' });
|
||||
expect(res.statusCode).toBe(404);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.errorCode).toBe(ApiErrorCode.NOT_FOUND);
|
||||
});
|
||||
|
||||
it('returns an empty list (not an error) when no sessions are discovered', async () => {
|
||||
mockHosts.push({ id: 'aa-desktop', label: 'aa', host: '1.2.3.4', username: 'aakht' });
|
||||
mockSessions = [];
|
||||
const res = await app.inject({ method: 'GET', url: '/api/remote-hosts/aa-desktop/sessions' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.sessions).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -174,8 +174,8 @@ describe('scheduled-routes', () => {
|
||||
// Bare { run } return (envelope-wrapped to { success:true, data:{ run } }
|
||||
// in production; harness sees the bare return).
|
||||
expect(body.run).toBeDefined();
|
||||
// Should default to 60 minutes
|
||||
expect(harness.ctx.startScheduledRun).toHaveBeenCalledWith('test', expect.any(String), 60);
|
||||
// Should default to 60 minutes; 4th arg is the multi-user owner (undefined in single-user).
|
||||
expect(harness.ctx.startScheduledRun).toHaveBeenCalledWith('test', expect.any(String), 60, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* Uses app.inject() with the production-mirroring envelope harness.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
@@ -123,4 +123,47 @@ describe('POST /api/sessions/:id/pin', () => {
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().success).toBe(false);
|
||||
});
|
||||
|
||||
// COD-142 keeps a pinned session's persisted record after kill, so pin toggles
|
||||
// must work WITHOUT a live Session — otherwise a pinned-then-killed record could
|
||||
// never be unpinned (cleanupStaleSessions deliberately skips pinned records).
|
||||
it('unpins a persisted-only (killed but pinned) record via the store fallback', async () => {
|
||||
const persisted = { id: 'dead-1', name: 'Dead', status: 'stopped', pinned: true, pinnedAt: 123 };
|
||||
(harness.ctx.store.getSession as ReturnType<typeof vi.fn>).mockReturnValue(persisted);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/dead-1/pin',
|
||||
payload: { pinned: false },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data.pinned).toBe(false);
|
||||
expect(body.data.pinnedAt).toBeUndefined();
|
||||
expect(harness.ctx.store.setSession).toHaveBeenCalledWith(
|
||||
'dead-1',
|
||||
expect.objectContaining({ pinned: undefined, pinnedAt: undefined })
|
||||
);
|
||||
const broadcastCalls = harness.ctx.broadcast.mock.calls.map((c) => c[0]);
|
||||
expect(broadcastCalls).toContain('session:pinned');
|
||||
});
|
||||
|
||||
it('re-pins a persisted-only record via the store fallback', async () => {
|
||||
const persisted = { id: 'dead-2', name: 'Dead2', status: 'stopped' };
|
||||
(harness.ctx.store.getSession as ReturnType<typeof vi.fn>).mockReturnValue(persisted);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/dead-2/pin',
|
||||
payload: { pinned: true },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data.pinned).toBe(true);
|
||||
expect(typeof body.data.pinnedAt).toBe('number');
|
||||
expect(harness.ctx.store.setSession).toHaveBeenCalledWith(
|
||||
'dead-2',
|
||||
expect.objectContaining({ pinned: true, pinnedAt: expect.any(Number) })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -225,7 +225,11 @@ describe('system-routes', () => {
|
||||
harness.ctx._session.status = 'working';
|
||||
harness.ctx.store.getDailyStats.mockReturnValue([
|
||||
{
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
// LOCAL date (not toISOString/UTC): away-digest's dayOverlapsRange parses
|
||||
// the date as local midnight, so a UTC date near the local-midnight boundary
|
||||
// (e.g. running at 01:xx CEST = prior-day UTC) would fall outside the 1h
|
||||
// window and make this assertion TZ/hour-flaky.
|
||||
date: new Date().toLocaleDateString('en-CA'),
|
||||
inputTokens: 100,
|
||||
outputTokens: 200,
|
||||
estimatedCost: 0.02,
|
||||
|
||||
@@ -138,6 +138,8 @@ describe('Codex quick start settings', () => {
|
||||
expect(requests.find((req) => req.url === '/api/quick-start')?.body).toMatchObject({
|
||||
caseName: 'codex-case',
|
||||
mode: 'codex',
|
||||
// tabs follow the w<n>-<case> naming convention (quick-start would otherwise auto-name codeman-<id>)
|
||||
sessionName: 'w1-codex-case',
|
||||
codexConfig: { dangerouslyBypassApprovals: true, renderMode: 'hybrid' },
|
||||
});
|
||||
expect(selected).toEqual(['sess-1']);
|
||||
@@ -179,6 +181,30 @@ describe('case selector refresh', () => {
|
||||
expect(app.filterCasePickerOptions(options, 'plex').map((option: any) => option.name)).toEqual(['plex-previews']);
|
||||
});
|
||||
|
||||
it('labels dockerized cases with a short "(docker)" tag (or the custom host id)', () => {
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
localStorage: { getItem: () => null, setItem: () => {} },
|
||||
document: { getElementById: () => null },
|
||||
console,
|
||||
});
|
||||
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
||||
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
||||
const app = new (CodemanApp as any)();
|
||||
|
||||
const label = (c: any) => app.formatCasePickerLabel(c);
|
||||
// default one-click host, the 'local' Docker-tab default, and per-case override
|
||||
// hosts all collapse to the short "(docker)" tag.
|
||||
expect(
|
||||
label({ name: 'sandbox', location: 'docker', docker: { hostId: 'default', container: 'codeman-case-sandbox' } })
|
||||
).toBe('sandbox (docker)');
|
||||
expect(label({ name: 'sandbox', location: 'docker', docker: { hostId: 'local' } })).toBe('sandbox (docker)');
|
||||
expect(label({ name: 'sandbox', location: 'docker', docker: { hostId: 'q-sandbox' } })).toBe('sandbox (docker)');
|
||||
// a user-named docker host shows its id
|
||||
expect(label({ name: 'ml', location: 'docker', docker: { hostId: 'gpu-box' } })).toBe('ml (gpu-box)');
|
||||
});
|
||||
|
||||
it('launches the highlighted case with the current run mode when pressing Enter in the picker', () => {
|
||||
const elements: Record<string, any> = {};
|
||||
const listeners: Record<string, (event: any) => void> = {};
|
||||
|
||||
+10
-2
@@ -375,9 +375,17 @@ describe('types utility functions', () => {
|
||||
expect(ApiErrorCode.INTERNAL_ERROR).toBe('INTERNAL_ERROR');
|
||||
});
|
||||
|
||||
it('should have 9 error codes', () => {
|
||||
it('should have 14 error codes', () => {
|
||||
const codes = Object.values(ApiErrorCode);
|
||||
expect(codes).toHaveLength(9);
|
||||
expect(codes).toHaveLength(14);
|
||||
});
|
||||
|
||||
it('includes the multi-user error codes', () => {
|
||||
expect(ApiErrorCode.FORBIDDEN).toBe('FORBIDDEN');
|
||||
expect(ApiErrorCode.PASSWORD_CHANGE_REQUIRED).toBe('PASSWORD_CHANGE_REQUIRED');
|
||||
expect(ApiErrorCode.USER_EXISTS).toBe('USER_EXISTS');
|
||||
expect(ApiErrorCode.USER_NOT_FOUND).toBe('USER_NOT_FOUND');
|
||||
expect(ApiErrorCode.LAST_ADMIN).toBe('LAST_ADMIN');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for the multi-user store (src/user-store.ts).
|
||||
*
|
||||
* Pure helpers (hashing/verify/params-upgrade/username validation/6.3 resolvers)
|
||||
* plus the IO layer against a per-test temp data dir (CODEMAN_DATA_DIR) so nothing
|
||||
* touches the real ~/.codeman. No server, no tmux.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import { existsSync, statSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
bootstrapInitialAdmin,
|
||||
canRunPrivilegedCommands,
|
||||
countEnabledAdmins,
|
||||
createUser,
|
||||
DEFAULT_SCRYPT_PARAMS,
|
||||
deleteUser,
|
||||
deleteUserSpace,
|
||||
findUser,
|
||||
generateOneTimePassword,
|
||||
hashPassword,
|
||||
hasUsers,
|
||||
invalidateUsersCache,
|
||||
isValidUsername,
|
||||
needsRehash,
|
||||
normalizeUsername,
|
||||
readUsers,
|
||||
resolveClaudeModeForUser,
|
||||
setPassword,
|
||||
toPublicUser,
|
||||
touchLastLogin,
|
||||
updateUser,
|
||||
UserStoreError,
|
||||
verifyPasswordHash,
|
||||
} from '../src/user-store.js';
|
||||
|
||||
let tmpDir: string;
|
||||
let spacesDir: string;
|
||||
const savedEnv: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codeman-users-'));
|
||||
spacesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codeman-spaces-'));
|
||||
for (const k of [
|
||||
'CODEMAN_DATA_DIR',
|
||||
'CODEMAN_USER_SPACES_DIR',
|
||||
'CODEMAN_MULTIUSER',
|
||||
'CODEMAN_MAX_USERS',
|
||||
'CODEMAN_USERNAME',
|
||||
'CODEMAN_PASSWORD',
|
||||
]) {
|
||||
savedEnv[k] = process.env[k];
|
||||
}
|
||||
process.env.CODEMAN_DATA_DIR = tmpDir;
|
||||
process.env.CODEMAN_USER_SPACES_DIR = spacesDir;
|
||||
delete process.env.CODEMAN_MAX_USERS;
|
||||
invalidateUsersCache();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const [k, v] of Object.entries(savedEnv)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
invalidateUsersCache();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
await fs.rm(spacesDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
describe('username validation', () => {
|
||||
it('accepts valid slugs', () => {
|
||||
for (const n of ['alice', 'bob99', 'a1', 'x_y-z', 'user-name_1']) {
|
||||
expect(isValidUsername(n)).toBe(true);
|
||||
}
|
||||
});
|
||||
it('rejects invalid slugs', () => {
|
||||
for (const n of [
|
||||
'',
|
||||
'a',
|
||||
'A',
|
||||
'1',
|
||||
'_leading',
|
||||
'-leading',
|
||||
'has space',
|
||||
'has.dot',
|
||||
'a/b',
|
||||
'..',
|
||||
'toolongxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
|
||||
]) {
|
||||
expect(isValidUsername(n)).toBe(false);
|
||||
}
|
||||
});
|
||||
it('accepts mixed-case input by normalizing (case-insensitive usernames)', () => {
|
||||
expect(isValidUsername('Alice')).toBe(true);
|
||||
expect(normalizeUsername(' ALICE ')).toBe('alice');
|
||||
});
|
||||
});
|
||||
|
||||
describe('password hashing', () => {
|
||||
it('round-trips a correct password and rejects a wrong one', async () => {
|
||||
const h = await hashPassword('correct horse');
|
||||
expect(h.algo).toBe('scrypt');
|
||||
expect(h.salt).toMatch(/^[0-9a-f]+$/);
|
||||
expect(await verifyPasswordHash('correct horse', h)).toBe(true);
|
||||
expect(await verifyPasswordHash('wrong password', h)).toBe(false);
|
||||
});
|
||||
it('produces a distinct salt each time', async () => {
|
||||
const a = await hashPassword('same');
|
||||
const b = await hashPassword('same');
|
||||
expect(a.salt).not.toBe(b.salt);
|
||||
expect(a.hash).not.toBe(b.hash);
|
||||
});
|
||||
it('never throws on a malformed record', async () => {
|
||||
expect(await verifyPasswordHash('x', { algo: 'scrypt', N: 1, r: 1, p: 1, salt: 'zz', hash: '' })).toBe(false);
|
||||
// @ts-expect-error deliberately malformed
|
||||
expect(await verifyPasswordHash('x', { algo: 'bogus' })).toBe(false);
|
||||
});
|
||||
it('needsRehash detects weaker params', async () => {
|
||||
const h = await hashPassword('pw', DEFAULT_SCRYPT_PARAMS);
|
||||
expect(needsRehash(h)).toBe(false);
|
||||
expect(needsRehash({ ...h, N: 1024 })).toBe(true);
|
||||
expect(needsRehash({ ...h, algo: 'md5' as unknown as 'scrypt' })).toBe(true);
|
||||
});
|
||||
it('generateOneTimePassword returns a >=8 char url-safe string', () => {
|
||||
const pw = generateOneTimePassword();
|
||||
expect(pw.length).toBeGreaterThanOrEqual(8);
|
||||
expect(pw).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveClaudeModeForUser (section 6.3)', () => {
|
||||
it('admins are unrestricted', () => {
|
||||
expect(resolveClaudeModeForUser('dangerously-skip-permissions', { role: 'admin' })).toBe(
|
||||
'dangerously-skip-permissions'
|
||||
);
|
||||
});
|
||||
it('granted regular users keep bypass', () => {
|
||||
expect(resolveClaudeModeForUser('dangerously-skip-permissions', { role: 'user', canBypassPermissions: true })).toBe(
|
||||
'dangerously-skip-permissions'
|
||||
);
|
||||
});
|
||||
it('non-granted regular users downgrade skip -> auto', () => {
|
||||
expect(resolveClaudeModeForUser('dangerously-skip-permissions', { role: 'user' })).toBe('auto');
|
||||
expect(resolveClaudeModeForUser(undefined, { role: 'user' })).toBe('auto');
|
||||
});
|
||||
it('non-granted regular users pass through modes already <= auto', () => {
|
||||
expect(resolveClaudeModeForUser('auto', { role: 'user' })).toBe('auto');
|
||||
expect(resolveClaudeModeForUser('normal', { role: 'user' })).toBe('normal');
|
||||
expect(resolveClaudeModeForUser('allowedTools', { role: 'user' })).toBe('allowedTools');
|
||||
});
|
||||
it('canRunPrivilegedCommands follows the same grant', () => {
|
||||
expect(canRunPrivilegedCommands({ role: 'admin' })).toBe(true);
|
||||
expect(canRunPrivilegedCommands({ role: 'user', canBypassPermissions: true })).toBe(true);
|
||||
expect(canRunPrivilegedCommands({ role: 'user' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('user store IO', () => {
|
||||
it('creates, reads back, and writes users.json at mode 0600 atomically', async () => {
|
||||
expect(await hasUsers()).toBe(false);
|
||||
const u = await createUser({ username: 'Alice', role: 'admin', password: 'password1' });
|
||||
expect(u.username).toBe('alice');
|
||||
expect(u.role).toBe('admin');
|
||||
expect(await hasUsers()).toBe(true);
|
||||
|
||||
const file = path.join(tmpDir, 'users.json');
|
||||
expect(existsSync(file)).toBe(true);
|
||||
// 0600 on POSIX
|
||||
if (process.platform !== 'win32') {
|
||||
expect(statSync(file).mode & 0o777).toBe(0o600);
|
||||
}
|
||||
// no leftover tmp file
|
||||
expect(existsSync(file + '.tmp')).toBe(false);
|
||||
|
||||
const found = await findUser('ALICE');
|
||||
expect(found?.username).toBe('alice');
|
||||
expect(toPublicUser(found!)).not.toHaveProperty('password');
|
||||
});
|
||||
|
||||
it('rejects duplicate usernames case-insensitively', async () => {
|
||||
await createUser({ username: 'bob', role: 'user', password: 'password1' });
|
||||
await expect(createUser({ username: 'BOB', role: 'user', password: 'password2' })).rejects.toMatchObject({
|
||||
code: 'USER_EXISTS',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid username and short password', async () => {
|
||||
await expect(createUser({ username: 'Bad Name', role: 'user', password: 'password1' })).rejects.toBeInstanceOf(
|
||||
UserStoreError
|
||||
);
|
||||
await expect(createUser({ username: 'good', role: 'user', password: 'short' })).rejects.toMatchObject({
|
||||
code: 'INVALID_INPUT',
|
||||
});
|
||||
});
|
||||
|
||||
it('enforces MAX_USERS', async () => {
|
||||
process.env.CODEMAN_MAX_USERS = '2';
|
||||
await createUser({ username: 'a1', role: 'admin', password: 'password1' });
|
||||
await createUser({ username: 'a2', role: 'user', password: 'password1' });
|
||||
await expect(createUser({ username: 'a3', role: 'user', password: 'password1' })).rejects.toMatchObject({
|
||||
code: 'INVALID_INPUT',
|
||||
});
|
||||
});
|
||||
|
||||
it('setPassword changes the hash and can clear mustChangePassword', async () => {
|
||||
await createUser({ username: 'carol', role: 'user', password: 'password1', mustChangePassword: true });
|
||||
const before = await findUser('carol');
|
||||
expect(before?.mustChangePassword).toBe(true);
|
||||
await setPassword('carol', 'password2', { mustChangePassword: false });
|
||||
const after = await findUser('carol');
|
||||
expect(after?.mustChangePassword).toBe(false);
|
||||
expect(await verifyPasswordHash('password2', after!.password)).toBe(true);
|
||||
expect(await verifyPasswordHash('password1', after!.password)).toBe(false);
|
||||
});
|
||||
|
||||
it('touchLastLogin records a timestamp', async () => {
|
||||
await createUser({ username: 'dave', role: 'user', password: 'password1' });
|
||||
expect((await findUser('dave'))?.lastLoginAt).toBeUndefined();
|
||||
await touchLastLogin('dave');
|
||||
expect((await findUser('dave'))?.lastLoginAt).toBeTypeOf('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('last-admin invariants', () => {
|
||||
it('cannot demote the last enabled admin', async () => {
|
||||
await createUser({ username: 'root', role: 'admin', password: 'password1' });
|
||||
await createUser({ username: 'joe', role: 'user', password: 'password1' });
|
||||
expect(countEnabledAdmins(await readUsers(true))).toBe(1);
|
||||
await expect(updateUser('root', { role: 'user' })).rejects.toMatchObject({ code: 'LAST_ADMIN' });
|
||||
await expect(updateUser('root', { disabled: true })).rejects.toMatchObject({ code: 'LAST_ADMIN' });
|
||||
});
|
||||
|
||||
it('cannot delete the last enabled admin', async () => {
|
||||
await createUser({ username: 'root', role: 'admin', password: 'password1' });
|
||||
await expect(deleteUser('root')).rejects.toMatchObject({ code: 'LAST_ADMIN' });
|
||||
});
|
||||
|
||||
it('allows demote/delete when another admin remains', async () => {
|
||||
await createUser({ username: 'root', role: 'admin', password: 'password1' });
|
||||
await createUser({ username: 'root2', role: 'admin', password: 'password1' });
|
||||
await expect(updateUser('root', { role: 'user' })).resolves.toMatchObject({ role: 'user' });
|
||||
await createUser({ username: 'root3', role: 'admin', password: 'password1' });
|
||||
await expect(deleteUser('root2')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('updateUser toggles canBypassPermissions', async () => {
|
||||
await createUser({ username: 'grantee', role: 'user', password: 'password1' });
|
||||
const updated = await updateUser('grantee', { canBypassPermissions: true });
|
||||
expect(updated.canBypassPermissions).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteUserSpace guards (section 8)', () => {
|
||||
it('deletes a real space dir inside USER_SPACES_DIR', async () => {
|
||||
const dir = path.join(spacesDir, 'ed', 'cases', 'proj');
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(spacesDir, 'ed', 'cases', 'proj', 'f.txt'), 'x');
|
||||
expect(existsSync(path.join(spacesDir, 'ed'))).toBe(true);
|
||||
await deleteUserSpace('ed');
|
||||
expect(existsSync(path.join(spacesDir, 'ed'))).toBe(false);
|
||||
});
|
||||
|
||||
it('is a no-op when the space does not exist', async () => {
|
||||
await expect(deleteUserSpace('ghost')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses to delete a symlinked user space', async () => {
|
||||
const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'codeman-outside-'));
|
||||
await fs.symlink(outside, path.join(spacesDir, 'evil'));
|
||||
await expect(deleteUserSpace('evil')).rejects.toMatchObject({ code: 'INVALID_INPUT' });
|
||||
// the symlink target still exists (was not followed + removed)
|
||||
expect(existsSync(outside)).toBe(true);
|
||||
await fs.rm(outside, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('bootstrapInitialAdmin', () => {
|
||||
it('creates the initial admin from env when no users exist', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
process.env.CODEMAN_USERNAME = 'boss';
|
||||
process.env.CODEMAN_PASSWORD = 'password1';
|
||||
const r = await bootstrapInitialAdmin();
|
||||
expect(r).toMatchObject({ status: 'created', username: 'boss' });
|
||||
expect((await findUser('boss'))?.role).toBe('admin');
|
||||
});
|
||||
|
||||
it('reports missing-env when no users and no credentials', async () => {
|
||||
delete process.env.CODEMAN_USERNAME;
|
||||
delete process.env.CODEMAN_PASSWORD;
|
||||
expect(await bootstrapInitialAdmin()).toMatchObject({ status: 'missing-env' });
|
||||
});
|
||||
|
||||
it('reports exists when users already present', async () => {
|
||||
await createUser({ username: 'someone', role: 'admin', password: 'password1' });
|
||||
expect(await bootstrapInitialAdmin()).toMatchObject({ status: 'exists' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user