From 047c96855e1ce0bbd14fdf01fcd696b7d2fafe76 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 18:25:46 -0800 Subject: [PATCH 01/26] feat(cloud): RA-11 strip over a running Orca Cloud agent whose sign-in expires; strip reserves its height MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - When the Orca account goes from active/expiring to expired/revoked, every running Orca Cloud Claude/Codex pane shows the strip (same component, place, height as #315): 'Your Claude sign-in expired' / 'Renew to keep working. Your conversation is kept.' / Renew (compact: 'Claude sign-in expired' + Renew). The process keeps running; nothing is parsed from terminal output. - One Renew clears state 1 everywhere; the panes that showed it move to state 2: 'Claude sign-in renewed' / 'Restart Claude to pick up where you left off.' (or 'Starts a new conversation.') / Restart Claude / Dismiss. Restart reuses the account-restart path (queuePaneAccountRestarts), which resumes the pane's own session. Never automatic; a turn that ends cleanly after the renewal clears it. - Kill switch / never offered: nothing new on a running pane. A held launch keeps #315's strip. - Account reads while a running cloud agent pane is mounted: on window focus and at most once a minute (one timer per window). - #315 P2: the strip is a pane-top banner (useReservePaneTopSpace), so the terminal refits below it and no row is covered; a spacer keeps it under a chromeless pane's title-row buttons. - Spec: terminal starts below the strip and its last row stays visible (held and running); state 2's Dismiss sits a full title row below the pane's ×. Removes the debug screenshot left in #315's spec and polls the resize assertion (it raced the layout). --- .../CloudAgentSignInStrip.fixture.tsx | 63 +++++-- .../CloudAgentSignInStrip.tsx | 17 +- .../CloudAgentSignInStripPortals.tsx | 14 +- .../CloudRunningStripPortal.tsx | 112 +++++++++++++ .../cloud-agent-strip-model.ts | 4 + .../cloud-running-strip-model.ts | 52 ++++++ .../pane-top-banner/PaneTopWarningBanner.tsx | 2 +- src/renderer/src/i18n/locales/en.json | 11 +- .../cloud-running-agent-signout.test.ts | 113 +++++++++++++ .../orca-cloud/cloud-running-agent-signout.ts | 157 ++++++++++++++++++ .../orca-cloud-agent-accounts-store.ts | 5 + .../cloud-agent-sign-in-strip.spec.ts | 52 +++++- 12 files changed, 573 insertions(+), 29 deletions(-) create mode 100644 src/renderer/src/components/agent-sign-in-strip/CloudRunningStripPortal.tsx create mode 100644 src/renderer/src/components/agent-sign-in-strip/cloud-running-strip-model.ts create mode 100644 src/renderer/src/lib/orca-cloud/cloud-running-agent-signout.test.ts create mode 100644 src/renderer/src/lib/orca-cloud/cloud-running-agent-signout.ts diff --git a/src/renderer/src/components/agent-sign-in-strip/CloudAgentSignInStrip.fixture.tsx b/src/renderer/src/components/agent-sign-in-strip/CloudAgentSignInStrip.fixture.tsx index 5edbf4a35e8..c1cb0d8be72 100644 --- a/src/renderer/src/components/agent-sign-in-strip/CloudAgentSignInStrip.fixture.tsx +++ b/src/renderer/src/components/agent-sign-in-strip/CloudAgentSignInStrip.fixture.tsx @@ -11,6 +11,8 @@ import { type CloudStripActions } from './CloudAgentSignInStrip' import { cloudAgentStripModel } from './cloud-agent-strip-model' +import { cloudRunningStripModel } from './cloud-running-strip-model' +import type { CloudRunningStrip } from '@/lib/orca-cloud/cloud-running-agent-signout' const noop = (): void => {} const ACTIONS: CloudStripActions = { @@ -24,29 +26,34 @@ const ACTIONS: CloudStripActions = { const PROMPT = 'Fix the flaky auth test and open a PR' function Pane({ - reason, + reason = 'accountExpired', view = null, agent = 'claude', - width = 900 + width = 900, + running }: { - reason: CloudAgentStripReason + reason?: CloudAgentStripReason view?: AgentSignInGateView | null agent?: HostAgentSignInAgent width?: number + /** RA-11: the strip over a running agent instead of a held launch. */ + running?: CloudRunningStrip }): React.JSX.Element { - const model = cloudAgentStripModel({ - agent, - reason, - view, - hostLabel: translate('agentSignInStrip.sandbox', 'the sandbox'), - t: translate - }) + const model = running + ? cloudRunningStripModel(running, translate) + : cloudAgentStripModel({ + agent, + reason, + view, + hostLabel: translate('agentSignInStrip.sandbox', 'the sandbox'), + t: translate + }) return (
{/* Stands in for a terminal pane: its title row (split, close) stays above the strip. */}
@@ -64,14 +71,21 @@ function Pane({ -
-          ~/acme/web $
-        
+ {/* The terminal: it starts below the strip (reserved, never covered) and keeps its rows. */} +
+ {running ? ( + <> +
⏺ Reading src/auth/session.ts…
+
⏺ API Error: 401 · Not logged in · Run /login
+ + ) : null} +
~/acme/web $
+
) @@ -128,3 +142,22 @@ export const waitingNarrow = fixture({ export const needExpiredNarrow = fixture({ reason: 'accountExpired', width: 520 }) /** Below 360 px: one line and Renew; the detail stays the region's description. */ export const needExpiredCompact = fixture({ reason: 'accountExpired', width: 320 }) + +/** RA-11 state 1: Claude was running when its Orca-account sign-in expired. */ +export const runningExpired = fixture({ running: { phase: 'expired', agent: 'claude' } }) +export const runningExpiredCompact = fixture({ + running: { phase: 'expired', agent: 'claude' }, + width: 320 +}) +/** RA-11 state 2: renewed; this pane's agent still needs a restart (resumes its conversation). */ +export const runningRenewed = fixture({ + running: { phase: 'renewed', agent: 'claude', resumable: true } +}) +/** RA-11 state 2 when the session can't be resumed. */ +export const runningRenewedFresh = fixture({ + running: { phase: 'renewed', agent: 'claude', resumable: false } +}) +export const runningRenewedNarrow = fixture({ + running: { phase: 'renewed', agent: 'claude', resumable: true }, + width: 520 +}) diff --git a/src/renderer/src/components/agent-sign-in-strip/CloudAgentSignInStrip.tsx b/src/renderer/src/components/agent-sign-in-strip/CloudAgentSignInStrip.tsx index 42db438513f..ff6ab3ba3e4 100644 --- a/src/renderer/src/components/agent-sign-in-strip/CloudAgentSignInStrip.tsx +++ b/src/renderer/src/components/agent-sign-in-strip/CloudAgentSignInStrip.tsx @@ -23,6 +23,7 @@ import { } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' +import { useReservePaneTopSpace } from '../terminal-pane/appearance/pane-top-banner/PaneTopWarningBanner' import type { CloudStripModel, CloudStripPrimary, @@ -69,11 +70,15 @@ function useLiveStripWidth( * buttons stay visible and clickable while it shows (RA-1). The pane and its fixture share it. */ export function CloudStripLayer({ children }: { children: React.ReactNode }): React.JSX.Element { + // A pane-top banner (RA-11): the pane reserves its height and the terminal refits below it, so + // no row sits under the strip. The spacer keeps it below a chromeless pane's title-row buttons. + const ref = useReservePaneTopSpace() return ( -
+
+
{children}
) @@ -282,12 +287,12 @@ export function CloudAgentSignInStrip({ ) : null} {/* Renew is the whole action and the strip leaves on its own; Escape still cancels. */} - {model.status === 'done' || primary?.action.kind === 'renew' ? null : ( + {!model.dismiss && (model.status === 'done' || primary?.action.kind === 'renew') ? null : ( + + + + + ) +} + +/** One request at a time, oldest first. Closing the dialog any other way denies it. */ +export function LocalCommandApprovalDialogHost(): React.JSX.Element | null { + const [prompts, setPrompts] = useState([]) + const [ready, setReady] = useState(false) + const [busy, setBusy] = useState(false) + const current = prompts[0] ?? null + const currentId = current?.id ?? null + const decidedRef = useRef(null) + + useEffect(() => { + const api = window.api.localCommands + if (!api) { + return + } + let disposed = false + const stop = api.onPrompts((next) => { + if (!disposed) { + setPrompts(next) + } + }) + void api + .getPrompts() + .then((initial) => !disposed && setPrompts(initial)) + .catch(() => {}) + return () => { + disposed = true + stop() + } + }, []) + + useEffect(() => { + setReady(false) + setBusy(false) + if (!currentId) { + return + } + const timer = setTimeout(() => setReady(true), READ_DELAY_MS) + return () => clearTimeout(timer) + }, [currentId]) + + const decide = useCallback( + (decision: LocalCommandDecision) => { + const api = window.api.localCommands + if (!api || !currentId || decidedRef.current === currentId) { + return + } + if (decision !== 'deny' && !ready) { + return + } + decidedRef.current = currentId + setBusy(true) + void api + .decide(currentId, decision) + .catch(() => ({ ok: false })) + .then((result) => { + if (!result.ok) { + // Refused (too soon, or turned off meanwhile): main keeps or drops the prompt itself. + decidedRef.current = null + setBusy(false) + } + }) + }, + [currentId, ready] + ) + + if (!current) { + return null + } + return ( + { + if (!open) { + decide('deny') + } + }} + > + + + ) +} diff --git a/src/renderer/src/components/settings/orca-cloud/OrcaCloudLocalCommandsPane.fixture.tsx b/src/renderer/src/components/settings/orca-cloud/OrcaCloudLocalCommandsPane.fixture.tsx new file mode 100644 index 00000000000..473c35c502a --- /dev/null +++ b/src/renderer/src/components/settings/orca-cloud/OrcaCloudLocalCommandsPane.fixture.tsx @@ -0,0 +1,110 @@ +import { defineFixture } from '../../../component-fixtures/component-fixture' +import { OrcaCloudLocalCommandsView } from './OrcaCloudLocalCommandsPane' + +const NOW = 1_762_800_000_000 + +export const populated = defineFixture({ + width: 760, + render: () => ( + {}} + onRemoveRule={() => {}} + onRetry={() => {}} + state={{ + available: true, + workspaces: [ + { + sandboxId: 'sbx_000000000000000000000001', + label: 'ios-login-fix', + enabled: true, + sharedAllowed: false, + shared: false, + isOwner: true, + connected: true + }, + { + sandboxId: 'sbx_000000000000000000000002', + label: 'payments-refactor', + enabled: false, + sharedAllowed: false, + shared: true, + isOwner: true, + connected: false + }, + { + sandboxId: 'sbx_000000000000000000000003', + label: 'docs-cleanup', + enabled: false, + sharedAllowed: false, + shared: true, + isOwner: false, + connected: false + } + ], + rules: [ + { + id: 'rule-1', + sandboxId: 'sbx_000000000000000000000001', + workspaceLabel: 'ios-login-fix', + command: 'xcrun simctl list devices booted', + cwd: '/Users/neil', + createdAt: NOW - 3_600_000 + } + ] + }} + audit={[ + { + at: NOW - 60_000, + requestId: 'lc_a', + sandboxId: 'sbx_000000000000000000000001', + workspaceLabel: 'ios-login-fix', + command: 'xcrun simctl list devices booted', + cwd: '/Users/neil', + agent: 'Claude Code', + outcome: 'auto-allowed', + exitCode: 0, + durationMs: 412 + }, + { + at: NOW - 600_000, + requestId: 'lc_b', + sandboxId: 'sbx_000000000000000000000001', + workspaceLabel: 'ios-login-fix', + command: 'security find-identity -p codesigning -v', + cwd: '/Users/neil', + agent: 'Claude Code', + outcome: 'denied' + }, + { + at: NOW - 3_600_000, + requestId: 'lc_c', + sandboxId: 'sbx_000000000000000000000001', + workspaceLabel: 'ios-login-fix', + command: 'xcodebuild -scheme Acme test', + cwd: '/Users/neil/projects/acme-ios', + agent: 'Claude Code', + outcome: 'allowed-once', + exitCode: 65, + durationMs: 93_000 + } + ]} + /> + ) +}) + +export const empty = defineFixture({ + width: 760, + render: () => ( + {}} + onRemoveRule={() => {}} + onRetry={() => {}} + state={{ available: true, workspaces: [], rules: [] }} + audit={[]} + /> + ) +}) diff --git a/src/renderer/src/components/settings/orca-cloud/OrcaCloudLocalCommandsPane.tsx b/src/renderer/src/components/settings/orca-cloud/OrcaCloudLocalCommandsPane.tsx new file mode 100644 index 00000000000..053200cacd3 --- /dev/null +++ b/src/renderer/src/components/settings/orca-cloud/OrcaCloudLocalCommandsPane.tsx @@ -0,0 +1,361 @@ +import { useCallback, useEffect, useState } from 'react' +import { SquareTerminal } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { InlineError } from '@/components/ui/inline-error' +import { Skeleton } from '@/components/ui/skeleton' +import { Switch } from '@/components/ui/switch' +import { useConfirmationDialog } from '@/components/confirmation-dialog-context' +import { formatUiRelativeTime } from '@/i18n/relative-time-format' +import { useTranslate, type Translate } from '@/i18n/use-translate' +import type { + LocalCommandAuditEntry, + LocalCommandAuditOutcome, + LocalCommandsState, + LocalCommandWorkspaceRow +} from '../../../../../shared/local-commands/local-command-desktop-types' +import { CloudApiEmptyState } from '../cloud-api/cloud-api-parts' +import { SettingsBadge, SettingsRow } from '../form-controls/SettingsFormControls' +import { SettingsGroup } from '../section-layout/SettingsGroup' + +const AUDIT_ROWS = 20 + +function workspaceDetail(row: LocalCommandWorkspaceRow, t: Translate): string { + if (row.isOwner === false) { + return t('localCommands.settings.notOwner', 'Only the workspace owner can turn this on.') + } + if (!row.enabled) { + return row.shared + ? t('localCommands.settings.offShared', 'Off · Shared with your team') + : t('localCommands.settings.off', 'Off') + } + const sharing = row.shared + ? ` · ${t('localCommands.settings.sharedOn', 'Shared with your team')}` + : '' + return row.connected + ? `${t('localCommands.settings.onConnected', 'On · Connected')}${sharing}` + : `${t('localCommands.settings.onWaiting', 'On · Connects when the workspace is running')}${sharing}` +} + +function outcomeLabel(outcome: LocalCommandAuditOutcome, t: Translate): string { + switch (outcome) { + case 'allowed-once': + return t('localCommands.audit.allowedOnce', 'Allowed once') + case 'always-allowed': + return t('localCommands.audit.alwaysAllowed', 'Always allowed') + case 'auto-allowed': + return t('localCommands.audit.autoAllowed', 'Allowed by rule') + case 'denied': + return t('localCommands.audit.denied', 'Denied') + case 'timed-out': + return t('localCommands.audit.timedOut', 'Not answered') + case 'refused': + return t('localCommands.audit.refused', 'Refused') + case 'closed': + return t('localCommands.audit.closed', 'Closed') + } +} + +function resultLabel(entry: LocalCommandAuditEntry, t: Translate): string | null { + if (entry.timedOut) { + return t('localCommands.audit.resultTimedOut', 'Timed out') + } + if (entry.signal) { + return t('localCommands.audit.resultSignal', 'Stopped ({{signal}})', { signal: entry.signal }) + } + if (typeof entry.exitCode === 'number') { + return t('localCommands.audit.resultExit', 'Exit {{code}}', { code: entry.exitCode }) + } + return null +} + +export type LocalCommandsPaneProps = { + state: LocalCommandsState | null | 'failed' + audit: LocalCommandAuditEntry[] + pendingSandboxId: string | null + now: number + onToggle: (row: LocalCommandWorkspaceRow) => void + onRemoveRule: (id: string) => void + onRetry: () => void +} + +export function OrcaCloudLocalCommandsView({ + state, + audit, + pendingSandboxId, + now, + onToggle, + onRemoveRule, + onRetry +}: LocalCommandsPaneProps): React.JSX.Element { + const t = useTranslate() + if (state === 'failed') { + return ( + + ) + } + if (state === null) { + return ( +
+ {t('localCommands.settings.loading', 'Loading…')} + +
+ ) + } + if (!state.available) { + return ( + + ) + } + return ( +
+ {state.workspaces.length === 0 ? ( + + + + ) : ( + + {state.workspaces.map((row) => ( + onToggle(row)} + /> + } + /> + ))} + + )} + + + {state.rules.length === 0 ? ( +

+ {t('localCommands.settings.rulesEmpty', 'No commands are always allowed.')} +

+ ) : ( + state.rules.map((rule) => ( + + {rule.command} + + } + description={`${rule.workspaceLabel} · ${rule.cwd}`} + control={ + + } + /> + )) + )} +
+ + + {audit.length === 0 ? ( +

+ {t('localCommands.settings.auditEmpty', 'No requests yet.')} +

+ ) : ( + audit.slice(0, AUDIT_ROWS).map((entry) => { + const result = resultLabel(entry, t) + return ( +
+
+

+ {entry.command || t('localCommands.audit.noCommand', '(unreadable request)')} +

+

+ {[ + entry.workspaceLabel, + formatUiRelativeTime(entry.at - now), + result, + entry.detail + ] + .filter(Boolean) + .join(' · ')} +

+
+ + {outcomeLabel(entry.outcome, t)} + +
+ ) + }) + )} +
+ +

+ {t( + 'localCommands.settings.footnote', + 'Commands run with /bin/sh in your home folder or the folder the agent names, with only PATH, HOME and locale set. They get no input, stop at their timeout, and output is cut at 128 KB. A request waits up to 5 minutes for you.' + )} +

+
+ ) +} + +export function OrcaCloudLocalCommandsPane(): React.JSX.Element { + const t = useTranslate() + const confirm = useConfirmationDialog() + const [state, setState] = useState(null) + const [audit, setAudit] = useState([]) + const [loadedAt, setLoadedAt] = useState(0) + const [pendingSandboxId, setPendingSandboxId] = useState(null) + const [error, setError] = useState(null) + const [attempt, setAttempt] = useState(0) + + const load = useCallback(async () => { + const api = window.api.localCommands + if (!api) { + setState({ available: false, workspaces: [], rules: [] }) + return + } + try { + const [next, entries] = await Promise.all([api.getState(), api.listAudit(AUDIT_ROWS)]) + setState(next) + setAudit(entries) + setLoadedAt(Date.now()) + } catch { + setState('failed') + } + }, []) + + useEffect(() => { + void load() + const stopState = window.api.localCommands?.onStateChanged(() => void load()) + // A prompt answered elsewhere lands in the audit log; refresh the list with it. + const stopPrompts = window.api.localCommands?.onPrompts(() => void load()) + return () => { + stopState?.() + stopPrompts?.() + } + }, [load, attempt]) + + const toggle = useCallback( + async (row: LocalCommandWorkspaceRow) => { + const api = window.api.localCommands + if (!api) { + return + } + const enabling = !row.enabled + let allowShared = false + if (enabling && row.shared) { + allowShared = await confirm({ + title: t( + 'localCommands.settings.sharedConfirmTitle', + 'Allow commands from a shared workspace?' + ), + description: t( + 'localCommands.settings.sharedConfirmBody', + 'Teammates can message the agent in {{workspace}}, so their messages can also lead to requests to run commands on this computer. You still approve every command.', + { workspace: row.label } + ), + confirmLabel: t('localCommands.settings.sharedConfirmAction', 'Turn on'), + surface: 'security', + initialFocus: 'cancel' + }) + if (!allowShared) { + return + } + } + setPendingSandboxId(row.sandboxId) + setError(null) + try { + const result = await api.setEnabled({ + sandboxId: row.sandboxId, + enabled: enabling, + allowShared + }) + if (!result.ok) { + setError( + result.error === 'not_owner' + ? t( + 'localCommands.settings.errorNotOwner', + 'Only the workspace owner can turn this on.' + ) + : t( + 'localCommands.settings.errorUnverified', + 'Couldn’t confirm who owns this workspace. Check your connection and try again.' + ) + ) + } + } finally { + setPendingSandboxId(null) + await load() + } + }, + [confirm, load, t] + ) + + return ( +
+ {error ? : null} + void toggle(row)} + onRemoveRule={(id) => void window.api.localCommands?.removeRule(id).then(() => load())} + onRetry={() => { + setState(null) + setAttempt((value) => value + 1) + }} + /> +
+ ) +} diff --git a/src/renderer/src/components/settings/orca-cloud/OrcaCloudSettingsLinks.tsx b/src/renderer/src/components/settings/orca-cloud/OrcaCloudSettingsLinks.tsx index d5b204c71f6..f6579ed1d35 100644 --- a/src/renderer/src/components/settings/orca-cloud/OrcaCloudSettingsLinks.tsx +++ b/src/renderer/src/components/settings/orca-cloud/OrcaCloudSettingsLinks.tsx @@ -1,4 +1,12 @@ -import { Boxes, ChevronRight, CreditCard, LockKeyhole, Plug, Users } from 'lucide-react' +import { + Boxes, + ChevronRight, + CreditCard, + LockKeyhole, + Plug, + SquareTerminal, + Users +} from 'lucide-react' import type { LucideIcon } from 'lucide-react' import { SettingsGroupHeader } from '@/components/settings/section-layout/SettingsGroup' import { useTranslate } from '@/i18n/use-translate' @@ -26,7 +34,12 @@ export function OrcaCloudSettingsLinks({ icon: Boxes, label: t('settings.orcaCloud.environments.title', 'Cloud environments') }, - { pane: 'cloud-mcp', icon: Plug, label: t('cloudMcpSync.title', 'MCP servers') } + { pane: 'cloud-mcp', icon: Plug, label: t('cloudMcpSync.title', 'MCP servers') }, + { + pane: 'cloud-local-commands', + icon: SquareTerminal, + label: t('localCommands.settings.title', 'Commands on this computer') + } ] return (