diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.test.ts b/mobile/src/session/mobile-structured-agent-session-host-support.test.ts index fde0d2ac70a..643c6ccee80 100644 --- a/mobile/src/session/mobile-structured-agent-session-host-support.test.ts +++ b/mobile/src/session/mobile-structured-agent-session-host-support.test.ts @@ -9,6 +9,7 @@ import { AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' import { AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-create-capabilities' +import { AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-permission-modes-capability' import { structuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' describe('structuredAgentSessionHostSupport', () => { @@ -21,7 +22,8 @@ describe('structuredAgentSessionHostSupport', () => { quietRepeatedStop: false, statusFeed: false, sendOptions: false, - conversationStop: false + conversationStop: false, + permissionModes: false } expect(structuredAgentSessionHostSupport([])).toEqual(none) expect( @@ -48,5 +50,8 @@ describe('structuredAgentSessionHostSupport', () => { expect( structuredAgentSessionHostSupport([AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY]) ).toEqual({ ...none, conversationStop: true }) + expect( + structuredAgentSessionHostSupport([AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY]) + ).toEqual({ ...none, permissionModes: true }) }) }) diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.ts b/mobile/src/session/mobile-structured-agent-session-host-support.ts index abe1a19edbb..1a3269f6b19 100644 --- a/mobile/src/session/mobile-structured-agent-session-host-support.ts +++ b/mobile/src/session/mobile-structured-agent-session-host-support.ts @@ -8,6 +8,7 @@ import { AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' import { AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-create-capabilities' +import { AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-permission-modes-capability' /** Structured-session features the connected host advertised; null until the status probe answers. */ export type StructuredAgentSessionHostSupport = { @@ -26,6 +27,8 @@ export type StructuredAgentSessionHostSupport = { sendOptions: boolean /** A Stop naming no turn stops what the chat has in flight; an older host needs the turn. */ conversationStop: boolean + /** The host applies each chat's permission mode; 1.4.225–1.4.226 do without advertising it. */ + permissionModes: boolean } export function structuredAgentSessionHostSupport( @@ -39,6 +42,7 @@ export function structuredAgentSessionHostSupport( statusFeed: capabilities.includes(AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY), quietRepeatedStop: capabilities.includes(AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY), sendOptions: capabilities.includes(AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY), - conversationStop: capabilities.includes(AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY) + conversationStop: capabilities.includes(AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY), + permissionModes: capabilities.includes(AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY) } } diff --git a/mobile/src/session/use-mobile-structured-agent-options.first-frame.test.ts b/mobile/src/session/use-mobile-structured-agent-options.first-frame.test.ts index 9c71931ad89..60eaa6d75a5 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.first-frame.test.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.first-frame.test.ts @@ -49,7 +49,7 @@ async function mount(initial: Props) { enabled: true, fence: props.fence, mutate, - takesSendOptions: true + hostSupport: { sendOptions: true, permissionModes: true } }) frames.push(modelChoices(options.optionSnapshot)) return null diff --git a/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts b/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts index 1fbfb9cf25b..bf7d315c1ef 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.permission-mode.test.ts @@ -45,7 +45,13 @@ function optionsClient(...modes: (string | undefined)[]) { } type Controller = ReturnType -type ProbeProps = { client: RpcClient; fence: number; optionStamp: string; agent?: string } +type ProbeProps = { + client: RpcClient + fence: number + optionStamp: string + agent?: string + hostAdvertisesPermissionModes?: boolean | null +} const mutate: StructuredAgentSessionMutate = vi.fn() @@ -60,7 +66,10 @@ async function mountOptions(props: ProbeProps) { fence: probe.fence, mutate, optionStamp: probe.optionStamp, - takesSendOptions: true + hostSupport: + probe.hostAdvertisesPermissionModes === null + ? null + : { sendOptions: true, permissionModes: probe.hostAdvertisesPermissionModes ?? true } }) return null } @@ -103,6 +112,44 @@ async function mountOptions(props: ProbeProps) { } } +// STA-10671: a 1.4.224 host ignores per-chat modes and runs its own Agent Permissions. +describe('the permission pill against a host predating per-chat modes', () => { + it("says the chat runs the host's Agent Permissions, and offers nothing to pick", async () => { + const options = await mountOptions({ + client: optionsClient(undefined).client, + fence: 1, + optionStamp: 'gpt-live|', + hostAdvertisesPermissionModes: false + }) + expect(options.permission()).toBe('host') + expect(options.offered()).toEqual(['host']) + await options.unmount() + }) + + // 1.4.225–1.4.226 apply modes without advertising the capability: their stated mode wins. + it('shows the mode a host states even when it does not advertise per-chat modes', async () => { + const options = await mountOptions({ + client: optionsClient('ask').client, + fence: 1, + optionStamp: 'gpt-live|ask', + hostAdvertisesPermissionModes: false + }) + expect(options.permission()).toBe('ask') + await options.unmount() + }) + + it('shows no host pill before the host capability probe answers', async () => { + const options = await mountOptions({ + client: optionsClient(undefined).client, + fence: 1, + optionStamp: 'gpt-live|', + hostAdvertisesPermissionModes: null + }) + expect(options.permission()).toBeNull() + await options.unmount() + }) +}) + describe('the permission mode on the phone', () => { it('re-reads once when the host status moves, so a desktop pick shows here', async () => { const rpc = optionsClient('ask', 'full-access') diff --git a/mobile/src/session/use-mobile-structured-agent-options.test.ts b/mobile/src/session/use-mobile-structured-agent-options.test.ts index 9c5e998aa9a..918c614e962 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.test.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.test.ts @@ -158,7 +158,7 @@ function Probe(props: ProbeProps): null { enabled: true, fence: props.fence, mutate: props.mutate, - takesSendOptions: true + hostSupport: { sendOptions: true, permissionModes: true } }) ) return null diff --git a/mobile/src/session/use-mobile-structured-agent-options.ts b/mobile/src/session/use-mobile-structured-agent-options.ts index 7a109f49f70..788451e164d 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.ts @@ -6,10 +6,7 @@ import type { AgentSessionOptionResult, AgentSessionOptionsResult } from '../../../src/shared/agent-session-wire' -import type { - SessionOptionsSurface, - SessionOptionValue -} from '../../../src/shared/native-chat-session-options' +import type { SessionOptionValue } from '../../../src/shared/native-chat-session-options' import { applyStructuredAgentSessionOptions, canSetStructuredAgentSessionOption, @@ -23,8 +20,13 @@ import { structuredAgentSessionOptionView, structuredAgentSessionShownSnapshot } from '../../../src/shared/structured-agent-session-option-view' -import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../src/shared/agent-chat-permission-mode' +import { + AGENT_CHAT_PERMISSION_MODE_OPTION_ID, + agentChatPermissionModes +} from '../../../src/shared/agent-chat-permission-mode' +import { legacyHostPermissionDescriptor } from '../../../src/shared/structured-agent-session-permission-option' import type { RpcClient } from '../transport/rpc-client' +import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' import { callAgentSession, type StructuredAgentSessionMutate @@ -36,6 +38,7 @@ import { mobileCreatedStructuredSession } from './mobile-created-structured-sessions' import { encodeStructuredAgentSessionOptionValue } from '../../../src/shared/structured-agent-session-option-codec' +import { useMobileStructuredOptionSurface } from './use-mobile-structured-option-surface' export function useMobileStructuredAgentOptions(args: { agent: string | null @@ -46,9 +49,11 @@ export function useMobileStructuredAgentOptions(args: { mutate: StructuredAgentSessionMutate /** Moves when the host's status says a session option changed; each move re-reads them. */ optionStamp?: string - takesSendOptions: boolean + /** What the host advertised; null until its status probe answers. */ + hostSupport: Pick | null }) { - const { agent, client, enabled, fence, mutate, optionStamp, sessionId, takesSendOptions } = args + const { agent, client, enabled, fence, mutate, optionStamp, sessionId, hostSupport } = args + const takesSendOptions = hostSupport?.sendOptions === true // Every agent's seed, as on the desktop: a built-in list or the provider-default pill. const optionCatalog = useMemo( () => (agent ? structuredAgentSessionSeedCatalog(agent) : null), @@ -69,10 +74,6 @@ export function useMobileStructuredAgentOptions(args: { }, [] ) - const [optionPickerRequest, setOptionPickerRequest] = useState<{ - id: string - sequence: number - } | null>(null) const [conversationSupport, setConversationSupport] = useState<{ sessionId: string commands: readonly AgentSessionConversationCommand[] @@ -80,6 +81,8 @@ export function useMobileStructuredAgentOptions(args: { const [imageSupport, setImageSupport] = useState<{ sessionId: string acceptsImages: boolean | undefined + /** The answer named the chat's permission mode, as only a host applying modes does. */ + statesPermissionMode: boolean } | null>(null) const optionIdentityRef = useRef(`${agent}:${sessionId}`) @@ -137,7 +140,11 @@ export function useMobileStructuredAgentOptions(args: { forgetMobileCreatedStructuredSession(sessionId) // Not an option value: a pick made during the read leaves it standing. if (!stale) { - setImageSupport({ sessionId, acceptsImages: result.acceptsImages }) + setImageSupport({ + sessionId, + acceptsImages: result.acceptsImages, + statesPermissionMode: result.current?.permissionMode !== undefined + }) } if (!stale && optionMutationGeneration.current === readGeneration) { setConversationSupport({ sessionId, commands: result.conversationCommands ?? [] }) @@ -163,10 +170,18 @@ export function useMobileStructuredAgentOptions(args: { updateOptionState ]) - const optionSnapshot = useMemo( - () => structuredAgentSessionShownSnapshot(optionState, takesSendOptions), - [optionState, takesSendOptions] - ) + // A host predating per-chat modes (1.4.224 and older) states none: say what its chats run instead. + const legacyHostPermissions = + agent !== null && + agentChatPermissionModes(agent) !== null && + hostSupport?.permissionModes === false && + imageSupport?.sessionId === sessionId && + !imageSupport.statesPermissionMode && + optionState.permissionMode === null + const optionSnapshot = useMemo(() => { + const shown = structuredAgentSessionShownSnapshot(optionState, takesSendOptions) + return legacyHostPermissions ? [...shown, legacyHostPermissionDescriptor()] : shown + }, [legacyHostPermissions, optionState, takesSendOptions]) const setStructuredOption = useCallback( async (id: string, value: SessionOptionValue): Promise => { @@ -273,36 +288,13 @@ export function useMobileStructuredAgentOptions(args: { [agent, client, mutate, optionCatalog, sessionId, takesSendOptions, updateOptionState] ) - const invokeStructuredOption = useCallback( - async (id: string) => { - if (!optionSnapshot.some((entry) => entry.id === id)) { - return false - } - setOptionPickerRequest((current) => ({ id, sequence: (current?.sequence ?? 0) + 1 })) - return true - }, - [optionSnapshot] - ) - - const setOption = useCallback( - async (id: string, value: SessionOptionValue) => { - await setStructuredOption(id, value) - return { - snapshot: structuredAgentSessionShownSnapshot(optionStateRef.current, takesSendOptions) - } - }, - [setStructuredOption, takesSendOptions] - ) - - const optionSurface = useMemo( - () => ({ - getSnapshot: () => optionSnapshot, - setOption, - invokeAction: async () => ({ snapshot: optionSnapshot }), - subscribe: () => () => {} - }), - [optionSnapshot, setOption] - ) + const { optionPickerRequest, invokeStructuredOption, optionSurface } = + useMobileStructuredOptionSurface({ + optionSnapshot, + setStructuredOption, + optionStateRef, + takesSendOptions + }) return { optionPickerRequest, diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 2dbf33a26f1..c49a9f7afaa 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -115,7 +115,7 @@ export function useMobileStructuredAgentSession(args: { fence: state.fence, mutate, optionStamp, - takesSendOptions: hostSupport?.sendOptions === true + hostSupport }) const { conversationCommands, invokeStructuredOption, optionSnapshot, setStructuredOption } = options diff --git a/mobile/src/session/use-mobile-structured-option-surface.ts b/mobile/src/session/use-mobile-structured-option-surface.ts new file mode 100644 index 00000000000..9568e8060aa --- /dev/null +++ b/mobile/src/session/use-mobile-structured-option-surface.ts @@ -0,0 +1,56 @@ +import { useCallback, useMemo, useState, type RefObject } from 'react' +import { structuredAgentSessionShownSnapshot } from '../../../src/shared/structured-agent-session-option-view' +import type { StructuredAgentSessionOptionState } from '../../../src/shared/structured-agent-session-options' +import type { + SessionOptionDescriptor, + SessionOptionsSurface, + SessionOptionValue +} from '../../../src/shared/native-chat-session-options' + +/** The picker surface over a structured chat's options: picks, `/`-opened pickers, snapshots. */ +export function useMobileStructuredOptionSurface(args: { + optionSnapshot: SessionOptionDescriptor[] + setStructuredOption: (id: string, value: SessionOptionValue) => Promise + /** The latest option state, which a settled pick's snapshot is read from. */ + optionStateRef: RefObject + takesSendOptions: boolean +}) { + const { optionSnapshot, optionStateRef, setStructuredOption, takesSendOptions } = args + const [optionPickerRequest, setOptionPickerRequest] = useState<{ + id: string + sequence: number + } | null>(null) + + const invokeStructuredOption = useCallback( + async (id: string) => { + if (!optionSnapshot.some((entry) => entry.id === id)) { + return false + } + setOptionPickerRequest((current) => ({ id, sequence: (current?.sequence ?? 0) + 1 })) + return true + }, + [optionSnapshot] + ) + + const setOption = useCallback( + async (id: string, value: SessionOptionValue) => { + await setStructuredOption(id, value) + return { + snapshot: structuredAgentSessionShownSnapshot(optionStateRef.current, takesSendOptions) + } + }, + [optionStateRef, setStructuredOption, takesSendOptions] + ) + + const optionSurface = useMemo( + () => ({ + getSnapshot: () => optionSnapshot, + setOption, + invokeAction: async () => ({ snapshot: optionSnapshot }), + subscribe: () => () => {} + }), + [optionSnapshot, setOption] + ) + + return { optionPickerRequest, invokeStructuredOption, optionSurface } +} diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 7b6f15b4116..ce8ab5c4ad1 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -171,7 +171,12 @@ const NativeChatComposerPane = forwardRef[] + /** Also true while the host may take no turn: Continue starts one. */ isWorking: boolean /** Stable; a Continue click is the user's newer action, so it clears the composer's error. */ reportComposerError: (text: null) => void diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredGoalStrip.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredGoalStrip.tsx new file mode 100644 index 00000000000..23df44d5627 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatStructuredGoalStrip.tsx @@ -0,0 +1,46 @@ +import { NativeChatThreadGoalBanner } from './NativeChatThreadGoalBanner' +import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types' +import type { StructuredAgentSessionThreadGoal } from './use-structured-agent-session-thread-goal' + +/** A structured chat's open goal above its composer. */ +export function NativeChatStructuredGoalStrip(props: { + sessionId: string + threadGoal: StructuredAgentSessionThreadGoal | null | undefined + transport: NativeChatStructuredComposerTransport + isWorking: boolean + isVisible: boolean + turnId: string | null + workingStartedAt: number | null | undefined +}): React.JSX.Element | null { + const { threadGoal, transport } = props + if (!threadGoal?.goal) { + return null + } + // A host held for its permission mode takes no turn, and resuming or clearing a goal starts one. + const startsHeld = transport.sendBlockedReason !== undefined + return ( + { + const startsTurn = + change.kind === 'set' || + (change.kind === 'status' && change.status === 'active') || + (change.kind === 'clear' && !threadGoal.hostChanges) + if (startsHeld && startsTurn) { + return + } + if (change.kind === 'clear' && !threadGoal.hostChanges) { + transport.onSubmitted?.() + } + void threadGoal.change(change) + }} + /> + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.orca-stop-continue.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.orca-stop-continue.test.tsx index 86fceb7065a..7ddccafd028 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.orca-stop-continue.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.orca-stop-continue.test.tsx @@ -36,6 +36,10 @@ vi.mock('@/runtime/structured-agent-session-host-capability', async (importOrigi : original.useStructuredAgentSessionHostCapabilityState(...args) } }) +// A host that applies the chat's permission mode; the hold has its own tests. +vi.mock('@/runtime/structured-chat-host-permission', () => ({ + useStructuredChatHostPermissionVerdict: () => ({ kind: 'allowed' }) +})) // The transcript's real rows, projected from the journal; only the list's windowing is left out. vi.mock('./NativeChatMessageList', async () => { const { MessageRow } = await import('./NativeChatMessageRow') diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index a277720be6c..e3e157ad057 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -46,12 +46,13 @@ import { chatApprovalFromJournal } from './native-chat-interactive-prompt' import { useAppStore } from '../../store' import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' import { useNativeChatStructuredComposerTransport } from './use-native-chat-structured-composer-transport' -import { NativeChatThreadGoalBanner } from './NativeChatThreadGoalBanner' +import { NativeChatStructuredGoalStrip } from './NativeChatStructuredGoalStrip' import { structuredAgentSessionReadFailureNotice } from './structured-agent-session-read-failure-notice' import { useStructuredAgentSessionDeliveryNotices } from './use-structured-agent-session-delivery-notices' import { dispatchWasWithdrawn } from '../../../../shared/structured-agent-session-dispatch-rejection' import { useNativeChatHostOutage } from './use-native-chat-host-outage' import { useNativeChatHostOutageNotice } from './use-native-chat-host-outage-notice' +import { useNativeChatHostPermissionGate } from './use-native-chat-host-permission-gate' import { useNativeChatAvailabilityNotice } from './use-native-chat-availability-notice' import { useStructuredAgentSessionStartFailureFacts } from './use-structured-agent-session-start-failure-facts' import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' @@ -130,16 +131,18 @@ export function NativeChatStructuredSession( props.agent, hostOutage === null ) - const submits = useStructuredNativeChatSubmitReveal(controller, provisionalLaunch.retry) + const agentLabel = structuredAgentLabel(props.agent) + const hostPermission = useNativeChatHostPermissionGate(props.target, props.agent, agentLabel) + const hostHeld = hostPermission.sendBlockedReason !== null + const submits = useStructuredNativeChatSubmitReveal(controller, provisionalLaunch.retry, hostHeld) const { revealLatest } = submits const promptResponse = useStructuredPromptResponseHold(submits.respond) - const agentLabel = structuredAgentLabel(props.agent) const continuation = useNativeChatInterruptedContinuation({ target: props.target, sessionId: props.sessionId, journalItems: controller.journalItems, submissions: controller.submissions, - isWorking: controller.isWorking, + isWorking: controller.isWorking || hostHeld, reportComposerError }) const needsFailureFacts = @@ -220,7 +223,8 @@ export function NativeChatStructuredSession( optionPickerRequest, setOptionPickerRequest, onError: reportComposerError, - onSubmitted: revealLatest + onSubmitted: revealLatest, + sendBlockedReason: hostPermission.sendBlockedReason }) // Said once: on the pane when the failure took it, else in the composer's notices. @@ -248,7 +252,8 @@ export function NativeChatStructuredSession( sessionError, composerError: composerError ?? continuation.continueError, claudeSignIn, - availability + availability, + hostPermission: hostPermission.notice }) if (hostNotice) { notices.push(hostNotice) @@ -329,7 +334,7 @@ export function NativeChatStructuredSession( chatWorktreeId={ownerWorktreeId ?? null} agentName={agentLabel} statedFailures={startFailures} - steerHeld={stopControls.stopping} + steerHeld={stopControls.stopping || hostHeld} focusComposer={focusComposer} /> - {!prompt && controller.threadGoal?.goal ? ( - { - if (change.kind === 'clear' && !controller.threadGoal?.hostChanges) { - structuredTransport.onSubmitted?.() - } - void controller.threadGoal?.change(change) - }} + turnId={controller.turnId} + workingStartedAt={controller.workingStartedAt} /> - ) : null} + )} {/* Prompt cards take the composer's slot, below the background-task dock. */} {composerShown ? null : } {prompt && approval ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.tsx b/src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.tsx index 60617c86855..fa8316e5cd9 100644 --- a/src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.tsx +++ b/src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.tsx @@ -51,6 +51,8 @@ export function NativeChatThreadGoalBanner(props: { hostChanges: boolean /** The composer cannot admit another message or the session is working. */ sendUnavailable: boolean + /** The host may take no turn now: Resume, and a clear that enters a turn, are held. */ + startsHeld?: boolean isVisible: boolean /** The session's running turn, or null when idle; goal time accrues only while one runs. */ runningTurn: { startedAt: number | null } | null @@ -90,7 +92,10 @@ export function NativeChatThreadGoalBanner(props: { onChange({ kind: 'clear' })} > @@ -106,7 +111,7 @@ export function NativeChatThreadGoalBanner(props: { ) : statusChange === 'active' ? ( onChange({ kind: 'status', status: 'active' })} > diff --git a/src/renderer/src/components/native-chat/native-chat-composer-types.ts b/src/renderer/src/components/native-chat/native-chat-composer-types.ts index e6542d21bff..a0aa6cad10f 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-types.ts @@ -39,6 +39,8 @@ export type NativeChatStructuredComposerTransport = { ) => boolean | 'queued' /** A send is out: Send stays disabled, and a send returns false, until it settles. */ sendOut?: boolean + /** Send is held for this reason, which the disabled button says. */ + sendBlockedReason?: string dispatchCommand: (text: string) => Promise optionsSurface: SessionOptionsSurface optionSnapshot: SessionOptionDescriptor[] diff --git a/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts b/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts index 6f28e7b6990..f890dc5bdfa 100644 --- a/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts +++ b/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts @@ -78,7 +78,8 @@ export function structuredSessionNotices({ sessionError, composerError, claudeSignIn = null, - availability = null + availability = null, + hostPermission = null }: { launch: { lifecycle: StructuredAgentSessionLaunchLifecycle | null @@ -91,6 +92,8 @@ export function structuredSessionNotices({ claudeSignIn?: NativeChatClaudeSignIn | null /** Why the host says no chat can start here, from `useNativeChatAvailabilityNotice`. */ availability?: NativeChatComposerNotice | null + /** Why this chat's host may not take a turn, from `useNativeChatHostPermissionGate`. */ + hostPermission?: NativeChatComposerNotice | null }): NativeChatComposerNotice[] { const launchNotice = nativeChatLaunchNotice({ lifecycle: launch.lifecycle, @@ -100,6 +103,7 @@ export function structuredSessionNotices({ claudeSignIn }) return [ + ...(hostPermission ? [hostPermission] : []), ...(availability ? [availability] : []), ...(launchNotice && !sessionError ? [launchNotice] : []), ...(sessionError diff --git a/src/renderer/src/components/native-chat/use-native-chat-host-permission-gate.ts b/src/renderer/src/components/native-chat/use-native-chat-host-permission-gate.ts new file mode 100644 index 00000000000..08307c47565 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-host-permission-gate.ts @@ -0,0 +1,42 @@ +import { translate } from '@/i18n/i18n' +import { hostPermissionRefusedText } from '@/lib/structured-agent-session-launch-permission-notice' +import { useStructuredChatHostPermissionVerdict } from '@/runtime/structured-chat-host-permission' +import { useAppStore } from '@/store' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import type { NativeChatComposerNotice } from './native-chat-composer-notice' + +export type NativeChatHostPermissionGate = { + /** Present while Send is held: what the user can do about it. */ + sendBlockedReason: string | null + notice: NativeChatComposerNotice | null +} + +const OPEN: NativeChatHostPermissionGate = { sendBlockedReason: null, notice: null } + +/** Holds Send on a paired host that would run this chat with more access than this client asks + * for: an older host ignores per-chat modes and runs its own Agent Permissions. */ +export function useNativeChatHostPermissionGate( + target: RuntimeClientTarget, + agent: string, + agentLabel: string +): NativeChatHostPermissionGate { + const verdict = useStructuredChatHostPermissionVerdict(target, agent) + const requested = useAppStore((state) => state.settings?.nativeChatPermissionMode) + if (verdict.kind === 'allowed') { + return OPEN + } + if (verdict.kind === 'checking') { + return { + sendBlockedReason: translate( + 'components.native-chat.hostPermission.checking', + "Checking this host's chat permissions…" + ), + notice: null + } + } + const text = hostPermissionRefusedText(agentLabel, verdict.kind, requested) + return { + sendBlockedReason: text, + notice: { key: `host-permission:${verdict.kind}`, kind: 'host', tone: 'warning', text } + } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx index f1615691bdb..7933b85a4bf 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx @@ -145,6 +145,21 @@ describe('reports the sends that bring the latest into view', () => { }) }) +// STA-10671: an older paired host would run the turn without the approval the chat asks for. +describe('a send held by its host', () => { + it('starts no turn, but still lets an option command through', async () => { + const { send, structuredTransport } = harness('claude') + structuredTransport.sendBlockedReason = 'Update Orca on this host to use Ask for approval.' + await send('hello', []) + await send('/compact', []) + expect(structuredTransport.send).not.toHaveBeenCalled() + expect(structuredTransport.onError).not.toHaveBeenCalled() + + await send('/model', []) + expect(structuredTransport.onError).toHaveBeenCalled() + }) +}) + describe("says a failed send in Orca's words, with the error it hit apart", () => { it('keeps a main-process failure apart from the words saying the message was not sent', async () => { const { send, structuredTransport } = harness('claude') diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts index ca6a6482a3a..9d065e6a923 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts @@ -67,6 +67,14 @@ export function useNativeChatStructuredComposerSend({ return } const hostCommand = isNativeChatStructuredHostCommand(text, agent, structuredTransport) + // A held host takes no turn, and its notice says why; an option command only changes what + // the next turn runs with. + if ( + structuredTransport.sendBlockedReason !== undefined && + (!hostCommand || structuredAgentSessionCommandChangesConversation(text)) + ) { + return + } // A command picked while images await re-attaching would send them without a file. const attachAgain = nativeChatAttachImagesAgainReason(attachments) if (attachAgain) { diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts index 3e4a90395a7..05f783fb703 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts @@ -25,9 +25,12 @@ export function useNativeChatStructuredComposerTransport(args: { setOptionPickerRequest: Dispatch> onError: NativeChatStructuredComposerTransport['onError'] onSubmitted: () => void + /** Present while this chat's host may not take a turn; Send is held and a send refused. */ + sendBlockedReason?: string | null }): NativeChatStructuredComposerTransport { const { props, controller, sendThroughLaunch, starting, worktreeId, optionPickerRequest } = args const { setOptionPickerRequest, onError, onSubmitted } = args + const sendBlockedReason = args.sendBlockedReason ?? null const acceptsImages = useStructuredAgentAcceptsImages( props.target, props.agent, @@ -36,10 +39,14 @@ export function useNativeChatStructuredComposerTransport(args: { return useMemo((): NativeChatStructuredComposerTransport => { const threadGoal = controller.threadGoal const setThreadGoalObjective = threadGoal?.hostChanges - ? (objective: string) => threadGoal.change({ kind: 'set', objective }) + ? (objective: string) => + sendBlockedReason === null + ? threadGoal.change({ kind: 'set', objective }) + : Promise.resolve(false) : null return { send: (text, attachments) => + sendBlockedReason === null && sendThroughLaunch(text, attachments.length > 0, () => controller.send( text, @@ -65,6 +72,7 @@ export function useNativeChatStructuredComposerTransport(args: { }), ...(setThreadGoalObjective ? { threadGoal: { setObjective: setThreadGoalObjective } } : {}), sendOut: controller.sendOut || starting, + ...(sendBlockedReason !== null ? { sendBlockedReason } : {}), optionsSurface: controller.optionSurface, conversationCommands: controller.conversationCommands, optionSnapshot: controller.optionSnapshot, @@ -90,6 +98,7 @@ export function useNativeChatStructuredComposerTransport(args: { props.agent, props.sessionId, props.target, + sendBlockedReason, sendThroughLaunch, setOptionPickerRequest, starting diff --git a/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.test.ts b/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.test.ts index 6720ac269dd..28e25b0fa16 100644 --- a/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.test.ts +++ b/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.test.ts @@ -38,3 +38,36 @@ it('reveals before launch Retry, but waits for a successful queue Resume', async expect(order).toEqual(['reveal', 'launch', 'resume', 'reveal']) }) + +// STA-10671: an older host held for its permission mode would run a queued card unprompted. +it('sends no queued card and retries no start on a host held for its permission mode', async () => { + const queuedMessages = { + queueCapable: true, + clearCapable: true, + cards: [], + pause: { reason: 'stopped' }, + resuming: false, + clear: vi.fn(async () => true), + resume: vi.fn(async () => true), + steer: vi.fn(async () => {}), + remove: vi.fn(async () => {}), + edit: vi.fn(async () => {}), + editCapable: false, + editor: undefined, + steerNewest: vi.fn(() => true) + } + const controller = { respond: vi.fn(async () => null), queuedMessages } + const retryLaunch = vi.fn() + const { result } = renderHook(() => + useStructuredNativeChatSubmitReveal(controller, retryLaunch, true) + ) + + await act(() => result.current.queuedMessages.resume()) + await act(() => result.current.queuedMessages.steer('m1')) + expect(result.current.queuedMessages.steerNewest()).toBe(false) + expect(queuedMessages.resume).not.toHaveBeenCalled() + expect(queuedMessages.steer).not.toHaveBeenCalled() + expect(queuedMessages.steerNewest).not.toHaveBeenCalled() + act(() => result.current.retryLaunch()) + expect(retryLaunch).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.ts b/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.ts index 570347ee041..60082d7c63f 100644 --- a/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.ts +++ b/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.ts @@ -14,7 +14,9 @@ type StructuredController = ReturnType export function useStructuredNativeChatSubmitReveal( controller: Pick, /** Relaunches a start that failed. */ - retryLaunch: () => void + retryLaunch: () => void, + /** The host may take no turn: no card is sent now or resumed, and no failed start retried. */ + startsHeld = false ): { messageListRef: React.RefObject /** For the composer, which reveals as it sends. */ @@ -26,9 +28,12 @@ export function useStructuredNativeChatSubmitReveal( const { messageListRef, revealLatest } = useNativeChatRevealLatest() const { respond, queuedMessages } = controller const revealingRetryLaunch = useCallback(() => { + if (startsHeld) { + return + } revealLatest() retryLaunch() - }, [retryLaunch, revealLatest]) + }, [retryLaunch, revealLatest, startsHeld]) const revealingRespond = useCallback( (...args) => { revealLatest() @@ -45,6 +50,14 @@ export function useStructuredNativeChatSubmitReveal( } return resumed } + if (startsHeld) { + return { + ...queuedMessages, + steer: async () => undefined, + resume: async () => false, + steerNewest: () => false + } + } return { ...queuedMessages, steer: (messageId) => { @@ -60,7 +73,7 @@ export function useStructuredNativeChatSubmitReveal( return steered } } - }, [queuedMessages, revealLatest]) + }, [queuedMessages, revealLatest, startsHeld]) return { messageListRef, revealLatest, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f785149daf7..d34850e066c 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18927,6 +18927,12 @@ }, "claudeSignIn": { "signingIn": "Signing in…" + }, + "hostPermission": { + "checking": "Checking this host's chat permissions…", + "launchRefused": "Orca did not start a {{value0}} chat", + "outdatedHost": "This host runs an older Orca that ignores chat permissions, so {{value0}} would run here without asking. Update Orca on this host to use {{value1}}.", + "unverified": "Orca couldn't confirm this host applies chat permissions. Reconnect to it to send." } }, "tab": { diff --git a/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts b/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts index 2cdb6dbe501..5cc2d50b679 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts @@ -6,7 +6,8 @@ const mocks = vi.hoisted(() => ({ createSupport: vi.fn(), toastInfo: vi.fn(), toastError: vi.fn(), - launchAgentInNewTab: vi.fn() + launchAgentInNewTab: vi.fn(), + hostPermission: vi.fn() })) vi.mock('sonner', () => ({ toast: { info: mocks.toastInfo, error: mocks.toastError } })) @@ -23,6 +24,10 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ }) })) +vi.mock('@/runtime/structured-chat-host-permission', () => ({ + readStructuredChatHostPermissionVerdict: mocks.hostPermission +})) + import { useAppStore } from '@/store' import { adoptAgentSessionLaunchVerdict } from './agent-session-launch-plan' import { beginStructuredAgentSessionProvisionalLaunch } from './structured-agent-session-provisional-tab' @@ -68,6 +73,7 @@ function expectNoChatCommitted(): void { beforeEach(() => { vi.clearAllMocks() + mocks.hostPermission.mockResolvedValue({ kind: 'allowed' }) localStorage.clear() useAppStore.setState({ activeWorktreeId: WORKTREE, @@ -210,6 +216,33 @@ describe('a structured chat launch on a paired server', () => { launch?.cancel() }) + // An older server ignores the chat's mode and would run Claude with its own bypass flag. + it.each(['refused', 'unverified'] as const)( + 'starts nothing on a server whose permission verdict is %s, and says so once', + async (kind) => { + mocks.createSupport.mockResolvedValue({ supported: true }) + mocks.hostPermission.mockResolvedValue( + kind === 'refused' ? { kind, hostMode: 'full-access' } : { kind } + ) + + const launch = beginStructuredAgentSessionProvisionalLaunch({ + plan: pairedPlan(), + hooks: {}, + onHostDeclined: vi.fn() + }) + const settlement = await launch?.settlement + + expect(mocks.hostPermission).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'server-1' }, + 'claude' + ) + expect(settlement).toEqual(expect.objectContaining({ kind: 'failed', notified: true })) + expect(mocks.toastError).toHaveBeenCalledTimes(1) + expect(mocks.launchAgentInNewTab).not.toHaveBeenCalled() + expectNoChatCommitted() + } + ) + it('opens the chat on the server that admitted it', async () => { mocks.createSupport.mockResolvedValue({ supported: true }) @@ -307,6 +340,20 @@ describe('a structured chat launch on this machine', () => { expectNoChatCommitted() }) + it('never asks this machine for a permission verdict: it applies every chat mode', async () => { + mocks.createSupport.mockResolvedValue({ supported: true }) + const launch = beginStructuredAgentSessionProvisionalLaunch({ + plan: localPlan(), + hooks: {}, + onHostDeclined: vi.fn() + }) + await vi.waitFor(() => + expect(useAppStore.getState().unifiedTabsByWorktree[WORKTREE]).toHaveLength(1) + ) + expect(mocks.hostPermission).not.toHaveBeenCalled() + launch?.cancel() + }) + it('opens the chat once this machine admits it', async () => { mocks.createSupport.mockResolvedValue({ supported: true }) diff --git a/src/renderer/src/lib/structured-agent-session-launch-admission.ts b/src/renderer/src/lib/structured-agent-session-launch-admission.ts index d44f0ea4ca8..f767a4313fe 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-admission.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-admission.ts @@ -23,6 +23,8 @@ import type { StructuredPromptDeliveryResult } from '@/lib/structured-agent-sess import type { LaunchAgentInNewTabArgs } from '@/lib/launch-agent-in-new-tab' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' +import { readStructuredChatHostPermissionVerdict } from '@/runtime/structured-chat-host-permission' +import { notifyHostPermissionRefused } from '@/lib/structured-agent-session-launch-permission-notice' /** What a host's "no" opens instead: the terminal the launch would have opened had it known. */ export type StructuredLaunchTerminal = { @@ -208,6 +210,23 @@ export function beginHostAdmittedStructuredLaunch(args: { ).then(resolveDelivery, () => resolveDelivery(NOT_DELIVERED)) return terminal.opened ? { kind: 'terminal' } : { kind: 'cancelled', sessionId: null } } + // A host that would run the chat with more access than asked for gets nothing to start. + const permission = paired + ? await readStructuredChatHostPermissionVerdict(args.target, plan.agent) + : { kind: 'allowed' as const } + if (isCancelled()) { + resolveDelivery(NOT_DELIVERED) + return { kind: 'cancelled', sessionId: null } + } + if (permission.kind !== 'allowed') { + notifyHostPermissionRefused(plan.agent, permission.kind) + resolveDelivery({ delivered: false, failureNotified: true }) + return { + kind: 'failed', + error: new Error('structured chat host runs with more access than asked for'), + notified: true + } + } admitted = args.openAdmitted( admission.kind === 'admitted' ? admission.seedOptions : undefined ) diff --git a/src/renderer/src/lib/structured-agent-session-launch-permission-notice.ts b/src/renderer/src/lib/structured-agent-session-launch-permission-notice.ts new file mode 100644 index 00000000000..86ff808f0a5 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-permission-notice.ts @@ -0,0 +1,42 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { isAgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode' +import type { TuiAgent } from '../../../shared/tui-agent' +import { nativeChatPermissionModeLabel } from '@/components/native-chat/native-chat-permission-mode-labels' +import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' + +/** Why a paired host may not run this chat: it would not apply the permission mode asked for. */ +export function hostPermissionRefusedText( + agentLabel: string, + kind: 'refused' | 'unverified', + requested: unknown = useAppStore.getState().settings?.nativeChatPermissionMode +): string { + return kind === 'unverified' + ? translate( + 'components.native-chat.hostPermission.unverified', + "Orca couldn't confirm this host applies chat permissions. Reconnect to it to send." + ) + : translate( + 'components.native-chat.hostPermission.outdatedHost', + 'This host runs an older Orca that ignores chat permissions, so {{value0}} would run here without asking. Update Orca on this host to use {{value1}}.', + { + value0: agentLabel, + value1: nativeChatPermissionModeLabel( + isAgentChatPermissionMode(requested) ? requested : 'ask' + ) + } + ) +} + +export function notifyHostPermissionRefused(agent: TuiAgent, kind: 'refused' | 'unverified'): void { + const agentLabel = structuredAgentLabel(agent) + toast.error( + translate( + 'components.native-chat.hostPermission.launchRefused', + 'Orca did not start a {{value0}} chat', + { value0: agentLabel } + ), + { description: hostPermissionRefusedText(agentLabel, kind) } + ) +} diff --git a/src/renderer/src/runtime/structured-chat-host-permission.test.ts b/src/renderer/src/runtime/structured-chat-host-permission.test.ts new file mode 100644 index 00000000000..6965f8dd25f --- /dev/null +++ b/src/renderer/src/runtime/structured-chat-host-permission.test.ts @@ -0,0 +1,150 @@ +// @vitest-environment happy-dom +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => { + const contactListeners: (() => void)[] = [] + return { supportsCapability: vi.fn(), callRuntimeRpc: vi.fn(), contactListeners } +}) + +vi.mock('./runtime-rpc-client', () => ({ + runtimeEnvironmentSupportsCapability: mocks.supportsCapability, + callRuntimeRpc: mocks.callRuntimeRpc +})) +vi.mock('@/store', () => { + const state = { settings: { nativeChatPermissionMode: 'ask' } } + const useAppStore = (select: (s: typeof state) => unknown) => select(state) + useAppStore.getState = () => state + return { useAppStore } +}) +vi.mock('./runtime-host-contact-regained', () => ({ + subscribeRuntimeHostContactRegained: (_id: string, listener: () => void) => { + mocks.contactListeners.push(listener) + return () => undefined + } +})) + +import { + readStructuredChatHostPermissionVerdict, + useStructuredChatHostPermissionVerdict +} from './structured-chat-host-permission' +import { AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY } from '../../../shared/agent-session-permission-modes-capability' + +const PAIRED = { kind: 'environment', environmentId: 'server-1' } as const + +beforeEach(() => { + vi.clearAllMocks() + mocks.supportsCapability.mockResolvedValue(false) +}) + +describe('the permission verdict on a paired host', () => { + it('lets a chat run on a host advertising per-chat modes without reading its settings', async () => { + mocks.supportsCapability.mockResolvedValue(true) + + await expect(readStructuredChatHostPermissionVerdict(PAIRED, 'claude', 'ask')).resolves.toEqual( + { kind: 'allowed' } + ) + expect(mocks.supportsCapability).toHaveBeenCalledWith( + 'server-1', + AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY, + expect.any(Number) + ) + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + }) + + // 1.4.225–1.4.226 apply modes but predate the capability. + it('lets a chat run on a host whose settings carry the new-chat permission default', async () => { + mocks.callRuntimeRpc.mockResolvedValue({ + settings: { nativeChatPermissionMode: 'full-access', agentDefaultArgs: {} } + }) + + await expect(readStructuredChatHostPermissionVerdict(PAIRED, 'claude', 'ask')).resolves.toEqual( + { kind: 'allowed' } + ) + }) + + // STA-10671: 1.4.224 started Claude with --dangerously-skip-permissions while Settings read Ask. + it('refuses Ask on an older host whose Agent Permissions bypass approval', async () => { + mocks.callRuntimeRpc.mockResolvedValue({ settings: { agentDefaultArgs: {} } }) + + await expect(readStructuredChatHostPermissionVerdict(PAIRED, 'claude', 'ask')).resolves.toEqual( + { kind: 'refused', hostMode: 'full-access' } + ) + await expect( + readStructuredChatHostPermissionVerdict(PAIRED, 'codex', 'accept-edits') + ).resolves.toEqual({ kind: 'refused', hostMode: 'full-access' }) + }) + + it('lets Full access run on that older host: it runs no more than asked for', async () => { + mocks.callRuntimeRpc.mockResolvedValue({ settings: { agentDefaultArgs: {} } }) + + await expect( + readStructuredChatHostPermissionVerdict(PAIRED, 'claude', 'full-access') + ).resolves.toEqual({ kind: 'allowed' }) + }) + + it('lets Ask run on an older host set to Manual, which asks too', async () => { + mocks.callRuntimeRpc.mockResolvedValue({ settings: { agentDefaultArgs: { claude: '' } } }) + + await expect(readStructuredChatHostPermissionVerdict(PAIRED, 'claude', 'ask')).resolves.toEqual( + { kind: 'allowed' } + ) + }) + + it('is unverified, never allowed, when the host cannot say', async () => { + mocks.supportsCapability.mockRejectedValue(new Error('timeout')) + mocks.callRuntimeRpc.mockRejectedValue(new Error('timeout')) + + await expect(readStructuredChatHostPermissionVerdict(PAIRED, 'claude', 'ask')).resolves.toEqual( + { kind: 'unverified' } + ) + }) + + it('asks nothing for this machine, or for an agent without chat modes', async () => { + await expect( + readStructuredChatHostPermissionVerdict({ kind: 'local' }, 'claude', 'ask') + ).resolves.toEqual({ kind: 'allowed' }) + await expect(readStructuredChatHostPermissionVerdict(PAIRED, 'grok', 'ask')).resolves.toEqual({ + kind: 'allowed' + }) + expect(mocks.supportsCapability).not.toHaveBeenCalled() + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + }) +}) + +describe('the permission verdict a chat holds', () => { + it('checks again, holding the chat, when contact with the host returns', async () => { + mocks.contactListeners.length = 0 + mocks.supportsCapability.mockResolvedValue(true) + const { result } = renderHook(() => useStructuredChatHostPermissionVerdict(PAIRED, 'claude')) + expect(result.current).toEqual({ kind: 'checking' }) + await vi.waitFor(() => expect(result.current).toEqual({ kind: 'allowed' })) + + // The host came back as an older build: its answer is still on the way. + mocks.supportsCapability.mockReturnValue(new Promise(() => undefined)) + act(() => mocks.contactListeners.forEach((listener) => listener())) + expect(result.current).toEqual({ kind: 'checking' }) + }) +}) + +describe('a held chat', () => { + it('asks again while held, so a host switched to Manual lets it through', async () => { + vi.useFakeTimers() + try { + mocks.callRuntimeRpc.mockResolvedValue({ settings: { agentDefaultArgs: {} } }) + const { result } = renderHook(() => useStructuredChatHostPermissionVerdict(PAIRED, 'claude')) + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + expect(result.current.kind).toBe('refused') + + mocks.callRuntimeRpc.mockResolvedValue({ settings: { agentDefaultArgs: { claude: '' } } }) + await act(async () => { + await vi.advanceTimersByTimeAsync(30_000) + }) + expect(result.current).toEqual({ kind: 'allowed' }) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/renderer/src/runtime/structured-chat-host-permission.ts b/src/renderer/src/runtime/structured-chat-host-permission.ts new file mode 100644 index 00000000000..e6338a3738b --- /dev/null +++ b/src/renderer/src/runtime/structured-chat-host-permission.ts @@ -0,0 +1,120 @@ +import { useEffect, useState } from 'react' +import { agentChatPermissionModes } from '../../../shared/agent-chat-permission-mode' +import { + chatPermissionHostVerdict, + hostSettingsApplyChatPermissionModes, + legacyHostChatPermissionMode, + type ChatPermissionHostVerdict +} from '../../../shared/agent-chat-permission-host-support' +import { AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY } from '../../../shared/agent-session-permission-modes-capability' +import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import { useAppStore } from '@/store' +import { callRuntimeRpc, runtimeEnvironmentSupportsCapability } from './runtime-rpc-client' +import { subscribeRuntimeHostContactRegained } from './runtime-host-contact-regained' + +/** `unverified`: the host could not say what it runs, so nothing may start there yet. */ +export type StructuredChatHostPermissionVerdict = ChatPermissionHostVerdict | { kind: 'unverified' } + +const HOST_PERMISSION_READ_TIMEOUT_MS = 15_000 +/** How often a held chat asks again: the host's Agent Permissions can change with no reconnect. */ +const HELD_RECHECK_MS = 30_000 + +/** The mode this client asks new chats to start in; on a paired host that predates per-chat modes + * it is the only stated intent, since that host keeps none. */ +function requestedChatPermissionMode(): unknown { + return useAppStore.getState().settings?.nativeChatPermissionMode +} + +/** + * Whether a chat on `target` may start or take a turn without running with more access than this + * client asks for. A paired host that does not apply per-chat modes runs its Agent Permissions. + */ +export async function readStructuredChatHostPermissionVerdict( + target: RuntimeClientTarget, + agent: string, + requested: unknown = requestedChatPermissionMode() +): Promise { + if (target.kind === 'local' || !agentChatPermissionModes(agent)) { + return { kind: 'allowed' } + } + const advertised = await runtimeEnvironmentSupportsCapability( + target.environmentId, + AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY, + HOST_PERMISSION_READ_TIMEOUT_MS + ).catch(() => false) + if (advertised) { + return { kind: 'allowed' } + } + let settings: unknown + try { + const result = await callRuntimeRpc<{ settings?: unknown }>(target, 'settings.get', undefined, { + timeoutMs: HOST_PERMISSION_READ_TIMEOUT_MS + }) + settings = result.settings + } catch { + return { kind: 'unverified' } + } + return chatPermissionHostVerdict({ + agent, + hostAppliesModes: hostSettingsApplyChatPermissionModes(settings), + legacyMode: legacyHostChatPermissionMode(agent, settings), + requested + }) +} + +/** `checking` until the host has answered; asked again on each regained contact, change of the + * requested mode and, while held, every 30s, so an updated host is let through without a reload. */ +export function useStructuredChatHostPermissionVerdict( + target: RuntimeClientTarget, + agent: string +): StructuredChatHostPermissionVerdict | { kind: 'checking' } { + const gated = target.kind === 'environment' && agentChatPermissionModes(agent) !== null + const environmentId = target.kind === 'environment' ? target.environmentId : null + const requested = useAppStore((state) => state.settings?.nativeChatPermissionMode) + const [contact, setContact] = useState(0) + // Contact is part of the key: a host back from a restart may be an older build, so a verdict from + // before it never stands in for the fresh one. + const key = `${environmentId ?? 'local'}\0${agent}\0${String(requested)}\0${contact}` + const [answer, setAnswer] = useState<{ + key: string + verdict: StructuredChatHostPermissionVerdict + } | null>(null) + // Not in the key: a held chat stays held while it asks again. + const [recheck, setRecheck] = useState(0) + const held = answer?.key === key && answer.verdict.kind !== 'allowed' + useEffect(() => { + if (!held) { + return + } + const timer = setTimeout(() => setRecheck((n) => n + 1), HELD_RECHECK_MS) + return () => clearTimeout(timer) + }, [answer, held]) + useEffect(() => { + if (!gated || environmentId === null) { + return + } + return subscribeRuntimeHostContactRegained(environmentId, () => setContact((n) => n + 1)) + }, [environmentId, gated]) + useEffect(() => { + if (!gated || environmentId === null) { + return + } + let cancelled = false + void readStructuredChatHostPermissionVerdict( + { kind: 'environment', environmentId }, + agent, + requested + ).then((verdict) => { + if (!cancelled) { + setAnswer({ key, verdict }) + } + }) + return () => { + cancelled = true + } + }, [agent, contact, environmentId, gated, key, recheck, requested]) + if (!gated) { + return { kind: 'allowed' } + } + return answer?.key === key ? answer.verdict : { kind: 'checking' } +} diff --git a/src/shared/agent-chat-permission-host-support.test.ts b/src/shared/agent-chat-permission-host-support.test.ts new file mode 100644 index 00000000000..35ab22b0020 --- /dev/null +++ b/src/shared/agent-chat-permission-host-support.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest' +import { + chatPermissionHostVerdict, + hostSettingsApplyChatPermissionModes, + legacyHostChatPermissionMode +} from './agent-chat-permission-host-support' +import { RUNTIME_CAPABILITIES } from './protocol-version' +import { AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY } from './agent-session-permission-modes-capability' + +describe('what a host predating per-chat modes runs', () => { + it('is Full access on its default Agent Permissions, which carry the bypass flag', () => { + expect(legacyHostChatPermissionMode('claude', { agentDefaultArgs: {} })).toBe('full-access') + expect(legacyHostChatPermissionMode('codex', {})).toBe('full-access') + }) + + it('is Ask once the host switched that agent to Manual', () => { + expect(legacyHostChatPermissionMode('claude', { agentDefaultArgs: { claude: '' } })).toBe('ask') + expect( + legacyHostChatPermissionMode('codex', { agentDefaultArgs: { codex: '--model x' } }) + ).toBe('ask') + }) + + it('reads a bypass flag any shell the host might use would pass', () => { + expect( + legacyHostChatPermissionMode('claude', { + agentDefaultArgs: { claude: '--verbose --dangerously-skip-permissions' } + }) + ).toBe('full-access') + }) + + it('names no mode for an agent without chat modes', () => { + expect(legacyHostChatPermissionMode('grok', {})).toBeNull() + }) +}) + +describe('whether a host applies per-chat modes', () => { + it('is told by the new-chat default its settings carry from 1.4.225 on', () => { + expect(hostSettingsApplyChatPermissionModes({ nativeChatPermissionMode: 'ask' })).toBe(true) + expect(hostSettingsApplyChatPermissionModes({ agentDefaultArgs: {} })).toBe(false) + expect(hostSettingsApplyChatPermissionModes(undefined)).toBe(false) + }) + + it('is advertised by this build', () => { + expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY) + }) +}) + +describe('the verdict on a chat start or turn', () => { + const legacy = { agent: 'claude', hostAppliesModes: false } as const + + it('refuses an older host that would run with more access than asked for', () => { + expect( + chatPermissionHostVerdict({ ...legacy, legacyMode: 'full-access', requested: 'ask' }) + ).toEqual({ kind: 'refused', hostMode: 'full-access' }) + expect( + chatPermissionHostVerdict({ ...legacy, legacyMode: 'full-access', requested: 'auto' }).kind + ).toBe('refused') + }) + + it('reads an unknown or missing request as Ask, never as more access', () => { + expect( + chatPermissionHostVerdict({ ...legacy, legacyMode: 'full-access', requested: undefined }).kind + ).toBe('refused') + expect( + chatPermissionHostVerdict({ ...legacy, legacyMode: 'full-access', requested: 'yolo' }).kind + ).toBe('refused') + }) + + it('refuses a host whose Agent Permissions could not be read', () => { + expect( + chatPermissionHostVerdict({ ...legacy, legacyMode: undefined, requested: 'ask' }) + ).toEqual({ kind: 'refused', hostMode: null }) + }) + + it('allows an older host that runs no more access than asked for', () => { + expect(chatPermissionHostVerdict({ ...legacy, legacyMode: 'ask', requested: 'ask' }).kind).toBe( + 'allowed' + ) + expect( + chatPermissionHostVerdict({ ...legacy, legacyMode: 'full-access', requested: 'full-access' }) + .kind + ).toBe('allowed') + }) + + it('allows a host that applies modes, and an agent with none', () => { + expect( + chatPermissionHostVerdict({ + agent: 'claude', + hostAppliesModes: true, + legacyMode: 'full-access', + requested: 'ask' + }).kind + ).toBe('allowed') + expect( + chatPermissionHostVerdict({ + agent: 'grok', + hostAppliesModes: false, + legacyMode: null, + requested: 'ask' + }).kind + ).toBe('allowed') + }) +}) diff --git a/src/shared/agent-chat-permission-host-support.ts b/src/shared/agent-chat-permission-host-support.ts new file mode 100644 index 00000000000..918273ef2eb --- /dev/null +++ b/src/shared/agent-chat-permission-host-support.ts @@ -0,0 +1,80 @@ +import { + AGENT_CHAT_PERMISSION_MODES, + agentChatPermissionModes, + isAgentChatPermissionMode, + type AgentChatPermissionMode +} from './agent-chat-permission-mode' +import { + resolveTuiAgentLaunchArgs, + tuiAgentArgsBypassPermissions +} from './tui-agent-launch-defaults' +import type { AgentStartupShell } from './tui-agent-startup-shell' +import { isTuiAgent } from './tui-agent-config' + +/** Every shell a host could parse its arguments with: any one granting bypass counts. */ +const HOST_STARTUP_SHELLS: readonly AgentStartupShell[] = ['posix', 'powershell', 'cmd'] + +/** Whether a host's own settings (`settings.get`) show it applies per-chat modes. */ +export function hostSettingsApplyChatPermissionModes(settings: unknown): boolean { + return ( + typeof settings === 'object' && + settings !== null && + 'nativeChatPermissionMode' in settings && + isAgentChatPermissionMode(settings.nativeChatPermissionMode) + ) +} + +/** + * The mode a host predating per-chat modes runs this agent's chats in: Full access when its Agent + * Permissions carry the bypass flag, else Ask. Null for an agent with no chat modes. + */ +export function legacyHostChatPermissionMode( + agent: string, + settings: unknown +): AgentChatPermissionMode | null { + if (!agentChatPermissionModes(agent) || !isTuiAgent(agent)) { + return null + } + const configured = + typeof settings === 'object' && settings !== null && 'agentDefaultArgs' in settings + ? settings.agentDefaultArgs + : undefined + const raw = + typeof configured === 'object' && configured !== null && agent in configured + ? Object.getOwnPropertyDescriptor(configured, agent)?.value + : undefined + const args = resolveTuiAgentLaunchArgs(agent, typeof raw === 'string' ? { [agent]: raw } : {}) + return HOST_STARTUP_SHELLS.some((shell) => tuiAgentArgsBypassPermissions(agent, args, shell)) + ? 'full-access' + : 'ask' +} + +function accessRank(mode: AgentChatPermissionMode): number { + return AGENT_CHAT_PERMISSION_MODES.indexOf(mode) +} + +/** What the host may be asked to run. `refused` names the access the host would grant instead. */ +export type ChatPermissionHostVerdict = + | { kind: 'allowed' } + | { kind: 'refused'; hostMode: AgentChatPermissionMode | null } + +/** + * Whether a chat may start or take a turn on its host. A host that predates per-chat modes is + * allowed only where it is known to grant no more access than `requested`: an unreadable host + * (`legacyMode` undefined) is refused, never assumed strict. + */ +export function chatPermissionHostVerdict(args: { + agent: string + hostAppliesModes: boolean + legacyMode: AgentChatPermissionMode | null | undefined + requested: unknown +}): ChatPermissionHostVerdict { + if (args.hostAppliesModes || !agentChatPermissionModes(args.agent) || args.legacyMode === null) { + return { kind: 'allowed' } + } + const requested = isAgentChatPermissionMode(args.requested) ? args.requested : 'ask' + if (args.legacyMode !== undefined && accessRank(args.legacyMode) <= accessRank(requested)) { + return { kind: 'allowed' } + } + return { kind: 'refused', hostMode: args.legacyMode ?? null } +} diff --git a/src/shared/agent-session-permission-modes-capability.ts b/src/shared/agent-session-permission-modes-capability.ts new file mode 100644 index 00000000000..2a54f37ff8f --- /dev/null +++ b/src/shared/agent-session-permission-modes-capability.ts @@ -0,0 +1,7 @@ +// Split out of protocol-version.ts, which lists it in RUNTIME_CAPABILITIES. Import it from here. + +// Why: a host predating per-chat modes ignores them and runs its Agent Permissions, so a client +// must know before it lets a chat there start or take a turn. 1.4.225–1.4.226 apply modes without +// advertising this; they are recognised by the `nativeChatPermissionMode` their settings carry. +export const AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY = + 'agent-session.permission-modes.v1' as const diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 18193c55454..7b74d7db262 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -52,6 +52,7 @@ import { QODER_OWNED_TERMINAL_CREATE_CAPABILITY } from './qoder-terminal-create- import { SESSION_TABS_RUNTIME_CAPABILITIES } from './session-tabs-runtime-capabilities' export { SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY } from './session-tabs-runtime-capabilities' import { AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY } from './agent-session-continue-interrupted-capability' +import { AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY } from './agent-session-permission-modes-capability' import { ORCAD_RUNTIME_CAPABILITIES } from './orcad-runtime-capabilities' import { DEVICE_KEY_RUNTIME_CAPABILITIES } from './device-key-capability' import { TERMINAL_QUICK_COMMAND_RUNTIME_CAPABILITIES } from './terminal-quick-command-capabilities' @@ -240,6 +241,7 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY, AGENT_SESSION_QUEUED_MESSAGE_EDIT_RUNTIME_CAPABILITY, AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY, + AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY, ...STRUCTURED_AGENT_SESSION_SURFACE_RUNTIME_CAPABILITIES, ...AGENT_SESSION_ATTENTION_RUNTIME_CAPABILITIES, AGENT_SESSION_REWIND_RUNTIME_CAPABILITY, diff --git a/src/shared/structured-agent-session-permission-option.ts b/src/shared/structured-agent-session-permission-option.ts index 9bf74de0a2d..7ba4b232d3e 100644 --- a/src/shared/structured-agent-session-permission-option.ts +++ b/src/shared/structured-agent-session-permission-option.ts @@ -80,3 +80,30 @@ export function structuredAgentSessionPermissionDescriptor(state: { settable: true } } + +/** The value a host predating per-chat modes shows: it runs its own Agent Permissions. */ +export const LEGACY_HOST_PERMISSION_VALUE = 'host' + +/** A read-only pill for a host that ignores per-chat modes, so its chats never look unset. */ +export function legacyHostPermissionDescriptor(): SessionOptionDescriptor { + return { + id: AGENT_CHAT_PERMISSION_MODE_OPTION_ID, + label: 'Permissions', + category: 'mode', + kind: { + type: 'select', + currentValue: LEGACY_HOST_PERMISSION_VALUE, + choices: [ + { + value: LEGACY_HOST_PERMISSION_VALUE, + label: "Host's Agent Permissions", + description: + "This host runs an older Orca that applies its Agent Permissions to every chat and can't ask per chat. Update Orca on the host to choose a mode here." + } + ] + }, + valueSource: 'applied', + transport: 'agent-session', + settable: false + } +}