From 5ab316e414cdc759b751a805067efaae0fb9196f Mon Sep 17 00:00:00 2001 From: Rinalds Uzkalns Date: Wed, 26 Aug 2026 12:18:29 +0200 Subject: [PATCH 1/5] fix(codex): tolerate long-running response streams --- .env.example | 7 + docs/docs/architecture/agent-runtime.md | 5 +- .../operations/configuration-reference.md | 3 + .../impl/utils/codexDockerArgsBuilder.ts | 65 +++++++++ test/contextAnalysisRuntime.test.ts | 123 +++++++++++++++--- 5 files changed, 181 insertions(+), 22 deletions(-) diff --git a/.env.example b/.env.example index 4ef4d1c4e..3879835cb 100644 --- a/.env.example +++ b/.env.example @@ -255,6 +255,13 @@ CLAUDE_CONFIG_PATH= CLAUDE_MAX_TURNS=10 CLAUDE_TIMEOUT_MS=86400000 CODEX_TIMEOUT_MS=86400000 +# Codex response-stream policy. SSE is more tolerant of long, quiet responses +# than the Responses WebSocket transport. Use "websocket" to retain WebSockets +# with the longer idle timeout, or "inherit" to use the mounted Codex provider +# configuration unchanged. +CODEX_STREAM_TRANSPORT=sse +CODEX_STREAM_IDLE_TIMEOUT_MS=1800000 +CODEX_STREAM_MAX_RETRIES=5 CONTEXT_ANALYSIS_TIMEOUT_MS=1800000 # Antigravity Configuration diff --git a/docs/docs/architecture/agent-runtime.md b/docs/docs/architecture/agent-runtime.md index 1512c1949..a1d9dc1b7 100644 --- a/docs/docs/architecture/agent-runtime.md +++ b/docs/docs/architecture/agent-runtime.md @@ -146,6 +146,9 @@ Common settings: HOST_CODEX_DIR=/home/your-user/.codex CODEX_TIMEOUT_MS=86400000 CODEX_MAX_TURNS=1000 +CODEX_STREAM_TRANSPORT=sse +CODEX_STREAM_IDLE_TIMEOUT_MS=1800000 +CODEX_STREAM_MAX_RETRIES=5 ``` The entrypoint checks for `/home/node/.codex/config.toml`, prepares `sessions` and `rules`, and avoids recursively changing bind-mounted workspace ownership. Codex runs as: @@ -154,7 +157,7 @@ The entrypoint checks for `/home/node/.codex/config.toml`, prepares `sessions` a codex exec --json --dangerously-bypass-approvals-and-sandbox --config features.multi_agent=false --skip-git-repo-check --cd /home/node/workspace - ``` -When a model is selected, ProPR adds `--model `. Codex emits NDJSON events that ProPR parses into logs, result text, session metadata, and token usage. +When a model is selected, ProPR adds `--model `. By default, ProPR also selects an OpenAI-compatible SSE provider with a 30-minute stream idle timeout, avoiding the Codex WebSocket transport's shorter quiet-period disconnects during long responses. Set `CODEX_STREAM_TRANSPORT=websocket` to retain WebSockets or `CODEX_STREAM_TRANSPORT=inherit` to preserve a custom provider from the mounted Codex configuration. Codex emits NDJSON events that ProPR parses into logs, result text, session metadata, and token usage. ### Antigravity diff --git a/docs/docs/operations/configuration-reference.md b/docs/docs/operations/configuration-reference.md index cd7908f25..5639eac44 100644 --- a/docs/docs/operations/configuration-reference.md +++ b/docs/docs/operations/configuration-reference.md @@ -78,6 +78,9 @@ Unified image selection, per-agent credential paths, and execution limits. Codin | `CLAUDE_MAX_TURNS` | Shipped `10` / code falls back to `1000` if unset | Maximum agent turns per Claude run. | Optional. | | `CLAUDE_TIMEOUT_MS` | `86400000` (24 hours) | Claude task run timeout. | Optional. | | `CODEX_TIMEOUT_MS` | `86400000` (24 hours) | Codex task run timeout. | Optional. | +| `CODEX_STREAM_TRANSPORT` | `sse` | Codex response transport. `sse` avoids WebSocket idle disconnects, `websocket` retains WebSockets with ProPR's stream timeout, and `inherit` leaves the mounted Codex provider configuration unchanged. | Optional; use `inherit` with a custom provider. | +| `CODEX_STREAM_IDLE_TIMEOUT_MS` | `1800000` (30 minutes) | Maximum quiet period on a Codex response stream before reconnecting. This is separate from the whole-task `CODEX_TIMEOUT_MS`. | Optional tuning. | +| `CODEX_STREAM_MAX_RETRIES` | `5` | Number of Codex response-stream reconnect attempts. Zero disables retries. | Optional tuning. | | `CONTEXT_ANALYSIS_TIMEOUT_MS` | `1800000` (30 minutes) | Timeout for planner keyword extraction and semantic relevance scoring calls. | Optional. | | `ANTIGRAVITY_TIMEOUT_MS` | `86400000` (24 hours) | Antigravity task run timeout. | Optional. | | `OPENCODE_TIMEOUT_MS` | `86400000` (24 hours) | OpenCode task run timeout. | Optional. | diff --git a/packages/core/src/agents/impl/utils/codexDockerArgsBuilder.ts b/packages/core/src/agents/impl/utils/codexDockerArgsBuilder.ts index cb3c24c6a..cd0e38a9a 100644 --- a/packages/core/src/agents/impl/utils/codexDockerArgsBuilder.ts +++ b/packages/core/src/agents/impl/utils/codexDockerArgsBuilder.ts @@ -11,6 +11,65 @@ import { const CONTAINER_CONFIG_PATH = '/home/node/.codex'; const GITHUB_CREDENTIAL_ENV_NAMES = new Set(['GH_TOKEN', 'GITHUB_TOKEN', 'GITHUB_ACCESS_TOKEN']); const GITHUB_CREDENTIAL_ENV_PATTERN = /^(?:GH|GITHUB)_.*(?:TOKEN|KEY|SECRET|PASSWORD|PAT|PRIVATE_KEY)$/; +const PROPR_OPENAI_PROVIDER_ID = 'propr_openai'; + +export const DEFAULT_CODEX_STREAM_TRANSPORT = 'sse' as const; +export const DEFAULT_CODEX_STREAM_IDLE_TIMEOUT_MS = 30 * 60 * 1000; +export const DEFAULT_CODEX_STREAM_MAX_RETRIES = 5; + +export type CodexStreamTransport = 'sse' | 'websocket' | 'inherit'; + +export interface CodexStreamConfig { + transport: CodexStreamTransport; + idleTimeoutMs: number; + maxRetries: number; +} + +function parseIntegerSetting(value: string | undefined, fallback: number, allowZero: boolean): number { + if (!value?.trim()) return fallback; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && (allowZero ? parsed >= 0 : parsed > 0) + ? parsed + : fallback; +} + +export function resolveCodexStreamConfig( + environment: Record = process.env +): CodexStreamConfig { + const configuredTransport = environment.CODEX_STREAM_TRANSPORT?.trim().toLowerCase(); + const transport: CodexStreamTransport = configuredTransport === 'websocket' || configuredTransport === 'inherit' + ? configuredTransport + : DEFAULT_CODEX_STREAM_TRANSPORT; + + return { + transport, + idleTimeoutMs: parseIntegerSetting( + environment.CODEX_STREAM_IDLE_TIMEOUT_MS, + DEFAULT_CODEX_STREAM_IDLE_TIMEOUT_MS, + false + ), + maxRetries: parseIntegerSetting( + environment.CODEX_STREAM_MAX_RETRIES, + DEFAULT_CODEX_STREAM_MAX_RETRIES, + true + ), + }; +} + +function buildCodexStreamConfigArgs(config: CodexStreamConfig): string[] { + if (config.transport === 'inherit') return []; + + return [ + '--config', `model_provider="${PROPR_OPENAI_PROVIDER_ID}"`, + '--config', `model_providers.${PROPR_OPENAI_PROVIDER_ID}.name="OpenAI"`, + '--config', `model_providers.${PROPR_OPENAI_PROVIDER_ID}.wire_api="responses"`, + '--config', `model_providers.${PROPR_OPENAI_PROVIDER_ID}.requires_openai_auth=true`, + '--config', `model_providers.${PROPR_OPENAI_PROVIDER_ID}.supports_websockets=${config.transport === 'websocket'}`, + '--config', `model_providers.${PROPR_OPENAI_PROVIDER_ID}.supports_standalone_web_search=true`, + '--config', `model_providers.${PROPR_OPENAI_PROVIDER_ID}.stream_idle_timeout_ms=${config.idleTimeoutMs}`, + '--config', `model_providers.${PROPR_OPENAI_PROVIDER_ID}.stream_max_retries=${config.maxRetries}`, + ]; +} function isGitHubCredentialEnvironmentVariable(name: string): boolean { const normalizedName = name.toUpperCase(); @@ -59,6 +118,11 @@ export function buildCodexDockerArgs(config: AgentConfig, params: CodexDockerArg const dockerImage = config.dockerImage; const configPath = resolveConfigPath(config.configPath); const envVars = buildEnvironmentVariableArgs([config.envVars, environment], repositoryInspection); + const streamConfig = resolveCodexStreamConfig({ + ...process.env, + ...config.envVars, + ...environment, + }); const shortTaskId = createContainerExecutionId(taskId); const taskType = executionType || (issueNumber === 0 ? 'analysis' : `issue-${issueNumber}`); const containerName = `${config.alias || 'codex'}-${taskType}-${shortTaskId}`; @@ -85,6 +149,7 @@ export function buildCodexDockerArgs(config: AgentConfig, params: CodexDockerArg ...(repositoryInspection ? buildCodexRepositoryScoutArgs() : ['--dangerously-bypass-approvals-and-sandbox', '--config', 'features.multi_agent=false']), + ...buildCodexStreamConfigArgs(streamConfig), ...(reasoningLevel ? ['--config', `model_reasoning_effort="${reasoningLevel}"`] : []), '--skip-git-repo-check', '--cd', '/home/node/workspace', diff --git a/test/contextAnalysisRuntime.test.ts b/test/contextAnalysisRuntime.test.ts index 3072ccf84..48cf93d8c 100644 --- a/test/contextAnalysisRuntime.test.ts +++ b/test/contextAnalysisRuntime.test.ts @@ -1,7 +1,13 @@ import { after, describe, test } from 'node:test'; import assert from 'node:assert/strict'; import { createContainerExecutionId } from '../packages/core/src/agents/impl/utils/containerExecutionId.js'; -import { buildCodexDockerArgs } from '../packages/core/src/agents/impl/utils/codexDockerArgsBuilder.js'; +import { + buildCodexDockerArgs, + DEFAULT_CODEX_STREAM_IDLE_TIMEOUT_MS, + DEFAULT_CODEX_STREAM_MAX_RETRIES, + DEFAULT_CODEX_STREAM_TRANSPORT, + resolveCodexStreamConfig, +} from '../packages/core/src/agents/impl/utils/codexDockerArgsBuilder.js'; import { closeConnection } from '../packages/core/src/db/connection.js'; import { DEFAULT_CONTEXT_ANALYSIS_TIMEOUT_MS, @@ -13,6 +19,29 @@ after(async () => { }); describe('context analysis runtime safeguards', () => { + const codexConfig = { + id: 'codex-test', + type: 'codex' as const, + alias: 'codex', + enabled: true, + dockerImage: 'propr/agent:test', + configPath: '/tmp/codex-config', + supportedModels: ['gpt-5.6-sol'], + }; + + const codexParams = { + worktreePath: '/tmp/review-worktree', + githubToken: '', + issueNumber: 0, + taskId: 'pr-comments-batch-integry-mcptest-268-006379edfa5d', + executionType: 'pr-review', + readOnlyWorkspace: true, + }; + + function codexConfigOverrides(args: string[]): string[] { + return args.flatMap((arg, index) => arg === '--config' ? [args[index + 1]] : []); + } + test('creates distinct fallback container IDs for parallel calls in the same millisecond', (t) => { t.mock.method(Date, 'now', () => 1_785_825_895_919); @@ -34,26 +63,8 @@ describe('context analysis runtime safeguards', () => { }); test('gives repeated Codex review attempts distinct Docker names', () => { - const config = { - id: 'codex-test', - type: 'codex' as const, - alias: 'codex', - enabled: true, - dockerImage: 'propr/agent:test', - configPath: '/tmp/codex-config', - supportedModels: ['gpt-5.6-sol'], - }; - const params = { - worktreePath: '/tmp/review-worktree', - githubToken: '', - issueNumber: 0, - taskId: 'pr-comments-batch-integry-mcptest-268-006379edfa5d', - executionType: 'pr-review', - readOnlyWorkspace: true, - }; - - const firstArgs = buildCodexDockerArgs(config, params); - const secondArgs = buildCodexDockerArgs(config, params); + const firstArgs = buildCodexDockerArgs(codexConfig, codexParams); + const secondArgs = buildCodexDockerArgs(codexConfig, codexParams); const firstName = firstArgs[firstArgs.indexOf('--name') + 1]; const secondName = secondArgs[secondArgs.indexOf('--name') + 1]; @@ -62,6 +73,76 @@ describe('context analysis runtime safeguards', () => { assert.notStrictEqual(firstName, secondName); }); + test('uses an SSE provider with a thirty-minute idle timeout by default', () => { + assert.deepEqual(resolveCodexStreamConfig({}), { + transport: DEFAULT_CODEX_STREAM_TRANSPORT, + idleTimeoutMs: DEFAULT_CODEX_STREAM_IDLE_TIMEOUT_MS, + maxRetries: DEFAULT_CODEX_STREAM_MAX_RETRIES, + }); + + const args = buildCodexDockerArgs({ + ...codexConfig, + envVars: { + CODEX_STREAM_TRANSPORT: 'sse', + CODEX_STREAM_IDLE_TIMEOUT_MS: String(DEFAULT_CODEX_STREAM_IDLE_TIMEOUT_MS), + CODEX_STREAM_MAX_RETRIES: String(DEFAULT_CODEX_STREAM_MAX_RETRIES), + }, + }, codexParams); + const overrides = codexConfigOverrides(args); + + assert.ok(overrides.includes('model_provider="propr_openai"')); + assert.ok(overrides.includes('model_providers.propr_openai.requires_openai_auth=true')); + assert.ok(overrides.includes('model_providers.propr_openai.supports_websockets=false')); + assert.ok(overrides.includes('model_providers.propr_openai.stream_idle_timeout_ms=1800000')); + assert.ok(overrides.includes('model_providers.propr_openai.stream_max_retries=5')); + }); + + test('allows WebSocket tuning and per-execution overrides', () => { + const args = buildCodexDockerArgs({ + ...codexConfig, + envVars: { + CODEX_STREAM_TRANSPORT: 'sse', + CODEX_STREAM_IDLE_TIMEOUT_MS: 'invalid', + CODEX_STREAM_MAX_RETRIES: '-1', + }, + }, { + ...codexParams, + environment: { + CODEX_STREAM_TRANSPORT: 'websocket', + CODEX_STREAM_IDLE_TIMEOUT_MS: '7200000', + CODEX_STREAM_MAX_RETRIES: '9', + }, + }); + const overrides = codexConfigOverrides(args); + + assert.ok(overrides.includes('model_providers.propr_openai.supports_websockets=true')); + assert.ok(overrides.includes('model_providers.propr_openai.stream_idle_timeout_ms=7200000')); + assert.ok(overrides.includes('model_providers.propr_openai.stream_max_retries=9')); + }); + + test('can inherit a user-managed Codex provider without injecting ProPR overrides', () => { + const args = buildCodexDockerArgs({ + ...codexConfig, + envVars: { CODEX_STREAM_TRANSPORT: 'inherit' }, + }, codexParams); + const overrides = codexConfigOverrides(args); + + assert.ok(!overrides.some(value => value.startsWith('model_provider='))); + assert.ok(!overrides.some(value => value.startsWith('model_providers.propr_openai.'))); + }); + + test('rejects invalid stream timeout and retry values', () => { + assert.deepEqual(resolveCodexStreamConfig({ + CODEX_STREAM_TRANSPORT: 'invalid', + CODEX_STREAM_IDLE_TIMEOUT_MS: '0', + CODEX_STREAM_MAX_RETRIES: '-1', + }), { + transport: DEFAULT_CODEX_STREAM_TRANSPORT, + idleTimeoutMs: DEFAULT_CODEX_STREAM_IDLE_TIMEOUT_MS, + maxRetries: DEFAULT_CODEX_STREAM_MAX_RETRIES, + }); + }); + test('defaults context analysis to thirty minutes', () => { assert.strictEqual(DEFAULT_CONTEXT_ANALYSIS_TIMEOUT_MS, 1_800_000); assert.strictEqual(resolveContextAnalysisTimeoutMs(undefined), 1_800_000); From 07e3f661519ca552aa391e3cb82cdb293ba32745 Mon Sep 17 00:00:00 2001 From: Rinalds Uzkalns Date: Sat, 29 Aug 2026 15:16:26 +0200 Subject: [PATCH 2/5] fix(demo): show Agent Tank usage sidebar --- packages/api/permissionGuards.ts | 13 +++++++++++ packages/api/routeRegistry.ts | 3 ++- packages/api/test/routeAuthorization.test.ts | 20 ++++++++++++++-- propr-ui/src/components/AgentTankSidebar.tsx | 24 ++++++++++++-------- propr-ui/src/components/Layout.tsx | 4 +++- 5 files changed, 51 insertions(+), 13 deletions(-) diff --git a/packages/api/permissionGuards.ts b/packages/api/permissionGuards.ts index d5bcac6af..cf426383f 100644 --- a/packages/api/permissionGuards.ts +++ b/packages/api/permissionGuards.ts @@ -1,6 +1,19 @@ +import type { RequestHandler } from 'express'; import { requirePermission } from './authorization.js'; export const requireManageSettings = requirePermission('instance.manage_settings'); export const requireManageAgents = requirePermission('instance.manage_agents'); export const requireManageMembers = requirePermission('instance.manage_members'); export const requireManageRuntime = requirePermission('instance.manage_runtime'); + +/** + * Agent Tank's demo feed contains synthetic data and is safe for the read-only + * demo user. Real installations still require the agent-management permission. + */ +export const requireAgentTankUsageAccess: RequestHandler = (req, res, next) => { + if (req.authorization?.source === 'demo') { + next(); + return; + } + requireManageAgents(req, res, next); +}; diff --git a/packages/api/routeRegistry.ts b/packages/api/routeRegistry.ts index eb2887578..b8787a4a4 100644 --- a/packages/api/routeRegistry.ts +++ b/packages/api/routeRegistry.ts @@ -8,6 +8,7 @@ import type { createInstanceCatalogRoutes, } from './routes/index.js'; import { + requireAgentTankUsageAccess, requireManageAgents, requireManageMembers, requireManageRuntime, @@ -64,7 +65,7 @@ export function createManagementRouteEntries({ ['get', '/api/config/agent-tank', requireManageAgents, configRoutes.getAgentTankSettings], ['post', '/api/config/agent-tank', requireManageAgents, configRoutes.postAgentTankSettings], ['get', '/api/config/agent-tank/status', requireManageAgents, configRoutes.getAgentTankStatus], - ['get', '/api/config/agent-tank/usage', requireManageAgents, configRoutes.getAgentTankUsage], + ['get', '/api/config/agent-tank/usage', requireAgentTankUsageAccess, configRoutes.getAgentTankUsage], ['post', '/api/config/agent-tank/refresh', requireManageAgents, configRoutes.postAgentTankRefresh], ['get', '/api/config/agent-tank/detect', requireManageAgents, configRoutes.getAgentTankDetect], diff --git a/packages/api/test/routeAuthorization.test.ts b/packages/api/test/routeAuthorization.test.ts index 2c793a05b..fa148ddda 100644 --- a/packages/api/test/routeAuthorization.test.ts +++ b/packages/api/test/routeAuthorization.test.ts @@ -26,7 +26,9 @@ function handlerCollection(): never { function createAuthorizationTestApp() { const app = express(); app.use((req, _res, next) => { - const admin = req.header('x-test-role') === 'admin'; + const role = req.header('x-test-role'); + const admin = role === 'admin'; + const demo = role === 'demo'; req.authorization = { role: admin ? 'admin' : 'member', permissions: admin @@ -37,7 +39,7 @@ function createAuthorizationTestApp() { 'instance.manage_settings', ] : [], - source: admin ? 'local' : 'implicit', + source: admin ? 'local' : demo ? 'demo' : 'implicit', }; next(); }); @@ -76,6 +78,7 @@ async function withServer( const managementRequests = [ ['GET', '/api/config/settings'], ['GET', '/api/config/agents'], + ['GET', '/api/config/agent-tank/usage'], ['GET', '/api/admin/members'], ['GET', '/api/agent-runtime/packages'], ['GET', '/api/agents/codex/images'], @@ -126,4 +129,17 @@ describe('assembled instance permission routes', () => { } }); }); + + test('demo users can read only the synthetic Agent Tank usage feed', async () => { + await withServer(async origin => { + const headers = { 'x-test-role': 'demo' }; + const usageResponse = await fetch(`${origin}/api/config/agent-tank/usage`, { headers }); + assert.equal(usageResponse.status, 200); + + for (const path of ['/api/config/agent-tank', '/api/config/agent-tank/status']) { + const response = await fetch(`${origin}${path}`, { headers }); + assert.equal(response.status, 403, path); + } + }); + }); }); diff --git a/propr-ui/src/components/AgentTankSidebar.tsx b/propr-ui/src/components/AgentTankSidebar.tsx index f52d963a1..12710ed7e 100644 --- a/propr-ui/src/components/AgentTankSidebar.tsx +++ b/propr-ui/src/components/AgentTankSidebar.tsx @@ -239,7 +239,11 @@ const AgentRow: React.FC = ({ agent, expanded, onToggle }) => { ); }; -const AgentTankSidebar: React.FC = () => { +interface AgentTankSidebarProps { + allowManualRefresh?: boolean; +} + +const AgentTankSidebar: React.FC = ({ allowManualRefresh = true }) => { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); @@ -297,14 +301,16 @@ const AgentTankSidebar: React.FC = () => { Usage - + {allowManualRefresh && ( + + )}
{agents.map(agent => ( diff --git a/propr-ui/src/components/Layout.tsx b/propr-ui/src/components/Layout.tsx index 3f0bd6eaa..218d7e050 100644 --- a/propr-ui/src/components/Layout.tsx +++ b/propr-ui/src/components/Layout.tsx @@ -227,7 +227,9 @@ const Layout: React.FC = ({ children }) => { ))} - {userHasPermission(user, 'instance.manage_agents') && } + {(isDemoMode || userHasPermission(user, 'instance.manage_agents')) && ( + + )}