From 855b46dd085597a7561fcb239cc1fbae80c0f36b Mon Sep 17 00:00:00 2001 From: chadbyte Date: Wed, 1 Jul 2026 12:14:04 +1200 Subject: [PATCH 1/5] fix(auth): detect Codex logged-out state and trigger login modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex doesn't emit a Claude-style "not logged in" message; when its token is revoked the app-server sends an unauthorized/token-revoked error event, which fell through as an unhandled runtime_specific event — so the login modal never opened. Map that error to a neutral auth_required yokeType in the Codex adapter, and handle auth_required in the message processor by emitting the same login flow (WS message + notification) used for Claude. Extract the auth-required emission into a shared, per-session-deduped helper so both vendors go through one path. Codex-specific error detection stays in the adapter; the relay only sees the neutral yokeType. --- lib/sdk-message-processor.js | 87 ++++++++++++++++++++++-------------- lib/yoke/adapters/codex.js | 16 +++++++ 2 files changed, 69 insertions(+), 34 deletions(-) diff --git a/lib/sdk-message-processor.js b/lib/sdk-message-processor.js index 5753ddef..76236bb5 100644 --- a/lib/sdk-message-processor.js +++ b/lib/sdk-message-processor.js @@ -36,6 +36,50 @@ function attachMessageProcessor(ctx) { sm.sendToSession(session, obj); } + // Emit the "not logged in" signal (WS message + notification) so the client + // auto-opens the login modal. Shared by the Claude path (login-prompt text + // detection) and the Codex path (app-server unauthorized error). Deduped + // per session to avoid spamming when multiple auth errors arrive in a burst. + function emitAuthRequired(session) { + var now = Date.now(); + if (session._lastAuthEmit && (now - session._lastAuthEmit) < 5000) return; + session._lastAuthEmit = now; + + var authUser = session.ownerId ? usersModule.findUserById(session.ownerId) : null; + var authLinuxUser = authUser && authUser.linuxUser ? authUser.linuxUser : null; + var canAutoLogin = !usersModule.isMultiUser() + || !!authLinuxUser + || (authUser && authUser.role === "admin"); + var authTitle = session.vendor === "codex" ? "Codex is not logged in." : "Claude Code is not logged in."; + var loginCommand = session.vendor === "codex" + ? "codex login --device-auth" + : "claude login"; + var _nmLogin = getNotificationsModule(); + sendAndRecord(session, { + type: "auth_required", + text: authTitle, + vendor: session.vendor || "claude", + loginCommand: loginCommand, + linuxUser: authLinuxUser, + canAutoLogin: canAutoLogin, + }); + if (_nmLogin) { + _nmLogin.notify("auth_required", { + title: authTitle, + body: "Open a terminal, then click the URL and follow the instructions.", + slug: slug, + sessionId: session.localId, + ownerId: session.ownerId || null, + vendor: session.vendor || "claude", + loginCommand: loginCommand, + linuxUser: authLinuxUser, + canAutoLogin: canAutoLogin, + }); + } + // Reset CLI session so the next query starts fresh with new auth. + session.cliSessionId = null; + } + function getModelsForVendor(vendor) { if (vendor && sm.modelsByVendor && sm.modelsByVendor[vendor]) return sm.modelsByVendor[vendor]; return sm.availableModels || []; @@ -447,40 +491,7 @@ function attachMessageProcessor(ctx) { // Detect "Not logged in / Please run /login" from SDK. // This is a short canned response with zero cost, not actual AI output. if (isLoginPrompt) { - var authUser = session.ownerId ? usersModule.findUserById(session.ownerId) : null; - var authLinuxUser = authUser && authUser.linuxUser ? authUser.linuxUser : null; - var canAutoLogin = !usersModule.isMultiUser() - || !!authLinuxUser - || (authUser && authUser.role === "admin"); - var authTitle = session.vendor === "codex" ? "Codex is not logged in." : "Claude Code is not logged in."; - var loginCommand = session.vendor === "codex" - ? "codex login --device-auth" - : "claude login"; - var _nmLogin = getNotificationsModule(); - var authMsg = { - type: "auth_required", - text: authTitle, - vendor: session.vendor || "claude", - loginCommand: loginCommand, - linuxUser: authLinuxUser, - canAutoLogin: canAutoLogin, - }; - sendAndRecord(session, authMsg); - if (_nmLogin) { - _nmLogin.notify("auth_required", { - title: authTitle, - body: "Open a terminal, then click the URL and follow the instructions.", - slug: slug, - sessionId: session.localId, - ownerId: session.ownerId || null, - vendor: session.vendor || "claude", - loginCommand: loginCommand, - linuxUser: authLinuxUser, - canAutoLogin: canAutoLogin, - }); - } - // Reset CLI session so next query starts fresh with new auth - session.cliSessionId = null; + emitAuthRequired(session); } sendAndRecord(session, { type: "done", code: 0 }); var _donePreviewText = (session.responsePreview || "").replace(/\s+/g, " ").trim(); @@ -751,6 +762,14 @@ function attachMessageProcessor(ctx) { message: parsed.message || "", }); + } else if (parsed.yokeType === "auth_required") { + // Vendor adapter signalled the session isn't authenticated (e.g. Codex + // app-server returned an unauthorized/token-revoked error). Trigger the + // same login flow as the Claude login-prompt path. + session.isProcessing = false; + onProcessingChanged(); + emitAuthRequired(session); + } else if (parsed.yokeType === "model_refusal") { // Model declined the request. "fallback" => the CLI retried on another // model; "no_fallback" => the turn ended with a refusal. diff --git a/lib/yoke/adapters/codex.js b/lib/yoke/adapters/codex.js index 1e551054..2d41d993 100644 --- a/lib/yoke/adapters/codex.js +++ b/lib/yoke/adapters/codex.js @@ -551,6 +551,22 @@ function flattenEvent(notification, state) { return events; } + // Top-level error event. Codex signals "not logged in" via an unauthorized / + // token-revoked error (not a login-prompt message like Claude), so map it to + // the neutral auth_required yokeType to drive the login flow. + if (method === "error" && params && params.error) { + var cErr = params.error; + var cErrMsg = cErr.message || "Codex error"; + var isAuthErr = cErr.codexErrorInfo === "unauthorized" + || /sign in again|unauthorized|token[_ ]?revoked|invalidated oauth|\b401\b/i.test(cErrMsg); + if (isAuthErr) { + events.push({ yokeType: "auth_required", vendor: "codex" }); + return events; + } + events.push({ yokeType: "error", text: cErrMsg }); + return events; + } + // Unknown event type - pass through console.log("[yoke/codex] UNHANDLED event:", method, JSON.stringify(params).substring(0, 200)); events.push({ From 7ce877ba64077381d5c1fd9e6414c248e5fd0afb Mon Sep 17 00:00:00 2001 From: chadbyte Date: Wed, 1 Jul 2026 14:01:15 +1200 Subject: [PATCH 2/5] fix(codex): list models without a live app-server init A new Codex session showed a Claude model in the picker: get_vendor_models awaited codex init(), and when the app-server `initialize` handshake timed out, model listing threw and the picker stayed on the previous vendor's model. Codex models are a fixed list, so decouple them from init: hoist the list to a CODEX_MODELS constant that supportedModels() returns directly, and in get_vendor_models don't let a failed init() skip supportedModels(). --- lib/project.js | 21 ++++++++++++++------- lib/yoke/adapters/codex.js | 28 +++++++++++++++++----------- 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/lib/project.js b/lib/project.js index ecb35b45..51ab83bd 100644 --- a/lib/project.js +++ b/lib/project.js @@ -907,13 +907,20 @@ function createProjectContext(opts) { slug: slug, }); } else if ((!sm.modelsByVendor || !sm.modelsByVendor[msg.vendor]) && typeof vendorAdapter.init === "function") { - await vendorAdapter.init({ - cwd: cwd, - clayPort: serverPort, - clayTls: serverTls, - clayAuthToken: serverAuthToken, - slug: slug, - }); + // Init warms the adapter, but a slow/failed init must not block + // model listing (e.g. Codex models are a fixed list). Keep going + // to supportedModels() even if init throws. + try { + await vendorAdapter.init({ + cwd: cwd, + clayPort: serverPort, + clayTls: serverTls, + clayAuthToken: serverAuthToken, + slug: slug, + }); + } catch (e) { + console.error("[project] " + msg.vendor + " init failed (continuing to model list):", e.message || e); + } } if (vendorAdapter) { sm.availableVendors = Object.keys(adapters); diff --git a/lib/yoke/adapters/codex.js b/lib/yoke/adapters/codex.js index 2d41d993..115cdbfa 100644 --- a/lib/yoke/adapters/codex.js +++ b/lib/yoke/adapters/codex.js @@ -1063,7 +1063,19 @@ function createCodexAdapter(opts) { var _cwd = (opts && opts.cwd) || process.cwd(); var _slug = (opts && opts.slug) || ""; var _defaultInitOpts = Object.assign({}, opts || {}); - var _cachedModels = []; + // Codex models are a fixed list (the app-server doesn't enumerate them), so + // model listing must not depend on a successful app-server init — otherwise a + // slow/failed `initialize` leaves the picker empty and the chip shows the + // previous vendor's model. + var CODEX_MODELS = [ + "gpt-5.5", + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.3-codex", + "gpt-5.3-codex-spark", + "gpt-5.2", + ]; + var _cachedModels = CODEX_MODELS.slice(); var _appServer = null; var _initPromise = null; var _shutdownPromise = null; @@ -1313,16 +1325,9 @@ function createCodexAdapter(opts) { throw createShutdownError(); } - console.log("[codex] App-server initialized, models: gpt-5.5, gpt-5.4, gpt-5.4-mini, gpt-5.3-codex, gpt-5.3-codex-spark, gpt-5.2"); + console.log("[codex] App-server initialized, models: " + CODEX_MODELS.join(", ")); - _cachedModels = [ - "gpt-5.5", - "gpt-5.4", - "gpt-5.4-mini", - "gpt-5.3-codex", - "gpt-5.3-codex-spark", - "gpt-5.2", - ]; + _cachedModels = CODEX_MODELS.slice(); // Discover skills: built-in Codex skills + Claude skills var skillNames = []; @@ -1377,7 +1382,8 @@ function createCodexAdapter(opts) { }, supportedModels: function() { - return Promise.resolve(_cachedModels.slice()); + // Fixed list; return it without requiring a live app-server init. + return Promise.resolve(CODEX_MODELS.slice()); }, createToolServer: function(def) { From 403845177f53d22069946ad8f87aa123f8be0b0d Mon Sep 17 00:00:00 2001 From: chadbyte Date: Wed, 1 Jul 2026 14:12:26 +1200 Subject: [PATCH 3/5] fix(codex): detect auth failures across all error paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The logged-out modal only fired for the clean unauthorized error event. When Codex retries the responses websocket it instead surfaces the 401 as turn/failed and item errors ("401 Unauthorized", "Missing bearer", "sign in again"), which slipped through as generic errors — no login modal. Add a shared isCodexAuthError() and apply it at every error emit point (turn/failed, item error, top-level error, run-loop catch), mapping auth failures to the neutral auth_required yokeType. The processor already dedupes auth_required per session, so repeated 401 retries collapse to a single login modal. --- lib/yoke/adapters/codex.js | 36 +++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/lib/yoke/adapters/codex.js b/lib/yoke/adapters/codex.js index 115cdbfa..d46b6f19 100644 --- a/lib/yoke/adapters/codex.js +++ b/lib/yoke/adapters/codex.js @@ -145,6 +145,16 @@ function normalizePlanStatus(status) { return "pending"; } +// Detect Codex "not logged in" errors. Codex surfaces auth failures several +// ways depending on transport: a clean error event with +// codexErrorInfo:"unauthorized", or a turn/failed / item error whose message +// carries a 401 / token-revoked / missing-bearer / "sign in again" string. +// Callers map a match to the neutral auth_required yokeType. +function isCodexAuthError(text, errObj) { + if (errObj && errObj.codexErrorInfo === "unauthorized") return true; + return /sign in again|token[_ ]?revoked|invalidated oauth|missing bearer|unauthorized|\b401\b/i.test(String(text || "")); +} + function extractPromptSuggestion(params) { if (!params) return ""; if (typeof params.suggestion === "string") return params.suggestion; @@ -226,9 +236,14 @@ function flattenEvent(notification, state) { } if (method === "turn/failed") { + var tfMsg = params.error ? params.error.message : "Turn failed"; + if (isCodexAuthError(tfMsg, params.error)) { + events.push({ yokeType: "auth_required", vendor: "codex" }); + return events; + } events.push({ yokeType: "error", - text: params.error ? params.error.message : "Turn failed", + text: tfMsg, }); return events; } @@ -525,9 +540,14 @@ function flattenEvent(notification, state) { // Error item if (item.type === "error") { + var ieMsg = item.message || "Unknown error"; + if (isCodexAuthError(ieMsg, item)) { + events.push({ yokeType: "auth_required", vendor: "codex" }); + return events; + } events.push({ yokeType: "error", - text: item.message || "Unknown error", + text: ieMsg, }); return events; } @@ -557,9 +577,7 @@ function flattenEvent(notification, state) { if (method === "error" && params && params.error) { var cErr = params.error; var cErrMsg = cErr.message || "Codex error"; - var isAuthErr = cErr.codexErrorInfo === "unauthorized" - || /sign in again|unauthorized|token[_ ]?revoked|invalidated oauth|\b401\b/i.test(cErrMsg); - if (isAuthErr) { + if (isCodexAuthError(cErrMsg, cErr)) { events.push({ yokeType: "auth_required", vendor: "codex" }); return events; } @@ -938,10 +956,10 @@ function createCodexQueryHandle(appServer, queryOpts) { if (!isCancelled() && e.name !== "AbortError") { console.error("[yoke/codex] runQueryLoop error:", e.message || e); console.error("[yoke/codex] stack:", e.stack || "(no stack)"); - pushEvent({ - yokeType: "error", - text: e.message || String(e), - }); + var loopErrMsg = e.message || String(e); + pushEvent(isCodexAuthError(loopErrMsg) + ? { yokeType: "auth_required", vendor: "codex" } + : { yokeType: "error", text: loopErrMsg }); } } From 2f7baad5eac98e4ee2a9a7e6ae42c07fd7b19f0e Mon Sep 17 00:00:00 2001 From: chadbyte Date: Wed, 1 Jul 2026 14:14:58 +1200 Subject: [PATCH 4/5] fix(model-picker): snap chip to vendor default on cross-vendor mismatch Opening a Codex session while the last model was a Claude one left the chip showing the Claude model: the model_info handler kept the existing selection whenever vendorSelectionLocked was set, even if that model didn't belong to the new vendor. Now the existing selection is kept only when it's actually in the vendor's model list; otherwise it snaps to the server-provided default (mirrors the server's modelToSend logic). --- lib/public/modules/app-messages.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/public/modules/app-messages.js b/lib/public/modules/app-messages.js index 69b777e5..0569e571 100644 --- a/lib/public/modules/app-messages.js +++ b/lib/public/modules/app-messages.js @@ -443,8 +443,18 @@ export function processMessage(msg) { if (_modelVal && typeof _modelVal === "object") _modelVal = _modelVal.value || _modelVal.displayName || ""; var _miUpdate = { currentModels: msg.models || [] }; if (Object.prototype.hasOwnProperty.call(msg, "model")) { - if (store.get('vendorSelectionLocked') && store.get('currentModel')) { - // Keep the user's existing selection; only update models list + // Keep the user's existing selection only if it actually belongs to + // this vendor's model list. Otherwise (e.g. switching to a Codex + // session while currentModel is still a Claude model) snap to the + // server-provided default so the chip doesn't show a cross-vendor + // model. + var _curModel = store.get('currentModel'); + var _curInList = !!_curModel && (msg.models || []).some(function (m) { + var v = typeof m === "string" ? m : (m && m.value); + return v === _curModel; + }); + if (store.get('vendorSelectionLocked') && _curInList) { + // Keep the user's existing (valid) selection; only update models list } else { _miUpdate.currentModel = _modelVal || ""; } From 1855f6a4a68ccef60c18204131948b44314e8d31 Mon Sep 17 00:00:00 2001 From: chadbyte Date: Wed, 1 Jul 2026 14:20:22 +1200 Subject: [PATCH 5/5] fix(codex): detect 401 auth failure from app-server stderr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The definitive Codex "not logged in" signal is a 401 on its responses endpoint, which only shows up on the app-server's stderr — the JSON-RPC error events carry a generic message, so the login modal never fired. Watch stderr for the 401/auth patterns and synthesize an unauthorized error event into the adapter's event handler, which maps to the neutral auth_required yokeType. Deduped (15s) so repeated 401 retries collapse to a single login modal. --- lib/yoke/codex-app-server.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/lib/yoke/codex-app-server.js b/lib/yoke/codex-app-server.js index 6b983649..bb2a2c29 100644 --- a/lib/yoke/codex-app-server.js +++ b/lib/yoke/codex-app-server.js @@ -164,6 +164,7 @@ CodexAppServer.prototype.start = function() { while (lines.length > 1) { var line = lines.shift(); if (line.trim()) console.log("[codex-app-server stderr]", line); + self._maybeSignalAuthError(line); } self._stderrBuf = lines[0] || ""; }); @@ -222,6 +223,23 @@ CodexAppServer.prototype._handleMessage = function(msg) { } }; +// The definitive "not logged in" signal from Codex is a 401 on its own +// responses endpoint, which only appears on stderr (not as a JSON-RPC error). +// When we see it, synthesize an error event so the adapter maps it to the +// neutral auth_required yokeType. Deduped so a burst of 401 retries collapses. +CodexAppServer.prototype._maybeSignalAuthError = function(line) { + if (!this.eventHandler || !line || this._authSignalSent) return; + var isAuth = /missing bearer|token[_ ]?revoked|invalidated oauth|please sign in again/i.test(line) + || (/\b401\b/.test(line) && /unauthorized|responses|openai\.com|chatgpt\.com/i.test(line)); + if (!isAuth) return; + this._authSignalSent = true; + var self = this; + setTimeout(function () { self._authSignalSent = false; }, 15000); + try { + this.eventHandler({ method: "error", params: { error: { codexErrorInfo: "unauthorized", message: line } } }); + } catch (e) {} +}; + // Send a JSON-RPC request (expects a response) CodexAppServer.prototype.send = function(method, params, timeoutMs) { var self = this;