diff --git a/.env.example b/.env.example index e27d41336..39b18a5c8 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 058c1469e..78e747267 100644 --- a/docs/docs/operations/configuration-reference.md +++ b/docs/docs/operations/configuration-reference.md @@ -88,6 +88,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/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 d48850d79..5af2cb478 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 246354917..39841454b 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'], ['POST', '/api/agent-runtime/packages/verify'], @@ -127,4 +130,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/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/packages/core/src/db/migrationGate.ts b/packages/core/src/db/migrationGate.ts index db2830669..b90f1bd52 100644 --- a/packages/core/src/db/migrationGate.ts +++ b/packages/core/src/db/migrationGate.ts @@ -5,6 +5,43 @@ export interface MigrationDatabase { }; } +export interface MigrationGateOptions { + lockRetryAttempts?: number; + lockRetryDelayMs?: number; + wait?: (milliseconds: number) => Promise; +} + +const DEFAULT_MIGRATION_LOCK_RETRY_ATTEMPTS = 60; +const DEFAULT_MIGRATION_LOCK_RETRY_DELAY_MS = 1_000; + +function isMigrationLockError(error: unknown): error is Error { + return error instanceof Error + && (error.name === 'MigrationLocked' + || error.message === 'Migration table is already locked'); +} + +async function migrateWithLockRetry( + database: MigrationDatabase, + options: MigrationGateOptions, +): Promise { + const retryAttempts = options.lockRetryAttempts + ?? DEFAULT_MIGRATION_LOCK_RETRY_ATTEMPTS; + const retryDelayMs = options.lockRetryDelayMs + ?? DEFAULT_MIGRATION_LOCK_RETRY_DELAY_MS; + const wait = options.wait + ?? (milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))); + + for (let attempt = 0; ; attempt += 1) { + try { + await database.migrate.latest(); + return; + } catch (error) { + if (!isMigrationLockError(error) || attempt >= retryAttempts) throw error; + await wait(retryDelayMs); + } + } +} + /** * Apply every pending migration before a process is allowed to start. * @@ -13,13 +50,16 @@ export interface MigrationDatabase { * operation failing rejects startup instead of leaving a process on an unknown * schema or connection state. */ -export async function applyDatabaseMigrations(database: MigrationDatabase): Promise { +export async function applyDatabaseMigrations( + database: MigrationDatabase, + options: MigrationGateOptions = {}, +): Promise { await database.raw('PRAGMA foreign_keys = OFF'); let migrationFailed = false; let migrationFailure: unknown; try { - await database.migrate.latest(); + await migrateWithLockRetry(database, options); } catch (error) { migrationFailed = true; migrationFailure = error; diff --git a/packages/core/src/db/migrations/20260802010000_add_notification_preference_apis.js b/packages/core/src/db/migrations/20260802010000_add_notification_preference_apis.js index 26a4a7799..96e7c4209 100644 --- a/packages/core/src/db/migrations/20260802010000_add_notification_preference_apis.js +++ b/packages/core/src/db/migrations/20260802010000_add_notification_preference_apis.js @@ -1142,10 +1142,44 @@ async function hasLocalhostPushEndpoints(knex) { } } +function findIntroducedForeignKeyViolations(before, after) { + const remainingBaselineViolations = new Map(); + for (const violation of before) { + const identity = JSON.stringify([ + violation.table, + violation.rowid ?? null, + violation.parent, + violation.fkid, + ]); + remainingBaselineViolations.set( + identity, + (remainingBaselineViolations.get(identity) || 0) + 1 + ); + } + + return after.filter((violation) => { + const identity = JSON.stringify([ + violation.table, + violation.rowid ?? null, + violation.parent, + violation.fkid, + ]); + const baselineCount = remainingBaselineViolations.get(identity) || 0; + if (baselineCount === 0) return true; + if (baselineCount === 1) remainingBaselineViolations.delete(identity); + else remainingBaselineViolations.set(identity, baselineCount - 1); + return false; + }); +} + async function withForeignKeysDisabled(knex, operation) { const connection = await knex.client.acquireConnection(); const raw = (sql) => knex.raw(sql).connection(connection); try { + // A legacy database can contain unrelated violations from older schemas. + // Preserve that existing state without allowing this rebuild to add any new + // violations of its own. + const baselineViolations = await raw('PRAGMA foreign_key_check'); const rows = await raw('PRAGMA foreign_keys'); const foreignKeysEnabled = rows[0]?.foreign_keys === 1; if (foreignKeysEnabled) await raw('PRAGMA foreign_keys = OFF'); @@ -1157,7 +1191,11 @@ async function withForeignKeysDisabled(knex, operation) { async (transaction) => { await operation(transaction); const violations = await transaction.raw('PRAGMA foreign_key_check'); - if (violations.length > 0) { + const introducedViolations = findIntroducedForeignKeyViolations( + baselineViolations, + violations + ); + if (introducedViolations.length > 0) { throw new Error( 'Foreign-key violations detected after rebuilding push subscriptions' ); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9efd78e4b..3626b9a41 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -75,7 +75,7 @@ export { getEffectiveTokenLimit, getModelHardLimit, DEFAULT_CONTEXT_LEVEL, MIN_C export type { ContextLevel } from './config/modelLimits.js'; export { db, closeConnection, createKnexConfigForMigrations, runMigrations } from './db/connection.js'; -export { applyDatabaseMigrations, type MigrationDatabase } from './db/migrationGate.js'; +export { applyDatabaseMigrations, type MigrationDatabase, type MigrationGateOptions } from './db/migrationGate.js'; export { getRepoConfigKey, detectDefaultBranch, listRepositoryBranchConfigurations } from './git/branchConfig.js'; export type { BranchConfiguration } from './git/branchConfig.js'; 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 be7d98f19..66c5b7998 100644 --- a/propr-ui/src/components/Layout.tsx +++ b/propr-ui/src/components/Layout.tsx @@ -233,7 +233,9 @@ const Layout: React.FC = ({ children }) => { ))} - {userHasPermission(user, 'instance.manage_agents') && } + {(isDemoMode || userHasPermission(user, 'instance.manage_agents')) && ( + + )}