add gated codex goals support

This commit is contained in:
ejclaw
2026-05-01 23:21:24 +09:00
parent bb1998be29
commit fcd55a1c98
10 changed files with 333 additions and 17 deletions

View File

@@ -5,7 +5,7 @@
"": {
"name": "ejclaw-codex-runner",
"dependencies": {
"@openai/codex": "^0.124.0",
"@openai/codex": "0.128.0",
"ejclaw-runners-shared": "file:../shared",
},
"devDependencies": {
@@ -15,19 +15,19 @@
},
},
"packages": {
"@openai/codex": ["@openai/codex@0.124.0", "", { "optionalDependencies": { "@openai/codex-darwin-arm64": "npm:@openai/codex@0.124.0-darwin-arm64", "@openai/codex-darwin-x64": "npm:@openai/codex@0.124.0-darwin-x64", "@openai/codex-linux-arm64": "npm:@openai/codex@0.124.0-linux-arm64", "@openai/codex-linux-x64": "npm:@openai/codex@0.124.0-linux-x64", "@openai/codex-win32-arm64": "npm:@openai/codex@0.124.0-win32-arm64", "@openai/codex-win32-x64": "npm:@openai/codex@0.124.0-win32-x64" }, "bin": { "codex": "bin/codex.js" } }, "sha512-1EVAuPyAQZ8zIVMw3bPJ6a4R8ifLAZ7LGsOyknj5c2he9AFXVRCmWx12WrdZJ25wcBvOEKt1n1Zx+QAj0EVGbQ=="],
"@openai/codex": ["@openai/codex@0.128.0", "", { "optionalDependencies": { "@openai/codex-darwin-arm64": "npm:@openai/codex@0.128.0-darwin-arm64", "@openai/codex-darwin-x64": "npm:@openai/codex@0.128.0-darwin-x64", "@openai/codex-linux-arm64": "npm:@openai/codex@0.128.0-linux-arm64", "@openai/codex-linux-x64": "npm:@openai/codex@0.128.0-linux-x64", "@openai/codex-win32-arm64": "npm:@openai/codex@0.128.0-win32-arm64", "@openai/codex-win32-x64": "npm:@openai/codex@0.128.0-win32-x64" }, "bin": { "codex": "bin/codex.js" } }, "sha512-+xp6ODmFfBNnexIWRHApEaPXot2j6gyM8A5we/5IS/uY4eYHj4arETct4hQ5M4eO+MK7JY3ZU4xhuobhlysr0A=="],
"@openai/codex-darwin-arm64": ["@openai/codex@0.124.0-darwin-arm64", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lnuqeAdl+RjPWsqVZ8rrPskRIOZmzlyr8e5q/wFVEnMPsm9dWgjRW1PKa84UmDSQVOuz9GObF28DEHFyC4A8NA=="],
"@openai/codex-darwin-arm64": ["@openai/codex@0.128.0-darwin-arm64", "", { "os": "darwin", "cpu": "arm64" }, "sha512-w+6zohfHx/kHBdles/CyFKaY57u9I3nK8QI9+NrdwMliKA0b7xn13yblRNkMpe09j6vL1oAWoxYsMOQ/vjBGug=="],
"@openai/codex-darwin-x64": ["@openai/codex@0.124.0-darwin-x64", "", { "os": "darwin", "cpu": "x64" }, "sha512-Br+VlL83IOu96Urw+mkZ1PQXLsQXGAYEH6kXNt4ULuVt7qAdQY2FKYwIgLLVLl0vpMk8qWxdfdVMqhiu2uCHlg=="],
"@openai/codex-darwin-x64": ["@openai/codex@0.128.0-darwin-x64", "", { "os": "darwin", "cpu": "x64" }, "sha512-SDbn6fO22Puy8xmMIbZi4f2znMrUEPwABApke4mo+4ihaauwuVjeqzXvW5SPJz5ty/bG11/mSupQgReT7T8BBw=="],
"@openai/codex-linux-arm64": ["@openai/codex@0.124.0-linux-arm64", "", { "os": "linux", "cpu": "arm64" }, "sha512-QXafFVQJxPfU1LSCI5afuHsF5evKAcbQpFuKou0Kl241/HG/zYHdwoWj546zH844gJgegIPAxPf36+RSkUsbbA=="],
"@openai/codex-linux-arm64": ["@openai/codex@0.128.0-linux-arm64", "", { "os": "linux", "cpu": "arm64" }, "sha512-+SvH73H60qvCXFuQGP/EsmR//s1hHMBR22PvJkXvM/hdnTIGucx+JqRUjAWdmmQ1IU6j3kgwVvdLW/6ICB+M6w=="],
"@openai/codex-linux-x64": ["@openai/codex@0.124.0-linux-x64", "", { "os": "linux", "cpu": "x64" }, "sha512-cJ4QfQIrz2gkXVWephiGo9JDyD3qLKhvkTTLk7gI8rKd7MZpVXn9/1e7pZCQnJVA7Dk6ocA5G+sxnR78SoIejw=="],
"@openai/codex-linux-x64": ["@openai/codex@0.128.0-linux-x64", "", { "os": "linux", "cpu": "x64" }, "sha512-2lnSPA05CRRuKAzFW8BCmmNCSieDcToLwfC2ALLbBYilGLgzhRibjlDglK9F1BkEzfohSSWJu4PBbRu/aG60lQ=="],
"@openai/codex-win32-arm64": ["@openai/codex@0.124.0-win32-arm64", "", { "os": "win32", "cpu": "arm64" }, "sha512-oDSI/CQh0kagBwWVPG9EiUBfHiY27ju3LPrjTVZg4VMpVJVNA31JTK8rHMZ6G/2gfNmOl6DMBA6+0ICxSkkshg=="],
"@openai/codex-win32-arm64": ["@openai/codex@0.128.0-win32-arm64", "", { "os": "win32", "cpu": "arm64" }, "sha512-ECJvsqmYFdA9pn42xxK3Odp/G16AjmBW0BglX8L0PwPjqbstbmlew9bfHf7xvL+SNfNl4NmyotW0+RNo1phgaA=="],
"@openai/codex-win32-x64": ["@openai/codex@0.124.0-win32-x64", "", { "os": "win32", "cpu": "x64" }, "sha512-t+lAwmCWwIWQKk6dKaYetRhQsmA+uDmQy1NLka1xAAv3PlNOH8iNz9Lsisr3qYkBR8Tf7tbQlt+pl9tw/A5mfA=="],
"@openai/codex-win32-x64": ["@openai/codex@0.128.0-win32-x64", "", { "os": "win32", "cpu": "x64" }, "sha512-k3jmUAFrzkUtvjGTXvSKjQqJLLlzjxp/VoHJDYedgmXUn6j70HxK38IwapzmnYfiBiTuzETvGwjXHzZgzKjhoQ=="],
"@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],

View File

@@ -10,8 +10,8 @@
"start": "bun dist/index.js"
},
"dependencies": {
"ejclaw-runners-shared": "file:../shared",
"@openai/codex": "^0.124.0"
"@openai/codex": "0.128.0",
"ejclaw-runners-shared": "file:../shared"
},
"devDependencies": {
"@types/node": "^22.10.7",

View File

@@ -42,6 +42,53 @@ export interface CodexAppServerTurnResult {
result: string | null;
}
export interface CodexThreadGoal {
threadId: string;
objective: string;
status: string;
tokenBudget: number | null;
tokensUsed: number;
timeUsedSeconds: number;
createdAt: number;
updatedAt: number;
}
export function buildCodexAppServerArgs(args: {
codexBin: string;
enableGoals?: boolean;
}): string[] {
return args.enableGoals === true
? [args.codexBin, '--enable', 'goals', 'app-server']
: [args.codexBin, 'app-server'];
}
function isCodexThreadGoal(value: unknown): value is CodexThreadGoal {
if (!value || typeof value !== 'object') return false;
const goal = value as Partial<CodexThreadGoal>;
return (
typeof goal.threadId === 'string' &&
typeof goal.objective === 'string' &&
typeof goal.status === 'string'
);
}
function extractOptionalGoalResult(result: unknown): CodexThreadGoal | null {
const goal = (result as { goal?: unknown } | null)?.goal ?? null;
if (goal === null) return null;
if (!isCodexThreadGoal(goal)) {
throw new Error('Codex app-server returned an invalid goal payload.');
}
return goal;
}
function extractGoalResult(result: unknown, method: string): CodexThreadGoal {
const goal = extractOptionalGoalResult(result);
if (!goal) {
throw new Error(`Codex app-server ${method} did not return a goal.`);
}
return goal;
}
interface JsonRpcResponse {
id: number;
result?: unknown;
@@ -79,12 +126,14 @@ export interface CodexAppServerClientOptions {
cwd: string;
env?: NodeJS.ProcessEnv;
log: (message: string) => void;
enableGoals?: boolean;
}
export class CodexAppServerClient {
private readonly cwd: string;
private readonly env: NodeJS.ProcessEnv;
private readonly log: (message: string) => void;
private readonly enableGoals: boolean;
private readonly pending = new Map<number, PendingRequest>();
private readonly require = createRequire(import.meta.url);
private nextId = 1;
@@ -96,6 +145,7 @@ export class CodexAppServerClient {
this.cwd = options.cwd;
this.env = options.env || process.env;
this.log = options.log;
this.enableGoals = options.enableGoals === true;
}
async start(): Promise<void> {
@@ -108,11 +158,18 @@ export class CodexAppServerClient {
'codex.js',
);
this.proc = spawn(process.execPath, [codexBin, 'app-server'], {
cwd: this.cwd,
env: this.env,
stdio: ['pipe', 'pipe', 'pipe'],
});
this.proc = spawn(
process.execPath,
buildCodexAppServerArgs({
codexBin,
enableGoals: this.enableGoals,
}),
{
cwd: this.cwd,
env: this.env,
stdio: ['pipe', 'pipe', 'pipe'],
},
);
this.proc.stdout.setEncoding('utf8');
this.proc.stdout.on('data', (chunk: string) => {
@@ -312,6 +369,27 @@ export class CodexAppServerClient {
return turnPromise;
}
async threadGoalSet(
threadId: string,
objective: string,
): Promise<CodexThreadGoal> {
const result = await this.request('thread/goal/set', {
threadId,
objective,
});
return extractGoalResult(result, 'thread/goal/set');
}
async threadGoalGet(threadId: string): Promise<CodexThreadGoal | null> {
const result = await this.request('thread/goal/get', { threadId });
return extractOptionalGoalResult(result);
}
async threadGoalClear(threadId: string): Promise<boolean> {
const result = await this.request('thread/goal/clear', { threadId });
return (result as { cleared?: boolean } | null)?.cleared === true;
}
private handleStdoutLine(line: string): void {
let message: JsonRpcResponse | JsonRpcNotification | JsonRpcServerRequest;
try {

View File

@@ -52,6 +52,7 @@ interface RunnerInput {
isScheduledTask?: boolean;
assistantName?: string;
agentType?: string;
codexGoals?: boolean;
roomRoleContext?: RoomRoleContext;
}
@@ -95,6 +96,10 @@ function log(message: string): void {
console.error(`[codex-runner] ${message}`);
}
function isCodexGoalsEnabled(runnerInput: RunnerInput): boolean {
return runnerInput.codexGoals === true || process.env.CODEX_GOALS === 'true';
}
async function readStdin(): Promise<string> {
return new Promise((resolve, reject) => {
let data = '';
@@ -343,6 +348,7 @@ async function runAppServerSession(
cwd: EFFECTIVE_CWD,
env: clientEnv,
log,
enableGoals: isCodexGoalsEnabled(runnerInput),
});
await client.start();

View File

@@ -0,0 +1,87 @@
import { describe, expect, it, vi } from 'vitest';
import {
CodexAppServerClient,
buildCodexAppServerArgs,
} from '../src/app-server-client.js';
describe('codex app-server client goals', () => {
it('does not enable goals by default when spawning app-server', () => {
expect(
buildCodexAppServerArgs({
codexBin: '/opt/codex/bin/codex.js',
}),
).toEqual(['/opt/codex/bin/codex.js', 'app-server']);
});
it('adds the under-development goals feature only when explicitly enabled', () => {
expect(
buildCodexAppServerArgs({
codexBin: '/opt/codex/bin/codex.js',
enableGoals: true,
}),
).toEqual(['/opt/codex/bin/codex.js', '--enable', 'goals', 'app-server']);
});
it('wraps thread goal JSON-RPC methods with the upstream objective field', async () => {
const client = new CodexAppServerClient({
cwd: '/repo',
log: () => undefined,
});
const request = vi
.fn()
.mockResolvedValueOnce({
goal: {
threadId: 'thread-1',
objective: 'ship the release',
status: 'active',
tokenBudget: null,
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: 1,
updatedAt: 1,
},
})
.mockResolvedValueOnce({
goal: {
threadId: 'thread-1',
objective: 'ship the release',
status: 'active',
tokenBudget: null,
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: 1,
updatedAt: 1,
},
})
.mockResolvedValueOnce({ cleared: true });
(
client as unknown as {
request: (method: string, params?: unknown) => Promise<unknown>;
}
).request = request;
await expect(
client.threadGoalSet('thread-1', 'ship the release'),
).resolves.toMatchObject({
threadId: 'thread-1',
objective: 'ship the release',
status: 'active',
});
await expect(client.threadGoalGet('thread-1')).resolves.toMatchObject({
objective: 'ship the release',
});
await expect(client.threadGoalClear('thread-1')).resolves.toBe(true);
expect(request).toHaveBeenNthCalledWith(1, 'thread/goal/set', {
threadId: 'thread-1',
objective: 'ship the release',
});
expect(request).toHaveBeenNthCalledWith(2, 'thread/goal/get', {
threadId: 'thread-1',
});
expect(request).toHaveBeenNthCalledWith(3, 'thread/goal/clear', {
threadId: 'thread-1',
});
});
});

View File

@@ -370,6 +370,79 @@ describe('prepareGroupEnvironment codex auth handling', () => {
});
});
describe('prepareGroupEnvironment Codex goals handling', () => {
let tempRoot: string;
let previousCwd: string;
let previousCodexGoals: string | undefined;
beforeEach(() => {
tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-agent-env-goals-'));
previousCwd = process.cwd();
previousCodexGoals = process.env.CODEX_GOALS;
process.chdir(tempRoot);
process.env.EJ_TEST_ROOT = tempRoot;
process.env.EJ_TEST_HOME = path.join(tempRoot, 'home');
delete process.env.CODEX_GOALS;
fs.mkdirSync(path.join(process.env.EJ_TEST_HOME, '.codex'), {
recursive: true,
});
mockReadEnvFile.mockReset();
mockGetActiveCodexAuthPath.mockReset();
vi.mocked(config.isReviewService).mockReturnValue(false);
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(false);
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: 'dc:test',
owner_service_id: 'claude',
reviewer_service_id: 'codex-main',
arbiter_service_id: null,
owner_failover_active: false,
activated_at: null,
reason: null,
explicit: false,
});
});
afterEach(() => {
process.chdir(previousCwd);
delete process.env.EJ_TEST_ROOT;
delete process.env.EJ_TEST_HOME;
if (previousCodexGoals) process.env.CODEX_GOALS = previousCodexGoals;
else delete process.env.CODEX_GOALS;
fs.rmSync(tempRoot, { recursive: true, force: true });
});
it('keeps Codex goals disabled by default and enables them only via opt-in config', () => {
mockReadEnvFile.mockReturnValue({});
const defaultPrepared = prepareGroupEnvironment(group, false, 'dc:test');
expect(defaultPrepared.env.CODEX_GOALS).toBeUndefined();
const enabledPrepared = prepareGroupEnvironment(
{
...group,
agentConfig: {
codexGoals: true,
},
},
false,
'dc:test',
);
expect(enabledPrepared.env.CODEX_GOALS).toBe('true');
});
it('allows CODEX_GOALS env opt-in for Codex runner sessions', () => {
mockReadEnvFile.mockReturnValue({
CODEX_GOALS: 'true',
});
const prepared = prepareGroupEnvironment(group, false, 'dc:test');
expect(prepared.env.CODEX_GOALS).toBe('true');
});
});
describe('prepareReadonlySessionEnvironment codex compatibility', () => {
let tempRoot: string;
let previousCwd: string;

View File

@@ -319,6 +319,15 @@ function prepareCodexSessionEnvironment(args: {
process.env.CODEX_EFFORT;
if (codexEffort) args.env.CODEX_EFFORT = codexEffort;
const codexGoals =
args.group.agentConfig?.codexGoals ??
(args.envVars.CODEX_GOALS === 'true' || process.env.CODEX_GOALS === 'true');
if (codexGoals) {
args.env.CODEX_GOALS = 'true';
} else {
delete args.env.CODEX_GOALS;
}
const sessionCodexDir = path.join(args.sessionRootDir, '.codex');
syncHostCodexSessionFiles(sessionCodexDir);
@@ -527,6 +536,7 @@ export function prepareGroupEnvironment(
'CLAUDE_EFFORT',
'CODEX_MODEL',
'CODEX_EFFORT',
'CODEX_GOALS',
]);
const env = buildBaseRunnerEnv({

View File

@@ -680,3 +680,59 @@ OUROBOROS_LLM_BACKEND = "codex"
);
});
});
describe('agent-runner Codex goals', () => {
beforeEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
fakeProc = createFakeProcess();
vi.mocked(fs.existsSync).mockImplementation((p: fs.PathLike) => {
const str = String(p);
return (
str.includes('dist/index.js') ||
str.includes('dist/ipc-mcp-stdio.js') ||
str.endsWith('/.codex/config.toml')
);
});
});
afterEach(() => {
vi.useRealTimers();
});
it('passes Codex goal opt-in config through env and runner stdin payload', async () => {
let stdinPayload = '';
fakeProc.stdin.on('data', (chunk) => {
stdinPayload += chunk.toString();
});
const codexGroup: RegisteredGroup = {
...testGroup,
agentType: 'codex',
agentConfig: {
codexGoals: true,
},
};
const resultPromise = runAgentProcess(
codexGroup,
testInput,
() => {},
async () => {},
);
fakeProc.emit('close', 0);
const result = await resultPromise;
expect(result.status).toBe('success');
expect(JSON.parse(stdinPayload)).toMatchObject({
prompt: 'Hello',
codexGoals: true,
});
const spawnEnv = vi.mocked(spawn).mock.calls.at(-1)?.[2]?.env as
| Record<string, string>
| undefined;
expect(spawnEnv?.CODEX_GOALS).toBe('true');
});
});

View File

@@ -34,6 +34,7 @@ export interface AgentInput {
useTaskScopedSession?: boolean;
assistantName?: string;
agentType?: 'claude-code' | 'codex';
codexGoals?: boolean;
roomRoleContext?: RoomRoleContext;
}
@@ -150,13 +151,17 @@ export async function runAgentProcess(
onProcess(proc, processName, env.EJCLAW_IPC_DIR);
proc.stdin.write(JSON.stringify(input));
const runnerInput: AgentInput = {
...input,
...(group.agentConfig?.codexGoals === true ? { codexGoals: true } : {}),
};
proc.stdin.write(JSON.stringify(runnerInput));
proc.stdin.end();
runSpawnedAgentProcess({
proc,
group,
input,
input: runnerInput,
processName,
logsDir,
startTime,

View File

@@ -5,6 +5,7 @@ export interface AgentConfig {
// Per-group model/effort overrides (take precedence over global env vars)
codexModel?: string;
codexEffort?: string;
codexGoals?: boolean;
claudeModel?: string;
claudeEffort?: string;
claudeThinking?: 'adaptive' | 'enabled' | 'disabled';