test/claude-trust.test.js
Rama main · réplica de lectura del repositorio original.
// Pre-seeding Claude Code's workspace trust for board workers (server/claude-trust.js).
// Every test runs against a temp HOME: the real ~/.claude.json is never touched.
import { describe, it, expect, beforeEach } from 'vitest';
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync, realpathSync, symlinkSync, lstatSync, existsSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { autoTrusts, trustClaudeFolder, codexTrustArgs } from '../server/claude-trust.js';
import { createSessionFromConfig } from '../server/pty.js';
let home, file, wt;
beforeEach(() => {
home = realpathSync(mkdtempSync(join(tmpdir(), 'claude-trust-')));
file = join(home, '.claude.json');
wt = join(home, 'worktrees', 'Falcon');
mkdirSync(wt, { recursive: true });
});
describe('trustClaudeFolder', () => {
it('adds only the one entry and keeps everything else, key order and mode included', () => {
const before = {
numStartups: 9, theme: 'light',
projects: { '/elsewhere': { hasTrustDialogAccepted: false, allowedTools: ['Bash'] } },
userID: 'u1',
};
writeFileSync(file, JSON.stringify(before, null, 2), { mode: 0o600 });
expect(trustClaudeFolder(wt, { home, env: {} })).toBe(true);
const after = JSON.parse(readFileSync(file, 'utf8'));
expect(after).toEqual({ ...before, projects: { ...before.projects, [wt]: { hasTrustDialogAccepted: true } } });
expect(Object.keys(after)).toEqual(Object.keys(before));
if (process.platform !== 'win32') expect(statSync(file).mode & 0o777).toBe(0o600); // Windows has no POSIX modes
expect(readdirSync(home).filter(f => f.includes('.tmp'))).toEqual([]);
});
it('keeps the fields of an entry that already exists for the path', () => {
writeFileSync(file, JSON.stringify({ projects: { [wt]: { allowedTools: ['Read'] } } }));
trustClaudeFolder(wt, { home, env: {} });
expect(JSON.parse(readFileSync(file, 'utf8')).projects[wt]).toEqual({ allowedTools: ['Read'], hasTrustDialogAccepted: true });
});
it('records the real path, as claude sees its cwd', () => {
writeFileSync(file, '{}');
trustClaudeFolder(join(wt, '..', 'Falcon'), { home, env: {} });
expect(Object.keys(JSON.parse(readFileSync(file, 'utf8')).projects)).toEqual([wt]);
});
it('does not rewrite a file that already trusts the path', () => {
const text = JSON.stringify({ projects: { [wt]: { hasTrustDialogAccepted: true } } });
writeFileSync(file, text);
expect(trustClaudeFolder(wt, { home, env: {} })).toBe(true);
expect(readFileSync(file, 'utf8')).toBe(text);
});
it('leaves a malformed file exactly as it was', () => {
for (const text of ['{"projects": {', '[1,2]', 'null', '{"projects": [1]}', '{"projects": "x"}', `{"projects": {${JSON.stringify(wt)}: "x"}}`]) {
writeFileSync(file, text);
expect(trustClaudeFolder(wt, { home, env: {} })).toBe(false);
expect(readFileSync(file, 'utf8')).toBe(text);
}
expect(readdirSync(home).filter(f => f.includes('.tmp'))).toEqual([]);
});
it('writes through a symlinked file and leaves the link in place', () => {
const real = join(home, 'dotfiles.json');
writeFileSync(real, '{"theme":"dark"}');
symlinkSync(real, file);
expect(trustClaudeFolder(wt, { home, env: {} })).toBe(true);
expect(lstatSync(file).isSymbolicLink()).toBe(true);
expect(JSON.parse(readFileSync(real, 'utf8'))).toEqual({ theme: 'dark', projects: { [wt]: { hasTrustDialogAccepted: true } } });
});
it('uses CLAUDE_CONFIG_DIR when set, as claude does', () => {
const dir = join(home, 'profile');
mkdirSync(dir);
writeFileSync(join(dir, '.claude.json'), '{}');
writeFileSync(file, '{}');
expect(trustClaudeFolder(wt, { home, env: { CLAUDE_CONFIG_DIR: dir } })).toBe(true);
expect(JSON.parse(readFileSync(join(dir, '.claude.json'), 'utf8')).projects[wt]).toEqual({ hasTrustDialogAccepted: true });
expect(readFileSync(file, 'utf8')).toBe('{}');
});
it('replaces a temp file left by a crash instead of writing through it', () => {
writeFileSync(file, '{}', { mode: 0o600 });
const stale = `${file}.agent007-${process.pid}.tmp`;
writeFileSync(stale, 'junk', { mode: 0o644 });
expect(trustClaudeFolder(wt, { home, env: {} })).toBe(true);
if (process.platform !== 'win32') expect(statSync(file).mode & 0o777).toBe(0o600); // Windows has no POSIX modes
expect(readdirSync(home).filter(f => f.includes('.tmp'))).toEqual([]);
});
it('does not create the file when claude has none', () => {
expect(trustClaudeFolder(wt, { home, env: {} })).toBe(false);
expect(readdirSync(home)).toEqual(['worktrees']);
});
});
describe('autoTrusts', () => {
const board = { spawnedBy: 'board', worktreePath: '/wt', command: "claude --permission-mode auto 'do it'" };
it('covers board-dispatched Claude Code workers by default', () => {
expect(autoTrusts(board, {})).toBe(true);
});
it('is off when TRUST_BOARD_WORKTREES opts out', () => {
for (const v of ['0', 'false', 'OFF', 'no', ' 0 ']) expect(autoTrusts(board, { TRUST_BOARD_WORKTREES: v })).toBe(false);
expect(autoTrusts(board, { TRUST_BOARD_WORKTREES: '1' })).toBe(true);
});
it('covers board-dispatched Codex workers too, under the same opt-out', () => {
const codex = { ...board, command: "codex 'do it'" };
expect(autoTrusts(codex, {})).toBe(true);
expect(autoTrusts(codex, { TRUST_BOARD_WORKTREES: '0' })).toBe(false);
expect(autoTrusts({ ...codex, spawnedBy: 'user' }, {})).toBe(false);
});
it('leaves hand-started agents, other CLIs and repo-less sessions alone', () => {
expect(autoTrusts({ ...board, spawnedBy: 'user' }, {})).toBe(false);
expect(autoTrusts({ ...board, command: 'gemini hi' }, {})).toBe(false);
expect(autoTrusts({ ...board, worktreePath: null }, {})).toBe(false);
});
});
describe('codexTrustArgs', () => {
it('trusts only the worktree, by its real path, as a TOML inline table', () => {
const dotted = join(home, '.agent-007', 'wt.1');
mkdirSync(dotted, { recursive: true });
expect(codexTrustArgs(dotted)).toEqual(['-c', `projects={${JSON.stringify(dotted)}={trust_level="trusted"}}`]);
if (process.platform !== 'win32') { // symlinks need privileges there
symlinkSync(dotted, join(home, 'link'));
expect(codexTrustArgs(join(home, 'link'))[1]).toContain(JSON.stringify(dotted));
}
});
it('writes nothing to CODEX_HOME', () => {
codexTrustArgs(wt);
expect(readdirSync(home)).toEqual(['worktrees']);
});
});
// A stand-in `codex` that records the arguments it was started with.
describe.skipIf(process.platform === 'win32')('a Codex spawn', () => {
const spawnCodex = (extra) => {
const bin = join(home, 'bin');
mkdirSync(bin, { recursive: true });
writeFileSync(join(bin, 'codex'), `#!/bin/sh\nprintf '%s\\n' "$@" > "${home}/args"\n`, { mode: 0o755 });
const PATH = process.env.PATH;
process.env.PATH = `${bin}:${PATH}`;
try {
return createSessionFromConfig({ sessionId: `ct-${Math.random()}`, name: 'T', color: '#000', command: "codex 'do it'", worktreePath: wt, spawnedBy: 'board', ...extra }, () => {});
} finally { process.env.PATH = PATH; }
};
const argsSeen = async () => {
for (let i = 0; i < 100 && !existsSync(join(home, 'args')); i++) await new Promise(r => setTimeout(r, 20));
return readFileSync(join(home, 'args'), 'utf8');
};
const done = (r) => { try { r.session.pty.kill(); } catch {} clearInterval(r.session.stateCheckInterval); };
it('gets the trust flag and no PTY answering when auto-trusted', async () => {
const r = spawnCodex({ autoTrust: true });
try {
expect(await argsSeen()).toContain(`projects={${JSON.stringify(wt)}={trust_level="trusted"}}`);
expect(r.session.answersTrust).toBe(false);
} finally { done(r); }
});
it('gets no trust flag otherwise', async () => {
const r = spawnCodex({ spawnedBy: 'user' });
try {
const seen = await argsSeen();
expect(seen).toContain('do it');
expect(seen).not.toContain('trust_level');
} finally { done(r); }
});
});
describe('trustClaudeFolder on a fresh install', () => {
it('says nothing when Claude Code has no ~/.claude.json yet', () => {
const errors = [];
const saved = console.error;
console.error = (...a) => errors.push(a.join(' '));
try { expect(trustClaudeFolder(wt, { home, env: {} })).toBe(false); } finally { console.error = saved; }
expect(errors).toEqual([]);
expect(existsSync(file)).toBe(false);
});
});