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:
Codeman maintainer
2026-07-20 14:31:44 +02:00
98 changed files with 13899 additions and 650 deletions
+147
View File
@@ -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';
}
});
});
+83
View File
@@ -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);
});
});
+83
View File
@@ -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');
});
});
+208
View File
@@ -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'");
});
});
+160
View File
@@ -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');
});
});
+465
View File
@@ -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();
});
});
+4 -1
View File
@@ -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 () => {
+1 -1
View File
@@ -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}="([^"]*)"`));
+207
View File
@@ -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 });
});
});
+7
View File
@@ -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);
+178
View File
@@ -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;
}
}
+298
View File
@@ -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 }]);
});
});
+151
View File
@@ -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);
});
});
+59
View File
@@ -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');
});
});
+39
View File
@@ -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);
});
});
+2
View File
@@ -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)}`;
+113
View File
@@ -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([]);
});
});
+2 -2
View File
@@ -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);
});
});
+44 -1
View File
@@ -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) })
);
});
});
+5 -1
View File
@@ -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,
+26
View File
@@ -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
View File
@@ -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');
});
});
});
+301
View File
@@ -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' });
});
});