fix(native-chat): say why Claude refused to start a chat, including Full access as root (#33812)

* fix(native-chat): say why Claude refused to start a chat, including Full access as root

Claude Code exits before initialize when Full access (--dangerously-skip-permissions)
meets a root uid, which is common on SSH hosts. Orca reported only that Claude
stopped before it finished starting, and retried four times. Classify that refusal
as fullAccessAsRoot with a next step, and show the CLI's own startup words behind
Details on the failure row and on the unsent message.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(native-chat): keep message Details to start failures; cover the exit path

Route the exit-error module and delivery notices to the managed e2e, explain its
timeout, and drop the exec non-null assertion. Match the zh Full access label.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(native-chat): type the refused fixture so the web typecheck passes

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-26 05:00:45 -07:00
committed by GitHub
co-authored by Claude
parent 5652de8872
commit f5b2cc5a79
31 changed files with 368 additions and 76 deletions
+12
View File
@@ -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',
@@ -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 () => {
@@ -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<boolean>
}
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)
@@ -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
)
}
@@ -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: () => {},
@@ -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,
@@ -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
@@ -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
@@ -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'
@@ -305,32 +305,59 @@ export function ProviderFrameRow({
}
const frame = block.providerFrame
return (
<details className="group text-xs text-chat-foreground-faint">
<summary className="flex cursor-pointer list-none items-center gap-2 rounded-md px-2 py-1 font-mono hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<span className="transition-transform motion-reduce:transition-none group-open:rotate-90">
›
</span>
{frame ? (
<NativeChatDetailsDisclosure
lead={
frame ? (
<span className="font-medium text-chat-foreground-strong">{frame.provider}</span>
) : null}
<span className="truncate">{summary ?? nativeChatProviderFrameSummary(block)}</span>
{frame?.payload.truncated ? (
) : null
}
summary={summary ?? nativeChatProviderFrameSummary(block)}
trail={
frame?.payload.truncated ? (
<span>
·{' '}
{translate('components.native-chat.providerFrame.byteLength', '{{value0}} bytes', {
value0: frame.payload.byteLength
})}
</span>
) : null}
) : null
}
>
{detail}
{detail && frame ? '\n\n' : ''}
{frame?.payload.head}
{frame?.payload.truncated ? '\n…' : ''}
</NativeChatDetailsDisclosure>
)
}
/** 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 (
<details className="group text-xs text-chat-foreground-faint">
<summary className="flex cursor-pointer list-none items-center gap-2 rounded-md px-2 py-1 font-mono hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<span className="transition-transform motion-reduce:transition-none group-open:rotate-90">
›
</span>
{lead}
<span className="truncate">{summary}</span>
{trail}
</summary>
<pre
data-native-chat-code-content
className="scrollbar-sleek mt-1 max-h-64 overflow-auto whitespace-pre-wrap rounded-md border border-border bg-muted p-2 font-mono text-chat-code text-foreground"
>
{detail}
{detail && frame ? '\n\n' : ''}
{frame?.payload.head}
{frame?.payload.truncated ? '\n…' : ''}
{children}
</pre>
</details>
)
@@ -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}
</div>
) : null}
{deliveryNotice?.detail ? (
<div className="max-w-[85%]">
<NativeChatDetailsDisclosure
summary={translate('components.native-chat.notices.details', 'Details')}
>
{deliveryNotice.detail}
</NativeChatDetailsDisclosure>
</div>
) : null}
</div>
)
}
@@ -149,6 +149,12 @@ const PIECES: Record<AgentSessionFailureCopyId, (values: AgentSessionFailureCopy
COPY.runtimeNotInstalled,
values
),
fullAccessAsRoot: (values) =>
translate(
'components.native-chat.failureWords.fullAccessAsRoot',
COPY.fullAccessAsRoot,
values
),
conversationOpenElsewhere: () =>
translate(
'components.native-chat.failureWords.conversationOpenElsewhere',
@@ -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' } })] }))
@@ -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 } : {})
})
}
}
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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 アカウントの設定で選択または追加してください。",
+1
View File
@@ -18684,6 +18684,7 @@
"claudeAccountSetupFailed": "선택한 Claude 계정을 설정할 수 없습니다. 다시 시도하거나 다른 계정을 선택하세요.",
"agentCommandNotRunnable": "{{agent}}의 설정 → 에이전트 → 명령은 Orca가 찾을 수 있는 프로그램 경로나 이름이어야 하며, 인수나 변수를 포함할 수 없습니다. 변경하거나 재설정하세요.",
"runtimeNotInstalled": "{{agent}}이(가) 이 채팅을 실행하는 컴퓨터에 설치되어 있지 않습니다. 최신 버전의 Orca에서 채팅을 통해 설치하세요.",
"fullAccessAsRoot": "{{agent}}은(는) root로 실행되는 동안 전체 액세스를 사용하지 않습니다. 이 채팅을 실행하는 컴퓨터의 설정 → 에이전트에서 {{agent}}에 다른 권한을 선택한 다음 새 채팅을 시작하세요.",
"conversationOpenElsewhere": "Codex가 아직 이 대화를 열어 두고 있습니다. 잠시 후 다시 시도하거나 다른 Codex 터미널을 닫으세요.",
"conversationOpenInTerminal": "{{agent}}이(가) 다른 터미널에서 이미 이 대화를 실행 중입니다. 그곳에서 {{agent}}을(를) 종료하거나 해당 터미널을 닫은 후 여기서 계속하세요.",
"chooseClaudeAccount": "Claude 계정 설정에서 선택하거나 추가하세요.",
+1
View File
@@ -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.",
+1
View File
@@ -18649,6 +18649,7 @@
"claudeAccountSetupFailed": "无法设置所选 Claude 账户。请重试或选择其他账户。",
"agentCommandNotRunnable": "{{agent}} 在设置 → 智能体中的命令必须是 Orca 能找到的程序路径或名称,且不能包含参数或变量。请修改或重置。",
"runtimeNotInstalled": "运行此聊天的计算机上未安装 {{agent}}。请在最新版本的 Orca 中从聊天里安装。",
"fullAccessAsRoot": "{{agent}} 以 root 身份运行时不会使用完全访问。请在运行此聊天的计算机的“设置”→“智能体”中为 {{agent}} 选择其他权限,然后开始新的聊天。",
"conversationOpenElsewhere": "Codex 仍在保持此对话打开。请稍后重试,或关闭其他 Codex 终端。",
"conversationOpenInTerminal": "{{agent}} 已在另一个终端中运行此对话。请退出那里的 {{agent}} 或关闭该终端,然后在这里继续。",
"chooseClaudeAccount": "请在 Claude 账户设置中选择或添加一个。",
+2
View File
@@ -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:
@@ -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)
}
+2 -22
View File
@@ -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) =>
+11
View File
@@ -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', () => {
+13
View File
@@ -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 extends Error>(
): 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
}
@@ -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',
@@ -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')
@@ -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',
@@ -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 })
})
})