mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+4
@@ -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 }
|
||||
}
|
||||
}
|
||||
+15
@@ -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) {
|
||||
|
||||
+10
-1
@@ -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
|
||||
|
||||
+33
@@ -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()
|
||||
})
|
||||
|
||||
+16
-3
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user