diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index 19f406ed9e4..a1e3edb4d11 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -286,6 +286,18 @@ export const PR_E2E_SOURCE_ROUTES = [ file )) }, + { + // A Claude chat a managed host's CLI refuses to start names why (#33768). + id: 'ssh.orcad-claude-chat-start-failure', + specs: ['tests/e2e/ssh-orcad-claude-chat-start-failure.spec.ts'], + matches: (file) => + MANAGED_SSH_HARNESS.test(file) || + file === 'tests/e2e/fixtures/structured-claude-stub.cjs' || + (isProductSource(file) && + /^src\/(?:main\/(?:claude\/claude-(?:stream-json-connection|stream-json-exit-error|structured-session-exit-lifecycle)|native-chat\/agent-session-wire\/structured-agent-session-failure-text)|renderer\/src\/components\/native-chat\/structured-agent-session-delivery-notices)\.ts$/.test( + file + )) + }, { // A reconnect the host cannot image must still bring an idle full-screen program up to date (#26887). id: 'ssh.orcad-reconnect-idle-frame', diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts index 22428e34613..cd05e91b6f8 100644 --- a/src/main/claude/claude-stream-json-connection.test.ts +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -22,6 +22,7 @@ import { openTestJournalHostDatabase } from '../native-chat/agent-session-journa import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' import { providerExecutableMissing } from '../provider-process/provider-executable-missing' +import { providerStartupFailureFact } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' // Exercise the real SDK against a scripted child CLI. const FAKE_CLI = join(__dirname, '__fixtures__', 'claude-agent-sdk-scripted-cli.mjs') @@ -609,6 +610,24 @@ describe('Claude stream-json connection', () => { } }) + it("names Claude's root refusal of Full access, keeping its words as the detail", async () => { + const refusal = + '--dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons' + const scenario = scriptScenario([{ stderr: `${refusal}\n` }, { exit: 1 }]) + let exit: Error | null = null + await open(launchFor(scenario), { + onExit: (error) => { + exit = error + } + }) + + await until(() => exit, 'the exit error') + expect(providerStartupFailureFact(exit)).toEqual({ + kind: 'fullAccessAsRoot', + detail: { text: expect.stringContaining(refusal), audience: 'log' } + }) + }) + it.runIf(process.platform !== 'win32')( 'proves a natural SDK exit and cleans up its descendant before recovery', async () => { diff --git a/src/main/claude/claude-stream-json-connection.ts b/src/main/claude/claude-stream-json-connection.ts index aab962869ef..0ccbb3464eb 100644 --- a/src/main/claude/claude-stream-json-connection.ts +++ b/src/main/claude/claude-stream-json-connection.ts @@ -1,5 +1,4 @@ import type { PipedProcessSpawner } from '@orca/process-host/process-spec' -import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-session-failure' import type * as ClaudeAgentSdk from '@anthropic-ai/claude-agent-sdk' import type { CanUseTool, OnUserDialog, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk' import { spawnProcess } from '@orca/process-host' @@ -20,8 +19,8 @@ import { createClaudeUserMessageQueue } from './claude-agent-sdk-user-message-queue' import type { ClaudeStructuredSdkOptions } from './claude-structured-launch-resolution' -import { providerStderrForDisplay } from '../provider-process/provider-spawn-failure-report' import { withMissingProviderExecutable } from '../provider-process/provider-executable-missing' +import { claudeStreamJsonExitError, type ClaudeExitStatus } from './claude-stream-json-exit-error' export { ClaudeControlRequestError } @@ -90,25 +89,6 @@ export type ClaudeStreamJsonConnection = ClaudeControlSurface & { close: () => Promise } -type ExitStatus = { code: number | null; signal: NodeJS.Signals | null } - -function exitError(stderrTail: string, status: ExitStatus | null, cause?: Error): Error { - const detail = providerStderrForDisplay(stderrTail).trim() - // Retain exit status and stderr through wrappers for refused-start diagnostics. - const how = - status?.signal !== null && status?.signal !== undefined - ? ` (signal ${status.signal})` - : status?.code !== null && status?.code !== undefined - ? ` (code ${status.code})` - : '' - const message = `claude stream-json exited${how}${detail ? `: ${detail}` : ''}` - const diagnostic = providerDiagnostic([how.trim(), detail].filter(Boolean).join('\n'), 'log') - return withProviderDiagnostic( - cause ? new Error(message, { cause }) : new Error(message), - diagnostic - ) -} - export async function openClaudeStreamJsonConnection( launch: ClaudeStreamJsonLaunch, handlers: ClaudeStreamJsonConnectionHandlers = {}, @@ -145,7 +125,7 @@ export async function openClaudeStreamJsonConnection( if (!child || !managed) { throw new Error('the claude agent SDK returned without spawning a child') } - let exitStatus: ExitStatus | null = null + let exitStatus: ClaudeExitStatus | null = null let closing = false let terminalError: Error | null = null let faultReported = false @@ -190,7 +170,7 @@ export async function openClaudeStreamJsonConnection( const handleUnexpectedEnd = (cause?: Error): void => { resumeReading() if (!terminalError) { - const error = exitError(managed.stderrTail(), exitStatus, cause) + const error = claudeStreamJsonExitError(managed.stderrTail(), exitStatus, cause) terminalError = managed.executableMissing ? withMissingProviderExecutable(error) : error } inbox.fail(terminalError) diff --git a/src/main/claude/claude-stream-json-exit-error.ts b/src/main/claude/claude-stream-json-exit-error.ts new file mode 100644 index 00000000000..d095ed9b6a3 --- /dev/null +++ b/src/main/claude/claude-stream-json-exit-error.ts @@ -0,0 +1,40 @@ +import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-session-failure' +import { providerStderrForDisplay } from '../provider-process/provider-spawn-failure-report' +import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' + +export type ClaudeExitStatus = { code: number | null; signal: NodeJS.Signals | null } + +// Claude Code's own startup refusal: as root it exits before initialize rather than skip permissions. +const CLAUDE_ROOT_FULL_ACCESS_REFUSAL = /--dangerously-skip-permissions cannot be used with root/ + +export function claudeStreamJsonExitError( + stderrTail: string, + status: ClaudeExitStatus | null, + cause?: Error +): Error { + const detail = providerStderrForDisplay(stderrTail).trim() + // Retain exit status and stderr through wrappers for refused-start diagnostics. + const how = + status?.signal !== null && status?.signal !== undefined + ? ` (signal ${status.signal})` + : status?.code !== null && status?.code !== undefined + ? ` (code ${status.code})` + : '' + const message = `claude stream-json exited${how}${detail ? `: ${detail}` : ''}` + const diagnostic = providerDiagnostic([how.trim(), detail].filter(Boolean).join('\n'), 'log') + // The typed refusal replaces this error in failure facts, so it carries the diagnostic too. + const refusal = CLAUDE_ROOT_FULL_ACCESS_REFUSAL.test(detail) + ? withProviderDiagnostic( + Object.assign( + new AgentSessionAcquisitionRefusal(message, 'fullAccessAsRoot'), + cause ? { cause } : {} + ), + diagnostic + ) + : undefined + const inner = refusal ?? cause + return withProviderDiagnostic( + inner ? new Error(message, { cause: inner }) : new Error(message), + diagnostic + ) +} diff --git a/src/main/claude/claude-structured-session-exit-blame.test.ts b/src/main/claude/claude-structured-session-exit-blame.test.ts index afb550dcb9a..14ac3c8e631 100644 --- a/src/main/claude/claude-structured-session-exit-blame.test.ts +++ b/src/main/claude/claude-structured-session-exit-blame.test.ts @@ -7,6 +7,7 @@ import { identityFor, PROVIDER_SESSION_ID } from './claude-structured-session-test-support' +import { claudeStreamJsonExitError } from './claude-stream-json-exit-error' async function startedClaude(sink: StructuredAgentSessionEventSink) { const claude = fakeClaude() @@ -36,6 +37,23 @@ describe('what a started Claude session says ended it', () => { }) }) + it("names Claude's root refusal of Full access rather than a bare exit", async () => { + const { connection, ended } = await startedClaude({ + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {} + }) + const refusal = + '--dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons' + + connection.handlers.onExit?.(claudeStreamJsonExitError(refusal, { code: 1, signal: null })) + + expect(await ended()).toMatchObject({ + cause: 'unexpected-exit', + failure: { kind: 'fullAccessAsRoot', detail: { text: `(code 1)\n${refusal}` } } + }) + }) + it('blames Orca when a journal fault makes Orca close the child', async () => { const { connection, ended } = await startedClaude({ appendItem: () => {}, diff --git a/src/main/claude/claude-structured-session-exit-lifecycle.ts b/src/main/claude/claude-structured-session-exit-lifecycle.ts index 51520f5371a..725e343894c 100644 --- a/src/main/claude/claude-structured-session-exit-lifecycle.ts +++ b/src/main/claude/claude-structured-session-exit-lifecycle.ts @@ -1,5 +1,9 @@ import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' -import { providerExitObserved } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' +import { + providerExitObserved, + providerStartupFailureFact, + startupAcquisitionRefusal +} from '../native-chat/agent-session-wire/structured-agent-session-failure-text' import { settledClaudeTurnEndLeaf } from './claude-structured-resume-point' import { claudeHeldSends } from './claude-command-lifecycle' import { @@ -83,10 +87,12 @@ export function settleClaudeUnexpectedExit( type: 'ended', sessionId, reason: exit.error.message, - // Distinguish provider exit from a host fault that forced cleanup. - failure: providerExitObserved(exit.error) - ? agentSessionFailureFact('providerExited', { detail: providerDiagnosticOf(exit.error) }) - : agentSessionFailureFact('hostFault'), + // A typed startup refusal keeps its next step; else distinguish provider exit from a host fault. + failure: startupAcquisitionRefusal(exit.error) + ? providerStartupFailureFact(exit.error) + : providerExitObserved(exit.error) + ? agentSessionFailureFact('providerExited', { detail: providerDiagnosticOf(exit.error) }) + : agentSessionFailureFact('hostFault'), cause: 'unexpected-exit', fence: exit.session.fence, acquisitionGeneration: exit.session.acquisitionGeneration, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts index 643f6d1d0d5..513b1e65fcf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -37,6 +37,7 @@ const TYPED_START_REFUSALS = [ 'claudeAccountSetupFailed', 'agentCommandNotRunnable', 'runtimeNotInstalled', + 'fullAccessAsRoot', 'conversationOpenInTerminal', 'conversationOpenElsewhere' ] as const satisfies readonly (AgentSessionFailureKind & @@ -80,7 +81,7 @@ export function startupAcquisitionRefusal( ) } -/** A failed start keeps its typed refusal or observed exit and private provider diagnostic. */ +/** A failed start keeps its typed refusal or observed exit, and the provider diagnostic its Details show. */ export function providerStartupFailureFact(cause?: unknown): SubmissionRejectionFact { cause = startupAcquisitionRefusal(cause) ?? cause const installation = agentCliInstallationProblemOf(cause) @@ -148,7 +149,7 @@ function refusedStartFailureFact( } export type StructuredAgentSessionStartFailureCause = - /** Provider words stay host-side; newSession distinguishes first launch from restart. */ + /** Provider words reach Details, never the sentence; newSession distinguishes first launch from restart. */ | { refusal: AgentSessionWireRefusal diagnostic?: ProviderDiagnostic diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx index e3c02106595..549c2dce369 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -24,8 +24,8 @@ import { useNativeChatRowNavigation } from './use-native-chat-row-navigation' /** Unconfirmed sending status or a failed-delivery notice with an optional dismissal. */ export type NativeChatDeliveryNotice = - | { sending: true; text?: never; onDismiss?: never } - | { sending?: never; text: string; onDismiss?: () => void } + | { sending: true; text?: never; onDismiss?: never; detail?: never } + | { sending?: never; text: string; onDismiss?: () => void; detail?: string } export type MessageRowProps = { message: NativeChatMessage diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx index 3b301943966..db587a88cc3 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx @@ -26,7 +26,10 @@ import { nativeChatClaudeSignInLabel, useNativeChatClaudeSignInView } from './native-chat-claude-sign-in' -import { readWholeAgentSessionFailureFact } from '../../../../shared/agent-session-failure' +import { + agentSessionFailureDetailText, + readWholeAgentSessionFailureFact +} from '../../../../shared/agent-session-failure' import { agentSessionFailureSentence } from '../../../../shared/agent-session-failure-words' import { sayAgentSessionFailureTranslated } from './agent-session-failure-words-text' import { nativeChatAntigravitySignInContent } from './native-chat-antigravity-settings' @@ -173,10 +176,8 @@ export function NativeChatNoticeRow({ sayAgentSessionFailureTranslated ) : agentSessionGoalControlWords(block.text) - const detail = - failure?.detail?.audience === 'person' && failure.detail.text.trim() !== text.trim() - ? failure.detail.text - : undefined + const failureDetail = agentSessionFailureDetailText(failure) + const detail = failureDetail?.trim() !== text.trim() ? failureDetail : undefined const tone = named ? 'notice' : look.kind === 'line' ? look.tone : null const Icon = tone === 'warning' diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx index 741363fe057..92d97647a70 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx @@ -305,32 +305,59 @@ export function ProviderFrameRow({ } const frame = block.providerFrame return ( -
- - - › - - {frame ? ( + {frame.provider} - ) : null} - {summary ?? nativeChatProviderFrameSummary(block)} - {frame?.payload.truncated ? ( + ) : null + } + summary={summary ?? nativeChatProviderFrameSummary(block)} + trail={ + frame?.payload.truncated ? ( ·{' '} {translate('components.native-chat.providerFrame.byteLength', '{{value0}} bytes', { value0: frame.payload.byteLength })} - ) : null} + ) : null + } + > + {detail} + {detail && frame ? '\n\n' : ''} + {frame?.payload.head} + {frame?.payload.truncated ? '\n…' : ''} + + ) +} + +/** A collapsed monospace body, for provider frames and the diagnostics behind a failure. */ +export function NativeChatDetailsDisclosure({ + summary, + lead, + trail, + children +}: { + summary: string + lead?: React.ReactNode + trail?: React.ReactNode + children: React.ReactNode +}): React.JSX.Element { + return ( +
+ + + › + + {lead} + {summary} + {trail}
-        {detail}
-        {detail && frame ? '\n\n' : ''}
-        {frame?.payload.head}
-        {frame?.payload.truncated ? '\n…' : ''}
+        {children}
       
) diff --git a/src/renderer/src/components/native-chat/NativeChatUserMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatUserMessageRow.tsx index 1d276a69dc1..35da09032e9 100644 --- a/src/renderer/src/components/native-chat/NativeChatUserMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatUserMessageRow.tsx @@ -14,7 +14,10 @@ import { NativeChatCodeBlock } from './NativeChatCodeBlock' import { NativeChatCopyButton } from './NativeChatCopyButton' import { NativeChatMessageTimestamp } from './NativeChatMessageTimestamp' import { NativeChatAgentMessageSenders } from './NativeChatAgentMessageSenders' -import { NativeChatImageAttachments } from './NativeChatTranscriptChrome' +import { + NativeChatDetailsDisclosure, + NativeChatImageAttachments +} from './NativeChatTranscriptChrome' import type { deriveNativeChatRowContent } from '../../../../shared/native-chat-row-content' const USER_META_REVEAL = @@ -159,6 +162,15 @@ export function NativeChatUserMessageRow({ ) : null} ) : null} + {deliveryNotice?.detail ? ( +
+ + {deliveryNotice.detail} + +
+ ) : null} ) } diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index b1214aaa200..f125881efa5 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -149,6 +149,12 @@ const PIECES: Record + translate( + 'components.native-chat.failureWords.fullAccessAsRoot', + COPY.fullAccessAsRoot, + values + ), conversationOpenElsewhere: () => translate( 'components.native-chat.failureWords.conversationOpenElsewhere', diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts index 080faa3ca2f..4a310d4c7b4 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts @@ -101,6 +101,32 @@ describe('the line under each of the chat own messages', () => { expect(notice?.onDismiss).toBeUndefined() }) + it("keeps the CLI's own startup words behind the rejected message unless a start row has them", () => { + const exited: AgentSessionFailureFact = { + kind: 'providerStartFailed', + detail: { text: '(code 1)\nclaude: unknown option --foo', audience: 'log' } + } + const submission = row('a', 'rejected', { reason: 'Written by the host.', rejection: exited }) + expect(notices({ submissions: [submission] }).get(agentJournalSubmissionKey('a'))).toEqual({ + text: 'Claude stopped before it finished starting. Send your message to try again.', + detail: '(code 1)\nclaude: unknown option --foo' + }) + expect( + notices({ submissions: [submission], startFailures: [exited] }).get( + agentJournalSubmissionKey('a') + )?.detail + ).toBeUndefined() + // A refusal's sentence already quotes the provider's words. + const rejected: AgentSessionFailureFact = { + kind: 'providerRejected', + detail: { text: 'Too long', audience: 'person' } + } + const refused = row('b', 'rejected', { rejection: rejected }) + expect(notices({ submissions: [refused] }).get(agentJournalSubmissionKey('b'))?.detail).toBe( + undefined + ) + }) + it('says nothing for a message a Stop withdrew: it went back to the composer', () => { expect( texts(notices({ submissions: [row('a', 'rejected', { rejection: { kind: 'cancelled' } })] })) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts index abe8c40839c..d8bd048ba9c 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts @@ -1,4 +1,10 @@ -import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' +import { + agentSessionFailureDetailText, + readWholeAgentSessionFailureFact, + type AgentSessionFailureFact +} from '../../../../shared/agent-session-failure' +import { agentSessionFailureStatedByRow } from '../../../../shared/agent-session-visible-failures' +import { classifyDispatchRejection } from '../../../../shared/structured-agent-session-dispatch-rejection' import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import { structuredAgentSessionRecordedRejectionParts } from '../../../../shared/structured-agent-session-rejection-words' @@ -44,6 +50,14 @@ export function structuredAgentSessionDeliveryNotices(args: { for (const submission of submissions) { const id = agentJournalSubmissionKey(submission.clientMessageId) if (submission.dispatchState === 'rejected' && shown.has(id)) { + // A failed start's words, unless its row carries them; other sentences quote their own. + const fact = readWholeAgentSessionFailureFact(submission.rejection) + const detail = + fact && + classifyDispatchRejection({ reason: null, rejection: fact }).category === 'startFailed' && + !agentSessionFailureStatedByRow(fact, args.startFailures) + ? agentSessionFailureDetailText(fact) + : undefined notices.set(id, { text: agentSessionWriteNoticeText( structuredAgentSessionRecordedRejectionParts( @@ -51,7 +65,8 @@ export function structuredAgentSessionDeliveryNotices(args: { { agentName }, args.startFailures ) - ) + ), + ...(detail ? { detail } : {}) }) } } diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index e6765e77c59..837ef04053e 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -3188,6 +3188,7 @@ "runCommandAgain": "Run /{{command}} again.", "runCommandWhenDone": "Run /{{command}} when it's done.", "runtimeNotInstalled": "{{agent}} isn't installed on the computer running this chat. Install it from the chat in a current version of Orca.", + "fullAccessAsRoot": "{{agent}} won't use Full access while it runs as root. Choose another permission for {{agent}} in Settings → Agents for the computer running this chat, then start a new chat.", "conversationOpenElsewhere": "Codex is still holding this conversation open. Try again in a moment, or close other Codex terminals.", "conversationOpenInTerminal": "{{agent}} is already running this conversation in another terminal. Quit {{agent}} there or close that terminal to continue here.", "sendAgainToTryOnceMore": "Send your message again to try once more.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 4df2c2fb277..da3f3632ccb 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -19042,6 +19042,7 @@ "claudeAccountSetupFailed": "The selected Claude account could not be set up. Try again or choose another account.", "agentCommandNotRunnable": "{{agent}}'s Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it.", "runtimeNotInstalled": "{{agent}} isn't installed on the computer running this chat. Install it from the chat in a current version of Orca.", + "fullAccessAsRoot": "{{agent}} won't use Full access while it runs as root. Choose another permission for {{agent}} in Settings → Agents for the computer running this chat, then start a new chat.", "conversationOpenElsewhere": "Codex is still holding this conversation open. Try again in a moment, or close other Codex terminals.", "conversationOpenInTerminal": "{{agent}} is already running this conversation in another terminal. Quit {{agent}} there or close that terminal to continue here.", "chooseClaudeAccount": "Choose or add one in Claude Accounts settings.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index a40069c66a0..b7ed6f33599 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -18654,6 +18654,7 @@ "claudeAccountSetupFailed": "No se pudo configurar la cuenta de Claude seleccionada. Vuelva a intentarlo o elija otra cuenta.", "agentCommandNotRunnable": "{{agent}}: el Comando en Ajustes → Agentes debe ser la ruta o el nombre de un programa que Orca pueda encontrar, sin argumentos ni variables. Cámbielo o restablézcalo con Reiniciar.", "runtimeNotInstalled": "{{agent}} no está instalado en el equipo que ejecuta este chat. Instálelo desde el chat en una versión actual de Orca.", + "fullAccessAsRoot": "{{agent}} no usará Acceso total mientras se ejecute como root. Elija otro permiso para {{agent}} en Configuración → Agentes para el equipo que ejecuta este chat y luego inicie un chat nuevo.", "conversationOpenElsewhere": "Codex sigue manteniendo abierta esta conversación. Inténtalo de nuevo en un momento o cierra otras terminales de Codex.", "conversationOpenInTerminal": "{{agent}} ya está ejecutando esta conversación en otra terminal. Sal de {{agent}} allí o cierra esa terminal para continuar aquí.", "chooseClaudeAccount": "Elija o agregue una en la configuración de Cuentas de Claude.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 4a2025d29bf..080e81b1968 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18794,6 +18794,7 @@ "claudeAccountSetupFailed": "Le compte Claude sélectionné n'a pas pu être configuré. Réessayez ou choisissez un autre compte.", "agentCommandNotRunnable": "{{agent}} : la Commande dans Paramètres → Agents doit être le chemin ou le nom d'un programme qu'Orca peut trouver, sans arguments ni variables. Modifiez-la ou réinitialisez-la.", "runtimeNotInstalled": "{{agent}} n'est pas installé sur l'ordinateur qui exécute ce chat. Installez-le depuis le chat dans une version récente d'Orca.", + "fullAccessAsRoot": "{{agent}} n'utilisera pas l'Accès complet tant qu'il s'exécute en tant que root. Choisissez une autre autorisation pour {{agent}} dans Paramètres → Agents pour l'ordinateur qui exécute ce chat, puis démarrez un nouveau chat.", "conversationOpenElsewhere": "Codex maintient encore cette conversation ouverte. Réessayez dans un instant ou fermez les autres terminaux Codex.", "conversationOpenInTerminal": "{{agent}} exécute déjà cette conversation dans un autre terminal. Quittez {{agent}} là-bas ou fermez ce terminal pour continuer ici.", "chooseClaudeAccount": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 47d976e949c..a1dab351afb 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18684,6 +18684,7 @@ "claudeAccountSetupFailed": "選択した Claude アカウントを設定できませんでした。もう一度試すか、別のアカウントを選択してください。", "agentCommandNotRunnable": "[設定] → [Agent] にある {{agent}} の [コマンド] には、Orca が見つけられるプログラムのパスまたは名前を、引数や変数なしで指定する必要があります。変更するか、[リセット] してください。", "runtimeNotInstalled": "{{agent}} はこのチャットを実行しているコンピューターにインストールされていません。最新バージョンの Orca でチャットからインストールしてください。", + "fullAccessAsRoot": "{{agent}} は root として実行されている間はフルアクセスを使用しません。このチャットを実行しているコンピューターの [設定 → Agent] で {{agent}} に別の権限を選択してから、新しいチャットを開始してください。", "conversationOpenElsewhere": "Codex がまだこの会話を開いたままにしています。少し待ってからもう一度試すか、ほかの Codex ターミナルを閉じてください。", "conversationOpenInTerminal": "{{agent}} は別のターミナルですでにこの会話を実行しています。そちらの {{agent}} を終了するか、そのターミナルを閉じて、ここで続けてください。", "chooseClaudeAccount": "Claude アカウントの設定で選択または追加してください。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 462696fece4..206f06abc9b 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18684,6 +18684,7 @@ "claudeAccountSetupFailed": "선택한 Claude 계정을 설정할 수 없습니다. 다시 시도하거나 다른 계정을 선택하세요.", "agentCommandNotRunnable": "{{agent}}의 설정 → 에이전트 → 명령은 Orca가 찾을 수 있는 프로그램 경로나 이름이어야 하며, 인수나 변수를 포함할 수 없습니다. 변경하거나 재설정하세요.", "runtimeNotInstalled": "{{agent}}이(가) 이 채팅을 실행하는 컴퓨터에 설치되어 있지 않습니다. 최신 버전의 Orca에서 채팅을 통해 설치하세요.", + "fullAccessAsRoot": "{{agent}}은(는) root로 실행되는 동안 전체 액세스를 사용하지 않습니다. 이 채팅을 실행하는 컴퓨터의 설정 → 에이전트에서 {{agent}}에 다른 권한을 선택한 다음 새 채팅을 시작하세요.", "conversationOpenElsewhere": "Codex가 아직 이 대화를 열어 두고 있습니다. 잠시 후 다시 시도하거나 다른 Codex 터미널을 닫으세요.", "conversationOpenInTerminal": "{{agent}}이(가) 다른 터미널에서 이미 이 대화를 실행 중입니다. 그곳에서 {{agent}}을(를) 종료하거나 해당 터미널을 닫은 후 여기서 계속하세요.", "chooseClaudeAccount": "Claude 계정 설정에서 선택하거나 추가하세요.", diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 27a2286c325..ab44fb70ec0 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -19633,6 +19633,7 @@ "claudeAccountSetupFailed": "Não foi possível configurar a conta do Claude selecionada. Tente novamente ou escolha outra conta.", "agentCommandNotRunnable": "O Comando de {{agent}} em Configurações → Agentes deve ser o caminho ou nome de um programa que o Orca possa encontrar, sem argumentos nem variáveis. Altere-o ou redefina-o.", "runtimeNotInstalled": "{{agent}} não está instalado no computador que executa este chat. Instale-o pelo chat em uma versão atual do Orca.", + "fullAccessAsRoot": "{{agent}} não usará Acesso total enquanto for executado como root. Escolha outra permissão para {{agent}} em Configurações → Agentes para o computador que executa este chat e inicie um novo chat.", "chooseClaudeAccount": "Escolha ou adicione uma nas configurações de Contas do Claude.", "chooseClaudeAccountThenRunCommand": "Escolha ou adicione uma nas configurações de Contas do Claude e execute /{{command}} novamente.", "chooseClaudeAccountThenSend": "Escolha ou adicione uma nas configurações de Contas do Claude e envie sua mensagem novamente.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 4de3026854d..9aa6405b5cc 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18649,6 +18649,7 @@ "claudeAccountSetupFailed": "无法设置所选 Claude 账户。请重试或选择其他账户。", "agentCommandNotRunnable": "{{agent}} 在设置 → 智能体中的命令必须是 Orca 能找到的程序路径或名称,且不能包含参数或变量。请修改或重置。", "runtimeNotInstalled": "运行此聊天的计算机上未安装 {{agent}}。请在最新版本的 Orca 中从聊天里安装。", + "fullAccessAsRoot": "{{agent}} 以 root 身份运行时不会使用完全访问。请在运行此聊天的计算机的“设置”→“智能体”中为 {{agent}} 选择其他权限,然后开始新的聊天。", "conversationOpenElsewhere": "Codex 仍在保持此对话打开。请稍后重试,或关闭其他 Codex 终端。", "conversationOpenInTerminal": "{{agent}} 已在另一个终端中运行此对话。请退出那里的 {{agent}} 或关闭该终端,然后在这里继续。", "chooseClaudeAccount": "请在 Claude 账户设置中选择或添加一个。", diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index 1c935d6d911..a8c4ae617b6 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -78,6 +78,8 @@ export const AGENT_SESSION_FAILURE_COPY = { "{{agent}}'s Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it.", runtimeNotInstalled: "{{agent}} isn't installed on the computer running this chat. Install it from the chat in a current version of Orca.", + fullAccessAsRoot: + "{{agent}} won't use Full access while it runs as root. Choose another permission for {{agent}} in Settings → Agents for the computer running this chat, then start a new chat.", conversationOpenElsewhere: 'Codex is still holding this conversation open. Try again in a moment, or close other Codex terminals.', conversationOpenInTerminal: diff --git a/src/shared/agent-session-failure-quoted-detail.ts b/src/shared/agent-session-failure-quoted-detail.ts new file mode 100644 index 00000000000..3c37d0358ba --- /dev/null +++ b/src/shared/agent-session-failure-quoted-detail.ts @@ -0,0 +1,27 @@ +import type { ProviderDiagnostic } from './agent-session-failure' +import type { + AgentSessionFailureCopyId, + AgentSessionFailureCopyValues, + AgentSessionFailureSay +} from './agent-session-failure-copy' + +/** Person-facing provider text is quoted, but bounded so the sentence stays one. */ +const MAX_QUOTED_DETAIL_CHARS = 512 + +/** The quoted lead when the provider wrote the detail for a person; the plain lead otherwise. */ +export function quotingPersonDetail( + say: AgentSessionFailureSay, + lead: AgentSessionFailureCopyId, + quotedLead: AgentSessionFailureCopyId, + detail: ProviderDiagnostic | undefined, + values: AgentSessionFailureCopyValues = {} +): string { + const quoted = + detail?.audience === 'person' + ? detail.text + .slice(0, MAX_QUOTED_DETAIL_CHARS) + .trim() + .replace(/[.\s]+$/, '') + : '' + return quoted ? say(quotedLead, { ...values, detail: quoted }) : say(lead, values) +} diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index eac5459a035..8f927ef2a24 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -4,7 +4,6 @@ import { isSubmissionRejectionFact, type AgentSessionFailureFact, type AgentSessionFailureKind, - type ProviderDiagnostic, type SubmissionRejectionFact, type SubmissionRejectionKind } from './agent-session-failure' @@ -14,7 +13,6 @@ import { agentCliInstallationFailureWords, sayAgentSessionFailureEnglish, type AgentSessionFailureCopyId, - type AgentSessionFailureCopyValues, type AgentSessionFailureSay } from './agent-session-failure-copy' import { AGENT_SESSION_GOAL_CONTROL_COPY } from './agent-session-goal-control-copy' @@ -27,6 +25,7 @@ import { import { commandRefusedByReason } from './agent-session-command-refusal-words' import { agent, attachmentProblemSentence } from './agent-session-attachment-failure-words' import { joinSentences } from './sentence-joining' +import { quotingPersonDetail } from './agent-session-failure-quoted-detail' import { requestTimeoutSeconds } from './agent-session-request-timeout' import { DISPATCH_REJECTED_CANCELLED, @@ -70,9 +69,6 @@ export type AgentSessionFailureWordsContext = { usageLimitReset?: AgentSessionUsageLimitResetWords } -/** Person-facing provider text is quoted, but bounded so the sentence stays one. */ -const MAX_QUOTED_DETAIL_CHARS = 512 - type Sentence = ( context: AgentSessionFailureWordsContext, fact: AgentSessionFailureFact, @@ -84,23 +80,6 @@ function namingAgent(id: AgentSessionFailureCopyId): Sentence { return (context, _fact, _surface, say) => say(id, agent(say, context)) } -function quotingPersonDetail( - say: AgentSessionFailureSay, - lead: AgentSessionFailureCopyId, - quotedLead: AgentSessionFailureCopyId, - detail: ProviderDiagnostic | undefined, - values: AgentSessionFailureCopyValues = {} -): string { - const quoted = - detail?.audience === 'person' - ? detail.text - .slice(0, MAX_QUOTED_DETAIL_CHARS) - .trim() - .replace(/[.\s]+$/, '') - : '' - return quoted ? say(quotedLead, { ...values, detail: quoted }) : say(lead, values) -} - /** The next step after a start or restart that failed: the command, or the message, again. */ function startRetry( say: AgentSessionFailureSay, @@ -186,6 +165,7 @@ const FAILURE_SENTENCES = { claudeAccountSetupFailed: (_context, _fact, _surface, say) => say('claudeAccountSetupFailed'), agentCommandNotRunnable: namingAgent('agentCommandNotRunnable'), runtimeNotInstalled: namingAgent('runtimeNotInstalled'), + fullAccessAsRoot: namingAgent('fullAccessAsRoot'), conversationOpenInTerminal: namingAgent('conversationOpenInTerminal'), conversationOpenElsewhere: (_context, _fact, _surface, say) => say('conversationOpenElsewhere'), managedAccountUnsupported: (context, _fact, _surface, say) => diff --git a/src/shared/agent-session-failure.test.ts b/src/shared/agent-session-failure.test.ts index af01c0117d8..f4cceeea154 100644 --- a/src/shared/agent-session-failure.test.ts +++ b/src/shared/agent-session-failure.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { MAX_PROVIDER_DIAGNOSTIC_CHARS, + agentSessionFailureDetailText, providerDiagnostic, providerDiagnosticOf, agentSessionFailureFact, @@ -46,6 +47,16 @@ describe('provider diagnostics', () => { ).toBe(undefined) expect(providerDiagnosticOf('a string')).toBeUndefined() }) + + it("puts a start's log tail behind Details, but not the raw frame a retry row already shows", () => { + const tail = { text: '(code 1)\nclaude: unknown option', audience: 'log' as const } + expect(agentSessionFailureDetailText({ kind: 'providerStartFailed', detail: tail })).toBe( + tail.text + ) + expect(agentSessionFailureDetailText({ kind: 'providerRetrying', detail: tail })).toBe( + undefined + ) + }) }) describe('reading a failure fact', () => { diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index 13705c9c1d1..938c72f6cf6 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -40,6 +40,8 @@ export const AGENT_SESSION_FAILURE_KINDS = [ 'claudeAccountSetupFailed', 'agentCommandNotRunnable', 'runtimeNotInstalled', + /** The agent refuses Full access while it runs as root. */ + 'fullAccessAsRoot', 'conversationOpenInTerminal', 'conversationOpenElsewhere', 'providerExited', @@ -363,3 +365,14 @@ export function withProviderDiagnostic( ): TError { return diagnostic ? Object.assign(error, { providerDiagnostic: diagnostic }) : error } + +/** The detail behind a failure's Details: provider words, or a log tail such as a CLI's startup stderr. + * A retry's log is the raw frame its row already shows. */ +export function agentSessionFailureDetailText( + fact: AgentSessionFailureFact | undefined +): string | undefined { + const detail = fact?.detail + return detail && !(detail.audience === 'log' && fact.kind === 'providerRetrying') + ? detail.text + : undefined +} diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index 6f9b6734161..613206528e5 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -85,6 +85,8 @@ export const AGENT_SESSION_REFUSAL_REASONS = { 'agentCommandNotRunnable', /** The runtime Orca installs for the agent is not installed on this host. */ 'runtimeNotInstalled', + /** The agent refuses Full access while it runs as root. */ + 'fullAccessAsRoot', /** A terminal may still be writing the chat's conversation, so its agent did not start. */ 'conversationOpenInTerminal', 'conversationOpenElsewhere', diff --git a/src/shared/agent-session-refusal-reason-words.ts b/src/shared/agent-session-refusal-reason-words.ts index 8a5b597e411..8a28de490a6 100644 --- a/src/shared/agent-session-refusal-reason-words.ts +++ b/src/shared/agent-session-refusal-reason-words.ts @@ -116,6 +116,7 @@ const REASON_WORDS = { claudeAccountSetupFailed: { fact: 'claudeAccountSetupFailed', action: 'retry' }, agentCommandNotRunnable: { fact: 'agentCommandNotRunnable', action: 'actFirst' }, runtimeNotInstalled: { fact: 'runtimeNotInstalled', action: 'actFirst' }, + fullAccessAsRoot: { fact: 'fullAccessAsRoot', action: 'actFirst' }, conversationOpenInTerminal: { fact: 'conversationOpenInTerminal', action: 'actFirst' }, conversationOpenElsewhere: { fact: 'conversationOpenElsewhere', action: 'actFirst' }, attachFailed: codeWords('retry') diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 2ca8308cac6..07afcc3d32a 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -80,6 +80,7 @@ const KIND_CATEGORY = { claudeAccountSetupFailed: 'startFailed', agentCommandNotRunnable: 'startFailed', runtimeNotInstalled: 'startFailed', + fullAccessAsRoot: 'startFailed', conversationOpenInTerminal: 'startFailed', conversationOpenElsewhere: 'startFailed', restartFailed: 'startFailed', @@ -115,6 +116,7 @@ const KIND_VERDICT = { claudeAccountSetupFailed: 'failure', agentCommandNotRunnable: 'failure', runtimeNotInstalled: 'failure', + fullAccessAsRoot: 'failure', conversationOpenInTerminal: 'failure', conversationOpenElsewhere: 'failure', restartFailed: 'failure', diff --git a/tests/e2e/ssh-orcad-claude-chat-start-failure.spec.ts b/tests/e2e/ssh-orcad-claude-chat-start-failure.spec.ts new file mode 100644 index 00000000000..a1bf16ba97e --- /dev/null +++ b/tests/e2e/ssh-orcad-claude-chat-start-failure.spec.ts @@ -0,0 +1,83 @@ +// A Claude chat on a managed SSH host whose CLI refuses to start says why, not only that it stopped. +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { expect, test } from './helpers/orca-app' +import { callEnvironment } from './helpers/paired-host-terminal' +import { + launchManagedWorkspace, + skipUnlessManagedDockerHost, + withManagedHost +} from './helpers/orcad-managed-workspace' + +skipUnlessManagedDockerHost() +test.skip(process.platform === 'win32', 'The Docker SSH host uses POSIX tooling') + +const STUB = readFileSync( + path.join(process.cwd(), 'tests', 'e2e', 'fixtures', 'structured-claude-stub.cjs') +) +const ROOT_REFUSAL = + '--dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons' + +// The Docker host's SSH user is root, and Claude Code refuses Full access as root this way. +const WRAPPER = `#!/bin/sh +for arg in "$@"; do + if [ "$arg" = "--dangerously-skip-permissions" ] && [ "$(id -u)" = 0 ]; then + echo "${ROOT_REFUSAL}" >&2 + exit 1 + fi +done +exec /usr/local/bin/node /usr/local/lib/orca-e2e-claude-stub.cjs /tmp/orca-e2e-claude-launches.jsonl "$@" +` + +test('a Claude chat that Claude refuses to start as root names the reason', async (// oxlint-disable-next-line no-empty-pattern -- Owns its app launch through a restart session. +{}, testInfo) => { + // Converting the Docker host to a managed server takes minutes before the chat opens. + test.setTimeout(10 * 60_000) + await withManagedHost(testInfo, async (fixture) => { + const exec = fixture.host.exec + if (!exec) { + throw new Error('the Docker host runs commands as its SSH user') + } + exec( + `echo ${STUB.toString('base64')} | base64 -d > /usr/local/lib/orca-e2e-claude-stub.cjs && ` + + `echo ${Buffer.from(WRAPPER).toString('base64')} | base64 -d > /usr/local/bin/claude && ` + + 'chmod 755 /usr/local/bin/claude' + ) + const { page, environmentId } = await launchManagedWorkspace(fixture) + await page.evaluate(async () => { + await window.__store?.getState().updateSettings({ + experimentalNativeChat: true, + defaultTuiAgent: 'claude' + }) + }) + await page.getByRole('button', { name: 'New tab', exact: true }).first().click() + await page.getByText('Claude', { exact: true }).first().click({ timeout: 60_000 }) + const chat = page.locator('[data-native-chat-root="true"]') + await expect(chat).toBeVisible({ timeout: 60_000 }) + const composer = chat.getByRole('textbox') + await composer.click() + await page.keyboard.type('hello') + await chat.getByRole('button', { name: 'Send', exact: true }).click() + await expect(chat).toContainText("Claude won't use Full access while it runs as root.", { + timeout: 120_000 + }) + // Claude's own words sit behind Details on the start-failure row. + await chat.getByText('Details', { exact: true }).first().click() + await expect(chat).toContainText(ROOT_REFUSAL) + await page.screenshot({ path: testInfo.outputPath('claude-root-start-failure.png') }) + + // The sentence's next step works: with another permission on that host, a new chat starts Claude. + await callEnvironment(page, environmentId, 'settings.mutateAgentLaunch', { + type: 'permission', + choice: 'ask', + agent: 'claude' + }) + await page.getByRole('button', { name: 'New tab', exact: true }).first().click() + await page.getByText('Claude', { exact: true }).first().click({ timeout: 60_000 }) + const next = page.locator('[data-native-chat-root="true"]').last() + await next.getByRole('textbox').click() + await page.keyboard.type('again') + await next.getByRole('button', { name: 'Send', exact: true }).click() + await expect(next.getByText('Stub reply.')).toBeVisible({ timeout: 120_000 }) + }) +})