From 5f34e262403867dcbc21e6942f09f38e34b8bd76 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 29 Apr 2026 17:02:15 +0300 Subject: [PATCH 1/9] fix(credentials): translate auth errors and require OneCLI for spawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related fixes for the case where credentials aren't usable: 1. Replace Claude Code's "Not logged in / Invalid API key · Please run /login" output with a host-aware message. The user can't run /login from chat, so the raw text is unhelpful. Provider gains an optional isAuthRequired() classifier; the poll-loop substitutes the message on both result-text and error paths. 2. Treat OneCLI gateway failure as a transient hard error instead of spawning a credential-less container. The catch in container-runner now propagates; router and host-sweep wrap wakeContainer to log and leave the inbound row pending so the next 60s sweep tick retries. Router also stops the typing indicator on failure. Co-Authored-By: Claude Opus 4.7 (1M context) --- container/agent-runner/src/poll-loop.ts | 44 ++++++++++++++----- .../agent-runner/src/providers/claude.ts | 12 +++++ container/agent-runner/src/providers/types.ts | 8 ++++ src/container-runner.ts | 24 +++++----- src/host-sweep.ts | 9 +++- src/router.ts | 12 ++++- 6 files changed, 82 insertions(+), 27 deletions(-) diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index bd48db2..2846337 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -21,6 +21,20 @@ function generateId(): string { return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } +const AUTH_REQUIRED_USER_TEXT = + "I can't reach my Anthropic credentials right now. The operator running NanoClaw needs to re-run setup, or run `claude` in the project directory on the machine I'm running on."; + +function writeAuthRequiredMessage(routing: RoutingContext): void { + writeMessageOut({ + id: generateId(), + kind: 'chat', + platform_id: routing.platformId, + channel_type: routing.channelType, + thread_id: routing.threadId, + content: JSON.stringify({ text: AUTH_REQUIRED_USER_TEXT }), + }); +} + export interface PollLoopConfig { provider: AgentProvider; /** @@ -171,7 +185,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise { const skippedSet = new Set(skipped); const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id)); try { - const result = await processQuery(query, routing, processingIds, config.providerName); + const result = await processQuery(query, routing, processingIds, config.provider, config.providerName); if (result.continuation && result.continuation !== continuation) { continuation = result.continuation; setContinuation(config.providerName, continuation); @@ -189,15 +203,18 @@ export async function runPollLoop(config: PollLoopConfig): Promise { clearContinuation(config.providerName); } - // Write error response so the user knows something went wrong - writeMessageOut({ - id: generateId(), - kind: 'chat', - platform_id: routing.platformId, - channel_type: routing.channelType, - thread_id: routing.threadId, - content: JSON.stringify({ text: `Error: ${errMsg}` }), - }); + if (config.provider.isAuthRequired?.(errMsg)) { + writeAuthRequiredMessage(routing); + } else { + writeMessageOut({ + id: generateId(), + kind: 'chat', + platform_id: routing.platformId, + channel_type: routing.channelType, + thread_id: routing.threadId, + content: JSON.stringify({ text: `Error: ${errMsg}` }), + }); + } } // Ensure completed even if processQuery ended without a result event @@ -249,6 +266,7 @@ async function processQuery( query: AgentQuery, routing: RoutingContext, initialBatchIds: string[], + provider: AgentProvider, providerName: string, ): Promise { let queryContinuation: string | undefined; @@ -310,7 +328,11 @@ async function processQuery( // at all — either way the turn is finished. markCompleted(initialBatchIds); if (event.text) { - dispatchResultText(event.text, routing); + if (provider.isAuthRequired?.(event.text)) { + writeAuthRequiredMessage(routing); + } else { + dispatchResultText(event.text, routing); + } } } } diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index fbb077c..6dcdb5a 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -236,6 +236,14 @@ const CLAUDE_CODE_AUTO_COMPACT_WINDOW = '165000'; */ const STALE_SESSION_RE = /no conversation found|ENOENT.*\.jsonl|session.*not found/i; +/** + * Auth-required detection. Matches Claude Code's output when no usable + * credential is available — "Not logged in · Please run /login" or + * "Invalid API key · Please run /login". The user can't run /login from + * chat, so the poll-loop substitutes a host-aware message. + */ +const AUTH_REQUIRED_RE = /(Not logged in|Invalid API key)[\s\S]*?Please run \/login/i; + export class ClaudeProvider implements AgentProvider { readonly supportsNativeSlashCommands = true; @@ -259,6 +267,10 @@ export class ClaudeProvider implements AgentProvider { return STALE_SESSION_RE.test(msg); } + isAuthRequired(text: string): boolean { + return AUTH_REQUIRED_RE.test(text); + } + query(input: QueryInput): AgentQuery { const stream = new MessageStream(); stream.push(input.prompt); diff --git a/container/agent-runner/src/providers/types.ts b/container/agent-runner/src/providers/types.ts index 55ab919..99833a7 100644 --- a/container/agent-runner/src/providers/types.ts +++ b/container/agent-runner/src/providers/types.ts @@ -14,6 +14,14 @@ export interface AgentProvider { * (missing transcript, unknown session, etc.) and should be cleared. */ isSessionInvalid(err: unknown): boolean; + + /** + * True if the given text/error indicates the underlying SDK or CLI has no + * usable Anthropic auth (e.g. Claude Code's "Not logged in · Please run + * /login"). The poll-loop swaps the raw output for a host-aware message + * since the user can't run /login from chat. + */ + isAuthRequired?(text: string): boolean; } /** diff --git a/src/container-runner.ts b/src/container-runner.ts index 029b5fe..dc71248 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -435,20 +435,18 @@ async function buildContainerArgs( } // OneCLI gateway — injects HTTPS_PROXY + certs so container API calls - // are routed through the agent vault for credential injection. - try { - if (agentIdentifier) { - await onecli.ensureAgent({ name: agentGroup.name, identifier: agentIdentifier }); - } - const onecliApplied = await onecli.applyContainerConfig(args, { addHostMapping: false, agent: agentIdentifier }); - if (onecliApplied) { - log.info('OneCLI gateway applied', { containerName }); - } else { - log.warn('OneCLI gateway not applied — container will have no credentials', { containerName }); - } - } catch (err) { - log.warn('OneCLI gateway error — container will have no credentials', { containerName, err }); + // are routed through the agent vault for credential injection. Treated as + // a transient hard failure: if we can't wire the gateway, we don't spawn. + // The caller (router or host-sweep) catches the throw, leaves the inbound + // message pending, and the next sweep tick retries. + if (agentIdentifier) { + await onecli.ensureAgent({ name: agentGroup.name, identifier: agentIdentifier }); } + const onecliApplied = await onecli.applyContainerConfig(args, { addHostMapping: false, agent: agentIdentifier }); + if (!onecliApplied) { + throw new Error('OneCLI gateway not applied — refusing to spawn container without credentials'); + } + log.info('OneCLI gateway applied', { containerName }); // Host gateway args.push(...hostGatewayArgs()); diff --git a/src/host-sweep.ts b/src/host-sweep.ts index 4dc2fb7..ff88fb0 100644 --- a/src/host-sweep.ts +++ b/src/host-sweep.ts @@ -168,7 +168,14 @@ async function sweepSession(session: Session): Promise { const dueCount = countDueMessages(inDb); if (dueCount > 0 && !isContainerRunning(session.id)) { log.info('Waking container for due messages', { sessionId: session.id, count: dueCount }); - await wakeContainer(session); + try { + await wakeContainer(session); + } catch (err) { + // Transient spawn failure (e.g. OneCLI gateway down). Leave messages + // pending so the next sweep tick retries; don't abort the rest of + // the sweep cycle for other sessions. + log.warn('wakeContainer failed — will retry on next sweep', { sessionId: session.id, err }); + } } const alive = isContainerRunning(session.id); diff --git a/src/router.ts b/src/router.ts index 3cf0192..e429977 100644 --- a/src/router.ts +++ b/src/router.ts @@ -27,7 +27,7 @@ import { getMessagingGroupWithAgentCount, } from './db/messaging-groups.js'; import { findSessionForAgent } from './db/sessions.js'; -import { startTypingRefresh } from './modules/typing/index.js'; +import { startTypingRefresh, stopTypingRefresh } from './modules/typing/index.js'; import { log } from './log.js'; import { resolveSession, writeSessionMessage, writeOutboundDirect } from './session-manager.js'; import { wakeContainer } from './container-runner.js'; @@ -450,7 +450,15 @@ async function deliverToAgent( startTypingRefresh(session.id, session.agent_group_id, event.channelType, event.platformId, event.threadId); const freshSession = getSession(session.id); if (freshSession) { - await wakeContainer(freshSession); + try { + await wakeContainer(freshSession); + } catch (err) { + // Transient spawn failure (e.g. OneCLI gateway down). The inbound + // row is already persisted — host-sweep will retry the wake on its + // next tick. Don't bubble out of the channel adapter. + log.warn('wakeContainer failed — host-sweep will retry', { sessionId: freshSession.id, err }); + stopTypingRefresh(freshSession.id); + } } } } From d5b48e474278a8dc6067944c63049a64fcd950f1 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 29 Apr 2026 17:51:32 +0300 Subject: [PATCH 2/9] fix(credentials): address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wakeContainer now never throws — returns Promise, catches internally. Closes the regression risk for the 5 awaited callers in agent-to-agent, interactive, and approvals/response-handler that the previous version left unwrapped. Router uses the boolean to stop the typing indicator on transient failure; host-sweep just awaits. - Tighten AUTH_REQUIRED_RE: anchor to start-of-string with the specific `·` (U+00B7) separator the CLI uses, so an agent that quotes the banner mid-sentence in a normal reply doesn't trip the classifier. - Log a one-line note from writeAuthRequiredMessage so substitutions are visible when debugging "user got the credentials message but I don't see why." - Add unit tests for ClaudeProvider.isAuthRequired covering both banner variants, trailing content, mid-sentence quoting, leading-prose quoting, alternate separators, and unrelated text. Co-Authored-By: Claude Opus 4.7 (1M context) --- container/agent-runner/src/poll-loop.ts | 1 + .../agent-runner/src/providers/claude.test.ts | 37 +++++++++++++++++++ .../agent-runner/src/providers/claude.ts | 8 +++- src/container-runner.ts | 24 +++++++++--- src/host-sweep.ts | 11 ++---- src/router.ts | 14 +++---- 6 files changed, 70 insertions(+), 25 deletions(-) create mode 100644 container/agent-runner/src/providers/claude.test.ts diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 2846337..43c9cf1 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -25,6 +25,7 @@ const AUTH_REQUIRED_USER_TEXT = "I can't reach my Anthropic credentials right now. The operator running NanoClaw needs to re-run setup, or run `claude` in the project directory on the machine I'm running on."; function writeAuthRequiredMessage(routing: RoutingContext): void { + log('Auth-required detected — substituting host-aware message for the user'); writeMessageOut({ id: generateId(), kind: 'chat', diff --git a/container/agent-runner/src/providers/claude.test.ts b/container/agent-runner/src/providers/claude.test.ts new file mode 100644 index 0000000..d906280 --- /dev/null +++ b/container/agent-runner/src/providers/claude.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'bun:test'; + +import { ClaudeProvider } from './claude.js'; + +describe('ClaudeProvider.isAuthRequired', () => { + const provider = new ClaudeProvider(); + + it('matches the "Not logged in" banner', () => { + expect(provider.isAuthRequired('Not logged in · Please run /login')).toBe(true); + }); + + it('matches the "Invalid API key" banner', () => { + expect(provider.isAuthRequired('Invalid API key · Please run /login')).toBe(true); + }); + + it('matches with trailing content after the banner', () => { + expect(provider.isAuthRequired('Not logged in · Please run /login\n\nstack trace …')).toBe(true); + }); + + it('does not match when the agent quotes the phrase mid-sentence', () => { + const quoted = "The error 'Invalid API key · Please run /login' means your auth has expired."; + expect(provider.isAuthRequired(quoted)).toBe(false); + }); + + it('does not match when the agent leads its reply with the phrase in prose', () => { + const prose = '"Not logged in · Please run /login" is a Claude Code error.'; + expect(provider.isAuthRequired(prose)).toBe(false); + }); + + it('does not match a different separator (defensive against typos in agent output)', () => { + expect(provider.isAuthRequired('Not logged in - Please run /login')).toBe(false); + }); + + it('does not match unrelated text', () => { + expect(provider.isAuthRequired('Tool execution failed: timeout')).toBe(false); + }); +}); diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index 6dcdb5a..11ea4b0 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -237,12 +237,16 @@ const CLAUDE_CODE_AUTO_COMPACT_WINDOW = '165000'; const STALE_SESSION_RE = /no conversation found|ENOENT.*\.jsonl|session.*not found/i; /** - * Auth-required detection. Matches Claude Code's output when no usable + * Auth-required detection. Matches Claude Code's banner when no usable * credential is available — "Not logged in · Please run /login" or * "Invalid API key · Please run /login". The user can't run /login from * chat, so the poll-loop substitutes a host-aware message. + * + * Anchored to start-of-string with the specific `·` separator (U+00B7) + * the CLI uses, so an agent that quotes the phrase verbatim mid-sentence + * in a normal reply doesn't trip the classifier. */ -const AUTH_REQUIRED_RE = /(Not logged in|Invalid API key)[\s\S]*?Please run \/login/i; +const AUTH_REQUIRED_RE = /^(Not logged in|Invalid API key)\s*·\s*Please run \/login/; export class ClaudeProvider implements AgentProvider { readonly supportsNativeSlashCommands = true; diff --git a/src/container-runner.ts b/src/container-runner.ts index dc71248..27b0f5c 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -58,7 +58,7 @@ const activeContainers = new Map>(); +const wakePromises = new Map>(); export function getActiveContainerCount(): number { return activeContainers.size; @@ -73,20 +73,32 @@ export function isContainerRunning(sessionId: string): boolean { * (the in-flight wake promise is reused). * * The container runs the v2 agent-runner which polls the session DB. + * + * Contract: never throws. Returns `true` on successful spawn, `false` on + * transient spawn failure (e.g. OneCLI gateway unreachable). Callers don't + * need to wrap — the inbound row stays pending and host-sweep retries on + * its next tick. Callers that care (e.g. the router's typing indicator) + * can branch on the boolean. */ -export function wakeContainer(session: Session): Promise { +export function wakeContainer(session: Session): Promise { if (activeContainers.has(session.id)) { log.debug('Container already running', { sessionId: session.id }); - return Promise.resolve(); + return Promise.resolve(true); } const existing = wakePromises.get(session.id); if (existing) { log.debug('Container wake already in-flight — joining existing promise', { sessionId: session.id }); return existing; } - const promise = spawnContainer(session).finally(() => { - wakePromises.delete(session.id); - }); + const promise = spawnContainer(session) + .then(() => true) + .catch((err) => { + log.warn('wakeContainer failed — host-sweep will retry', { sessionId: session.id, err }); + return false; + }) + .finally(() => { + wakePromises.delete(session.id); + }); wakePromises.set(session.id, promise); return promise; } diff --git a/src/host-sweep.ts b/src/host-sweep.ts index ff88fb0..69a4d61 100644 --- a/src/host-sweep.ts +++ b/src/host-sweep.ts @@ -168,14 +168,9 @@ async function sweepSession(session: Session): Promise { const dueCount = countDueMessages(inDb); if (dueCount > 0 && !isContainerRunning(session.id)) { log.info('Waking container for due messages', { sessionId: session.id, count: dueCount }); - try { - await wakeContainer(session); - } catch (err) { - // Transient spawn failure (e.g. OneCLI gateway down). Leave messages - // pending so the next sweep tick retries; don't abort the rest of - // the sweep cycle for other sessions. - log.warn('wakeContainer failed — will retry on next sweep', { sessionId: session.id, err }); - } + // wakeContainer never throws — transient spawn failures (OneCLI down, + // etc.) return false and leave messages pending for the next tick. + await wakeContainer(session); } const alive = isContainerRunning(session.id); diff --git a/src/router.ts b/src/router.ts index e429977..69d7313 100644 --- a/src/router.ts +++ b/src/router.ts @@ -450,15 +450,11 @@ async function deliverToAgent( startTypingRefresh(session.id, session.agent_group_id, event.channelType, event.platformId, event.threadId); const freshSession = getSession(session.id); if (freshSession) { - try { - await wakeContainer(freshSession); - } catch (err) { - // Transient spawn failure (e.g. OneCLI gateway down). The inbound - // row is already persisted — host-sweep will retry the wake on its - // next tick. Don't bubble out of the channel adapter. - log.warn('wakeContainer failed — host-sweep will retry', { sessionId: freshSession.id, err }); - stopTypingRefresh(freshSession.id); - } + const woke = await wakeContainer(freshSession); + // wakeContainer never throws — it returns false on transient spawn + // failure (host-sweep retries). Stop the typing indicator we just + // started so it doesn't leak; the inbound row stays pending. + if (!woke) stopTypingRefresh(freshSession.id); } } } From beb5e049eda84b178bc02f10c4346e6ef99d1279 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 29 Apr 2026 18:03:25 +0300 Subject: [PATCH 3/9] fix(credentials): move auth-required remediation message into provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a paired `authRequiredMessage()` method to AgentProvider so per-provider auth-failure remediation can differ. Claude returns the Anthropic/`claude` instruction; future providers (Codex, OpenCode, …) can return their own remediation text. The poll-loop calls `provider.authRequiredMessage?.()` and falls back to a generic message if a provider implements `isAuthRequired` without supplying its own remediation. Co-Authored-By: Claude Opus 4.7 (1M context) --- container/agent-runner/src/poll-loop.ts | 17 +++++++++++------ .../agent-runner/src/providers/claude.test.ts | 10 ++++++++++ container/agent-runner/src/providers/claude.ts | 4 ++++ container/agent-runner/src/providers/types.ts | 17 +++++++++++++++-- 4 files changed, 40 insertions(+), 8 deletions(-) diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 43c9cf1..fb54378 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -21,10 +21,15 @@ function generateId(): string { return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } -const AUTH_REQUIRED_USER_TEXT = - "I can't reach my Anthropic credentials right now. The operator running NanoClaw needs to re-run setup, or run `claude` in the project directory on the machine I'm running on."; +// Generic fallback for providers that classify auth failures via +// `isAuthRequired` but don't supply their own remediation text. Concrete +// providers (Claude, Codex, …) override this with a provider-specific +// message via `authRequiredMessage()`. +const GENERIC_AUTH_REQUIRED_MESSAGE = + "I can't reach my credentials right now. The operator running NanoClaw needs to re-authenticate on the host machine."; -function writeAuthRequiredMessage(routing: RoutingContext): void { +function writeAuthRequiredMessage(provider: AgentProvider, routing: RoutingContext): void { + const text = provider.authRequiredMessage?.() ?? GENERIC_AUTH_REQUIRED_MESSAGE; log('Auth-required detected — substituting host-aware message for the user'); writeMessageOut({ id: generateId(), @@ -32,7 +37,7 @@ function writeAuthRequiredMessage(routing: RoutingContext): void { platform_id: routing.platformId, channel_type: routing.channelType, thread_id: routing.threadId, - content: JSON.stringify({ text: AUTH_REQUIRED_USER_TEXT }), + content: JSON.stringify({ text }), }); } @@ -205,7 +210,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise { } if (config.provider.isAuthRequired?.(errMsg)) { - writeAuthRequiredMessage(routing); + writeAuthRequiredMessage(config.provider, routing); } else { writeMessageOut({ id: generateId(), @@ -330,7 +335,7 @@ async function processQuery( markCompleted(initialBatchIds); if (event.text) { if (provider.isAuthRequired?.(event.text)) { - writeAuthRequiredMessage(routing); + writeAuthRequiredMessage(provider, routing); } else { dispatchResultText(event.text, routing); } diff --git a/container/agent-runner/src/providers/claude.test.ts b/container/agent-runner/src/providers/claude.test.ts index d906280..91836e4 100644 --- a/container/agent-runner/src/providers/claude.test.ts +++ b/container/agent-runner/src/providers/claude.test.ts @@ -35,3 +35,13 @@ describe('ClaudeProvider.isAuthRequired', () => { expect(provider.isAuthRequired('Tool execution failed: timeout')).toBe(false); }); }); + +describe('ClaudeProvider.authRequiredMessage', () => { + const provider = new ClaudeProvider(); + + it('returns the Anthropic-specific remediation', () => { + const msg = provider.authRequiredMessage(); + expect(msg).toContain('Anthropic credentials'); + expect(msg).toContain('claude'); + }); +}); diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index 11ea4b0..89dd5cd 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -275,6 +275,10 @@ export class ClaudeProvider implements AgentProvider { return AUTH_REQUIRED_RE.test(text); } + authRequiredMessage(): string { + return "I can't reach my Anthropic credentials right now. The operator running NanoClaw needs to re-run setup, or run `claude` in the project directory on the machine I'm running on."; + } + query(input: QueryInput): AgentQuery { const stream = new MessageStream(); stream.push(input.prompt); diff --git a/container/agent-runner/src/providers/types.ts b/container/agent-runner/src/providers/types.ts index 99833a7..3124c07 100644 --- a/container/agent-runner/src/providers/types.ts +++ b/container/agent-runner/src/providers/types.ts @@ -17,11 +17,24 @@ export interface AgentProvider { /** * True if the given text/error indicates the underlying SDK or CLI has no - * usable Anthropic auth (e.g. Claude Code's "Not logged in · Please run + * usable credentials (e.g. Claude Code's "Not logged in · Please run * /login"). The poll-loop swaps the raw output for a host-aware message - * since the user can't run /login from chat. + * since the user can't authenticate from chat. + * + * Paired with `authRequiredMessage()` — providers that implement one + * should implement both. The matcher is provider-specific because each + * SDK/CLI has its own auth-failure banner format. */ isAuthRequired?(text: string): boolean; + + /** + * User-facing remediation message returned when `isAuthRequired` matches. + * Provider-specific because the actionable instruction differs across + * providers (e.g. Claude vs Codex vs OpenCode each direct the operator + * to a different auth flow). Falls back to a generic message in the + * poll-loop if a provider implements `isAuthRequired` but not this. + */ + authRequiredMessage?(): string; } /** From 9889848932d5b27b6f53c3e6d3b717c41d8aabea Mon Sep 17 00:00:00 2001 From: robbyczgw-cla Date: Wed, 29 Apr 2026 15:07:26 +0000 Subject: [PATCH 4/9] fix(claude-provider): respect operator-set CLAUDE_CODE_AUTO_COMPACT_WINDOW Closes #1820. The container agent-runner sets CLAUDE_CODE_AUTO_COMPACT_WINDOW unconditionally on the container process env, with no way to override it per-deployment without editing source. Read process.env first and fall back to the existing 165000 literal when unset. Default behavior is unchanged for installs that do not set the env var. Operators running 1M-context models or emergency-tuning a live deployment can now raise or lower the threshold from the host env. --- container/agent-runner/src/providers/claude.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index fbb077c..c9478b8 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -226,8 +226,12 @@ function createPreCompactHook(assistantName?: string): HookCallback { /** * Claude Code auto-compacts context at this window (tokens). Kept here so * the generic bootstrap doesn't need to know about Claude-specific env vars. + * + * Operator override: set CLAUDE_CODE_AUTO_COMPACT_WINDOW in the host env to + * raise or lower the threshold without editing source — useful when running + * with a 1M-context model variant or when emergency-tuning a deployment. */ -const CLAUDE_CODE_AUTO_COMPACT_WINDOW = '165000'; +const CLAUDE_CODE_AUTO_COMPACT_WINDOW = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW || '165000'; /** * Stale-session detection. Matches Claude Code's error text when a From 70cb35f58b221a8236302cb409df6cc37b1c6cb8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 29 Apr 2026 15:13:37 +0000 Subject: [PATCH 5/9] chore: bump version to 2.0.16 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9b3b6fb..419e4b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.15", + "version": "2.0.16", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From ee165d09c2959032c789309f5c6994767fce1fd4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 29 Apr 2026 15:13:40 +0000 Subject: [PATCH 6/9] =?UTF-8?q?docs:=20update=20token=20count=20to=20134k?= =?UTF-8?q?=20tokens=20=C2=B7=2067%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 5a0fe82..cccf8c7 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 133k tokens, 67% of context window + + 134k tokens, 67% of context window @@ -15,8 +15,8 @@ tokens - - 133k + + 134k From e31a6c7e3493e00371a616eacb94d570e0174ca5 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 29 Apr 2026 18:26:04 +0300 Subject: [PATCH 7/9] revert(credentials): drop auth-required login-message handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing the "Not logged in · Please run /login" detection and substitution from this PR — narrowing scope to just the OneCLI gateway transient-retry change. The login-message handling will be addressed separately. Reverts: - AgentProvider.isAuthRequired / authRequiredMessage - ClaudeProvider auth-required regex, classifier, and remediation text - poll-loop writeAuthRequiredMessage helper + call sites - claude.test.ts (auth-only test file) OneCLI/wakeContainer changes (the remaining content of the PR) are unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) --- container/agent-runner/src/poll-loop.ts | 50 ++++--------------- .../agent-runner/src/providers/claude.test.ts | 47 ----------------- .../agent-runner/src/providers/claude.ts | 20 -------- container/agent-runner/src/providers/types.ts | 21 -------- 4 files changed, 11 insertions(+), 127 deletions(-) delete mode 100644 container/agent-runner/src/providers/claude.test.ts diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index fb54378..bd48db2 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -21,26 +21,6 @@ function generateId(): string { return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } -// Generic fallback for providers that classify auth failures via -// `isAuthRequired` but don't supply their own remediation text. Concrete -// providers (Claude, Codex, …) override this with a provider-specific -// message via `authRequiredMessage()`. -const GENERIC_AUTH_REQUIRED_MESSAGE = - "I can't reach my credentials right now. The operator running NanoClaw needs to re-authenticate on the host machine."; - -function writeAuthRequiredMessage(provider: AgentProvider, routing: RoutingContext): void { - const text = provider.authRequiredMessage?.() ?? GENERIC_AUTH_REQUIRED_MESSAGE; - log('Auth-required detected — substituting host-aware message for the user'); - writeMessageOut({ - id: generateId(), - kind: 'chat', - platform_id: routing.platformId, - channel_type: routing.channelType, - thread_id: routing.threadId, - content: JSON.stringify({ text }), - }); -} - export interface PollLoopConfig { provider: AgentProvider; /** @@ -191,7 +171,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise { const skippedSet = new Set(skipped); const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id)); try { - const result = await processQuery(query, routing, processingIds, config.provider, config.providerName); + const result = await processQuery(query, routing, processingIds, config.providerName); if (result.continuation && result.continuation !== continuation) { continuation = result.continuation; setContinuation(config.providerName, continuation); @@ -209,18 +189,15 @@ export async function runPollLoop(config: PollLoopConfig): Promise { clearContinuation(config.providerName); } - if (config.provider.isAuthRequired?.(errMsg)) { - writeAuthRequiredMessage(config.provider, routing); - } else { - writeMessageOut({ - id: generateId(), - kind: 'chat', - platform_id: routing.platformId, - channel_type: routing.channelType, - thread_id: routing.threadId, - content: JSON.stringify({ text: `Error: ${errMsg}` }), - }); - } + // Write error response so the user knows something went wrong + writeMessageOut({ + id: generateId(), + kind: 'chat', + platform_id: routing.platformId, + channel_type: routing.channelType, + thread_id: routing.threadId, + content: JSON.stringify({ text: `Error: ${errMsg}` }), + }); } // Ensure completed even if processQuery ended without a result event @@ -272,7 +249,6 @@ async function processQuery( query: AgentQuery, routing: RoutingContext, initialBatchIds: string[], - provider: AgentProvider, providerName: string, ): Promise { let queryContinuation: string | undefined; @@ -334,11 +310,7 @@ async function processQuery( // at all — either way the turn is finished. markCompleted(initialBatchIds); if (event.text) { - if (provider.isAuthRequired?.(event.text)) { - writeAuthRequiredMessage(provider, routing); - } else { - dispatchResultText(event.text, routing); - } + dispatchResultText(event.text, routing); } } } diff --git a/container/agent-runner/src/providers/claude.test.ts b/container/agent-runner/src/providers/claude.test.ts deleted file mode 100644 index 91836e4..0000000 --- a/container/agent-runner/src/providers/claude.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, it, expect } from 'bun:test'; - -import { ClaudeProvider } from './claude.js'; - -describe('ClaudeProvider.isAuthRequired', () => { - const provider = new ClaudeProvider(); - - it('matches the "Not logged in" banner', () => { - expect(provider.isAuthRequired('Not logged in · Please run /login')).toBe(true); - }); - - it('matches the "Invalid API key" banner', () => { - expect(provider.isAuthRequired('Invalid API key · Please run /login')).toBe(true); - }); - - it('matches with trailing content after the banner', () => { - expect(provider.isAuthRequired('Not logged in · Please run /login\n\nstack trace …')).toBe(true); - }); - - it('does not match when the agent quotes the phrase mid-sentence', () => { - const quoted = "The error 'Invalid API key · Please run /login' means your auth has expired."; - expect(provider.isAuthRequired(quoted)).toBe(false); - }); - - it('does not match when the agent leads its reply with the phrase in prose', () => { - const prose = '"Not logged in · Please run /login" is a Claude Code error.'; - expect(provider.isAuthRequired(prose)).toBe(false); - }); - - it('does not match a different separator (defensive against typos in agent output)', () => { - expect(provider.isAuthRequired('Not logged in - Please run /login')).toBe(false); - }); - - it('does not match unrelated text', () => { - expect(provider.isAuthRequired('Tool execution failed: timeout')).toBe(false); - }); -}); - -describe('ClaudeProvider.authRequiredMessage', () => { - const provider = new ClaudeProvider(); - - it('returns the Anthropic-specific remediation', () => { - const msg = provider.authRequiredMessage(); - expect(msg).toContain('Anthropic credentials'); - expect(msg).toContain('claude'); - }); -}); diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index 89dd5cd..fbb077c 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -236,18 +236,6 @@ const CLAUDE_CODE_AUTO_COMPACT_WINDOW = '165000'; */ const STALE_SESSION_RE = /no conversation found|ENOENT.*\.jsonl|session.*not found/i; -/** - * Auth-required detection. Matches Claude Code's banner when no usable - * credential is available — "Not logged in · Please run /login" or - * "Invalid API key · Please run /login". The user can't run /login from - * chat, so the poll-loop substitutes a host-aware message. - * - * Anchored to start-of-string with the specific `·` separator (U+00B7) - * the CLI uses, so an agent that quotes the phrase verbatim mid-sentence - * in a normal reply doesn't trip the classifier. - */ -const AUTH_REQUIRED_RE = /^(Not logged in|Invalid API key)\s*·\s*Please run \/login/; - export class ClaudeProvider implements AgentProvider { readonly supportsNativeSlashCommands = true; @@ -271,14 +259,6 @@ export class ClaudeProvider implements AgentProvider { return STALE_SESSION_RE.test(msg); } - isAuthRequired(text: string): boolean { - return AUTH_REQUIRED_RE.test(text); - } - - authRequiredMessage(): string { - return "I can't reach my Anthropic credentials right now. The operator running NanoClaw needs to re-run setup, or run `claude` in the project directory on the machine I'm running on."; - } - query(input: QueryInput): AgentQuery { const stream = new MessageStream(); stream.push(input.prompt); diff --git a/container/agent-runner/src/providers/types.ts b/container/agent-runner/src/providers/types.ts index 3124c07..55ab919 100644 --- a/container/agent-runner/src/providers/types.ts +++ b/container/agent-runner/src/providers/types.ts @@ -14,27 +14,6 @@ export interface AgentProvider { * (missing transcript, unknown session, etc.) and should be cleared. */ isSessionInvalid(err: unknown): boolean; - - /** - * True if the given text/error indicates the underlying SDK or CLI has no - * usable credentials (e.g. Claude Code's "Not logged in · Please run - * /login"). The poll-loop swaps the raw output for a host-aware message - * since the user can't authenticate from chat. - * - * Paired with `authRequiredMessage()` — providers that implement one - * should implement both. The matcher is provider-specific because each - * SDK/CLI has its own auth-failure banner format. - */ - isAuthRequired?(text: string): boolean; - - /** - * User-facing remediation message returned when `isAuthRequired` matches. - * Provider-specific because the actionable instruction differs across - * providers (e.g. Claude vs Codex vs OpenCode each direct the operator - * to a different auth flow). Falls back to a generic message in the - * poll-loop if a provider implements `isAuthRequired` but not this. - */ - authRequiredMessage?(): string; } /** From 1452ed262b018c7f07430018cd156c6b1bc3e754 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 29 Apr 2026 15:30:20 +0000 Subject: [PATCH 8/9] chore: bump version to 2.0.17 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 419e4b3..6287027 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.16", + "version": "2.0.17", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 34f361287746a507ffa952cc1c542fe35e4b5b5b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 29 Apr 2026 15:30:22 +0000 Subject: [PATCH 9/9] =?UTF-8?q?docs:=20update=20token=20count=20to=20135k?= =?UTF-8?q?=20tokens=20=C2=B7=2067%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index cccf8c7..20b07a3 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 134k tokens, 67% of context window + + 135k tokens, 67% of context window @@ -15,8 +15,8 @@ tokens - - 134k + + 135k