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
9 changes: 6 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,14 @@ OpenAI Chat Completions–compatible HTTP proxy that routes inference through th
8. `thread/start` failure routes through cleanup function (not inline release)
9. Use `Model.model` (NOT `Model.id`) for `/v1/models` id field
10. `sourceKinds: ["custom"]` on `thread/list` for orphan reconciliation
11. The proxy evaluates CLI-native tool execution requests against a policy config. Commands and file writes are approved/denied per policy rules. MCP/dynamic tools remain denied.

## Forbidden Patterns
- No reverse-engineering private APIs (no direct chatgpt.com calls)
- No credential extraction or impersonation
- No tool execution passthrough
- No thread reuse across requests
- No OpenClaw tool mapping injection
- No MCP/dynamic tool execution. CLI-native tools are policy-gated — see `tool-policy.json`. Override: `CODEX_TOOL_APPROVAL=deny` disables all approvals.

## Environment Variables
- `CODEX_PROXY_PORT` (default 3460)
Expand All @@ -40,12 +41,14 @@ OpenAI Chat Completions–compatible HTTP proxy that routes inference through th
- `CODEX_ORPHAN_SWEEP_INTERVAL_MS` (default 900000)
- `CODEX_DEGRADATION_THRESHOLD` (default 5)
- `CODEX_MAX_RESPONSE_SIZE` (default 5242880)
- `CODEX_TOOL_APPROVAL` — set to `deny` to revert all tool approvals to blanket denial (v1.0 behavior)
- `CODEX_TOOL_POLICY_PATH` — override path to `tool-policy.json` (default `~/codex-proxy/tool-policy.json`)

## PM2 Management
```
pm2 startOrRestart ecosystem.config.js
pm2 save
cd ~/codex-proxy && pm2 start ecosystem.config.cjs && pm2 save
```
Note: ecosystem config uses `.cjs` extension because `package.json` has `"type": "module"`.

