Skip to content
Open
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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion docs/docs/architecture/agent-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 <id>`. Codex emits NDJSON events that ProPR parses into logs, result text, session metadata, and token usage.
When a model is selected, ProPR adds `--model <id>`. 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

Expand Down
3 changes: 3 additions & 0 deletions docs/docs/operations/configuration-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
13 changes: 13 additions & 0 deletions packages/api/permissionGuards.ts
Original file line number Diff line number Diff line change
@@ -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);
};
3 changes: 2 additions & 1 deletion packages/api/routeRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
createInstanceCatalogRoutes,
} from './routes/index.js';
import {
requireAgentTankUsageAccess,
requireManageAgents,
requireManageMembers,
requireManageRuntime,
Expand Down Expand Up @@ -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],

Expand Down
20 changes: 18 additions & 2 deletions packages/api/test/routeAuthorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -37,7 +39,7 @@ function createAuthorizationTestApp() {
'instance.manage_settings',
]
: [],
source: admin ? 'local' : 'implicit',
source: admin ? 'local' : demo ? 'demo' : 'implicit',
};
next();
});
Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -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);
}
});
});
});
65 changes: 65 additions & 0 deletions packages/core/src/agents/impl/utils/codexDockerArgsBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | undefined> = 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();
Expand Down Expand Up @@ -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}`;
Expand All @@ -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',
Expand Down
44 changes: 42 additions & 2 deletions packages/core/src/db/migrationGate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,43 @@ export interface MigrationDatabase {
};
}

export interface MigrationGateOptions {
lockRetryAttempts?: number;
lockRetryDelayMs?: number;
wait?: (milliseconds: number) => Promise<void>;
}

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<void> {
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.
*
Expand All @@ -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<void> {
export async function applyDatabaseMigrations(
database: MigrationDatabase,
options: MigrationGateOptions = {},
): Promise<void> {
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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'
);
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
24 changes: 15 additions & 9 deletions propr-ui/src/components/AgentTankSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,11 @@ const AgentRow: React.FC<AgentRowProps> = ({ agent, expanded, onToggle }) => {
);
};

const AgentTankSidebar: React.FC = () => {
interface AgentTankSidebarProps {
allowManualRefresh?: boolean;
}

const AgentTankSidebar: React.FC<AgentTankSidebarProps> = ({ allowManualRefresh = true }) => {
const [data, setData] = useState<AgentTankUsageResponse | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
Expand Down Expand Up @@ -297,14 +301,16 @@ const AgentTankSidebar: React.FC = () => {
<span className="text-[10px] font-semibold uppercase tracking-wider text-gray-400">
Usage
</span>
<button
onClick={() => fetchUsage(true)}
disabled={refreshing}
className="text-gray-400 hover:text-primary-600 disabled:opacity-50"
title="Refresh usage"
>
<RefreshCw className={`w-3 h-3 ${refreshing ? 'animate-spin' : ''}`} />
</button>
{allowManualRefresh && (
<button
onClick={() => fetchUsage(true)}
disabled={refreshing}
className="text-gray-400 hover:text-primary-600 disabled:opacity-50"
title="Refresh usage"
>
<RefreshCw className={`w-3 h-3 ${refreshing ? 'animate-spin' : ''}`} />
</button>
)}
</div>
<div className="space-y-0">
{agents.map(agent => (
Expand Down
4 changes: 3 additions & 1 deletion propr-ui/src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,9 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
</Link>
))}
</nav>
{userHasPermission(user, 'instance.manage_agents') && <AgentTankSidebar />}
{(isDemoMode || userHasPermission(user, 'instance.manage_agents')) && (
<AgentTankSidebar allowManualRefresh={!isDemoMode} />
)}
<footer className="px-4 py-3 border-t border-gray-100 text-[11px] leading-tight text-gray-400 space-y-1">
<div>
<a
Expand Down
Loading
Loading