Stabilize paired reviewer recovery and owner follow-up

This commit is contained in:
ejclaw
2026-04-06 07:49:26 +09:00
parent 994e957767
commit d75d26c23d
27 changed files with 1474 additions and 166 deletions

View File

@@ -66,7 +66,7 @@ User message
├─ Arbiter enabled → Arbiter judges → PROCEED/REVISE/RESET/ESCALATE ├─ Arbiter enabled → Arbiter judges → PROCEED/REVISE/RESET/ESCALATE
└─ Arbiter disabled → Escalate to user → @user ⚠️ └─ Arbiter disabled → Escalate to user → @user ⚠️
→ Owner BLOCKED/NEEDS_CONTEXT → Arbiter (same path as reviewer) → Owner BLOCKED/NEEDS_CONTEXT → Arbiter (same path as reviewer)
→ Deadlock (3+ round trips without progress) → Deadlock (2+ round trips without progress)
→ Arbiter summoned → binding verdict → loop resumes → Arbiter summoned → binding verdict → loop resumes
``` ```

View File

@@ -73,7 +73,7 @@ User message
├─ Arbiter enabled → Arbiter judges → PROCEED/REVISE/RESET/ESCALATE ├─ Arbiter enabled → Arbiter judges → PROCEED/REVISE/RESET/ESCALATE
└─ Arbiter disabled → Escalate to user → @user ⚠️ └─ Arbiter disabled → Escalate to user → @user ⚠️
→ Owner BLOCKED/NEEDS_CONTEXT → Arbiter (same path as reviewer) → Owner BLOCKED/NEEDS_CONTEXT → Arbiter (same path as reviewer)
→ Deadlock (3+ round trips without progress) → Deadlock (2+ round trips without progress)
→ Arbiter summoned → binding verdict → loop resumes → Arbiter summoned → binding verdict → loop resumes
``` ```

View File