## Manual Re-auth Procedure
1. `codex login --device-auth` on gpu1
Expand Down
File renamed without changes.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/server/standalone.js"
"start": "node dist/server/standalone.js",
"test": "npm run build && node tests/tool-policy.test.mjs"
},
"dependencies": {
"express": "^4.21.2",
Expand Down
18 changes: 9 additions & 9 deletions scripts/codex-auth-check.sh
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
#!/usr/bin/env bash
# Codex auth health check — reads ~/.codex/auth.json, decodes JWT exp claim,
# alerts via Discord #infra-agent-swarm (1475832162648461316) if expiry within 48 hours.
# alerts via Discord #status-updates (1485787606561062942) if expiry within 48 hours.
# Schedule: every 1 hour via OpenClaw cron.

set -euo pipefail

AUTH_FILE="${HOME}/.codex/auth.json"
ALERT_CHANNEL="1475832162648461316"
ALERT_CHANNEL="1485787606561062942"
WARN_SECONDS=$((48 * 3600)) # 48 hours

# ─── Read auth.json ───────────────────────────────────────────────────────────
Expand All @@ -20,11 +20,11 @@ fi

# ─── Extract access token ─────────────────────────────────────────────────────

ACCESS_TOKEN=$(python3 -c "
import json, sys
ACCESS_TOKEN=$(AUTH_FILE="${AUTH_FILE}" python3 -c "
import json, sys, os
try:
data = json.load(open('${AUTH_FILE}'))
token = data.get('accessToken') or data.get('access_token') or data.get('token')
data = json.load(open(os.environ['AUTH_FILE']))
token = data.get('accessToken') or data.get('access_token') or data.get('token') or (data.get('tokens') or {}).get('access_token')
if not token:
print('', end='')
else:
Expand All @@ -43,10 +43,10 @@ fi

# ─── Decode JWT exp claim ─────────────────────────────────────────────────────

EXP=$(python3 -c "
import base64, json, sys
EXP=$(ACCESS_TOKEN="${ACCESS_TOKEN}" python3 -c "
import base64, json, sys, os

token = '${ACCESS_TOKEN}'
token = os.environ['ACCESS_TOKEN']
parts = token.split('.')
if len(parts) != 3:
print(-1)
Expand Down
88 changes: 75 additions & 13 deletions src/client/app-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,23 @@ import type {
ErrorNotificationParams,
AnyServerRequest,
CommandExecutionDenial,
CommandExecutionApproval,
CommandExecutionParams,
FileChangeDenial,
FileChangeApproval,
FileChangeParams,
PermissionsRequestApprovalResponse,
ToolRequestUserInputResponse,
ToolCallDenial,
McpElicitationDenial,
ApplyPatchDenial,
ApplyPatchApprovalResult,
ExecCommandDenial,
ExecCommandApprovalResult,
TurnInterruptParams,
UserInput,
} from '../types/codex.js';
import { evaluateCommandExecution, evaluateFileChange, getConfig } from '../policy/tool-policy.js';

// ─── Module-level ID counter (NEVER resets) ──────────────────────────────────
let nextId = 1;
Expand Down Expand Up @@ -405,16 +412,33 @@ export class AppServerClient {

switch (method) {
case 'item/commandExecution/requestApproval': {
const denial: CommandExecutionDenial = { decision: 'decline' };
result = denial;
const params = req.params as CommandExecutionParams | undefined;
const decision = evaluateCommandExecution(params, getConfig());
log.info('Tool policy', { method, decision: decision.approved ? 'APPROVED' : 'DENIED', reason: decision.reason, command: params?.command });
if (decision.approved) {
const approval: CommandExecutionApproval = { decision: 'accept' };
result = approval;
} else {
const denial: CommandExecutionDenial = { decision: 'decline' };
result = denial;
}
break;
}
case 'item/fileChange/requestApproval': {
const denial: FileChangeDenial = { decision: 'decline' };
result = denial;
const params = req.params as FileChangeParams | undefined;
const decision = evaluateFileChange(params, getConfig());
log.info('Tool policy', { method, decision: decision.approved ? 'APPROVED' : 'DENIED', reason: decision.reason, grantRoot: params?.grantRoot });
if (decision.approved) {
const approval: FileChangeApproval = { decision: 'accept' };
result = approval;
} else {
const denial: FileChangeDenial = { decision: 'decline' };
result = denial;
}
break;
}
case 'item/permissions/requestApproval': {
log.info('Tool policy', { method, decision: 'DENIED', reason: 'permissions always denied' });
const denial: PermissionsRequestApprovalResponse = { permissions: {}, scope: 'turn' };
result = denial;
break;
Expand All @@ -435,13 +459,39 @@ export class AppServerClient {
break;
}
case 'applyPatchApproval': {
const denial: ApplyPatchDenial = { decision: 'denied' };
result = denial;
// Legacy: treat as file change — extract path from params if available
const rawParams = req.params as Record<string, unknown> | undefined;
const grantRoot = typeof rawParams?.['path'] === 'string' ? rawParams['path'] : null;
const decision = evaluateFileChange(
grantRoot ? { itemId: '', threadId: '', turnId: '', grantRoot } : null,
getConfig(),
);
log.info('Tool policy', { method, decision: decision.approved ? 'APPROVED' : 'DENIED', reason: decision.reason, grantRoot });
if (decision.approved) {
const approval: ApplyPatchApprovalResult = { decision: 'approved' };
result = approval;
} else {
const denial: ApplyPatchDenial = { decision: 'denied' };
result = denial;
}
break;
}
case 'execCommandApproval': {
const denial: ExecCommandDenial = { decision: 'denied' };
result = denial;
// Legacy: treat as command execution
const rawParams = req.params as Record<string, unknown> | undefined;
const command = typeof rawParams?.['command'] === 'string' ? rawParams['command'] : null;
const decision = evaluateCommandExecution(
command ? { itemId: '', threadId: '', turnId: '', command } : null,
getConfig(),
);
log.info('Tool policy', { method, decision: decision.approved ? 'APPROVED' : 'DENIED', reason: decision.reason, command });
if (decision.approved) {
const approval: ExecCommandApprovalResult = { decision: 'approved' };
result = approval;
} else {
const denial: ExecCommandDenial = { decision: 'denied' };
result = denial;
}
break;
}
default:
Expand Down Expand Up @@ -493,7 +543,17 @@ export class AppServerClient {
}
if (inflight.cleanupDone) return;

// If turnId not yet set, buffer the delta
// If turnId not yet set, try to resolve from delta params before buffering
if (!inflight.turnId) {
if (turnId) {
// Delta carries turnId — resolve it now and flush buffer
inflight.turnId = turnId;
this.flushDeltaBuffer(inflight);
// Fall through to emit current delta normally (turnId is now set)
}
}

// If turnId still not set after attempting resolution, buffer the delta
if (!inflight.turnId) {
// Check buffer limits
inflight.deltaBufferSize += delta.length;
Expand Down Expand Up @@ -608,6 +668,7 @@ export class AppServerClient {
message: 'Response too large',
errorType: 'server_error',
});
return;
}
}
}
Expand Down Expand Up @@ -661,8 +722,9 @@ export class AppServerClient {
if (!inflight.stream && inflight.gracePeriodTimer) {
clearTimeout(inflight.gracePeriodTimer);
inflight.gracePeriodTimer = null;
// Send the non-streaming response now
// Send the non-streaming response now, then release the slot and archive
this.sendNonStreamingResponse(inflight);
this.triggerCleanup(inflight, { type: 'success' });
}
}

Expand Down Expand Up @@ -726,7 +788,7 @@ export class AppServerClient {
}

private onTurnCompleted(params: TurnCompletedParams): void {
const threadId = params.thread.id;
const threadId = params.thread?.id ?? (params as unknown as { threadId: string }).threadId;
const turn = params.turn;
const inflight = this.inFlightRequests.get(threadId);

Expand Down Expand Up @@ -1045,7 +1107,7 @@ export class AppServerClient {
if (cursor) params.cursor = cursor;

const result = await this.sendRequest<ModelListResult>('model/list', params);
models.push(...result.models);
models.push(...result.data);
cursor = result.nextCursor ?? undefined;
} while (cursor);

Expand Down Expand Up @@ -1088,7 +1150,7 @@ export class AppServerClient {
const result = await this.sendRequest<ThreadListResult>('thread/list', params);
cursor = result.nextCursor ?? undefined;

for (const thread of result.threads) {
for (const thread of result.data) {
// Check in-flight map immediately before each archive
if (this.inFlightRequests.has(thread.id)) {
log.debug('Skipping active thread in orphan sweep', { threadId: thread.id });
Expand Down
Loading