diff --git a/config/recurring-timer-baseline.txt b/config/recurring-timer-baseline.txt
index 89443a4430d..cea5d3b42c5 100644
--- a/config/recurring-timer-baseline.txt
+++ b/config/recurring-timer-baseline.txt
@@ -73,6 +73,7 @@
1 src/main/wsl/host-lifecycle/wsl-hosts-policy-live-update.ts
1 src/main/wsl/orcad/wsl-orcad-connect-stop-fence.ts
1 src/renderer/src/app-shell/use-app-session-persistence.ts
+1 src/renderer/src/components/agent-pane/use-secret-request-card.ts
1 src/renderer/src/components/browser-pane/browser-client-page-position-driver.ts
1 src/renderer/src/components/contextual-tours/use-contextual-tour.ts
1 src/renderer/src/components/editor/setup-contextual-copy.ts
diff --git a/src/main/cloud-secrets/cloud-secrets-client.test.ts b/src/main/cloud-secrets/cloud-secrets-client.test.ts
index 4305bbda085..03410c45d03 100644
--- a/src/main/cloud-secrets/cloud-secrets-client.test.ts
+++ b/src/main/cloud-secrets/cloud-secrets-client.test.ts
@@ -34,6 +34,29 @@ const VIEW = {
}
describe('createCloudSecretsClient', () => {
+ it('reads who owns a sandbox and whether it is shared, failing closed on an unknown owner', async () => {
+ const answers = [
+ { owner: { userId: 'u2', displayName: 'Ada', isCaller: false }, sharedWorkspace: true },
+ { owner: { userId: 'u1', displayName: 'Me', isCaller: true }, sharedWorkspace: false },
+ { owner: null, sharedWorkspace: true },
+ { owner: null }
+ ]
+ const { client, seen } = fakeControlPlane(() => Response.json(answers.shift()))
+ expect(await client.workspaceSharing('sbx 1')).toEqual({
+ shared: true,
+ ownerName: 'Ada',
+ isOwner: false
+ })
+ expect(await client.workspaceSharing('sbx')).toEqual({
+ shared: false,
+ ownerName: null,
+ isOwner: true
+ })
+ expect((await client.workspaceSharing('sbx')).isOwner).toBe(false)
+ expect((await client.workspaceSharing('sbx')).isOwner).toBe(true)
+ expect(seen[0].url).toBe('https://control.test/v1/agent-sources?workspaceId=sbx+1')
+ })
+
it('addresses scope, name and repo on the wire and sends the value only in the body', async () => {
const { client, seen } = fakeControlPlane(({ method }) =>
Response.json(method === 'GET' ? { secrets: [VIEW] } : { secret: VIEW })
diff --git a/src/main/cloud-secrets/cloud-secrets-client.ts b/src/main/cloud-secrets/cloud-secrets-client.ts
index 19f9aeb88bb..d797bc8585c 100644
--- a/src/main/cloud-secrets/cloud-secrets-client.ts
+++ b/src/main/cloud-secrets/cloud-secrets-client.ts
@@ -1,6 +1,7 @@
// Orca Cloud secrets on the sandbox control plane. Write-only for values: requests carry them, no
// response or error does, and nothing here logs a request body.
import {
+ CloudAgentSourcesResponseSchema,
CloudEnvRefreshResponseSchema,
CloudSecretBatchResponseSchema,
CloudSecretDeleteResponseSchema,
@@ -12,7 +13,8 @@ import {
type CloudSecretsErrorCode,
type CloudSecretTarget,
type CloudSecretView,
- type CloudSecretsEffective
+ type CloudSecretsEffective,
+ type CloudWorkspaceSharing
} from '../../shared/cloud-secrets/cloud-secrets-wire'
import { isEnvironmentProviderError } from '../environment-providers/environment-provider-driver'
import {
@@ -30,6 +32,8 @@ export type CloudSecretsClient = {
): Promise
effective(repo?: string, signal?: AbortSignal): Promise
refreshEnv(sandboxId: string): Promise
+ /** Who owns a sandbox and whether teammates share it (GET /v1/agent-sources?workspaceId=). */
+ workspaceSharing(sandboxId: string): Promise
}
function withRepo(path: string, repo: string | undefined): string {
@@ -94,7 +98,23 @@ export function createCloudSecretsClient(
CloudEnvRefreshResponseSchema,
{ kind: 'control' }
)
- ).applied
+ ).applied,
+ workspaceSharing: async (sandboxId) => {
+ const sources = await call(
+ 'GET',
+ `v1/agent-sources?${new URLSearchParams({ workspaceId: sandboxId })}`,
+ CloudAgentSourcesResponseSchema,
+ { kind: 'control' }
+ )
+ const owner = sources.owner ?? null
+ const shared = sources.sharedWorkspace === true
+ return {
+ shared,
+ ownerName: owner && !owner.isCaller ? owner.displayName : null,
+ // Unknown owner: only an unshared workspace is safely the caller's own.
+ isOwner: owner ? owner.isCaller : !shared
+ }
+ }
}
}
diff --git a/src/main/cloud-secrets/cloud-secrets-ipc.ts b/src/main/cloud-secrets/cloud-secrets-ipc.ts
index 502a3502990..6c13f5e87b1 100644
--- a/src/main/cloud-secrets/cloud-secrets-ipc.ts
+++ b/src/main/cloud-secrets/cloud-secrets-ipc.ts
@@ -68,7 +68,8 @@ const CHANNELS = [
'cloudSecrets:batch',
'cloudSecrets:effective',
'cloudSecrets:refreshEnv',
- 'cloudSecrets:saveDesktopKey'
+ 'cloudSecrets:saveDesktopKey',
+ 'cloudSecrets:workspaceSharing'
] as const
async function attempt(
@@ -179,6 +180,17 @@ export function registerCloudSecretsHandlers(userDataPath: string): void {
}
return attempt(client, (c) => c.put({ scope: 'personal' }, envName, value))
})
+ // Reads the control plane only; never wakes the sandbox.
+ ipcMain.handle('cloudSecrets:workspaceSharing', (_event, raw: unknown) => {
+ const request = RefreshRequest.safeParse(raw)
+ if (!request.success) {
+ return invalid
+ }
+ const sandboxId = sandboxIdFor(userDataPath, request.data.runtimeEnvironmentId)
+ return sandboxId
+ ? attempt(client, (c) => c.workspaceSharing(sandboxId))
+ : { ok: false, error: 'not_found' }
+ })
ipcMain.handle('cloudSecrets:refreshEnv', (_event, raw: unknown) => {
const request = RefreshRequest.safeParse(raw)
if (!request.success) {
diff --git a/src/preload/api/cloud-secrets-bridge.ts b/src/preload/api/cloud-secrets-bridge.ts
index a311162c9f2..8a0381587a2 100644
--- a/src/preload/api/cloud-secrets-bridge.ts
+++ b/src/preload/api/cloud-secrets-bridge.ts
@@ -5,7 +5,8 @@ import type {
CloudSecretView,
CloudSecretsEffective,
CloudSecretsResult,
- CloudSecretsStatus
+ CloudSecretsStatus,
+ CloudWorkspaceSharing
} from '../../shared/cloud-secrets/cloud-secrets-wire'
/** Desktop only. Values go in; only names, scopes and last-4 hints come back. */
@@ -29,6 +30,10 @@ export type CloudSecretsApi = {
effective: (request: { repo?: string }) => Promise>
/** Best-effort nudge so a running workspace picks up new values now; the server also does it on write. */
refreshEnv: (request: { runtimeEnvironmentId: string }) => Promise>
+ /** Who owns an Orca Cloud workspace and whether teammates share it (control plane only). */
+ workspaceSharing: (request: {
+ runtimeEnvironmentId: string
+ }) => Promise>
/** Saves one of this computer's Agent API Keys as a personal Cloud secret (the value never leaves main). */
saveDesktopKey: (request: {
agent: string
@@ -44,5 +49,6 @@ export const cloudSecretsApi: CloudSecretsApi = {
batch: (request) => ipcRenderer.invoke('cloudSecrets:batch', request),
effective: (request) => ipcRenderer.invoke('cloudSecrets:effective', request),
refreshEnv: (request) => ipcRenderer.invoke('cloudSecrets:refreshEnv', request),
- saveDesktopKey: (request) => ipcRenderer.invoke('cloudSecrets:saveDesktopKey', request)
+ saveDesktopKey: (request) => ipcRenderer.invoke('cloudSecrets:saveDesktopKey', request),
+ workspaceSharing: (request) => ipcRenderer.invoke('cloudSecrets:workspaceSharing', request)
}
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index 51127280e98..67208a753ce 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -7,6 +7,8 @@ import { BrowserWebAuthnAccountDialog } from './components/browser-webauthn-acco
import { BrowserExtensionInstallDialog } from './components/browser-extension-install-dialog'
import { PlansDialogHost } from './components/billing/plans/PlansDialogHost'
import { CloudMoveToComputerDialogMount } from './components/orca-cloud/CloudMoveToComputerDialogMount'
+import { CloudSecretsAskDialogHost } from './components/cloud-secrets-ask/CloudSecretsMissingNotice'
+import { SecretRequestUnseenNotifier } from './components/agent-pane/SecretRequestUnseenNotifier'
import { TeamDialogHost } from './components/billing/team/TeamDialogHost'
import { BrowserPagePrompts } from './components/browser-pane/browser-page-prompts'
import { LinkRoutingPreferenceDialogProvider } from './components/link-routing-preference-dialog'
@@ -122,6 +124,8 @@ function App(): React.JSX.Element {
+
+
diff --git a/src/renderer/src/components/agent-host-panel/cloud-sections/CloudMissingSecretRow.tsx b/src/renderer/src/components/agent-host-panel/cloud-sections/CloudMissingSecretRow.tsx
new file mode 100644
index 00000000000..2917af5db7f
--- /dev/null
+++ b/src/renderer/src/components/agent-host-panel/cloud-sections/CloudMissingSecretRow.tsx
@@ -0,0 +1,212 @@
+import React from 'react'
+import { Loader2 } from 'lucide-react'
+import { useTranslate } from '@/i18n/use-translate'
+import { Button } from '@/components/ui/button'
+import { Label } from '@/components/ui/label'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue
+} from '@/components/ui/select'
+import { SecretValueInput } from '@/components/settings/cloud-secrets/SecretValueInput'
+import {
+ cloudSecretScopeLabel,
+ cloudSecretsErrorCopy
+} from '@/components/settings/cloud-secrets/cloud-secrets-copy'
+import type {
+ CloudSecretScope,
+ CloudSecretsErrorCode
+} from '../../../../../shared/cloud-secrets/cloud-secrets-wire'
+import { parseScope, writableScopes } from '../../cloud-secrets-ask/cloud-secrets-ask-model'
+import {
+ isGuestInSharedWorkspace,
+ type CloudPanelAddSecret,
+ type CloudPanelSectionsData
+} from './use-cloud-panel-sections'
+
+/** A declared name nobody provides: Missing chip, then Add (members: Add mine) opens an inline field. */
+export function CloudMissingSecretRow({
+ name,
+ data,
+ expanded,
+ onExpand,
+ onCollapse,
+ onAdd,
+ autoFocus
+}: {
+ name: string
+ data: CloudPanelSectionsData
+ expanded: boolean
+ onExpand: () => void
+ onCollapse: () => void
+ onAdd: CloudPanelAddSecret
+ autoFocus: boolean
+}): React.JSX.Element {
+ const t = useTranslate()
+ const id = React.useId()
+ const admin = data.effective.caller.canWrite.org
+ // A teammate's Personal values don't reach someone else's shared workspace.
+ const guest = isGuestInSharedWorkspace(data)
+ const scopes = writableScopes(data.effective.caller, {
+ repo: data.repoKey !== null,
+ shared: true
+ }).filter((option) => !guest || option !== 'personal')
+ const canAdd = scopes.length > 0
+ const [value, setValue] = React.useState('')
+ const [scope, setScope] = React.useState(scopes[0] ?? 'personal')
+ const [saving, setSaving] = React.useState(false)
+ const [error, setError] = React.useState(null)
+ const addLabel =
+ admin || guest
+ ? t('agentHostPanel.env.add', 'Add')
+ : t('agentHostPanel.env.addMine', 'Add mine')
+ const save = async (): Promise => {
+ if (!value || saving) {
+ return
+ }
+ setSaving(true)
+ setError(null)
+ const refusal = await onAdd(name, value, scope)
+ setSaving(false)
+ if (refusal) {
+ setError(refusal)
+ return
+ }
+ setValue('')
+ onCollapse()
+ }
+ return (
+
+
+ {name}
+
+ {t('agentHostPanel.env.missing', 'Missing')}
+
+ {expanded ? null : !canAdd ? (
+
+ {data.sharing?.ownerName
+ ? t('agentHostPanel.env.askOwner', 'Ask {{owner}}, an owner or an admin to add it.', {
+ owner: data.sharing.ownerName
+ })
+ : t('agentHostPanel.keys.askAdmin', 'Ask an owner or admin to add one.')}
+
+ ) : (
+
+ {addLabel}
+
+ )}
+
+ {expanded && canAdd ? (
+
+
+ {t('agentHostPanel.env.valueLabel', 'Value for {{name}}', { name })}
+
+
+
{
+ if (event.key === 'Enter') {
+ event.preventDefault()
+ void save()
+ } else if (event.key === 'Escape') {
+ event.stopPropagation()
+ onCollapse()
+ }
+ }}
+ >
+ {
+ setValue(next)
+ setError(null)
+ }}
+ placeholder={t('agentHostPanel.env.valuePlaceholder', 'Value')}
+ invalid={error !== null}
+ disabled={saving}
+ autoFocus={autoFocus}
+ describedBy={error ? `${id}-error` : `${id}-help`}
+ />
+
+ {scopes.length > 1 || scopes[0] !== 'personal' ? (
+
setScope(parseScope(next))}
+ disabled={saving}
+ >
+
+
+
+
+ {scopes.map((option) => (
+
+ {cloudSecretScopeLabel(option, t)}
+
+ ))}
+
+
+ ) : (
+
+ {cloudSecretScopeLabel('personal', t)}
+
+ )}
+
+ {error ? (
+
+ {error === 'org_admin_required'
+ ? t(
+ 'cloudSecretsAsk.memberRefused',
+ 'Only owners and admins can save team or repository values. Choose Personal.'
+ )
+ : cloudSecretsErrorCopy(error, t)}
+
+ ) : null}
+
+
+ {admin || guest
+ ? t('cloudSecrets.editor.help', 'Write-only. You won’t see it again after saving.')
+ : t(
+ 'agentHostPanel.env.memberHelp',
+ 'Saved to your personal Cloud secrets, so only your workspaces get it.'
+ )}
+
+
+
+ {t('agentHostPanel.env.cancel', 'Cancel')}
+
+ void save() : undefined}
+ >
+ {saving ? : null}
+ {t('agentHostPanel.env.save', 'Save')}
+
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/src/renderer/src/components/agent-host-panel/cloud-sections/CloudPanelSections.fixture.tsx b/src/renderer/src/components/agent-host-panel/cloud-sections/CloudPanelSections.fixture.tsx
new file mode 100644
index 00000000000..c1e3ef34c31
--- /dev/null
+++ b/src/renderer/src/components/agent-host-panel/cloud-sections/CloudPanelSections.fixture.tsx
@@ -0,0 +1,191 @@
+import React from 'react'
+import { defineFixture } from '../../../component-fixtures/component-fixture'
+import type {
+ CloudSecretsCaller,
+ CloudSecretsEffective,
+ CloudWorkspaceSharing
+} from '../../../../../shared/cloud-secrets/cloud-secrets-wire'
+import { CloudPanelSections } from './CloudPanelSections'
+import { cloudPanelSectionsData, type CloudPanelSectionsLoad } from './use-cloud-panel-sections'
+
+const UPDATED = 1_731_000_000_000
+const PRECEDENCE: CloudSecretsEffective['precedence'] = {
+ env: ['repo', 'personal', 'org'],
+ agent_key: ['org', 'repo', 'personal']
+}
+
+const ADMIN: CloudSecretsCaller = {
+ userId: 'u_ada',
+ orgId: 'org_acme',
+ orgRole: 'admin',
+ canWrite: { personal: true, org: true, repo: true }
+}
+const MEMBER: CloudSecretsCaller = {
+ userId: 'u_bo',
+ orgId: 'org_acme',
+ orgRole: 'member',
+ canWrite: { personal: true, org: false, repo: false }
+}
+
+function effective(caller: CloudSecretsCaller): CloudSecretsEffective {
+ return {
+ caller,
+ precedence: PRECEDENCE,
+ secrets: [
+ {
+ name: 'OPENAI_API_KEY',
+ kind: 'agent_key',
+ scope: 'org',
+ hint: 'M3kz',
+ updatedAt: UPDATED,
+ shadowed: [{ scope: 'personal', hint: '9f2c' }]
+ },
+ {
+ name: 'ANTHROPIC_API_KEY',
+ kind: 'agent_key',
+ scope: 'repo',
+ hint: 'a71b',
+ updatedAt: UPDATED,
+ shadowed: []
+ },
+ {
+ name: 'DATABASE_URL',
+ kind: 'env',
+ scope: 'repo',
+ hint: null,
+ updatedAt: UPDATED,
+ shadowed: [{ scope: 'personal', hint: null }]
+ },
+ {
+ name: 'NPM_TOKEN',
+ kind: 'env',
+ scope: 'personal',
+ hint: null,
+ updatedAt: UPDATED,
+ shadowed: []
+ },
+ {
+ name: 'SENTRY_DSN',
+ kind: 'env',
+ scope: 'org',
+ hint: null,
+ updatedAt: UPDATED,
+ shadowed: []
+ }
+ ]
+ }
+}
+
+function ready(
+ caller: CloudSecretsCaller,
+ options: { asleep?: boolean; sharing?: CloudWorkspaceSharing } = {}
+): CloudPanelSectionsLoad {
+ return {
+ status: 'ready',
+ data: cloudPanelSectionsData({
+ effective: effective(caller),
+ envVarNames: ['DATABASE_URL', 'NPM_TOKEN', 'SENTRY_DSN', 'STRIPE_TEST_KEY'],
+ repoKey: 'github.com/acme/web',
+ asleep: options.asleep ?? false,
+ sharing: options.sharing ?? null
+ })
+ }
+}
+
+const SHARED_GUEST: CloudWorkspaceSharing = {
+ shared: true,
+ ownerName: 'Ada Lovelace',
+ isOwner: false
+}
+
+const noop = (): void => {}
+const saved = async (): Promise => null
+
+/** Stands in for the panel shell (gap-agents' AgentHostPanel): 460 px, sections 2 and 3 only. */
+function Frame({
+ load,
+ initialExpanded = null
+}: {
+ load: CloudPanelSectionsLoad
+ initialExpanded?: string | null
+}): React.JSX.Element {
+ return (
+
+ )
+}
+
+export const admin = defineFixture({ width: 520, render: () => })
+
+export const adminAdding = defineFixture({
+ width: 520,
+ render: () =>
+})
+
+export const member = defineFixture({ width: 520, render: () => })
+
+export const memberAdding = defineFixture({
+ width: 520,
+ render: () =>
+})
+
+export const asleep = defineFixture({
+ width: 520,
+ render: () =>
+})
+
+/** D13: a teammate in Ada's shared workspace. Their Personal values don't apply there. */
+export const shared = defineFixture({
+ width: 520,
+ render: () =>
+})
+
+export const sharedAsleep = defineFixture({
+ width: 520,
+ render: () =>
+})
+
+export const sharedAdmin = defineFixture({
+ width: 520,
+ render: () => (
+
+ )
+})
+
+export const loading = defineFixture({
+ width: 520,
+ render: () =>
+})
+
+export const failed = defineFixture({
+ width: 520,
+ render: () =>
+})
+
+export const empty = defineFixture({
+ width: 520,
+ render: () => (
+
+ )
+})
diff --git a/src/renderer/src/components/agent-host-panel/cloud-sections/CloudPanelSections.tsx b/src/renderer/src/components/agent-host-panel/cloud-sections/CloudPanelSections.tsx
new file mode 100644
index 00000000000..b3d701f987f
--- /dev/null
+++ b/src/renderer/src/components/agent-host-panel/cloud-sections/CloudPanelSections.tsx
@@ -0,0 +1,387 @@
+import React from 'react'
+import { useTranslate, type Translate } from '@/i18n/use-translate'
+import { Button } from '@/components/ui/button'
+import { InlineError } from '@/components/ui/inline-error'
+import { maskedHint } from '@/components/settings/cloud-secrets/CloudSecretList'
+import {
+ cloudSecretScopeLabel,
+ cloudSecretsErrorCopy
+} from '@/components/settings/cloud-secrets/cloud-secrets-copy'
+import { cn } from '@/lib/utils'
+import type {
+ CloudSecretScope,
+ CloudSecretsErrorCode
+} from '../../../../../shared/cloud-secrets/cloud-secrets-wire'
+import { CloudMissingSecretRow } from './CloudMissingSecretRow'
+import { LoadingRows, PanelSection } from './PanelSection'
+import {
+ cloudEnvRows,
+ cloudKeyRows,
+ type CloudKeyOwner,
+ type CloudKeyRow,
+ type ShadowedKey
+} from './cloud-panel-sections-model'
+import {
+ isGuestInSharedWorkspace,
+ type CloudPanelAddSecret,
+ type CloudPanelSectionsLoad
+} from './use-cloud-panel-sections'
+
+const OWNER_LABEL: Record, string> = {
+ claude: 'Claude',
+ codex: 'Codex',
+ gemini: 'Gemini',
+ cursor: 'Cursor',
+ copilot: 'Copilot'
+}
+
+function ownerLabel(owner: CloudKeyOwner, t: Translate): string {
+ switch (owner) {
+ case 'gateway':
+ return t('agentHostPanel.keys.gateway', 'AI Gateway')
+ case 'aws':
+ return 'AWS'
+ case 'other':
+ return t('agentHostPanel.keys.other', 'Other')
+ case 'claude':
+ case 'codex':
+ case 'gemini':
+ case 'cursor':
+ case 'copilot':
+ return OWNER_LABEL[owner]
+ }
+}
+
+/** A read that failed: transport problems say where, without the save-oriented "Nothing was changed". */
+export function cloudPanelLoadErrorTitle(code: CloudSecretsErrorCode, t: Translate): string {
+ return code === 'unavailable' || code === 'timeout' || code === 'unknown'
+ ? t('agentHostPanel.unreachable', 'Couldn’t reach Orca Cloud.')
+ : cloudSecretsErrorCopy(code, t)
+}
+
+function shadowedReason(shadowedBy: CloudSecretScope, t: Translate): string {
+ return shadowedBy === 'org'
+ ? t('cloudAgents.source.shadowedByTeam', 'Not used while the team key is set.')
+ : t('cloudAgents.source.shadowedByRepo', 'Not used while the repository key is set.')
+}
+
+function ShadowedKeyLine({ name, entry }: { name: string; entry: ShadowedKey }): React.JSX.Element {
+ const t = useTranslate()
+ return (
+
+
+ {name} {maskedHint(entry.hint)}
+
+
+ {cloudSecretScopeLabel(entry.scope, t)} · {shadowedReason(entry.shadowedBy, t)}
+
+
+ )
+}
+
+function KeyRow({
+ row,
+ onEdit
+}: {
+ row: CloudKeyRow
+ onEdit?: (row: CloudKeyRow) => void
+}): React.JSX.Element {
+ const t = useTranslate()
+ const scope = cloudSecretScopeLabel(row.scope, t)
+ const detail = row.editable
+ ? row.shadowed.length > 0
+ ? t('agentHostPanel.keys.overrides', '{{scope}} · overrides {{others}}', {
+ scope,
+ others: row.shadowed.map((entry) => cloudSecretScopeLabel(entry.scope, t)).join(', ')
+ })
+ : scope
+ : t('agentHostPanel.keys.readOnly', '{{scope}} · only owners and admins can change it', {
+ scope
+ })
+ return (
+
+ {ownerLabel(row.owner, t)}
+
+
+
+ {row.name} {maskedHint(row.hint)}
+
+ {detail}
+
+ {row.shadowed.length > 0 ? (
+
+ {row.shadowed.map((entry) => (
+
+ ))}
+
+ ) : null}
+
+ {row.editable && onEdit ? (
+ onEdit(row)}
+ aria-label={t('agentHostPanel.keys.editLabel', 'Edit {{name}}', { name: row.name })}
+ >
+ {t('agentHostPanel.keys.edit', 'Edit')}
+
+ ) : (
+
+ )}
+
+ )
+}
+
+/**
+ * Panel section 2 on Orca Cloud: the agent keys a workspace gets from Cloud secrets, the winning
+ * scope first and any key it hides listed disabled with the reason. Desktop keys never appear here;
+ * the shell appends its "Use in Orca Cloud" offer through `extraRows`.
+ */
+export function CloudAgentKeysSection({
+ load,
+ number,
+ onEdit,
+ onManage,
+ onRetry,
+ extraRows
+}: {
+ load: CloudPanelSectionsLoad
+ number?: React.ReactNode
+ onEdit?: (row: CloudKeyRow) => void
+ /** Opens Cloud secrets (Settings), where keys are added. */
+ onManage?: () => void
+ onRetry: () => void
+ extraRows?: React.ReactNode
+}): React.JSX.Element {
+ const t = useTranslate()
+ const rows = load.status === 'ready' ? cloudKeyRows(load.data.effective) : []
+ return (
+
+ {load.status === 'ready' && load.data.sharing?.shared ? (
+
+ ) : null}
+ {load.status === 'loading' ? (
+
+ ) : load.status === 'failed' ? (
+
+ ) : rows.length === 0 && !extraRows ? (
+
+
+ {t('agentHostPanel.keys.empty', 'No agent keys in Cloud secrets.')}{' '}
+ {load.data.effective.caller.canWrite.org
+ ? null
+ : t(
+ 'agentHostPanel.keys.emptyMember',
+ 'Add your own, or ask an owner or admin for a team key.'
+ )}
+
+ {onManage ? (
+
+ {t('agentHostPanel.keys.manage', 'Open Cloud secrets')}
+
+ ) : null}
+
+ ) : (
+
+ {rows.map((row) => (
+
+ ))}
+ {extraRows}
+
+ )}
+
+ )
+}
+
+/**
+ * Panel section 3 on Orca Cloud: the variables the workspace gets (winning scope and what it
+ * overrides), then the environment's declared names nobody provides, each with an inline Add
+ * (members: Add mine, Personal only).
+ */
+export function CloudEnvironmentPanelSection({
+ load,
+ number,
+ onRetry,
+ onAdd,
+ focusSecret,
+ initialExpanded = null,
+ showLoadError = true
+}: {
+ load: CloudPanelSectionsLoad
+ number?: React.ReactNode
+ onRetry: () => void
+ onAdd: CloudPanelAddSecret
+ /** Off when section 2 already shows the shared read's error. */
+ showLoadError?: boolean
+ /** Deep link `{ secret: NAME }`: that missing row opens with its field focused. */
+ focusSecret?: string
+ /** Fixtures only: a row shown already expanded. */
+ initialExpanded?: string | null
+}): React.JSX.Element {
+ const t = useTranslate()
+ const [expanded, setExpanded] = React.useState(focusSecret ?? initialExpanded)
+ const data = load.status === 'ready' ? load.data : null
+ const rows = data ? cloudEnvRows(data.effective, data.missing) : []
+ const missingCount = data?.missing.length ?? 0
+ return (
+
+ {t('agentHostPanel.asleepFooter', 'Changes reach the sandbox when it wakes.')}
+
+ ) : null
+ }
+ >
+ {load.status === 'loading' ? (
+
+ ) : load.status === 'failed' ? (
+ showLoadError ? (
+
+ ) : null
+ ) : rows.length === 0 ? (
+
+ {t('agentHostPanel.env.empty', 'No variables from Cloud secrets yet.')}
+
+ ) : (
+ <>
+
+ {missingCount > 0
+ ? t('cloudSecretsAsk.missing', '{{count}} secrets missing', {
+ count: missingCount,
+ defaultValue_one: '{{count}} secret missing'
+ })
+ : ''}
+
+
+ {rows.map((row) =>
+ row.kind === 'set' ? (
+
+ {row.name}
+
+ {cloudSecretScopeLabel(row.scope, t)}
+
+ {row.overrides.length > 0 ? (
+
+ {t('agentHostPanel.env.overrides', 'overrides {{scopes}}', {
+ scopes: row.overrides
+ .map((scope) => cloudSecretScopeLabel(scope, t))
+ .join(', ')
+ })}
+
+ ) : null}
+
+ ) : data ? (
+ setExpanded(row.name)}
+ onCollapse={() => setExpanded(null)}
+ onAdd={onAdd}
+ autoFocus={expanded === row.name && focusSecret === row.name}
+ />
+ ) : null
+ )}
+
+ >
+ )}
+
+ )
+}
+
+/** Both Cloud sections, as the panel shell mounts them under section 1. */
+export function CloudPanelSections({
+ load,
+ onRetry,
+ onAdd,
+ onEdit,
+ onManage,
+ focusSecret,
+ keysExtraRows,
+ initialExpanded,
+ className
+}: {
+ load: CloudPanelSectionsLoad
+ onRetry: () => void
+ onAdd: CloudPanelAddSecret
+ onEdit?: (row: CloudKeyRow) => void
+ onManage?: () => void
+ focusSecret?: string
+ keysExtraRows?: React.ReactNode
+ initialExpanded?: string | null
+ className?: string
+}): React.JSX.Element {
+ return (
+
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/agent-host-panel/cloud-sections/PanelSection.tsx b/src/renderer/src/components/agent-host-panel/cloud-sections/PanelSection.tsx
new file mode 100644
index 00000000000..e512ff8cac0
--- /dev/null
+++ b/src/renderer/src/components/agent-host-panel/cloud-sections/PanelSection.tsx
@@ -0,0 +1,55 @@
+import React from 'react'
+import { useTranslate } from '@/i18n/use-translate'
+import { Skeleton } from '@/components/ui/skeleton'
+
+/** The section frame the panel shell uses: numbered h3, help line, then rows. */
+export function PanelSection({
+ number,
+ title,
+ help,
+ children,
+ footer
+}: {
+ number?: React.ReactNode
+ title: string
+ help?: string
+ children: React.ReactNode
+ footer?: React.ReactNode
+}): React.JSX.Element {
+ const id = React.useId()
+ return (
+
+
+
+ {number !== undefined ? (
+
+ {number}
+
+ ) : null}
+ {title}
+
+ {help ?
{help}
: null}
+
+ {children}
+ {footer}
+
+ )
+}
+
+export function LoadingRows(): React.JSX.Element {
+ const t = useTranslate()
+ return (
+
+ {[0, 1].map((row) => (
+
+
+
+
+
+ ))}
+
+ {t('agentHostPanel.loadingSecrets', 'Loading Cloud secrets…')}
+
+
+ )
+}
diff --git a/src/renderer/src/components/agent-host-panel/cloud-sections/cloud-panel-sections-model.test.ts b/src/renderer/src/components/agent-host-panel/cloud-sections/cloud-panel-sections-model.test.ts
new file mode 100644
index 00000000000..325eeaca5b0
--- /dev/null
+++ b/src/renderer/src/components/agent-host-panel/cloud-sections/cloud-panel-sections-model.test.ts
@@ -0,0 +1,114 @@
+import { describe, expect, it } from 'vitest'
+import type {
+ CloudEffectiveSecret,
+ CloudSecretsCaller,
+ CloudSecretsEffective
+} from '../../../../../shared/cloud-secrets/cloud-secrets-wire'
+import {
+ cloudEnvRows,
+ cloudKeyOwner,
+ cloudKeyRows,
+ withoutCallerPersonal
+} from './cloud-panel-sections-model'
+import { cloudPanelSectionsData } from './use-cloud-panel-sections'
+
+const MEMBER: CloudSecretsCaller = {
+ userId: 'u',
+ orgId: 'o',
+ orgRole: 'member',
+ canWrite: { personal: true, org: false, repo: false }
+}
+
+function secret(patch: Partial & { name: string }): CloudEffectiveSecret {
+ return { kind: 'env', scope: 'org', hint: null, updatedAt: 0, shadowed: [], ...patch }
+}
+
+function effective(secrets: CloudEffectiveSecret[]): CloudSecretsEffective {
+ return {
+ caller: MEMBER,
+ precedence: { env: ['repo', 'personal', 'org'], agent_key: ['org', 'repo', 'personal'] },
+ secrets
+ }
+}
+
+describe('cloudKeyOwner', () => {
+ it('maps credential names to their agent', () => {
+ expect(cloudKeyOwner('ANTHROPIC_AUTH_TOKEN')).toBe('claude')
+ expect(cloudKeyOwner('OPENAI_BASE_URL')).toBe('codex')
+ expect(cloudKeyOwner('GEMINI_API_KEY')).toBe('gemini')
+ expect(cloudKeyOwner('AI_GATEWAY_API_KEY')).toBe('gateway')
+ expect(cloudKeyOwner('AWS_REGION')).toBe('aws')
+ })
+})
+
+describe('cloudKeyRows', () => {
+ it('lists agent keys only, by agent, with the keys each one hides', () => {
+ const rows = cloudKeyRows(
+ effective([
+ secret({ name: 'DATABASE_URL' }),
+ secret({
+ name: 'OPENAI_API_KEY',
+ kind: 'agent_key',
+ scope: 'org',
+ hint: 'M3kz',
+ shadowed: [{ scope: 'personal', hint: '9f2c' }]
+ }),
+ secret({ name: 'ANTHROPIC_API_KEY', kind: 'agent_key', scope: 'personal', hint: 'a1' })
+ ])
+ )
+ expect(rows.map((row) => row.name)).toEqual(['ANTHROPIC_API_KEY', 'OPENAI_API_KEY'])
+ // A member can change their own Personal key, never the team's.
+ expect(rows.map((row) => row.editable)).toEqual([true, false])
+ expect(rows[1].shadowed).toEqual([{ scope: 'personal', hint: '9f2c', shadowedBy: 'org' }])
+ })
+})
+
+describe('cloudEnvRows', () => {
+ it('lists set variables with what they override, then missing names', () => {
+ const rows = cloudEnvRows(
+ effective([
+ secret({
+ name: 'DATABASE_URL',
+ scope: 'repo',
+ shadowed: [{ scope: 'personal', hint: null }]
+ }),
+ secret({ name: 'OPENAI_API_KEY', kind: 'agent_key' })
+ ]),
+ ['STRIPE_KEY']
+ )
+ expect(rows).toEqual([
+ { kind: 'set', name: 'DATABASE_URL', scope: 'repo', overrides: ['personal'] },
+ { kind: 'missing', name: 'STRIPE_KEY' }
+ ])
+ })
+})
+
+describe('shared workspaces (D13)', () => {
+ const values = effective([
+ secret({ name: 'NPM_TOKEN', scope: 'personal' }),
+ secret({ name: 'SENTRY_DSN', scope: 'personal', shadowed: [{ scope: 'org', hint: null }] }),
+ secret({ name: 'DATABASE_URL', scope: 'repo', shadowed: [{ scope: 'personal', hint: null }] })
+ ])
+
+ it("drops a teammate's Personal values: the next scope wins or the name goes missing", () => {
+ expect(withoutCallerPersonal(values).secrets).toEqual([
+ secret({ name: 'SENTRY_DSN', scope: 'org' }),
+ secret({ name: 'DATABASE_URL', scope: 'repo' })
+ ])
+ })
+
+ it('applies only to a teammate who is not the owner', () => {
+ const declared = ['NPM_TOKEN', 'SENTRY_DSN', 'DATABASE_URL']
+ const base = { effective: values, envVarNames: declared, repoKey: null, asleep: false }
+ expect(
+ cloudPanelSectionsData({
+ ...base,
+ sharing: { shared: true, ownerName: 'Ada', isOwner: false }
+ }).missing
+ ).toEqual(['NPM_TOKEN'])
+ expect(
+ cloudPanelSectionsData({ ...base, sharing: { shared: true, ownerName: null, isOwner: true } })
+ .missing
+ ).toEqual([])
+ })
+})
diff --git a/src/renderer/src/components/agent-host-panel/cloud-sections/cloud-panel-sections-model.ts b/src/renderer/src/components/agent-host-panel/cloud-sections/cloud-panel-sections-model.ts
new file mode 100644
index 00000000000..583102e0ce4
--- /dev/null
+++ b/src/renderer/src/components/agent-host-panel/cloud-sections/cloud-panel-sections-model.ts
@@ -0,0 +1,153 @@
+// Rows for the Orca Cloud panel's sections 2 (API keys from Cloud secrets) and 3 (Environment).
+// Pure: names, scopes and hints only.
+import { AGENT_CREDENTIAL_NAMES } from '../../../../../shared/agent-credentials/agent-credential-names'
+import type {
+ CloudEffectiveSecret,
+ CloudSecretScope,
+ CloudSecretsCaller,
+ CloudSecretsEffective
+} from '../../../../../shared/cloud-secrets/cloud-secrets-wire'
+import { effectiveOfKind } from '../../cloud-secrets-ask/cloud-secrets-ask-model'
+
+/** Which agent a Cloud-secret key serves, for the row label. Shared names (gateway, AWS) say so. */
+export type CloudKeyOwner =
+ | 'claude'
+ | 'codex'
+ | 'gemini'
+ | 'cursor'
+ | 'copilot'
+ | 'gateway'
+ | 'aws'
+ | 'other'
+
+const OWNER_ORDER: readonly CloudKeyOwner[] = [
+ 'claude',
+ 'codex',
+ 'gemini',
+ 'cursor',
+ 'copilot',
+ 'gateway',
+ 'aws',
+ 'other'
+]
+
+export function cloudKeyOwner(name: string): CloudKeyOwner {
+ if (AGENT_CREDENTIAL_NAMES.claude.includes(name)) {
+ return 'claude'
+ }
+ if (AGENT_CREDENTIAL_NAMES.codex.includes(name)) {
+ return 'codex'
+ }
+ if (name.startsWith('GEMINI_') || name.startsWith('GOOGLE_')) {
+ return 'gemini'
+ }
+ if (name.startsWith('CURSOR_')) {
+ return 'cursor'
+ }
+ if (name.startsWith('COPILOT_')) {
+ return 'copilot'
+ }
+ if (name === 'AI_GATEWAY_API_KEY') {
+ return 'gateway'
+ }
+ if (name.startsWith('AWS_')) {
+ return 'aws'
+ }
+ return 'other'
+}
+
+export type ShadowedKey = {
+ scope: CloudSecretScope
+ hint: string | null
+ /** Why it's not used: the winner's scope ('org' reads "team key", 'repo' "repository key"). */
+ shadowedBy: CloudSecretScope
+}
+
+export type CloudKeyRow = {
+ owner: CloudKeyOwner
+ name: string
+ scope: CloudSecretScope
+ hint: string | null
+ /** The caller may change the winning value (members can't touch Shared or Repository). */
+ editable: boolean
+ shadowed: ShadowedKey[]
+}
+
+export function canWriteScope(
+ caller: Pick,
+ scope: CloudSecretScope
+): boolean {
+ return scope === 'org'
+ ? caller.canWrite.org
+ : scope === 'repo'
+ ? caller.canWrite.repo
+ : caller.canWrite.personal
+}
+
+/** Section 2: one row per agent key name, the winner first, the keys it hides listed under it. */
+export function cloudKeyRows(effective: CloudSecretsEffective): CloudKeyRow[] {
+ return effectiveOfKind(effective, 'agent_key')
+ .map((secret) => ({
+ owner: cloudKeyOwner(secret.name),
+ name: secret.name,
+ scope: secret.scope,
+ hint: secret.hint,
+ editable: canWriteScope(effective.caller, secret.scope),
+ shadowed: secret.shadowed.map((entry) => ({
+ scope: entry.scope,
+ hint: entry.hint,
+ shadowedBy: secret.scope
+ }))
+ }))
+ .sort(
+ (a, b) =>
+ OWNER_ORDER.indexOf(a.owner) - OWNER_ORDER.indexOf(b.owner) || a.name.localeCompare(b.name)
+ )
+}
+
+export type CloudEnvRow =
+ | {
+ kind: 'set'
+ name: string
+ scope: CloudSecretScope
+ /** Scopes this value overrides, in precedence order. */
+ overrides: CloudSecretScope[]
+ }
+ | { kind: 'missing'; name: string }
+
+/**
+ * Section 3: variables the workspace gets (with the scope that wins), then the environment's declared
+ * names nobody provides.
+ */
+export function cloudEnvRows(
+ effective: CloudSecretsEffective,
+ missing: readonly string[]
+): CloudEnvRow[] {
+ const set: CloudEnvRow[] = effectiveOfKind(effective, 'env').map(
+ (secret: CloudEffectiveSecret) => ({
+ kind: 'set',
+ name: secret.name,
+ scope: secret.scope,
+ overrides: secret.shadowed.map((entry) => entry.scope)
+ })
+ )
+ return [...set, ...missing.map((name): CloudEnvRow => ({ kind: 'missing', name }))]
+}
+
+/**
+ * What a teammate who doesn't own a shared workspace actually gets there: the owner's Personal values
+ * apply, not theirs, so the caller's Personal winners give way to the next scope (or drop out).
+ */
+export function withoutCallerPersonal(effective: CloudSecretsEffective): CloudSecretsEffective {
+ const secrets: CloudEffectiveSecret[] = []
+ for (const secret of effective.secrets) {
+ const chain = [{ scope: secret.scope, hint: secret.hint }, ...secret.shadowed].filter(
+ (entry) => entry.scope !== 'personal'
+ )
+ const [winner, ...rest] = chain
+ if (winner) {
+ secrets.push({ ...secret, scope: winner.scope, hint: winner.hint, shadowed: rest })
+ }
+ }
+ return { ...effective, secrets }
+}
diff --git a/src/renderer/src/components/agent-host-panel/cloud-sections/use-cloud-panel-sections.ts b/src/renderer/src/components/agent-host-panel/cloud-sections/use-cloud-panel-sections.ts
new file mode 100644
index 00000000000..788a52a1a62
--- /dev/null
+++ b/src/renderer/src/components/agent-host-panel/cloud-sections/use-cloud-panel-sections.ts
@@ -0,0 +1,142 @@
+// Data for the Orca Cloud panel's sections 2–3. Both reads go to the control plane (effective Cloud
+// secrets and the org environment list), never to the sandbox, so an asleep sandbox stays asleep.
+import { useCallback, useEffect, useState } from 'react'
+import type {
+ CloudSecretScope,
+ CloudSecretsEffective,
+ CloudSecretsErrorCode,
+ CloudWorkspaceSharing
+} from '../../../../../shared/cloud-secrets/cloud-secrets-wire'
+import { withoutCallerPersonal } from './cloud-panel-sections-model'
+import {
+ environmentForRepo,
+ missingSecretNames
+} from '../../cloud-secrets-ask/cloud-secrets-ask-model'
+
+export type CloudPanelSectionsData = {
+ effective: CloudSecretsEffective
+ /** The environment's declared names nobody provides (empty without an environment). */
+ missing: string[]
+ /** `github.com/owner/name`, for Repository writes; null when the workspace repo isn't on GitHub. */
+ repoKey: string | null
+ /** Asleep: show what the control plane knows and say changes land on wake. Never wake to check. */
+ asleep: boolean
+ /** Shared with teammates (D13), and whose workspace it is; null when unknown. */
+ sharing: CloudWorkspaceSharing | null
+}
+
+/** A teammate's view of someone else's shared workspace: only their non-Personal values apply. */
+export function isGuestInSharedWorkspace(data: Pick): boolean {
+ return data.sharing !== null && data.sharing.shared && !data.sharing.isOwner
+}
+
+export function cloudPanelSectionsData(args: {
+ effective: CloudSecretsEffective
+ envVarNames: readonly string[] | null
+ repoKey: string | null
+ asleep: boolean
+ sharing: CloudWorkspaceSharing | null
+}): CloudPanelSectionsData {
+ const guest = isGuestInSharedWorkspace(args)
+ const effective = guest ? withoutCallerPersonal(args.effective) : args.effective
+ return {
+ effective,
+ missing: args.envVarNames ? missingSecretNames(args.envVarNames, effective) : [],
+ repoKey: args.repoKey,
+ asleep: args.asleep,
+ sharing: args.sharing
+ }
+}
+
+const NOT_CONFIGURED: CloudSecretsErrorCode = 'not_configured'
+
+export type CloudPanelSectionsLoad =
+ | { status: 'loading' }
+ | { status: 'failed'; error: CloudSecretsErrorCode }
+ | { status: 'ready'; data: CloudPanelSectionsData }
+
+/** Saves one value for a missing name; resolves to the refusal code, or null once saved. */
+export type CloudPanelAddSecret = (
+ name: string,
+ value: string,
+ scope: CloudSecretScope
+) => Promise
+
+export function useCloudPanelSections(args: {
+ repoKey: string | null
+ /** The sandbox's runtime environment, refreshed after a save while it's awake. */
+ runtimeEnvironmentId: string | null
+ asleep: boolean
+}): { load: CloudPanelSectionsLoad; reload: () => void; addSecret: CloudPanelAddSecret } {
+ const { repoKey, runtimeEnvironmentId, asleep } = args
+ const [load, setLoad] = useState({ status: 'loading' })
+ const [generation, setGeneration] = useState(0)
+
+ useEffect(() => {
+ const secrets = window.api.cloudSecrets
+ if (!secrets) {
+ setLoad({ status: 'failed', error: NOT_CONFIGURED })
+ return
+ }
+ let cancelled = false
+ void (async () => {
+ const [effective, list, sharing] = await Promise.all([
+ secrets.effective(repoKey ? { repo: repoKey } : {}).catch(() => null),
+ window.api.orcaCloudEnvironments?.list().catch(() => null) ?? Promise.resolve(null),
+ runtimeEnvironmentId
+ ? secrets.workspaceSharing({ runtimeEnvironmentId }).catch(() => null)
+ : Promise.resolve(null)
+ ])
+ if (cancelled) {
+ return
+ }
+ if (!effective || !effective.ok) {
+ setLoad({ status: 'failed', error: effective?.ok === false ? effective.error : 'unknown' })
+ return
+ }
+ const environment =
+ repoKey && list?.ok ? environmentForRepo(list.data.environments, repoKey) : null
+ setLoad({
+ status: 'ready',
+ data: cloudPanelSectionsData({
+ effective: effective.value,
+ envVarNames: environment?.envVarNames ?? null,
+ repoKey,
+ asleep,
+ sharing: sharing?.ok ? sharing.value : null
+ })
+ })
+ })()
+ return () => {
+ cancelled = true
+ }
+ }, [repoKey, asleep, runtimeEnvironmentId, generation])
+
+ const reload = useCallback(() => setGeneration((value) => value + 1), [])
+
+ const addSecret = useCallback(
+ async (name, value, scope) => {
+ const api = window.api.cloudSecrets
+ if (!api) {
+ return 'not_configured'
+ }
+ if (scope === 'repo' && !repoKey) {
+ return 'repo_required'
+ }
+ const target = scope === 'repo' ? { scope, repo: repoKey ?? undefined } : { scope }
+ const result = await api.put({ target, name, value }).catch(() => null)
+ if (!result || !result.ok) {
+ return result?.ok === false ? result.error : 'unknown'
+ }
+ // An awake sandbox picks it up now; an asleep one gets it when it wakes.
+ if (!asleep && runtimeEnvironmentId) {
+ void api.refreshEnv({ runtimeEnvironmentId }).catch(() => {})
+ }
+ setGeneration((current) => current + 1)
+ return null
+ },
+ [repoKey, asleep, runtimeEnvironmentId]
+ )
+
+ return { load, reload, addSecret }
+}
diff --git a/src/renderer/src/components/agent-pane/AgentPanePortals.tsx b/src/renderer/src/components/agent-pane/AgentPanePortals.tsx
new file mode 100644
index 00000000000..400d15a6089
--- /dev/null
+++ b/src/renderer/src/components/agent-pane/AgentPanePortals.tsx
@@ -0,0 +1,17 @@
+import { AgentSignInPanePortals } from '../agent-sign-in/AgentSignInPanePortals'
+import type { TerminalPaneController } from '../terminal-pane/controller/use-terminal-pane-controller'
+import { SecretRequestPanePortals } from './SecretRequestPanePortals'
+
+/** Cards an agent's host can raise over its pane: sign-in (#40228) and secret requests (S4-b). */
+export function AgentPanePortals({
+ controller
+}: {
+ controller: TerminalPaneController
+}): React.JSX.Element {
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/src/renderer/src/components/agent-pane/SecretRequestCard.fixture.tsx b/src/renderer/src/components/agent-pane/SecretRequestCard.fixture.tsx
new file mode 100644
index 00000000000..4b188d7cc56
--- /dev/null
+++ b/src/renderer/src/components/agent-pane/SecretRequestCard.fixture.tsx
@@ -0,0 +1,76 @@
+import React from 'react'
+import { defineFixture } from '../../component-fixtures/component-fixture'
+import {
+ SecretRequestCard,
+ type SecretRequestCardActions,
+ type SecretRequestCardModel
+} from './SecretRequestCard'
+
+const noop = (): void => {}
+const ACTIONS: SecretRequestCardActions = {
+ onValueChange: noop,
+ onScopeChange: noop,
+ onAdd: noop,
+ onNotNow: noop,
+ onRetry: noop,
+ onRestart: noop,
+ onDismiss: noop,
+ onToggleCollapsed: noop,
+ onDetails: noop
+}
+
+const BASE: SecretRequestCardModel = {
+ agent: 'Claude',
+ name: 'NPM_TOKEN',
+ reason: 'Needed to install @acme packages from npm.acme.dev.',
+ suggestedRepo: false,
+ scopes: ['personal', 'repo'],
+ scope: 'personal',
+ value: '',
+ phase: 'open',
+ fieldError: null,
+ more: 0,
+ canRestart: true,
+ collapsed: false
+}
+
+/** A terminal pane behind the card, as it sits over a Claude session. */
+function Pane({ model }: { model: Partial }): React.JSX.Element {
+ return (
+
+
+
● Installing private packages…
+
npm ERR! 401 Unauthorized - GET https://npm.acme.dev/@acme%2fui
+
● I need an npm token for npm.acme.dev to continue.
+
+
+
+
+
+ )
+}
+
+const frame = (model: Partial) =>
+ defineFixture({ width: 640, render: () => })
+
+export const open = frame({ value: 'npm_9f2cA81bX7' })
+export const openWithMore = frame({ more: 2 })
+export const repoHint = frame({ suggestedRepo: true })
+export const personalOnly = frame({ scopes: ['personal'] })
+export const unknownAgent = frame({ agent: null, canRestart: false })
+export const longReason = frame({
+ reason:
+ 'The install step pulls @acme/ui, @acme/icons and @acme/tokens from the private registry at npm.acme.dev, which answers 401 without a token. I tried the public mirror first, but these packages are not published there, so I need NPM_TOKEN to finish setting up.'
+})
+export const saving = frame({ value: 'npm_9f2cA81bX7', phase: 'saving' })
+export const refused = frame({
+ value: 'x'.repeat(12),
+ fieldError: 'Values can be up to 8 KB.'
+})
+export const failed = frame({ value: 'npm_9f2cA81bX7', phase: 'failed' })
+export const saved = frame({ phase: 'saved' })
+export const savedRepo = frame({ phase: 'saved', scope: 'repo' })
+export const savedAfterExpiry = frame({ phase: 'savedAfterExpiry' })
+export const expired = frame({ phase: 'expired' })
+export const collapsed = frame({ collapsed: true, more: 1 })
+export const notOnAskingPane = frame({ phase: 'saved', canRestart: false })
diff --git a/src/renderer/src/components/agent-pane/SecretRequestCard.tsx b/src/renderer/src/components/agent-pane/SecretRequestCard.tsx
new file mode 100644
index 00000000000..435c5b01ce9
--- /dev/null
+++ b/src/renderer/src/components/agent-pane/SecretRequestCard.tsx
@@ -0,0 +1,320 @@
+import React from 'react'
+import { ChevronDown, ChevronUp, KeyRound, Loader2 } from 'lucide-react'
+import { useTranslate } from '@/i18n/use-translate'
+import { Button } from '@/components/ui/button'
+import { InlineError } from '@/components/ui/inline-error'
+import { Label } from '@/components/ui/label'
+import { SecretValueInput } from '@/components/settings/cloud-secrets/SecretValueInput'
+import { cn } from '@/lib/utils'
+import type { SecretRequestScope } from '../../../../shared/secret-request-contract'
+import {
+ SecretRequestDone,
+ SecretRequestScopeControl,
+ SecretRequestTitle,
+ UntrustedReason
+} from './SecretRequestCardParts'
+
+export { secretRequestTitle } from './SecretRequestCardParts'
+
+export type SecretRequestCardPhase =
+ | 'open'
+ | 'saving'
+ /** The save didn't reach Orca Cloud; nothing changed. */
+ | 'failed'
+ | 'saved'
+ /** Saved, but the agent wasn't told: the request expired first, or orcad didn't answer (D3). */
+ | 'savedAfterExpiry'
+ | 'expired'
+
+export type SecretRequestCardModel = {
+ /** Display name ("Claude"), or null when orcad couldn't tell which agent asked. */
+ agent: string | null
+ name: string
+ /** Untrusted agent text; rendered as plain text only. */
+ reason: string | null
+ /** The agent suggested Repository. A hint only: it never preselects (D2). */
+ suggestedRepo: boolean
+ /** Personal always; Repository only when the person can write it. Never Shared (D6). */
+ scopes: readonly SecretRequestScope[]
+ scope: SecretRequestScope
+ value: string
+ phase: SecretRequestCardPhase
+ /** A refusal for this name or value (reserved name, too large…), shown under the field. */
+ fieldError: string | null
+ /** Other requests waiting on this pane. */
+ more: number
+ /** Restart is offered only on the asking agent's own pane. */
+ canRestart: boolean
+ /** Folded to one line so the output under it can be read, without declining. */
+ collapsed: boolean
+}
+
+export type SecretRequestCardActions = {
+ onValueChange: (value: string) => void
+ onScopeChange: (scope: SecretRequestScope) => void
+ onAdd: () => void
+ onNotNow: () => void
+ onRetry: () => void
+ onRestart: () => void
+ onDismiss: () => void
+ onToggleCollapsed: () => void
+ /** Opens the Agent accounts & secrets panel on this name; absent until the panel exists. */
+ onDetails?: () => void
+ /** Focus entered or left the card (the desktop keeps the request alive while it has focus). */
+ onFocusChange?: (focused: boolean) => void
+}
+
+/**
+ * S4-b: an agent in an Orca Cloud workspace asked for a secret. The value goes from this card to
+ * Orca Cloud only; the agent is told it's ready and never gets it from Orca. Non-modal: it never takes
+ * focus from the terminal (F6 moves focus in and back out).
+ */
+export function SecretRequestCard({
+ model,
+ actions,
+ rootRef,
+ className
+}: {
+ model: SecretRequestCardModel
+ actions: SecretRequestCardActions
+ rootRef?: React.Ref
+ className?: string
+}): React.JSX.Element {
+ const t = useTranslate()
+ const id = React.useId()
+ const titleId = `${id}-title`
+ const helpId = `${id}-help`
+ const errorId = `${id}-error`
+ const agentName = model.agent ?? t('secretRequest.anAgent', 'an agent')
+ const done = model.phase === 'saved' || model.phase === 'savedAfterExpiry'
+ const saving = model.phase === 'saving'
+ const canSave = model.value.length > 0 && !saving
+ const ownRef = React.useRef(null)
+ const focusedWithin = React.useRef(false)
+
+ // When the form gives way to the result, focus that was in it lands on Dismiss (never Restart, so a
+ // second Enter can't relaunch the agent) instead of falling to the page.
+ React.useLayoutEffect(() => {
+ const root = ownRef.current
+ if (done && focusedWithin.current && root && !root.contains(document.activeElement)) {
+ root.querySelector('[data-secret-request-dismiss]')?.focus()
+ }
+ }, [done])
+
+ const setRefs = (node: HTMLDivElement | null): void => {
+ ownRef.current = node
+ if (typeof rootRef === 'function') {
+ rootRef(node)
+ } else if (rootRef) {
+ rootRef.current = node
+ }
+ }
+
+ const header = (
+
+
+
+
+
+
+ {model.more > 0 ? (
+
+ {t('secretRequest.more', '{{count}} more requests', {
+ count: model.more,
+ defaultValue_one: '{{count}} more request'
+ })}
+
+ ) : null}
+
+
+ {model.collapsed ? : }
+
+
+ )
+
+ return (
+ {
+ if (event.key === 'Escape' && !done && !saving && model.phase !== 'expired') {
+ event.stopPropagation()
+ actions.onNotNow()
+ }
+ }}
+ onFocus={() => {
+ focusedWithin.current = true
+ actions.onFocusChange?.(true)
+ }}
+ onBlur={(event) => {
+ const root = event.currentTarget
+ if (root.contains(event.relatedTarget)) {
+ return
+ }
+ // A control that unmounts (Add → result) blurs with nowhere to go; that isn't leaving.
+ window.setTimeout(() => {
+ if (!root.isConnected || root.contains(document.activeElement)) {
+ return
+ }
+ if (document.activeElement === document.body) {
+ return
+ }
+ focusedWithin.current = false
+ actions.onFocusChange?.(false)
+ }, 0)
+ }}
+ className={cn(
+ 'flex w-full flex-col gap-3 rounded-lg border border-border bg-popover font-sans text-sm text-popover-foreground shadow-lg motion-safe:animate-in motion-safe:fade-in-0',
+ model.collapsed ? 'px-3 py-2' : 'p-4',
+ className
+ )}
+ >
+ {done ? (
+
+ ) : model.collapsed ? (
+ header
+ ) : (
+ <>
+ {header}
+ {model.reason ? (
+
+
+
+ ) : null}
+ {model.phase === 'expired' ? (
+
+ {t('secretRequest.dismiss', 'Dismiss')}
+
+ }
+ />
+ ) : (
+ <>
+
+
+ {t('secretRequest.valueLabel', 'Value for {{name}}', { name: model.name })}
+
+
+
{
+ if (event.key === 'Enter' && canSave) {
+ event.preventDefault()
+ actions.onAdd()
+ }
+ }}
+ >
+
+
+
+
+ {model.fieldError ? (
+
+ {model.fieldError}
+
+ ) : null}
+
+ {t(
+ 'secretRequest.helpBeforeSave',
+ 'Saves to your Cloud secrets. Commands in this workspace can read it as ${{name}}.',
+ { name: model.name }
+ )}
+
+ {model.suggestedRepo && model.scopes.includes('repo') ? (
+
+ {t('secretRequest.repoHint', '{{agent}} suggested Repository', {
+ agent: agentName
+ })}
+
+ ) : null}
+
+ {model.phase === 'failed' ? (
+
+ ) : null}
+
+ {actions.onDetails ? (
+
+ {t('agentHostPanel.details', 'Details')}
+
+ ) : null}
+
+ {t('secretRequest.notNow', 'Not now')}
+
+
+ {saving ? : null}
+ {t('secretRequest.add', 'Add secret')}
+
+
+ >
+ )}
+ >
+ )}
+
+ )
+}
diff --git a/src/renderer/src/components/agent-pane/SecretRequestCardParts.tsx b/src/renderer/src/components/agent-pane/SecretRequestCardParts.tsx
new file mode 100644
index 00000000000..adbe3219780
--- /dev/null
+++ b/src/renderer/src/components/agent-pane/SecretRequestCardParts.tsx
@@ -0,0 +1,216 @@
+import React from 'react'
+import { CircleCheck, RotateCw } from 'lucide-react'
+import { useTranslate, type Translate } from '@/i18n/use-translate'
+import { Button } from '@/components/ui/button'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue
+} from '@/components/ui/select'
+import { cloudSecretScopeLabel } from '@/components/settings/cloud-secrets/cloud-secrets-copy'
+import { cn } from '@/lib/utils'
+import type { SecretRequestScope } from '../../../../shared/secret-request-contract'
+
+const NAME_SLOT = '\u0000'
+
+/** The translated title with the variable name set in monospace. */
+export function SecretRequestTitle({
+ agent,
+ name
+}: {
+ agent: string | null
+ name: string
+}): React.JSX.Element {
+ const t = useTranslate()
+ const text = agent
+ ? t('secretRequest.title', '{{agent}} asked for {{name}}', { agent, name: NAME_SLOT })
+ : t('secretRequest.titleUnknown', 'An agent asked for {{name}}', { name: NAME_SLOT })
+ const at = text.indexOf(NAME_SLOT)
+ if (at === -1) {
+ return <>{text}>
+ }
+ return (
+ <>
+ {text.slice(0, at)}
+ {name}
+ {text.slice(at + NAME_SLOT.length)}
+ >
+ )
+}
+
+/** Title text for aria-label and announcements. */
+export function secretRequestTitle(agent: string | null, name: string, t: Translate): string {
+ return agent
+ ? t('secretRequest.title', '{{agent}} asked for {{name}}', { agent, name })
+ : t('secretRequest.titleUnknown', 'An agent asked for {{name}}', { name })
+}
+
+/** Plain text clamped to two lines, with Show more when it overflows. Never linkified. */
+export function UntrustedReason({
+ reason,
+ from
+}: {
+ reason: string
+ from: string
+}): React.JSX.Element {
+ const t = useTranslate()
+ const ref = React.useRef(null)
+ const [expanded, setExpanded] = React.useState(false)
+ const [overflows, setOverflows] = React.useState(false)
+ React.useLayoutEffect(() => {
+ const element = ref.current
+ if (element && !expanded) {
+ setOverflows(element.scrollHeight > element.clientHeight + 1)
+ }
+ }, [reason, expanded])
+ return (
+
+
+
+ {`“${reason}”`}
+
+
+
+ {from}
+ {overflows || expanded ? (
+ setExpanded(!expanded)}
+ >
+ {expanded
+ ? t('secretRequest.showLess', 'Show less')
+ : t('secretRequest.showMore', 'Show more')}
+
+ ) : null}
+
+
+ )
+}
+
+export function SecretRequestScopeControl({
+ name,
+ scopes,
+ scope,
+ disabled,
+ onChange
+}: {
+ name: string
+ scopes: readonly SecretRequestScope[]
+ scope: SecretRequestScope
+ disabled: boolean
+ onChange: (scope: SecretRequestScope) => void
+}): React.JSX.Element {
+ const t = useTranslate()
+ if (scopes.length < 2) {
+ return (
+
+ {cloudSecretScopeLabel('personal', t)}
+
+ )
+ }
+ return (
+ onChange(next === 'repo' ? 'repo' : 'personal')}
+ disabled={disabled}
+ >
+
+
+
+
+ {scopes.map((option) => (
+
+ {cloudSecretScopeLabel(option, t)}
+
+ ))}
+
+
+ )
+}
+
+/** Saved: when the value reaches new shells, whether the agent was told, and Restart {{agent}} (Q5). */
+export function SecretRequestDone({
+ agent,
+ name,
+ scope,
+ toldAgent,
+ canRestart,
+ titleId,
+ onRestart,
+ onDismiss
+}: {
+ agent: string | null
+ name: string
+ scope: SecretRequestScope
+ /** False when the request expired first or orcad didn't answer: the agent wasn't told. */
+ toldAgent: boolean
+ canRestart: boolean
+ titleId: string
+ onRestart: () => void
+ onDismiss: () => void
+}): React.JSX.Element {
+ const t = useTranslate()
+ const theAgent = agent ?? t('secretRequest.theAgent', 'The agent')
+ const agentName = agent ?? t('secretRequest.anAgent', 'an agent')
+ return (
+
+
+
+
+ {!toldAgent
+ ? t(
+ 'secretRequest.savedAfterExpiry',
+ 'Saved. {{agent}} stopped waiting, so tell it the secret is ready.',
+ { agent: theAgent }
+ )
+ : scope === 'repo'
+ ? t('secretRequest.doneRepo', '{{name}} added to this repository’s secrets.', {
+ name
+ })
+ : t('secretRequest.done', '{{name}} added to your personal secrets.', { name })}
+
+
+ {toldAgent
+ ? t(
+ 'secretRequest.doneDetail',
+ 'New shells get it now. {{agent}} was told it’s ready, and it never gets the value from Orca.',
+ { agent: theAgent }
+ )
+ : t('secretRequest.doneDetailExpired', 'New shells get it now.')}{' '}
+ {canRestart
+ ? t(
+ 'secretRequest.restartDetail',
+ 'Restart {{agent}} to give its own shell the new value.',
+ { agent: agentName }
+ )
+ : null}
+
+
+
+ {canRestart ? (
+
+
+ {t('secretRequest.restart', 'Restart {{agent}}', { agent: agentName })}
+
+ ) : null}
+
+ {t('secretRequest.dismiss', 'Dismiss')}
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/agent-pane/SecretRequestPanePortals.tsx b/src/renderer/src/components/agent-pane/SecretRequestPanePortals.tsx
new file mode 100644
index 00000000000..cc38a0a6d07
--- /dev/null
+++ b/src/renderer/src/components/agent-pane/SecretRequestPanePortals.tsx
@@ -0,0 +1,174 @@
+import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
+import { useAppStore } from '@/store'
+import { useTranslate } from '@/i18n/use-translate'
+import type { ManagedPane } from '@/lib/pane-manager/pane-manager-types'
+import {
+ useSecretRequests,
+ type SecretRequestEntry
+} from '@/lib/secret-requests/secret-request-store'
+import {
+ routeSecretRequests,
+ secretRequestAgentName
+} from '@/lib/secret-requests/secret-request-routing'
+import { makePaneKey } from '../../../../shared/workspace-layout/stable-pane-id'
+import { parseRemoteRuntimePtyId } from '../../../../shared/remote-runtime/remote-runtime-pty-id'
+import type { TerminalPaneController } from '../terminal-pane/controller/use-terminal-pane-controller'
+import { SecretRequestCard, secretRequestTitle } from './SecretRequestCard'
+import {
+ registerSecretRequestCard,
+ setSecretRequestSlot,
+ useSecretRequestSlots
+} from './secret-request-focus'
+import { useSecretRequestCard } from './use-secret-request-card'
+
+function SecretRequestPaneCardBody({
+ entry,
+ more,
+ pane,
+ paneKey,
+ ptyId,
+ worktreeId,
+ onAskingPane,
+ primary,
+ announcement
+}: {
+ entry: SecretRequestEntry
+ more: number
+ pane: ManagedPane
+ paneKey: string
+ ptyId: string
+ worktreeId: string
+ onAskingPane: boolean
+ primary: boolean
+ announcement: string
+}): React.JSX.Element {
+ const { model, actions } = useSecretRequestCard({ entry, more, worktreeId, ptyId, onAskingPane })
+ const cardRef = useRef(null)
+ // Filled after mount so screen readers hear it once; a live region's initial text isn't read.
+ const [spoken, setSpoken] = useState('')
+ const open = model.phase === 'open'
+ useEffect(() => {
+ if (!open) {
+ setSpoken('')
+ return
+ }
+ const timer = window.setTimeout(() => setSpoken(announcement), 150)
+ return () => window.clearTimeout(timer)
+ }, [open, announcement])
+
+ // F6 reaches this card from its pane (or from anywhere when it's on the active pane), and the card
+ // hands focus back to where it came from when it goes away.
+ useLayoutEffect(
+ () =>
+ registerSecretRequestCard({
+ paneKey,
+ container: pane.container,
+ card: () => cardRef.current,
+ primary
+ }),
+ [paneKey, pane.container, primary]
+ )
+
+ return (
+
+
+ {spoken}
+
+
+
+ )
+}
+
+function SecretRequestPaneSlot({
+ pane,
+ tabId,
+ visible,
+ primary,
+ worktreeId
+}: {
+ pane: ManagedPane
+ tabId: string
+ visible: boolean
+ primary: boolean
+ worktreeId: string
+}): React.JSX.Element | null {
+ const t = useTranslate()
+ const paneKey = makePaneKey(tabId, pane.leafId)
+ const ptyId = useAppStore(
+ (state) => state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[pane.leafId] ?? null
+ )
+ const remote = ptyId ? parseRemoteRuntimePtyId(ptyId) : null
+ const environmentId = remote?.environmentId ?? null
+ const handle = remote?.handle ?? null
+ useEffect(() => {
+ if (!environmentId) {
+ return
+ }
+ setSecretRequestSlot(paneKey, { paneKey, environmentId, handle, visible, primary })
+ return () => setSecretRequestSlot(paneKey, null)
+ }, [paneKey, environmentId, handle, visible, primary])
+
+ const requests = useSecretRequests()
+ const slots = useSecretRequestSlots()
+ const mine = useMemo(
+ () => (environmentId ? (routeSecretRequests(requests, slots).get(paneKey) ?? []) : []),
+ [environmentId, requests, slots, paneKey]
+ )
+ const first = mine[0]
+ if (!first || !ptyId) {
+ return null
+ }
+ const { entry } = first
+ return createPortal(
+ ,
+ pane.container,
+ `secret-request-${pane.id}`
+ )
+}
+
+/** S4-b: a pane whose agent asked for a secret shows the request card over its top edge. */
+export function SecretRequestPanePortals({
+ controller
+}: {
+ controller: TerminalPaneController
+}): React.JSX.Element {
+ const { managedPanes, tabId, activePane, isActive, isRendererVisible, worktreeId } = controller
+ return (
+ <>
+ {managedPanes.map((pane) => (
+
+ ))}
+ >
+ )
+}
diff --git a/src/renderer/src/components/agent-pane/SecretRequestUnseenNotifier.tsx b/src/renderer/src/components/agent-pane/SecretRequestUnseenNotifier.tsx
new file mode 100644
index 00000000000..e2d344005e0
--- /dev/null
+++ b/src/renderer/src/components/agent-pane/SecretRequestUnseenNotifier.tsx
@@ -0,0 +1,78 @@
+import { useEffect } from 'react'
+import { toast } from 'sonner'
+import { useTranslate } from '@/i18n/use-translate'
+import { useAppStore } from '@/store'
+import { selectWorktreesByRepo } from '@/store/workspaces/catalog/workspace-record-fields'
+import { activateAndRevealWorktree } from '@/lib/workspace/activation/worktree-activation'
+import {
+ useSecretRequests,
+ type SecretRequestEntry
+} from '@/lib/secret-requests/secret-request-store'
+import {
+ secretRequestAgentName,
+ unseenSecretRequests
+} from '@/lib/secret-requests/secret-request-routing'
+import { secretRequestTitle } from './SecretRequestCard'
+import { useSecretRequestSlots } from './secret-request-focus'
+
+// Panes mount a moment after their stream attaches; don't toast for a card that's about to show.
+const SETTLE_MS = 2_000
+const told = new Set()
+
+/** The workspace on this computer that the asking host's request belongs to. */
+function workspaceFor(entry: SecretRequestEntry): { id: string; name: string } | null {
+ const all = Object.values(selectWorktreesByRepo(useAppStore.getState()) ?? {}).flat()
+ const match =
+ all.find((worktree) => worktree.id === entry.event.worktreeId) ??
+ all.find((worktree) => worktree.runtimeOwnerEnvironmentId === entry.environmentId)
+ return match ? { id: match.id, name: match.displayName } : null
+}
+
+/**
+ * A request whose workspace isn't on screen would otherwise wait unseen until it expires: say so
+ * once, name the workspace, and open it on click.
+ */
+export function SecretRequestUnseenNotifier(): null {
+ const t = useTranslate()
+ const requests = useSecretRequests()
+ const slots = useSecretRequestSlots()
+ useEffect(() => {
+ const unseen = unseenSecretRequests(requests, slots).filter(
+ (entry) => !told.has(`${entry.environmentId}:${entry.event.requestId}`)
+ )
+ if (unseen.length === 0) {
+ return
+ }
+ const timer = window.setTimeout(() => {
+ for (const entry of unseen) {
+ told.add(`${entry.environmentId}:${entry.event.requestId}`)
+ const workspace = workspaceFor(entry)
+ toast.info(
+ secretRequestTitle(secretRequestAgentName(entry.event.agent), entry.event.name, t),
+ {
+ description: workspace
+ ? t(
+ 'secretRequest.unseenIn',
+ 'In {{workspace}}. The request waits up to 15 minutes.',
+ {
+ workspace: workspace.name
+ }
+ )
+ : t(
+ 'secretRequest.unseen',
+ 'Open that workspace to answer. The request waits up to 15 minutes.'
+ ),
+ action: workspace
+ ? {
+ label: t('secretRequest.openWorkspace', 'Open'),
+ onClick: () => activateAndRevealWorktree(workspace.id)
+ }
+ : undefined
+ }
+ )
+ }
+ }, SETTLE_MS)
+ return () => window.clearTimeout(timer)
+ }, [requests, slots, t])
+ return null
+}
diff --git a/src/renderer/src/components/agent-pane/secret-request-focus.ts b/src/renderer/src/components/agent-pane/secret-request-focus.ts
new file mode 100644
index 00000000000..8c61dca0ae9
--- /dev/null
+++ b/src/renderer/src/components/agent-pane/secret-request-focus.ts
@@ -0,0 +1,155 @@
+// Where secret-request cards can show (one slot per mounted remote pane), and keyboard focus for the
+// cards: one app-wide F6 handler moves focus into the card for the pane you're in and back out, and a
+// card that goes away hands focus back instead of dropping it on the page.
+import { useSyncExternalStore } from 'react'
+import type { SecretRequestPaneSlot } from '@/lib/secret-requests/secret-request-routing'
+
+const slots = new Map()
+const slotListeners = new Set<() => void>()
+let slotSnapshot: readonly SecretRequestPaneSlot[] = []
+
+export function setSecretRequestSlot(paneKey: string, slot: SecretRequestPaneSlot | null): void {
+ const current = slots.get(paneKey)
+ if (slot === null) {
+ if (!current) {
+ return
+ }
+ slots.delete(paneKey)
+ } else {
+ if (
+ current?.environmentId === slot.environmentId &&
+ current.handle === slot.handle &&
+ current.visible === slot.visible &&
+ current.primary === slot.primary
+ ) {
+ return
+ }
+ slots.set(paneKey, slot)
+ }
+ slotSnapshot = [...slots.values()]
+ for (const listener of slotListeners) {
+ listener()
+ }
+}
+
+export function useSecretRequestSlots(): readonly SecretRequestPaneSlot[] {
+ return useSyncExternalStore(
+ (listener) => {
+ slotListeners.add(listener)
+ return () => slotListeners.delete(listener)
+ },
+ () => slotSnapshot,
+ () => slotSnapshot
+ )
+}
+
+type CardRecord = {
+ paneKey: string
+ container: HTMLElement
+ element: HTMLElement
+ primary: boolean
+ /** Where F6 came from, to go back to. */
+ returnTo: HTMLElement | null
+ hadFocus: boolean
+}
+
+const cards = new Map()
+
+/** The pane's terminal input, when the element focus came from is gone. */
+function terminalInput(container: HTMLElement): HTMLElement | null {
+ return container.querySelector('textarea')
+}
+
+function focusInto(record: CardRecord, from: Element | null): void {
+ record.returnTo = from instanceof HTMLElement && from !== document.body ? from : null
+ const target =
+ record.element.querySelector('input:not([disabled]), textarea:not([disabled])') ??
+ record.element.querySelector('button')
+ target?.focus()
+}
+
+function focusBack(record: CardRecord): void {
+ const back =
+ record.returnTo && record.returnTo.isConnected
+ ? record.returnTo
+ : terminalInput(record.container)
+ back?.focus()
+}
+
+function onKeyDown(event: KeyboardEvent): void {
+ if (event.key !== 'F6' || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
+ return
+ }
+ const active = document.activeElement
+ const all = [...cards.values()]
+ const inside = all.find((record) => record.element.contains(active))
+ const target =
+ inside ??
+ all.find((record) => active !== null && record.container.contains(active)) ??
+ // From the terminal or nowhere only: inside Settings or a modal, F6 belongs to that surface.
+ (active === null || active === document.body || active.closest('.xterm') !== null
+ ? all.find((record) => record.primary)
+ : undefined)
+ if (!target) {
+ return
+ }
+ event.preventDefault()
+ event.stopImmediatePropagation()
+ if (inside) {
+ focusBack(inside)
+ } else {
+ focusInto(target, active)
+ }
+}
+
+export function registerSecretRequestCard(args: {
+ paneKey: string
+ container: HTMLElement
+ card: () => HTMLElement | null
+ primary: boolean
+}): () => void {
+ const element = args.card()
+ if (!element) {
+ return () => {}
+ }
+ const previous = cards.get(args.paneKey)
+ const record: CardRecord = {
+ paneKey: args.paneKey,
+ container: args.container,
+ element,
+ primary: args.primary,
+ returnTo: previous?.element === element ? previous.returnTo : null,
+ hadFocus: previous?.element === element ? previous.hadFocus : false
+ }
+ const onFocusIn = (): void => {
+ record.hadFocus = true
+ }
+ const onFocusOut = (event: FocusEvent): void => {
+ if (event.relatedTarget instanceof Node && !element.contains(event.relatedTarget)) {
+ record.hadFocus = false
+ }
+ }
+ element.addEventListener('focusin', onFocusIn)
+ element.addEventListener('focusout', onFocusOut)
+ if (cards.size === 0) {
+ window.addEventListener('keydown', onKeyDown, true)
+ }
+ cards.set(args.paneKey, record)
+ return () => {
+ element.removeEventListener('focusin', onFocusIn)
+ element.removeEventListener('focusout', onFocusOut)
+ if (cards.get(args.paneKey) === record) {
+ cards.delete(args.paneKey)
+ }
+ if (cards.size === 0) {
+ window.removeEventListener('keydown', onKeyDown, true)
+ }
+ // After the commit: if the card left the page while it held focus, focus goes back, not to .
+ queueMicrotask(() => {
+ const lost = document.activeElement === null || document.activeElement === document.body
+ if (!element.isConnected && record.hadFocus && lost) {
+ focusBack(record)
+ }
+ })
+ }
+}
diff --git a/src/renderer/src/components/agent-pane/use-secret-request-card.test.tsx b/src/renderer/src/components/agent-pane/use-secret-request-card.test.tsx
new file mode 100644
index 00000000000..8c5f65e586a
--- /dev/null
+++ b/src/renderer/src/components/agent-pane/use-secret-request-card.test.tsx
@@ -0,0 +1,207 @@
+// @vitest-environment happy-dom
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { SecretRequestedEvent } from '../../../../shared/secret-request-contract'
+import {
+ applySecretRequested,
+ readSecretRequests,
+ resetSecretRequestStoreForTests,
+ useSecretRequests
+} from '@/lib/secret-requests/secret-request-store'
+import { SecretRequestCard } from './SecretRequestCard'
+import {
+ resetSecretRequestCardCachesForTests,
+ SECRET_REQUEST_TOUCH_INTERVAL_MS,
+ useSecretRequestCard
+} from './use-secret-request-card'
+
+const rpc = vi.hoisted(() => vi.fn())
+vi.mock('@/runtime/rpc/calls/runtime-rpc-call', () => ({ callRuntimeRpc: rpc }))
+
+const NOW = 1_700_000_000_000
+const put = vi.fn()
+const refreshEnv = vi.fn()
+const effective = vi.fn()
+
+function event(patch: Partial = {}): SecretRequestedEvent {
+ return {
+ type: 'secrets.requested',
+ requestId: 'sr_aaaaaaaa',
+ name: 'NPM_TOKEN',
+ reason: 'See here ',
+ scope: 'repo',
+ agent: 'claude',
+ worktreeId: null,
+ terminalHandle: 'h1',
+ requestedAt: NOW,
+ expiresAt: NOW + 15 * 60_000,
+ ...patch
+ }
+}
+
+function Harness(): React.JSX.Element | null {
+ const entry = useSecretRequests()[0]
+ return entry ? : null
+}
+
+function Card({ entry }: { entry: ReturnType[number] }) {
+ const { model, actions } = useSecretRequestCard({
+ entry,
+ more: 0,
+ worktreeId: null,
+ ptyId: 'p',
+ onAskingPane: true
+ })
+ return
+}
+
+function valueInput(): HTMLInputElement {
+ return screen.getByLabelText('Value for NPM_TOKEN')
+}
+
+describe('S4-b secret request card', () => {
+ beforeEach(() => {
+ vi.useFakeTimers({
+ now: NOW,
+ toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval']
+ })
+ put.mockResolvedValue({ ok: true, value: {} })
+ refreshEnv.mockResolvedValue({ ok: true, value: true })
+ effective.mockResolvedValue({ ok: false, error: 'unavailable' })
+ rpc.mockImplementation(async (_target, method: string) =>
+ method === 'secrets.touchRequest'
+ ? { expiresAt: Date.now() + 15 * 60_000 }
+ : { outcome: 'provided' }
+ )
+ Object.assign(window, { api: { cloudSecrets: { put, refreshEnv, effective } } })
+ applySecretRequested('env-1', event())
+ })
+ afterEach(() => {
+ cleanup()
+ vi.useRealTimers()
+ vi.clearAllMocks()
+ resetSecretRequestStoreForTests()
+ resetSecretRequestCardCachesForTests()
+ })
+
+ it('shows the reason as plain text and never preselects the agent’s Repository hint', () => {
+ render( )
+ expect(document.querySelector('a')).toBeNull()
+ expect(screen.getByText(/See {
+ render( )
+ fireEvent.change(valueInput(), { target: { value: 'npm_secret_1' } })
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'Add secret' }))
+ })
+ expect(put).toHaveBeenCalledWith({
+ target: { scope: 'personal' },
+ name: 'NPM_TOKEN',
+ value: 'npm_secret_1'
+ })
+ const resolve = rpc.mock.calls.find(([, method]) => method === 'secrets.resolveRequest')
+ expect(resolve?.[2]).toEqual({ requestId: 'sr_aaaaaaaa', outcome: 'provided' })
+ expect(JSON.stringify(rpc.mock.calls)).not.toContain('npm_secret_1')
+ expect(screen.getByText('NPM_TOKEN added to your personal secrets.')).toBeTruthy()
+ expect(screen.getByRole('button', { name: /Restart Claude/ })).toBeTruthy()
+ })
+
+ it('says the agent stopped waiting when the request expired mid-save', async () => {
+ rpc.mockImplementation(async (_target, method: string) =>
+ method === 'secrets.resolveRequest' ? { outcome: 'expired' } : { expiresAt: NOW }
+ )
+ render( )
+ fireEvent.change(valueInput(), { target: { value: 'v' } })
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'Add secret' }))
+ })
+ expect(screen.getByText(/Claude stopped waiting, so tell it the secret is ready/)).toBeTruthy()
+ })
+
+ it('keeps the value and offers Try again when Orca Cloud is unreachable', async () => {
+ put.mockResolvedValue({ ok: false, error: 'unavailable' })
+ render( )
+ fireEvent.change(valueInput(), { target: { value: 'v' } })
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'Add secret' }))
+ })
+ expect(screen.getByText('Couldn’t reach Orca Cloud.')).toBeTruthy()
+ expect(valueInput().value).toBe('v')
+ expect(rpc.mock.calls.some(([, method]) => method === 'secrets.resolveRequest')).toBe(false)
+ })
+
+ it('touches the request at most once a minute while a value is typed', async () => {
+ render( )
+ fireEvent.change(valueInput(), { target: { value: 'v' } })
+ await act(async () => {
+ vi.advanceTimersByTime(0)
+ })
+ fireEvent.focus(valueInput())
+ fireEvent.blur(valueInput())
+ fireEvent.focus(valueInput())
+ await act(async () => {
+ vi.advanceTimersByTime(SECRET_REQUEST_TOUCH_INTERVAL_MS * 2 + 1_000)
+ })
+ const touches = rpc.mock.calls.filter(([, method]) => method === 'secrets.touchRequest')
+ expect(touches).toHaveLength(3)
+ })
+
+ it('expires on its own clock and clears what was typed', async () => {
+ render( )
+ fireEvent.change(valueInput(), { target: { value: 'typed' } })
+ // Fails the touch so the expiry stays put.
+ rpc.mockRejectedValue(new Error('offline'))
+ await act(async () => {
+ vi.advanceTimersByTime(15 * 60_000 + 1)
+ })
+ expect(screen.getByText('This request expired.')).toBeTruthy()
+ expect(screen.queryByLabelText('Value for NPM_TOKEN')).toBeNull()
+ })
+
+ it('never declines while a save is in flight', async () => {
+ let finish: (value: unknown) => void = () => {}
+ put.mockReturnValue(new Promise((resolve) => (finish = resolve)))
+ render( )
+ fireEvent.change(valueInput(), { target: { value: 'v' } })
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'Add secret' }))
+ })
+ await act(async () => {
+ fireEvent.keyDown(valueInput(), { key: 'Escape' })
+ fireEvent.click(screen.getByRole('button', { name: 'Not now' }))
+ })
+ expect(rpc.mock.calls.some(([, , params]) => params?.outcome === 'declined')).toBe(false)
+ await act(async () => finish({ ok: true, value: {} }))
+ expect(screen.getByText('NPM_TOKEN added to your personal secrets.')).toBeTruthy()
+ })
+
+ it('says the agent wasn’t told when orcad doesn’t answer the resolve', async () => {
+ rpc.mockImplementation(async (_target, method: string) => {
+ if (method === 'secrets.resolveRequest') {
+ throw new Error('timeout')
+ }
+ return { expiresAt: NOW }
+ })
+ render( )
+ fireEvent.change(valueInput(), { target: { value: 'v' } })
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'Add secret' }))
+ })
+ expect(screen.getByText(/stopped waiting, so tell it the secret is ready/)).toBeTruthy()
+ })
+
+ it('declines on Escape', async () => {
+ render( )
+ await act(async () => {
+ fireEvent.keyDown(valueInput(), { key: 'Escape' })
+ })
+ expect(readSecretRequests()).toEqual([])
+ const resolve = rpc.mock.calls.find(([, method]) => method === 'secrets.resolveRequest')
+ expect(resolve?.[2]).toEqual({ requestId: 'sr_aaaaaaaa', outcome: 'declined' })
+ })
+})
diff --git a/src/renderer/src/components/agent-pane/use-secret-request-card.ts b/src/renderer/src/components/agent-pane/use-secret-request-card.ts
new file mode 100644
index 00000000000..474d3d44f4b
--- /dev/null
+++ b/src/renderer/src/components/agent-pane/use-secret-request-card.ts
@@ -0,0 +1,300 @@
+// Drives one S4-b card: the typed value lives only here, is sent once to Orca Cloud (PUT
+// /v1/secrets/:scope/:NAME through main), then orcad is told the outcome. The value never goes to orcad.
+import { useCallback, useEffect, useState } from 'react'
+import { useTranslate } from '@/i18n/use-translate'
+import { useAppStore } from '@/store'
+import { callRuntimeRpc } from '@/runtime/rpc/calls/runtime-rpc-call'
+import { cloudSecretsErrorCopy } from '@/components/settings/cloud-secrets/cloud-secrets-copy'
+import { cloudSecretRepoKey } from '@/components/settings/cloud-secrets/cloud-secret-repos'
+import {
+ dismissSecretRequest,
+ readSecretRequestTouchedAt,
+ setSecretRequestExpiry,
+ setSecretRequestLocal,
+ setSecretRequestTouched,
+ type SecretRequestEntry
+} from '@/lib/secret-requests/secret-request-store'
+import { useAgentHostPanelOpener } from '@/lib/agent-host-panel/agent-host-panel-opener'
+import {
+ restartableSecretRequestAgent,
+ secretRequestAgentName
+} from '@/lib/secret-requests/secret-request-routing'
+import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id'
+import {
+ SECRETS_RESOLVE_REQUEST_METHOD,
+ SECRETS_TOUCH_REQUEST_METHOD,
+ type SecretRequestScope,
+ type SecretsResolveRequestResult,
+ type SecretsTouchRequestResult
+} from '../../../../shared/secret-request-contract'
+import type { CloudSecretsErrorCode } from '../../../../shared/cloud-secrets/cloud-secrets-wire'
+import type {
+ SecretRequestCardActions,
+ SecretRequestCardModel,
+ SecretRequestCardPhase
+} from './SecretRequestCard'
+
+/** D3: while the card has focus or a typed value, keep the request alive, at most once a minute. */
+export const SECRET_REQUEST_TOUCH_INTERVAL_MS = 60_000
+const RPC_TIMEOUT_MS = 15_000
+
+/** Failures that mean the request never reached Orca Cloud: nothing changed, so retry is safe. */
+const TRANSPORT_FAILURES: ReadonlySet = new Set([
+ 'unavailable',
+ 'timeout',
+ 'unknown'
+])
+
+/** Whether this person can write the workspace repo's scope; asked once per repo per session. */
+const repoWriteCache = new Map>()
+
+function canWriteRepo(repoKey: string): Promise {
+ let pending = repoWriteCache.get(repoKey)
+ if (!pending) {
+ const api = window.api.cloudSecrets
+ pending = api
+ ? api.effective({ repo: repoKey }).then(
+ (result) => result.ok && result.value.caller.canWrite.repo,
+ () => false
+ )
+ : Promise.resolve(false)
+ // A failed read is retried next time rather than cached as "no".
+ void pending.then((value) => {
+ if (!value) {
+ repoWriteCache.delete(repoKey)
+ }
+ })
+ repoWriteCache.set(repoKey, pending)
+ }
+ return pending
+}
+
+export function resetSecretRequestCardCachesForTests(): void {
+ repoWriteCache.clear()
+}
+
+function useWorkspaceRepoKey(worktreeId: string | null): string | null {
+ return useAppStore((state) => {
+ if (!worktreeId) {
+ return null
+ }
+ const repoId = getRepoIdFromWorktreeId(worktreeId)
+ const repo = state.repos.find((entry) => entry.id === repoId)
+ return repo ? cloudSecretRepoKey(repo) : null
+ })
+}
+
+async function resolveRequest(
+ environmentId: string,
+ requestId: string,
+ outcome: 'provided' | 'declined'
+): Promise {
+ try {
+ const result = await callRuntimeRpc(
+ { kind: 'environment', environmentId },
+ SECRETS_RESOLVE_REQUEST_METHOD,
+ { requestId, outcome },
+ { timeoutMs: RPC_TIMEOUT_MS }
+ )
+ return result.outcome
+ } catch {
+ return null
+ }
+}
+
+export function useSecretRequestCard(args: {
+ entry: SecretRequestEntry
+ more: number
+ worktreeId: string | null
+ /** The pane's PTY, for Restart {{agent}}. */
+ ptyId: string | null
+ /** The card sits on the asking agent's own pane; only then can Restart target it. */
+ onAskingPane: boolean
+}): { model: SecretRequestCardModel; actions: SecretRequestCardActions } {
+ const { entry, more, worktreeId, ptyId, onAskingPane } = args
+ const { environmentId, event } = entry
+ const { requestId } = event
+ const t = useTranslate()
+ const [value, setValue] = useState('')
+ const [scope, setScope] = useState('personal')
+ const [failed, setFailed] = useState(false)
+ const [fieldError, setFieldError] = useState(null)
+ const [focused, setFocused] = useState(false)
+ const [repoWritable, setRepoWritable] = useState(false)
+ const [collapsed, setCollapsed] = useState(false)
+ const repoKey = useWorkspaceRepoKey(event.worktreeId ?? worktreeId)
+ const openPanel = useAgentHostPanelOpener()
+ const local = entry.local
+
+ useEffect(() => {
+ let cancelled = false
+ if (repoKey) {
+ void canWriteRepo(repoKey).then((writable) => {
+ if (!cancelled) {
+ setRepoWritable(writable)
+ }
+ })
+ }
+ return () => {
+ cancelled = true
+ }
+ }, [repoKey])
+
+ // The card's own clock: an open request past its expiry shows as expired and drops what was typed.
+ useEffect(() => {
+ if (local !== null) {
+ return
+ }
+ const remaining = event.expiresAt - Date.now()
+ const expire = (): void => {
+ setValue('')
+ setSecretRequestLocal(environmentId, requestId, 'expired')
+ }
+ if (remaining <= 0) {
+ expire()
+ return
+ }
+ const timer = window.setTimeout(expire, remaining)
+ return () => window.clearTimeout(timer)
+ }, [environmentId, requestId, event.expiresAt, local])
+
+ // D3 hold: touch now (if a minute has passed) and every minute while focused or holding a value.
+ const holding = (focused || value.length > 0) && local === null
+ useEffect(() => {
+ if (!holding) {
+ return
+ }
+ const touch = (): void => {
+ setSecretRequestTouched(environmentId, requestId, Date.now())
+ void callRuntimeRpc(
+ { kind: 'environment', environmentId },
+ SECRETS_TOUCH_REQUEST_METHOD,
+ { requestId },
+ { timeoutMs: RPC_TIMEOUT_MS }
+ ).then(
+ (result) => setSecretRequestExpiry(environmentId, requestId, result.expiresAt),
+ () => {}
+ )
+ }
+ // Never more than once a minute, across focus changes and card remounts too.
+ const delay = Math.max(
+ 0,
+ readSecretRequestTouchedAt(environmentId, requestId) +
+ SECRET_REQUEST_TOUCH_INTERVAL_MS -
+ Date.now()
+ )
+ let interval: number | undefined
+ const first = window.setTimeout(() => {
+ touch()
+ interval = window.setInterval(touch, SECRET_REQUEST_TOUCH_INTERVAL_MS)
+ }, delay)
+ return () => {
+ window.clearTimeout(first)
+ if (interval !== undefined) {
+ window.clearInterval(interval)
+ }
+ }
+ }, [holding, environmentId, requestId])
+
+ const scopes: SecretRequestScope[] = repoKey && repoWritable ? ['personal', 'repo'] : ['personal']
+ const effectiveScope: SecretRequestScope = scopes.includes(scope) ? scope : 'personal'
+
+ const add = useCallback(async () => {
+ const api = window.api.cloudSecrets
+ if (!value || local !== null) {
+ return
+ }
+ setFailed(false)
+ setFieldError(null)
+ if (!api) {
+ setFailed(true)
+ return
+ }
+ setSecretRequestLocal(environmentId, requestId, 'saving')
+ const target =
+ effectiveScope === 'repo' && repoKey
+ ? { scope: 'repo' as const, repo: repoKey }
+ : { scope: 'personal' as const }
+ const result = await api.put({ target, name: event.name, value }).catch(() => null)
+ if (!result || !result.ok) {
+ const code: CloudSecretsErrorCode = result?.ok === false ? result.error : 'unknown'
+ setSecretRequestLocal(environmentId, requestId, null)
+ if (TRANSPORT_FAILURES.has(code)) {
+ setFailed(true)
+ } else {
+ setFieldError(cloudSecretsErrorCopy(code, t))
+ }
+ return
+ }
+ // Saved: the value is gone from the renderer from here on.
+ setValue('')
+ void api.refreshEnv({ runtimeEnvironmentId: environmentId }).catch(() => {})
+ const outcome = await resolveRequest(environmentId, requestId, 'provided')
+ // Only a confirmed 'provided' means the agent was told; otherwise say so and let the person tell it.
+ setSecretRequestLocal(
+ environmentId,
+ requestId,
+ outcome === 'provided' ? 'saved' : 'savedAfterExpiry'
+ )
+ }, [value, local, environmentId, requestId, effectiveScope, repoKey, event.name, t])
+
+ const phase: SecretRequestCardPhase =
+ local === 'saving'
+ ? 'saving'
+ : local === 'saved' || local === 'savedAfterExpiry' || local === 'expired'
+ ? local
+ : failed
+ ? 'failed'
+ : 'open'
+
+ const agent = secretRequestAgentName(event.agent)
+ const restartAgent = restartableSecretRequestAgent(event.agent)
+
+ return {
+ model: {
+ agent,
+ name: event.name,
+ reason: event.reason ?? null,
+ suggestedRepo: event.scope === 'repo',
+ scopes,
+ scope: effectiveScope,
+ // An expired request shows no field, and what was typed doesn't come back.
+ value: local === 'expired' ? '' : value,
+ phase,
+ fieldError,
+ more,
+ canRestart: restartAgent !== null && ptyId !== null && onAskingPane,
+ collapsed
+ },
+ actions: {
+ onValueChange: (next) => {
+ setValue(next)
+ setFieldError(null)
+ },
+ onScopeChange: setScope,
+ onAdd: () => void add(),
+ onRetry: () => void add(),
+ onNotNow: () => {
+ // Mid-save the value may already be stored: never tell the agent "declined" then.
+ if (local !== null) {
+ return
+ }
+ setValue('')
+ dismissSecretRequest(environmentId, requestId)
+ void resolveRequest(environmentId, requestId, 'declined')
+ },
+ onRestart: () => {
+ if (restartAgent && ptyId) {
+ useAppStore.getState().queuePaneAccountRestarts([ptyId], restartAgent)
+ }
+ dismissSecretRequest(environmentId, requestId)
+ },
+ onDismiss: () => dismissSecretRequest(environmentId, requestId),
+ onToggleCollapsed: () => setCollapsed((current) => !current),
+ onDetails: openPanel
+ ? () => openPanel({ hostId: environmentId, focus: { secret: event.name } })
+ : undefined,
+ onFocusChange: setFocused
+ }
+ }
+}
diff --git a/src/renderer/src/components/cloud-secrets-ask/CloudSecretsAskForm.tsx b/src/renderer/src/components/cloud-secrets-ask/CloudSecretsAskForm.tsx
new file mode 100644
index 00000000000..3eb672cbb96
--- /dev/null
+++ b/src/renderer/src/components/cloud-secrets-ask/CloudSecretsAskForm.tsx
@@ -0,0 +1,374 @@
+import React from 'react'
+import { CircleCheck, ClipboardPaste, KeyRound, Loader2 } from 'lucide-react'
+import { useTranslate, type Translate } from '@/i18n/use-translate'
+import { getIntlLocale } from '@/i18n/i18n'
+import { Button } from '@/components/ui/button'
+import { InlineError } from '@/components/ui/inline-error'
+import { Textarea } from '@/components/ui/textarea'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue
+} from '@/components/ui/select'
+import { SecretValueInput } from '@/components/settings/cloud-secrets/SecretValueInput'
+import {
+ cloudSecretScopeLabel,
+ cloudSecretsErrorCopy
+} from '@/components/settings/cloud-secrets/cloud-secrets-copy'
+import { cn } from '@/lib/utils'
+import type {
+ CloudSecretScope,
+ CloudSecretsErrorCode
+} from '../../../../shared/cloud-secrets/cloud-secrets-wire'
+import {
+ isTeamAdmin,
+ parseScope,
+ writableScopes,
+ type CloudSecretsAskData
+} from './cloud-secrets-ask-model'
+
+export type CloudSecretsAskPhase = 'editing' | 'saving' | 'failed' | 'saved' | 'skipped'
+
+export type CloudSecretsAskFormState = {
+ /** Typed values, by name. Cleared (and the inputs unmounted) once saved. */
+ values: Record
+ scopes: Record
+ phase: CloudSecretsAskPhase
+ /** Server refusals for single names. */
+ refused: Record
+ pasteOpen: boolean
+ pasteSummary: { matched: number; count: number; ignored: number } | null
+}
+
+export const EMPTY_ASK_STATE: CloudSecretsAskFormState = {
+ values: {},
+ scopes: {},
+ phase: 'editing',
+ refused: {},
+ pasteOpen: false,
+ pasteSummary: null
+}
+
+export type CloudSecretsAskFormActions = {
+ onValueChange: (name: string, value: string) => void
+ onScopeChange: (name: string, scope: CloudSecretScope) => void
+ onTogglePaste: () => void
+ onPaste: (text: string) => void
+ onSave: () => void
+ onSkip: () => void
+}
+
+/** "A, B and C", in the UI language. */
+function listNames(names: readonly string[]): string {
+ try {
+ return new Intl.ListFormat(getIntlLocale(), { style: 'long', type: 'conjunction' }).format(
+ names
+ )
+ } catch {
+ return names.join(', ')
+ }
+}
+
+function coverageLine(data: CloudSecretsAskData, t: Translate): string {
+ return t(
+ 'cloudSecretsAsk.coverage',
+ 'The {{environment}} environment uses {{total}}. Your team and this repository provide {{covered}}.',
+ {
+ environment: data.environmentName,
+ total: data.total,
+ covered: data.total - data.missing.length
+ }
+ )
+}
+
+/** "2 secrets missing" heading plus its coverage line (shared by the sheet and the dialog). */
+export function CloudSecretsAskHeading({
+ data,
+ headingId,
+ action
+}: {
+ data: CloudSecretsAskData
+ headingId: string
+ action?: React.ReactNode
+}): React.JSX.Element {
+ const t = useTranslate()
+ return (
+
+
+
+
+ {t('cloudSecretsAsk.missing', '{{count}} secrets missing', {
+ count: data.missing.length,
+ defaultValue_one: '{{count}} secret missing'
+ })}
+
+
{coverageLine(data, t)}
+
+ {action}
+
+ )
+}
+
+/**
+ * S2: the environment's declared names this person doesn't get a value for, asked inline. Never
+ * blocks Open; values go to Orca Cloud in one batch and the inputs unmount after the save.
+ */
+export function CloudSecretsAskForm({
+ data,
+ state,
+ actions,
+ className
+}: {
+ data: CloudSecretsAskData
+ state: CloudSecretsAskFormState
+ actions: CloudSecretsAskFormActions
+ className?: string
+}): React.JSX.Element | null {
+ const t = useTranslate()
+ const id = React.useId()
+ const headingId = `${id}-heading`
+ const admin = isTeamAdmin(data.caller)
+ const scopes = writableScopes(data.caller, { repo: data.repoKey !== null, shared: true })
+ const filled = data.missing.filter((name) => (state.values[name] ?? '').length > 0)
+ const saving = state.phase === 'saving'
+ const canSave = filled.length > 0 && !saving
+
+ if (state.phase === 'skipped') {
+ return (
+
+
+
+ {t('cloudSecretsAsk.skipped', 'Secrets skipped')}
+ {' · '}
+ {t('cloudSecretsAsk.skippedDetail', 'Add them later from the workspace’s cloud panel.')}
+
+
+ )
+ }
+
+ const savedLine =
+ state.phase === 'saved' ? (
+
+
+
+
+ {t('cloudSecretsAsk.saved', 'Secrets saved')}
+
+
+ {t(
+ 'cloudSecretsAsk.savedDetail',
+ 'New terminals and agents in this workspace get them within a few seconds.'
+ )}
+
+
+
+ ) : null
+
+ // Everything saved: just the confirmation.
+ if (state.phase === 'saved' && data.missing.length === 0) {
+ return (
+
+ {savedLine}
+
+ )
+ }
+
+ const lastName = data.missing.at(-1)
+ return (
+
+
+ {savedLine}
+
+
+
+ {t('cloudSecretsAsk.pasteEnv', 'Paste .env')}
+
+ }
+ />
+ {!admin && data.built ? (
+
+ {t(
+ 'cloudSecretsAsk.buildTeamOnly',
+ 'The environment build only uses team values. Ask an owner or admin to add {{name}} as team secrets.',
+ {
+ name: listNames(data.missing),
+ count: data.missing.length,
+ defaultValue_one:
+ 'The environment build only uses team values. Ask an owner or admin to add {{name}} as a team secret.'
+ }
+ )}
+
+ ) : null}
+ {state.pasteOpen ? (
+
+
+ {t('cloudSecretsAsk.pasteLabel', 'Paste the contents of a .env file')}
+
+
+ ) : null}
+ {state.pasteSummary ? (
+
+ {t(
+ 'cloudSecretsAsk.pasteFound',
+ 'Found {{matched}} of {{count}}. {{ignored}} other lines ignored.',
+ {
+ matched: state.pasteSummary.matched,
+ count: state.pasteSummary.count,
+ ignored: state.pasteSummary.ignored
+ }
+ )}
+
+ ) : null}
+
+ {data.missing.map((name) => {
+ const refusal = state.refused[name]
+ const errorId = `${id}-${name}-error`
+ return (
+
+
+ {name}
+
+ {
+ if (event.key === 'Enter' && name === lastName && canSave) {
+ event.preventDefault()
+ actions.onSave()
+ }
+ }}
+ >
+ actions.onValueChange(name, value)}
+ placeholder={t('cloudSecretsAsk.valuePlaceholder', 'Value')}
+ invalid={Boolean(refusal)}
+ disabled={saving}
+ describedBy={refusal ? errorId : undefined}
+ />
+
+ {scopes.length > 1 ? (
+ actions.onScopeChange(name, parseScope(scope))}
+ disabled={saving}
+ >
+
+
+
+
+ {scopes.map((scope) => (
+
+ {cloudSecretScopeLabel(scope, t)}
+
+ ))}
+
+
+ ) : (
+
+ {cloudSecretScopeLabel('personal', t)}
+
+ )}
+ {refusal ? (
+
+ {refusal === 'org_admin_required'
+ ? t(
+ 'cloudSecretsAsk.memberRefused',
+ 'Only owners and admins can save team or repository values. Choose Personal.'
+ )
+ : cloudSecretsErrorCopy(refusal, t)}
+
+ ) : null}
+
+ )
+ })}
+
+ {state.phase === 'failed' ? (
+
+ ) : null}
+
+
+ {admin
+ ? t('cloudSecrets.editor.help', 'Write-only. You won’t see it again after saving.')
+ : t(
+ 'cloudSecretsAsk.memberFooter',
+ 'Saved to your personal Cloud secrets, so only your workspaces get them. To set them for the whole team, ask an owner or admin.'
+ )}
+
+
+
+ {t('cloudSecretsAsk.skip', 'Skip')}
+
+
+ {saving ? : null}
+ {filled.length === 0
+ ? t('cloudSecretsAsk.saveNone', 'Save')
+ : t('cloudSecretsAsk.save', 'Save {{count}} secrets', {
+ count: filled.length,
+ defaultValue_one: 'Save secret'
+ })}
+
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/cloud-secrets-ask/CloudSecretsMissingNotice.fixture.tsx b/src/renderer/src/components/cloud-secrets-ask/CloudSecretsMissingNotice.fixture.tsx
new file mode 100644
index 00000000000..bb510eb11c9
--- /dev/null
+++ b/src/renderer/src/components/cloud-secrets-ask/CloudSecretsMissingNotice.fixture.tsx
@@ -0,0 +1,54 @@
+import { defineFixture } from '../../component-fixtures/component-fixture'
+import {
+ recordCloudSecretsMissing,
+ resetCloudSecretsMissingForTests
+} from '@/lib/orca-cloud/cloud-secrets-missing-notice-store'
+import type { HostedWorkspaceStatus } from '../../../../shared/hosted-sandbox/hosted-sandbox-wire'
+import { CloudWorkspaceStatusPanel } from '../orca-cloud/CloudWorkspaceStatusPanel'
+
+const NOW = Date.now()
+const noop = (): void => {}
+const ACTIONS = { onWake: noop, onRestart: noop, onArchive: noop, onUnarchive: noop }
+const RUNNING: HostedWorkspaceStatus = {
+ state: 'ready',
+ metrics: {
+ cpuPercent: 37,
+ cpus: 4,
+ load1: 1.2,
+ memTotalBytes: 8e9,
+ memUsedBytes: 5.1e9,
+ diskTotalBytes: 34.3e9,
+ diskUsedBytes: 6.2e9,
+ uptimeSeconds: 7_420,
+ oomKills: 0,
+ lastOomVictim: null,
+ orcadAlive: true
+ },
+ uptimeMs: 2 * 3_600_000,
+ usage: { totalMs: 14.6 * 3_600_000, centsPerHour: 100, costCents: 1460 },
+ archive: null,
+ checkedAt: NOW
+}
+
+/** The workspace's cloud panel after the build sheet's Secrets row was skipped. */
+export const panelSecretsMissing = defineFixture({
+ width: 300,
+ setup: () => {
+ resetCloudSecretsMissingForTests()
+ recordCloudSecretsMissing('ws-secrets', {
+ names: ['STRIPE_TEST_KEY', 'SENTRY_DSN'],
+ repoKey: 'github.com/acme/web'
+ })
+ },
+ render: () => (
+
+
+
+ )
+})
diff --git a/src/renderer/src/components/cloud-secrets-ask/CloudSecretsMissingNotice.tsx b/src/renderer/src/components/cloud-secrets-ask/CloudSecretsMissingNotice.tsx
new file mode 100644
index 00000000000..1fc732d4930
--- /dev/null
+++ b/src/renderer/src/components/cloud-secrets-ask/CloudSecretsMissingNotice.tsx
@@ -0,0 +1,172 @@
+import React, { useCallback, useEffect, useSyncExternalStore } from 'react'
+import { KeyRound, X } from 'lucide-react'
+import { useTranslate } from '@/i18n/use-translate'
+import { Button } from '@/components/ui/button'
+import { InlineError } from '@/components/ui/inline-error'
+import { Skeleton } from '@/components/ui/skeleton'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle
+} from '@/components/ui/dialog'
+import { cloudSecretsErrorCopy } from '@/components/settings/cloud-secrets/cloud-secrets-copy'
+import {
+ clearCloudSecretsMissing,
+ narrowCloudSecretsMissing,
+ readCloudSecretsMissing,
+ useCloudSecretsMissing
+} from '@/lib/orca-cloud/cloud-secrets-missing-notice-store'
+import { CloudSecretsAskForm } from './CloudSecretsAskForm'
+import { useCloudSecretsAskLoad } from './use-cloud-secrets-ask'
+import { useCloudSecretsAskForm } from './use-cloud-secrets-ask-form'
+
+// One dialog for the whole app, opened from whichever workspace panel asked.
+type OpenTarget = { workspaceId: string; repoKey: string }
+let openFor: OpenTarget | null = null
+const listeners = new Set<() => void>()
+function setOpenFor(target: OpenTarget | null): void {
+ openFor = target
+ for (const listener of listeners) {
+ listener()
+ }
+}
+function subscribe(listener: () => void): () => void {
+ listeners.add(listener)
+ return () => listeners.delete(listener)
+}
+
+export function openCloudSecretsAsk(workspaceId: string): void {
+ const notice = readCloudSecretsMissing(workspaceId)
+ if (notice) {
+ setOpenFor({ workspaceId, repoKey: notice.repoKey })
+ }
+}
+
+/** Shown in a cloud workspace's status panel while names it needs are still unfilled. */
+export function CloudSecretsMissingNotice({
+ workspaceId
+}: {
+ workspaceId: string
+}): React.JSX.Element | null {
+ const t = useTranslate()
+ const notice = useCloudSecretsMissing(workspaceId)
+ if (!notice) {
+ return null
+ }
+ return (
+
+
+
+ {t('cloudSecretsAsk.missing', '{{count}} secrets missing', {
+ count: notice.names.length,
+ defaultValue_one: '{{count}} secret missing'
+ })}
+
+ openCloudSecretsAsk(workspaceId)}
+ >
+ {t('cloudSecretsAsk.add', 'Add')}
+
+ clearCloudSecretsMissing(workspaceId)}
+ >
+
+
+
+ )
+}
+
+function CloudSecretsAskDialogBody({
+ workspaceId,
+ repoKey
+}: {
+ workspaceId: string
+ repoKey: string
+}): React.JSX.Element {
+ const t = useTranslate()
+ const { load, reload } = useCloudSecretsAskLoad(repoKey)
+ const data = load.status === 'ready' ? load.data : null
+ // Filled elsewhere since the sheet closed: the notice follows what is really missing now.
+ useEffect(() => {
+ if (load.status === 'ready') {
+ narrowCloudSecretsMissing(workspaceId, load.data.missing)
+ } else if (load.status === 'none') {
+ clearCloudSecretsMissing(workspaceId)
+ }
+ }, [load, workspaceId])
+ const onSaved = useCallback(() => reload(), [reload])
+ const onSkip = useCallback(() => setOpenFor(null), [])
+ const { state, actions } = useCloudSecretsAskForm({
+ data,
+ refreshRuntimeEnvironmentId: null,
+ onSaved,
+ onSkip
+ })
+ if (load.status === 'loading') {
+ return (
+
+
+
+ {t('cloudSecretsAsk.loading', 'Loading secrets…')}
+
+ )
+ }
+ if (load.status === 'failed') {
+ return (
+
+ )
+ }
+ if (!data) {
+ return (
+
+ {t('cloudSecretsAsk.nothingMissing', 'Nothing is missing.')}
+
+ )
+ }
+ return
+}
+
+/** App-level dialog behind the notice's Add. */
+export function CloudSecretsAskDialogHost(): React.JSX.Element | null {
+ const t = useTranslate()
+ const target = useSyncExternalStore(
+ subscribe,
+ () => openFor,
+ () => openFor
+ )
+ if (!target) {
+ return null
+ }
+ return (
+ !open && setOpenFor(null)}>
+
+
+ {t('cloudSecretsAsk.dialogTitle', 'Add missing secrets')}
+
+ {t(
+ 'cloudSecretsAsk.dialogDetail',
+ 'Values go to your Cloud secrets. New terminals and agents in this workspace get them within a few seconds.'
+ )}
+
+
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/cloud-secrets-ask/VmBuildSecretsRow.tsx b/src/renderer/src/components/cloud-secrets-ask/VmBuildSecretsRow.tsx
new file mode 100644
index 00000000000..5831c82940d
--- /dev/null
+++ b/src/renderer/src/components/cloud-secrets-ask/VmBuildSecretsRow.tsx
@@ -0,0 +1,100 @@
+import React, { useCallback, useEffect, useRef } from 'react'
+import { useAppStore } from '@/store'
+import type { PendingWorktreeCreation } from '@/lib/workspace/creation/pending-worktree-creation'
+import { cloudSecretRepoKey } from '@/components/settings/cloud-secrets/cloud-secret-repos'
+import {
+ clearCloudSecretsMissing,
+ recordCloudSecretsMissing
+} from '@/lib/orca-cloud/cloud-secrets-missing-notice-store'
+import { isOrcaCloudBuild } from '../worktree-creation/vm-build-steps'
+import type { CloudSecretsAskData } from './cloud-secrets-ask-model'
+import {
+ CloudSecretsAskForm,
+ type CloudSecretsAskFormActions,
+ type CloudSecretsAskFormState
+} from './CloudSecretsAskForm'
+import { useCloudSecretsAskLoad } from './use-cloud-secrets-ask'
+import { useCloudSecretsAskForm } from './use-cloud-secrets-ask-form'
+
+/** The build sheet's list item around the form; separated from the steps above like the Agents row. */
+export function VmBuildSecretsRowView({
+ data,
+ state,
+ actions
+}: {
+ data: CloudSecretsAskData
+ state: CloudSecretsAskFormState
+ actions: CloudSecretsAskFormActions
+}): React.JSX.Element | null {
+ // Hidden when nothing is missing, unless it is confirming a save.
+ if (data.missing.length === 0 && state.phase !== 'saved') {
+ return null
+ }
+ return (
+
+
+
+ )
+}
+
+/**
+ * S2 on the Orca Cloud build sheet: the Cloud environment's declared names that this person's
+ * workspace won't get, asked inline. Never blocks Open. Left unfilled, it becomes the workspace
+ * panel's "secrets missing" notice.
+ */
+export function VmBuildSecretsRow({
+ entry
+}: {
+ entry: PendingWorktreeCreation
+}): React.JSX.Element | null {
+ const cloud = isOrcaCloudBuild(entry)
+ const sourceRepoId = entry.request.ephemeralVmRecipe?.sourceRepoId
+ const repoKey = useAppStore((state) => {
+ const repo = state.repos.find((candidate) => candidate.id === sourceRepoId)
+ return repo ? cloudSecretRepoKey(repo) : null
+ })
+ const { load, reload } = useCloudSecretsAskLoad(cloud ? repoKey : null)
+ const data = load.status === 'ready' ? load.data : null
+
+ // What to leave behind for the workspace panel if the sheet closes with names unfilled.
+ const leaveBehind = useRef<{ workspaceId: string | null; names: string[] }>({
+ workspaceId: null,
+ names: []
+ })
+ const workspaceId = entry.worktreeId ?? null
+ const missing = data?.missing
+ useEffect(() => {
+ leaveBehind.current.workspaceId = workspaceId
+ leaveBehind.current.names = missing ?? []
+ }, [workspaceId, missing])
+
+ const onSaved = useCallback(() => reload(), [reload])
+ // Skipping only collapses the row; the panel notice is left either way when the sheet closes.
+ const onSkip = useCallback(() => {}, [])
+ const { state, actions } = useCloudSecretsAskForm({
+ data,
+ // Only once the sandbox answers; before that its first session env reads the new values anyway.
+ refreshRuntimeEnvironmentId: entry.vmEnvironmentId ?? null,
+ onSaved,
+ onSkip
+ })
+
+ useEffect(() => {
+ const record = leaveBehind.current
+ return () => {
+ if (!record.workspaceId || !repoKey) {
+ return
+ }
+ if (record.names.length > 0) {
+ recordCloudSecretsMissing(record.workspaceId, { names: record.names, repoKey })
+ } else {
+ clearCloudSecretsMissing(record.workspaceId)
+ }
+ }
+ }, [repoKey])
+
+ if (!data) {
+ return null
+ }
+ return
+}
diff --git a/src/renderer/src/components/cloud-secrets-ask/cloud-secrets-ask-model.test.ts b/src/renderer/src/components/cloud-secrets-ask/cloud-secrets-ask-model.test.ts
new file mode 100644
index 00000000000..033b3387bbe
--- /dev/null
+++ b/src/renderer/src/components/cloud-secrets-ask/cloud-secrets-ask-model.test.ts
@@ -0,0 +1,122 @@
+import { describe, expect, it } from 'vitest'
+import type {
+ CloudSecretsCaller,
+ CloudSecretsEffective
+} from '../../../../shared/cloud-secrets/cloud-secrets-wire'
+import {
+ cloudSecretsAskData,
+ environmentForRepo,
+ githubRepoKey,
+ missingSecretNames,
+ applyDotenvPaste,
+ parseScope,
+ writableScopes
+} from './cloud-secrets-ask-model'
+
+const MEMBER: CloudSecretsCaller = {
+ userId: 'u',
+ orgId: 'o',
+ orgRole: 'member',
+ canWrite: { personal: true, org: false, repo: false }
+}
+
+function effective(names: string[]): CloudSecretsEffective {
+ return {
+ caller: MEMBER,
+ precedence: { env: ['repo', 'personal', 'org'], agent_key: ['org', 'repo', 'personal'] },
+ secrets: names.map((name) => ({
+ name,
+ kind: 'env',
+ scope: 'org',
+ hint: null,
+ updatedAt: 0,
+ shadowed: []
+ }))
+ }
+}
+
+describe('githubRepoKey', () => {
+ it.each([
+ ['https://github.com/Acme/Web.git', 'github.com/acme/web'],
+ ['git@github.com:acme/web.git', 'github.com/acme/web'],
+ ['ssh://git@github.com/acme/web', 'github.com/acme/web'],
+ ['github.com/acme/web/', 'github.com/acme/web'],
+ ['acme/web', 'github.com/acme/web']
+ ])('reads %s', (url, key) => {
+ expect(githubRepoKey(url)).toBe(key)
+ })
+
+ it('rejects other hosts', () => {
+ expect(githubRepoKey('https://gitlab.com/acme/web')).toBeNull()
+ })
+})
+
+describe('environmentForRepo', () => {
+ it('finds the environment that lists the repo in any URL form', () => {
+ const environments = [
+ { id: 'a', repos: [{ url: 'https://github.com/acme/api', defaultBranch: null }] },
+ { id: 'b', repos: [{ url: 'git@github.com:acme/web.git', defaultBranch: null }] }
+ ]
+ expect(environmentForRepo(environments, 'github.com/acme/web')?.id).toBe('b')
+ expect(environmentForRepo(environments, 'github.com/acme/docs')).toBeNull()
+ })
+})
+
+describe('missingSecretNames', () => {
+ it('is declared names minus effective ones, without agent keys or repeats', () => {
+ expect(
+ missingSecretNames(
+ ['DATABASE_URL', 'STRIPE_KEY', 'ANTHROPIC_API_KEY', 'STRIPE_KEY', 'SENTRY_DSN'],
+ effective(['DATABASE_URL'])
+ )
+ ).toEqual(['STRIPE_KEY', 'SENTRY_DSN'])
+ })
+})
+
+describe('writableScopes', () => {
+ it('starts with Personal and adds only what the caller can write', () => {
+ expect(writableScopes(MEMBER, { repo: true, shared: true })).toEqual(['personal'])
+ const admin = { canWrite: { personal: true, org: true, repo: true } }
+ expect(writableScopes(admin, { repo: true, shared: true })).toEqual(['personal', 'repo', 'org'])
+ // A secret request never offers Shared, and Repository needs a repo.
+ expect(writableScopes(admin, { repo: false, shared: false })).toEqual(['personal'])
+ })
+})
+
+describe('cloudSecretsAskData', () => {
+ it('counts declared names without agent keys and flags a built environment', () => {
+ const data = cloudSecretsAskData({
+ environment: {
+ name: 'acme-web',
+ envVarNames: ['A', 'B', 'C', 'OPENAI_API_KEY'],
+ activeBuildId: 'bld_1'
+ },
+ effective: effective(['A']),
+ repoKey: 'github.com/acme/web'
+ })
+ expect(data).toMatchObject({ total: 3, missing: ['B', 'C'], built: true })
+ })
+})
+
+describe('parseScope', () => {
+ it('falls back to Personal for anything unexpected', () => {
+ expect(parseScope('org')).toBe('org')
+ expect(parseScope('team')).toBe('personal')
+ })
+})
+
+describe('applyDotenvPaste', () => {
+ it('fills only missing names and counts the rest as ignored', () => {
+ const result = applyDotenvPaste(
+ 'STRIPE_KEY=sk_1\nOTHER=x\n# comment\nSENTRY_DSN="https://a"\nSTRIPE_KEY=sk_2',
+ ['STRIPE_KEY', 'SENTRY_DSN', 'NPM_TOKEN'],
+ { NPM_TOKEN: 'kept' }
+ )
+ expect(result.values).toEqual({
+ NPM_TOKEN: 'kept',
+ STRIPE_KEY: 'sk_2',
+ SENTRY_DSN: 'https://a'
+ })
+ expect(result.summary).toEqual({ matched: 2, count: 3, ignored: 2 })
+ })
+})
diff --git a/src/renderer/src/components/cloud-secrets-ask/cloud-secrets-ask-model.ts b/src/renderer/src/components/cloud-secrets-ask/cloud-secrets-ask-model.ts
new file mode 100644
index 00000000000..9e048ac8ec7
--- /dev/null
+++ b/src/renderer/src/components/cloud-secrets-ask/cloud-secrets-ask-model.ts
@@ -0,0 +1,153 @@
+// What a Cloud workspace still needs from Cloud secrets: the environment's declared names that no scope
+// provides for this repo and caller (spec S2, panel section 3). Names only; no value ever reaches here.
+import { cloudSecretKind } from '../../../../shared/cloud-secrets/cloud-secret-rules'
+import { parseDotenvPaste } from '../../../../shared/cloud-secrets/dotenv-paste'
+import type {
+ CloudEffectiveSecret,
+ CloudSecretScope,
+ CloudSecretsCaller,
+ CloudSecretsEffective
+} from '../../../../shared/cloud-secrets/cloud-secrets-wire'
+import type { HostedEnvironmentListEntry } from '../../../../shared/hosted-sandbox/hosted-environment-wire'
+
+/** `github.com/owner/name`, lower case, from any GitHub remote form (https, ssh, bare owner/name). */
+export function githubRepoKey(url: string): string | null {
+ const trimmed = url
+ .trim()
+ .toLowerCase()
+ .replace(/\.git$/, '')
+ .replace(/\/+$/, '')
+ const match =
+ /^(?:https?:\/\/|ssh:\/\/)?(?:[^@/]+@)?github\.com[:/]([^/\s]+)\/([^/\s]+)$/.exec(trimmed) ??
+ /^([^/\s:@]+)\/([^/\s]+)$/.exec(trimmed)
+ return match ? `github.com/${match[1]}/${match[2]}` : null
+}
+
+/** The org environment that lists this repo; the control plane boots a create from the same one. */
+export function environmentForRepo>(
+ environments: readonly T[],
+ repoKey: string
+): T | null {
+ return (
+ environments.find((environment) =>
+ environment.repos.some((repo) => githubRepoKey(repo.url) === repoKey)
+ ) ?? null
+ )
+}
+
+/**
+ * Declared names this caller's workspace won't get. Agent keys are left out: the Agents slice owns
+ * those, and a Cloud secret request can't ask for them either.
+ */
+export function missingSecretNames(
+ envVarNames: readonly string[],
+ effective: Pick
+): string[] {
+ const provided = new Set(effective.secrets.map((secret) => secret.name))
+ const seen = new Set()
+ const missing: string[] = []
+ for (const name of envVarNames) {
+ if (provided.has(name) || seen.has(name) || cloudSecretKind(name) === 'agent_key') {
+ continue
+ }
+ seen.add(name)
+ missing.push(name)
+ }
+ return missing
+}
+
+/** Scopes a person can save a missing value to, Personal first. Shared only where asked for. */
+export function writableScopes(
+ caller: Pick,
+ options: { repo: boolean; shared: boolean }
+): CloudSecretScope[] {
+ const scopes: CloudSecretScope[] = ['personal']
+ if (options.repo && caller.canWrite.repo) {
+ scopes.push('repo')
+ }
+ if (options.shared && caller.canWrite.org) {
+ scopes.push('org')
+ }
+ return scopes
+}
+
+/** Owners and admins: anyone who can write the team scope. */
+export function isTeamAdmin(caller: Pick): boolean {
+ return caller.canWrite.org
+}
+
+export type CloudSecretsAskData = {
+ environmentName: string
+ /** Declared names, after dropping agent keys. */
+ total: number
+ missing: string[]
+ caller: CloudSecretsCaller
+ /** The environment has been built, so its install script ran without the missing team values. */
+ built: boolean
+ repoKey: string | null
+}
+
+export function cloudSecretsAskData(args: {
+ environment: Pick
+ effective: CloudSecretsEffective
+ repoKey: string | null
+}): CloudSecretsAskData {
+ const declared = args.environment.envVarNames.filter(
+ (name) => cloudSecretKind(name) !== 'agent_key'
+ )
+ return {
+ environmentName: args.environment.name,
+ total: new Set(declared).size,
+ missing: missingSecretNames(declared, args.effective),
+ caller: args.effective.caller,
+ built: args.environment.activeBuildId !== null,
+ repoKey: args.repoKey
+ }
+}
+
+/** Effective entries of one kind, sorted by name. */
+export function effectiveOfKind(
+ effective: Pick,
+ kind: CloudEffectiveSecret['kind']
+): CloudEffectiveSecret[] {
+ return effective.secrets
+ .filter((secret) => secret.kind === kind)
+ .sort((a, b) => a.name.localeCompare(b.name))
+}
+
+const SCOPES: readonly CloudSecretScope[] = ['personal', 'repo', 'org']
+
+/** A Select's string value back as a scope; anything unexpected falls back to Personal. */
+export function parseScope(value: string): CloudSecretScope {
+ return SCOPES.find((scope) => scope === value) ?? 'personal'
+}
+
+/** Fills the missing names from pasted .env text; the text itself is never kept. */
+export function applyDotenvPaste(
+ text: string,
+ missing: readonly string[],
+ values: Record
+): {
+ values: Record
+ summary: { matched: number; count: number; ignored: number }
+} {
+ const wanted = new Set(missing)
+ const entries = parseDotenvPaste(text)
+ const next = { ...values }
+ const matched = new Set()
+ for (const entry of entries) {
+ // Later lines win, as in a shell; an earlier duplicate counts as ignored.
+ if (wanted.has(entry.name) && (entry.issue === null || entry.issue === 'overridden')) {
+ next[entry.name] = entry.value
+ matched.add(entry.name)
+ }
+ }
+ return {
+ values: next,
+ summary: {
+ matched: matched.size,
+ count: missing.length,
+ ignored: entries.length - matched.size
+ }
+ }
+}
diff --git a/src/renderer/src/components/cloud-secrets-ask/use-cloud-secrets-ask-form.ts b/src/renderer/src/components/cloud-secrets-ask/use-cloud-secrets-ask-form.ts
new file mode 100644
index 00000000000..92669da887d
--- /dev/null
+++ b/src/renderer/src/components/cloud-secrets-ask/use-cloud-secrets-ask-form.ts
@@ -0,0 +1,99 @@
+import { useCallback, useMemo, useState } from 'react'
+import type { CloudSecretScope } from '../../../../shared/cloud-secrets/cloud-secrets-wire'
+import { applyDotenvPaste, type CloudSecretsAskData } from './cloud-secrets-ask-model'
+import {
+ EMPTY_ASK_STATE,
+ type CloudSecretsAskFormActions,
+ type CloudSecretsAskFormState
+} from './CloudSecretsAskForm'
+import { saveMissingSecrets } from './use-cloud-secrets-ask'
+
+export function useCloudSecretsAskForm(args: {
+ data: CloudSecretsAskData | null
+ /** Running sandbox to refresh after a save; null while it is starting or asleep. */
+ refreshRuntimeEnvironmentId: string | null
+ onSaved: (names: string[]) => void
+ onSkip: () => void
+}): { state: CloudSecretsAskFormState; actions: CloudSecretsAskFormActions } {
+ const { data, refreshRuntimeEnvironmentId, onSaved, onSkip } = args
+ const [state, setState] = useState(EMPTY_ASK_STATE)
+
+ const save = useCallback(async () => {
+ if (!data) {
+ return
+ }
+ const values = Object.fromEntries(
+ data.missing
+ .map((name) => [name, state.values[name] ?? ''] as const)
+ .filter(([, value]) => value.length > 0)
+ )
+ if (Object.keys(values).length === 0) {
+ return
+ }
+ setState((current) => ({ ...current, phase: 'saving', refused: {} }))
+ const result = await saveMissingSecrets({
+ values,
+ scopes: state.scopes,
+ repoKey: data.repoKey,
+ refreshRuntimeEnvironmentId
+ })
+ if (!result.ok) {
+ setState((current) => ({ ...current, phase: 'failed' }))
+ return
+ }
+ setState((current) => {
+ // Saved values leave the renderer at once; refused ones stay so they can be fixed.
+ const kept = Object.fromEntries(
+ Object.entries(current.values).filter(([name]) => !result.saved.includes(name))
+ )
+ return {
+ ...current,
+ values: kept,
+ refused: result.refused,
+ phase: result.saved.length > 0 ? 'saved' : 'editing',
+ pasteSummary: null
+ }
+ })
+ if (result.saved.length > 0) {
+ onSaved(result.saved)
+ }
+ }, [data, state.values, state.scopes, refreshRuntimeEnvironmentId, onSaved])
+
+ const actions = useMemo(
+ () => ({
+ onValueChange: (name, value) =>
+ setState((current) => {
+ const { [name]: _cleared, ...refused } = current.refused
+ return {
+ ...current,
+ values: { ...current.values, [name]: value },
+ refused,
+ phase: current.phase === 'failed' ? 'editing' : current.phase
+ }
+ }),
+ onScopeChange: (name: string, scope: CloudSecretScope) =>
+ setState((current) => ({ ...current, scopes: { ...current.scopes, [name]: scope } })),
+ onTogglePaste: () => setState((current) => ({ ...current, pasteOpen: !current.pasteOpen })),
+ onPaste: (text) =>
+ setState((current) => {
+ if (!data || !text.trim()) {
+ return current
+ }
+ const applied = applyDotenvPaste(text, data.missing, current.values)
+ return {
+ ...current,
+ values: applied.values,
+ pasteOpen: false,
+ pasteSummary: applied.summary
+ }
+ }),
+ onSave: () => void save(),
+ onSkip: () => {
+ setState({ ...EMPTY_ASK_STATE, phase: 'skipped' })
+ onSkip()
+ }
+ }),
+ [data, save, onSkip]
+ )
+ return { state, actions }
+}
diff --git a/src/renderer/src/components/cloud-secrets-ask/use-cloud-secrets-ask.ts b/src/renderer/src/components/cloud-secrets-ask/use-cloud-secrets-ask.ts
new file mode 100644
index 00000000000..d715d2b7ac7
--- /dev/null
+++ b/src/renderer/src/components/cloud-secrets-ask/use-cloud-secrets-ask.ts
@@ -0,0 +1,153 @@
+// Loads what S2 and the panel's section 3 need for one repo: the org environment that lists it and the
+// caller's effective Cloud secrets. Two reads, no new endpoint; nothing here wakes a sandbox.
+import { useCallback, useEffect, useState } from 'react'
+import {
+ cloudSecretsErrorCode,
+ type CloudSecretBatchResult,
+ type CloudSecretScope,
+ type CloudSecretsEffective,
+ type CloudSecretsErrorCode
+} from '../../../../shared/cloud-secrets/cloud-secrets-wire'
+import type { HostedEnvironmentListEntry } from '../../../../shared/hosted-sandbox/hosted-environment-wire'
+import {
+ cloudSecretsAskData,
+ environmentForRepo,
+ type CloudSecretsAskData
+} from './cloud-secrets-ask-model'
+
+const UNAVAILABLE: CloudSecretsErrorCode = 'unavailable'
+const NOT_CONFIGURED: CloudSecretsErrorCode = 'not_configured'
+
+export type CloudSecretsAskLoad =
+ | { status: 'loading' }
+ /** No Cloud, no environment for this repo, or nothing declared: S2 stays hidden. */
+ | { status: 'none' }
+ | { status: 'failed'; error: CloudSecretsErrorCode }
+ | {
+ status: 'ready'
+ data: CloudSecretsAskData
+ effective: CloudSecretsEffective
+ environment: HostedEnvironmentListEntry
+ }
+
+export function useCloudSecretsAskLoad(repoKey: string | null): {
+ load: CloudSecretsAskLoad
+ reload: () => void
+} {
+ const [load, setLoad] = useState({ status: 'loading' })
+ const [generation, setGeneration] = useState(0)
+ useEffect(() => {
+ const secrets = window.api.cloudSecrets
+ const environments = window.api.orcaCloudEnvironments
+ if (!repoKey || !secrets || !environments) {
+ setLoad({ status: 'none' })
+ return
+ }
+ let cancelled = false
+ void (async () => {
+ const [list, effective] = await Promise.all([
+ environments.list().catch(() => null),
+ secrets.effective({ repo: repoKey }).catch(() => null)
+ ])
+ if (cancelled) {
+ return
+ }
+ // A list that failed isn't "no environment": the caller must not take it as nothing missing.
+ if (!list || !list.ok) {
+ setLoad({ status: 'failed', error: UNAVAILABLE })
+ return
+ }
+ const environment = environmentForRepo(list.data.environments, repoKey)
+ if (!environment) {
+ setLoad({ status: 'none' })
+ return
+ }
+ if (!effective || !effective.ok) {
+ setLoad({ status: 'failed', error: effective?.ok === false ? effective.error : 'unknown' })
+ return
+ }
+ setLoad({
+ status: 'ready',
+ data: cloudSecretsAskData({ environment, effective: effective.value, repoKey }),
+ effective: effective.value,
+ environment
+ })
+ })()
+ return () => {
+ cancelled = true
+ }
+ }, [repoKey, generation])
+ const reload = useCallback(() => setGeneration((value) => value + 1), [])
+ return { load, reload }
+}
+
+export type CloudSecretsSaveResult =
+ | { ok: true; saved: string[]; refused: Record }
+ | { ok: false; error: CloudSecretsErrorCode }
+
+const TRANSPORT: ReadonlySet = new Set(['unavailable', 'timeout', 'unknown'])
+
+/**
+ * One batch per scope (usually one), then the existing env refresh for a running sandbox. A transport
+ * failure before anything was written reports "nothing changed"; per-name refusals come back by name.
+ */
+export async function saveMissingSecrets(args: {
+ values: Record
+ scopes: Record
+ repoKey: string | null
+ /** The sandbox's runtime environment, when it is up and should pick the values up now. */
+ refreshRuntimeEnvironmentId: string | null
+}): Promise {
+ const api = window.api.cloudSecrets
+ if (!api) {
+ return { ok: false, error: NOT_CONFIGURED }
+ }
+ const byScope = new Map>()
+ for (const [name, value] of Object.entries(args.values)) {
+ if (!value) {
+ continue
+ }
+ const scope = args.scopes[name] ?? 'personal'
+ byScope.set(scope, { ...byScope.get(scope), [name]: value })
+ }
+ const saved: string[] = []
+ const refused: Record = {}
+ for (const [scope, set] of byScope) {
+ if (scope === 'repo' && !args.repoKey) {
+ for (const name of Object.keys(set)) {
+ refused[name] = 'repo_required'
+ }
+ continue
+ }
+ const target = scope === 'repo' ? { scope, repo: args.repoKey ?? undefined } : { scope }
+ const result = await api.batch({ target, set }).catch(() => null)
+ if (!result || !result.ok) {
+ const error: CloudSecretsErrorCode = result?.ok === false ? result.error : 'unknown'
+ if (TRANSPORT.has(error) && saved.length === 0) {
+ return { ok: false, error }
+ }
+ for (const name of Object.keys(set)) {
+ refused[name] = error
+ }
+ continue
+ }
+ collect(result.value, saved, refused)
+ }
+ if (saved.length > 0 && args.refreshRuntimeEnvironmentId) {
+ void api.refreshEnv({ runtimeEnvironmentId: args.refreshRuntimeEnvironmentId }).catch(() => {})
+ }
+ return { ok: true, saved, refused }
+}
+
+function collect(
+ result: CloudSecretBatchResult,
+ saved: string[],
+ refused: Record
+): void {
+ for (const secret of result.secrets) {
+ saved.push(secret.name)
+ }
+ for (const error of result.errors) {
+ refused[error.name] = cloudSecretsErrorCode(error.reason ?? error.error)
+ }
+}
diff --git a/src/renderer/src/components/orca-cloud/CloudWorkspaceStatusPanel.tsx b/src/renderer/src/components/orca-cloud/CloudWorkspaceStatusPanel.tsx
index 432fec9bd84..1643fbd1b27 100644
--- a/src/renderer/src/components/orca-cloud/CloudWorkspaceStatusPanel.tsx
+++ b/src/renderer/src/components/orca-cloud/CloudWorkspaceStatusPanel.tsx
@@ -25,6 +25,7 @@ import {
type CloudStatusEntry
} from '@/lib/orca-cloud/orca-cloud-workspace-store'
import { CloudStateDot } from './CloudStateDot'
+import { CloudSecretsMissingNotice } from '../cloud-secrets-ask/CloudSecretsMissingNotice'
import { cloudStateDetail, cloudStateLabel } from './cloud-state-copy'
export type CloudPanelActions = {
@@ -202,6 +203,8 @@ export function CloudWorkspaceStatusPanel({
+ {state === 'archived' ? null : }
+
{unavailable ? (
options.pendingWrites ? never() : ok(view(target.scope, name, 'env', null, 0, caller.userId)),
remove: () => (options.pendingWrites ? never() : ok(true as const)),
+ workspaceSharing: () => ok({ shared: false, ownerName: null, isOwner: true }),
batch: ({ target, set }) =>
options.pendingWrites
? never()
diff --git a/src/renderer/src/components/terminal-pane/controller/TerminalPaneSurface.tsx b/src/renderer/src/components/terminal-pane/controller/TerminalPaneSurface.tsx
index 715d644d9f3..9cedd7531af 100644
--- a/src/renderer/src/components/terminal-pane/controller/TerminalPaneSurface.tsx
+++ b/src/renderer/src/components/terminal-pane/controller/TerminalPaneSurface.tsx
@@ -21,7 +21,7 @@ import { handleInternalTerminalFileDrop } from '../input/file-drop/terminal-drop
import { TerminalQuickCommandEditorDialog } from '../quick-commands/TerminalQuickCommandEditorDialog'
import { TerminalPaneNativeChatPortal } from '../chat/TerminalPaneNativeChatPortal'
import { TerminalPaneSleepingPortals } from '../session-restore/spawn-deferral/TerminalPaneSleepingPortals'
-import { AgentSignInPanePortals } from '../../agent-sign-in/AgentSignInPanePortals'
+import { AgentPanePortals } from '../../agent-pane/AgentPanePortals'
import { TerminalPaneCodexRestartPortals } from './TerminalPaneCodexRestartPortals'
import { TerminalPaneMobileDriverPortals } from '../mobile-driver/TerminalPaneMobileDriverPortals'
import { TerminalPaneProcessExitPortals } from '../process-exit/TerminalPaneProcessExitPortals'
@@ -390,7 +390,7 @@ export function TerminalPaneSurface({
/>
-
+
{}
@@ -310,3 +312,49 @@ function LiveBuild(): React.JSX.Element {
}
export const live = defineFixture({ width: 760, render: () => })
+
+// Orca Cloud › Secrets (S2): the environment's names this person doesn't get a value for.
+
+const cloudBuilding = entry(
+ { environmentCheck: { status: 'running', startedAt: T0 + 4_800 }, provisioningLog: LOG },
+ CLOUD_RECIPE
+)
+const secretsFrame = (row: React.ReactNode) =>
+ defineFixture({
+ width: 760,
+ setup: () => seedFixtureRepo(),
+ render: () => sheet(cloudBuilding, null, T0 + 6_000, undefined, { secretsRow: row })
+ })
+const TYPED = { STRIPE_TEST_KEY: 'sk_test_51H8xQ2eZvKYlo2C' }
+
+export const secretsMissing = secretsFrame(secretsRow(secretsData('admin'), { values: TYPED }))
+export const secretsMember = secretsFrame(
+ secretsRow(secretsData('member', { missing: ['STRIPE_TEST_KEY', 'DATABASE_URL'] }))
+)
+export const secretsBuildOnlyTeam = secretsFrame(secretsRow(secretsData('member', { built: true })))
+export const secretsPasted = secretsFrame(
+ secretsRow(secretsData('admin'), {
+ values: { ...TYPED, SENTRY_DSN: 'https://k@o1.ingest.sentry.io/1' },
+ pasteSummary: { matched: 2, count: 2, ignored: 4 }
+ })
+)
+export const secretsPasteOpen = secretsFrame(secretsRow(secretsData('admin'), { pasteOpen: true }))
+export const secretsSaving = secretsFrame(
+ secretsRow(secretsData('admin'), { values: TYPED, phase: 'saving' })
+)
+export const secretsSaved = secretsFrame(
+ secretsRow(secretsData('admin', { missing: [] }), { phase: 'saved' })
+)
+export const secretsPartlySaved = secretsFrame(
+ secretsRow(secretsData('admin', { missing: ['SENTRY_DSN'] }), { phase: 'saved' })
+)
+export const secretsFailed = secretsFrame(
+ secretsRow(secretsData('admin'), { values: TYPED, phase: 'failed' })
+)
+export const secretsRefused = secretsFrame(
+ secretsRow(secretsData('admin'), {
+ values: TYPED,
+ refused: { STRIPE_TEST_KEY: 'value_too_large' }
+ })
+)
+export const secretsSkipped = secretsFrame(secretsRow(secretsData('admin'), { phase: 'skipped' }))
diff --git a/src/renderer/src/components/worktree-creation/VmBuildSheet.tsx b/src/renderer/src/components/worktree-creation/VmBuildSheet.tsx
index 40bd4023c00..fc95e9fd68e 100644
--- a/src/renderer/src/components/worktree-creation/VmBuildSheet.tsx
+++ b/src/renderer/src/components/worktree-creation/VmBuildSheet.tsx
@@ -19,6 +19,7 @@ import { firstBuildLogErrorLine, VmBuildLog } from './VmBuildLog'
import { VmBuildFailureCard } from './VmBuildFailureCard'
import { VmBuildAgentSignInRow } from './VmBuildAgentSignInRow'
import { VmBuildCloudAgentStep } from '../cloud-agent-setup/VmBuildCloudAgentStep'
+import { VmBuildSecretsRow } from '../cloud-secrets-ask/VmBuildSecretsRow'
import { OrcaCloudQueuedPrompt } from '@/components/orca-cloud/OrcaCloudQueuedPrompt'
import {
classifyVmBuildFailure,
@@ -92,6 +93,7 @@ export function VmBuildSheet({
onEditRecipe,
agentSignInRow,
cloudAgentStep,
+ secretsRow,
stepTimings
}: {
creationId: string
@@ -110,6 +112,8 @@ export function VmBuildSheet({
agentSignInRow?: React.ReactNode
/** Fixtures only: a fixed "Add an agent" step (null for none) in place of the live one. */
cloudAgentStep?: React.ReactNode
+ /** Fixtures only: a fixed Secrets row (null for none) in place of the live one. */
+ secretsRow?: React.ReactNode
/** Fixtures only: measured step timings in place of the control plane's. */
stepTimings?: VmBuildStepTimings
}): React.JSX.Element {
@@ -247,6 +251,11 @@ export function VmBuildSheet({
environmentId={entry.vmEnvironmentId}
/>
) : null)}
+ {secretsRow !== undefined ? (
+ secretsRow
+ ) : cloud ? (
+
+ ) : null}
)}
diff --git a/src/renderer/src/components/worktree-creation/vm-build-secrets-fixture-data.tsx b/src/renderer/src/components/worktree-creation/vm-build-secrets-fixture-data.tsx
new file mode 100644
index 00000000000..66010457c96
--- /dev/null
+++ b/src/renderer/src/components/worktree-creation/vm-build-secrets-fixture-data.tsx
@@ -0,0 +1,57 @@
+// Builders for the build sheet's Secrets row fixtures (S2), on the Orca Cloud sheet.
+import React from 'react'
+import type { CloudSecretsCaller } from '../../../../shared/cloud-secrets/cloud-secrets-wire'
+import type { CloudSecretsAskData } from '../cloud-secrets-ask/cloud-secrets-ask-model'
+import {
+ EMPTY_ASK_STATE,
+ type CloudSecretsAskFormActions,
+ type CloudSecretsAskFormState
+} from '../cloud-secrets-ask/CloudSecretsAskForm'
+import { VmBuildSecretsRowView } from '../cloud-secrets-ask/VmBuildSecretsRow'
+
+const ADMIN: CloudSecretsCaller = {
+ userId: 'u_ada',
+ orgId: 'org_acme',
+ orgRole: 'admin',
+ canWrite: { personal: true, org: true, repo: true }
+}
+const MEMBER: CloudSecretsCaller = {
+ userId: 'u_bo',
+ orgId: 'org_acme',
+ orgRole: 'member',
+ canWrite: { personal: true, org: false, repo: false }
+}
+
+export function secretsData(
+ role: 'admin' | 'member',
+ patch: Partial = {}
+): CloudSecretsAskData {
+ return {
+ environmentName: 'acme-web',
+ total: 5,
+ missing: ['STRIPE_TEST_KEY', 'SENTRY_DSN'],
+ caller: role === 'admin' ? ADMIN : MEMBER,
+ built: false,
+ repoKey: 'github.com/acme/web',
+ ...patch
+ }
+}
+
+const noop = (): void => {}
+const ACTIONS: CloudSecretsAskFormActions = {
+ onValueChange: noop,
+ onScopeChange: noop,
+ onTogglePaste: noop,
+ onPaste: noop,
+ onSave: noop,
+ onSkip: noop
+}
+
+export function secretsRow(
+ data: CloudSecretsAskData,
+ state: Partial = {}
+): React.ReactNode {
+ return (
+
+ )
+}
diff --git a/src/renderer/src/components/worktree-creation/vm-build-sheet-fixture-support.tsx b/src/renderer/src/components/worktree-creation/vm-build-sheet-fixture-support.tsx
index c9a136dd578..2584f6ec8f9 100644
--- a/src/renderer/src/components/worktree-creation/vm-build-sheet-fixture-support.tsx
+++ b/src/renderer/src/components/worktree-creation/vm-build-sheet-fixture-support.tsx
@@ -55,7 +55,8 @@ export function sheet(
value: PendingWorktreeCreation,
error: string | null,
now: number,
- agentSignInRow?: React.ReactNode
+ agentSignInRow?: React.ReactNode,
+ options: { cloudAgentStep?: React.ReactNode; secretsRow?: React.ReactNode } = {}
) {
return (
@@ -72,7 +73,8 @@ export function sheet(
onEditRecipe={() => {}}
agentSignInRow={agentSignInRow}
stepTimings={FIXTURE_STEP_TIMINGS}
- cloudAgentStep={null}
+ cloudAgentStep={options.cloudAgentStep ?? null}
+ secretsRow={options.secretsRow ?? null}
/>
)
diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json
index 5ea645e29d3..cc420322f81 100644
--- a/src/renderer/src/i18n/en-runtime-required.json
+++ b/src/renderer/src/i18n/en-runtime-required.json
@@ -1525,6 +1525,14 @@
"lines_other": "Multi-line value · {{count}} lines"
}
},
+ "cloudSecretsAsk": {
+ "buildTeamOnly_one": "The environment build only uses team values. Ask an owner or admin to add {{name}} as a team secret.",
+ "buildTeamOnly_other": "The environment build only uses team values. Ask an owner or admin to add {{name}} as team secrets.",
+ "missing_one": "{{count}} secret missing",
+ "missing_other": "{{count}} secrets missing",
+ "save_one": "Save secret",
+ "save_other": "Save {{count}} secrets"
+ },
"codeHost": {
"bitbucketConnect": {
"accessTokenRequired": "Enter an access token.",
@@ -2372,6 +2380,10 @@
}
}
},
+ "secretRequest": {
+ "more_one": "{{count}} more request",
+ "more_other": "{{count}} more requests"
+ },
"sessionSearch": {
"panel": {
"resultCount_one": "{{count}} result",
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index fb9af10ad44..62b06b498fd 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -27187,6 +27187,125 @@
"recipeEnvReview": "Review",
"recipeEnvHide": "Hide",
"recipeEnvKeyLike": "Look like keys",
- "recipeEnvOther": "Other variables"
+ "recipeEnvOther": "Other variables",
+ "env": {
+ "add": "Add",
+ "addMine": "Add mine",
+ "missing": "Missing",
+ "askOwner": "Ask {{owner}}, an owner or an admin to add it.",
+ "valueLabel": "Value for {{name}}",
+ "valuePlaceholder": "Value",
+ "memberHelp": "Saved to your personal Cloud secrets, so only your workspaces get it.",
+ "cancel": "Cancel",
+ "save": "Save",
+ "guestOwner": "In a shared workspace, Personal values come from its owner, {{owner}}, not from you.",
+ "guest": "In a shared workspace, Personal values come from its owner, not from you.",
+ "empty": "No variables from Cloud secrets yet.",
+ "overrides": "overrides {{scopes}}"
+ },
+ "keys": {
+ "askAdmin": "Ask an owner or admin to add one.",
+ "gateway": "AI Gateway",
+ "other": "Other",
+ "overrides": "{{scope}} · overrides {{others}}",
+ "readOnly": "{{scope}} · only owners and admins can change it",
+ "shadowedLabel": "Keys not used for {{name}}",
+ "editLabel": "Edit {{name}}",
+ "edit": "Edit",
+ "empty": "No agent keys in Cloud secrets.",
+ "emptyMember": "Add your own, or ask an owner or admin for a team key.",
+ "manage": "Open Cloud secrets"
+ },
+ "unreachable": "Couldn’t reach Orca Cloud.",
+ "section": {
+ "keysCloud": "API keys from Cloud secrets",
+ "env": "Environment"
+ },
+ "help": {
+ "cloudKeys": "From your Orca account and Cloud secrets. Keys only on this computer aren’t sent to Orca Cloud.",
+ "cloudEnv": "From Cloud secrets: Repository, then Personal, then Shared."
+ },
+ "retry": "Try again",
+ "asleepFooter": "Changes reach the sandbox when it wakes.",
+ "loadingSecrets": "Loading Cloud secrets…",
+ "details": "Details"
+ },
+ "cloudSecretsAsk": {
+ "scopeLabel": "Scope for {{name}}",
+ "memberRefused": "Only owners and admins can save team or repository values. Choose Personal.",
+ "missing_one": "{{count}} secret missing",
+ "missing_other": "{{count}} secrets missing",
+ "coverage": "The {{environment}} environment uses {{total}}. Your team and this repository provide {{covered}}.",
+ "skipped": "Secrets skipped",
+ "skippedDetail": "Add them later from the workspace’s cloud panel.",
+ "saved": "Secrets saved",
+ "savedDetail": "New terminals and agents in this workspace get them within a few seconds.",
+ "pasteEnv": "Paste .env",
+ "buildTeamOnly_one": "The environment build only uses team values. Ask an owner or admin to add {{name}} as a team secret.",
+ "buildTeamOnly_other": "The environment build only uses team values. Ask an owner or admin to add {{name}} as team secrets.",
+ "pasteLabel": "Paste the contents of a .env file",
+ "pastePlaceholder": "NAME=value",
+ "pasteFound": "Found {{matched}} of {{count}}. {{ignored}} other lines ignored.",
+ "valuePlaceholder": "Value",
+ "unreachable": "Couldn’t reach Orca Cloud.",
+ "nothingChanged": "Nothing was changed.",
+ "retry": "Try again",
+ "memberFooter": "Saved to your personal Cloud secrets, so only your workspaces get them. To set them for the whole team, ask an owner or admin.",
+ "skip": "Skip",
+ "saveNone": "Save",
+ "save_one": "Save secret",
+ "save_other": "Save {{count}} secrets",
+ "add": "Add",
+ "dismissNotice": "Dismiss secrets missing notice",
+ "loading": "Loading secrets…",
+ "nothingMissing": "Nothing is missing.",
+ "dialogTitle": "Add missing secrets",
+ "dialogDetail": "Values go to your Cloud secrets. New terminals and agents in this workspace get them within a few seconds."
+ },
+ "cloudAgents": {
+ "source": {
+ "shadowedByTeam": "Not used while the team key is set.",
+ "shadowedByRepo": "Not used while the repository key is set."
+ }
+ },
+ "agentSources": {
+ "shared": "Personal accounts aren’t used in shared workspaces."
+ },
+ "secretRequest": {
+ "anAgent": "an agent",
+ "more_one": "{{count}} more request",
+ "more_other": "{{count}} more requests",
+ "expand": "Show request",
+ "collapse": "Hide request",
+ "from": "from {{agent}}",
+ "expired": "This request expired.",
+ "expiredDetail": "{{agent}} can ask again.",
+ "theAgent": "The agent",
+ "dismiss": "Dismiss",
+ "valueLabel": "Value for {{name}}",
+ "valuePlaceholder": "Value",
+ "repoHint": "{{agent}} suggested Repository",
+ "unreachable": "Couldn’t reach Orca Cloud.",
+ "nothingChanged": "Nothing was changed.",
+ "retry": "Try again",
+ "notNow": "Not now",
+ "add": "Add secret",
+ "title": "{{agent}} asked for {{name}}",
+ "titleUnknown": "An agent asked for {{name}}",
+ "showLess": "Show less",
+ "showMore": "Show more",
+ "scopeLabel": "Scope for {{name}}",
+ "savedAfterExpiry": "Saved. {{agent}} stopped waiting, so tell it the secret is ready.",
+ "doneRepo": "{{name}} added to this repository’s secrets.",
+ "done": "{{name}} added to your personal secrets.",
+ "doneDetail": "New shells get it now. {{agent}} was told it’s ready, and it never gets the value from Orca.",
+ "doneDetailExpired": "New shells get it now.",
+ "restartDetail": "Restart {{agent}} to give its own shell the new value.",
+ "restart": "Restart {{agent}}",
+ "announce": "{{title}}. Press F6 to answer.",
+ "unseenIn": "In {{workspace}}. The request waits up to 15 minutes.",
+ "unseen": "Open that workspace to answer. The request waits up to 15 minutes.",
+ "openWorkspace": "Open",
+ "helpBeforeSave": "Saves to your Cloud secrets. Commands in this workspace can read it as ${{name}}."
}
}
diff --git a/src/renderer/src/lib/agent-host-panel/agent-host-panel-opener.ts b/src/renderer/src/lib/agent-host-panel/agent-host-panel-opener.ts
new file mode 100644
index 00000000000..002bc00878f
--- /dev/null
+++ b/src/renderer/src/lib/agent-host-panel/agent-host-panel-opener.ts
@@ -0,0 +1,42 @@
+// The deep link into the "Agent accounts & secrets" panel (spec §4). The panel shell registers itself
+// here; prompts that point into it (S4-b's Details) show the link only once it's there.
+import { useSyncExternalStore } from 'react'
+
+export type AgentHostPanelFocus = 'claude' | 'codex' | 'keys' | { secret: string }
+
+export type OpenAgentHostPanel = (target: {
+ /** The runtime environment id of the host. */
+ hostId: string
+ focus?: AgentHostPanelFocus
+}) => void
+
+let opener: OpenAgentHostPanel | null = null
+const listeners = new Set<() => void>()
+
+/** Called by the panel shell when it mounts; returns the unregister function. */
+export function registerAgentHostPanelOpener(open: OpenAgentHostPanel): () => void {
+ opener = open
+ for (const listener of listeners) {
+ listener()
+ }
+ return () => {
+ if (opener === open) {
+ opener = null
+ for (const listener of listeners) {
+ listener()
+ }
+ }
+ }
+}
+
+/** The opener, or null while no panel shell is mounted. */
+export function useAgentHostPanelOpener(): OpenAgentHostPanel | null {
+ return useSyncExternalStore(
+ (listener) => {
+ listeners.add(listener)
+ return () => listeners.delete(listener)
+ },
+ () => opener,
+ () => opener
+ )
+}
diff --git a/src/renderer/src/lib/orca-cloud/cloud-secrets-missing-notice-store.test.ts b/src/renderer/src/lib/orca-cloud/cloud-secrets-missing-notice-store.test.ts
new file mode 100644
index 00000000000..80a64b574fe
--- /dev/null
+++ b/src/renderer/src/lib/orca-cloud/cloud-secrets-missing-notice-store.test.ts
@@ -0,0 +1,30 @@
+import { afterEach, describe, expect, it } from 'vitest'
+import {
+ clearCloudSecretsMissing,
+ narrowCloudSecretsMissing,
+ readCloudSecretsMissing,
+ recordCloudSecretsMissing,
+ resetCloudSecretsMissingForTests
+} from './cloud-secrets-missing-notice-store'
+
+const REPO = 'github.com/acme/web'
+
+describe('cloud secrets missing notice', () => {
+ afterEach(() => resetCloudSecretsMissingForTests())
+
+ it('keeps names per workspace until filled or dismissed', () => {
+ recordCloudSecretsMissing('ws', { names: ['A', 'B'], repoKey: REPO }, 1)
+ narrowCloudSecretsMissing('ws', ['B', 'C'])
+ expect(readCloudSecretsMissing('ws')?.names).toEqual(['B'])
+ narrowCloudSecretsMissing('ws', [])
+ expect(readCloudSecretsMissing('ws')).toBeNull()
+ recordCloudSecretsMissing('ws', { names: ['A'], repoKey: REPO })
+ clearCloudSecretsMissing('ws')
+ expect(readCloudSecretsMissing('ws')).toBeNull()
+ })
+
+ it('records nothing when nothing is missing', () => {
+ recordCloudSecretsMissing('ws', { names: [], repoKey: REPO })
+ expect(readCloudSecretsMissing('ws')).toBeNull()
+ })
+})
diff --git a/src/renderer/src/lib/orca-cloud/cloud-secrets-missing-notice-store.ts b/src/renderer/src/lib/orca-cloud/cloud-secrets-missing-notice-store.ts
new file mode 100644
index 00000000000..0284ae7f7f0
--- /dev/null
+++ b/src/renderer/src/lib/orca-cloud/cloud-secrets-missing-notice-store.ts
@@ -0,0 +1,133 @@
+// "2 secrets missing · Add" in a cloud workspace's panel after the build sheet's Secrets row was skipped
+// or left unfilled (spec S2). Names only, per workspace, until filled or dismissed. Never a value.
+import { useSyncExternalStore } from 'react'
+
+const STORAGE_KEY = 'orca.cloudSecretsMissing.v1'
+const MAX_WORKSPACES = 200
+
+export type CloudSecretsMissingNotice = {
+ /** The names still missing when the person left the sheet. */
+ names: string[]
+ /** `github.com/owner/name`: the repo the values are for. */
+ repoKey: string
+ at: number
+}
+
+type Notices = Readonly>
+
+let notices: Notices = read()
+const listeners = new Set<() => void>()
+
+function read(): Notices {
+ try {
+ const raw = globalThis.localStorage?.getItem(STORAGE_KEY)
+ const parsed: unknown = raw ? JSON.parse(raw) : {}
+ if (!parsed || typeof parsed !== 'object') {
+ return {}
+ }
+ const clean: Record = {}
+ for (const [workspaceId, value] of Object.entries(parsed)) {
+ if (isNotice(value)) {
+ clean[workspaceId] = value
+ }
+ }
+ return clean
+ } catch {
+ return {}
+ }
+}
+
+function isNotice(value: unknown): value is CloudSecretsMissingNotice {
+ if (
+ typeof value !== 'object' ||
+ value === null ||
+ !('names' in value) ||
+ !('repoKey' in value) ||
+ !('at' in value)
+ ) {
+ return false
+ }
+ const { names, repoKey, at } = value
+ return (
+ Array.isArray(names) &&
+ names.every((name) => typeof name === 'string' && /^[A-Z_][A-Z0-9_]*$/.test(name)) &&
+ typeof repoKey === 'string' &&
+ typeof at === 'number'
+ )
+}
+
+function write(next: Notices): void {
+ // Oldest first out, so the record can't grow without bound.
+ const entries = Object.entries(next).sort(([, a], [, b]) => b.at - a.at)
+ notices = Object.fromEntries(entries.slice(0, MAX_WORKSPACES))
+ try {
+ globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(notices))
+ } catch {
+ // Storage full or unavailable: the notice still lives for this session.
+ }
+ for (const listener of listeners) {
+ listener()
+ }
+}
+
+export function recordCloudSecretsMissing(
+ workspaceId: string,
+ notice: Omit,
+ now = Date.now()
+): void {
+ if (notice.names.length === 0) {
+ clearCloudSecretsMissing(workspaceId)
+ return
+ }
+ write({ ...notices, [workspaceId]: { ...notice, names: [...notice.names], at: now } })
+}
+
+/** Filled or dismissed. */
+export function clearCloudSecretsMissing(workspaceId: string): void {
+ if (!(workspaceId in notices)) {
+ return
+ }
+ const { [workspaceId]: _removed, ...rest } = notices
+ write(rest)
+}
+
+/** Some names were filled from elsewhere (the panel, Settings): keep only the rest. */
+export function narrowCloudSecretsMissing(
+ workspaceId: string,
+ stillMissing: readonly string[]
+): void {
+ const current = notices[workspaceId]
+ if (!current) {
+ return
+ }
+ const names = current.names.filter((name) => stillMissing.includes(name))
+ if (names.length === current.names.length) {
+ return
+ }
+ if (names.length === 0) {
+ clearCloudSecretsMissing(workspaceId)
+ } else {
+ write({ ...notices, [workspaceId]: { ...current, names } })
+ }
+}
+
+function subscribe(listener: () => void): () => void {
+ listeners.add(listener)
+ return () => listeners.delete(listener)
+}
+
+export function useCloudSecretsMissing(workspaceId: string): CloudSecretsMissingNotice | null {
+ return useSyncExternalStore(
+ subscribe,
+ () => notices[workspaceId] ?? null,
+ () => notices[workspaceId] ?? null
+ )
+}
+
+export function readCloudSecretsMissing(workspaceId: string): CloudSecretsMissingNotice | null {
+ return notices[workspaceId] ?? null
+}
+
+export function resetCloudSecretsMissingForTests(seed: Notices = {}): void {
+ notices = seed
+}
diff --git a/src/renderer/src/lib/secret-requests/secret-request-event-guards.ts b/src/renderer/src/lib/secret-requests/secret-request-event-guards.ts
new file mode 100644
index 00000000000..18481d57f04
--- /dev/null
+++ b/src/renderer/src/lib/secret-requests/secret-request-event-guards.ts
@@ -0,0 +1,54 @@
+// The host's events are checked before a card shows them: a malformed or oversized one is dropped.
+import { z } from 'zod'
+import {
+ SECRET_REQUEST_REASON_MAX,
+ SECRETS_REQUEST_SETTLED_EVENT,
+ SECRETS_REQUEST_UPDATED_EVENT,
+ SECRETS_REQUESTED_EVENT,
+ type SecretRequestedEvent,
+ type SecretRequestSettledEvent,
+ type SecretRequestUpdatedEvent
+} from '../../../../shared/secret-request-contract'
+
+const RequestId = z.string().regex(/^sr_[A-Za-z0-9_-]{8,64}$/)
+const Millis = z.number().finite().nonnegative()
+
+const RequestedSchema = z.object({
+ type: z.literal(SECRETS_REQUESTED_EVENT),
+ requestId: RequestId,
+ name: z
+ .string()
+ .max(128)
+ .regex(/^[A-Z_][A-Z0-9_]*$/),
+ reason: z.string().max(SECRET_REQUEST_REASON_MAX).optional(),
+ scope: z.enum(['personal', 'repo']),
+ agent: z.string().max(64).nullable(),
+ worktreeId: z.string().max(512).nullable(),
+ terminalHandle: z.string().min(1).max(512).optional(),
+ requestedAt: Millis,
+ expiresAt: Millis
+})
+
+const SettledSchema = z.object({
+ type: z.literal(SECRETS_REQUEST_SETTLED_EVENT),
+ requestId: RequestId,
+ status: z.enum(['provided', 'declined', 'expired'])
+})
+
+const UpdatedSchema = z.object({
+ type: z.literal(SECRETS_REQUEST_UPDATED_EVENT),
+ requestId: RequestId,
+ expiresAt: Millis
+})
+
+export function isSecretRequestUpdatedEvent(value: unknown): value is SecretRequestUpdatedEvent {
+ return UpdatedSchema.safeParse(value).success
+}
+
+export function isSecretRequestedEvent(value: unknown): value is SecretRequestedEvent {
+ return RequestedSchema.safeParse(value).success
+}
+
+export function isSecretRequestSettledEvent(value: unknown): value is SecretRequestSettledEvent {
+ return SettledSchema.safeParse(value).success
+}
diff --git a/src/renderer/src/lib/secret-requests/secret-request-routing.ts b/src/renderer/src/lib/secret-requests/secret-request-routing.ts
new file mode 100644
index 00000000000..9d2a4726ce9
--- /dev/null
+++ b/src/renderer/src/lib/secret-requests/secret-request-routing.ts
@@ -0,0 +1,116 @@
+// Which pane shows which secret request. One card per pane; the rest count as "N more requests".
+import type { SecretRequestEntry } from './secret-request-store'
+
+export type SecretRequestPaneSlot = {
+ paneKey: string
+ environmentId: string
+ /** The remote terminal handle this pane runs, when known. */
+ handle: string | null
+ /** The pane's tab is the one showing. */
+ visible: boolean
+ /** The active pane of a showing tab: where requests without a showing asking pane land. */
+ primary: boolean
+}
+
+export type RoutedSecretRequest = {
+ entry: SecretRequestEntry
+ /** Shown on the pane running the asking terminal, so "Restart {{agent}}" restarts the right one. */
+ onAskingPane: boolean
+}
+
+function handleKey(environmentId: string, handle: string): string {
+ return JSON.stringify([environmentId, handle])
+}
+
+/**
+ * The asking terminal's pane when its tab is showing; else the active pane of a showing tab on the
+ * same host; else the asking pane even though hidden (it shows when that tab is opened).
+ */
+export function routeSecretRequests(
+ entries: readonly SecretRequestEntry[],
+ slots: readonly SecretRequestPaneSlot[]
+): Map {
+ const routed = new Map()
+ const byHandle = new Map()
+ for (const slot of slots) {
+ if (slot.handle) {
+ byHandle.set(handleKey(slot.environmentId, slot.handle), slot)
+ }
+ }
+ // Deterministic when several showing tabs share a host: the lowest pane key.
+ const primaryByEnvironment = new Map()
+ for (const slot of slots) {
+ const current = primaryByEnvironment.get(slot.environmentId)
+ if (slot.primary && slot.visible && (current === undefined || slot.paneKey < current)) {
+ primaryByEnvironment.set(slot.environmentId, slot.paneKey)
+ }
+ }
+ for (const entry of entries) {
+ const handle = entry.event.terminalHandle
+ const asking = handle ? byHandle.get(handleKey(entry.environmentId, handle)) : undefined
+ const primary = primaryByEnvironment.get(entry.environmentId)
+ const paneKey = asking?.visible ? asking.paneKey : (primary ?? asking?.paneKey)
+ if (!paneKey) {
+ continue
+ }
+ const item = { entry, onAskingPane: asking?.paneKey === paneKey }
+ const list = routed.get(paneKey)
+ if (list) {
+ list.push(item)
+ } else {
+ routed.set(paneKey, [item])
+ }
+ }
+ return routed
+}
+
+/** Pending requests no showing pane displays (the person is looking at another workspace). */
+export function unseenSecretRequests(
+ entries: readonly SecretRequestEntry[],
+ slots: readonly SecretRequestPaneSlot[]
+): SecretRequestEntry[] {
+ const routed = routeSecretRequests(entries, slots)
+ const visibleKeys = new Set(slots.filter((slot) => slot.visible).map((slot) => slot.paneKey))
+ const seen = new Set()
+ for (const [paneKey, items] of routed) {
+ if (visibleKeys.has(paneKey)) {
+ for (const item of items) {
+ seen.add(item.entry)
+ }
+ }
+ }
+ return entries.filter(
+ (entry) => !seen.has(entry) && entry.local === null && entry.settled === null
+ )
+}
+
+const AGENT_DISPLAY_NAME: Record = {
+ claude: 'Claude',
+ codex: 'Codex',
+ gemini: 'Gemini',
+ cursor: 'Cursor',
+ opencode: 'OpenCode',
+ copilot: 'Copilot',
+ pi: 'Pi',
+ amp: 'Amp',
+ droid: 'Droid',
+ aider: 'Aider'
+}
+
+/** orcad names the agent by id; unknown ids still read as a name, never as markup. */
+export function secretRequestAgentName(agent: string | null): string | null {
+ if (!agent) {
+ return null
+ }
+ const known = AGENT_DISPLAY_NAME[agent.toLowerCase()]
+ if (known) {
+ return known
+ }
+ const clean = agent.replace(/[^A-Za-z0-9 ._-]/g, '').slice(0, 32)
+ return clean ? clean.charAt(0).toUpperCase() + clean.slice(1) : null
+}
+
+/** Agents Orca can restart in place in a pane (with --resume where the CLI supports it). */
+export function restartableSecretRequestAgent(agent: string | null): 'claude' | 'codex' | null {
+ return agent === 'claude' || agent === 'codex' ? agent : null
+}
diff --git a/src/renderer/src/lib/secret-requests/secret-request-store.test.ts b/src/renderer/src/lib/secret-requests/secret-request-store.test.ts
new file mode 100644
index 00000000000..b60c5fcdf0b
--- /dev/null
+++ b/src/renderer/src/lib/secret-requests/secret-request-store.test.ts
@@ -0,0 +1,178 @@
+import { afterEach, describe, expect, it } from 'vitest'
+import type { SecretRequestedEvent } from '../../../../shared/secret-request-contract'
+import {
+ applySecretRequested,
+ applySecretRequestSettled,
+ dismissSecretRequest,
+ readSecretRequests,
+ resetSecretRequestStoreForTests,
+ pruneSecretRequestsOnReady,
+ setSecretRequestExpiry,
+ setSecretRequestLocal
+} from './secret-request-store'
+import { isSecretRequestedEvent, isSecretRequestSettledEvent } from './secret-request-event-guards'
+import {
+ routeSecretRequests,
+ secretRequestAgentName,
+ unseenSecretRequests,
+ type SecretRequestPaneSlot
+} from './secret-request-routing'
+
+function requested(patch: Partial = {}): SecretRequestedEvent {
+ return {
+ type: 'secrets.requested',
+ requestId: 'sr_aaaaaaaa',
+ name: 'NPM_TOKEN',
+ reason: 'Needed for npm.acme.dev',
+ scope: 'personal',
+ agent: 'claude',
+ worktreeId: null,
+ terminalHandle: 'h1',
+ requestedAt: 1_000,
+ expiresAt: 1_000 + 15 * 60_000,
+ ...patch
+ }
+}
+
+describe('secret request store', () => {
+ afterEach(() => resetSecretRequestStoreForTests())
+
+ it('keeps what this window did when orcad replays a request on attach', () => {
+ applySecretRequested('env', requested())
+ setSecretRequestLocal('env', 'sr_aaaaaaaa', 'saving')
+ applySecretRequested('env', requested())
+ expect(readSecretRequests()).toHaveLength(1)
+ expect(readSecretRequests()[0].local).toBe('saving')
+ })
+
+ it('drops a request another client settled, but keeps one this window finished', () => {
+ applySecretRequested('env', requested())
+ applySecretRequested('env', requested({ requestId: 'sr_bbbbbbbb', name: 'B' }))
+ setSecretRequestLocal('env', 'sr_bbbbbbbb', 'saved')
+ applySecretRequestSettled('env', {
+ type: 'secrets.requestSettled',
+ requestId: 'sr_aaaaaaaa',
+ status: 'declined'
+ })
+ applySecretRequestSettled('env', {
+ type: 'secrets.requestSettled',
+ requestId: 'sr_bbbbbbbb',
+ status: 'provided'
+ })
+ expect(readSecretRequests().map((entry) => entry.event.name)).toEqual(['B'])
+ dismissSecretRequest('env', 'sr_bbbbbbbb')
+ expect(readSecretRequests()).toEqual([])
+ })
+
+ it('shows an expiry reported by orcad as the expired state', () => {
+ applySecretRequested('env', requested())
+ applySecretRequestSettled('env', {
+ type: 'secrets.requestSettled',
+ requestId: 'sr_aaaaaaaa',
+ status: 'expired'
+ })
+ expect(readSecretRequests()[0].local).toBe('expired')
+ })
+
+ it('moves the expiry when a touch extends it', () => {
+ applySecretRequested('env', requested())
+ setSecretRequestExpiry('env', 'sr_aaaaaaaa', 99_000_000)
+ expect(readSecretRequests()[0].event.expiresAt).toBe(99_000_000)
+ })
+
+ it('on ready, drops what orcad no longer replays (it replays pending requests before ready)', () => {
+ applySecretRequested('env', requested())
+ applySecretRequested('env', requested({ requestId: 'sr_bbbbbbbb', name: 'B' }))
+ applySecretRequested('env', requested({ requestId: 'sr_cccccccc', name: 'C' }))
+ pruneSecretRequestsOnReady('env')
+ expect(readSecretRequests()).toHaveLength(3)
+ setSecretRequestLocal('env', 'sr_bbbbbbbb', 'saved')
+ // Reconnect: only C is still pending in orcad and replayed, then ready.
+ applySecretRequested('env', requested({ requestId: 'sr_cccccccc', name: 'C' }))
+ pruneSecretRequestsOnReady('env')
+ // A was settled while away; B is kept because this window is showing its result.
+ expect(readSecretRequests().map((entry) => entry.event.name)).toEqual(['B', 'C'])
+ })
+
+ it('keeps requests from different hosts apart', () => {
+ applySecretRequested('a', requested())
+ applySecretRequested('b', requested())
+ expect(readSecretRequests()).toHaveLength(2)
+ })
+})
+
+describe('event guards', () => {
+ it('accepts the contract shape and rejects markup or oversized fields', () => {
+ expect(isSecretRequestedEvent(requested())).toBe(true)
+ expect(isSecretRequestedEvent(requested({ name: 'x ' }))).toBe(false)
+ expect(isSecretRequestedEvent(requested({ reason: 'x'.repeat(201) }))).toBe(false)
+ expect(isSecretRequestedEvent({ ...requested(), requestId: 'nope' })).toBe(false)
+ expect(
+ isSecretRequestSettledEvent({
+ type: 'secrets.requestSettled',
+ requestId: 'sr_aaaaaaaa',
+ status: 'provided'
+ })
+ ).toBe(true)
+ })
+})
+
+describe('routeSecretRequests', () => {
+ afterEach(() => resetSecretRequestStoreForTests())
+ const slot = (patch: Partial): SecretRequestPaneSlot => ({
+ paneKey: 'tab:1',
+ environmentId: 'env',
+ handle: null,
+ visible: true,
+ primary: false,
+ ...patch
+ })
+
+ it('pins a request to the showing pane running the asking terminal', () => {
+ applySecretRequested('env', requested({ terminalHandle: 'h2' }))
+ const routed = routeSecretRequests(readSecretRequests(), [
+ slot({ paneKey: 'tab:1', handle: 'h1', primary: true }),
+ slot({ paneKey: 'tab:2', handle: 'h2' })
+ ])
+ expect([...routed.keys()]).toEqual(['tab:2'])
+ expect(routed.get('tab:2')?.[0].onAskingPane).toBe(true)
+ })
+
+ it('shows a request from a hidden tab on the showing pane, without Restart rights there', () => {
+ applySecretRequested('env', requested({ terminalHandle: 'h2' }))
+ const routed = routeSecretRequests(readSecretRequests(), [
+ slot({ paneKey: 'tab:1', handle: 'h1', primary: true }),
+ slot({ paneKey: 'tab:2', handle: 'h2', visible: false })
+ ])
+ expect([...routed.keys()]).toEqual(['tab:1'])
+ expect(routed.get('tab:1')?.[0].onAskingPane).toBe(false)
+ })
+
+ it('falls back to the active pane on the same host, and stacks the rest there', () => {
+ applySecretRequested('env', requested({ terminalHandle: undefined }))
+ applySecretRequested('env', requested({ requestId: 'sr_cccccccc', terminalHandle: 'gone' }))
+ applySecretRequested('other', requested({ requestId: 'sr_dddddddd' }))
+ const slots = [
+ slot({ paneKey: 'tab:1', handle: 'h1' }),
+ slot({ paneKey: 'tab:9', handle: 'h9', primary: true })
+ ]
+ const routed = routeSecretRequests(readSecretRequests(), slots)
+ expect(routed.get('tab:9')?.map((item) => item.entry.event.requestId)).toEqual([
+ 'sr_aaaaaaaa',
+ 'sr_cccccccc'
+ ])
+ // Nothing on the other host is mounted, so its request is unseen (the notifier says so).
+ expect(routed.size).toBe(1)
+ expect(unseenSecretRequests(readSecretRequests(), slots).map((e) => e.environmentId)).toEqual([
+ 'other'
+ ])
+ })
+})
+
+describe('secretRequestAgentName', () => {
+ it('names known agents and cleans unknown ids', () => {
+ expect(secretRequestAgentName('claude')).toBe('Claude')
+ expect(secretRequestAgentName('my')).toBe('Myagent')
+ expect(secretRequestAgentName(null)).toBeNull()
+ })
+})
diff --git a/src/renderer/src/lib/secret-requests/secret-request-store.ts b/src/renderer/src/lib/secret-requests/secret-request-store.ts
new file mode 100644
index 00000000000..474bb7927cc
--- /dev/null
+++ b/src/renderer/src/lib/secret-requests/secret-request-store.ts
@@ -0,0 +1,230 @@
+// Agent secret requests (contract §3) seen on each runtime environment's client-event stream, plus what
+// this window did with them. Names, reasons and ids only: the value a person types stays in the card.
+import { useSyncExternalStore } from 'react'
+import type {
+ SecretRequestedEvent,
+ SecretRequestSettledEvent,
+ SecretRequestStatus
+} from '../../../../shared/secret-request-contract'
+
+/** What this window is showing for a request once the person acted on it. */
+export type SecretRequestLocalPhase =
+ | 'saving'
+ /** Saved and the agent was told. */
+ | 'saved'
+ /** Saved, but the request had expired, so the agent stopped waiting (D3). */
+ | 'savedAfterExpiry'
+ | 'expired'
+
+export type SecretRequestEntry = {
+ environmentId: string
+ event: SecretRequestedEvent
+ /** Set by orcad's settle event, or by this window's own resolve. */
+ settled: Exclude | null
+ local: SecretRequestLocalPhase | null
+ /** This window's clock when the event first arrived (ties in requestedAt keep arrival order). */
+ receivedAt: number
+ /** When this window last sent touchRequest (D3: at most once a minute, across card remounts). */
+ touchedAt: number
+}
+
+type State = { entries: ReadonlyMap }
+
+let state: State = { entries: new Map() }
+const listeners = new Set<() => void>()
+
+function commit(next: Map): void {
+ state = { entries: next }
+ for (const listener of listeners) {
+ listener()
+ }
+}
+
+function subscribe(listener: () => void): () => void {
+ listeners.add(listener)
+ return () => listeners.delete(listener)
+}
+
+function keyOf(environmentId: string, requestId: string): string {
+ return `${environmentId}\u0000${requestId}`
+}
+
+/** A card keeps showing once the person acted (saving, done, expired) until they dismiss it. */
+function stillShown(entry: SecretRequestEntry): boolean {
+ return entry.local !== null || entry.settled === null
+}
+
+export function applySecretRequested(
+ environmentId: string,
+ event: SecretRequestedEvent,
+ now = Date.now()
+): void {
+ const key = keyOf(environmentId, event.requestId)
+ const existing = state.entries.get(key)
+ const next = new Map(state.entries)
+ // A replay on attach repeats the event; keep what this window already did with it.
+ next.set(key, {
+ environmentId,
+ event,
+ settled: existing?.settled ?? null,
+ local: existing?.local ?? null,
+ receivedAt: existing?.receivedAt ?? now,
+ touchedAt: existing?.touchedAt ?? 0
+ })
+ const seen = seenSinceReady.get(environmentId) ?? new Set()
+ seen.add(event.requestId)
+ seenSinceReady.set(environmentId, seen)
+ commit(next)
+}
+
+/** Any client's touchRequest moved the expiry (secrets.requestUpdated). */
+export function applySecretRequestUpdated(
+ environmentId: string,
+ event: { requestId: string; expiresAt: number }
+): void {
+ setSecretRequestExpiry(environmentId, event.requestId, event.expiresAt)
+}
+
+// Requests seen on each host's stream since its last 'ready'. orcad replays every pending request
+// right before 'ready', so at 'ready' anything unresolved and unseen was settled while we were away.
+const seenSinceReady = new Map>()
+
+/** The stream sent 'ready': drop requests orcad no longer has (unless this window is showing a result). */
+export function pruneSecretRequestsOnReady(environmentId: string): void {
+ const seen = seenSinceReady.get(environmentId) ?? new Set()
+ seenSinceReady.set(environmentId, new Set())
+ const pruned = new Map(
+ [...state.entries].filter(
+ ([, entry]) =>
+ entry.environmentId !== environmentId ||
+ entry.local !== null ||
+ seen.has(entry.event.requestId)
+ )
+ )
+ if (pruned.size !== state.entries.size) {
+ commit(pruned)
+ }
+}
+
+export function setSecretRequestTouched(
+ environmentId: string,
+ requestId: string,
+ touchedAt: number
+): void {
+ const key = keyOf(environmentId, requestId)
+ const existing = state.entries.get(key)
+ if (!existing) {
+ return
+ }
+ const next = new Map(state.entries)
+ next.set(key, { ...existing, touchedAt })
+ commit(next)
+}
+
+export function applySecretRequestSettled(
+ environmentId: string,
+ event: SecretRequestSettledEvent
+): void {
+ const key = keyOf(environmentId, event.requestId)
+ const existing = state.entries.get(key)
+ if (!existing) {
+ return
+ }
+ const next = new Map(state.entries)
+ const updated: SecretRequestEntry = {
+ ...existing,
+ settled: event.status,
+ // Expiry reported by orcad shows the expired state unless this window already finished it.
+ local: event.status === 'expired' && existing.local === null ? 'expired' : existing.local
+ }
+ if (stillShown(updated)) {
+ next.set(key, updated)
+ } else {
+ next.delete(key)
+ }
+ commit(next)
+}
+
+export function setSecretRequestLocal(
+ environmentId: string,
+ requestId: string,
+ local: SecretRequestLocalPhase | null
+): void {
+ const key = keyOf(environmentId, requestId)
+ const existing = state.entries.get(key)
+ if (!existing || existing.local === local) {
+ return
+ }
+ const next = new Map(state.entries)
+ const updated = { ...existing, local }
+ if (stillShown(updated)) {
+ next.set(key, updated)
+ } else {
+ next.delete(key)
+ }
+ commit(next)
+}
+
+/** touchRequest moved the expiry; the card's clock follows it. */
+export function setSecretRequestExpiry(
+ environmentId: string,
+ requestId: string,
+ expiresAt: number
+): void {
+ const key = keyOf(environmentId, requestId)
+ const existing = state.entries.get(key)
+ if (!existing || existing.event.expiresAt === expiresAt) {
+ return
+ }
+ const next = new Map(state.entries)
+ next.set(key, { ...existing, event: { ...existing.event, expiresAt } })
+ commit(next)
+}
+
+export function dismissSecretRequest(environmentId: string, requestId: string): void {
+ const key = keyOf(environmentId, requestId)
+ if (!state.entries.has(key)) {
+ return
+ }
+ const next = new Map(state.entries)
+ next.delete(key)
+ commit(next)
+}
+
+const EMPTY: readonly SecretRequestEntry[] = []
+let cachedFor: State | null = null
+let cachedList: readonly SecretRequestEntry[] = EMPTY
+
+function listAll(): readonly SecretRequestEntry[] {
+ if (cachedFor !== state) {
+ cachedFor = state
+ cachedList =
+ state.entries.size === 0
+ ? EMPTY
+ : [...state.entries.values()].sort(
+ (a, b) => a.event.requestedAt - b.event.requestedAt || a.receivedAt - b.receivedAt
+ )
+ }
+ return cachedList
+}
+
+/** Every request this window shows a card for, oldest first. */
+export function useSecretRequests(): readonly SecretRequestEntry[] {
+ return useSyncExternalStore(subscribe, listAll, listAll)
+}
+
+export function readSecretRequests(): readonly SecretRequestEntry[] {
+ return listAll()
+}
+
+export function resetSecretRequestStoreForTests(): void {
+ seenSinceReady.clear()
+ state = { entries: new Map() }
+ cachedFor = null
+ cachedList = EMPTY
+}
+
+/** When this window last touched the request, read at the time a hold starts. */
+export function readSecretRequestTouchedAt(environmentId: string, requestId: string): number {
+ return state.entries.get(keyOf(environmentId, requestId))?.touchedAt ?? 0
+}
diff --git a/src/renderer/src/runtime/rpc/subscriptions/runtime-client-events.ts b/src/renderer/src/runtime/rpc/subscriptions/runtime-client-events.ts
index 48eecbbd24f..5efcee6e5bd 100644
--- a/src/renderer/src/runtime/rpc/subscriptions/runtime-client-events.ts
+++ b/src/renderer/src/runtime/rpc/subscriptions/runtime-client-events.ts
@@ -9,6 +9,22 @@ import { admitSshConnectionState } from '../../../../../shared/ssh-retained-payl
import { getRuntimeEnvironmentRevision } from '../environment-revisions/runtime-environment-revision'
import { subscribeRuntimeEnvironment } from '../environment-revisions/runtime-environment-pairing-refresh'
import { answerBrowserClientHostRequest } from '../../browser/client-hosting/browser-client-host-request-answer'
+import {
+ SECRETS_REQUEST_SETTLED_EVENT,
+ SECRETS_REQUEST_UPDATED_EVENT,
+ SECRETS_REQUESTED_EVENT
+} from '../../../../../shared/secret-request-contract'
+import {
+ applySecretRequested,
+ applySecretRequestSettled,
+ applySecretRequestUpdated,
+ pruneSecretRequestsOnReady
+} from '@/lib/secret-requests/secret-request-store'
+import {
+ isSecretRequestedEvent,
+ isSecretRequestSettledEvent,
+ isSecretRequestUpdatedEvent
+} from '@/lib/secret-requests/secret-request-event-guards'
export type RuntimeClientEventSubscription = {
unsubscribe: () => void
@@ -64,6 +80,8 @@ function handleRuntimeClientEventResponse(
}
const message = response.result as RuntimeClientEventStreamMessage
if (message.type === 'ready') {
+ // orcad replays pending secret requests just before this; ones it didn't replay are gone.
+ pruneSecretRequestsOnReady(environmentId)
for (const sshState of message.snapshot?.sshStates ?? []) {
const state = admitSshConnectionState(sshState.state, sshState.targetId)
if (state) {
@@ -81,6 +99,25 @@ function handleRuntimeClientEventResponse(
void answerBrowserClientHostRequest(environmentId, message.requestId)
return
}
+ // Agent secret requests go to their own store; the pane cards read it (contract §3).
+ if (message.type === SECRETS_REQUESTED_EVENT) {
+ if (isSecretRequestedEvent(message)) {
+ applySecretRequested(environmentId, message)
+ }
+ return
+ }
+ if (message.type === SECRETS_REQUEST_UPDATED_EVENT) {
+ if (isSecretRequestUpdatedEvent(message)) {
+ applySecretRequestUpdated(environmentId, message)
+ }
+ return
+ }
+ if (message.type === SECRETS_REQUEST_SETTLED_EVENT) {
+ if (isSecretRequestSettledEvent(message)) {
+ applySecretRequestSettled(environmentId, message)
+ }
+ return
+ }
if (message.type === 'sshStateChanged') {
const state = admitSshConnectionState(message.state, message.targetId)
if (state) {
diff --git a/src/shared/cloud-secrets/cloud-secrets-wire.ts b/src/shared/cloud-secrets/cloud-secrets-wire.ts
index 6d48c2943eb..4cabef7690a 100644
--- a/src/shared/cloud-secrets/cloud-secrets-wire.ts
+++ b/src/shared/cloud-secrets/cloud-secrets-wire.ts
@@ -126,3 +126,28 @@ export type CloudSecretsResult =
export type CloudSecretTarget = { scope: CloudSecretScope; repo?: string }
export type CloudSecretsStatus = { available: boolean }
+
+/**
+ * GET /v1/agent-sources?workspaceId= (contract §2), read for who a workspace's values belong to. Loose:
+ * the panel needs only the owner and the shared flag (D13); the rest belongs to the Agents slice.
+ */
+export const CloudAgentSourcesResponseSchema = z.looseObject({
+ owner: z
+ .object({
+ userId: z.string().max(256),
+ displayName: z.string().max(256).nullable(),
+ isCaller: z.boolean()
+ })
+ .nullable()
+ .optional(),
+ /** D13: more than one member, so personal accounts aren't used there. */
+ sharedWorkspace: z.boolean().optional()
+})
+
+/** Whose workspace this is, as far as Cloud secrets go. */
+export type CloudWorkspaceSharing = {
+ shared: boolean
+ /** The owner's display name; null when it's the caller or unknown. */
+ ownerName: string | null
+ isOwner: boolean
+}