mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
Merge pull request #262 from nwparker/cloud/w6-spec-ui
feat(cloud): agent-accounts W6 UI: panel Cloud sections 2–3, S2 build-sheet Secrets, S4-b secret request card
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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<CloudSecretBatchResult>
|
||||
effective(repo?: string, signal?: AbortSignal): Promise<CloudSecretsEffective>
|
||||
refreshEnv(sandboxId: string): Promise<boolean>
|
||||
/** Who owns a sandbox and whether teammates share it (GET /v1/agent-sources?workspaceId=). */
|
||||
workspaceSharing(sandboxId: string): Promise<CloudWorkspaceSharing>
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,8 @@ const CHANNELS = [
|
||||
'cloudSecrets:batch',
|
||||
'cloudSecrets:effective',
|
||||
'cloudSecrets:refreshEnv',
|
||||
'cloudSecrets:saveDesktopKey'
|
||||
'cloudSecrets:saveDesktopKey',
|
||||
'cloudSecrets:workspaceSharing'
|
||||
] as const
|
||||
|
||||
async function attempt<T>(
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<CloudSecretsResult<CloudSecretsEffective>>
|
||||
/** Best-effort nudge so a running workspace picks up new values now; the server also does it on write. */
|
||||
refreshEnv: (request: { runtimeEnvironmentId: string }) => Promise<CloudSecretsResult<boolean>>
|
||||
/** Who owns an Orca Cloud workspace and whether teammates share it (control plane only). */
|
||||
workspaceSharing: (request: {
|
||||
runtimeEnvironmentId: string
|
||||
}) => Promise<CloudSecretsResult<CloudWorkspaceSharing>>
|
||||
/** 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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
<BrowserExtensionInstallDialog />
|
||||
<PlansDialogHost />
|
||||
<CloudMoveToComputerDialogMount />
|
||||
<CloudSecretsAskDialogHost />
|
||||
<SecretRequestUnseenNotifier />
|
||||
<TeamDialogHost />
|
||||
</LinkRoutingPreferenceDialogProvider>
|
||||
</ConfirmationDialogProvider>
|
||||
|
||||
+212
@@ -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<CloudSecretScope>(scopes[0] ?? 'personal')
|
||||
const [saving, setSaving] = React.useState(false)
|
||||
const [error, setError] = React.useState<CloudSecretsErrorCode | null>(null)
|
||||
const addLabel =
|
||||
admin || guest
|
||||
? t('agentHostPanel.env.add', 'Add')
|
||||
: t('agentHostPanel.env.addMine', 'Add mine')
|
||||
const save = async (): Promise<void> => {
|
||||
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 (
|
||||
<li className="flex flex-col gap-2 py-2.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<code className="truncate font-mono text-xs text-foreground">{name}</code>
|
||||
<span className="inline-flex shrink-0 items-center rounded-full border border-status-warning/40 bg-status-warning-background px-2 py-0.5 text-xs font-medium text-status-warning-text">
|
||||
{t('agentHostPanel.env.missing', 'Missing')}
|
||||
</span>
|
||||
{expanded ? null : !canAdd ? (
|
||||
<span className="ms-auto text-end text-xs text-muted-foreground">
|
||||
{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.')}
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="ms-auto"
|
||||
onClick={onExpand}
|
||||
aria-label={`${addLabel} · ${name}`}
|
||||
>
|
||||
{addLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{expanded && canAdd ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={`${id}-value`} className="sr-only">
|
||||
{t('agentHostPanel.env.valueLabel', 'Value for {{name}}', { name })}
|
||||
</Label>
|
||||
<div className="flex items-start gap-2">
|
||||
<div
|
||||
className="min-w-0 flex-1"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void save()
|
||||
} else if (event.key === 'Escape') {
|
||||
event.stopPropagation()
|
||||
onCollapse()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SecretValueInput
|
||||
id={`${id}-value`}
|
||||
value={value}
|
||||
onChange={(next) => {
|
||||
setValue(next)
|
||||
setError(null)
|
||||
}}
|
||||
placeholder={t('agentHostPanel.env.valuePlaceholder', 'Value')}
|
||||
invalid={error !== null}
|
||||
disabled={saving}
|
||||
autoFocus={autoFocus}
|
||||
describedBy={error ? `${id}-error` : `${id}-help`}
|
||||
/>
|
||||
</div>
|
||||
{scopes.length > 1 || scopes[0] !== 'personal' ? (
|
||||
<Select
|
||||
value={scope}
|
||||
onValueChange={(next) => setScope(parseScope(next))}
|
||||
disabled={saving}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="w-32 shrink-0"
|
||||
aria-label={t('cloudSecretsAsk.scopeLabel', 'Scope for {{name}}', { name })}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{scopes.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{cloudSecretScopeLabel(option, t)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<span className="inline-flex h-8 shrink-0 items-center rounded-full border border-border px-2.5 text-xs text-muted-foreground">
|
||||
{cloudSecretScopeLabel('personal', t)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{error ? (
|
||||
<p id={`${id}-error`} role="alert" className="text-xs text-destructive">
|
||||
{error === 'org_admin_required'
|
||||
? t(
|
||||
'cloudSecretsAsk.memberRefused',
|
||||
'Only owners and admins can save team or repository values. Choose Personal.'
|
||||
)
|
||||
: cloudSecretsErrorCopy(error, t)}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p id={`${id}-help`} className="min-w-0 flex-1 text-xs text-muted-foreground">
|
||||
{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.'
|
||||
)}
|
||||
</p>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={onCollapse}
|
||||
disabled={saving}
|
||||
>
|
||||
{t('agentHostPanel.env.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
aria-disabled={!value || saving || undefined}
|
||||
aria-busy={saving || undefined}
|
||||
onClick={value && !saving ? () => void save() : undefined}
|
||||
>
|
||||
{saving ? <Loader2 aria-hidden className="motion-safe:animate-spin" /> : null}
|
||||
{t('agentHostPanel.env.save', 'Save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
+191
@@ -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> => 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 (
|
||||
<div className="bg-background p-4">
|
||||
<div className="w-[460px] overflow-hidden rounded-xl border border-border bg-card">
|
||||
<CloudPanelSections
|
||||
load={load}
|
||||
onRetry={noop}
|
||||
onAdd={saved}
|
||||
onEdit={noop}
|
||||
onManage={noop}
|
||||
initialExpanded={initialExpanded}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const admin = defineFixture({ width: 520, render: () => <Frame load={ready(ADMIN)} /> })
|
||||
|
||||
export const adminAdding = defineFixture({
|
||||
width: 520,
|
||||
render: () => <Frame load={ready(ADMIN)} initialExpanded="STRIPE_TEST_KEY" />
|
||||
})
|
||||
|
||||
export const member = defineFixture({ width: 520, render: () => <Frame load={ready(MEMBER)} /> })
|
||||
|
||||
export const memberAdding = defineFixture({
|
||||
width: 520,
|
||||
render: () => <Frame load={ready(MEMBER)} initialExpanded="STRIPE_TEST_KEY" />
|
||||
})
|
||||
|
||||
export const asleep = defineFixture({
|
||||
width: 520,
|
||||
render: () => <Frame load={ready(MEMBER, { asleep: true })} />
|
||||
})
|
||||
|
||||
/** D13: a teammate in Ada's shared workspace. Their Personal values don't apply there. */
|
||||
export const shared = defineFixture({
|
||||
width: 520,
|
||||
render: () => <Frame load={ready(MEMBER, { sharing: SHARED_GUEST })} />
|
||||
})
|
||||
|
||||
export const sharedAsleep = defineFixture({
|
||||
width: 520,
|
||||
render: () => <Frame load={ready(MEMBER, { asleep: true, sharing: SHARED_GUEST })} />
|
||||
})
|
||||
|
||||
export const sharedAdmin = defineFixture({
|
||||
width: 520,
|
||||
render: () => (
|
||||
<Frame load={ready(ADMIN, { sharing: SHARED_GUEST })} initialExpanded="STRIPE_TEST_KEY" />
|
||||
)
|
||||
})
|
||||
|
||||
export const loading = defineFixture({
|
||||
width: 520,
|
||||
render: () => <Frame load={{ status: 'loading' }} />
|
||||
})
|
||||
|
||||
export const failed = defineFixture({
|
||||
width: 520,
|
||||
render: () => <Frame load={{ status: 'failed', error: 'unavailable' }} />
|
||||
})
|
||||
|
||||
export const empty = defineFixture({
|
||||
width: 520,
|
||||
render: () => (
|
||||
<Frame
|
||||
load={{
|
||||
status: 'ready',
|
||||
data: {
|
||||
effective: { caller: MEMBER, precedence: PRECEDENCE, secrets: [] },
|
||||
missing: [],
|
||||
repoKey: 'github.com/acme/web',
|
||||
asleep: false,
|
||||
sharing: null
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -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<Exclude<CloudKeyOwner, 'gateway' | 'aws' | 'other'>, 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 (
|
||||
<li
|
||||
aria-disabled="true"
|
||||
className="flex min-w-0 flex-col gap-0.5 text-xs leading-5 text-muted-foreground/80"
|
||||
>
|
||||
<span className="truncate font-mono line-through decoration-muted-foreground/50">
|
||||
{name} {maskedHint(entry.hint)}
|
||||
</span>
|
||||
<span>
|
||||
{cloudSecretScopeLabel(entry.scope, t)} · {shadowedReason(entry.shadowedBy, t)}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<li className="grid grid-cols-[5rem_minmax(0,1fr)_auto] items-start gap-x-3 py-2.5">
|
||||
<span className="pt-px text-sm text-foreground">{ownerLabel(row.owner, t)}</span>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="truncate font-mono text-xs text-foreground">
|
||||
{row.name} {maskedHint(row.hint)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{detail}</span>
|
||||
</div>
|
||||
{row.shadowed.length > 0 ? (
|
||||
<ul
|
||||
aria-label={t('agentHostPanel.keys.shadowedLabel', 'Keys not used for {{name}}', {
|
||||
name: row.name
|
||||
})}
|
||||
className="flex flex-col gap-1 border-l border-border ps-2.5"
|
||||
>
|
||||
{row.shadowed.map((entry) => (
|
||||
<ShadowedKeyLine key={entry.scope} name={row.name} entry={entry} />
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
{row.editable && onEdit ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => onEdit(row)}
|
||||
aria-label={t('agentHostPanel.keys.editLabel', 'Edit {{name}}', { name: row.name })}
|
||||
>
|
||||
{t('agentHostPanel.keys.edit', 'Edit')}
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<PanelSection
|
||||
number={number}
|
||||
title={t('agentHostPanel.section.keysCloud', 'API keys from Cloud secrets')}
|
||||
help={t(
|
||||
'agentHostPanel.help.cloudKeys',
|
||||
'From your Orca account and Cloud secrets. Keys only on this computer aren’t sent to Orca Cloud.'
|
||||
)}
|
||||
>
|
||||
{load.status === 'ready' && load.data.sharing?.shared ? (
|
||||
<InlineError
|
||||
layout="line"
|
||||
tone="neutral"
|
||||
title={t('agentSources.shared', 'Personal accounts aren’t used in shared workspaces.')}
|
||||
/>
|
||||
) : null}
|
||||
{load.status === 'loading' ? (
|
||||
<LoadingRows />
|
||||
) : load.status === 'failed' ? (
|
||||
<InlineError
|
||||
density="compact"
|
||||
title={cloudPanelLoadErrorTitle(load.error, t)}
|
||||
retryLabel={t('agentHostPanel.retry', 'Try again')}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
) : rows.length === 0 && !extraRows ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{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.'
|
||||
)}
|
||||
</p>
|
||||
{onManage ? (
|
||||
<Button type="button" variant="outline" size="xs" onClick={onManage}>
|
||||
{t('agentHostPanel.keys.manage', 'Open Cloud secrets')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-border/60">
|
||||
{rows.map((row) => (
|
||||
<KeyRow key={row.name} row={row} onEdit={onEdit} />
|
||||
))}
|
||||
{extraRows}
|
||||
</ul>
|
||||
)}
|
||||
</PanelSection>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string | null>(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 (
|
||||
<PanelSection
|
||||
number={number}
|
||||
title={t('agentHostPanel.section.env', 'Environment')}
|
||||
help={
|
||||
data && isGuestInSharedWorkspace(data)
|
||||
? `${t('agentHostPanel.help.cloudEnv', 'From Cloud secrets: Repository, then Personal, then Shared.')} ${
|
||||
data.sharing?.ownerName
|
||||
? t(
|
||||
'agentHostPanel.env.guestOwner',
|
||||
'In a shared workspace, Personal values come from its owner, {{owner}}, not from you.',
|
||||
{ owner: data.sharing.ownerName }
|
||||
)
|
||||
: t(
|
||||
'agentHostPanel.env.guest',
|
||||
'In a shared workspace, Personal values come from its owner, not from you.'
|
||||
)
|
||||
}`
|
||||
: t(
|
||||
'agentHostPanel.help.cloudEnv',
|
||||
'From Cloud secrets: Repository, then Personal, then Shared.'
|
||||
)
|
||||
}
|
||||
footer={
|
||||
data?.asleep ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('agentHostPanel.asleepFooter', 'Changes reach the sandbox when it wakes.')}
|
||||
</p>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{load.status === 'loading' ? (
|
||||
<LoadingRows />
|
||||
) : load.status === 'failed' ? (
|
||||
showLoadError ? (
|
||||
<InlineError
|
||||
density="compact"
|
||||
title={cloudPanelLoadErrorTitle(load.error, t)}
|
||||
retryLabel={t('agentHostPanel.retry', 'Try again')}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
) : null
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('agentHostPanel.env.empty', 'No variables from Cloud secrets yet.')}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<span role="status" className="sr-only">
|
||||
{missingCount > 0
|
||||
? t('cloudSecretsAsk.missing', '{{count}} secrets missing', {
|
||||
count: missingCount,
|
||||
defaultValue_one: '{{count}} secret missing'
|
||||
})
|
||||
: ''}
|
||||
</span>
|
||||
<ul className="flex flex-col divide-y divide-border/60">
|
||||
{rows.map((row) =>
|
||||
row.kind === 'set' ? (
|
||||
<li key={row.name} className="flex min-w-0 flex-wrap items-center gap-2 py-2.5">
|
||||
<code className="truncate font-mono text-xs text-foreground">{row.name}</code>
|
||||
<span className="inline-flex shrink-0 items-center rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{cloudSecretScopeLabel(row.scope, t)}
|
||||
</span>
|
||||
{row.overrides.length > 0 ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('agentHostPanel.env.overrides', 'overrides {{scopes}}', {
|
||||
scopes: row.overrides
|
||||
.map((scope) => cloudSecretScopeLabel(scope, t))
|
||||
.join(', ')
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
) : data ? (
|
||||
<CloudMissingSecretRow
|
||||
key={row.name}
|
||||
name={row.name}
|
||||
data={data}
|
||||
expanded={expanded === row.name}
|
||||
onExpand={() => setExpanded(row.name)}
|
||||
onCollapse={() => setExpanded(null)}
|
||||
onAdd={onAdd}
|
||||
autoFocus={expanded === row.name && focusSecret === row.name}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</PanelSection>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className={cn('flex flex-col divide-y divide-border', className)}>
|
||||
<CloudAgentKeysSection
|
||||
load={load}
|
||||
onEdit={onEdit}
|
||||
onManage={onManage}
|
||||
onRetry={onRetry}
|
||||
extraRows={keysExtraRows}
|
||||
/>
|
||||
<CloudEnvironmentPanelSection
|
||||
load={load}
|
||||
onRetry={onRetry}
|
||||
onAdd={onAdd}
|
||||
focusSecret={focusSecret}
|
||||
initialExpanded={initialExpanded}
|
||||
// One read feeds both sections, so section 2 alone shows (and announces) its error.
|
||||
showLoadError={false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<section aria-labelledby={id} className="flex flex-col gap-3 px-5 py-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 id={id} className="flex items-baseline gap-2 text-sm font-medium text-foreground">
|
||||
{number !== undefined ? (
|
||||
<span aria-hidden className="text-muted-foreground tabular-nums">
|
||||
{number}
|
||||
</span>
|
||||
) : null}
|
||||
{title}
|
||||
</h3>
|
||||
{help ? <p className="text-xs leading-5 text-muted-foreground">{help}</p> : null}
|
||||
</div>
|
||||
{children}
|
||||
{footer}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function LoadingRows(): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
return (
|
||||
<div aria-busy="true" className="flex flex-col gap-3">
|
||||
{[0, 1].map((row) => (
|
||||
<div key={row} className="flex items-center gap-3">
|
||||
<Skeleton shape="small" className="h-3 w-16" />
|
||||
<Skeleton shape="small" className="h-3 w-44" />
|
||||
<Skeleton shape="small" className="ms-auto h-3 w-10" />
|
||||
</div>
|
||||
))}
|
||||
<span className="sr-only">
|
||||
{t('agentHostPanel.loadingSecrets', 'Loading Cloud secrets…')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+114
@@ -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<CloudEffectiveSecret> & { 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([])
|
||||
})
|
||||
})
|
||||
+153
@@ -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<CloudSecretsCaller, 'canWrite'>,
|
||||
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 }
|
||||
}
|
||||
+142
@@ -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<CloudPanelSectionsData, 'sharing'>): 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<CloudSecretsErrorCode | null>
|
||||
|
||||
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<CloudPanelSectionsLoad>({ 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<CloudPanelAddSecret>(
|
||||
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 }
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<AgentSignInPanePortals controller={controller} />
|
||||
<SecretRequestPanePortals controller={controller} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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<SecretRequestCardModel> }): React.JSX.Element {
|
||||
return (
|
||||
<div className="bg-background p-3">
|
||||
<div className="relative h-[340px] overflow-hidden rounded-lg border border-border bg-[var(--terminal-background,#111)] p-3 font-mono text-xs leading-5 text-muted-foreground">
|
||||
<p>● Installing private packages…</p>
|
||||
<p>npm ERR! 401 Unauthorized - GET https://npm.acme.dev/@acme%2fui</p>
|
||||
<p>● I need an npm token for npm.acme.dev to continue.</p>
|
||||
<div className="absolute inset-x-3 top-3 flex justify-end">
|
||||
<SecretRequestCard model={{ ...BASE, ...model }} actions={ACTIONS} className="max-w-md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const frame = (model: Partial<SecretRequestCardModel>) =>
|
||||
defineFixture({ width: 640, render: () => <Pane model={model} /> })
|
||||
|
||||
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 })
|
||||
@@ -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<HTMLDivElement>
|
||||
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<HTMLDivElement>(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<HTMLElement>('[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 = (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<KeyRound aria-hidden className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
|
||||
<h3 id={titleId} className="min-w-0 font-medium break-words text-foreground">
|
||||
<SecretRequestTitle agent={model.agent} name={model.name} />
|
||||
</h3>
|
||||
{model.more > 0 ? (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{t('secretRequest.more', '{{count}} more requests', {
|
||||
count: model.more,
|
||||
defaultValue_one: '{{count}} more request'
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="-my-0.5 shrink-0"
|
||||
aria-expanded={!model.collapsed}
|
||||
aria-label={
|
||||
model.collapsed
|
||||
? t('secretRequest.expand', 'Show request')
|
||||
: t('secretRequest.collapse', 'Hide request')
|
||||
}
|
||||
onClick={actions.onToggleCollapsed}
|
||||
>
|
||||
{model.collapsed ? <ChevronDown aria-hidden /> : <ChevronUp aria-hidden />}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setRefs}
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
aria-labelledby={titleId}
|
||||
data-secret-request-card=""
|
||||
onKeyDown={(event) => {
|
||||
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 ? (
|
||||
<SecretRequestDone
|
||||
agent={model.agent}
|
||||
name={model.name}
|
||||
scope={model.scope}
|
||||
toldAgent={model.phase === 'saved'}
|
||||
canRestart={model.canRestart}
|
||||
titleId={titleId}
|
||||
onRestart={actions.onRestart}
|
||||
onDismiss={actions.onDismiss}
|
||||
/>
|
||||
) : model.collapsed ? (
|
||||
header
|
||||
) : (
|
||||
<>
|
||||
{header}
|
||||
{model.reason ? (
|
||||
<div className="ps-6.5">
|
||||
<UntrustedReason
|
||||
reason={model.reason}
|
||||
from={t('secretRequest.from', 'from {{agent}}', { agent: agentName })}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{model.phase === 'expired' ? (
|
||||
<InlineError
|
||||
tone="neutral"
|
||||
title={t('secretRequest.expired', 'This request expired.')}
|
||||
detail={t('secretRequest.expiredDetail', '{{agent}} can ask again.', {
|
||||
agent: model.agent ?? t('secretRequest.theAgent', 'The agent')
|
||||
})}
|
||||
action={
|
||||
<Button type="button" variant="outline" size="xs" onClick={actions.onDismiss}>
|
||||
{t('secretRequest.dismiss', 'Dismiss')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div data-secret-request-field="" className="flex flex-col gap-1.5">
|
||||
<Label htmlFor={`${id}-value`} className="sr-only">
|
||||
{t('secretRequest.valueLabel', 'Value for {{name}}', { name: model.name })}
|
||||
</Label>
|
||||
<div className="flex items-start gap-2">
|
||||
<div
|
||||
className="min-w-0 flex-1"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && canSave) {
|
||||
event.preventDefault()
|
||||
actions.onAdd()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SecretValueInput
|
||||
id={`${id}-value`}
|
||||
value={model.value}
|
||||
onChange={actions.onValueChange}
|
||||
placeholder={t('secretRequest.valuePlaceholder', 'Value')}
|
||||
invalid={model.fieldError !== null}
|
||||
disabled={saving}
|
||||
describedBy={model.fieldError ? `${errorId} ${helpId}` : helpId}
|
||||
/>
|
||||
</div>
|
||||
<SecretRequestScopeControl
|
||||
name={model.name}
|
||||
scopes={model.scopes}
|
||||
scope={model.scope}
|
||||
disabled={saving}
|
||||
onChange={actions.onScopeChange}
|
||||
/>
|
||||
</div>
|
||||
{model.fieldError ? (
|
||||
<p id={errorId} role="alert" className="text-xs text-destructive">
|
||||
{model.fieldError}
|
||||
</p>
|
||||
) : null}
|
||||
<p id={helpId} className="text-xs leading-5 text-muted-foreground">
|
||||
{t(
|
||||
'secretRequest.helpBeforeSave',
|
||||
'Saves to your Cloud secrets. Commands in this workspace can read it as ${{name}}.',
|
||||
{ name: model.name }
|
||||
)}
|
||||
</p>
|
||||
{model.suggestedRepo && model.scopes.includes('repo') ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('secretRequest.repoHint', '{{agent}} suggested Repository', {
|
||||
agent: agentName
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{model.phase === 'failed' ? (
|
||||
<InlineError
|
||||
title={t('secretRequest.unreachable', 'Couldn’t reach Orca Cloud.')}
|
||||
detail={t('secretRequest.nothingChanged', 'Nothing was changed.')}
|
||||
retryLabel={t('secretRequest.retry', 'Try again')}
|
||||
onRetry={actions.onRetry}
|
||||
density="compact"
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex items-center gap-2">
|
||||
{actions.onDetails ? (
|
||||
<Button type="button" variant="ghost" size="xs" onClick={actions.onDetails}>
|
||||
{t('agentHostPanel.details', 'Details')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="ms-auto"
|
||||
aria-disabled={saving || undefined}
|
||||
onClick={saving ? undefined : actions.onNotNow}
|
||||
>
|
||||
{t('secretRequest.notNow', 'Not now')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
aria-disabled={!canSave || undefined}
|
||||
aria-busy={saving || undefined}
|
||||
onClick={canSave ? actions.onAdd : undefined}
|
||||
>
|
||||
{saving ? <Loader2 aria-hidden className="motion-safe:animate-spin" /> : null}
|
||||
{t('secretRequest.add', 'Add secret')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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)}
|
||||
<code className="font-mono text-[0.95em]">{name}</code>
|
||||
{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<HTMLSpanElement>(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 (
|
||||
<figure className="flex min-w-0 flex-col gap-0.5 border-l-2 border-border pl-2.5 text-xs leading-5 text-muted-foreground">
|
||||
<blockquote className="min-w-0">
|
||||
<span ref={ref} className={cn('break-words', !expanded && 'line-clamp-2')}>
|
||||
{`“${reason}”`}
|
||||
</span>
|
||||
</blockquote>
|
||||
<figcaption className="flex items-center gap-2">
|
||||
<span>{from}</span>
|
||||
{overflows || expanded ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-sm text-foreground underline-offset-2 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded
|
||||
? t('secretRequest.showLess', 'Show less')
|
||||
: t('secretRequest.showMore', 'Show more')}
|
||||
</button>
|
||||
) : null}
|
||||
</figcaption>
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<span className="inline-flex h-8 shrink-0 items-center rounded-full border border-border px-2.5 text-xs text-muted-foreground">
|
||||
{cloudSecretScopeLabel('personal', t)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Select
|
||||
value={scope}
|
||||
onValueChange={(next) => onChange(next === 'repo' ? 'repo' : 'personal')}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="w-32 shrink-0"
|
||||
aria-label={t('secretRequest.scopeLabel', 'Scope for {{name}}', { name })}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{scopes.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{cloudSecretScopeLabel(option, t)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="flex items-start gap-3">
|
||||
<CircleCheck aria-hidden className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<div role="status" className="min-w-0 flex-1 space-y-1">
|
||||
<p id={titleId} className="font-medium text-foreground">
|
||||
{!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 })}
|
||||
</p>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
{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}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
{canRestart ? (
|
||||
<Button type="button" variant="outline" size="xs" onClick={onRestart}>
|
||||
<RotateCw aria-hidden />
|
||||
{t('secretRequest.restart', 'Restart {{agent}}', { agent: agentName })}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
data-secret-request-dismiss=""
|
||||
onClick={onDismiss}
|
||||
>
|
||||
{t('secretRequest.dismiss', 'Dismiss')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<div
|
||||
data-secret-request-overlay=""
|
||||
className="pointer-events-none absolute inset-x-3 top-10 z-30 flex justify-end"
|
||||
>
|
||||
<span role="status" className="sr-only">
|
||||
{spoken}
|
||||
</span>
|
||||
<SecretRequestCard
|
||||
model={model}
|
||||
actions={actions}
|
||||
rootRef={cardRef}
|
||||
className="pointer-events-auto max-w-md"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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(
|
||||
<SecretRequestPaneCardBody
|
||||
// A new request starts from an empty field and Personal.
|
||||
key={`${entry.environmentId}:${entry.event.requestId}`}
|
||||
entry={entry}
|
||||
more={mine.length - 1}
|
||||
pane={pane}
|
||||
paneKey={paneKey}
|
||||
ptyId={ptyId}
|
||||
worktreeId={worktreeId}
|
||||
onAskingPane={first.onAskingPane}
|
||||
primary={primary}
|
||||
announcement={t('secretRequest.announce', '{{title}}. Press F6 to answer.', {
|
||||
title: secretRequestTitle(secretRequestAgentName(entry.event.agent), entry.event.name, t)
|
||||
})}
|
||||
/>,
|
||||
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) => (
|
||||
<SecretRequestPaneSlot
|
||||
key={pane.id}
|
||||
pane={pane}
|
||||
tabId={tabId}
|
||||
worktreeId={worktreeId}
|
||||
// On screen, even in a split group that isn't the active one.
|
||||
visible={isRendererVisible}
|
||||
primary={isActive && activePane?.id === pane.id}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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<string>()
|
||||
|
||||
/** 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
|
||||
}
|
||||
@@ -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<string, SecretRequestPaneSlot>()
|
||||
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<string, CardRecord>()
|
||||
|
||||
/** The pane's terminal input, when the element focus came from is gone. */
|
||||
function terminalInput(container: HTMLElement): HTMLElement | null {
|
||||
return container.querySelector<HTMLElement>('textarea')
|
||||
}
|
||||
|
||||
function focusInto(record: CardRecord, from: Element | null): void {
|
||||
record.returnTo = from instanceof HTMLElement && from !== document.body ? from : null
|
||||
const target =
|
||||
record.element.querySelector<HTMLElement>('input:not([disabled]), textarea:not([disabled])') ??
|
||||
record.element.querySelector<HTMLElement>('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 <body>.
|
||||
queueMicrotask(() => {
|
||||
const lost = document.activeElement === null || document.activeElement === document.body
|
||||
if (!element.isConnected && record.hadFocus && lost) {
|
||||
focusBack(record)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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> = {}): SecretRequestedEvent {
|
||||
return {
|
||||
type: 'secrets.requested',
|
||||
requestId: 'sr_aaaaaaaa',
|
||||
name: 'NPM_TOKEN',
|
||||
reason: 'See <a href="https://evil.example">here</a>',
|
||||
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 ? <Card entry={entry} /> : null
|
||||
}
|
||||
|
||||
function Card({ entry }: { entry: ReturnType<typeof readSecretRequests>[number] }) {
|
||||
const { model, actions } = useSecretRequestCard({
|
||||
entry,
|
||||
more: 0,
|
||||
worktreeId: null,
|
||||
ptyId: 'p',
|
||||
onAskingPane: true
|
||||
})
|
||||
return <SecretRequestCard model={model} actions={actions} />
|
||||
}
|
||||
|
||||
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(<Harness />)
|
||||
expect(document.querySelector('a')).toBeNull()
|
||||
expect(screen.getByText(/See <a href=/)).toBeTruthy()
|
||||
// Repository isn't writable here, so only the Personal chip shows.
|
||||
expect(screen.queryByRole('combobox')).toBeNull()
|
||||
expect(screen.getByText(/Commands in this workspace can read it as \$NPM_TOKEN/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('saves through Cloud secrets, then tells orcad the outcome without the value', async () => {
|
||||
render(<Harness />)
|
||||
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(<Harness />)
|
||||
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(<Harness />)
|
||||
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(<Harness />)
|
||||
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(<Harness />)
|
||||
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(<Harness />)
|
||||
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(<Harness />)
|
||||
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(<Harness />)
|
||||
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' })
|
||||
})
|
||||
})
|
||||
@@ -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<CloudSecretsErrorCode> = 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<string, Promise<boolean>>()
|
||||
|
||||
function canWriteRepo(repoKey: string): Promise<boolean> {
|
||||
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<SecretsResolveRequestResult['outcome'] | null> {
|
||||
try {
|
||||
const result = await callRuntimeRpc<SecretsResolveRequestResult>(
|
||||
{ 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<SecretRequestScope>('personal')
|
||||
const [failed, setFailed] = useState(false)
|
||||
const [fieldError, setFieldError] = useState<string | null>(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<SecretsTouchRequestResult>(
|
||||
{ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, string>
|
||||
scopes: Record<string, CloudSecretScope>
|
||||
phase: CloudSecretsAskPhase
|
||||
/** Server refusals for single names. */
|
||||
refused: Record<string, CloudSecretsErrorCode>
|
||||
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 (
|
||||
<div className="flex items-start gap-3">
|
||||
<KeyRound aria-hidden className="mt-0.5 size-4 shrink-0 text-status-warning" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 id={headingId} className="text-sm font-medium text-foreground">
|
||||
{t('cloudSecretsAsk.missing', '{{count}} secrets missing', {
|
||||
count: data.missing.length,
|
||||
defaultValue_one: '{{count}} secret missing'
|
||||
})}
|
||||
</h3>
|
||||
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">{coverageLine(data, t)}</p>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className={cn('flex items-start gap-3 py-2', className)}>
|
||||
<KeyRound aria-hidden className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="text-foreground">{t('cloudSecretsAsk.skipped', 'Secrets skipped')}</span>
|
||||
{' · '}
|
||||
{t('cloudSecretsAsk.skippedDetail', 'Add them later from the workspace’s cloud panel.')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const savedLine =
|
||||
state.phase === 'saved' ? (
|
||||
<div className="flex items-start gap-3">
|
||||
<CircleCheck aria-hidden className="mt-0.5 size-4 shrink-0 text-foreground" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t('cloudSecretsAsk.saved', 'Secrets saved')}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
|
||||
{t(
|
||||
'cloudSecretsAsk.savedDetail',
|
||||
'New terminals and agents in this workspace get them within a few seconds.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
|
||||
// Everything saved: just the confirmation.
|
||||
if (state.phase === 'saved' && data.missing.length === 0) {
|
||||
return (
|
||||
<div className={cn('py-2', className)} role="status">
|
||||
{savedLine}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const lastName = data.missing.at(-1)
|
||||
return (
|
||||
<section aria-labelledby={headingId} className={cn('flex flex-col gap-3 py-2', className)}>
|
||||
<div role="status" className="contents">
|
||||
{savedLine}
|
||||
</div>
|
||||
<CloudSecretsAskHeading
|
||||
data={data}
|
||||
headingId={headingId}
|
||||
action={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
aria-expanded={state.pasteOpen}
|
||||
onClick={actions.onTogglePaste}
|
||||
disabled={saving}
|
||||
>
|
||||
<ClipboardPaste aria-hidden />
|
||||
{t('cloudSecretsAsk.pasteEnv', 'Paste .env')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{!admin && data.built ? (
|
||||
<p className="ps-7 text-xs leading-5 text-muted-foreground">
|
||||
{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.'
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
{state.pasteOpen ? (
|
||||
<div className="flex flex-col gap-1.5 ps-7">
|
||||
<label htmlFor={`${id}-paste`} className="text-xs text-muted-foreground">
|
||||
{t('cloudSecretsAsk.pasteLabel', 'Paste the contents of a .env file')}
|
||||
</label>
|
||||
<Textarea
|
||||
id={`${id}-paste`}
|
||||
variant="code"
|
||||
value=""
|
||||
placeholder={t('cloudSecretsAsk.pastePlaceholder', 'NAME=value')}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
data-1p-ignore=""
|
||||
className="min-h-16"
|
||||
// The text is read at once and dropped: only the matching values stay, in the masked fields.
|
||||
onChange={(event) => actions.onPaste(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{state.pasteSummary ? (
|
||||
<p role="status" className="ps-7 text-xs text-muted-foreground">
|
||||
{t(
|
||||
'cloudSecretsAsk.pasteFound',
|
||||
'Found {{matched}} of {{count}}. {{ignored}} other lines ignored.',
|
||||
{
|
||||
matched: state.pasteSummary.matched,
|
||||
count: state.pasteSummary.count,
|
||||
ignored: state.pasteSummary.ignored
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
<ul className="flex flex-col gap-2 ps-7">
|
||||
{data.missing.map((name) => {
|
||||
const refusal = state.refused[name]
|
||||
const errorId = `${id}-${name}-error`
|
||||
return (
|
||||
<li
|
||||
key={name}
|
||||
className="grid grid-cols-[minmax(0,11rem)_minmax(0,1fr)_auto] items-start gap-x-3 gap-y-1"
|
||||
>
|
||||
<label
|
||||
htmlFor={`${id}-${name}`}
|
||||
className="h-8 min-w-0 truncate font-mono text-xs leading-8 font-normal text-foreground"
|
||||
title={name}
|
||||
>
|
||||
{name}
|
||||
</label>
|
||||
<div
|
||||
className="min-w-0"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && name === lastName && canSave) {
|
||||
event.preventDefault()
|
||||
actions.onSave()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SecretValueInput
|
||||
id={`${id}-${name}`}
|
||||
value={state.values[name] ?? ''}
|
||||
onChange={(value) => actions.onValueChange(name, value)}
|
||||
placeholder={t('cloudSecretsAsk.valuePlaceholder', 'Value')}
|
||||
invalid={Boolean(refusal)}
|
||||
disabled={saving}
|
||||
describedBy={refusal ? errorId : undefined}
|
||||
/>
|
||||
</div>
|
||||
{scopes.length > 1 ? (
|
||||
<Select
|
||||
value={state.scopes[name] ?? 'personal'}
|
||||
onValueChange={(scope) => actions.onScopeChange(name, parseScope(scope))}
|
||||
disabled={saving}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="w-32"
|
||||
aria-label={t('cloudSecretsAsk.scopeLabel', 'Scope for {{name}}', { name })}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{scopes.map((scope) => (
|
||||
<SelectItem key={scope} value={scope}>
|
||||
{cloudSecretScopeLabel(scope, t)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<span className="inline-flex h-8 items-center rounded-full border border-border px-2.5 text-xs text-muted-foreground">
|
||||
{cloudSecretScopeLabel('personal', t)}
|
||||
</span>
|
||||
)}
|
||||
{refusal ? (
|
||||
<p
|
||||
id={errorId}
|
||||
role="alert"
|
||||
className="col-start-2 col-end-4 text-xs text-destructive"
|
||||
>
|
||||
{refusal === 'org_admin_required'
|
||||
? t(
|
||||
'cloudSecretsAsk.memberRefused',
|
||||
'Only owners and admins can save team or repository values. Choose Personal.'
|
||||
)
|
||||
: cloudSecretsErrorCopy(refusal, t)}
|
||||
</p>
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
{state.phase === 'failed' ? (
|
||||
<InlineError
|
||||
className="ms-7"
|
||||
title={t('cloudSecretsAsk.unreachable', 'Couldn’t reach Orca Cloud.')}
|
||||
detail={t('cloudSecretsAsk.nothingChanged', 'Nothing was changed.')}
|
||||
retryLabel={t('cloudSecretsAsk.retry', 'Try again')}
|
||||
onRetry={actions.onSave}
|
||||
density="compact"
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 ps-7">
|
||||
<p className="min-w-0 flex-1 basis-60 text-xs leading-5 text-muted-foreground">
|
||||
{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.'
|
||||
)}
|
||||
</p>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={actions.onSkip}
|
||||
disabled={saving}
|
||||
>
|
||||
{t('cloudSecretsAsk.skip', 'Skip')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
aria-disabled={!canSave || undefined}
|
||||
aria-busy={saving || undefined}
|
||||
onClick={canSave ? actions.onSave : undefined}
|
||||
>
|
||||
{saving ? <Loader2 aria-hidden className="motion-safe:animate-spin" /> : null}
|
||||
{filled.length === 0
|
||||
? t('cloudSecretsAsk.saveNone', 'Save')
|
||||
: t('cloudSecretsAsk.save', 'Save {{count}} secrets', {
|
||||
count: filled.length,
|
||||
defaultValue_one: 'Save secret'
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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: () => (
|
||||
<div className="rounded-md border border-border bg-floating-surface p-3">
|
||||
<CloudWorkspaceStatusPanel
|
||||
workspaceId="ws-secrets"
|
||||
state="running"
|
||||
entry={{ status: RUNNING, loading: false, error: null, fetchedAt: NOW - 42_000 }}
|
||||
pending={undefined}
|
||||
actions={ACTIONS}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -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 (
|
||||
<div
|
||||
role="status"
|
||||
className="flex items-center gap-2 rounded-md border border-border bg-muted/40 py-1 ps-2.5 pe-1 text-xs"
|
||||
>
|
||||
<KeyRound aria-hidden className="size-3.5 shrink-0 text-status-warning" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-foreground">
|
||||
{t('cloudSecretsAsk.missing', '{{count}} secrets missing', {
|
||||
count: notice.names.length,
|
||||
defaultValue_one: '{{count}} secret missing'
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => openCloudSecretsAsk(workspaceId)}
|
||||
>
|
||||
{t('cloudSecretsAsk.add', 'Add')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={t('cloudSecretsAsk.dismissNotice', 'Dismiss secrets missing notice')}
|
||||
onClick={() => clearCloudSecretsMissing(workspaceId)}
|
||||
>
|
||||
<X aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div aria-busy="true" className="flex flex-col gap-2 py-2">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<span className="sr-only">{t('cloudSecretsAsk.loading', 'Loading secrets…')}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (load.status === 'failed') {
|
||||
return (
|
||||
<InlineError
|
||||
title={cloudSecretsErrorCopy(load.error, t)}
|
||||
retryLabel={t('cloudSecretsAsk.retry', 'Try again')}
|
||||
onRetry={reload}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (!data) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('cloudSecretsAsk.nothingMissing', 'Nothing is missing.')}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return <CloudSecretsAskForm data={data} state={state} actions={actions} />
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<Dialog open onOpenChange={(open) => !open && setOpenFor(null)}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('cloudSecretsAsk.dialogTitle', 'Add missing secrets')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
'cloudSecretsAsk.dialogDetail',
|
||||
'Values go to your Cloud secrets. New terminals and agents in this workspace get them within a few seconds.'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<CloudSecretsAskDialogBody workspaceId={target.workspaceId} repoKey={target.repoKey} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<li data-vm-build-secrets="" className="mt-1 border-t border-border/60 pt-2">
|
||||
<CloudSecretsAskForm data={data} state={state} actions={actions} />
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <VmBuildSecretsRowView data={data} state={state} actions={actions} />
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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<T extends Pick<HostedEnvironmentListEntry, 'repos'>>(
|
||||
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<CloudSecretsEffective, 'secrets'>
|
||||
): string[] {
|
||||
const provided = new Set(effective.secrets.map((secret) => secret.name))
|
||||
const seen = new Set<string>()
|
||||
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<CloudSecretsCaller, 'canWrite'>,
|
||||
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<CloudSecretsCaller, 'canWrite'>): 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<HostedEnvironmentListEntry, 'name' | 'envVarNames' | 'activeBuildId'>
|
||||
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<CloudSecretsEffective, 'secrets'>,
|
||||
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<string, string>
|
||||
): {
|
||||
values: Record<string, string>
|
||||
summary: { matched: number; count: number; ignored: number }
|
||||
} {
|
||||
const wanted = new Set(missing)
|
||||
const entries = parseDotenvPaste(text)
|
||||
const next = { ...values }
|
||||
const matched = new Set<string>()
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<CloudSecretsAskFormState>(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<CloudSecretsAskFormActions>(
|
||||
() => ({
|
||||
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 }
|
||||
}
|
||||
@@ -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<CloudSecretsAskLoad>({ 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<string, CloudSecretsErrorCode> }
|
||||
| { ok: false; error: CloudSecretsErrorCode }
|
||||
|
||||
const TRANSPORT: ReadonlySet<CloudSecretsErrorCode> = 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<string, string>
|
||||
scopes: Record<string, CloudSecretScope>
|
||||
repoKey: string | null
|
||||
/** The sandbox's runtime environment, when it is up and should pick the values up now. */
|
||||
refreshRuntimeEnvironmentId: string | null
|
||||
}): Promise<CloudSecretsSaveResult> {
|
||||
const api = window.api.cloudSecrets
|
||||
if (!api) {
|
||||
return { ok: false, error: NOT_CONFIGURED }
|
||||
}
|
||||
const byScope = new Map<CloudSecretScope, Record<string, string>>()
|
||||
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<string, CloudSecretsErrorCode> = {}
|
||||
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<string, CloudSecretsErrorCode>
|
||||
): 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)
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state === 'archived' ? null : <CloudSecretsMissingNotice workspaceId={workspaceId} />}
|
||||
|
||||
{unavailable ? (
|
||||
<InlineError
|
||||
tone="neutral"
|
||||
|
||||
@@ -130,6 +130,7 @@ export function fixtureCloudSecretsApi(options: {
|
||||
put: ({ target, name }) =>
|
||||
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()
|
||||
|
||||
@@ -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({
|
||||
/>
|
||||
<TerminalPaneRecoveryPortals controller={controller} />
|
||||
<TerminalPaneSleepingPortals controller={controller} />
|
||||
<AgentSignInPanePortals controller={controller} />
|
||||
<AgentPanePortals controller={controller} />
|
||||
<TerminalPaneMobileDriverPortals controller={controller} />
|
||||
<CloseTerminalDialog
|
||||
open={pendingCloseConfirmation !== null}
|
||||
|
||||
@@ -12,10 +12,12 @@ import {
|
||||
frame,
|
||||
LOG_LINES,
|
||||
NOW,
|
||||
seedFixtureRepo,
|
||||
sheet,
|
||||
stampsFor,
|
||||
T0
|
||||
} from './vm-build-sheet-fixture-support'
|
||||
import { secretsData, secretsRow } from './vm-build-secrets-fixture-data'
|
||||
|
||||
const noop = (): void => {}
|
||||
|
||||
@@ -310,3 +312,49 @@ function LiveBuild(): React.JSX.Element {
|
||||
}
|
||||
|
||||
export const live = defineFixture({ width: 760, render: () => <LiveBuild /> })
|
||||
|
||||
// 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' }))
|
||||
|
||||
@@ -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 ? (
|
||||
<VmBuildSecretsRow entry={entry} />
|
||||
) : null}
|
||||
</ol>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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> = {}
|
||||
): 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<CloudSecretsAskFormState> = {}
|
||||
): React.ReactNode {
|
||||
return (
|
||||
<VmBuildSecretsRowView data={data} state={{ ...EMPTY_ASK_STATE, ...state }} actions={ACTIONS} />
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="bg-background p-3">
|
||||
@@ -72,7 +73,8 @@ export function sheet(
|
||||
onEditRecipe={() => {}}
|
||||
agentSignInRow={agentSignInRow}
|
||||
stepTimings={FIXTURE_STEP_TIMINGS}
|
||||
cloudAgentStep={null}
|
||||
cloudAgentStep={options.cloudAgentStep ?? null}
|
||||
secretsRow={options.secretsRow ?? null}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
+12
@@ -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",
|
||||
|
||||
@@ -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}}."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<Record<string, CloudSecretsMissingNotice>>
|
||||
|
||||
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<string, CloudSecretsMissingNotice> = {}
|
||||
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<CloudSecretsMissingNotice, 'at'>,
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<string, RoutedSecretRequest[]> {
|
||||
const routed = new Map<string, RoutedSecretRequest[]>()
|
||||
const byHandle = new Map<string, SecretRequestPaneSlot>()
|
||||
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<string, string>()
|
||||
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<SecretRequestEntry>()
|
||||
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<string, string> = {
|
||||
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
|
||||
}
|
||||
@@ -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> = {}): 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: '<b>x</b>' }))).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>): 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<agent>')).toBe('Myagent')
|
||||
expect(secretRequestAgentName(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -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<SecretRequestStatus, 'pending'> | 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<string, SecretRequestEntry> }
|
||||
|
||||
let state: State = { entries: new Map() }
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
function commit(next: Map<string, SecretRequestEntry>): 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<string>()
|
||||
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<string, Set<string>>()
|
||||
|
||||
/** 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<string>()
|
||||
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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -126,3 +126,28 @@ export type CloudSecretsResult<T> =
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user