Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,7 @@ import type { DagNode, WorkflowDefinition } from '@/lib/api';
5. **`workflow_runs`** - Workflow execution tracking and state
6. **`workflow_events`** - Step-level workflow event log (step transitions, artifacts, errors)
7. **`messages`** - Conversation message history with tool call metadata (JSONB)
8. **`codebase_env_vars`** - Per-project env vars injected into Claude SDK subprocess env (managed via Web UI or `env:` in config)
8. **`codebase_env_vars`** - Per-project env vars injected into project-scoped execution surfaces (Claude, Codex, bash/script nodes, and direct chat when codebase-scoped), managed via Web UI or `env:` in config

**Key Patterns:**
- Conversation ID format: Platform-specific (`thread_ts`, `chat_id`, `user/repo#123`)
Expand Down Expand Up @@ -686,7 +686,7 @@ async function createSession(conversationId: string, codebaseId: string) {
2. **Workflows** (YAML-based):
- Stored in `.archon/workflows/` (searched recursively)
- Multi-step AI execution chains, discovered at runtime
- **`nodes:` (DAG format)**: Nodes with explicit `depends_on` edges; independent nodes in the same topological layer run concurrently. Node types: `command:` (named command file), `prompt:` (inline prompt), `bash:` (shell script, stdout captured as `$nodeId.output`, no AI), `loop:` (iterative AI prompt until completion signal), `approval:` (human gate; pauses until user approves or rejects; `capture_response: true` stores the user's comment as `$<node-id>.output` for downstream nodes, default false), `script:` (inline TypeScript/Python or named script from `.archon/scripts/`, runs via `bun` or `uv`, stdout captured as `$nodeId.output`, no AI, supports `deps:` for dependency installation and `timeout:` in ms, requires `runtime: bun` or `runtime: uv`) . Supports `when:` conditions, `trigger_rule` join semantics, `$nodeId.output` substitution, `output_format` for structured JSON output (Claude and Codex), `allowed_tools`/`denied_tools` for per-node tool restrictions (Claude only), `hooks` for per-node SDK hook callbacks (Claude only), `mcp` for per-node MCP server config files (Claude only, env vars expanded at execution time), and `skills` for per-node skill preloading via AgentDefinition wrapping (Claude only), and `effort`/`thinking`/`maxBudgetUsd`/`systemPrompt`/`fallbackModel`/`betas`/`sandbox` for Claude SDK advanced options (Claude only, also settable at workflow level)
- **`nodes:` (DAG format)**: Nodes with explicit `depends_on` edges; independent nodes in the same topological layer run concurrently. Node types: `command:` (named command file), `prompt:` (inline prompt), `bash:` (shell script, stdout captured as `$nodeId.output`, no AI, receives managed per-project env vars in its subprocess environment when configured), `loop:` (iterative AI prompt until completion signal), `approval:` (human gate; pauses until user approves or rejects; `capture_response: true` stores the user's comment as `$<node-id>.output` for downstream nodes, default false), `script:` (inline TypeScript/Python or named script from `.archon/scripts/`, runs via `bun` or `uv`, stdout captured as `$nodeId.output`, no AI, receives managed per-project env vars in its subprocess environment when configured, supports `deps:` for dependency installation and `timeout:` in ms, requires `runtime: bun` or `runtime: uv`) . Supports `when:` conditions, `trigger_rule` join semantics, `$nodeId.output` substitution, `output_format` for structured JSON output (Claude and Codex), `allowed_tools`/`denied_tools` for per-node tool restrictions (Claude only), `hooks` for per-node SDK hook callbacks (Claude only), `mcp` for per-node MCP server config files (Claude only, env vars expanded at execution time), and `skills` for per-node skill preloading via AgentDefinition wrapping (Claude only), and `effort`/`thinking`/`maxBudgetUsd`/`systemPrompt`/`fallbackModel`/`betas`/`sandbox` for Claude SDK advanced options (Claude only, also settable at workflow level)
- Provider inherited from `.archon/config.yaml` unless explicitly set; per-node `provider` and `model` overrides supported
- Model and options can be set per workflow or inherited from config defaults
- `interactive: true` at the workflow level forces foreground execution on web (required for approval-gate workflows in the web UI)
Expand Down
82 changes: 80 additions & 2 deletions packages/core/src/orchestrator/orchestrator-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,17 @@ const mockExecuteWorkflow = mock(() => Promise.resolve());
const mockHandleCommand = mock(() =>
Promise.resolve({ success: true, message: 'ok', workflow: undefined })
);
const mockSendQuery = mock(async function* () {
yield { type: 'assistant', content: 'test response' };
yield { type: 'result', sessionId: 'session-1' };
});
const mockGetCodebaseEnvVars = mock(() => Promise.resolve({}));
const mockLoadConfig = mock(() =>
Promise.resolve({
assistants: { claude: {}, codex: {} },
envVars: {},
})
);

const mockLogger = createMockLogger();

Expand Down Expand Up @@ -95,12 +106,16 @@ mock.module('@archon/workflows/executor', () => ({

mock.module('@archon/providers', () => ({
getAgentProvider: mock(() => ({
sendQuery: mock(async function* () {}),
sendQuery: mockSendQuery,
getType: mock(() => 'claude'),
getCapabilities: mock(() => ({})),
})),
}));

mock.module('../db/env-vars', () => ({
getCodebaseEnvVars: mockGetCodebaseEnvVars,
}));

mock.module('../utils/error-formatter', () => ({
classifyAndFormatError: mock((err: Error) => `Error: ${err.message}`),
}));
Expand All @@ -127,7 +142,7 @@ mock.module('../db/workflow-events', () => ({
}));

mock.module('../config/config-loader', () => ({
loadConfig: mock(() => Promise.resolve({})),
loadConfig: mockLoadConfig,
}));

mock.module('../services/title-generator', () => ({
Expand Down Expand Up @@ -873,9 +888,19 @@ describe('discoverAllWorkflows — remote sync', () => {
mockToRepoPath.mockClear();
mockGetOrCreateConversation.mockReset();
mockGetCodebase.mockReset();
mockSendQuery.mockClear();
mockGetCodebaseEnvVars.mockReset();
mockLoadConfig.mockReset();
// Reset mocks between tests in this suite and restore safe defaults
mockGetOrCreateConversation.mockImplementation(() => Promise.resolve(null));
mockGetCodebase.mockImplementation(() => Promise.resolve(null));
mockGetCodebaseEnvVars.mockImplementation(() => Promise.resolve({}));
mockLoadConfig.mockImplementation(() =>
Promise.resolve({
assistants: { claude: {}, codex: {} },
envVars: {},
})
);
});

test('calls syncWorkspace with codebase.default_cwd when conversation has codebase_id', async () => {
Expand Down Expand Up @@ -954,6 +979,59 @@ describe('discoverAllWorkflows — remote sync', () => {
'workspace.sync_failed'
);
});

test('passes merged repo and DB env vars to provider for codebase-scoped chat', async () => {
const conversation = makeConversation({ codebase_id: 'codebase-1' });
const codebase = makeCodebaseForSync();
mockGetOrCreateConversation.mockReturnValueOnce(Promise.resolve(conversation));
mockGetCodebase.mockReturnValueOnce(Promise.resolve(codebase));
mockGetCodebaseEnvVars.mockResolvedValueOnce({ DB_SECRET: 'db-value' });
mockLoadConfig.mockResolvedValueOnce({
assistants: { claude: {}, codex: {} },
envVars: { FILE_SECRET: 'file-value' },
});

const platform = makePlatform();
await handleMessage(platform, 'conv-1', 'What is the latest commit?');

expect(mockSendQuery).toHaveBeenCalled();
const requestOptions = mockSendQuery.mock.calls[0][3] as Record<string, unknown>;
expect(requestOptions.env).toEqual({
FILE_SECRET: 'file-value',
DB_SECRET: 'db-value',
});
});

test('does not load codebase env vars when conversation has no codebase_id', async () => {
mockGetOrCreateConversation.mockReturnValueOnce(Promise.resolve(makeConversation()));

const platform = makePlatform();
await handleMessage(platform, 'conv-1', 'Hello');

expect(mockGetCodebaseEnvVars).not.toHaveBeenCalled();
});

test('falls back to config env when codebase env loading fails', async () => {
const conversation = makeConversation({ codebase_id: 'codebase-1' });
const codebase = makeCodebaseForSync();
mockGetOrCreateConversation.mockReturnValueOnce(Promise.resolve(conversation));
mockGetCodebase.mockReturnValueOnce(Promise.resolve(codebase));
mockGetCodebaseEnvVars.mockRejectedValueOnce(new Error('db unavailable'));
mockLoadConfig.mockResolvedValueOnce({
assistants: { claude: {}, codex: {} },
envVars: { FILE_SECRET: 'file-value' },
});

const platform = makePlatform();
await handleMessage(platform, 'conv-1', 'What is the latest commit?');

expect(mockLogger.warn).toHaveBeenCalledWith(
expect.objectContaining({ codebaseId: 'codebase-1' }),
'codebase_env_vars_load_failed'
);
const requestOptions = mockSendQuery.mock.calls[0][3] as Record<string, unknown>;
expect(requestOptions.env).toEqual({ FILE_SECRET: 'file-value' });
});
});

// ─── Workflow dispatch routing — interactive flag ─────────────────────────────
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/orchestrator/orchestrator-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import { IsolationBlockedError } from '@archon/isolation';
import { buildOrchestratorPrompt, buildProjectScopedPrompt } from './prompt-builder';
import * as workflowDb from '../db/workflows';
import * as workflowEventDb from '../db/workflow-events';
import { getCodebaseEnvVars } from '../db/env-vars';
import type { ApprovalContext } from '@archon/workflows/schemas/workflow-run';

/** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */
Expand Down Expand Up @@ -759,8 +760,21 @@ export async function handleMessage(
// Fall back to loadConfig only when no codebase is scoped (discoveredConfig is undefined).
const config = discoveredConfig ?? (await loadConfig());
const providerKey = conversation.ai_assistant_type as 'claude' | 'codex';
let dbEnvVars: Record<string, string> = {};
if (conversation.codebase_id) {
try {
dbEnvVars = await getCodebaseEnvVars(conversation.codebase_id);
} catch (error) {
getLog().warn(
{ err: error as Error, codebaseId: conversation.codebase_id },
'codebase_env_vars_load_failed'
);
}
}
const effectiveEnv = { ...(config.envVars ?? {}), ...dbEnvVars };
const requestOptions: SendQueryOptions = {
assistantConfig: (config.assistants[providerKey] ?? {}) as Record<string, unknown>,
env: Object.keys(effectiveEnv).length > 0 ? effectiveEnv : undefined,
};

const mode = platform.getStreamingMode();
Expand Down
2 changes: 1 addition & 1 deletion packages/git/src/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const promisifiedExecFile = promisify(execFile);
export async function execFileAsync(
cmd: string,
args: string[],
options?: { timeout?: number; cwd?: string; maxBuffer?: number }
options?: { timeout?: number; cwd?: string; maxBuffer?: number; env?: NodeJS.ProcessEnv }
): Promise<{ stdout: string; stderr: string }> {
const result = await promisifiedExecFile(cmd, args, options);
return {
Expand Down
102 changes: 100 additions & 2 deletions packages/providers/src/codex/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,15 @@ mock.module('@openai/codex-sdk', () => ({
Codex: MockCodex,
}));

import { CodexProvider } from './provider';
import { CodexProvider, resetCodexSingleton } from './provider';

describe('CodexProvider', () => {
let client: CodexProvider;

beforeEach(() => {
resetCodexSingleton();
client = new CodexProvider({ retryBaseDelayMs: 1 });
MockCodex.mockClear();
mockStartThread.mockClear();
mockResumeThread.mockClear();
mockRunStreamed.mockClear();
Expand Down Expand Up @@ -75,7 +77,7 @@ describe('CodexProvider', () => {
skills: false,
toolRestrictions: false,
structuredOutput: true,
envInjection: false,
envInjection: true,
costControl: false,
effortControl: false,
thinkingControl: false,
Expand Down Expand Up @@ -717,6 +719,102 @@ describe('CodexProvider', () => {
expect(mockRunStreamed).toHaveBeenCalledWith('test prompt', {});
});

test('creates a per-call Codex instance when env is provided', async () => {
mockRunStreamed.mockResolvedValue({
events: (async function* () {
yield { type: 'turn.completed', usage: defaultUsage };
})(),
});

for await (const _ of client.sendQuery('test prompt', '/workspace', undefined, {
env: { MY_SECRET: 'abc123' },
})) {
// consume
}

expect(MockCodex).toHaveBeenCalledWith(
expect.objectContaining({
env: expect.objectContaining({ MY_SECRET: 'abc123' }),
})
);
expect(mockStartThread).toHaveBeenCalledTimes(1);
});

test('builds env by preserving process vars and letting request env win on collisions', async () => {
const originalPath = process.env.PATH;
const originalArchonEnv = process.env.ARCHON_CODEX_TEST_ENV;
process.env.PATH = 'from-process';
process.env.ARCHON_CODEX_TEST_ENV = 'kept-from-process';

try {
mockRunStreamed.mockResolvedValue({
events: (async function* () {
yield { type: 'turn.completed', usage: defaultUsage };
})(),
});

for await (const _ of client.sendQuery('test prompt', '/workspace', undefined, {
env: { PATH: 'from-request', MY_SECRET: 'abc123' },
})) {
// consume
}

expect(MockCodex).toHaveBeenCalledWith(
expect.objectContaining({
env: expect.objectContaining({
PATH: 'from-request',
ARCHON_CODEX_TEST_ENV: 'kept-from-process',
MY_SECRET: 'abc123',
}),
})
);
} finally {
if (originalPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = originalPath;
}
if (originalArchonEnv === undefined) {
delete process.env.ARCHON_CODEX_TEST_ENV;
} else {
process.env.ARCHON_CODEX_TEST_ENV = originalArchonEnv;
}
}
});

test('reuses the singleton Codex instance across sequential calls without env', async () => {
mockRunStreamed.mockResolvedValue({
events: (async function* () {
yield { type: 'turn.completed', usage: defaultUsage };
})(),
});

for await (const _ of client.sendQuery('first prompt', '/workspace')) {
// consume
}
for await (const _ of client.sendQuery('second prompt', '/workspace')) {
// consume
}

expect(MockCodex).toHaveBeenCalledTimes(1);
});

test('wraps per-call Codex constructor failures with provider error context', async () => {
MockCodex.mockImplementationOnce(() => {
throw new Error('constructor failed');
});

const consumeGenerator = async (): Promise<void> => {
for await (const _ of client.sendQuery('test prompt', '/workspace', undefined, {
env: { MY_SECRET: 'abc123' },
})) {
// consume
}
};

await expect(consumeGenerator()).rejects.toThrow('Codex query failed: constructor failed');
});

test('breaks on turn.completed event', async () => {
mockRunStreamed.mockResolvedValue({
events: (async function* () {
Expand Down
37 changes: 32 additions & 5 deletions packages/providers/src/codex/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,14 @@ function buildThreadOptions(
};
}

function buildCodexEnv(requestEnv: Record<string, string>): Record<string, string> {
const baseEnv = Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined)
);
// Managed project env intentionally overrides inherited process env for project-scoped execution.
return { ...baseEnv, ...requestEnv };
}

const CODEX_MODEL_FALLBACKS: Record<string, string> = {
'gpt-5.3-codex': 'gpt-5.2-codex',
};
Expand Down Expand Up @@ -465,6 +473,28 @@ export class CodexProvider implements IAgentProvider {
this.retryBaseDelayMs = options?.retryBaseDelayMs ?? RETRY_BASE_DELAY_MS;
}

private async createCodexClient(
configCodexBinaryPath: string | undefined,
requestEnv?: Record<string, string>
): Promise<Codex> {
if (!requestEnv || Object.keys(requestEnv).length === 0) {
return getCodex(configCodexBinaryPath);
}

try {
return new Codex({
codexPathOverride: await resolveCodexBinaryPath(configCodexBinaryPath),
env: buildCodexEnv(requestEnv),
});
} catch (error) {
const err = error as Error;
if (isModelAccessError(err.message)) {
throw new Error(buildModelAccessMessage());
}
throw new Error(`Codex query failed: ${err.message}`);
}
}

getCapabilities(): ProviderCapabilities {
return {
sessionResume: true,
Expand All @@ -473,7 +503,7 @@ export class CodexProvider implements IAgentProvider {
skills: false,
toolRestrictions: false,
structuredOutput: true,
envInjection: false,
envInjection: true,
costControl: false,
effortControl: false,
thinkingControl: false,
Expand All @@ -482,9 +512,6 @@ export class CodexProvider implements IAgentProvider {
};
}

// Env safety: Codex inherits cleaned parent env (stripCwdEnv at boot).
// Codex native binary does not auto-load .env from CWD (E2E verified).
// Managed env injection tracked in #1161.
async *sendQuery(
prompt: string,
cwd: string,
Expand All @@ -495,7 +522,7 @@ export class CodexProvider implements IAgentProvider {
const codexConfig = parseCodexConfig(assistantConfig);

// 1. Initialize SDK and build thread options
const codex = await getCodex(codexConfig.codexBinaryPath);
const codex = await this.createCodexClient(codexConfig.codexBinaryPath, requestOptions?.env);
const threadOptions = buildThreadOptions(cwd, requestOptions?.model, assistantConfig);

if (requestOptions?.abortSignal?.aborted) {
Expand Down
Loading
Loading