mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
feat(cloud): sign agents out before sandbox delete, scrub env-build images, recipe env warning (A9, P-4)
- Worker: run orca-agent-signout (each agent's logout, then credential files) on a running sandbox before the provider delete; bounded (20 s) and best effort, never blocks the delete. - Environment builds: scrub agent auth (--scrub-only) before the snapshot; fail closed. - New VM script registered in VM_SCRIPTS + provision loop; vm-scripts regenerated. - Main IPC ephemeralVm:forwardedEnvSummary returns names only, grouped by credential-looking. - P-4 warning component (+ fixture) shown under Settings > recipes; delete dialog line for awake Orca Cloud sandboxes.
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import {
|
||||
summarizeRecipeForwardedEnv,
|
||||
type RecipeForwardedEnvSummary
|
||||
} from '../../shared/ephemeral-vm-recipe-forwarded-env'
|
||||
|
||||
/** Recipe scripts inherit this process's env (buildRecipeEnv); only the names cross to the renderer. */
|
||||
export function registerRecipeForwardedEnvHandler(): void {
|
||||
ipcMain.removeHandler('ephemeralVm:forwardedEnvSummary')
|
||||
ipcMain.handle('ephemeralVm:forwardedEnvSummary', (): RecipeForwardedEnvSummary =>
|
||||
summarizeRecipeForwardedEnv(process.env)
|
||||
)
|
||||
}
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
pickEphemeralVmFailureCode,
|
||||
type EphemeralVmFailure
|
||||
} from '../../shared/ephemeral-vm-failure-copy'
|
||||
import { registerRecipeForwardedEnvHandler } from './ephemeral-vm-forwarded-env'
|
||||
|
||||
const activeProvisionControllers = new Map<string, AbortController>()
|
||||
|
||||
@@ -97,6 +98,8 @@ export function registerEphemeralVmHandlers(store: Store, pluginService?: Plugin
|
||||
return listPickerRecipes(store, args.repoId, pluginService)
|
||||
})
|
||||
|
||||
registerRecipeForwardedEnvHandler()
|
||||
|
||||
ipcMain.handle(
|
||||
'ephemeralVm:listRecipeCatalog',
|
||||
async (): Promise<EphemeralVmRecipeCatalogEntry[]> => {
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
} from '../../shared/cloud-ssh'
|
||||
import type { CloudRemoteDesktopResult } from '../../shared/hosted-sandbox/cloud-remote-desktop'
|
||||
import type { PendingVmCheckout } from '../../shared/pending-vm-checkouts'
|
||||
import type { RecipeForwardedEnvSummary } from '../../shared/ephemeral-vm-recipe-forwarded-env'
|
||||
|
||||
export type EphemeralVmApi = {
|
||||
listRecipes: (args: { repoId: string }) => Promise<{
|
||||
@@ -45,6 +46,8 @@ export type EphemeralVmApi = {
|
||||
diagnostics: NonNullable<OrcaHooks['environmentRecipeDiagnostics']>
|
||||
}[]
|
||||
>
|
||||
/** Names (never values) of this computer's env that recipe create scripts receive. */
|
||||
forwardedEnvSummary: () => Promise<RecipeForwardedEnvSummary>
|
||||
doctor: (args: { repoId: string; recipeId: string }) => Promise<EphemeralVmRecipeDoctorResult>
|
||||
provision: (args: {
|
||||
repoId: string
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
export const ephemeralVmApi = {
|
||||
listRecipes: (args) => ipcRenderer.invoke('ephemeralVm:listRecipes', args),
|
||||
listRecipeCatalog: () => ipcRenderer.invoke('ephemeralVm:listRecipeCatalog'),
|
||||
forwardedEnvSummary: () => ipcRenderer.invoke('ephemeralVm:forwardedEnvSummary'),
|
||||
doctor: (args) => ipcRenderer.invoke('ephemeralVm:doctor', args),
|
||||
provision: (args) => ipcRenderer.invoke('ephemeralVm:provision', args),
|
||||
cancelProvision: (args) => ipcRenderer.invoke('ephemeralVm:cancelProvision', args),
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { defineFixture } from '../../component-fixtures/component-fixture'
|
||||
import { RecipeEnvForwardingWarning } from './RecipeEnvForwardingWarning'
|
||||
|
||||
const summary = {
|
||||
count: 42,
|
||||
keyLike: ['ANTHROPIC_API_KEY', 'GITHUB_TOKEN', 'NPM_TOKEN'],
|
||||
other: ['EDITOR', 'HOME', 'LANG', 'PATH', 'SHELL', 'TERM', 'USER']
|
||||
}
|
||||
|
||||
export const warning = defineFixture({
|
||||
width: 420,
|
||||
render: () => <RecipeEnvForwardingWarning summary={summary} />
|
||||
})
|
||||
|
||||
export const review = defineFixture({
|
||||
width: 420,
|
||||
render: () => <RecipeEnvForwardingWarning summary={summary} defaultReviewOpen />
|
||||
})
|
||||
|
||||
export const manyKeys = defineFixture({
|
||||
width: 420,
|
||||
render: () => (
|
||||
<RecipeEnvForwardingWarning
|
||||
summary={{
|
||||
...summary,
|
||||
keyLike: ['ANTHROPIC_API_KEY', 'AWS_SECRET_ACCESS_KEY', 'GITHUB_TOKEN', 'NPM_TOKEN']
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
export const noKeys = defineFixture({
|
||||
width: 420,
|
||||
render: () => <RecipeEnvForwardingWarning summary={{ ...summary, keyLike: [] }} />
|
||||
})
|
||||
|
||||
export const loading = defineFixture({
|
||||
width: 420,
|
||||
render: () => <RecipeEnvForwardingWarning summary={null} />
|
||||
})
|
||||
|
||||
export const unavailable = defineFixture({
|
||||
width: 420,
|
||||
render: () => <RecipeEnvForwardingWarning summary={undefined} />
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
import { TriangleAlert } from 'lucide-react'
|
||||
import type React from 'react'
|
||||
import { useEffect, useId, useState } from 'react'
|
||||
import { useTranslate } from '@/i18n/use-translate'
|
||||
import { Button } from '../ui/button'
|
||||
import { Skeleton } from '../ui/skeleton'
|
||||
import type { RecipeForwardedEnvSummary } from '../../../../shared/ephemeral-vm-recipe-forwarded-env'
|
||||
|
||||
const NAMES_IN_LINE = 3
|
||||
|
||||
/**
|
||||
* P-4 (agent accounts spec §4, Q9): recipe create scripts get this computer's whole environment.
|
||||
* A warning only; names, never values. `summary` null while loading, undefined when unavailable.
|
||||
*/
|
||||
export function RecipeEnvForwardingWarning({
|
||||
summary,
|
||||
defaultReviewOpen = false
|
||||
}: {
|
||||
summary: RecipeForwardedEnvSummary | null | undefined
|
||||
defaultReviewOpen?: boolean
|
||||
}): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
const listId = useId()
|
||||
const [reviewOpen, setReviewOpen] = useState(defaultReviewOpen)
|
||||
const keyLike = summary?.keyLike ?? []
|
||||
const names =
|
||||
keyLike.slice(0, NAMES_IN_LINE).join(', ') + (keyLike.length > NAMES_IN_LINE ? ', …' : '')
|
||||
const detail = !summary
|
||||
? null
|
||||
: keyLike.length === 0
|
||||
? t(
|
||||
'agentHostPanel.recipeEnvDetailNoKeys',
|
||||
'{{count}} variables, none that look like keys. The recipe decides what reaches the VM.',
|
||||
{ count: summary.count }
|
||||
)
|
||||
: keyLike.length === 1
|
||||
? t(
|
||||
'agentHostPanel.recipeEnvDetailOneKey',
|
||||
'{{count}} variables, including 1 that looks like a key ({{names}}). The recipe decides what reaches the VM.',
|
||||
{ count: summary.count, names }
|
||||
)
|
||||
: t(
|
||||
'agentHostPanel.recipeEnvDetail',
|
||||
'{{count}} variables, including {{keys}} that look like keys ({{names}}). The recipe decides what reaches the VM.',
|
||||
{ count: summary.count, keys: keyLike.length, names }
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
role="note"
|
||||
aria-busy={summary === null}
|
||||
className="rounded-lg border border-status-warning-border bg-status-warning-background px-3 py-2.5 text-xs leading-5"
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<TriangleAlert className="mt-0.5 size-4 shrink-0 text-status-warning" aria-hidden />
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t(
|
||||
'agentHostPanel.recipeEnv',
|
||||
"This recipe's create script gets this computer's environment."
|
||||
)}
|
||||
</p>
|
||||
{summary === null ? (
|
||||
<Skeleton className="h-3.5 w-4/5" shape="small" />
|
||||
) : detail ? (
|
||||
<p className="text-muted-foreground">{detail}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{summary ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="shrink-0"
|
||||
aria-expanded={reviewOpen}
|
||||
aria-controls={listId}
|
||||
onClick={() => setReviewOpen((open) => !open)}
|
||||
>
|
||||
{reviewOpen
|
||||
? t('agentHostPanel.recipeEnvHide', 'Hide')
|
||||
: t('agentHostPanel.recipeEnvReview', 'Review')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{summary && reviewOpen ? (
|
||||
<div id={listId} className="scrollbar-sleek mt-2 max-h-56 space-y-2 overflow-y-auto pl-6.5">
|
||||
<ForwardedNameGroup
|
||||
label={t('agentHostPanel.recipeEnvKeyLike', 'Look like keys')}
|
||||
names={summary.keyLike}
|
||||
/>
|
||||
<ForwardedNameGroup
|
||||
label={t('agentHostPanel.recipeEnvOther', 'Other variables')}
|
||||
names={summary.other}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ForwardedNameGroup({
|
||||
label,
|
||||
names
|
||||
}: {
|
||||
label: string
|
||||
names: string[]
|
||||
}): React.JSX.Element | null {
|
||||
const headingId = useId()
|
||||
if (names.length === 0) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<section aria-labelledby={headingId}>
|
||||
<h4 id={headingId} className="font-medium text-foreground">
|
||||
{label}
|
||||
</h4>
|
||||
<ul className="font-mono text-muted-foreground">
|
||||
{names.map((name) => (
|
||||
<li key={name} className="break-all">
|
||||
{name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** Loads the names from main; recipe scripts run there, so this computer is main's process env. */
|
||||
export function RecipeEnvForwardingNotice(): React.JSX.Element {
|
||||
const [summary, setSummary] = useState<RecipeForwardedEnvSummary | null | undefined>(null)
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
void (async () => {
|
||||
try {
|
||||
const next = await window.api.ephemeralVm.forwardedEnvSummary()
|
||||
if (live) {
|
||||
setSummary(next)
|
||||
}
|
||||
} catch {
|
||||
// Older main or web client: the warning still stands, without the counts.
|
||||
if (live) {
|
||||
setSummary(undefined)
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [])
|
||||
return <RecipeEnvForwardingWarning summary={summary} />
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { useAppStore } from '@/store/index'
|
||||
import { Button } from '../../../ui/button'
|
||||
import { AgentSkillSetupPanel } from '../../../skills/setup/AgentSkillSetupPanel'
|
||||
import { EphemeralVmRecipeRow } from './EphemeralVmRecipeRow'
|
||||
import { RecipeEnvForwardingNotice } from '../../../agent-host-panel/RecipeEnvForwardingWarning'
|
||||
import { ORCA_CLOUD_RECIPE_ID } from '../../../../../../shared/hosted-sandbox/orca-cloud-recipe-id'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useTranslate } from '@/i18n/use-translate'
|
||||
import { EPHEMERAL_VMS_SKILL_NAME } from '@/lib/skills/installation/agent-feature-skill-names'
|
||||
@@ -308,6 +310,9 @@ export function EphemeralVmsPane(): React.JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{recipes.some(({ recipe }) => recipe.id !== ORCA_CLOUD_RECIPE_ID) ? (
|
||||
<RecipeEnvForwardingNotice />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import type React from 'react'
|
||||
import { useTranslate } from '@/i18n/use-translate'
|
||||
import { useCloudSshMenuState, type CloudSshWorktree } from '../cloud-ssh/use-cloud-ssh-status'
|
||||
|
||||
/** A9: the control plane runs each agent's sign-out on an awake sandbox before deleting it. */
|
||||
export function DeleteCloudSandboxAgentsNote({
|
||||
worktree
|
||||
}: {
|
||||
worktree: CloudSshWorktree
|
||||
}): React.JSX.Element | null {
|
||||
const t = useTranslate()
|
||||
const state = useCloudSshMenuState(worktree)
|
||||
// Asleep sandboxes are never woken for it; their disk goes with the delete.
|
||||
if (state.kind !== 'ready' || state.status.asleep) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
'auto.components.sidebar.DeleteWorktreeDialog.cloudSignsAgentsOut',
|
||||
'Signs agents out on the sandbox first.'
|
||||
)}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
+3
@@ -44,6 +44,7 @@ import { useDeleteWorktreeDialogTargets } from './use-delete-worktree-dialog-tar
|
||||
import { collectRemovalUnsavedFiles } from '@/components/editor/save/flush/removal-unsaved-editor-files'
|
||||
import { useDetachWorktreeDeleteChildren } from './use-detach-worktree-delete-children'
|
||||
import { useDeleteWorktreeDialogDismissal } from './use-delete-worktree-dialog-dismissal'
|
||||
import { DeleteCloudSandboxAgentsNote } from './DeleteCloudSandboxAgentsNote'
|
||||
|
||||
const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
|
||||
const t = useTranslate()
|
||||
@@ -349,6 +350,8 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
|
||||
dirtyChangePreviewsByWorktreeId={dirtyChanges.previews}
|
||||
/>
|
||||
|
||||
{!isBatchDelete && worktree ? <DeleteCloudSandboxAgentsNote worktree={worktree} /> : null}
|
||||
|
||||
<RemovalUnsavedFilesNotice targets={unsavedTargets} enabled={isOpen} />
|
||||
|
||||
{(childWorkspaceCount > 0 || preserveChildrenSelected) && (
|
||||
|
||||
@@ -6605,7 +6605,8 @@
|
||||
"removeWithChildrenGit_one": "Remove <target/> and <descendants>{{count}} child workspace</descendants> from git and delete their workspace folders.",
|
||||
"removeWithChildrenGit_other": "Remove <target/> and <descendants>{{count}} child workspaces</descendants> from git and delete their workspace folders.",
|
||||
"removeSingleFolderFromOrca": "Removes it from Orca. The folder on disk is kept.",
|
||||
"deleteSingleWorktreeAndBranch": "Deletes the worktree and its branch (kept if unmerged)."
|
||||
"deleteSingleWorktreeAndBranch": "Deletes the worktree and its branch (kept if unmerged).",
|
||||
"cloudSignsAgentsOut": "Signs agents out on the sandbox first."
|
||||
},
|
||||
"DeleteWorktreeDirtyChangeHint": {
|
||||
"showLoadedPaths": "Show loaded paths",
|
||||
@@ -27095,5 +27096,18 @@
|
||||
"tabTitle": "Run: {{name}}",
|
||||
"noneTitle": "No run actions for this workspace",
|
||||
"noneBody": "Add a command under scripts.run in orca.yaml, for example a dev server on $ORCA_PORT."
|
||||
},
|
||||
"agentHostPanel": {
|
||||
"recipeEnv": "This recipe's create script gets this computer's environment.",
|
||||
"recipeEnvDetail_one": "{{count}} variable, including {{keys}} that look like keys ({{names}}). The recipe decides what reaches the VM.",
|
||||
"recipeEnvDetail_other": "{{count}} variables, including {{keys}} that look like keys ({{names}}). The recipe decides what reaches the VM.",
|
||||
"recipeEnvDetailOneKey_one": "{{count}} variable, including 1 that looks like a key ({{names}}). The recipe decides what reaches the VM.",
|
||||
"recipeEnvDetailOneKey_other": "{{count}} variables, including 1 that looks like a key ({{names}}). The recipe decides what reaches the VM.",
|
||||
"recipeEnvDetailNoKeys_one": "{{count}} variable, none that look like keys. The recipe decides what reaches the VM.",
|
||||
"recipeEnvDetailNoKeys_other": "{{count}} variables, none that look like keys. The recipe decides what reaches the VM.",
|
||||
"recipeEnvReview": "Review",
|
||||
"recipeEnvHide": "Hide",
|
||||
"recipeEnvKeyLike": "Look like keys",
|
||||
"recipeEnvOther": "Other variables"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { summarizeRecipeForwardedEnv } from './ephemeral-vm-recipe-forwarded-env'
|
||||
|
||||
describe('summarizeRecipeForwardedEnv', () => {
|
||||
it('returns names only, grouping the ones that look like credentials', () => {
|
||||
const summary = summarizeRecipeForwardedEnv({
|
||||
PATH: '/usr/bin',
|
||||
HOME: '/home/me',
|
||||
GITHUB_TOKEN: 'ghp_secret_value',
|
||||
AWS_SECRET_ACCESS_KEY: 'aws-secret-value',
|
||||
DB_PASSWORD: 'hunter2',
|
||||
ANTHROPIC_BASE_URL: 'https://gateway.example',
|
||||
openai_api_key: 'sk-lower',
|
||||
KEYCHAIN_PATH: '/tmp/k',
|
||||
UNSET: undefined
|
||||
})
|
||||
expect(summary).toEqual({
|
||||
count: 8,
|
||||
keyLike: [
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'AWS_SECRET_ACCESS_KEY',
|
||||
'DB_PASSWORD',
|
||||
'GITHUB_TOKEN',
|
||||
'openai_api_key'
|
||||
],
|
||||
other: ['HOME', 'KEYCHAIN_PATH', 'PATH']
|
||||
})
|
||||
expect(JSON.stringify(summary)).not.toMatch(
|
||||
/ghp_secret_value|aws-secret-value|hunter2|sk-lower/
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { AGENT_CREDENTIAL_NAMES } from './agent-credentials/agent-credential-names'
|
||||
|
||||
/** Names only: recipe create scripts get this computer's whole env, so values never leave main. */
|
||||
export type RecipeForwardedEnvSummary = {
|
||||
count: number
|
||||
/** Sorted names that look like credentials (key/token/secret/password, or an agent credential). */
|
||||
keyLike: string[]
|
||||
/** Every other forwarded name, sorted. */
|
||||
other: string[]
|
||||
}
|
||||
|
||||
const AGENT_CREDENTIAL_NAME_SET = new Set(Object.values(AGENT_CREDENTIAL_NAMES).flat())
|
||||
const CREDENTIAL_SEGMENTS = new Set([
|
||||
'KEY',
|
||||
'KEYS',
|
||||
'APIKEY',
|
||||
'TOKEN',
|
||||
'TOKENS',
|
||||
'SECRET',
|
||||
'SECRETS',
|
||||
'CREDENTIAL',
|
||||
'CREDENTIALS',
|
||||
'PAT'
|
||||
])
|
||||
const CREDENTIAL_SUBSTRINGS = /PASSWORD|PASSWD|SECRET|TOKEN|PRIVATE_KEY|API_?KEY/
|
||||
|
||||
export function looksLikeCredentialEnvName(name: string): boolean {
|
||||
// Windows env names are case-insensitive; match them the way agents would read them.
|
||||
const upper = name.toUpperCase()
|
||||
return (
|
||||
AGENT_CREDENTIAL_NAME_SET.has(upper) ||
|
||||
CREDENTIAL_SUBSTRINGS.test(upper) ||
|
||||
upper.split(/[_\-.]/).some((segment) => CREDENTIAL_SEGMENTS.has(segment))
|
||||
)
|
||||
}
|
||||
|
||||
export function summarizeRecipeForwardedEnv(env: NodeJS.ProcessEnv): RecipeForwardedEnvSummary {
|
||||
const names = Object.keys(env)
|
||||
.filter((name) => name.length > 0 && env[name] !== undefined)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
return {
|
||||
count: names.length,
|
||||
keyLike: names.filter(looksLikeCredentialEnvName),
|
||||
other: names.filter((name) => !looksLikeCredentialEnvName(name))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user