Merge master into PR #156 (remote tmux durability)

Resolutions:
- session.ts: keep the extracted _buildRespawnPaneOptions() helper (COD-108)
  and add master's docker/owner fields to it
- tmux-manager.ts: docker branch first, then remote via buildRemoteSessionCommand
  (now an options object threading claudeMode/allowedTools into
  buildRemoteLaunchCommand, preserving the 6.3 multi-user permission downgrade)
- case-routes.ts: keep master's adminOnly helper; gate the new COD-105 discovery
  endpoint admin-only in multi-user mode (hosts are machine-level infra)
- settings-ui.js: union of remoteAutoReconnect + master's header-button defaults
- session-routes.ts: union of imports; session gets remote + owner

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-07-20 14:27:02 +02:00
88 changed files with 11862 additions and 616 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;
}
}
+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);
});
});
+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' });
});
});