@@ -25,7 +25,10 @@ import {
} from './room-role-context.js'; } from './room-role-context.js';
import { import {
assertReadonlyWorkspaceRepoConnectivity, assertReadonlyWorkspaceRepoConnectivity,
buildClaudeReadonlySandboxSettings,
buildReviewerGitGuardEnv, buildReviewerGitGuardEnv,
getClaudeReadonlySandboxMode,
isClaudeReadonlyReviewerRuntime,
isReviewerMutatingShellCommand, isReviewerMutatingShellCommand,
isReviewerRuntime, isReviewerRuntime,
} from './reviewer-runtime.js'; } from './reviewer-runtime.js';
@@ -545,6 +548,8 @@ async function runQuery(
containerInput: ContainerInput, containerInput: ContainerInput,
sdkEnv: Record<string, string | undefined>, sdkEnv: Record<string, string | undefined>,
reviewerRuntime: boolean, reviewerRuntime: boolean,
claudeReadonlyReviewerRuntime: boolean,
claudeReadonlySandboxMode: 'strict' | 'best-effort' | null,
): Promise<{ ): Promise<{
newSessionId?: string; newSessionId?: string;
closedDuringQuery: boolean; closedDuringQuery: boolean;
@@ -622,8 +627,20 @@ async function runQuery(
if (thinking) log(`Thinking config: ${JSON.stringify(thinking)}`); if (thinking) log(`Thinking config: ${JSON.stringify(thinking)}`);
if (effort) log(`Effort: ${effort}`); if (effort) log(`Effort: ${effort}`);
if (reviewerRuntime) log('Reviewer runtime restrictions enabled'); if (reviewerRuntime) log('Reviewer runtime restrictions enabled');
if (claudeReadonlyReviewerRuntime) {
log(
`Claude host reviewer read-only sandbox enabled (${claudeReadonlySandboxMode || 'unknown'})`,
);
}
if (claudeReadonlySandboxMode === 'best-effort') {
log(
'Claude host reviewer sandbox capability unavailable on this host, using best-effort read-only mode',
);
}
const allowedTools = reviewerRuntime const readonlyReviewerRuntime =
reviewerRuntime || claudeReadonlyReviewerRuntime;
const allowedTools = readonlyReviewerRuntime
? [ ? [
'Bash', 'Bash',
'Read', 'Glob', 'Grep', 'Read', 'Glob', 'Grep',
@@ -644,6 +661,18 @@ async function runQuery(
'mcp__ejclaw__*' 'mcp__ejclaw__*'
]; ];
const readonlyProtectedPaths = [effectiveCwd, ...extraDirs].filter(
(value): value is string => Boolean(value),
);
const claudeReadonlySandboxSettings =
claudeReadonlyReviewerRuntime && claudeReadonlySandboxMode
? buildClaudeReadonlySandboxSettings(
readonlyProtectedPaths,
undefined,
claudeReadonlySandboxMode,
)
: undefined;
for await (const message of query({ for await (const message of query({
prompt: stream, prompt: stream,
options: { options: {
@@ -657,6 +686,11 @@ async function runQuery(
env: sdkEnv, env: sdkEnv,
permissionMode: 'bypassPermissions', permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true, allowDangerouslySkipPermissions: true,
...(claudeReadonlySandboxSettings
? {
sandbox: claudeReadonlySandboxSettings,
}
: {}),
settingSources: ['project', 'user'], settingSources: ['project', 'user'],
abortController: agentAbortController, abortController: agentAbortController,
mcpServers: { mcpServers: {
@@ -684,7 +718,7 @@ async function runQuery(
PreToolUse: [ PreToolUse: [
{ {
matcher: 'Bash', matcher: 'Bash',
hooks: reviewerRuntime hooks: readonlyReviewerRuntime
? [createReviewerBashGuardHook(), createSanitizeBashHook()] ? [createReviewerBashGuardHook(), createSanitizeBashHook()]
: [createSanitizeBashHook()], : [createSanitizeBashHook()],
}, },
@@ -950,9 +984,19 @@ async function main(): Promise<void> {
const reviewerRuntime = const reviewerRuntime =
process.env.EJCLAW_REVIEWER_RUNTIME === '1' || process.env.EJCLAW_REVIEWER_RUNTIME === '1' ||
isReviewerRuntime(containerInput.roomRoleContext); isReviewerRuntime(containerInput.roomRoleContext);
const claudeReadonlyReviewerRuntime =
isClaudeReadonlyReviewerRuntime(containerInput.roomRoleContext);
const claudeReadonlySandboxMode = claudeReadonlyReviewerRuntime
? getClaudeReadonlySandboxMode()
: null;
const readonlyRuntime = const readonlyRuntime =
reviewerRuntime || process.env.EJCLAW_ARBITER_RUNTIME === '1'; reviewerRuntime ||
const guardedSdkEnv = buildReviewerGitGuardEnv(sdkEnv, reviewerRuntime); claudeReadonlyReviewerRuntime ||
process.env.EJCLAW_ARBITER_RUNTIME === '1';
const guardedSdkEnv = buildReviewerGitGuardEnv(
sdkEnv,
reviewerRuntime || claudeReadonlyReviewerRuntime,
);
assertReadonlyWorkspaceRepoConnectivity(guardedSdkEnv, readonlyRuntime); assertReadonlyWorkspaceRepoConnectivity(guardedSdkEnv, readonlyRuntime);
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -1092,6 +1136,8 @@ async function main(): Promise<void> {
containerInput, containerInput,
guardedSdkEnv, guardedSdkEnv,
reviewerRuntime, reviewerRuntime,
claudeReadonlyReviewerRuntime,
claudeReadonlySandboxMode,
); );
if (queryResult.newSessionId) { if (queryResult.newSessionId) {
sessionId = queryResult.newSessionId; sessionId = queryResult.newSessionId;

View File

@@ -5,6 +5,8 @@ import path from 'path';
import type { RoomRoleContext } from './room-role-context.js'; import type { RoomRoleContext } from './room-role-context.js';
export type ClaudeReadonlySandboxMode = 'strict' | 'best-effort';
const BLOCKED_GIT_SUBCOMMANDS = new Set([ const BLOCKED_GIT_SUBCOMMANDS = new Set([
'add', 'add',
'am', 'am',
@@ -40,6 +42,89 @@ export function isReviewerRuntime(
return roomRoleContext?.role === 'reviewer'; return roomRoleContext?.role === 'reviewer';
} }
export function isClaudeReadonlyReviewerRuntime(
roomRoleContext?: RoomRoleContext,
): boolean {
return (
process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE === '1' &&
process.env.EJCLAW_CLAUDE_REVIEWER_READONLY === '1' &&
roomRoleContext?.role === 'reviewer'
);
}
let cachedLinuxBubblewrapReadonlyCapability: boolean | undefined;
function commandExistsForRuntime(command: string): boolean {
try {
execFileSync('bash', ['-lc', `command -v ${command}`], {
stdio: ['ignore', 'ignore', 'ignore'],
});
return true;
} catch {
return false;
}
}
export function canUseLinuxBubblewrapReadonlySandbox(): boolean {
if (cachedLinuxBubblewrapReadonlyCapability != null) {
return cachedLinuxBubblewrapReadonlyCapability;
}
if (os.platform() !== 'linux') {
cachedLinuxBubblewrapReadonlyCapability = false;
return false;
}
if (!commandExistsForRuntime('bwrap')) {
cachedLinuxBubblewrapReadonlyCapability = false;
return false;
}
try {
execFileSync('bwrap', ['--ro-bind', '/', '/', '/bin/true'], {
stdio: ['ignore', 'ignore', 'ignore'],
});
cachedLinuxBubblewrapReadonlyCapability = true;
} catch {
cachedLinuxBubblewrapReadonlyCapability = false;
}
return cachedLinuxBubblewrapReadonlyCapability;
}
export function getClaudeReadonlySandboxMode(
platform: NodeJS.Platform = os.platform(),
linuxCapabilityProbe: () => boolean = canUseLinuxBubblewrapReadonlySandbox,
): ClaudeReadonlySandboxMode {
if (platform === 'linux') {
return linuxCapabilityProbe() ? 'strict' : 'best-effort';
}
return 'best-effort';
}
export function buildClaudeReadonlySandboxSettings(
protectedPaths: string[],
platform: NodeJS.Platform = os.platform(),
sandboxMode: ClaudeReadonlySandboxMode = getClaudeReadonlySandboxMode(
platform,
),
) {
const normalizedPaths = [...new Set(
protectedPaths
.filter((value): value is string => Boolean(value))
.map((value) => path.resolve(value)),
)];
return {
enabled: true,
failIfUnavailable: sandboxMode === 'strict',
autoAllowBashIfSandboxed: true,
allowUnsandboxedCommands: false,
filesystem:
normalizedPaths.length > 0
? { denyWrite: normalizedPaths }
: undefined,
};
}
function resolveGitBinary(baseEnv: NodeJS.ProcessEnv): string { function resolveGitBinary(baseEnv: NodeJS.ProcessEnv): string {
return execFileSync('bash', ['-lc', 'command -v git'], { return execFileSync('bash', ['-lc', 'command -v git'], {
encoding: 'utf-8', encoding: 'utf-8',

View File

@@ -3,15 +3,44 @@ import fs from 'fs';
import os from 'os'; import os from 'os';
import path from 'path'; import path from 'path';
import { describe, expect, it } from 'vitest'; import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { import {
assertReadonlyWorkspaceRepoConnectivity, assertReadonlyWorkspaceRepoConnectivity,
buildClaudeReadonlySandboxSettings,
buildReviewerGitGuardEnv, buildReviewerGitGuardEnv,
getClaudeReadonlySandboxMode,
isClaudeReadonlyReviewerRuntime,
isReviewerMutatingShellCommand, isReviewerMutatingShellCommand,
isReviewerRuntime, isReviewerRuntime,
} from '../src/reviewer-runtime.js'; } from '../src/reviewer-runtime.js';
const ORIGINAL_CLAUDE_REVIEWER_READONLY =
process.env.EJCLAW_CLAUDE_REVIEWER_READONLY;
const ORIGINAL_UNSAFE_HOST_PAIRED_MODE =
process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE;
afterEach(() => {
if (ORIGINAL_CLAUDE_REVIEWER_READONLY == null) {
delete process.env.EJCLAW_CLAUDE_REVIEWER_READONLY;
} else {
process.env.EJCLAW_CLAUDE_REVIEWER_READONLY =
ORIGINAL_CLAUDE_REVIEWER_READONLY;
}
if (ORIGINAL_UNSAFE_HOST_PAIRED_MODE == null) {
delete process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE;
} else {
process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE =
ORIGINAL_UNSAFE_HOST_PAIRED_MODE;
}
});
beforeEach(() => {
delete process.env.EJCLAW_CLAUDE_REVIEWER_READONLY;
delete process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE;
});
function createTempRepo(prefix: string): string { function createTempRepo(prefix: string): string {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); const cwd = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
execFileSync('git', ['init'], { execFileSync('git', ['init'], {
@@ -56,6 +85,76 @@ describe('claude reviewer runtime guard', () => {
).toBe(true); ).toBe(true);
}); });
it('detects claude host reviewer read-only mode from env + role', () => {
process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE = '1';
process.env.EJCLAW_CLAUDE_REVIEWER_READONLY = '1';
expect(
isClaudeReadonlyReviewerRuntime({
serviceId: 'claude',
role: 'reviewer',
ownerServiceId: 'codex-main',
reviewerServiceId: 'claude',
failoverOwner: false,
}),
).toBe(true);
expect(
isClaudeReadonlyReviewerRuntime({
serviceId: 'claude',
role: 'owner',
ownerServiceId: 'codex-main',
reviewerServiceId: 'claude',
failoverOwner: false,
}),
).toBe(false);
});
it('builds a read-only sandbox with normalized protected paths', () => {
const sandbox = buildClaudeReadonlySandboxSettings([
'/repo/work',
'/repo/work',
'/repo/owner/../owner',
], 'linux', 'strict');
expect(sandbox).toMatchObject({
enabled: true,
failIfUnavailable: true,
autoAllowBashIfSandboxed: true,
allowUnsandboxedCommands: false,
filesystem: {
denyWrite: ['/repo/work', '/repo/owner'],
},
});
});
it('uses best-effort sandbox mode when linux capability probe fails', () => {
expect(getClaudeReadonlySandboxMode('linux', () => false)).toBe(
'best-effort',
);
const sandbox = buildClaudeReadonlySandboxSettings(
['/repo/work'],
'linux',
'best-effort',
);
expect(sandbox.failIfUnavailable).toBe(false);
});
it('keeps non-linux reviewers in best-effort sandbox mode', () => {
expect(getClaudeReadonlySandboxMode('darwin', () => true)).toBe(
'best-effort',
);
const sandbox = buildClaudeReadonlySandboxSettings(
['/repo/work'],
'darwin',
'best-effort',
);
expect(sandbox.failIfUnavailable).toBe(false);
});
it('flags mutating shell commands', () => { it('flags mutating shell commands', () => {
expect(isReviewerMutatingShellCommand('git commit -m "x"')).toBe(false); expect(isReviewerMutatingShellCommand('git commit -m "x"')).toBe(false);
expect(isReviewerMutatingShellCommand('git -c color.ui=false commit -m "x"')).toBe( expect(isReviewerMutatingShellCommand('git -c color.ui=false commit -m "x"')).toBe(

View File

@@ -9,7 +9,14 @@ import { Database } from 'bun:sqlite';
import { STORE_DIR } from '../src/config.js'; import { STORE_DIR } from '../src/config.js';
import { logger } from '../src/logger.js'; import { logger } from '../src/logger.js';
import { getPlatform, isHeadless, isWSL } from './platform.js'; import {
canUseLinuxBubblewrapReadonlySandbox,
commandExists,
getAppArmorRestrictUnprivilegedUsernsValue,
getPlatform,
isHeadless,
isWSL,
} from './platform.js';
import { emitStatus } from './status.js'; import { emitStatus } from './status.js';
export async function run(_args: string[]): Promise<void> { export async function run(_args: string[]): Promise<void> {
@@ -20,6 +27,14 @@ export async function run(_args: string[]): Promise<void> {
const platform = getPlatform(); const platform = getPlatform();
const wsl = isWSL(); const wsl = isWSL();
const headless = isHeadless(); const headless = isHeadless();
const hasBubblewrap = platform === 'linux' && commandExists('bwrap');
const hasSocat = platform === 'linux' && commandExists('socat');
const apparmorRestrictUnprivilegedUserns =
platform === 'linux'
? getAppArmorRestrictUnprivilegedUsernsValue()
: null;
const hasBubblewrapReadonlySandboxCapability =
platform === 'linux' && canUseLinuxBubblewrapReadonlySandbox();
// Check existing config // Check existing config
const hasEnv = fs.existsSync(path.join(projectRoot, '.env')); const hasEnv = fs.existsSync(path.join(projectRoot, '.env'));
@@ -55,6 +70,10 @@ export async function run(_args: string[]): Promise<void> {
hasEnv, hasEnv,
hasAuth, hasAuth,
hasRegisteredGroups, hasRegisteredGroups,
hasBubblewrap,
hasSocat,
apparmorRestrictUnprivilegedUserns,
hasBubblewrapReadonlySandboxCapability,
}, },
'Environment check complete', 'Environment check complete',
); );
@@ -66,6 +85,12 @@ export async function run(_args: string[]): Promise<void> {
HAS_ENV: hasEnv, HAS_ENV: hasEnv,
HAS_AUTH: hasAuth, HAS_AUTH: hasAuth,
HAS_REGISTERED_GROUPS: hasRegisteredGroups, HAS_REGISTERED_GROUPS: hasRegisteredGroups,
HAS_BWRAP: hasBubblewrap,
HAS_SOCAT: hasSocat,
APPARMOR_RESTRICT_UNPRIVILEGED_USERNS:
apparmorRestrictUnprivilegedUserns ?? 'n/a',
HAS_BWRAP_READONLY_SANDBOX_CAPABILITY:
hasBubblewrapReadonlySandboxCapability,
STATUS: 'success', STATUS: 'success',
LOG: 'logs/setup.log', LOG: 'logs/setup.log',
}); });

View File

@@ -1,6 +1,9 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { import {
ensureLinuxReadonlySandboxAppArmorSupport,
getAppArmorRestrictUnprivilegedUsernsValue,
canUseLinuxBubblewrapReadonlySandbox,
getPlatform, getPlatform,
isWSL, isWSL,
isRoot, isRoot,
@@ -99,6 +102,218 @@ describe('commandExists', () => {
}); });
}); });
describe('canUseLinuxBubblewrapReadonlySandbox', () => {
it('returns false outside linux', () => {
expect(
canUseLinuxBubblewrapReadonlySandbox(
'macos',
() => {
throw new Error('should not probe commands on macOS');
},
),
).toBe(false);
});
it('returns false when bwrap is not installed', () => {
expect(
canUseLinuxBubblewrapReadonlySandbox(
'linux',
() => false,
),
).toBe(false);
});
it('returns true when linux bwrap readonly probe succeeds', () => {
expect(
canUseLinuxBubblewrapReadonlySandbox(
'linux',
() => true,
(() => Buffer.from('')) as typeof import('child_process').execSync,
),
).toBe(true);
});
it('returns false when linux bwrap readonly probe fails', () => {
expect(
canUseLinuxBubblewrapReadonlySandbox(
'linux',
() => true,
(() => {
throw new Error('permission denied');
}) as typeof import('child_process').execSync,
),
).toBe(false);
});
});
describe('getAppArmorRestrictUnprivilegedUsernsValue', () => {
it('returns null when the proc sysctl path is unavailable', () => {
expect(
getAppArmorRestrictUnprivilegedUsernsValue((() => {
throw new Error('missing');
}) as typeof import('fs').readFileSync),
).toBeNull();
});
it('returns the trimmed proc sysctl value', () => {
expect(
getAppArmorRestrictUnprivilegedUsernsValue((() =>
'1\n') as typeof import('fs').readFileSync),
).toBe('1');
});
});
describe('ensureLinuxReadonlySandboxAppArmorSupport', () => {
it('does nothing outside linux', () => {
expect(
ensureLinuxReadonlySandboxAppArmorSupport({
platform: 'macos',
}),
).toBe('not-linux');
});
it('does nothing when apparmor does not block sandboxing', () => {
expect(
ensureLinuxReadonlySandboxAppArmorSupport({
platform: 'linux',
apparmorRestrictUnprivilegedUserns: '0',
sandboxCapable: false,
}),
).toBe('not-needed');
});
it('does nothing when sandbox is already capable', () => {
expect(
ensureLinuxReadonlySandboxAppArmorSupport({
platform: 'linux',
apparmorRestrictUnprivilegedUserns: '1',
sandboxCapable: true,
}),
).toBe('not-needed');
});
it('requires root when linux apparmor blocks sandboxing', () => {
expect(
ensureLinuxReadonlySandboxAppArmorSupport({
platform: 'linux',
apparmorRestrictUnprivilegedUserns: '1',
sandboxCapable: false,
isRootUser: false,
}),
).toBe('requires-root');
});
it('writes and applies the sysctl when running as root', () => {
const writes: string[] = [];
const commands: string[] = [];
const fileContents = new Map<string, string>([
['/proc/sys/kernel/apparmor_restrict_unprivileged_userns', '1\n'],
]);
expect(
ensureLinuxReadonlySandboxAppArmorSupport({
platform: 'linux',
apparmorRestrictUnprivilegedUserns: '1',
sandboxCapable: false,
isRootUser: true,
readFileSyncFn: ((target) => {
const value = fileContents.get(String(target));
if (value == null) throw new Error('missing');
return value;
}) as typeof import('fs').readFileSync,
mkdirSyncFn: (() => undefined) as typeof import('fs').mkdirSync,
writeFileSyncFn: ((target, contents) => {
const normalizedTarget = String(target);
const normalizedContents = String(contents);
writes.push(`${normalizedTarget}\n${normalizedContents}`);
fileContents.set(normalizedTarget, normalizedContents);
}) as typeof import('fs').writeFileSync,
execFn: ((command) => {
commands.push(String(command));
fileContents.set(
'/proc/sys/kernel/apparmor_restrict_unprivileged_userns',
'0\n',
);
return Buffer.from('');
}) as typeof import('child_process').execSync,
}),
).toBe('configured');
expect(writes[0]).toContain('/etc/sysctl.d/90-ejclaw-sandbox.conf');
expect(writes[0]).toContain(
'kernel.apparmor_restrict_unprivileged_userns=0',
);
expect(commands).toContain(
'sysctl -w kernel.apparmor_restrict_unprivileged_userns=0',
);
});
it('skips rewriting an already-correct sysctl file but still applies and verifies', () => {
const writes: string[] = [];
const commands: string[] = [];
const fileContents = new Map<string, string>([
['/etc/sysctl.d/90-ejclaw-sandbox.conf',
'# Managed by EJClaw setup to allow bubblewrap readonly sandboxing.\n' +
'kernel.apparmor_restrict_unprivileged_userns=0\n'],
['/proc/sys/kernel/apparmor_restrict_unprivileged_userns', '1\n'],
]);
expect(
ensureLinuxReadonlySandboxAppArmorSupport({
platform: 'linux',
apparmorRestrictUnprivilegedUserns: '1',
sandboxCapable: false,
isRootUser: true,
readFileSyncFn: ((target) => {
const value = fileContents.get(String(target));
if (value == null) throw new Error('missing');
return value;
}) as typeof import('fs').readFileSync,
mkdirSyncFn: (() => undefined) as typeof import('fs').mkdirSync,
writeFileSyncFn: ((target, contents) => {
writes.push(`${String(target)}\n${String(contents)}`);
}) as typeof import('fs').writeFileSync,
execFn: ((command) => {
commands.push(String(command));
fileContents.set(
'/proc/sys/kernel/apparmor_restrict_unprivileged_userns',
'0\n',
);
return Buffer.from('');
}) as typeof import('child_process').execSync,
}),
).toBe('configured');
expect(writes).toHaveLength(0);
expect(commands).toContain(
'sysctl -w kernel.apparmor_restrict_unprivileged_userns=0',
);
});
it('returns failed when the sysctl apply does not take effect', () => {
const fileContents = new Map<string, string>([
['/proc/sys/kernel/apparmor_restrict_unprivileged_userns', '1\n'],
]);
expect(
ensureLinuxReadonlySandboxAppArmorSupport({
platform: 'linux',
apparmorRestrictUnprivilegedUserns: '1',
sandboxCapable: false,
isRootUser: true,
readFileSyncFn: ((target) => {
const value = fileContents.get(String(target));
if (value == null) throw new Error('missing');
return value;
}) as typeof import('fs').readFileSync,
mkdirSyncFn: (() => undefined) as typeof import('fs').mkdirSync,
writeFileSyncFn: (() => undefined) as typeof import('fs').writeFileSync,
execFn: (() => Buffer.from('')) as typeof import('child_process').execSync,
}),
).toBe('failed');
});
});
// --- getNodeVersion --- // --- getNodeVersion ---
describe('getNodeVersion', () => { describe('getNodeVersion', () => {

View File

@@ -7,6 +7,12 @@ import os from 'os';
export type Platform = 'macos' | 'linux' | 'unknown'; export type Platform = 'macos' | 'linux' | 'unknown';
export type ServiceManager = 'launchd' | 'systemd' | 'none'; export type ServiceManager = 'launchd' | 'systemd' | 'none';
export type LinuxReadonlySandboxAppArmorSetupResult =
| 'not-linux'
| 'not-needed'
| 'requires-root'
| 'failed'
| 'configured';
export function getPlatform(): Platform { export function getPlatform(): Platform {
const platform = os.platform(); const platform = os.platform();
@@ -115,6 +121,110 @@ export function commandExists(name: string): boolean {
} }
} }
export function canUseLinuxBubblewrapReadonlySandbox(
platform: Platform = getPlatform(),
commandExistsFn: (name: string) => boolean = commandExists,
execFn: typeof execSync = execSync,
): boolean {
if (platform !== 'linux') return false;
if (!commandExistsFn('bwrap')) return false;
try {
execFn('bwrap --ro-bind / / /bin/true', { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
function readProcSysValue(
procPath: string,
readFileSyncFn: typeof fs.readFileSync = fs.readFileSync,
): string | null {
try {
return readFileSyncFn(procPath, 'utf-8').trim();
} catch {
return null;
}
}
export function getAppArmorRestrictUnprivilegedUsernsValue(
readFileSyncFn: typeof fs.readFileSync = fs.readFileSync,
): string | null {
return readProcSysValue(
'/proc/sys/kernel/apparmor_restrict_unprivileged_userns',
readFileSyncFn,
);
}
export function ensureLinuxReadonlySandboxAppArmorSupport(options?: {
platform?: Platform;
isRootUser?: boolean;
apparmorRestrictUnprivilegedUserns?: string | null;
sandboxCapable?: boolean;
readFileSyncFn?: typeof fs.readFileSync;
mkdirSyncFn?: typeof fs.mkdirSync;
writeFileSyncFn?: typeof fs.writeFileSync;
execFn?: typeof execSync;
}): LinuxReadonlySandboxAppArmorSetupResult {
const platform = options?.platform ?? getPlatform();
if (platform !== 'linux') return 'not-linux';
const readFileSyncFn = options?.readFileSyncFn ?? fs.readFileSync;
const apparmorRestrictUnprivilegedUserns =
options?.apparmorRestrictUnprivilegedUserns ??
getAppArmorRestrictUnprivilegedUsernsValue(readFileSyncFn);
const sandboxCapable =
options?.sandboxCapable ?? canUseLinuxBubblewrapReadonlySandbox(platform);
if (
apparmorRestrictUnprivilegedUserns !== '1' ||
sandboxCapable
) {
return 'not-needed';
}
const isRootUser = options?.isRootUser ?? isRoot();
if (!isRootUser) return 'requires-root';
const mkdirSyncFn = options?.mkdirSyncFn ?? fs.mkdirSync;
const writeFileSyncFn = options?.writeFileSyncFn ?? fs.writeFileSync;
const execFn = options?.execFn ?? execSync;
const sysctlPath = '/etc/sysctl.d/90-ejclaw-sandbox.conf';
const sysctlContents =
'# Managed by EJClaw setup to allow bubblewrap readonly sandboxing.\n' +
'kernel.apparmor_restrict_unprivileged_userns=0\n';
let existingContents: string | null = null;
try {
existingContents = readFileSyncFn(sysctlPath, 'utf-8');
} catch {
existingContents = null;
}
try {
mkdirSyncFn('/etc/sysctl.d', { recursive: true });
if (existingContents !== sysctlContents) {
writeFileSyncFn(sysctlPath, sysctlContents);
}
execFn('sysctl -w kernel.apparmor_restrict_unprivileged_userns=0', {
stdio: 'ignore',
});
const appliedValue = getAppArmorRestrictUnprivilegedUsernsValue(
readFileSyncFn,
);
if (appliedValue !== '0') {
throw new Error(
`kernel.apparmor_restrict_unprivileged_userns remained ${appliedValue ?? 'unavailable'} after sysctl apply`,
);
}
return 'configured';
} catch {
return 'failed';
}
}
export function getNodeVersion(): string | null { export function getNodeVersion(): string | null {
try { try {
const version = execSync('node --version', { encoding: 'utf-8' }).trim(); const version = execSync('node --version', { encoding: 'utf-8' }).trim();

View File

@@ -11,7 +11,12 @@ import os from 'os';
import path from 'path'; import path from 'path';
import { logger } from '../src/logger.js'; import { logger } from '../src/logger.js';
import { getPlatform, getNodePath, getServiceManager } from './platform.js'; import {
ensureLinuxReadonlySandboxAppArmorSupport,
getPlatform,
getNodePath,
getServiceManager,
} from './platform.js';
import { import {
detectLegacyServiceIssues, detectLegacyServiceIssues,
formatLegacyServiceFailureMessage, formatLegacyServiceFailureMessage,
@@ -33,6 +38,22 @@ export async function run(_args: string[]): Promise<void> {
logger.info({ platform, nodePath, projectRoot }, 'Setting up service'); logger.info({ platform, nodePath, projectRoot }, 'Setting up service');
const readonlySandboxAppArmorSetup =
ensureLinuxReadonlySandboxAppArmorSupport();
if (readonlySandboxAppArmorSetup === 'configured') {
logger.info(
'Configured AppArmor user namespace sysctl for EJClaw readonly sandbox support',
);
} else if (readonlySandboxAppArmorSetup === 'failed') {
logger.warn(
'Failed to apply AppArmor user namespace sysctl for EJClaw readonly sandbox support',
);
} else if (readonlySandboxAppArmorSetup === 'requires-root') {
logger.warn(
'Readonly sandbox strict mode requires root setup to disable AppArmor unprivileged userns restriction',
);
}
const legacyServiceIssues = detectLegacyServiceIssues( const legacyServiceIssues = detectLegacyServiceIssues(
projectRoot, projectRoot,
serviceManager, serviceManager,

View File

@@ -108,8 +108,7 @@ export async function runAgentProcess(
// Reviewers always run inside a Docker container with read-only source // Reviewers always run inside a Docker container with read-only source
// mount for kernel-level write protection. Docker is required. // mount for kernel-level write protection. Docker is required.
if ( if (
!unsafeHostPairedMode && (!unsafeHostPairedMode && envOverrides?.EJCLAW_REVIEWER_RUNTIME === '1') ||
envOverrides?.EJCLAW_REVIEWER_RUNTIME === '1' ||
(!unsafeHostPairedMode && envOverrides?.EJCLAW_ARBITER_RUNTIME === '1') (!unsafeHostPairedMode && envOverrides?.EJCLAW_ARBITER_RUNTIME === '1')
) { ) {
const ownerWorkspaceDir = const ownerWorkspaceDir =

View File

@@ -120,7 +120,7 @@ export const AGENT_LANGUAGE = getEnv('AGENT_LANGUAGE') || '';
/** Number of consecutive owner↔reviewer round trips before arbiter is auto-requested. */ /** Number of consecutive owner↔reviewer round trips before arbiter is auto-requested. */
export const ARBITER_DEADLOCK_THRESHOLD = parseInt( export const ARBITER_DEADLOCK_THRESHOLD = parseInt(
getEnv('ARBITER_DEADLOCK_THRESHOLD') || '3', getEnv('ARBITER_DEADLOCK_THRESHOLD') || '2',
10, 10,
); );

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./agent-runner.js', () => ({ vi.mock('./agent-runner.js', () => ({
runAgentProcess: vi.fn(), runAgentProcess: vi.fn(),
@@ -156,9 +156,14 @@ vi.mock('./session-recovery.js', () => ({
vi.mock('./token-rotation.js', () => ({ vi.mock('./token-rotation.js', () => ({
rotateToken: vi.fn(() => false), rotateToken: vi.fn(() => false),
getTokenCount: vi.fn(() => 1), getTokenCount: vi.fn(() => 1),
getCurrentTokenIndex: vi.fn(() => 0),
markTokenHealthy: vi.fn(), markTokenHealthy: vi.fn(),
})); }));
vi.mock('./token-refresh.js', () => ({
forceRefreshToken: vi.fn(async () => null),
}));
vi.mock('./codex-token-rotation.js', () => ({ vi.mock('./codex-token-rotation.js', () => ({
detectCodexRotationTrigger: vi.fn((error?: string | null) => { detectCodexRotationTrigger: vi.fn((error?: string | null) => {
const lower = (error || '').toLowerCase(); const lower = (error || '').toLowerCase();
@@ -198,6 +203,7 @@ import { runAgentForGroup } from './message-agent-executor.js';
import * as pairedExecutionContext from './paired-execution-context.js'; import * as pairedExecutionContext from './paired-execution-context.js';
import * as sessionRecovery from './session-recovery.js'; import * as sessionRecovery from './session-recovery.js';
import * as serviceRouting from './service-routing.js'; import * as serviceRouting from './service-routing.js';
import * as tokenRefresh from './token-refresh.js';
import * as tokenRotation from './token-rotation.js'; import * as tokenRotation from './token-rotation.js';
import type { RegisteredGroup } from './types.js'; import type { RegisteredGroup } from './types.js';
@@ -226,9 +232,13 @@ function makeDeps() {
}; };
} }
const ORIGINAL_UNSAFE_HOST_PAIRED_MODE =
process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE;
describe('runAgentForGroup room memory', () => { describe('runAgentForGroup room memory', () => {
beforeEach(() => { beforeEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
delete process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE;
vi.mocked(agentRunner.runAgentProcess).mockImplementation( vi.mocked(agentRunner.runAgentProcess).mockImplementation(
async (_group, _input, _onProcess, onOutput) => { async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({ await onOutput?.({
@@ -245,6 +255,15 @@ describe('runAgentForGroup room memory', () => {
); );
}); });
afterAll(() => {
if (ORIGINAL_UNSAFE_HOST_PAIRED_MODE === undefined) {
delete process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE;
} else {
process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE =
ORIGINAL_UNSAFE_HOST_PAIRED_MODE;
}
});
it('injects a room memory briefing when starting a fresh session', async () => { it('injects a room memory briefing when starting a fresh session', async () => {
const group = { ...makeGroup(), folder: 'test-group' }; const group = { ...makeGroup(), folder: 'test-group' };
const deps = makeDeps(); const deps = makeDeps();
@@ -1031,6 +1050,108 @@ describe('runAgentForGroup room memory', () => {
); );
}); });
it('starts reviewer Claude fresh in unsafe host mode instead of resuming a stored session', async () => {
process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE = '1';
const group = {
...makeGroup(),
folder: 'test-group',
agentType: 'codex' as const,
};
const deps = {
...makeDeps(),
getSessions: () => ({ 'test-group:reviewer': 'reviewer-session' }),
};
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: 'group@test',
owner_agent_type: 'codex',
reviewer_agent_type: 'claude-code',
arbiter_agent_type: null,
owner_service_id: 'codex-main',
reviewer_service_id: 'claude',
arbiter_service_id: null,
activated_at: null,
reason: null,
explicit: false,
});
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
id: 'paired-task-review',
chat_jid: 'group@test',
group_folder: 'test-group',
owner_service_id: 'codex-main',
reviewer_service_id: 'claude',
title: null,
source_ref: 'HEAD',
plan_notes: null,
round_trip_count: 0,
review_requested_at: '2026-03-31T00:00:00.000Z',
status: 'review_ready',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: '2026-03-31T00:00:00.000Z',
updated_at: '2026-03-31T00:00:00.000Z',
});
vi.mocked(
pairedExecutionContext.preparePairedExecutionContext,
).mockReturnValue({
task: {
id: 'paired-task-review',
chat_jid: 'group@test',
group_folder: 'test-group',
owner_service_id: 'codex-main',
reviewer_service_id: 'claude',
title: null,
source_ref: 'HEAD',
plan_notes: null,
round_trip_count: 0,
review_requested_at: '2026-03-31T00:00:00.000Z',
status: 'review_ready',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: '2026-03-31T00:00:00.000Z',
updated_at: '2026-03-31T00:00:00.000Z',
},
workspace: null,
envOverrides: {
EJCLAW_PAIRED_TASK_ID: 'paired-task-review',
EJCLAW_PAIRED_ROLE: 'reviewer',
EJCLAW_UNSAFE_HOST_PAIRED_MODE: '1',
CLAUDE_CONFIG_DIR: '/tmp/test-group-reviewer',
},
});
vi.mocked(agentRunner.runAgentProcess).mockResolvedValue({
status: 'success',
result: 'review ok',
newSessionId: 'review-session-new',
});
await runAgentForGroup(deps, {
group,
prompt: 'please review',
chatJid: 'group@test',
runId: 'run-review-plan-unsafe-host',
});
expect(deps.clearSession).toHaveBeenCalledWith('test-group:reviewer');
expect(agentRunner.runAgentProcess).toHaveBeenCalledWith(
expect.objectContaining({
folder: 'test-group',
agentType: 'claude-code',
}),
expect.objectContaining({
sessionId: undefined,
}),
expect.any(Function),
undefined,
expect.objectContaining({
EJCLAW_UNSAFE_HOST_PAIRED_MODE: '1',
}),
);
expect(deps.persistSession).not.toHaveBeenCalled();
});
it('does not enqueue a second generic follow-up when reviewer approval already moved the task to merge_ready', async () => { it('does not enqueue a second generic follow-up when reviewer approval already moved the task to merge_ready', async () => {
const group = { ...makeGroup(), folder: 'test-group' }; const group = { ...makeGroup(), folder: 'test-group' };
const deps = makeDeps(); const deps = makeDeps();
@@ -1123,7 +1244,9 @@ describe('runAgentForGroup Claude rotation', () => {
vi.resetAllMocks(); vi.resetAllMocks();
vi.mocked(buildRoomMemoryBriefing).mockResolvedValue(undefined); vi.mocked(buildRoomMemoryBriefing).mockResolvedValue(undefined);
vi.mocked(tokenRotation.getTokenCount).mockReturnValue(1); vi.mocked(tokenRotation.getTokenCount).mockReturnValue(1);
vi.mocked(tokenRotation.getCurrentTokenIndex).mockReturnValue(0);
vi.mocked(tokenRotation.rotateToken).mockReturnValue(false); vi.mocked(tokenRotation.rotateToken).mockReturnValue(false);
vi.mocked(tokenRefresh.forceRefreshToken).mockResolvedValue(null);
}); });
it('rotates to another Claude account on usage exhaustion', async () => { it('rotates to another Claude account on usage exhaustion', async () => {
@@ -1229,6 +1352,62 @@ describe('runAgentForGroup Claude rotation', () => {
expect(outputs).toEqual(['새 Claude 토큰 응답입니다.']); expect(outputs).toEqual(['새 Claude 토큰 응답입니다.']);
}); });
it('force-refreshes the active Claude token before rotating on auth-expired', async () => {
const outputs: string[] = [];
vi.mocked(tokenRefresh.forceRefreshToken).mockResolvedValueOnce(
'new-access-token',
);
vi.mocked(agentRunner.runAgentProcess)
.mockImplementationOnce(async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
phase: 'intermediate',
result:
'Failed to authenticate. API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"Invalid authentication credentials"}}',
});
await onOutput?.({
status: 'success',
phase: 'final',
result:
'Failed to authenticate. API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"Invalid authentication credentials"}}',
});
return {
status: 'success',
result: null,
};
})
.mockImplementationOnce(async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
phase: 'final',
result: 'force refresh 뒤 Claude 응답입니다.',
});
return {
status: 'success',
result: null,
};
});
const result = await runAgentForGroup(makeDeps(), {
group: makeGroup(),
prompt: 'hello',
chatJid: 'group@test',
runId: 'run-auth-expired-force-refresh',
onOutput: async (output) => {
if (typeof output.result === 'string') outputs.push(output.result);
},
});
expect(result).toBe('success');
expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(2);
expect(tokenRefresh.forceRefreshToken).toHaveBeenCalledWith(0);
expect(tokenRotation.rotateToken).not.toHaveBeenCalled();
expect(tokenRotation.markTokenHealthy).toHaveBeenCalledTimes(1);
expect(outputs).toEqual(['force refresh 뒤 Claude 응답입니다.']);
});
it('suppresses Claude 502 HTML and returns error when no rotation is available', async () => { it('suppresses Claude 502 HTML and returns error when no rotation is available', async () => {
const outputs: string[] = []; const outputs: string[] = [];
@@ -1505,6 +1684,48 @@ describe('runAgentForGroup Claude rotation', () => {
); );
}); });
it('drops a stale Claude session id before retrying a fresh session', async () => {
const deps = {
...makeDeps(),
getSessions: () => ({ 'test-claude': 'stale-session-id' }),
};
vi.mocked(sessionRecovery.shouldRetryFreshSessionOnAgentFailure)
.mockReturnValueOnce(true)
.mockReturnValueOnce(false);
vi.mocked(agentRunner.runAgentProcess)
.mockImplementationOnce(async (_group, input) => {
expect(input.sessionId).toBe('stale-session-id');
throw new Error('No conversation found with session ID: stale-session-id');
})
.mockImplementationOnce(async (_group, input, _onProcess, onOutput) => {
expect(input.sessionId).toBeUndefined();
await onOutput?.({
status: 'success',
phase: 'final',
result: 'fresh retry success',
});
return {
status: 'success',
result: 'fresh retry success',
newSessionId: 'fresh-session-id',
};
});
const result = await runAgentForGroup(deps, {
group: makeGroup(),
prompt: 'hello',
chatJid: 'group@test',
runId: 'run-stale-session-id-retry',
onOutput: async () => {},
});
expect(result).toBe('success');
expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(2);
expect(deps.clearSession).toHaveBeenCalledWith('test-claude');
});
it('suppresses a usage-exhausted banner even when Claude already emitted progress text', async () => { it('suppresses a usage-exhausted banner even when Claude already emitted progress text', async () => {
const outputs: string[] = []; const outputs: string[] = [];

View File

@@ -150,12 +150,22 @@ export async function runAgentForGroup(
activeRole, activeRole,
group.agentType, group.agentType,
); );
const unsafeHostPairedMode =
process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE === '1';
const forceFreshClaudeReviewerSession =
reviewerMode && isClaudeCodeAgent && unsafeHostPairedMode;
const storedSessionId = sessions[sessionFolder];
if (forceFreshClaudeReviewerSession && storedSessionId) {
deps.clearSession(sessionFolder);
}
// Arbiter always starts fresh — never resume a previous session // Arbiter always starts fresh — never resume a previous session
const sessionId = let currentSessionId =
activeRole === 'arbiter' || args.forcedAgentType activeRole === 'arbiter' ||
args.forcedAgentType ||
forceFreshClaudeReviewerSession
? undefined ? undefined
: sessions[sessionFolder]; : storedSessionId;
const memoryBriefing = sessionId const memoryBriefing = currentSessionId
? undefined ? undefined
: await buildRoomMemoryBriefing({ : await buildRoomMemoryBriefing({
groupFolder: group.folder, groupFolder: group.folder,
@@ -185,7 +195,9 @@ export async function runAgentForGroup(
let resetSessionRequested = false; let resetSessionRequested = false;
const canRotateToken = isClaudeCodeAgent && getTokenCount() > 1; const claudeTokenCount = isClaudeCodeAgent ? getTokenCount() : 0;
const canRetryClaudeCredentials = isClaudeCodeAgent && claudeTokenCount > 0;
const canRotateToken = isClaudeCodeAgent && claudeTokenCount > 1;
const roomRoleContext = buildRoomRoleContext( const roomRoleContext = buildRoomRoleContext(
currentLease, currentLease,
effectiveServiceId, effectiveServiceId,
@@ -212,6 +224,7 @@ export async function runAgentForGroup(
pairedExecutionContext.envOverrides[effortKey] = roleConfig.effort; pairedExecutionContext.envOverrides[effortKey] = roleConfig.effort;
} }
} }
const log = createScopedLogger({ const log = createScopedLogger({
chatJid, chatJid,
groupName: group.name, groupName: group.name,
@@ -243,11 +256,27 @@ export async function runAgentForGroup(
reviewerMode, reviewerMode,
arbiterMode, arbiterMode,
sessionFolder, sessionFolder,
resumedSession: sessionId ?? null, resumedSession: currentSessionId ?? null,
}, },
'Resolved execution target for agent turn', 'Resolved execution target for agent turn',
); );
const clearRoleSdkSessions = (): void => {
const configDir = pairedExecutionContext?.envOverrides?.CLAUDE_CONFIG_DIR;
if (!configDir) return;
for (const sdkDir of ['.claude', '.codex']) {
const sessionsDir = path.join(configDir, sdkDir, 'sessions');
if (fs.existsSync(sessionsDir)) {
fs.rmSync(sessionsDir, { recursive: true, force: true });
log.info({ sessionsDir }, 'Cleared SDK sessions for fresh Claude turn');
}
}
};
if (forceFreshClaudeReviewerSession) {
clearRoleSdkSessions();
}
// ── MoA prompt enrichment ───────────────────────────────────── // ── MoA prompt enrichment ─────────────────────────────────────
// When MoA is enabled and we're in arbiter mode, query external API // When MoA is enabled and we're in arbiter mode, query external API
// models (Kimi, GLM, etc.) in parallel for their opinions, then inject // models (Kimi, GLM, etc.) in parallel for their opinions, then inject
@@ -394,7 +423,7 @@ export async function runAgentForGroup(
const agentInput = { const agentInput = {
prompt: effectivePrompt, prompt: effectivePrompt,
sessionId, sessionId: currentSessionId,
memoryBriefing, memoryBriefing,
groupFolder: group.folder, groupFolder: group.folder,
chatJid, chatJid,
@@ -451,6 +480,7 @@ export async function runAgentForGroup(
sawVisibleOutput: false, sawVisibleOutput: false,
sawSuccessNullResultWithoutOutput: false, sawSuccessNullResultWithoutOutput: false,
}; };
const attemptSessionId = provider === 'claude' ? currentSessionId : undefined;
const wrappedOnOutput = onOutput const wrappedOnOutput = onOutput
? async (output: AgentOutput) => { ? async (output: AgentOutput) => {
@@ -476,7 +506,7 @@ export async function runAgentForGroup(
effectiveServiceId, effectiveServiceId,
effectiveAgentType, effectiveAgentType,
sessionFolder, sessionFolder,
resumedSession: sessionId ?? null, resumedSession: attemptSessionId ?? null,
streamedSessionId: output.newSessionId ?? null, streamedSessionId: output.newSessionId ?? null,
roomRoleServiceId: roomRoleContext?.serviceId ?? null, roomRoleServiceId: roomRoleContext?.serviceId ?? null,
roomRole: roomRoleContext?.role ?? null, roomRole: roomRoleContext?.role ?? null,
@@ -499,9 +529,11 @@ export async function runAgentForGroup(
if ( if (
provider === 'claude' && provider === 'claude' &&
output.newSessionId && output.newSessionId &&
!resetSessionRequested !resetSessionRequested &&
!forceFreshClaudeReviewerSession
) { ) {
deps.persistSession(sessionFolder, output.newSessionId); deps.persistSession(sessionFolder, output.newSessionId);
currentSessionId = output.newSessionId;
} }
const evaluation = evaluateStreamedOutput(output, streamedState, { const evaluation = evaluateStreamedOutput(output, streamedState, {
agentType: isClaudeCodeAgent ? 'claude-code' : 'codex', agentType: isClaudeCodeAgent ? 'claude-code' : 'codex',
@@ -510,7 +542,7 @@ export async function runAgentForGroup(
trackSuccessNullResult: true, trackSuccessNullResult: true,
shortCircuitTriggeredErrors: shortCircuitTriggeredErrors:
provider === 'claude' provider === 'claude'
? canRotateToken ? canRetryClaudeCredentials
: getCodexAccountCount() > 1, : getCodexAccountCount() > 1,
}); });
streamedState = evaluation.state; streamedState = evaluation.state;
@@ -601,7 +633,7 @@ export async function runAgentForGroup(
effectiveGroup, effectiveGroup,
{ {
...agentInput, ...agentInput,
sessionId: provider === 'claude' ? sessionId : undefined, sessionId: attemptSessionId,
}, },
(proc, processName, ipcDir) => (proc, processName, ipcDir) =>
deps.queue.registerProcess(chatJid, proc, processName, ipcDir), deps.queue.registerProcess(chatJid, proc, processName, ipcDir),
@@ -609,8 +641,13 @@ export async function runAgentForGroup(
pairedExecutionContext?.envOverrides, pairedExecutionContext?.envOverrides,
); );
if (provider === 'claude' && output.newSessionId) { if (
provider === 'claude' &&
output.newSessionId &&
!forceFreshClaudeReviewerSession
) {
deps.persistSession(sessionFolder, output.newSessionId); deps.persistSession(sessionFolder, output.newSessionId);
currentSessionId = output.newSessionId;
} }
providerLog.info( providerLog.info(
@@ -804,23 +841,9 @@ export async function runAgentForGroup(
}))); })));
if (isRetryableClaudeSessionFailure(primaryAttempt)) { if (isRetryableClaudeSessionFailure(primaryAttempt)) {
currentSessionId = undefined;
deps.clearSession(sessionFolder); deps.clearSession(sessionFolder);
// Also clear SDK session state inside the reviewer/arbiter config dir clearRoleSdkSessions();
// so the persistent container doesn't resume the same stale session.
const configDir =
pairedExecutionContext?.envOverrides?.CLAUDE_CONFIG_DIR;
if (configDir) {
for (const sdkDir of ['.claude', '.codex']) {
const sessionsDir = path.join(configDir, sdkDir, 'sessions');
if (fs.existsSync(sessionsDir)) {
fs.rmSync(sessionsDir, { recursive: true, force: true });
log.info(
{ sessionsDir },
'Cleared container SDK sessions for stale session recovery',
);
}
}
}
log.warn( log.warn(
'Cleared poisoned Claude session before visible output, retrying fresh session', 'Cleared poisoned Claude session before visible output, retrying fresh session',
); );
@@ -828,6 +851,7 @@ export async function runAgentForGroup(
primaryAttempt = await runAttempt('claude'); primaryAttempt = await runAttempt('claude');
if (isRetryableClaudeSessionFailure(primaryAttempt)) { if (isRetryableClaudeSessionFailure(primaryAttempt)) {
currentSessionId = undefined;
deps.clearSession(sessionFolder); deps.clearSession(sessionFolder);
log.warn('Fresh Claude retry also hit a retryable session failure'); log.warn('Fresh Claude retry also hit a retryable session failure');
@@ -840,7 +864,7 @@ export async function runAgentForGroup(
if (primaryAttempt.error) { if (primaryAttempt.error) {
if ( if (
canRotateToken && canRetryClaudeCredentials &&
provider === 'claude' && provider === 'claude' &&
!primaryAttempt.sawOutput !primaryAttempt.sawOutput
) { ) {
@@ -913,7 +937,7 @@ export async function runAgentForGroup(
} }
if ( if (
canRotateToken && canRetryClaudeCredentials &&
provider === 'claude' && provider === 'claude' &&
!primaryAttempt.sawOutput && !primaryAttempt.sawOutput &&
primaryAttempt.streamedTriggerReason && primaryAttempt.streamedTriggerReason &&
@@ -945,7 +969,7 @@ export async function runAgentForGroup(
if (output.status === 'error') { if (output.status === 'error') {
if ( if (
canRotateToken && canRetryClaudeCredentials &&
provider === 'claude' && provider === 'claude' &&
!primaryAttempt.sawOutput !primaryAttempt.sawOutput
) { ) {

View File

@@ -1704,6 +1704,230 @@ describe('createMessageRuntime', () => {
); );
}); });
it('auto-runs an owner follow-up when a task returns to active after reviewer feedback', async () => {
const chatJid = 'group@test';
const group = makeGroup('codex');
const channel = makeChannel(chatJid);
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
id: 'task-active-owner-follow-up',
chat_jid: chatJid,
group_folder: group.folder,
owner_service_id: 'codex-main',
reviewer_service_id: 'claude',
title: null,
source_ref: 'HEAD',
plan_notes: null,
review_requested_at: '2026-03-30T00:00:00.000Z',
round_trip_count: 1,
status: 'active',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: '2026-03-30T00:00:00.000Z',
updated_at: '2026-03-30T00:00:00.000Z',
});
vi.mocked(db.getMessagesSince).mockReturnValue([]);
vi.mocked(db.getPairedTurnOutputs).mockReturnValue([
{
id: 1,
task_id: 'task-active-owner-follow-up',
turn_number: 1,
role: 'owner',
output_text: 'owner 초안',
created_at: '2026-03-30T00:00:01.000Z',
},
{
id: 2,
task_id: 'task-active-owner-follow-up',
turn_number: 2,
role: 'reviewer',
output_text: '리뷰어가 수정 요청을 남김',
created_at: '2026-03-30T00:00:02.000Z',
},
]);
vi.mocked(db.getRecentChatMessages).mockReturnValue([
{
id: 'human-1',
chat_jid: chatJid,
sender: 'user@test',
sender_name: '눈쟁이',
content: '이 기능 마무리해줘',
timestamp: '2026-03-30T00:00:00.500Z',
seq: 1,
is_bot_message: false,
} as any,
]);
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
async (_group, input, _onProcess, onOutput) => {
expect(input.prompt).toContain('이 기능 마무리해줘');
expect(input.prompt).toContain('리뷰어가 수정 요청을 남김');
await onOutput?.({
status: 'success',
phase: 'final',
result: 'owner가 reviewer 피드백을 반영했습니다.',
newSessionId: 'session-owner-follow-up',
});
return {
status: 'success',
result: 'owner가 reviewer 피드백을 반영했습니다.',
newSessionId: 'session-owner-follow-up',
};
},
);
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [channel],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => ({}),
saveState: vi.fn(),
persistSession: vi.fn(),
clearSession: vi.fn(),
});
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-active-owner-follow-up',
reason: 'messages',
});
expect(result).toBe(true);
expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(1);
expect(channel.sendMessage).toHaveBeenCalledWith(
chatJid,
'owner가 reviewer 피드백을 반영했습니다.',
);
});
it('builds owner follow-up prompts from paired turn outputs instead of raw reviewer bot delivery text', async () => {
const chatJid = 'group@test';
const group = makeGroup('codex');
const channel = makeChannel(chatJid);
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
id: 'task-active-bot-follow-up',
chat_jid: chatJid,
group_folder: group.folder,
owner_service_id: 'codex-main',
reviewer_service_id: 'claude',
title: null,
source_ref: 'HEAD',
plan_notes: null,
review_requested_at: '2026-03-30T00:00:00.000Z',
round_trip_count: 1,
status: 'active',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: '2026-03-30T00:00:00.000Z',
updated_at: '2026-03-30T00:00:00.000Z',
});
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'reviewer-bot-message-active',
chat_jid: chatJid,
sender: 'reviewer-bot@test',
sender_name: '리뷰어',
content: 'DONE_WITH_CONCERNS\n\n리뷰어 디스코드 출력 원문',
timestamp: '2026-03-30T00:00:04.000Z',
seq: 42,
is_bot_message: true,
} as any,
]);
vi.mocked(db.getPairedTurnOutputs).mockReturnValue([
{
id: 1,
task_id: 'task-active-bot-follow-up',
turn_number: 1,
role: 'owner',
output_text: 'owner 초안',
created_at: '2026-03-30T00:00:01.000Z',
},
{
id: 2,
task_id: 'task-active-bot-follow-up',
turn_number: 2,
role: 'reviewer',
output_text: 'paired_turn_outputs 에 저장된 reviewer 요약',
created_at: '2026-03-30T00:00:02.000Z',
},
]);
vi.mocked(db.getRecentChatMessages).mockReturnValue([
{
id: 'human-1',
chat_jid: chatJid,
sender: 'user@test',
sender_name: '눈쟁이',
content: '이 기능 마무리해줘',
timestamp: '2026-03-30T00:00:00.500Z',
seq: 1,
is_bot_message: false,
} as any,
]);
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
async (_group, input, _onProcess, onOutput) => {
expect(input.prompt).toContain('paired_turn_outputs 에 저장된 reviewer 요약');
expect(input.prompt).not.toContain('리뷰어 디스코드 출력 원문');
await onOutput?.({
status: 'success',
phase: 'final',
result: 'owner follow-up ok',
newSessionId: 'session-owner-bot-follow-up',
});
return {
status: 'success',
result: 'owner follow-up ok',
newSessionId: 'session-owner-bot-follow-up',
};
},
);
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [channel],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => ({}),
saveState: vi.fn(),
persistSession: vi.fn(),
clearSession: vi.fn(),
});
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-active-bot-follow-up',
reason: 'messages',
});
expect(result).toBe(true);
expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(1);
expect(channel.sendMessage).toHaveBeenCalledWith(chatJid, 'owner follow-up ok');
});
it('reuses the shared arbiter prompt builder for pending arbiter turns', async () => { it('reuses the shared arbiter prompt builder for pending arbiter turns', async () => {
const chatJid = 'group@test'; const chatJid = 'group@test';
const group = makeGroup('codex'); const group = makeGroup('codex');

View File

@@ -270,6 +270,30 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
return `User request:\n---\n${userMessage}\n---\n\nReview the latest owner changes in the workspace.`; return `User request:\n---\n${userMessage}\n---\n\nReview the latest owner changes in the workspace.`;
}; };
const buildOwnerPendingPrompt = (
task: PairedTask,
chatJid: string,
timezone: string,
): string => {
const turnOutputs = getPairedTurnOutputs(task.id);
if (turnOutputs.length > 0) {
const humanMessages = getRecentChatMessages(chatJid, 20).filter(
(message) => !message.is_bot_message,
);
return formatMessages(
mergeHumanAndTurnOutputMessages(chatJid, humanMessages, turnOutputs),
timezone,
);
}
const userMessage = getLastHumanMessageContent(chatJid);
if (!userMessage) {
return 'Continue the owner turn using the latest reviewer or arbiter feedback.';
}
return `User request:\n---\n${userMessage}\n---\n\nContinue the owner turn using the latest reviewer or arbiter feedback.`;
};
const buildArbiterPromptForTask = ( const buildArbiterPromptForTask = (
task: PairedTask, task: PairedTask,
chatJid: string, chatJid: string,
@@ -825,6 +849,22 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
}; };
} }
if (taskStatus === 'active') {
const turnOutputs = getPairedTurnOutputs(task.id);
const lastTurnOutput = turnOutputs[turnOutputs.length - 1];
if (
lastTurnOutput &&
(lastTurnOutput.role === 'reviewer' ||
lastTurnOutput.role === 'arbiter')
) {
return {
prompt: buildOwnerPendingPrompt(task, chatJid, deps.timezone),
channel: resolveChannel(taskStatus),
cursor,
};
}
}
return null; return null;
}; };

View File

@@ -3,7 +3,7 @@ import fs from 'fs';
import os from 'os'; import os from 'os';
import path from 'path'; import path from 'path';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./db.js', () => ({ vi.mock('./db.js', () => ({
createPairedTask: vi.fn(), createPairedTask: vi.fn(),
@@ -78,6 +78,12 @@ const failoverOwnerContext: RoomRoleContext = {
failoverOwner: true, failoverOwner: true,
}; };
const ORIGINAL_UNSAFE_HOST_PAIRED_MODE =
process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE;
const ORIGINAL_REVIEWER_RUNTIME = process.env.EJCLAW_REVIEWER_RUNTIME;
const ORIGINAL_CLAUDE_REVIEWER_READONLY =
process.env.EJCLAW_CLAUDE_REVIEWER_READONLY;
function createCanonicalRepoWithCommit(commitMessage: string): string { function createCanonicalRepoWithCommit(commitMessage: string): string {
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-finalize-')); const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-finalize-'));
execFileSync('git', ['init'], { cwd: repoDir, stdio: 'ignore' }); execFileSync('git', ['init'], { cwd: repoDir, stdio: 'ignore' });
@@ -150,6 +156,9 @@ function buildWorkspace(
describe('paired execution context', () => { describe('paired execution context', () => {
beforeEach(() => { beforeEach(() => {
delete process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE;
delete process.env.EJCLAW_REVIEWER_RUNTIME;
delete process.env.EJCLAW_CLAUDE_REVIEWER_READONLY;
vi.resetAllMocks(); vi.resetAllMocks();
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue(undefined); vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue(undefined);
vi.mocked(db.getPairedTaskById).mockReturnValue(undefined); vi.mocked(db.getPairedTaskById).mockReturnValue(undefined);
@@ -176,6 +185,28 @@ describe('paired execution context', () => {
}); });
}); });
afterAll(() => {
if (ORIGINAL_UNSAFE_HOST_PAIRED_MODE == null) {
delete process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE;
} else {
process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE =
ORIGINAL_UNSAFE_HOST_PAIRED_MODE;
}
if (ORIGINAL_REVIEWER_RUNTIME == null) {
delete process.env.EJCLAW_REVIEWER_RUNTIME;
} else {
process.env.EJCLAW_REVIEWER_RUNTIME = ORIGINAL_REVIEWER_RUNTIME;
}
if (ORIGINAL_CLAUDE_REVIEWER_READONLY == null) {
delete process.env.EJCLAW_CLAUDE_REVIEWER_READONLY;
} else {
process.env.EJCLAW_CLAUDE_REVIEWER_READONLY =
ORIGINAL_CLAUDE_REVIEWER_READONLY;
}
});
it('creates an owner execution with a worktree override', () => { it('creates an owner execution with a worktree override', () => {
const result = preparePairedExecutionContext({ const result = preparePairedExecutionContext({
group, group,
@@ -413,6 +444,7 @@ describe('paired execution context', () => {
EJCLAW_PAIRED_ROLE: 'reviewer', EJCLAW_PAIRED_ROLE: 'reviewer',
EJCLAW_UNSAFE_HOST_PAIRED_MODE: '1', EJCLAW_UNSAFE_HOST_PAIRED_MODE: '1',
}); });
expect(result?.envOverrides.EJCLAW_CLAUDE_REVIEWER_READONLY).toBe('1');
expect(result?.envOverrides.EJCLAW_REVIEWER_RUNTIME).toBeUndefined(); expect(result?.envOverrides.EJCLAW_REVIEWER_RUNTIME).toBeUndefined();
expect(result?.envOverrides.CLAUDE_CONFIG_DIR).toContain( expect(result?.envOverrides.CLAUDE_CONFIG_DIR).toContain(
'/data/sessions/paired-room-reviewer', '/data/sessions/paired-room-reviewer',

View File

@@ -387,6 +387,9 @@ export function preparePairedExecutionContext(args: {
envOverrides.CLAUDE_CONFIG_DIR = reviewerSessionDir; envOverrides.CLAUDE_CONFIG_DIR = reviewerSessionDir;
if (unsafeHostPairedMode) { if (unsafeHostPairedMode) {
envOverrides.EJCLAW_UNSAFE_HOST_PAIRED_MODE = '1'; envOverrides.EJCLAW_UNSAFE_HOST_PAIRED_MODE = '1';
if (REVIEWER_AGENT_TYPE === 'claude-code') {
envOverrides.EJCLAW_CLAUDE_REVIEWER_READONLY = '1';
}
} else { } else {
envOverrides.EJCLAW_REVIEWER_RUNTIME = '1'; envOverrides.EJCLAW_REVIEWER_RUNTIME = '1';
} }

View File

@@ -219,10 +219,9 @@ describe('paired workspace manager', () => {
packageManager: 'pnpm' as const, packageManager: 'pnpm' as const,
})); }));
vi.doMock('./workspace-package-manager.js', async () => { vi.doMock('./workspace-package-manager.js', async () => {
const actual = const actual = await vi.importActual<
await vi.importActual<typeof import('./workspace-package-manager.js')>( typeof import('./workspace-package-manager.js')
'./workspace-package-manager.js', >('./workspace-package-manager.js');
);
return { return {
...actual, ...actual,
ensureWorkspaceDependenciesInstalled: ensureWorkspaceDependenciesInstalled:

View File

@@ -12,21 +12,30 @@ vi.mock('./logger.js', () => ({
vi.mock('./token-rotation.js', () => ({ vi.mock('./token-rotation.js', () => ({
rotateToken: vi.fn(() => false), rotateToken: vi.fn(() => false),
getTokenCount: vi.fn(() => 1), getTokenCount: vi.fn(() => 1),
getCurrentTokenIndex: vi.fn(() => 0),
markTokenHealthy: vi.fn(), markTokenHealthy: vi.fn(),
})); }));
vi.mock('./token-refresh.js', () => ({
forceRefreshToken: vi.fn(async () => null),
}));
import { runClaudeRotationLoop } from './provider-retry.js'; import { runClaudeRotationLoop } from './provider-retry.js';
import { import {
getCurrentTokenIndex,
getTokenCount, getTokenCount,
markTokenHealthy, markTokenHealthy,
rotateToken, rotateToken,
} from './token-rotation.js'; } from './token-rotation.js';
import { forceRefreshToken } from './token-refresh.js';
describe('runClaudeRotationLoop', () => { describe('runClaudeRotationLoop', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(getTokenCount).mockReturnValue(1); vi.mocked(getTokenCount).mockReturnValue(1);
vi.mocked(getCurrentTokenIndex).mockReturnValue(0);
vi.mocked(rotateToken).mockReturnValue(false); vi.mocked(rotateToken).mockReturnValue(false);
vi.mocked(forceRefreshToken).mockResolvedValue(null);
}); });
it('rotates and succeeds after an org-access-denied trigger', async () => { it('rotates and succeeds after an org-access-denied trigger', async () => {
@@ -85,6 +94,60 @@ describe('runClaudeRotationLoop', () => {
}); });
}); });
it('force-refreshes the active token before rotating on auth-expired', async () => {
vi.mocked(forceRefreshToken).mockResolvedValueOnce('new-access-token');
const outcome = await runClaudeRotationLoop(
{ reason: 'auth-expired' },
async () => ({
output: { status: 'success', result: 'ok' },
sawOutput: true,
}),
{ runId: 'force-refresh-auth-expired' },
);
expect(outcome).toEqual({ type: 'success', sawOutput: true });
expect(forceRefreshToken).toHaveBeenCalledWith(0);
expect(rotateToken).not.toHaveBeenCalled();
expect(markTokenHealthy).toHaveBeenCalledTimes(1);
});
it('falls back to rotation when force refresh does not recover auth-expired', async () => {
vi.mocked(getTokenCount).mockReturnValue(2);
vi.mocked(forceRefreshToken).mockResolvedValueOnce('new-access-token');
vi.mocked(rotateToken).mockReturnValueOnce(true);
let attempts = 0;
const outcome = await runClaudeRotationLoop(
{ reason: 'auth-expired' },
async () => {
attempts += 1;
if (attempts === 1) {
return {
output: {
status: 'error',
result: null,
error:
'Failed to authenticate. API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"Invalid authentication credentials"}}',
},
sawOutput: false,
};
}
return {
output: { status: 'success', result: 'ok' },
sawOutput: true,
};
},
{ runId: 'force-refresh-then-rotate' },
);
expect(outcome).toEqual({ type: 'success', sawOutput: true });
expect(forceRefreshToken).toHaveBeenCalledWith(0);
expect(rotateToken).toHaveBeenCalledTimes(1);
expect(markTokenHealthy).toHaveBeenCalledTimes(1);
});
it('uses structured public output text as the next rotation message', async () => { it('uses structured public output text as the next rotation message', async () => {
vi.mocked(getTokenCount).mockReturnValue(2); vi.mocked(getTokenCount).mockReturnValue(2);
vi.mocked(rotateToken).mockReturnValueOnce(true).mockReturnValueOnce(false); vi.mocked(rotateToken).mockReturnValueOnce(true).mockReturnValueOnce(false);

View File

@@ -15,10 +15,12 @@ import {
import { logger } from './logger.js'; import { logger } from './logger.js';
import { getErrorMessage } from './utils.js'; import { getErrorMessage } from './utils.js';
import { import {
getCurrentTokenIndex,
rotateToken, rotateToken,
getTokenCount, getTokenCount,
markTokenHealthy, markTokenHealthy,
} from './token-rotation.js'; } from './token-rotation.js';
import { forceRefreshToken } from './token-refresh.js';
import { clearGlobalFailover } from './service-routing.js'; import { clearGlobalFailover } from './service-routing.js';
// ── Types ──────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────
@@ -40,38 +42,19 @@ export type RotationOutcome =
| { type: 'success'; sawOutput: boolean } | { type: 'success'; sawOutput: boolean }
| { type: 'error'; trigger?: TriggerInfo }; | { type: 'error'; trigger?: TriggerInfo };
// ── Shared rotation loop ───────────────────────────────────────── type AttemptOutcome =
| { type: 'success'; sawOutput: boolean }
| { type: 'error'; trigger?: TriggerInfo }
| {
type: 'continue';
trigger: TriggerInfo;
rotationMessage?: string;
};
/** function evaluateClaudeAttempt(
* Retry a Claude request by rotating through available tokens. attempt: RotationAttemptResult,
*
* Returns 'success' if a rotated token worked, or 'error' if all
* tokens are exhausted or the error is non-retryable.
*/
export async function runClaudeRotationLoop(
initialTrigger: TriggerInfo,
runAttempt: () => Promise<RotationAttemptResult>,
logContext: Record<string, unknown>, logContext: Record<string, unknown>,
rotationMessage?: string, ): AttemptOutcome {
): Promise<RotationOutcome> {
let trigger = initialTrigger;
let lastRotationMessage = rotationMessage;
while (
shouldRotateClaudeToken(trigger.reason) &&
getTokenCount() > 1 &&
// Respect per-token cooldowns so exhausted auth/quota failures can
// terminate instead of cycling forever.
rotateToken(lastRotationMessage)
) {
logger.info(
{ ...logContext, reason: trigger.reason },
'Claude account unavailable, retrying with rotated account',
);
const attempt = await runAttempt();
// ── Thrown error (exception from spawn/process) ──
if (attempt.thrownError) { if (attempt.thrownError) {
if (!attempt.sawOutput) { if (!attempt.sawOutput) {
const errMsg = getErrorMessage(attempt.thrownError); const errMsg = getErrorMessage(attempt.thrownError);
@@ -83,12 +66,14 @@ export async function runClaudeRotationLoop(
} }
: classifyRotationTrigger(errMsg); : classifyRotationTrigger(errMsg);
if (retryTrigger.shouldRetry) { if (retryTrigger.shouldRetry) {
trigger = { return {
type: 'continue',
trigger: {
reason: retryTrigger.reason, reason: retryTrigger.reason,
retryAfterMs: retryTrigger.retryAfterMs, retryAfterMs: retryTrigger.retryAfterMs,
},
rotationMessage: errMsg,
}; };
lastRotationMessage = errMsg;
continue;
} }
} }
@@ -108,21 +93,21 @@ export async function runClaudeRotationLoop(
return { type: 'error' }; return { type: 'error' };
} }
// ── Streamed trigger in non-error success ──
if ( if (
!attempt.sawOutput && !attempt.sawOutput &&
attempt.streamedTriggerReason && attempt.streamedTriggerReason &&
output.status !== 'error' output.status !== 'error'
) { ) {
trigger = { return {
type: 'continue',
trigger: {
reason: attempt.streamedTriggerReason.reason, reason: attempt.streamedTriggerReason.reason,
retryAfterMs: attempt.streamedTriggerReason.retryAfterMs, retryAfterMs: attempt.streamedTriggerReason.retryAfterMs,
},
rotationMessage: getAgentOutputText(output) ?? undefined,
}; };
lastRotationMessage = getAgentOutputText(output) ?? undefined;
continue;
} }
// ── Success with null result ──
if (!attempt.sawOutput && attempt.sawSuccessNullResult) { if (!attempt.sawOutput && attempt.sawSuccessNullResult) {
logger.warn( logger.warn(
{ ...logContext, provider: 'claude' }, { ...logContext, provider: 'claude' },
@@ -131,7 +116,6 @@ export async function runClaudeRotationLoop(
return { type: 'error', trigger: { reason: 'success-null-result' } }; return { type: 'error', trigger: { reason: 'success-null-result' } };
} }
// ── Error status ──
if (output.status === 'error') { if (output.status === 'error') {
if (!attempt.sawOutput) { if (!attempt.sawOutput) {
const retryTrigger = attempt.streamedTriggerReason const retryTrigger = attempt.streamedTriggerReason
@@ -142,12 +126,14 @@ export async function runClaudeRotationLoop(
} }
: classifyRotationTrigger(output.error); : classifyRotationTrigger(output.error);
if (retryTrigger.shouldRetry) { if (retryTrigger.shouldRetry) {
trigger = { return {
type: 'continue',
trigger: {
reason: retryTrigger.reason, reason: retryTrigger.reason,
retryAfterMs: retryTrigger.retryAfterMs, retryAfterMs: retryTrigger.retryAfterMs,
},
rotationMessage: output.error ?? undefined,
}; };
lastRotationMessage = output.error ?? undefined;
continue;
} }
} }
@@ -158,12 +144,93 @@ export async function runClaudeRotationLoop(
return { type: 'error' }; return { type: 'error' };
} }
// ── Success ──
markTokenHealthy(); markTokenHealthy();
clearGlobalFailover(); clearGlobalFailover();
return { type: 'success', sawOutput: attempt.sawOutput }; return { type: 'success', sawOutput: attempt.sawOutput };
} }
// ── Shared rotation loop ─────────────────────────────────────────
/**
* Retry a Claude request by rotating through available tokens.
*
* Returns 'success' if a rotated token worked, or 'error' if all
* tokens are exhausted or the error is non-retryable.
*/
export async function runClaudeRotationLoop(
initialTrigger: TriggerInfo,
runAttempt: () => Promise<RotationAttemptResult>,
logContext: Record<string, unknown>,
rotationMessage?: string,
): Promise<RotationOutcome> {
let trigger = initialTrigger;
let lastRotationMessage = rotationMessage;
const attemptedForcedRefreshIndexes = new Set<number>();
while (shouldRotateClaudeToken(trigger.reason)) {
if (trigger.reason === 'auth-expired') {
const tokenIndex = getCurrentTokenIndex();
if (
tokenIndex != null &&
!attemptedForcedRefreshIndexes.has(tokenIndex)
) {
attemptedForcedRefreshIndexes.add(tokenIndex);
const refreshedToken = await forceRefreshToken(tokenIndex);
if (refreshedToken) {
logger.info(
{ ...logContext, tokenIndex },
'Claude auth-expired recovered by force-refreshing current token',
);
const refreshAttemptOutcome = evaluateClaudeAttempt(
await runAttempt(),
logContext,
);
if (refreshAttemptOutcome.type === 'success') {
return refreshAttemptOutcome;
}
if (refreshAttemptOutcome.type === 'error') {
return refreshAttemptOutcome;
}
trigger = refreshAttemptOutcome.trigger;
lastRotationMessage = refreshAttemptOutcome.rotationMessage;
continue;
}
}
}
if (
!(
getTokenCount() > 1 &&
// Respect per-token cooldowns so exhausted auth/quota failures can
// terminate instead of cycling forever.
rotateToken(lastRotationMessage)
)
) {
break;
}
logger.info(
{ ...logContext, reason: trigger.reason },
'Claude account unavailable, retrying with rotated account',
);
const rotatedAttemptOutcome = evaluateClaudeAttempt(
await runAttempt(),
logContext,
);
if (rotatedAttemptOutcome.type === 'success') {
return rotatedAttemptOutcome;
}
if (rotatedAttemptOutcome.type === 'error') {
return rotatedAttemptOutcome;
}
trigger = rotatedAttemptOutcome.trigger;
lastRotationMessage = rotatedAttemptOutcome.rotationMessage;
}
// ── All tokens exhausted ── // ── All tokens exhausted ──
logger.warn( logger.warn(
{ ...logContext, reason: trigger.reason }, { ...logContext, reason: trigger.reason },

View File

@@ -54,9 +54,14 @@ vi.mock('./agent-error-detection.js', async (importOriginal) => {
vi.mock('./token-rotation.js', () => ({ vi.mock('./token-rotation.js', () => ({
rotateToken: vi.fn(() => false), rotateToken: vi.fn(() => false),
getTokenCount: vi.fn(() => 1), getTokenCount: vi.fn(() => 1),
getCurrentTokenIndex: vi.fn(() => 0),
markTokenHealthy: vi.fn(), markTokenHealthy: vi.fn(),
})); }));
vi.mock('./token-refresh.js', () => ({
forceRefreshToken: vi.fn(async () => null),
}));
vi.mock('./codex-token-rotation.js', () => ({ vi.mock('./codex-token-rotation.js', () => ({
detectCodexRotationTrigger: vi.fn((error?: string | null) => { detectCodexRotationTrigger: vi.fn((error?: string | null) => {
const lower = (error || '').toLowerCase(); const lower = (error || '').toLowerCase();
@@ -142,6 +147,7 @@ import { _initTestDatabase, createTask, getTaskById } from './db.js';
import * as codexTokenRotation from './codex-token-rotation.js'; import * as codexTokenRotation from './codex-token-rotation.js';
import { TIMEZONE } from './config.js'; import { TIMEZONE } from './config.js';
import * as serviceRouting from './service-routing.js'; import * as serviceRouting from './service-routing.js';
import * as tokenRefresh from './token-refresh.js';
import { createTaskStatusTracker } from './task-status-tracker.js'; import { createTaskStatusTracker } from './task-status-tracker.js';
import { TASK_STATUS_MESSAGE_PREFIX } from './task-watch-status.js'; import { TASK_STATUS_MESSAGE_PREFIX } from './task-watch-status.js';
import * as tokenRotation from './token-rotation.js'; import * as tokenRotation from './token-rotation.js';
@@ -183,9 +189,12 @@ describe('task scheduler', () => {
}); });
// No fallback provider setup needed // No fallback provider setup needed
vi.mocked(tokenRotation.getTokenCount).mockReturnValue(1); vi.mocked(tokenRotation.getTokenCount).mockReturnValue(1);
vi.mocked(tokenRotation.getCurrentTokenIndex).mockReturnValue(0);
vi.mocked(tokenRotation.markTokenHealthy).mockClear(); vi.mocked(tokenRotation.markTokenHealthy).mockClear();
vi.mocked(tokenRotation.rotateToken).mockClear(); vi.mocked(tokenRotation.rotateToken).mockClear();
vi.mocked(tokenRotation.rotateToken).mockReturnValue(false); vi.mocked(tokenRotation.rotateToken).mockReturnValue(false);
vi.mocked(tokenRefresh.forceRefreshToken).mockReset();
vi.mocked(tokenRefresh.forceRefreshToken).mockResolvedValue(null);
vi.mocked(codexTokenRotation.rotateCodexToken).mockClear(); vi.mocked(codexTokenRotation.rotateCodexToken).mockClear();
vi.mocked(codexTokenRotation.rotateCodexToken).mockReturnValue(false); vi.mocked(codexTokenRotation.rotateCodexToken).mockReturnValue(false);
vi.mocked(codexTokenRotation.getCodexAccountCount).mockReturnValue(1); vi.mocked(codexTokenRotation.getCodexAccountCount).mockReturnValue(1);

View File

@@ -237,6 +237,7 @@ async function refreshToken(
*/ */
async function checkAndRefreshAccount( async function checkAndRefreshAccount(
accountIndex: number, accountIndex: number,
opts?: { force?: boolean },
): Promise<string | null> { ): Promise<string | null> {
const creds = readCredentials(accountIndex); const creds = readCredentials(accountIndex);
if (!creds?.claudeAiOauth) return null; if (!creds?.claudeAiOauth) return null;
@@ -250,7 +251,7 @@ async function checkAndRefreshAccount(
const now = Date.now(); const now = Date.now();
const remaining = expiresAt - now; const remaining = expiresAt - now;
if (remaining > REFRESH_BEFORE_EXPIRY_MS) { if (!opts?.force && remaining > REFRESH_BEFORE_EXPIRY_MS) {
logger.debug( logger.debug(
{ accountIndex, remainingMin: Math.round(remaining / 60000) }, { accountIndex, remainingMin: Math.round(remaining / 60000) },
'Token still valid, no refresh needed', 'Token still valid, no refresh needed',
@@ -260,7 +261,12 @@ async function checkAndRefreshAccount(
const isExpired = remaining <= 0; const isExpired = remaining <= 0;
logger.info( logger.info(
{ accountIndex, remainingMin: Math.round(remaining / 60000), isExpired }, {
accountIndex,
remainingMin: Math.round(remaining / 60000),
isExpired,
force: opts?.force === true,
},
'Refreshing Claude OAuth token', 'Refreshing Claude OAuth token',
); );
@@ -407,7 +413,9 @@ export async function forceRefreshToken(
): Promise<string | null> { ): Promise<string | null> {
logger.info({ tokenIndex }, 'Force-refreshing token after 401'); logger.info({ tokenIndex }, 'Force-refreshing token after 401');
const newAccessToken = await checkAndRefreshAccount(tokenIndex); const newAccessToken = await checkAndRefreshAccount(tokenIndex, {
force: true,
});
if (newAccessToken) { if (newAccessToken) {
const allTokens = getAllTokens(); const allTokens = getAllTokens();
if (tokenIndex < allTokens.length) { if (tokenIndex < allTokens.length) {

View File

@@ -155,6 +155,13 @@ export function getCurrentToken(): string | undefined {
return tokens[currentIndex % tokens.length]?.token; return tokens[currentIndex % tokens.length]?.token;
} }
/** Get the current active token index. */
export function getCurrentTokenIndex(): number | null {
if (tokens.length === 0) return null;
refreshRuntimeTokenSelection();
return currentIndex % tokens.length;
}
/** /**
* Try to rotate to the next available (non-rate-limited) token. * Try to rotate to the next available (non-rate-limited) token.
* Returns true if a fresh token was found, false if all are exhausted. * Returns true if a fresh token was found, false if all are exhausted.

View File

@@ -20,7 +20,9 @@ describe('verification helpers', () => {
}); });
it('builds deterministic commands for each profile', () => { it('builds deterministic commands for each profile', () => {
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-verification-')); const repoDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'ejclaw-verification-'),
);
fs.writeFileSync(path.join(repoDir, 'package.json'), JSON.stringify({})); fs.writeFileSync(path.join(repoDir, 'package.json'), JSON.stringify({}));
expect(buildVerificationCommand('test', repoDir)).toMatchObject({ expect(buildVerificationCommand('test', repoDir)).toMatchObject({

View File

@@ -77,7 +77,11 @@ export function buildVerificationCommand(
repoDir: string = process.cwd(), repoDir: string = process.cwd(),
): VerificationCommandSpec { ): VerificationCommandSpec {
const scriptName = const scriptName =
profile === 'test' ? 'test' : profile === 'typecheck' ? 'typecheck' : 'build'; profile === 'test'
? 'test'
: profile === 'typecheck'
? 'typecheck'
: 'build';
const command = buildWorkspaceScriptCommand(repoDir, scriptName); const command = buildWorkspaceScriptCommand(repoDir, scriptName);
return { return {
file: command.file, file: command.file,

View File

@@ -57,7 +57,10 @@ describe('workspace package manager helpers', () => {
JSON.stringify({ packageManager: 'bun@1.3.11' }), JSON.stringify({ packageManager: 'bun@1.3.11' }),
); );
fs.writeFileSync(path.join(mixedRepo, 'bun.lock'), ''); fs.writeFileSync(path.join(mixedRepo, 'bun.lock'), '');
fs.writeFileSync(path.join(mixedRepo, 'pnpm-lock.yaml'), 'lockfileVersion: 9.0\n'); fs.writeFileSync(
path.join(mixedRepo, 'pnpm-lock.yaml'),
'lockfileVersion: 9.0\n',
);
expect(detectWorkspacePackageManager(pnpmRepo)).toBe('pnpm'); expect(detectWorkspacePackageManager(pnpmRepo)).toBe('pnpm');
expect(detectWorkspacePackageManager(bunRepo)).toBe('bun'); expect(detectWorkspacePackageManager(bunRepo)).toBe('bun');
@@ -118,7 +121,10 @@ describe('workspace package manager helpers', () => {
scripts: { typecheck: 'tsc --noEmit' }, scripts: { typecheck: 'tsc --noEmit' },
}), }),
); );
fs.writeFileSync(path.join(repoDir, 'pnpm-lock.yaml'), 'lockfileVersion: 9.0\n'); fs.writeFileSync(
path.join(repoDir, 'pnpm-lock.yaml'),
'lockfileVersion: 9.0\n',
);
execFileSyncMock.mockImplementation((_file, _args, options) => { execFileSyncMock.mockImplementation((_file, _args, options) => {
const cwd = (options as { cwd: string }).cwd; const cwd = (options as { cwd: string }).cwd;

View File

@@ -44,7 +44,9 @@ function readPackageJsonMetadata(repoDir: string): PackageJsonMetadata | null {
return null; return null;
} }
return JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) as PackageJsonMetadata; return JSON.parse(
fs.readFileSync(packageJsonPath, 'utf-8'),
) as PackageJsonMetadata;
} }
function detectPackageManagerFromField( function detectPackageManagerFromField(
@@ -60,10 +62,7 @@ function detectPackageManagerFromField(
return null; return null;
} }
function hasLockfile( function hasLockfile(repoDir: string, ...names: readonly string[]): boolean {
repoDir: string,
...names: readonly string[]
): boolean {
return names.some((name) => fs.existsSync(path.join(repoDir, name))); return names.some((name) => fs.existsSync(path.join(repoDir, name)));
} }
@@ -91,7 +90,9 @@ function detectYarnBerry(
packageManagerField: string | undefined, packageManagerField: string | undefined,
): boolean { ): boolean {
if (packageManagerField?.startsWith('yarn@')) { if (packageManagerField?.startsWith('yarn@')) {
const version = packageManagerField.slice('yarn@'.length).split(/[+-]/, 1)[0]; const version = packageManagerField
.slice('yarn@'.length)
.split(/[+-]/, 1)[0];
const major = Number.parseInt(version.split('.', 1)[0] ?? '', 10); const major = Number.parseInt(version.split('.', 1)[0] ?? '', 10);
if (Number.isFinite(major) && major >= 2) { if (Number.isFinite(major) && major >= 2) {
return true; return true;
@@ -142,11 +143,7 @@ function computeInstallFingerprint(repoDir: string): string | null {
} }
function readInstallFingerprint(repoDir: string): string | null { function readInstallFingerprint(repoDir: string): string | null {
const statePath = path.join( const statePath = path.join(repoDir, 'node_modules', INSTALL_STATE_FILENAME);
repoDir,
'node_modules',
INSTALL_STATE_FILENAME,
);
if (!fs.existsSync(statePath)) { if (!fs.existsSync(statePath)) {
return null; return null;
} }
@@ -356,7 +353,9 @@ export function ensureWorkspaceDependenciesInstalled(
? (error as { stdout: string }).stdout.trim() ? (error as { stdout: string }).stdout.trim()
: ''; : '';
const detail = const detail =
stderr || stdout || (error instanceof Error ? error.message : String(error)); stderr ||
stdout ||
(error instanceof Error ? error.message : String(error));
throw new Error( throw new Error(
`Failed to install workspace dependencies with "${command.commandText}" in ${repoDir}: ${detail}`, `Failed to install workspace dependencies with "${command.commandText}" in ${repoDir}: ${detail}`,
); );