fix(native-chat): never run a chat on an older host with more access than Ask for approval allows (#29442)

* fix(native-chat): refuse to start or send on a host that ignores Ask for approval

* fix(native-chat): hide Continue on a host held for its permission mode

* fix(native-chat): hold queued cards and goal turns on a held host; re-check on reconnect

* fix(native-chat): hold goal objectives and start retries on a held host; re-ask while held

* test(native-chat): give the remote Continue test a host that applies permission modes
This commit is contained in:
Neil
2026-10-15 23:23:38 -07:00
committed by GitHub
parent 15953ef0ea
commit 6ae1386f98
33 changed files with 978 additions and 89 deletions
@@ -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 })
})
})
@@ -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)
}
}
@@ -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
@@ -45,7 +45,13 @@ function optionsClient(...modes: (string | undefined)[]) {
}
type Controller = ReturnType<typeof useMobileStructuredAgentOptions>
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')
@@ -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
@@ -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<StructuredAgentSessionHostSupport, 'sendOptions' | 'permissionModes'> | 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<boolean> => {
@@ -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<SessionOptionsSurface>(
() => ({
getSnapshot: () => optionSnapshot,
setOption,
invokeAction: async () => ({ snapshot: optionSnapshot }),
subscribe: () => () => {}
}),
[optionSnapshot, setOption]
)
const { optionPickerRequest, invokeStructuredOption, optionSurface } =
useMobileStructuredOptionSurface({
optionSnapshot,
setStructuredOption,
optionStateRef,
takesSendOptions
})
return {
optionPickerRequest,
@@ -115,7 +115,7 @@ export function useMobileStructuredAgentSession(args: {
fence: state.fence,
mutate,
optionStamp,
takesSendOptions: hostSupport?.sendOptions === true
hostSupport
})
const { conversationCommands, invokeStructuredOption, optionSnapshot, setStructuredOption } =
options
@@ -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<boolean>
/** The latest option state, which a settled pick's snapshot is read from. */
optionStateRef: RefObject<StructuredAgentSessionOptionState>
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<SessionOptionsSurface>(
() => ({
getSnapshot: () => optionSnapshot,
setOption,
invokeAction: async () => ({ snapshot: optionSnapshot }),
subscribe: () => () => {}
}),
[optionSnapshot, setOption]
)
return { optionPickerRequest, invokeStructuredOption, optionSurface }
}
@@ -171,7 +171,12 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
const { imageAttachments, attachResolvedPaths, clearImageAttachments, removeImageAttachment } =
attachments
const imageBlock = nativeChatImageSendBlock(imageAttachments)
const sendHeld = disabled || structuredTransport?.sendOut === true || imageBlock.holdsSend
const sendBlockedReason = imageBlock.reason ?? structuredTransport?.sendBlockedReason ?? null
const sendHeld =
disabled ||
structuredTransport?.sendOut === true ||
imageBlock.holdsSend ||
structuredTransport?.sendBlockedReason !== undefined
const sendButtonDisabled = isWorking
? !hasPty || !onStop
: sendHeld || (draft.trim() === '' && imageAttachments.length === 0)
@@ -360,7 +365,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
imageAttachments={imageAttachments}
attachmentEnvironmentId={structuredTransport?.runtimeEnvironmentId ?? undefined}
sendButtonDisabled={sendButtonDisabled}
sendBlockedReason={imageBlock.reason}
sendBlockedReason={sendBlockedReason}
isWorking={isWorking}
isStopping={isStopping}
afterStop={afterStop}
@@ -39,6 +39,7 @@ export function useNativeChatInterruptedContinuation(input: {
sessionId: string
journalItems: readonly AgentJournalRenderItem[]
submissions: readonly Pick<AgentJournalSubmission, 'dispatchState'>[]
/** 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
@@ -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 (
<NativeChatThreadGoalBanner
key={props.sessionId}
goal={threadGoal.goal}
pending={threadGoal.pending}
hostChanges={threadGoal.hostChanges}
sendUnavailable={transport.sendOut === true || props.isWorking}
startsHeld={startsHeld}
isVisible={props.isVisible}
runningTurn={props.turnId === null ? null : { startedAt: props.workingStartedAt ?? null }}
onChange={(change) => {
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)
}}
/>
)
}
@@ -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')
@@ -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}
/>
<NativeChatStructuredSessionStatus
@@ -339,27 +344,17 @@ export function NativeChatStructuredSession(
backgroundTasks={controller.backgroundTasks}
stopBackgroundTask={controller.stopBackgroundTask}
/>
{!prompt && controller.threadGoal?.goal ? (
<NativeChatThreadGoalBanner
key={props.sessionId}
goal={controller.threadGoal.goal}
pending={controller.threadGoal.pending}
hostChanges={controller.threadGoal.hostChanges}
sendUnavailable={structuredTransport.sendOut || controller.isWorking}
{prompt ? null : (
<NativeChatStructuredGoalStrip
sessionId={props.sessionId}
threadGoal={controller.threadGoal}
transport={structuredTransport}
isWorking={controller.isWorking}
isVisible={props.isVisible}
runningTurn={
controller.turnId === null
? null
: { startedAt: controller.workingStartedAt ?? null }
}
onChange={(change) => {
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 : <NativeChatPromptSlotNotices notices={notices} />}
{prompt && approval ? (
@@ -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: {
<GoalAction
label={translate('components.native-chat.goal.clear', 'Clear goal')}
// Claude's `/goal clear` must enter a fresh turn, like a composer send.
disabled={pending || (!props.hostChanges && props.sendUnavailable)}
disabled={
pending ||
(!props.hostChanges && (props.sendUnavailable || props.startsHeld === true))
}
onClick={() => onChange({ kind: 'clear' })}
>
<Trash2 className="size-3.5" />
@@ -106,7 +111,7 @@ export function NativeChatThreadGoalBanner(props: {
) : statusChange === 'active' ? (
<GoalAction
label={translate('components.native-chat.goal.resume', 'Resume goal')}
disabled={pending}
disabled={pending || props.startsHeld === true}
onClick={() => onChange({ kind: 'status', status: 'active' })}
>
<Play className="size-3.5" />
@@ -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<StructuredAgentSessionCommandOutcome>
optionsSurface: SessionOptionsSurface
optionSnapshot: SessionOptionDescriptor[]
@@ -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
@@ -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 }
}
}
@@ -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')
@@ -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) {
@@ -25,9 +25,12 @@ export function useNativeChatStructuredComposerTransport(args: {
setOptionPickerRequest: Dispatch<SetStateAction<NativeChatOptionPickerRequest | null>>
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
@@ -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()
})
@@ -14,7 +14,9 @@ type StructuredController = ReturnType<typeof useStructuredAgentSession>
export function useStructuredNativeChatSubmitReveal(
controller: Pick<StructuredController, 'respond' | 'queuedMessages'>,
/** 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<NativeChatMessageListHandle | null>
/** 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<StructuredController['respond']>(
(...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,
+6
View File
@@ -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": {
@@ -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 })
@@ -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
)
@@ -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) }
)
}
@@ -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()
}
})
})
@@ -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<StructuredChatHostPermissionVerdict> {
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' }
}
@@ -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')
})
})
@@ -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 }
}
@@ -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
+2
View File
@@ -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,
@@ -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
}
}