From 960dcdb8de7dc5edb05ec3797fbfa8101bbfe9a0 Mon Sep 17 00:00:00 2001 From: Ryder Sun Date: Sat, 7 Nov 2026 20:50:47 +0800 Subject: [PATCH] Allow Claude project credentials when globally logged out Treat the global authentication probe as inconclusive for project settings and keep automatic routing gated on verified runs. Verified readiness and workspace tests and desktop packaging. --- desktop/src/agents/local-agent-readiness.cjs | 3 +- .../agents/local-agent-readiness.test.cjs | 6 +-- .../local-workspace-claude-native.test.cjs | 48 +++++++++++++++++++ 3 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 desktop/test/workspace/local-workspace-claude-native.test.cjs diff --git a/desktop/src/agents/local-agent-readiness.cjs b/desktop/src/agents/local-agent-readiness.cjs index e66054d..298226b 100644 --- a/desktop/src/agents/local-agent-readiness.cjs +++ b/desktop/src/agents/local-agent-readiness.cjs @@ -989,7 +989,8 @@ function claudeAuthState(output) { return { state: 'ready', source: 'native-credential' } } if (status?.loggedIn === true) return { state: 'ready', source: 'native-auth-status' } - if (status?.loggedIn === false) return { state: 'missing', source: 'native-auth-status' } + // The status command does not account for credentials in project settings. + if (status?.loggedIn === false) return { state: 'unknown', source: 'unverified' } } catch { /* keep the file-based result */ } return null } diff --git a/desktop/test/agents/local-agent-readiness.test.cjs b/desktop/test/agents/local-agent-readiness.test.cjs index 4a8fce4..000b54b 100644 --- a/desktop/test/agents/local-agent-readiness.test.cjs +++ b/desktop/test/agents/local-agent-readiness.test.cjs @@ -1869,7 +1869,7 @@ test('MiMo readiness remains unknown for missing or malformed identity output', } }) -test('Claude auth status overrides a stale credential file', async () => { +test('Claude logged-out status does not make stale credentials or project settings verified', async () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-claude-stale-auth-')) const credentialPath = path.join(home, '.claude', '.credentials.json') fs.mkdirSync(path.dirname(credentialPath), { recursive: true }) @@ -1886,7 +1886,7 @@ test('Claude auth status overrides a stale credential file', async () => { }, }) - assert.deepEqual(result, { state: 'missing', source: 'native-auth-status' }) + assert.deepEqual(result, { state: 'unknown', source: 'unverified' }) assert.equal(calls, 1) } finally { fs.rmSync(home, { recursive: true, force: true }) @@ -1913,7 +1913,7 @@ test('Claude probes native auth with cloud configuration even when a direct API }, }) assert.equal(calls, 1) - assert.deepEqual(result, { state: 'missing', source: 'native-auth-status' }) + assert.deepEqual(result, { state: 'unknown', source: 'unverified' }) }) test('Claude API key status remains configuration evidence rather than verified authentication', async () => { diff --git a/desktop/test/workspace/local-workspace-claude-native.test.cjs b/desktop/test/workspace/local-workspace-claude-native.test.cjs new file mode 100644 index 0000000..a22686a --- /dev/null +++ b/desktop/test/workspace/local-workspace-claude-native.test.cjs @@ -0,0 +1,48 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const { LocalWorkspace } = require('../../src/workspace/local-workspace.cjs') +const { resolveNativeCredentialState } = require('../../src/agents/local-agent-readiness.cjs') +const { fixture } = require('../support/local-workspace-test-helpers.cjs') + +test('Claude project credentials remain usable in direct and group chats when global auth is logged out', async t => { + const { directory, options } = fixture() + t.after(() => fs.rmSync(directory, { recursive: true, force: true })) + const project = path.join(directory, 'project') + fs.mkdirSync(path.join(project, '.claude'), { recursive: true }) + fs.writeFileSync(path.join(project, '.claude', 'settings.json'), JSON.stringify({ + env: { ANTHROPIC_API_KEY: 'synthetic-project-key' }, + })) + options.detectAgents = async () => [ + { kind: 'claude', name: 'Claude', executable: '/tmp/claude', version: '1' }, + { kind: 'codex', name: 'Codex', executable: '/tmp/codex', version: '1' }, + ] + options.credentialState = async kind => kind === 'claude' + ? resolveNativeCredentialState(kind, { home: directory, env: {}, executable: '/tmp/claude', + execFileFn: async () => ({ stdout: JSON.stringify({ loggedIn: false }) }) }) + : { state: 'ready', source: 'native-credential' } + options.runAgent = async (agent, _prompt, workdir) => { + if (agent.kind === 'claude') { + const settings = JSON.parse(fs.readFileSync(path.join(workdir, '.claude', 'settings.json'), 'utf8')) + assert.equal(settings.env.ANTHROPIC_API_KEY, 'synthetic-project-key') + } + return { text: `${agent.kind} reply`, outcome: 'completed' } + } + const workspace = new LocalWorkspace(options) + await workspace.refreshAgents() + const claude = workspace.detectedAgents.find(agent => agent.kind === 'claude') + assert.equal(claude.credentialState, 'unknown') + assert.equal(claude.recentlyVerified, false) + const group = workspace.createGroup({ name: 'Group', agentKinds: ['claude', 'codex'], workdir: project }) + await assert.rejects(workspace.sendMessage({ groupId: group.id, text: 'Reply', routingMode: 'automatic' }), + { code: 'LOCAL_AGENT_ROUTING_UNVERIFIED' }) + const direct = workspace.createGroup({ conversationType: 'direct', directAgentKind: 'claude', workdir: project }) + await workspace.sendMessage({ groupId: direct.id, text: 'Reply' }) + await workspace.sendMessage({ groupId: group.id, text: 'Reply', targetKinds: ['claude', 'codex'] }) + for (const chat of [direct, group]) { + assert.ok(workspace.snapshot().messages.some(message => message.groupId === chat.id + && message.agentKind === 'claude' && message.content === 'claude reply')) + } + await workspace.stopAll() +})