Stabilize paired reviewer recovery and owner follow-up
This commit is contained in:
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,10 +144,91 @@ 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 ──
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}`,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user