Merge pull request #1462 from nearai/staging-promote/cba1bc37-23334371795

chore: promote staging to staging-promote/c1762616-23332963145 (2026-03-20 08:09 UTC)
This commit is contained in:
Henry Park
2026-03-23 12:00:38 -07:00
committed by GitHub
29 changed files with 2895 additions and 132 deletions
+8 -1
View File
@@ -4,7 +4,7 @@ DATABASE_POOL_SIZE=10
# LLM Provider
# LLM_BACKEND=nearai # default
# Possible values: nearai, ollama, openai_compatible, openai, anthropic, tinfoil
# Possible values: nearai, ollama, openai_compatible, openai, anthropic, tinfoil, openai_codex
# LLM_REQUEST_TIMEOUT_SECS=120 # Increase for local LLMs (Ollama, vLLM, LM Studio)
# === Anthropic Direct ===
@@ -92,6 +92,13 @@ NEARAI_AUTH_URL=https://private.near.ai
# long = 1-hour TTL, 2.0× (200%) write surcharge
# ANTHROPIC_CACHE_RETENTION=short
# === OpenAI Codex (ChatGPT subscription, OAuth) ===
# LLM_BACKEND=openai_codex
# OPENAI_CODEX_MODEL=gpt-5.3-codex # default
# OPENAI_CODEX_CLIENT_ID=app_EMoamEEZ73f0CkXaXp7hrann # override (rare)
# OPENAI_CODEX_AUTH_URL=https://auth.openai.com # override (rare)
# OPENAI_CODEX_API_URL=https://chatgpt.com/backend-api/codex # override (rare)
# For full provider setup guide see docs/LLM_PROVIDERS.md
# Channel Configuration
+3
View File
@@ -421,6 +421,7 @@ mod tests {
job_id: job_id.to_string(),
status: "completed".to_string(),
session_id: None,
fallback_deliverable: None,
},
))
.unwrap();
@@ -468,6 +469,7 @@ mod tests {
job_id: job_id.to_string(),
status: "failed".to_string(),
session_id: None,
fallback_deliverable: None,
},
))
.unwrap();
@@ -506,6 +508,7 @@ mod tests {
job_id: job_id.to_string(),
status: "completed".to_string(),
session_id: None,
fallback_deliverable: None,
},
))
.unwrap();
+1
View File
@@ -696,6 +696,7 @@ impl AppBuilder {
// fail early with a clear error instead of a confusing runtime failure.
if self.config.llm.backend != "nearai"
&& self.config.llm.backend != "bedrock"
&& self.config.llm.backend != "openai_codex"
&& self.config.llm.provider.is_none()
{
let backend = &self.config.llm.backend;
+11
View File
@@ -3314,6 +3314,7 @@ mod tests {
use std::sync::Arc;
use crate::channels::Channel;
use crate::channels::OutgoingResponse;
use crate::channels::wasm::capabilities::ChannelCapabilities;
use crate::channels::wasm::runtime::{
PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig,
@@ -3401,6 +3402,16 @@ mod tests {
assert!(channel.health_check().await.is_err());
}
#[tokio::test]
async fn test_broadcast_delegates_to_call_on_broadcast() {
let channel = create_test_channel();
// With `component: None`, call_on_broadcast short-circuits to Ok(()).
let result = channel
.broadcast("146032821", OutgoingResponse::text("hello"))
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_execute_poll_no_wasm_returns_empty() {
// When there's no WASM module (None component), execute_poll
+11
View File
@@ -344,6 +344,7 @@ pub async fn start_server(
.route("/", get(index_handler))
.route("/style.css", get(css_handler))
.route("/app.js", get(js_handler))
.route("/theme-init.js", get(theme_init_handler))
.route("/favicon.ico", get(favicon_handler))
.route("/i18n/index.js", get(i18n_index_handler))
.route("/i18n/en.js", get(i18n_en_handler))
@@ -465,6 +466,16 @@ async fn js_handler() -> impl IntoResponse {
)
}
async fn theme_init_handler() -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "application/javascript"),
(header::CACHE_CONTROL, "no-cache"),
],
include_str!("static/theme-init.js"),
)
}
async fn favicon_handler() -> impl IntoResponse {
(
[
+64
View File
@@ -1,5 +1,69 @@
// IronClaw Web Gateway - Client
// --- Theme Management (dark / light / system) ---
// Icon switching is handled by pure CSS via data-theme-mode on <html>.
function getSystemTheme() {
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
const VALID_THEME_MODES = { dark: true, light: true, system: true };
function getThemeMode() {
const stored = localStorage.getItem('ironclaw-theme');
return (stored && VALID_THEME_MODES[stored]) ? stored : 'system';
}
function resolveTheme(mode) {
return mode === 'system' ? getSystemTheme() : mode;
}
function applyTheme(mode) {
const resolved = resolveTheme(mode);
document.documentElement.setAttribute('data-theme', resolved);
document.documentElement.setAttribute('data-theme-mode', mode);
const titleKeys = { dark: 'theme.tooltipDark', light: 'theme.tooltipLight', system: 'theme.tooltipSystem' };
const btn = document.getElementById('theme-toggle');
if (btn) btn.title = (typeof I18n !== 'undefined' && titleKeys[mode]) ? I18n.t(titleKeys[mode]) : ('Theme: ' + mode);
const announce = document.getElementById('theme-announce');
if (announce) announce.textContent = (typeof I18n !== 'undefined') ? I18n.t('theme.announce', { mode: mode }) : ('Theme: ' + mode);
}
function toggleTheme() {
const cycle = { dark: 'light', light: 'system', system: 'dark' };
const current = getThemeMode();
const next = cycle[current] || 'dark';
localStorage.setItem('ironclaw-theme', next);
applyTheme(next);
}
// Apply theme immediately (FOUC prevention is done via inline script in <head>,
// but we call again here to ensure tooltip is set after DOM is ready).
applyTheme(getThemeMode());
// Delay enabling theme transition to avoid flash on initial load.
requestAnimationFrame(function() {
requestAnimationFrame(function() {
document.body.classList.add('theme-transition');
});
});
// Listen for OS theme changes — only re-apply when in 'system' mode.
const mql = window.matchMedia('(prefers-color-scheme: light)');
const onSchemeChange = function() {
if (getThemeMode() === 'system') {
applyTheme('system');
}
};
if (mql.addEventListener) {
mql.addEventListener('change', onSchemeChange);
} else if (mql.addListener) {
mql.addListener(onSchemeChange);
}
// Bind theme toggle button (CSP-compliant — no inline onclick).
document.getElementById('theme-toggle').addEventListener('click', toggleTheme);
let token = '';
let eventSource = null;
let logEventSource = null;
+6
View File
@@ -24,6 +24,12 @@ I18n.register('en', {
'restart.progressSubtitle': 'Please wait for the process to restart...',
'restart.checkLogs': 'Check the Logs tab for details after restart completes.',
// Theme
'theme.tooltipDark': 'Theme: Dark (click for Light)',
'theme.tooltipLight': 'Theme: Light (click for System)',
'theme.tooltipSystem': 'Theme: System (click for Dark)',
'theme.announce': 'Theme: {mode}',
// Tabs
'tab.chat': 'Chat',
'tab.memory': 'Memory',
+6
View File
@@ -24,6 +24,12 @@ I18n.register('zh-CN', {
'restart.progressSubtitle': '请等待进程重启...',
'restart.checkLogs': '重启完成后,请查看日志标签页了解详情。',
// 主题
'theme.tooltipDark': '主题:深色(点击切换浅色)',
'theme.tooltipLight': '主题:浅色(点击切换跟随系统)',
'theme.tooltipSystem': '主题:跟随系统(点击切换深色)',
'theme.announce': '主题:{mode}',
// 标签页
'tab.chat': '聊天',
'tab.memory': '记忆',
+13
View File
@@ -25,6 +25,7 @@
integrity="sha384-pN9zSKOnTZwXRtYZAu0PBPEgR2B7DOC1aeLxQ33oJ0oy5iN1we6gm57xldM2irDG"
crossorigin="anonymous"
></script>
<script src="/theme-init.js"></script>
</head>
<body>
<!-- Auth Screen -->
@@ -109,6 +110,18 @@
</div>
<button class="status-logs-btn" data-tab="logs" data-i18n="tab.logs" title="Logs">Logs</button>
<button class="theme-toggle-btn" id="theme-toggle" title="Toggle theme" aria-label="Toggle theme">
<svg class="theme-icon icon-dark" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
<svg class="theme-icon icon-light" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
<svg class="theme-icon icon-system" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>
</svg>
</button>
<span id="theme-announce" class="sr-only" aria-live="polite"></span>
<div class="tee-shield" id="tee-shield" style="display:none" title="Running in a Trusted Execution Environment">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
+292 -118
View File
@@ -18,10 +18,46 @@
--radius-lg: 12px;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
--font-mono: 'IBM Plex Mono', 'SF Mono', 'Fira Code', Consolas, monospace;
--text-muted: #71717a;
--bg-hover: rgba(255, 255, 255, 0.03);
--danger-soft: rgba(230, 76, 76, 0.15);
--warning-soft: rgba(245, 166, 35, 0.15);
--bg-overlay: rgba(0, 0, 0, 0.5);
--bg-modal: #1a1a1a;
--border-modal: #333;
--border-soft: #2a2a2a;
--text-tertiary: #e0e0e0;
--text-muted: #888;
--text-dimmed: #666;
--text-on-accent: #09090b;
--accent-brand: #00D894;
--accent-brand-hover: #00be82;
--warning-bg: #1e1400;
--warning-border: #3a2a00;
--warning-text: #facc15;
--tab-bg: rgba(9, 9, 11, 0.75);
--popover-bg: rgba(15, 15, 17, 0.9);
--badge-sandbox-bg: rgba(136, 132, 216, 0.15);
--badge-sandbox-text: #b4b0e8;
--hover-surface: rgba(255, 255, 255, 0.03);
--focus-ring: rgba(52, 211, 153, 0.1);
--accent-subtle: rgba(52, 211, 153, 0.15);
--accent-border-subtle: rgba(52, 211, 153, 0.3);
--danger-subtle: rgba(230, 76, 76, 0.15);
--danger-border-subtle: rgba(230, 76, 76, 0.3);
--warning-subtle: rgba(245, 166, 35, 0.15);
--border-hover: rgba(255, 255, 255, 0.15);
--user-msg-bg: rgba(52, 211, 153, 0.08);
--user-msg-border: rgba(52, 211, 153, 0.2);
--danger-error-bg: rgba(230, 76, 76, 0.1);
--accent-tee-bg: rgba(52, 211, 153, 0.1);
--accent-tee-border: rgba(52, 211, 153, 0.25);
--accent-tee-hover: rgba(52, 211, 153, 0.18);
--text-on-danger: #fff;
--shadow-card: 0 4px 24px rgba(0, 0, 0, 0.4);
--shadow-toast: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
--danger-error-border: rgba(230, 76, 76, 0.2);
--note-bg: rgba(255, 255, 255, 0.04);
--overlay-heavy: rgba(0, 0, 0, 0.6);
--highlight-bg: rgba(52, 211, 153, 0.3);
--hover-subtle: rgba(255, 255, 255, 0.06);
--transition-fast: 150ms ease;
--transition-base: 0.2s ease;
}
@@ -62,7 +98,7 @@ body {
display: flex;
flex-direction: column;
gap: 24px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
box-shadow: var(--shadow-card);
}
.auth-brand {
@@ -106,13 +142,13 @@ body {
#auth-screen input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.3);
box-shadow: 0 0 0 3px var(--accent-border-subtle);
}
#auth-screen button {
padding: 10px 16px;
background: var(--accent);
color: #09090b;
color: var(--text-on-accent);
border: none;
border-radius: var(--radius);
cursor: pointer;
@@ -156,7 +192,7 @@ body {
/* Tab Bar */
.tab-bar {
display: flex;
background: rgba(9, 9, 11, 0.75);
background: var(--tab-bg);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
will-change: backdrop-filter;
@@ -212,7 +248,7 @@ body {
.tab-bar .status-logs-btn.active {
color: var(--accent);
border-color: var(--accent);
background: rgba(52, 211, 153, 0.1);
background: var(--accent-tee-bg);
}
.tab-bar .status {
@@ -245,8 +281,8 @@ body {
color: var(--success);
padding: 4px 10px;
border-radius: 12px;
background: rgba(52, 211, 153, 0.1);
border: 1px solid rgba(52, 211, 153, 0.25);
background: var(--accent-tee-bg);
border: 1px solid var(--accent-tee-border);
cursor: pointer;
position: relative;
margin-right: 8px;
@@ -254,7 +290,7 @@ body {
}
.tee-shield:hover {
background: rgba(52, 211, 153, 0.18);
background: var(--accent-tee-hover);
}
.tee-shield svg {
@@ -275,20 +311,20 @@ body {
padding: 0.25rem 0.75rem;
border-radius: 0.5rem;
font-size: 0.8rem;
border: 1px solid #00d894;
color: #00d894;
border: 1px solid var(--accent-brand);
color: var(--accent-brand);
background-color: transparent;
cursor: pointer;
transition: color 150ms, background-color 150ms, border-color 150ms;
}
.tab-bar .restart-btn:hover:not(:disabled) {
background-color: rgba(0, 216, 148, 0.1);
background-color: var(--accent-tee-bg);
}
.tab-bar .restart-btn:disabled {
border-color: #333;
color: #666;
border-color: var(--border-modal);
color: var(--text-dimmed);
cursor: not-allowed;
}
@@ -330,7 +366,7 @@ body {
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
background: var(--bg-overlay);
backdrop-filter: blur(4px);
z-index: -1;
}
@@ -338,10 +374,10 @@ body {
.restart-loader-content {
position: relative;
z-index: 10000;
background-color: var(--bg-secondary);
border: 1px solid var(--border);
background-color: var(--bg-modal);
border: 1px solid var(--border-modal);
border-radius: 0.75rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
box-shadow: var(--shadow-lg);
width: 100%;
max-width: 28rem;
margin: 0 1rem;
@@ -358,7 +394,7 @@ body {
}
.restart-title {
color: var(--text);
color: var(--text-tertiary);
font-size: 0.85rem;
margin-bottom: 1rem;
margin-top: 0;
@@ -387,17 +423,17 @@ body {
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
background: var(--bg-overlay);
backdrop-filter: blur(4px);
}
.restart-modal-content {
position: relative;
z-index: 10000;
background-color: var(--bg-secondary);
border: 1px solid var(--border);
background-color: var(--bg-modal);
border: 1px solid var(--border-modal);
border-radius: 0.75rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
box-shadow: var(--shadow-lg);
width: 100%;
max-width: 28rem;
margin: 0 1rem;
@@ -409,17 +445,17 @@ body {
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border);
border-bottom: 1px solid var(--border-soft);
}
.restart-modal-header h2 {
color: var(--text);
color: var(--text-tertiary);
font-size: 0.95rem;
margin: 0;
}
.restart-modal-close {
color: #888;
color: var(--text-muted);
padding: 0.25rem;
border-radius: 0.25rem;
background-color: transparent;
@@ -432,8 +468,8 @@ body {
}
.restart-modal-close:hover {
color: var(--text-secondary);
background-color: var(--bg-tertiary);
color: var(--text);
background-color: var(--border-soft);
}
.restart-modal-body {
@@ -448,14 +484,14 @@ body {
.restart-modal-warning {
margin-top: 1rem;
background-color: var(--warning-soft);
border: 1px solid rgba(245, 166, 35, 0.25);
background-color: var(--warning-bg);
border: 1px solid var(--warning-border);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
}
.restart-modal-warning p {
color: var(--warning);
color: var(--warning-text);
font-size: 0.8rem;
margin: 0;
}
@@ -466,7 +502,7 @@ body {
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-top: 1px solid var(--border);
border-top: 1px solid var(--border-soft);
}
.restart-modal-btn {
@@ -479,28 +515,28 @@ body {
}
.restart-modal-btn.cancel {
color: var(--text-secondary);
color: var(--text);
background-color: transparent;
}
.restart-modal-btn.cancel:hover {
background-color: var(--bg-tertiary);
background-color: var(--border-soft);
}
.restart-modal-btn.confirm {
background-color: var(--accent);
color: #09090b;
background-color: var(--accent-brand);
color: var(--text-on-accent);
}
.restart-modal-btn.confirm:hover {
background-color: var(--accent-hover);
background-color: var(--accent-brand-hover);
}
/* Progress Bar for Restart */
.restart-progress-bar {
width: 100%;
height: 0.375rem;
background-color: var(--bg-tertiary);
background-color: var(--border-soft);
border-radius: 9999px;
overflow: hidden;
}
@@ -508,7 +544,7 @@ body {
.restart-progress-fill {
height: 100%;
border-radius: 9999px;
background-color: var(--accent);
background-color: var(--accent-brand);
width: 40%;
animation: indeterminate 1.5s ease-in-out infinite;
}
@@ -529,14 +565,14 @@ body {
}
.restart-modal-info {
color: var(--text-secondary);
color: var(--text-dimmed);
font-size: 0.8rem;
margin-top: 1.25rem;
margin-bottom: 0;
}
.restart-modal-info a {
color: var(--accent);
color: var(--accent-brand);
text-decoration: none;
}
@@ -550,7 +586,7 @@ body {
top: 100%;
right: 0;
margin-top: 8px;
background: rgba(15, 15, 17, 0.9);
background: var(--popover-bg);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--border);
@@ -887,11 +923,11 @@ body {
}
.activity-tool-card[data-status="running"] {
border-color: rgba(52, 211, 153, 0.3);
border-color: var(--accent-border-subtle);
}
.activity-tool-card[data-status="fail"] {
border-color: rgba(230, 76, 76, 0.3);
border-color: var(--danger-border-subtle);
}
.activity-tool-card[data-status="fail"] .activity-tool-name {
@@ -1132,21 +1168,21 @@ body {
.approval-card .approval-actions button.approve {
background: var(--success);
border-color: var(--success);
color: #09090b;
color: var(--text-on-accent);
font-weight: 600;
}
.approval-card .approval-actions button.always {
background: var(--accent);
border-color: var(--accent);
color: #09090b;
color: var(--text-on-accent);
font-weight: 600;
}
.approval-card .approval-actions button.deny {
background: var(--danger);
border-color: var(--danger);
color: #fff;
color: var(--text-on-danger);
}
.approval-resolved {
@@ -1308,7 +1344,7 @@ body {
.auth-card .auth-token-input input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
box-shadow: 0 0 0 3px var(--focus-ring);
}
.auth-card .auth-actions {
@@ -1335,7 +1371,7 @@ body {
.auth-card .auth-actions button.auth-submit {
background: var(--accent);
border-color: var(--accent);
color: #09090b;
color: var(--text-on-accent);
font-weight: 600;
}
@@ -1347,7 +1383,7 @@ body {
.auth-card .auth-actions button.auth-oauth {
background: var(--success);
border-color: var(--success);
color: #09090b;
color: var(--text-on-accent);
font-weight: 600;
}
@@ -1413,7 +1449,7 @@ body {
.chat-input-wrapper textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
box-shadow: 0 0 0 3px var(--focus-ring);
}
.chat-input-wrapper textarea:disabled {
@@ -1451,7 +1487,7 @@ body {
.chat-input button {
padding: 8px 20px;
background: var(--accent);
color: #09090b;
color: var(--text-on-accent);
border: none;
border-radius: var(--radius);
cursor: pointer;
@@ -1518,7 +1554,7 @@ body {
.memory-sidebar input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
box-shadow: 0 0 0 3px var(--focus-ring);
}
.memory-tree {
@@ -1712,7 +1748,7 @@ body {
}
.summary-card:hover {
border-color: rgba(255, 255, 255, 0.15);
border-color: var(--border-hover);
}
.summary-card .count {
@@ -1756,7 +1792,7 @@ body {
}
.jobs-table tr:hover td {
background: rgba(255, 255, 255, 0.03);
background: var(--hover-surface);
}
.badge {
@@ -1768,13 +1804,13 @@ body {
}
.badge.pending { background: var(--bg-tertiary); color: var(--text-secondary); }
.badge.in_progress { background: rgba(52, 211, 153, 0.15); color: var(--accent); }
.badge.completed { background: rgba(52, 211, 153, 0.15); color: var(--success); }
.badge.failed { background: rgba(230, 76, 76, 0.15); color: var(--danger); }
.badge.stuck { background: rgba(245, 166, 35, 0.15); color: var(--warning); }
.badge.in_progress { background: var(--accent-subtle); color: var(--accent); }
.badge.completed { background: var(--accent-subtle); color: var(--success); }
.badge.failed { background: var(--danger-subtle); color: var(--danger); }
.badge.stuck { background: var(--warning-subtle); color: var(--warning); }
.badge.cancelled { background: var(--bg-tertiary); color: var(--text-secondary); }
.badge.interrupted { background: rgba(245, 166, 35, 0.15); color: var(--warning); }
.badge.source-sandbox { background: rgba(136, 132, 216, 0.15); color: #b4b0e8; }
.badge.interrupted { background: var(--warning-subtle); color: var(--warning); }
.badge.source-sandbox { background: var(--badge-sandbox-bg); color: var(--badge-sandbox-text); }
.badge.source-direct { background: var(--bg-tertiary); color: var(--text-secondary); }
.btn-cancel {
@@ -1788,7 +1824,7 @@ body {
}
.btn-cancel:hover {
background: rgba(230, 76, 76, 0.15);
background: var(--danger-subtle);
}
.btn-restart {
@@ -1802,7 +1838,7 @@ body {
}
.btn-restart:hover {
background: rgba(52, 211, 153, 0.15);
background: var(--accent-subtle);
}
.btn-browse {
@@ -1817,7 +1853,7 @@ body {
}
.btn-browse:hover {
background: rgba(52, 211, 153, 0.15);
background: var(--accent-subtle);
}
/* Job started card in chat */
@@ -1835,7 +1871,7 @@ body {
}
.job-card:hover {
border-color: rgba(255, 255, 255, 0.15);
border-color: var(--border-hover);
}
.job-card-icon {
@@ -1872,7 +1908,7 @@ body {
}
.job-card-view:hover {
background: rgba(52, 211, 153, 0.15);
background: var(--accent-subtle);
}
.job-card-browse {
@@ -1882,7 +1918,7 @@ body {
}
.job-card-browse:hover {
background: rgba(52, 211, 153, 0.15);
background: var(--accent-subtle);
}
/* Clickable job rows */
@@ -2145,7 +2181,7 @@ body {
}
.action-error {
background: rgba(230, 76, 76, 0.1);
background: var(--danger-error-bg);
padding: 8px 12px;
border-radius: var(--radius);
font-size: 12px;
@@ -2187,8 +2223,8 @@ body {
.conv-system .conv-body { color: var(--text-secondary); font-size: 13px; }
.conv-user {
background: rgba(52, 211, 153, 0.08);
border: 1px solid rgba(52, 211, 153, 0.2);
background: var(--user-msg-bg);
border: 1px solid var(--user-msg-border);
}
.conv-user .conv-role { color: var(--accent); }
@@ -2335,7 +2371,7 @@ body {
}
.routines-table tr:hover td {
background: rgba(255, 255, 255, 0.03);
background: var(--hover-surface);
}
.routine-row {
@@ -2346,9 +2382,9 @@ body {
padding: 16px 0;
}
.badge.enabled { background: rgba(52, 211, 153, 0.15); color: var(--success); }
.badge.enabled { background: var(--accent-subtle); color: var(--success); }
.badge.disabled { background: var(--bg-tertiary); color: var(--text-secondary); }
.badge.failing { background: rgba(230, 76, 76, 0.15); color: var(--danger); }
.badge.failing { background: var(--danger-subtle); color: var(--danger); }
.btn-trigger {
padding: 4px 10px;
@@ -2361,7 +2397,7 @@ body {
}
.btn-trigger:hover {
background: rgba(52, 211, 153, 0.15);
background: var(--accent-subtle);
}
.btn-toggle {
@@ -2375,7 +2411,7 @@ body {
}
.btn-toggle:hover {
background: rgba(245, 166, 35, 0.15);
background: var(--warning-subtle);
}
/* Logs Tab */
@@ -2419,7 +2455,7 @@ body {
.logs-toolbar input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
box-shadow: 0 0 0 3px var(--focus-ring);
}
.logs-checkbox {
@@ -2580,7 +2616,7 @@ body {
}
.ext-card:hover {
border-color: rgba(255, 255, 255, 0.15);
border-color: var(--border-hover);
}
.ext-header {
@@ -2605,17 +2641,17 @@ body {
}
.ext-kind.kind-mcp_server {
background: rgba(52, 211, 153, 0.15);
background: var(--accent-subtle);
color: var(--accent);
}
.ext-kind.kind-wasm_tool {
background: rgba(52, 211, 153, 0.15);
background: var(--accent-subtle);
color: var(--success);
}
.ext-kind.kind-wasm_channel {
background: rgba(245, 166, 35, 0.15);
background: var(--warning-subtle);
color: var(--warning);
}
@@ -2720,7 +2756,7 @@ body {
.stepper-step.failed .stepper-circle {
background: var(--danger);
color: #fff;
color: var(--text-on-danger);
}
.stepper-step.failed .stepper-label {
@@ -2773,8 +2809,8 @@ body {
.ext-error {
font-size: 11px;
color: var(--danger);
background: rgba(230, 76, 76, 0.1);
border: 1px solid rgba(230, 76, 76, 0.2);
background: var(--danger-error-bg);
border: 1px solid var(--danger-error-border);
border-radius: var(--radius);
padding: 6px 8px;
margin-top: 6px;
@@ -2783,7 +2819,7 @@ body {
.ext-note {
font-size: 11px;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.04);
background: var(--note-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 6px 8px;
@@ -2821,7 +2857,7 @@ body {
}
.btn-ext.activate:hover {
background: rgba(52, 211, 153, 0.15);
background: var(--accent-subtle);
}
.btn-ext.remove {
@@ -2830,7 +2866,7 @@ body {
}
.btn-ext.remove:hover {
background: rgba(230, 76, 76, 0.15);
background: var(--danger-subtle);
}
.btn-ext.install {
@@ -2839,7 +2875,7 @@ body {
}
.btn-ext.install:hover {
background: rgba(52, 211, 153, 0.15);
background: var(--accent-subtle);
}
.btn-ext.install:disabled {
@@ -2863,7 +2899,7 @@ body {
}
.btn-ext.configure:hover {
background: rgba(136, 132, 216, 0.15);
background: var(--badge-sandbox-bg);
}
/* Pairing requests */
@@ -2911,7 +2947,7 @@ body {
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
background: var(--overlay-heavy);
backdrop-filter: blur(4px);
z-index: 1000;
display: flex;
@@ -3076,6 +3112,32 @@ body {
justify-content: flex-end;
}
.tools-table {
width: 100%;
border-collapse: collapse;
}
.tools-table th,
.tools-table td {
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.tools-table th {
color: var(--text-secondary);
font-weight: 500;
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.5px;
}
.tools-table tr:hover td {
background: var(--hover-surface);
}
/* --- Activity tab (unified sandbox job events) --- */
.activity-terminal {
@@ -3094,7 +3156,7 @@ body {
.activity-event {
padding: 4px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
border-bottom: 1px solid var(--note-bg);
}
.activity-event-message .activity-role {
@@ -3197,13 +3259,13 @@ body {
.activity-input-bar input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
box-shadow: 0 0 0 3px var(--focus-ring);
}
.activity-input-bar button {
padding: 8px 16px;
background: var(--accent);
color: #09090b;
color: var(--text-on-accent);
border: none;
border-radius: var(--radius);
cursor: pointer;
@@ -3280,13 +3342,13 @@ body {
padding: 10px 16px;
border-radius: var(--radius);
font-size: 13px;
color: #fff;
color: var(--text-on-danger);
pointer-events: auto;
transform: translateX(120%);
transition: transform 0.25s ease;
max-width: 360px;
word-break: break-word;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
box-shadow: var(--shadow-toast);
}
.toast.visible {
@@ -3308,7 +3370,7 @@ body {
/* --- Memory search highlighting --- */
mark {
background: rgba(52, 211, 153, 0.3);
background: var(--highlight-bg);
color: inherit;
border-radius: 2px;
padding: 0 1px;
@@ -3361,7 +3423,7 @@ mark {
}
.thread-new-btn:hover {
background: rgba(52, 211, 153, 0.15);
background: var(--accent-subtle);
}
.assistant-item {
@@ -3379,11 +3441,11 @@ mark {
}
.assistant-item:hover {
background: rgba(255, 255, 255, 0.06);
background: var(--hover-subtle);
}
.assistant-item.active {
background: rgba(52, 211, 153, 0.1);
background: var(--accent-tee-bg);
color: var(--accent);
border-left: 2px solid var(--accent);
}
@@ -3471,14 +3533,14 @@ mark {
letter-spacing: 0.5px;
padding: 1px 5px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.08);
background: var(--border);
color: var(--text-secondary);
margin-right: 6px;
flex-shrink: 0;
}
.thread-badge-routine { background: rgba(52, 211, 153, 0.15); color: var(--accent); }
.thread-badge-heartbeat { background: rgba(245, 166, 35, 0.15); color: var(--warning); }
.thread-badge-routine { background: var(--accent-subtle); color: var(--accent); }
.thread-badge-heartbeat { background: var(--warning-subtle); color: var(--warning); }
.thread-badge-telegram { background: rgba(0, 136, 204, 0.15); color: #0088cc; }
.thread-badge-signal { background: rgba(59, 118, 240, 0.15); color: #3b76f0; }
.thread-badge-slack { background: rgba(74, 21, 75, 0.15); color: #e01e5a; }
@@ -3546,7 +3608,7 @@ mark {
.memory-editor textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
box-shadow: 0 0 0 3px var(--focus-ring);
}
.memory-editor-actions {
@@ -3557,7 +3619,7 @@ mark {
.btn-save {
padding: 6px 16px;
background: var(--accent);
color: #09090b;
color: var(--text-on-accent);
border: none;
border-radius: var(--radius);
cursor: pointer;
@@ -3638,7 +3700,7 @@ mark {
top: 100%;
right: 0;
margin-top: 8px;
background: rgba(15, 15, 17, 0.9);
background: var(--popover-bg);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--border);
@@ -3736,13 +3798,13 @@ mark {
.ext-install-form input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
box-shadow: 0 0 0 3px var(--focus-ring);
}
.ext-install-form button {
padding: 6px 16px;
background: var(--accent);
color: #09090b;
color: var(--text-on-accent);
border: none;
border-radius: var(--radius);
cursor: pointer;
@@ -3786,13 +3848,13 @@ mark {
.skill-search-box input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
box-shadow: 0 0 0 3px var(--focus-ring);
}
.skill-search-box button {
padding: 8px 20px;
background: var(--accent);
color: #09090b;
color: var(--text-on-accent);
border: none;
border-radius: var(--radius);
cursor: pointer;
@@ -3816,7 +3878,7 @@ mark {
}
.skill-trust.trust-trusted {
background: rgba(52, 211, 153, 0.15);
background: var(--accent-subtle);
color: var(--success);
}
@@ -3861,7 +3923,7 @@ mark {
.activity-toolbar select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
box-shadow: 0 0 0 3px var(--focus-ring);
}
/* --- Mobile responsive --- */
@@ -4103,7 +4165,7 @@ mark {
}
.settings-row:hover {
background: var(--bg-hover);
background: var(--hover-surface);
}
.settings-row.hidden {
@@ -4170,8 +4232,8 @@ mark {
align-items: center;
gap: 10px;
padding: 10px 14px;
background: var(--warning-soft);
border: 1px solid rgba(245, 166, 35, 0.25);
background: var(--warning-subtle);
border: 1px solid var(--warning-border);
border-radius: var(--radius);
color: var(--text);
font-size: 12px;
@@ -4331,7 +4393,7 @@ input[type="checkbox"]:focus-visible {
height: 18px;
border-radius: 50%;
background: var(--danger);
color: #fff;
color: var(--text-on-danger);
border: none;
font-size: 12px;
line-height: 18px;
@@ -4341,7 +4403,7 @@ input[type="checkbox"]:focus-visible {
}
.image-preview-remove:hover {
background: #c33;
filter: brightness(1.2);
}
/* Generated Image */
@@ -4627,3 +4689,115 @@ input[type="checkbox"]:focus-visible {
color: var(--text-muted);
font-size: 13px;
}
/* Screen-reader only utility */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* ============================================================
Light Theme
============================================================ */
[data-theme="light"] {
--bg: #ffffff;
--bg-secondary: #f5f5f7;
--bg-tertiary: #ebebed;
--border: rgba(0, 0, 0, 0.1);
--text: #1a1a2e;
--text-secondary: #555555;
--accent: #059669;
--accent-hover: #047857;
--success: #059669;
--warning: #d97706;
--danger: #dc2626;
--code-bg: #f0f0f2;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
--bg-overlay: rgba(0, 0, 0, 0.3);
--bg-modal: #ffffff;
--border-modal: #e0e0e0;
--border-soft: #e5e5e5;
--text-tertiary: #333333;
--text-muted: #777777;
--text-dimmed: #999999;
--text-on-accent: #ffffff;
--accent-brand: #059669;
--accent-brand-hover: #047857;
--warning-bg: #fffbeb;
--warning-border: #fde68a;
--warning-text: #92400e;
--tab-bg: rgba(255, 255, 255, 0.9);
--popover-bg: rgba(255, 255, 255, 0.95);
--badge-sandbox-bg: rgba(136, 132, 216, 0.1);
--badge-sandbox-text: #6b67b0;
--hover-surface: rgba(0, 0, 0, 0.03);
--focus-ring: rgba(5, 150, 105, 0.15);
--accent-subtle: rgba(5, 150, 105, 0.1);
--accent-border-subtle: rgba(5, 150, 105, 0.3);
--danger-subtle: rgba(220, 38, 38, 0.1);
--danger-border-subtle: rgba(220, 38, 38, 0.2);
--warning-subtle: rgba(217, 119, 6, 0.1);
--border-hover: rgba(0, 0, 0, 0.15);
--user-msg-bg: rgba(5, 150, 105, 0.08);
--user-msg-border: rgba(5, 150, 105, 0.2);
--danger-error-bg: rgba(220, 38, 38, 0.06);
--accent-tee-bg: rgba(5, 150, 105, 0.08);
--accent-tee-border: rgba(5, 150, 105, 0.2);
--accent-tee-hover: rgba(5, 150, 105, 0.15);
--text-on-danger: #fff;
--shadow-card: 0 4px 24px rgba(0, 0, 0, 0.08);
--shadow-toast: 0 4px 12px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 25px 50px -12px rgba(0, 0, 0, 0.1);
--danger-error-border: rgba(220, 38, 38, 0.15);
--note-bg: rgba(0, 0, 0, 0.02);
--overlay-heavy: rgba(0, 0, 0, 0.4);
--highlight-bg: rgba(5, 150, 105, 0.2);
--hover-subtle: rgba(0, 0, 0, 0.04);
}
/* ============================================================
Theme transition (delayed via JS to avoid FOUC)
============================================================ */
body.theme-transition,
body.theme-transition *:not(svg):not(path):not(line):not(circle):not(rect) {
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}
/* ============================================================
Theme toggle button
============================================================ */
.theme-toggle-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
background: none;
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-secondary);
cursor: pointer;
align-self: center;
margin-right: 8px;
transition: color 0.2s, border-color 0.2s;
}
.theme-toggle-btn:hover {
color: var(--text);
border-color: var(--text-secondary);
}
/* CSS-only icon switching via data-theme-mode on <html> */
.theme-icon { display: none; }
[data-theme-mode="dark"] .icon-dark { display: block; }
[data-theme-mode="light"] .icon-light { display: block; }
[data-theme-mode="system"] .icon-system { display: block; }
+12
View File
@@ -0,0 +1,12 @@
// Prevent FOUC: apply saved theme before first paint.
// This script must be loaded synchronously in <head> (no defer/async).
(function() {
const stored = localStorage.getItem('ironclaw-theme');
const mode = (stored === 'dark' || stored === 'light' || stored === 'system') ? stored : 'system';
let resolved = mode;
if (mode === 'system') {
resolved = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
document.documentElement.setAttribute('data-theme', resolved);
document.documentElement.setAttribute('data-theme-mode', mode);
})();
+11
View File
@@ -239,6 +239,17 @@ pub enum Command {
)]
Import(ImportCommand),
/// Authenticate with a provider (re-login)
#[command(
about = "Authenticate with a provider",
long_about = "Re-authenticate with an LLM provider.\nExample: ironclaw login --openai-codex"
)]
Login {
/// Authenticate with OpenAI Codex (ChatGPT subscription)
#[arg(long)]
openai_codex: bool,
},
/// Run as a sandboxed worker inside a Docker container (internal use).
/// This is invoked automatically by the orchestrator, not by users directly.
#[command(hide = true)]
@@ -24,6 +24,7 @@ Commands:
status Show system status
completion Generate completions
import Import from other AI systems
login Authenticate with a provider
help Print this message or the help of the given subcommand(s)
Options:
@@ -23,6 +23,7 @@ Commands:
logs View and manage gateway logs
status Show system status
completion Generate completions
login Authenticate with a provider
help Print this message or the help of the given subcommand(s)
Options:
@@ -27,6 +27,7 @@ Commands:
status Show system status
completion Generate completions
import Import from other AI systems
login Authenticate with a provider
help Print this message or the help of the given subcommand(s)
Options:
@@ -26,6 +26,7 @@ Commands:
logs View and manage gateway logs
status Show system status
completion Generate completions
login Authenticate with a provider
help Print this message or the help of the given subcommand(s)
Options:
+198 -3
View File
@@ -37,6 +37,7 @@ impl LlmConfig {
},
provider: None,
bedrock: None,
openai_codex: None,
request_timeout_secs: 120,
cheap_model: None,
smart_routing_cascade: false,
@@ -72,8 +73,12 @@ impl LlmConfig {
backend_lower == "nearai" || backend_lower == "near_ai" || backend_lower == "near";
let is_bedrock =
backend_lower == "bedrock" || backend_lower == "aws_bedrock" || backend_lower == "aws";
let is_openai_codex = backend_lower == "openai_codex"
|| backend_lower == "openai-codex"
|| backend_lower == "codex";
if !is_nearai && !is_bedrock && registry.find(&backend_lower).is_none() {
if !is_nearai && !is_bedrock && !is_openai_codex && registry.find(&backend_lower).is_none()
{
tracing::warn!(
"Unknown LLM backend '{}'. Will attempt as openai_compatible fallback.",
backend
@@ -126,8 +131,8 @@ impl LlmConfig {
smart_routing_cascade: parse_optional_env("SMART_ROUTING_CASCADE", true)?,
};
// Resolve registry provider config (for non-NearAI, non-Bedrock backends)
let provider = if is_nearai || is_bedrock {
// Resolve registry provider config (for non-NearAI, non-Bedrock, non-Codex backends)
let provider = if is_nearai || is_bedrock || is_openai_codex {
None
} else {
Some(Self::resolve_registry_provider(
@@ -174,6 +179,38 @@ impl LlmConfig {
None
};
// Resolve OpenAI Codex config
let openai_codex = if is_openai_codex {
// Model: OPENAI_CODEX_MODEL > OPENAI_MODEL > settings.selected_model > default
let model = optional_env("OPENAI_CODEX_MODEL")?
.or(optional_env("OPENAI_MODEL")?)
.or_else(|| settings.selected_model.clone())
.unwrap_or_else(|| "gpt-5.3-codex".to_string());
let auth_endpoint = optional_env("OPENAI_CODEX_AUTH_URL")?
.unwrap_or_else(|| "https://auth.openai.com".to_string());
validate_base_url(&auth_endpoint, "OPENAI_CODEX_AUTH_URL")?;
let api_base_url = optional_env("OPENAI_CODEX_API_URL")?
.unwrap_or_else(|| "https://chatgpt.com/backend-api/codex".to_string());
validate_base_url(&api_base_url, "OPENAI_CODEX_API_URL")?;
let client_id = optional_env("OPENAI_CODEX_CLIENT_ID")?
.unwrap_or_else(|| "app_EMoamEEZ73f0CkXaXp7hrann".to_string());
let session_path = optional_env("OPENAI_CODEX_SESSION_PATH")?
.map(PathBuf::from)
.unwrap_or_else(|| ironclaw_base_dir().join("openai_codex_session.json"));
let token_refresh_margin_secs =
parse_optional_env("OPENAI_CODEX_REFRESH_MARGIN_SECS", 300)?;
Some(OpenAiCodexConfig {
model,
auth_endpoint,
api_base_url,
client_id,
session_path,
token_refresh_margin_secs,
})
} else {
None
};
let request_timeout_secs = parse_optional_env("LLM_REQUEST_TIMEOUT_SECS", 120)?;
// Generic cheap model (works with any backend).
@@ -189,6 +226,8 @@ impl LlmConfig {
"nearai".to_string()
} else if is_bedrock {
"bedrock".to_string()
} else if is_openai_codex {
"openai_codex".to_string()
} else if let Some(ref p) = provider {
p.provider_id.clone()
} else {
@@ -198,6 +237,7 @@ impl LlmConfig {
nearai,
provider,
bedrock,
openai_codex,
request_timeout_secs,
cheap_model,
smart_routing_cascade,
@@ -1069,4 +1109,159 @@ mod tests {
std::env::remove_var("LLM_REQUEST_TIMEOUT_SECS");
}
}
// ── OpenAI Codex tests ──────────────────────────────────────────
/// Clear all openai-codex-related env vars.
fn clear_openai_codex_env() {
// SAFETY: Only called under ENV_MUTEX in tests.
unsafe {
std::env::remove_var("LLM_BACKEND");
std::env::remove_var("OPENAI_CODEX_MODEL");
std::env::remove_var("OPENAI_MODEL");
}
}
#[test]
fn openai_codex_resolves_config() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
clear_openai_codex_env();
let settings = Settings {
llm_backend: Some("openai_codex".to_string()),
..Default::default()
};
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
assert_eq!(cfg.backend, "openai_codex");
let codex = cfg.openai_codex.expect("codex config should be present");
assert_eq!(codex.model, "gpt-5.3-codex"); // default
assert!(
cfg.provider.is_none(),
"codex should not use registry provider"
);
}
#[test]
fn openai_codex_model_env_resolution() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
clear_openai_codex_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::set_var("OPENAI_CODEX_MODEL", "o3-pro");
}
let settings = Settings {
llm_backend: Some("openai_codex".to_string()),
..Default::default()
};
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
let codex = cfg.openai_codex.expect("codex config should be present");
assert_eq!(codex.model, "o3-pro");
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::remove_var("OPENAI_CODEX_MODEL");
}
}
#[test]
fn openai_codex_falls_back_to_openai_model() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
clear_openai_codex_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::set_var("OPENAI_MODEL", "gpt-4o");
}
let settings = Settings {
llm_backend: Some("openai_codex".to_string()),
..Default::default()
};
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
let codex = cfg.openai_codex.expect("codex config should be present");
assert_eq!(codex.model, "gpt-4o");
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::remove_var("OPENAI_MODEL");
}
}
#[test]
fn openai_codex_falls_back_to_selected_model() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
clear_openai_codex_env();
let settings = Settings {
llm_backend: Some("openai_codex".to_string()),
selected_model: Some("gpt-4o-mini".to_string()),
..Default::default()
};
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
let codex = cfg.openai_codex.expect("codex config should be present");
assert_eq!(codex.model, "gpt-4o-mini");
}
/// Regression: SSRF validation on OPENAI_CODEX_API_URL (#1103).
#[test]
fn openai_codex_rejects_ssrf_api_url() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
clear_openai_codex_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::set_var(
"OPENAI_CODEX_API_URL",
"http://169.254.169.254/latest/meta-data",
);
}
let settings = Settings {
llm_backend: Some("openai_codex".to_string()),
..Default::default()
};
let err = LlmConfig::resolve(&settings).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("OPENAI_CODEX_API_URL"),
"error should reference the field name: {msg}"
);
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::remove_var("OPENAI_CODEX_API_URL");
}
}
/// Regression: SSRF validation on OPENAI_CODEX_AUTH_URL (#1103).
#[test]
fn openai_codex_rejects_ssrf_auth_url() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
clear_openai_codex_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::set_var("OPENAI_CODEX_AUTH_URL", "http://10.0.0.1");
}
let settings = Settings {
llm_backend: Some("openai_codex".to_string()),
..Default::default()
};
let err = LlmConfig::resolve(&settings).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("OPENAI_CODEX_AUTH_URL"),
"error should reference the field name: {msg}"
);
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::remove_var("OPENAI_CODEX_AUTH_URL");
}
}
}
+2 -2
View File
@@ -54,7 +54,7 @@ pub use self::transcription::TranscriptionConfig;
pub use self::tunnel::TunnelConfig;
pub use self::wasm::WasmConfig;
pub use crate::llm::config::{
BedrockConfig, CacheRetention, LlmConfig, NearAiConfig, OAUTH_PLACEHOLDER,
BedrockConfig, CacheRetention, LlmConfig, NearAiConfig, OAUTH_PLACEHOLDER, OpenAiCodexConfig,
RegistryProviderConfig,
};
pub use crate::llm::session::SessionConfig;
@@ -377,7 +377,7 @@ pub(crate) fn resolve_owner_id(settings: &Settings) -> Result<String, ConfigErro
/// are read by `optional_env()` before falling back to `std::env::var()`,
/// so explicit env vars always win.
///
/// Also loads tokens from OS credential stores (macOS Keychain, Linux
/// Also loads tokens from OS credential stores (macOS Keychain / Linux
/// credentials files) which don't require the secrets DB.
pub async fn inject_llm_keys_from_secrets(
secrets: &dyn crate::secrets::SecretsStore,
+23 -1
View File
@@ -13,6 +13,9 @@ Multi-provider LLM integration with circuit breaker, retry, failover, and respon
| `nearai_chat.rs` | NEAR AI Chat Completions provider (dual auth: session token or API key) |
| `codex_auth.rs` | Reads Codex CLI `auth.json`, extracts tokens, refreshes ChatGPT OAuth access tokens |
| `codex_chatgpt.rs` | Custom Responses API provider for Codex ChatGPT backend (`/backend-api/codex`) |
| `openai_codex_provider.rs` | OpenAI Codex Responses API client (SSE streaming, JWT auth, subscription billing) |
| `openai_codex_session.rs` | OAuth 2.0 session manager for OpenAI Codex (device code flow, token persistence) |
| `token_refreshing.rs` | Token-refreshing `LlmProvider` decorator for OpenAI Codex (pre-emptive refresh, zero-cost billing) |
| `reasoning.rs` | `Reasoning` struct, `ReasoningContext`, `RespondResult`, `ActionPlan`, `ToolSelection`; thinking-tag stripping; `SILENT_REPLY_TOKEN` |
| `session.rs` | NEAR AI session token management with disk + DB persistence, OAuth login flow |
| `circuit_breaker.rs` | Circuit breaker: Closed → Open → HalfOpen state machine |
@@ -38,6 +41,7 @@ Set via `LLM_BACKEND` env var:
| `openai_compatible` | Any OpenAI-compatible endpoint | `LLM_BASE_URL`, `LLM_API_KEY`, `LLM_MODEL` |
| `tinfoil` | Tinfoil TEE inference | `TINFOIL_API_KEY`, `TINFOIL_MODEL` |
| `bedrock` | AWS Bedrock (requires `--features bedrock`) | `BEDROCK_REGION`, `BEDROCK_MODEL`, `AWS_PROFILE` |
| `openai_codex` | OpenAI Codex (ChatGPT subscription) | `OPENAI_CODEX_MODEL`, `OPENAI_CODEX_CLIENT_ID` |
Codex auth reuse:
- Set `LLM_USE_CODEX_AUTH=true` to load credentials from `~/.codex/auth.json` (override with `CODEX_AUTH_PATH`).
@@ -148,9 +152,27 @@ To add a new provider:
Set `LLM_EXTRA_HEADERS=Key:Value,Key2:Value2` to inject headers into every request. Useful for OpenRouter attribution (`HTTP-Referer`, `X-Title`). Invalid header names/values are skipped with a warning (not a fatal error).
## OpenAI Codex Provider
Uses the Responses API at `chatgpt.com/backend-api/codex/responses` with ChatGPT subscription OAuth tokens (zero API cost — billing through subscription).
**Auth flow:** Device code OAuth via `auth.openai.com/api/accounts/deviceauth/*` endpoints. On first run, displays a code for the user to enter at a URL. Tokens are persisted to `~/.ironclaw/openai_codex_session.json` (mode 0600) and auto-refreshed before expiry.
**Provider chain:** `OpenAiCodexProvider``TokenRefreshingProvider` (pre-emptive refresh + retry on 401) → standard decorator chain. The `TokenRefreshingProvider` intercepts `AuthFailed`/`SessionExpired` errors, refreshes the OAuth token, and retries once.
**Key differences from other providers:**
- Uses Responses API (not Chat Completions) — SSE streaming with different event types
- System messages are sent as `instructions` field, not in `input` array
- Tool schemas are normalized via `normalize_schema_strict()` for OpenAI strict mode
- `cost_per_token()` returns `(0, 0)` — subscription-based billing
- `set_model()` returns error — model is fixed at construction time
- Image attachments are silently dropped with a warning log
**Env vars:** `OPENAI_CODEX_MODEL` (default: `gpt-5.3-codex`), `OPENAI_CODEX_CLIENT_ID`, `OPENAI_CODEX_AUTH_URL`, `OPENAI_CODEX_API_URL`.
## Provider Chain Construction
`build_provider_chain()` in `mod.rs` is the single source of truth for assembling decorators. The chain is:
`build_provider_chain()` in `mod.rs` is the single source of truth for assembling decorators. It creates the base provider (dispatching to `create_openai_codex_provider()` for codex, `create_llm_provider()` for everything else), then applies all decorators inline:
```
Raw provider
+34
View File
@@ -0,0 +1,34 @@
//! Shared test helpers for OpenAI Codex provider tests.
#![cfg(test)]
use crate::config::OpenAiCodexConfig;
/// Build a minimal JWT for testing (header.payload.signature).
pub(crate) fn make_test_jwt(account_id: &str) -> String {
use base64::Engine;
let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
let header = engine.encode(b"{\"alg\":\"RS256\",\"typ\":\"JWT\"}");
let payload_json = serde_json::json!({
"sub": "user123",
"https://api.openai.com/auth": {
"chatgpt_account_id": account_id,
},
});
let payload = engine.encode(payload_json.to_string().as_bytes());
let sig = engine.encode(b"fake-signature");
format!("{header}.{payload}.{sig}")
}
/// Build a test `OpenAiCodexConfig` with a given session path.
pub(crate) fn test_codex_config(session_path: std::path::PathBuf) -> OpenAiCodexConfig {
OpenAiCodexConfig {
model: "gpt-5.3-codex".to_string(),
auth_endpoint: "https://auth.openai.com".to_string(),
api_base_url: "https://chatgpt.com/backend-api/codex".to_string(),
client_id: "test_client_id".to_string(),
session_path,
token_refresh_margin_secs: 300,
}
}
+33
View File
@@ -9,6 +9,7 @@ use std::path::PathBuf;
use secrecy::SecretString;
use crate::bootstrap::ironclaw_base_dir;
use crate::llm::registry::ProviderProtocol;
use crate::llm::session::SessionConfig;
@@ -102,6 +103,36 @@ pub struct RegistryProviderConfig {
pub unsupported_params: Vec<String>,
}
/// Configuration for OpenAI Codex (ChatGPT subscription OAuth).
#[derive(Debug, Clone)]
pub struct OpenAiCodexConfig {
/// Model to use (default: "gpt-5.3-codex").
pub model: String,
/// OAuth authorization server (default: "https://auth.openai.com").
pub auth_endpoint: String,
/// Responses API base URL (default: "https://chatgpt.com/backend-api/codex").
pub api_base_url: String,
/// OAuth client ID (default: OpenAI's public Codex client).
pub client_id: String,
/// Path to session file (default: ~/.ironclaw/openai_codex_session.json).
pub session_path: PathBuf,
/// Seconds before expiry to proactively refresh (default: 300).
pub token_refresh_margin_secs: u64,
}
impl Default for OpenAiCodexConfig {
fn default() -> Self {
Self {
model: "gpt-5.3-codex".to_string(),
auth_endpoint: "https://auth.openai.com".to_string(),
api_base_url: "https://chatgpt.com/backend-api/codex".to_string(),
client_id: "app_EMoamEEZ73f0CkXaXp7hrann".to_string(),
session_path: ironclaw_base_dir().join("openai_codex_session.json"),
token_refresh_margin_secs: 300,
}
}
}
/// Configuration for AWS Bedrock (native Converse API).
#[derive(Debug, Clone)]
pub struct BedrockConfig {
@@ -134,6 +165,8 @@ pub struct LlmConfig {
pub provider: Option<RegistryProviderConfig>,
/// AWS Bedrock config (populated when backend=bedrock, requires --features bedrock).
pub bedrock: Option<BedrockConfig>,
/// OpenAI Codex config (populated when backend=openai_codex).
pub openai_codex: Option<OpenAiCodexConfig>,
/// HTTP request timeout in seconds for LLM API calls.
/// Default: 120. Increase for local LLMs (Ollama, vLLM, LM Studio) that
/// need more time for prompt evaluation on consumer hardware.
+66 -2
View File
@@ -20,6 +20,8 @@ pub mod error;
pub mod failover;
mod nearai_chat;
pub mod oauth_helpers;
pub mod openai_codex_provider;
pub mod openai_codex_session;
mod provider;
mod reasoning;
pub mod recording;
@@ -29,6 +31,10 @@ pub mod retry;
mod rig_adapter;
pub mod session;
pub mod smart_routing;
mod token_refreshing;
#[cfg(test)]
mod codex_test_helpers;
pub mod image_models;
pub mod models;
@@ -37,12 +43,14 @@ pub mod vision_models;
pub use circuit_breaker::{CircuitBreakerConfig, CircuitBreakerProvider};
pub use config::{
BedrockConfig, CacheRetention, LlmConfig, NearAiConfig, OAUTH_PLACEHOLDER,
BedrockConfig, CacheRetention, LlmConfig, NearAiConfig, OAUTH_PLACEHOLDER, OpenAiCodexConfig,
RegistryProviderConfig,
};
pub use error::LlmError;
pub use failover::{CooldownConfig, FailoverProvider};
pub use nearai_chat::{DEFAULT_MODEL, ModelInfo, NearAiChatProvider, default_models};
pub use openai_codex_provider::OpenAiCodexProvider;
pub use openai_codex_session::{OpenAiCodexSession, OpenAiCodexSessionManager};
pub use provider::{
ChatMessage, CompletionRequest, CompletionResponse, ContentPart, FinishReason, ImageUrl,
LlmProvider, ModelMetadata, Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse,
@@ -59,6 +67,7 @@ pub use retry::{RetryConfig, RetryProvider};
pub use rig_adapter::RigAdapter;
pub use session::{SessionConfig, SessionManager, create_session_manager};
pub use smart_routing::{SmartRoutingConfig, SmartRoutingProvider, TaskComplexity};
pub use token_refreshing::TokenRefreshingProvider;
use std::sync::Arc;
@@ -97,6 +106,15 @@ pub async fn create_llm_provider(
}
}
if config.backend == "openai_codex" {
return Err(LlmError::RequestFailed {
provider: "openai_codex".to_string(),
reason:
"OpenAI Codex uses a dedicated factory path. Use build_provider_chain() instead of create_llm_provider()."
.to_string(),
});
}
let reg_config = config
.provider
.as_ref()
@@ -374,6 +392,47 @@ fn create_ollama_from_registry(
Ok(Arc::new(adapter))
}
/// Create an OpenAI Codex provider with OAuth authentication.
///
/// This is async because it needs to ensure authentication before
/// creating the provider (which requires a valid Bearer token).
///
/// Uses the Responses API (`chatgpt.com/backend-api/codex/responses`)
/// instead of the Chat Completions API, matching OpenClaw's approach.
async fn create_openai_codex_provider(
config: &LlmConfig,
) -> Result<Arc<dyn LlmProvider>, LlmError> {
let codex = config
.openai_codex
.as_ref()
.ok_or_else(|| LlmError::AuthFailed {
provider: "openai_codex".to_string(),
})?;
let session_mgr = Arc::new(OpenAiCodexSessionManager::new(codex.clone())?);
session_mgr.ensure_authenticated().await?;
let token = session_mgr.get_access_token().await?;
let provider = Arc::new(OpenAiCodexProvider::new(
&codex.model,
&codex.api_base_url,
token.expose_secret(),
config.request_timeout_secs,
)?);
tracing::info!(
"Using OpenAI Codex (Responses API, model: {}, base: {})",
codex.model,
codex.api_base_url,
);
Ok(Arc::new(TokenRefreshingProvider::new(
provider,
session_mgr,
)))
}
/// Create a cheap/fast LLM provider for lightweight tasks (heartbeat, routing, evaluation).
///
/// Resolution order:
@@ -460,7 +519,11 @@ pub async fn build_provider_chain(
),
LlmError,
> {
let llm = create_llm_provider(config, session.clone()).await?;
let llm: Arc<dyn LlmProvider> = if config.backend == "openai_codex" {
create_openai_codex_provider(config).await?
} else {
create_llm_provider(config, session.clone()).await?
};
tracing::debug!("LLM provider initialized: {}", llm.model_name());
// 1. Retry
@@ -632,6 +695,7 @@ mod tests {
request_timeout_secs: 120,
cheap_model: None,
smart_routing_cascade: true,
openai_codex: None,
}
}
+1
View File
@@ -347,5 +347,6 @@ pub(crate) fn build_nearai_model_fetch_config() -> crate::config::LlmConfig {
request_timeout_secs: 120,
cheap_model: None,
smart_routing_cascade: false,
openai_codex: None,
}
}
File diff suppressed because it is too large Load Diff
+731
View File
@@ -0,0 +1,731 @@
//! OAuth 2.0 session manager for OpenAI Codex (ChatGPT subscription).
//!
//! Supports two auth flows:
//! - **Device Code** (primary): Works on headless servers, no browser needed.
//! - **Browser PKCE** (fallback): Standard OAuth for local machines.
//!
//! Tokens are persisted to `~/.ironclaw/openai_codex_session.json` and
//! auto-refreshed before expiry.
use chrono::{DateTime, Utc};
use reqwest::Client;
use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT};
use secrecy::SecretString;
use serde::{Deserialize, Serialize};
use tokio::sync::{Mutex, RwLock};
use crate::config::OpenAiCodexConfig;
use crate::error::LlmError;
/// Persisted OAuth session data.
///
/// Note: `Debug` is manually implemented to redact tokens.
#[derive(Serialize, Deserialize)]
pub struct OpenAiCodexSession {
pub(crate) access_token: String,
pub(crate) refresh_token: String,
pub(crate) expires_at: DateTime<Utc>,
pub(crate) created_at: DateTime<Utc>,
}
impl std::fmt::Debug for OpenAiCodexSession {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OpenAiCodexSession")
.field("access_token", &"[REDACTED]")
.field("refresh_token", &"[REDACTED]")
.field("expires_at", &self.expires_at)
.field("created_at", &self.created_at)
.finish()
}
}
/// Request body for the device code usercode endpoint.
#[derive(Debug, Serialize)]
struct UserCodeRequest {
client_id: String,
}
/// Response from the device code usercode endpoint.
#[derive(Debug, Deserialize)]
struct UserCodeResponse {
/// Unique ID for this device auth session.
device_auth_id: String,
/// Code the user enters in their browser.
user_code: String,
/// URL where the user enters the code (may not be present).
#[serde(default = "default_verification_uri")]
verification_uri: String,
/// Polling interval in seconds (OpenAI sends this as a string).
#[serde(
default = "default_interval",
deserialize_with = "deserialize_string_or_u64"
)]
interval: u64,
/// Expiry timestamp (OpenAI sends `expires_at` as ISO-8601).
#[serde(default)]
expires_at: Option<String>,
/// Seconds until the device code expires (standard field, may not be present).
#[serde(default)]
expires_in: Option<u64>,
}
fn default_verification_uri() -> String {
"https://auth.openai.com/codex/device".to_string()
}
fn default_interval() -> u64 {
5
}
/// Deserialize a value that may be either a string or a number as u64.
fn deserialize_string_or_u64<'de, D>(deserializer: D) -> Result<u64, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de;
struct StringOrU64;
impl<'de> de::Visitor<'de> for StringOrU64 {
type Value = u64;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a string or integer")
}
fn visit_u64<E: de::Error>(self, v: u64) -> Result<u64, E> {
Ok(v)
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<u64, E> {
v.parse().map_err(de::Error::custom)
}
}
deserializer.deserialize_any(StringOrU64)
}
impl UserCodeResponse {
/// Get the expiry duration in seconds, from either `expires_in` or `expires_at`.
fn expires_in_secs(&self) -> u64 {
if let Some(secs) = self.expires_in {
return secs;
}
if let Some(ref ts) = self.expires_at
&& let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts)
{
let remaining = dt.signed_duration_since(Utc::now()).num_seconds();
return remaining.max(0) as u64;
}
900 // default 15 minutes
}
}
/// Request body for polling the device auth token endpoint.
#[derive(Debug, Serialize)]
struct DeviceTokenPollRequest {
device_auth_id: String,
user_code: String,
}
/// Successful response from the device auth token endpoint.
/// Returns an authorization code + PKCE pair for the final token exchange.
#[derive(Debug, Deserialize)]
struct DeviceAuthCodeResponse {
authorization_code: String,
#[allow(dead_code)]
code_challenge: String,
code_verifier: String,
}
/// Response from the final OAuth token exchange.
#[derive(Debug, Deserialize)]
struct TokenResponse {
access_token: String,
#[serde(default)]
refresh_token: String,
#[serde(default)]
expires_in: u64,
#[serde(default)]
#[allow(dead_code)]
token_type: String,
}
/// Manages OpenAI Codex OAuth sessions with persistence and auto-refresh.
pub struct OpenAiCodexSessionManager {
config: OpenAiCodexConfig,
client: Client,
session: RwLock<Option<OpenAiCodexSession>>,
renewal_lock: Mutex<()>,
}
impl OpenAiCodexSessionManager {
/// Create a new session manager. Tries to load existing session from disk.
///
/// # Errors
///
/// Returns `LlmError` if the HTTP client cannot be constructed.
pub fn new(config: OpenAiCodexConfig) -> Result<Self, LlmError> {
let mut headers = HeaderMap::new();
headers.insert(
USER_AGENT,
HeaderValue::from_static(concat!("ironclaw/", env!("CARGO_PKG_VERSION"))),
);
let client = Client::builder()
.default_headers(headers)
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| LlmError::RequestFailed {
provider: "openai_codex".into(),
reason: format!("HTTP client build failed: {e}"),
})?;
let mgr = Self {
config,
client,
session: RwLock::new(None),
renewal_lock: Mutex::new(()),
};
// Try synchronous load from disk during construction
if let Ok(data) = std::fs::read_to_string(&mgr.config.session_path)
&& let Ok(session) = serde_json::from_str::<OpenAiCodexSession>(&data)
&& let Ok(mut guard) = mgr.session.try_write()
{
*guard = Some(session);
tracing::info!(
"Loaded OpenAI Codex session from {}",
mgr.config.session_path.display()
);
}
Ok(mgr)
}
/// Check if we have a session (may be expired).
pub async fn has_session(&self) -> bool {
self.session.read().await.is_some()
}
/// Check if the current access token needs refreshing.
pub async fn needs_refresh(&self) -> bool {
let guard = self.session.read().await;
match guard.as_ref() {
None => true,
Some(s) => {
let margin =
chrono::Duration::seconds(self.config.token_refresh_margin_secs as i64);
Utc::now() + margin >= s.expires_at
}
}
}
/// Get the current access token, refreshing if needed.
///
/// If the token is within the refresh margin, silently refreshes first.
/// If no session exists, returns an AuthFailed error.
pub async fn get_access_token(&self) -> Result<SecretString, LlmError> {
if self.needs_refresh().await {
let has_refresh = self
.session
.read()
.await
.as_ref()
.map(|s| !s.refresh_token.is_empty())
.unwrap_or(false);
if has_refresh {
self.refresh_tokens().await?;
} else {
return Err(LlmError::AuthFailed {
provider: "openai_codex".to_string(),
});
}
}
let guard = self.session.read().await;
guard
.as_ref()
.map(|s| SecretString::from(s.access_token.clone()))
.ok_or_else(|| LlmError::AuthFailed {
provider: "openai_codex".to_string(),
})
}
/// Ensure we have a valid session. Loads from disk, refreshes, or prompts login.
pub async fn ensure_authenticated(&self) -> Result<(), LlmError> {
// Try loading from disk if we don't have a session
if !self.has_session().await {
let _ = self.load_session().await;
}
if !self.has_session().await {
// No session at all -- need to authenticate
return self.device_code_login().await;
}
if self.needs_refresh().await {
// Try refresh; if it fails, re-authenticate
match self.refresh_tokens().await {
Ok(()) => Ok(()),
Err(e) => {
tracing::info!("Token refresh failed ({}), re-authenticating...", e);
self.device_code_login().await
}
}
} else {
Ok(())
}
}
/// Run OpenAI's device code auth flow.
///
/// Uses OpenAI's custom `/api/accounts/deviceauth/*` endpoints (not the standard
/// Auth0 `/oauth/device/code` which is behind Cloudflare managed challenge).
///
/// Flow:
/// 1. POST `/api/accounts/deviceauth/usercode` → get device_auth_id + user_code
/// 2. Poll POST `/api/accounts/deviceauth/token` → get authorization_code + PKCE
/// 3. Exchange via POST `/oauth/token` → get access_token + refresh_token
pub async fn device_code_login(&self) -> Result<(), LlmError> {
let _guard = self.renewal_lock.lock().await;
let auth_base = format!("{}/api/accounts", self.config.auth_endpoint);
// Step 1: Request device code
let usercode_url = format!("{}/deviceauth/usercode", auth_base);
let resp = self
.client
.post(&usercode_url)
.json(&UserCodeRequest {
client_id: self.config.client_id.clone(),
})
.send()
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Device code request failed: {}", e),
})?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Device code request failed: HTTP {} -- {}", status, body),
});
}
let body_text = resp
.text()
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Failed to read device code response: {}", e),
})?;
tracing::debug!("Device code response received ({} bytes)", body_text.len());
let device: UserCodeResponse =
serde_json::from_str(&body_text).map_err(|e| LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!(
"Failed to parse device code response: {} ({} bytes)",
e,
body_text.len()
),
})?;
// Step 2: Display code to user
println!();
println!("===========================================================");
println!(" OpenAI Codex Authentication ");
println!("===========================================================");
println!();
println!(" 1. Open this URL in any browser:");
println!(" {}", device.verification_uri);
println!();
println!(" 2. Enter this code:");
println!();
println!(" [ {} ]", device.user_code);
println!();
let expires_secs = device.expires_in_secs();
println!(
" Waiting for authorization... (expires in {} min)",
expires_secs / 60
);
println!("===========================================================");
println!();
// Step 3: Poll for authorization code
let poll_url = format!("{}/deviceauth/token", auth_base);
let mut interval = std::time::Duration::from_secs(device.interval.max(5));
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(expires_secs);
let auth_code = loop {
tokio::time::sleep(interval).await;
if tokio::time::Instant::now() >= deadline {
return Err(LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: "Device code authorization timed out".to_string(),
});
}
let resp = self
.client
.post(&poll_url)
.json(&DeviceTokenPollRequest {
device_auth_id: device.device_auth_id.clone(),
user_code: device.user_code.clone(),
})
.send()
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Token poll request failed: {}", e),
})?;
let status = resp.status();
if status.is_success() {
let code_resp: DeviceAuthCodeResponse =
resp.json()
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Failed to parse auth code response: {}", e),
})?;
break code_resp;
}
// 403 = authorization_pending, keep polling
// 404 = device code not found / not enabled
if status == reqwest::StatusCode::FORBIDDEN {
continue;
}
if status == reqwest::StatusCode::NOT_FOUND {
return Err(LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: "Device code login is not enabled. Please check your OpenAI account settings.".to_string(),
});
}
// Slow down on 429, cap at 60s to avoid unbounded growth
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
interval = (interval + std::time::Duration::from_secs(5))
.min(std::time::Duration::from_secs(60));
continue;
}
let body = resp.text().await.unwrap_or_default();
return Err(LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Device auth poll failed: HTTP {} -- {}", status, body),
});
};
// Step 4: Exchange authorization code for tokens (form-encoded, per Auth0 spec)
let token_url = format!("{}/oauth/token", self.config.auth_endpoint);
let resp = self
.client
.post(&token_url)
.form(&[
("grant_type", "authorization_code"),
("code", &auth_code.authorization_code),
("code_verifier", &auth_code.code_verifier),
("client_id", &self.config.client_id),
(
"redirect_uri",
&format!("{}/deviceauth/callback", self.config.auth_endpoint),
),
])
.send()
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Token exchange failed: {}", e),
})?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Token exchange failed: HTTP {} -- {}", status, body),
});
}
let token_resp: TokenResponse =
resp.json()
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Failed to parse token response: {}", e),
})?;
let session = OpenAiCodexSession {
access_token: token_resp.access_token,
refresh_token: token_resp.refresh_token,
expires_at: Utc::now()
+ chrono::Duration::seconds(if token_resp.expires_in > 0 {
token_resp.expires_in
} else {
tracing::warn!("Token response has expires_in=0, defaulting to 3600s");
3600
} as i64),
created_at: Utc::now(),
};
self.save_session(&session).await?;
self.set_session(session).await;
println!();
println!("Authentication successful!");
println!();
Ok(())
}
/// Refresh the access token using the refresh token.
pub async fn refresh_tokens(&self) -> Result<(), LlmError> {
let _guard = self.renewal_lock.lock().await;
// Double-check: another task may have refreshed while we waited on the lock
if !self.needs_refresh().await {
return Ok(());
}
let refresh_token = {
let guard = self.session.read().await;
guard
.as_ref()
.map(|s| s.refresh_token.clone())
.ok_or_else(|| LlmError::AuthFailed {
provider: "openai_codex".to_string(),
})?
};
let token_url = format!("{}/oauth/token", self.config.auth_endpoint);
let resp = self
.client
.post(&token_url)
.form(&[
("grant_type", "refresh_token"),
("refresh_token", refresh_token.as_str()),
("client_id", self.config.client_id.as_str()),
])
.send()
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Token refresh request failed: {}", e),
})?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Token refresh failed: HTTP {} -- {}", status, body),
});
}
let token_resp: TokenResponse =
resp.json()
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Failed to parse refresh response: {}", e),
})?;
let session = OpenAiCodexSession {
access_token: token_resp.access_token,
refresh_token: token_resp.refresh_token,
expires_at: Utc::now()
+ chrono::Duration::seconds(if token_resp.expires_in > 0 {
token_resp.expires_in
} else {
tracing::warn!("Token response has expires_in=0, defaulting to 3600s");
3600
} as i64),
created_at: Utc::now(),
};
self.save_session(&session).await?;
self.set_session(session).await;
tracing::debug!("OpenAI Codex token refreshed successfully");
Ok(())
}
/// Save session data to disk with restrictive permissions.
pub async fn save_session(&self, session: &OpenAiCodexSession) -> Result<(), LlmError> {
if let Some(parent) = self.config.session_path.parent() {
tokio::fs::create_dir_all(parent).await.map_err(|e| {
LlmError::Io(std::io::Error::new(
e.kind(),
format!("Failed to create session directory: {}", e),
))
})?;
}
let json =
serde_json::to_string_pretty(session).map_err(|e| LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Failed to serialize session: {}", e),
})?;
tokio::fs::write(&self.config.session_path, &json)
.await
.map_err(|e| {
LlmError::Io(std::io::Error::new(
e.kind(),
format!("Failed to write session file: {}", e),
))
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
tokio::fs::set_permissions(&self.config.session_path, perms)
.await
.map_err(|e| {
LlmError::Io(std::io::Error::new(
e.kind(),
format!("Failed to set permissions: {}", e),
))
})?;
}
Ok(())
}
/// Load session from disk.
pub async fn load_session(&self) -> Result<(), LlmError> {
let data = tokio::fs::read_to_string(&self.config.session_path)
.await
.map_err(|e| {
LlmError::Io(std::io::Error::new(
e.kind(),
format!("Failed to read session file: {}", e),
))
})?;
let session: OpenAiCodexSession =
serde_json::from_str(&data).map_err(|e| LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Failed to parse session file: {}", e),
})?;
let mut guard = self.session.write().await;
*guard = Some(session);
tracing::info!(
"Loaded OpenAI Codex session from {}",
self.config.session_path.display()
);
Ok(())
}
/// Set session directly (for testing or after auth).
pub async fn set_session(&self, session: OpenAiCodexSession) {
let mut guard = self.session.write().await;
*guard = Some(session);
}
/// Handle a 401 response by refreshing, or re-authenticating.
pub async fn handle_auth_failure(&self) -> Result<(), LlmError> {
match self.refresh_tokens().await {
Ok(()) => Ok(()),
Err(_) => self.device_code_login().await,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm::codex_test_helpers::test_codex_config as test_config;
use tempfile::tempdir;
#[tokio::test]
async fn test_save_and_load_session() {
let dir = tempdir().unwrap();
let path = dir.path().join("session.json");
let config = test_config(path.clone());
let mgr = OpenAiCodexSessionManager::new(config).unwrap();
// No session initially
assert!(!mgr.has_session().await);
// Save a session
let session = OpenAiCodexSession {
access_token: "access_abc".to_string(),
refresh_token: "refresh_xyz".to_string(),
expires_at: chrono::Utc::now() + chrono::Duration::hours(1),
created_at: chrono::Utc::now(),
};
mgr.save_session(&session).await.unwrap();
mgr.set_session(session).await;
assert!(mgr.has_session().await);
// Load from disk in a new manager
let config2 = test_config(path);
let mgr2 = OpenAiCodexSessionManager::new(config2).unwrap();
mgr2.load_session().await.unwrap();
assert!(mgr2.has_session().await);
}
#[tokio::test]
async fn test_needs_refresh_when_near_expiry() {
let dir = tempdir().unwrap();
let config = test_config(dir.path().join("session.json"));
let mgr = OpenAiCodexSessionManager::new(config).unwrap();
// Token expiring in 2 minutes (margin is 300s = 5 min)
let session = OpenAiCodexSession {
access_token: "access_abc".to_string(),
refresh_token: "refresh_xyz".to_string(),
expires_at: chrono::Utc::now() + chrono::Duration::minutes(2),
created_at: chrono::Utc::now(),
};
mgr.set_session(session).await;
assert!(mgr.needs_refresh().await);
}
#[test]
fn device_code_parse_error_redacts_body() {
// Regression: the parse error used to include raw body_text which could
// contain sensitive auth data. Now it only shows byte count.
let body_text = r#"{"secret_token":"sk-12345","error":"unexpected"}"#;
let err: Result<UserCodeResponse, _> = serde_json::from_str(body_text);
assert!(err.is_err());
let e = err.unwrap_err();
let error_msg = format!(
"Failed to parse device code response: {} ({} bytes)",
e,
body_text.len()
);
assert!(
!error_msg.contains("sk-12345"),
"error message must not contain raw body: {error_msg}"
);
assert!(
error_msg.contains("bytes"),
"error message should show byte count"
);
}
#[tokio::test]
async fn test_no_refresh_when_fresh() {
let dir = tempdir().unwrap();
let config = test_config(dir.path().join("session.json"));
let mgr = OpenAiCodexSessionManager::new(config).unwrap();
// Token expiring in 30 minutes (margin is 300s = 5 min)
let session = OpenAiCodexSession {
access_token: "access_abc".to_string(),
refresh_token: "refresh_xyz".to_string(),
expires_at: chrono::Utc::now() + chrono::Duration::minutes(30),
created_at: chrono::Utc::now(),
};
mgr.set_session(session).await;
assert!(!mgr.needs_refresh().await);
}
}
+1 -1
View File
@@ -132,7 +132,7 @@ fn round_f32_to_f64(val: f32) -> f64 {
///
/// This is applied as a clone-and-transform at the provider boundary so the
/// original tool definitions remain unchanged for other providers.
fn normalize_schema_strict(schema: &JsonValue) -> JsonValue {
pub(crate) fn normalize_schema_strict(schema: &JsonValue) -> JsonValue {
let mut schema = schema.clone();
normalize_schema_recursive(&mut schema);
schema
+191
View File
@@ -0,0 +1,191 @@
//! Token-refreshing LlmProvider decorator for OpenAI Codex.
//!
//! Wraps an `OpenAiCodexProvider` and:
//! - Pre-emptively refreshes the OAuth access token before each call if near expiry
//! - Updates the inner provider's token after refresh (no client rebuild needed)
//! - Retries once on `AuthFailed` / `SessionExpired` after refreshing
//! - Overrides `cost_per_token()` to return (0, 0) since billing is through subscription
use std::sync::Arc;
use async_trait::async_trait;
use rust_decimal::Decimal;
use secrecy::ExposeSecret;
use crate::error::LlmError;
use crate::llm::openai_codex_provider::OpenAiCodexProvider;
use crate::llm::openai_codex_session::OpenAiCodexSessionManager;
use crate::llm::provider::{
CompletionRequest, CompletionResponse, LlmProvider, ModelMetadata, ToolCompletionRequest,
ToolCompletionResponse,
};
/// Decorator that refreshes OAuth tokens before API calls and reports zero cost.
///
/// The inner `OpenAiCodexProvider` manages its own token state, so after a
/// refresh we just call `update_token()` -- no client rebuild is needed.
pub struct TokenRefreshingProvider {
inner: Arc<OpenAiCodexProvider>,
session: Arc<OpenAiCodexSessionManager>,
}
impl TokenRefreshingProvider {
pub fn new(inner: Arc<OpenAiCodexProvider>, session: Arc<OpenAiCodexSessionManager>) -> Self {
Self { inner, session }
}
/// Push a fresh token from the session manager into the inner provider.
async fn update_inner_token(&self) -> Result<(), LlmError> {
let token = self.session.get_access_token().await?;
self.inner.update_token(token.expose_secret()).await?;
tracing::debug!("Updated inner provider token after refresh");
Ok(())
}
/// Best-effort pre-emptive token refresh before an API call.
///
/// If refresh fails (e.g., no refresh token), we log and continue so the
/// actual request still fires and the retry-on-auth-failure path can kick in.
async fn ensure_fresh_token(&self) {
if self.session.needs_refresh().await {
match self.session.refresh_tokens().await {
Ok(()) => {
if let Err(e) = self.update_inner_token().await {
tracing::warn!(
"Pre-emptive token update failed: {e}, will retry on auth failure"
);
}
}
Err(e) => {
tracing::warn!(
"Pre-emptive token refresh failed: {e}, will retry on auth failure"
);
}
}
}
}
}
#[async_trait]
impl LlmProvider for TokenRefreshingProvider {
fn model_name(&self) -> &str {
self.inner.model_name()
}
fn cost_per_token(&self) -> (Decimal, Decimal) {
(Decimal::ZERO, Decimal::ZERO)
}
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
self.ensure_fresh_token().await;
match self.inner.complete(request.clone()).await {
Err(LlmError::AuthFailed { .. } | LlmError::SessionExpired { .. }) => {
tracing::info!("Auth failure during complete(), refreshing and retrying once");
self.session.handle_auth_failure().await?;
self.update_inner_token().await?;
self.inner.complete(request).await
}
other => other,
}
}
async fn complete_with_tools(
&self,
request: ToolCompletionRequest,
) -> Result<ToolCompletionResponse, LlmError> {
self.ensure_fresh_token().await;
match self.inner.complete_with_tools(request.clone()).await {
Err(LlmError::AuthFailed { .. } | LlmError::SessionExpired { .. }) => {
tracing::info!(
"Auth failure during complete_with_tools(), refreshing and retrying once"
);
self.session.handle_auth_failure().await?;
self.update_inner_token().await?;
self.inner.complete_with_tools(request).await
}
other => other,
}
}
async fn list_models(&self) -> Result<Vec<String>, LlmError> {
self.ensure_fresh_token().await;
self.inner.list_models().await
}
async fn model_metadata(&self) -> Result<ModelMetadata, LlmError> {
self.ensure_fresh_token().await;
self.inner.model_metadata().await
}
fn active_model_name(&self) -> String {
self.inner.model_name().to_string()
}
fn effective_model_name(&self, requested_model: Option<&str>) -> String {
self.inner.effective_model_name(requested_model)
}
fn set_model(&self, model: &str) -> Result<(), LlmError> {
self.inner.set_model(model)
}
fn calculate_cost(&self, _input_tokens: u32, _output_tokens: u32) -> Decimal {
Decimal::ZERO
}
fn cache_write_multiplier(&self) -> Decimal {
self.inner.cache_write_multiplier()
}
fn cache_read_discount(&self) -> Decimal {
self.inner.cache_read_discount()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm::codex_test_helpers::{make_test_jwt, test_codex_config};
use crate::llm::openai_codex_session::OpenAiCodexSessionManager;
use tempfile::tempdir;
fn make_provider_and_session() -> (TokenRefreshingProvider, tempfile::TempDir) {
let dir = tempdir().unwrap();
let config = test_codex_config(dir.path().join("session.json"));
let jwt = make_test_jwt("acct_test");
let inner = Arc::new(
OpenAiCodexProvider::new(&config.model, &config.api_base_url, &jwt, 300)
.expect("provider creation should succeed"),
);
let session = Arc::new(OpenAiCodexSessionManager::new(config).unwrap());
(TokenRefreshingProvider::new(inner, session), dir)
}
#[test]
fn test_model_name_delegates() {
let (provider, _dir) = make_provider_and_session();
assert_eq!(provider.model_name(), "gpt-5.3-codex");
}
#[test]
fn test_cost_per_token_zero() {
let (provider, _dir) = make_provider_and_session();
let (input, output) = provider.cost_per_token();
assert_eq!(input, Decimal::ZERO);
assert_eq!(output, Decimal::ZERO);
}
#[test]
fn test_calculate_cost_zero() {
let (provider, _dir) = make_provider_and_session();
assert_eq!(provider.calculate_cost(1000, 500), Decimal::ZERO);
}
#[test]
fn test_active_model_name_delegates() {
let (provider, _dir) = make_provider_and_session();
assert_eq!(provider.active_model_name(), "gpt-5.3-codex");
}
}
+41
View File
@@ -139,6 +139,47 @@ async fn async_main() -> anyhow::Result<()> {
)
.await;
}
Some(Command::Login { openai_codex }) => {
init_cli_tracing();
if *openai_codex {
// Resolve codex config so OPENAI_CODEX_* env overrides are
// honoured even when LLM_BACKEND isn't set to openai_codex.
let codex_config = {
let config = Config::from_env()
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
config.llm.openai_codex.unwrap_or_else(|| {
use ironclaw::llm::OpenAiCodexConfig;
let mut cfg = OpenAiCodexConfig::default();
if let Ok(v) = std::env::var("OPENAI_CODEX_AUTH_URL") {
cfg.auth_endpoint = v;
}
if let Ok(v) = std::env::var("OPENAI_CODEX_API_URL") {
cfg.api_base_url = v;
}
if let Ok(v) = std::env::var("OPENAI_CODEX_CLIENT_ID") {
cfg.client_id = v;
}
if let Ok(v) = std::env::var("OPENAI_CODEX_SESSION_PATH") {
cfg.session_path = std::path::PathBuf::from(v);
}
cfg
})
};
let mgr = ironclaw::llm::OpenAiCodexSessionManager::new(codex_config)
.map_err(|e| anyhow::anyhow!("{}", e))?;
mgr.device_code_login()
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
println!(
"OpenAI Codex authentication complete. Set LLM_BACKEND=openai_codex to use it."
);
} else {
println!("Specify a provider to authenticate with:");
println!(" ironclaw login --openai-codex (ChatGPT subscription)");
}
return Ok(());
}
Some(Command::Onboard {
skip_auth,
channels_only,
+41 -4
View File
@@ -3,7 +3,7 @@
//! The wizard guides users through:
//! 1. Database connection
//! 2. Security (secrets master key)
//! 3. Inference provider (NEAR AI, Anthropic, OpenAI, Ollama, OpenAI-compatible)
//! 3. Inference provider (NEAR AI, Anthropic, OpenAI, OpenAI Codex, Ollama, OpenAI-compatible)
//! 4. Model selection
//! 5. Embeddings
//! 6. Channel configuration
@@ -1083,8 +1083,10 @@ impl SetupWizard {
print_info(&format!("Current provider: {}", display));
println!();
let is_known =
current == "nearai" || current == "bedrock" || registry.is_known(&current);
let is_known = current == "nearai"
|| current == "bedrock"
|| current == "openai_codex"
|| registry.is_known(&current);
if is_known && confirm("Keep current provider?", true).map_err(SetupError::Io)? {
if current == "bedrock" {
@@ -1093,6 +1095,10 @@ impl SetupWizard {
print_info("Keeping existing AWS Bedrock configuration.");
return Ok(());
}
if current == "openai_codex" {
print_info("Keeping existing OpenAI Codex configuration.");
return Ok(());
}
return self.run_provider_setup(&current, &registry).await;
}
@@ -1107,7 +1113,7 @@ impl SetupWizard {
print_info("Select your inference provider:");
println!();
// Build menu: NearAI first, then all registry providers with setup hints, then Bedrock
// Build menu: NearAI first, then OpenAI Codex, then registry providers, then Bedrock
let selectable = registry.selectable();
let mut options: Vec<String> = Vec::with_capacity(2 + selectable.len());
let mut provider_ids: Vec<String> = Vec::with_capacity(2 + selectable.len());
@@ -1115,6 +1121,9 @@ impl SetupWizard {
options.push("NEAR AI - multi-model access via NEAR account".to_string());
provider_ids.push("nearai".to_string());
options.push("OpenAI Codex - ChatGPT subscription (Plus/Pro/Max)".to_string());
provider_ids.push("openai_codex".to_string());
for def in &selectable {
let label = format!(
"{:<17}- {}",
@@ -1158,6 +1167,10 @@ impl SetupWizard {
return self.setup_nearai().await;
}
if provider_id == "openai_codex" {
return self.setup_openai_codex().await;
}
let def = registry
.find(provider_id)
.ok_or_else(|| SetupError::Config(format!("Unknown provider: {}", provider_id)))?;
@@ -1490,6 +1503,29 @@ impl SetupWizard {
Ok(())
}
/// OpenAI Codex (ChatGPT subscription) setup: device code OAuth flow.
async fn setup_openai_codex(&mut self) -> Result<(), SetupError> {
self.settings.llm_backend = Some("openai_codex".to_string());
if self.settings.selected_model.is_some() {
self.settings.selected_model = None;
}
use crate::config::OpenAiCodexConfig;
use crate::llm::OpenAiCodexSessionManager;
let config = OpenAiCodexConfig::default();
let mgr = OpenAiCodexSessionManager::new(config).map_err(|e| {
SetupError::Config(format!("OpenAI Codex session manager init failed: {}", e))
})?;
mgr.device_code_login().await.map_err(|e| {
SetupError::Config(format!("OpenAI Codex authentication failed: {}", e))
})?;
print_success("OpenAI Codex configured (ChatGPT subscription)");
Ok(())
}
/// Generic Ollama-style setup: just needs a base URL, no API key.
fn setup_ollama_generic(
&mut self,
@@ -2963,6 +2999,7 @@ impl SetupWizard {
"ollama" => "Ollama",
"openai_compatible" => "OpenAI-compatible",
"bedrock" => "AWS Bedrock",
"openai_codex" => "OpenAI Codex",
other => other,
};
println!(" Provider: {}", display);