mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 09:27:29 +00:00
fix(recipes): show credentials recipe scripts inherit, add opt-in passEnv allowlist (#40757)
* fix(recipes): warn which credentials recipe scripts inherit, add opt-in passEnv allowlist Recipe lifecycle scripts run locally with Orca's full environment. Keep that default (provider CLIs and git auth rely on it) but name the credential-like variables in Settings and let a recipe opt into passEnv so only listed variables plus a safe base set reach its scripts. * fix(recipes): fail closed on a malformed passEnv instead of dropping the recipe Dropping the recipe let a stored snapshot or a same-id plugin recipe run with the full environment. Keep it with an empty allowlist and a diagnostic, match base variable names exactly outside Windows, and make the warning copy apply to any recipe source. * fix(recipes): import user-agent-platform from its new app-shell location
This commit is contained in:
@@ -301,6 +301,17 @@ timeoutSeconds:
|
||||
destroy: 120
|
||||
```
|
||||
|
||||
Recipe scripts inherit Orca's whole environment by default, including any API keys and tokens in
|
||||
it. Set `passEnv` to pass only the variables the scripts need, as exact names or a prefix ending in
|
||||
`*`; `PATH`, `HOME`, `USER`, `SHELL`, `LANG`, `TMPDIR` and similar basics are always kept, and the
|
||||
`ORCA_*` recipe variables are always set. Recipes that use an SSH agent also need `SSH_AUTH_SOCK`,
|
||||
and provider CLIs need their own credential or config variables. A malformed `passEnv` is reported
|
||||
and limits the scripts to the basic variables until it is fixed.
|
||||
|
||||
```yaml
|
||||
passEnv: [GH_TOKEN, GITHUB_TOKEN, 'CLOUDSDK_*']
|
||||
```
|
||||
|
||||
A timed-out `create` may still have started a VM, so make `create` idempotent per
|
||||
`ORCA_VM_INSTANCE_ID` or have it record the resource before slow work starts.
|
||||
|
||||
|
||||
@@ -2114,6 +2114,17 @@ const ORCA_PER_WORKSPACE_ENV_MARKDOWN = [
|
||||
" destroy: 120",
|
||||
"```",
|
||||
"",
|
||||
"Recipe scripts inherit Orca's whole environment by default, including any API keys and tokens in",
|
||||
"it. Set `passEnv` to pass only the variables the scripts need, as exact names or a prefix ending in",
|
||||
"`*`; `PATH`, `HOME`, `USER`, `SHELL`, `LANG`, `TMPDIR` and similar basics are always kept, and the",
|
||||
"`ORCA_*` recipe variables are always set. Recipes that use an SSH agent also need `SSH_AUTH_SOCK`,",
|
||||
"and provider CLIs need their own credential or config variables. A malformed `passEnv` is reported",
|
||||
"and limits the scripts to the basic variables until it is fixed.",
|
||||
"",
|
||||
"```yaml",
|
||||
"passEnv: [GH_TOKEN, GITHUB_TOKEN, 'CLOUDSDK_*']",
|
||||
"```",
|
||||
"",
|
||||
"A timed-out `create` may still have started a VM, so make `create` idempotent per",
|
||||
"`ORCA_VM_INSTANCE_ID` or have it record the resource before slow work starts.",
|
||||
"",
|
||||
|
||||
@@ -108,4 +108,58 @@ describe('recipe timeoutSeconds and persisted runtimes', () => {
|
||||
getRuntimeRecipeContext(store, userDataPath, provisioned.runtime.id).recipe
|
||||
).not.toHaveProperty('timeoutSeconds')
|
||||
})
|
||||
|
||||
it('keeps passEnv out of the persisted snapshot and re-applies it for lifecycle steps', async () => {
|
||||
const userDataPath = makeDir('orca-vm-passenv-user-data-')
|
||||
const repoPath = makeDir('orca-vm-passenv-repo-')
|
||||
const startPath = join(repoPath, 'start.js')
|
||||
const pairingCode = encodePairingOffer({
|
||||
v: PAIRING_OFFER_VERSION,
|
||||
endpoint: 'wss://vm.example.com',
|
||||
deviceToken: 'token',
|
||||
publicKeyB64: 'public-key'
|
||||
})
|
||||
writeFileSync(
|
||||
startPath,
|
||||
`console.log(JSON.stringify({ schemaVersion: 1, pairingCode: ${JSON.stringify(pairingCode)}, projectRoot: '/workspace/repo' }))\n`
|
||||
)
|
||||
const recipe: OrcaVmRecipe = {
|
||||
id: 'remote-vm',
|
||||
name: 'Remote VM',
|
||||
create: `"${process.execPath}" "${startPath}"`,
|
||||
destroy: './destroy.sh',
|
||||
passEnv: ['GH_TOKEN']
|
||||
}
|
||||
|
||||
const provisioned = await provisionEphemeralVmRuntime({
|
||||
userDataPath,
|
||||
repoPath,
|
||||
recipe,
|
||||
repoId: 'repo-1',
|
||||
now: 1_000
|
||||
})
|
||||
|
||||
expect(provisioned.ok).toBe(true)
|
||||
if (!provisioned.ok) {
|
||||
return
|
||||
}
|
||||
expect(provisioned.runtime.recipe).not.toHaveProperty('passEnv')
|
||||
const onDisk = JSON.parse(readFileSync(getEphemeralVmRuntimeStorePath(userDataPath), 'utf8'))
|
||||
expect(RollbackEphemeralVmRuntimeStoreSchema.safeParse(onDisk).success).toBe(true)
|
||||
|
||||
hooks.recipes = [recipe]
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: getRuntimeRecipeContext only reads getRepo from the store.
|
||||
const store = {
|
||||
getRepo: () => ({ id: 'repo-1', path: repoPath, kind: 'git' })
|
||||
} as unknown as Store
|
||||
expect(
|
||||
getRuntimeRecipeContext(store, userDataPath, provisioned.runtime.id).recipe
|
||||
).toMatchObject({ passEnv: ['GH_TOKEN'] })
|
||||
|
||||
// A different recipe that later took the same id does not change the environment.
|
||||
hooks.recipes = [{ ...recipe, create: './other-create.sh', passEnv: ['OTHER'] }]
|
||||
expect(
|
||||
getRuntimeRecipeContext(store, userDataPath, provisioned.runtime.id).recipe
|
||||
).not.toHaveProperty('passEnv')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -110,11 +110,12 @@ export function getRuntimeRecipeContext(
|
||||
// holds the same recipe, so a different checkout later placed there can't run its scripts.
|
||||
const fallback = runtime.recipe && runtime.repoPath ? runtime.recipe : null
|
||||
if (fallback && runtime.repoPath) {
|
||||
const sameRecipe = (loadHooks(runtime.repoPath)?.environmentRecipes ?? []).some(
|
||||
const sameRecipe = (loadHooks(runtime.repoPath)?.environmentRecipes ?? []).find(
|
||||
(entry) => entry.id === fallback.id && entry.create === fallback.create
|
||||
)
|
||||
if (sameRecipe) {
|
||||
return { runtime, repoPath: runtime.repoPath, recipe: fallback }
|
||||
const passEnv = sameRecipe.passEnv ? { passEnv: sameRecipe.passEnv } : {}
|
||||
return { runtime, repoPath: runtime.repoPath, recipe: { ...fallback, ...passEnv } }
|
||||
}
|
||||
}
|
||||
throw new EphemeralVmFailureError(
|
||||
@@ -128,11 +129,15 @@ export function getRuntimeRecipeContext(
|
||||
// Snapshots never persist timeoutSeconds, so take the repo's current limits (lifecycle commands stay
|
||||
// snapshotted). Only when the repo recipe has the same create command, so a different recipe that
|
||||
// later took this id (e.g. a repo recipe shadowing a plugin one) cannot impose its limits.
|
||||
const limits =
|
||||
repoRecipe?.timeoutSeconds && repoRecipe.create === runtime.recipe?.create
|
||||
// passEnv follows the same rule, so suspend/resume/destroy see the environment create saw.
|
||||
const sameCreate = repoRecipe !== undefined && repoRecipe.create === runtime.recipe?.create
|
||||
const fromRepo = {
|
||||
...(sameCreate && repoRecipe.timeoutSeconds
|
||||
? { timeoutSeconds: repoRecipe.timeoutSeconds }
|
||||
: {}
|
||||
const recipe = runtime.recipe ? { ...runtime.recipe, ...limits } : repoRecipe
|
||||
: {}),
|
||||
...(sameCreate && repoRecipe.passEnv ? { passEnv: repoRecipe.passEnv } : {})
|
||||
}
|
||||
const recipe = runtime.recipe ? { ...runtime.recipe, ...fromRepo } : repoRecipe
|
||||
if (!recipe) {
|
||||
throw new EphemeralVmFailureError(
|
||||
ephemeralVmFailure('recipeNotFound', { recipeId: runtime.recipeId })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { app, BrowserWindow, ipcMain } from 'electron'
|
||||
import type { Store } from '../persistence/loading-store/store'
|
||||
import { listCredentialLikeEnvNames } from '../../shared/ephemeral-vm-recipe-env'
|
||||
import {
|
||||
listEphemeralVmRuntimes,
|
||||
updateEphemeralVmRuntimeStatus
|
||||
@@ -112,9 +113,15 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void {
|
||||
ipcMain.removeHandler('ephemeralVm:stopCleanup')
|
||||
ipcMain.removeHandler('ephemeralVm:forgetRuntime')
|
||||
ipcMain.removeHandler('ephemeralVm:getCleanupCommand')
|
||||
ipcMain.removeHandler('ephemeralVm:listCredentialEnvNames')
|
||||
stopRuntimesChangedBroadcast?.()
|
||||
stopRuntimesChangedBroadcast = onEphemeralVmRuntimesChanged(broadcastEphemeralVmRuntimesChanged)
|
||||
|
||||
// Names only: the recipe list warns which credentials scripts inherit, never their values.
|
||||
ipcMain.handle('ephemeralVm:listCredentialEnvNames', () =>
|
||||
listCredentialLikeEnvNames(process.env)
|
||||
)
|
||||
|
||||
ipcMain.handle('ephemeralVm:listRuntimes', (): EphemeralVmRuntimeRecord[] => {
|
||||
return listEphemeralVmRuntimes(app.getPath('userData'))
|
||||
})
|
||||
|
||||
@@ -29,6 +29,8 @@ export type EphemeralVmApi = {
|
||||
diagnostics: NonNullable<OrcaHooks['environmentRecipeDiagnostics']>
|
||||
}[]
|
||||
>
|
||||
/** Credential-like variable names (never values) recipe scripts would inherit from Orca. */
|
||||
listCredentialEnvNames: () => Promise<string[]>
|
||||
doctor: (args: { repoId: string; recipeId: string }) => Promise<EphemeralVmRecipeDoctorResult>
|
||||
provision: (args: {
|
||||
repoId: string
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
export const ephemeralVmApi = {
|
||||
listRecipes: (args) => ipcRenderer.invoke('ephemeralVm:listRecipes', args),
|
||||
listRecipeCatalog: () => ipcRenderer.invoke('ephemeralVm:listRecipeCatalog'),
|
||||
listCredentialEnvNames: () => ipcRenderer.invoke('ephemeralVm:listCredentialEnvNames'),
|
||||
doctor: (args) => ipcRenderer.invoke('ephemeralVm:doctor', args),
|
||||
provision: (args) => ipcRenderer.invoke('ephemeralVm:provision', args),
|
||||
cancelProvision: (args) => ipcRenderer.invoke('ephemeralVm:cancelProvision', args),
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { defineFixture } from '../../../../component-fixtures/component-fixture'
|
||||
import { EphemeralVmRecipeRow } from './EphemeralVmRecipeRow'
|
||||
|
||||
const entry = { repoId: 'r', repoName: 'acme/web', repoPath: '/repo', recipes: [], diagnostics: [] }
|
||||
const names = ['ANTHROPIC_API_KEY', 'AWS_SECRET_ACCESS_KEY', 'GITHUB_TOKEN', 'OPENAI_API_KEY']
|
||||
|
||||
function Rows() {
|
||||
return (
|
||||
<div className="divide-y divide-border/50 rounded-lg border border-border/50 bg-background text-foreground">
|
||||
<EphemeralVmRecipeRow
|
||||
entry={entry}
|
||||
recipe={{
|
||||
id: 'remote-vm',
|
||||
name: 'Remote VM',
|
||||
create: './scripts/vm/create.sh',
|
||||
destroy: './scripts/vm/destroy.sh'
|
||||
}}
|
||||
credentialEnvNames={names}
|
||||
onUse={() => {}}
|
||||
/>
|
||||
<EphemeralVmRecipeRow
|
||||
entry={entry}
|
||||
recipe={{
|
||||
id: 'limited-vm',
|
||||
name: 'Limited VM',
|
||||
create: './scripts/vm/create.sh',
|
||||
destroy: './scripts/vm/destroy.sh',
|
||||
passEnv: ['GITHUB_TOKEN']
|
||||
}}
|
||||
credentialEnvNames={names}
|
||||
onUse={() => {}}
|
||||
/>
|
||||
<EphemeralVmRecipeRow
|
||||
entry={entry}
|
||||
recipe={{
|
||||
id: 'ssh-vm',
|
||||
name: 'SSH host',
|
||||
create: './scripts/vm/ssh.sh',
|
||||
destroyDisabled: true,
|
||||
passEnv: []
|
||||
}}
|
||||
credentialEnvNames={names}
|
||||
onUse={() => {}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const rows = defineFixture({ width: 720, render: () => <Rows /> })
|
||||
+70
-1
@@ -1,21 +1,28 @@
|
||||
import { Play } from 'lucide-react'
|
||||
import { KeyRound, Play } from 'lucide-react'
|
||||
import type React from 'react'
|
||||
import type { OrcaHooks } from '../../../../../../shared/orca-yaml-hook-types'
|
||||
import { isRecipeEnvNamePassed } from '../../../../../../shared/ephemeral-vm-recipe-env'
|
||||
import { Button } from '../../../ui/button'
|
||||
import { useTranslate } from '@/i18n/use-translate'
|
||||
import { isWindowsUserAgent } from '@/app-shell/platform/user-agent-platform'
|
||||
|
||||
type RecipeCatalogEntry = Awaited<
|
||||
ReturnType<typeof window.api.ephemeralVm.listRecipeCatalog>
|
||||
>[number]
|
||||
type Recipe = NonNullable<OrcaHooks['environmentRecipes']>[number]
|
||||
|
||||
const MAX_LISTED_ENV_NAMES = 6
|
||||
|
||||
export function EphemeralVmRecipeRow({
|
||||
entry,
|
||||
recipe,
|
||||
credentialEnvNames,
|
||||
onUse
|
||||
}: {
|
||||
entry: RecipeCatalogEntry
|
||||
recipe: Recipe
|
||||
/** Credential-like names in Orca's environment; null until loaded or when unavailable. */
|
||||
credentialEnvNames: readonly string[] | null
|
||||
onUse: () => void
|
||||
}): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
@@ -34,6 +41,7 @@ export function EphemeralVmRecipeRow({
|
||||
<p className="truncate font-mono text-xs text-muted-foreground">
|
||||
{recipe.id} · {recipe.create} · {destroyLabel}
|
||||
</p>
|
||||
<RecipeEnvExposure recipe={recipe} credentialEnvNames={credentialEnvNames} />
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button type="button" variant="outline" size="xs" className="gap-1.5" onClick={onUse}>
|
||||
@@ -44,3 +52,64 @@ export function EphemeralVmRecipeRow({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RecipeEnvExposure({
|
||||
recipe,
|
||||
credentialEnvNames
|
||||
}: {
|
||||
recipe: Recipe
|
||||
credentialEnvNames: readonly string[] | null
|
||||
}): React.JSX.Element | null {
|
||||
const t = useTranslate()
|
||||
if (!credentialEnvNames) {
|
||||
return null
|
||||
}
|
||||
const platform = isWindowsUserAgent() ? 'win32' : 'linux'
|
||||
const passed = credentialEnvNames.filter((name) =>
|
||||
isRecipeEnvNamePassed(name, recipe.passEnv, platform)
|
||||
)
|
||||
if (passed.length === 0) {
|
||||
return recipe.passEnv ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t(
|
||||
'auto.components.settings.EphemeralVmRecipeRow.envLimited',
|
||||
'Scripts only receive the variables listed in passEnv, plus PATH, HOME and similar basics.'
|
||||
)}
|
||||
</p>
|
||||
) : null
|
||||
}
|
||||
const listed = passed.slice(0, MAX_LISTED_ENV_NAMES).join(', ')
|
||||
const names =
|
||||
passed.length > MAX_LISTED_ENV_NAMES
|
||||
? t(
|
||||
'auto.components.settings.EphemeralVmRecipeRow.envNamesMore',
|
||||
'{{names}} +{{count}} more',
|
||||
{
|
||||
names: listed,
|
||||
count: passed.length - MAX_LISTED_ENV_NAMES
|
||||
}
|
||||
)
|
||||
: listed
|
||||
return (
|
||||
<p
|
||||
className="mt-1 flex items-start gap-1.5 text-xs text-status-warning-text"
|
||||
title={passed.join('\n')}
|
||||
data-testid="recipe-env-exposure"
|
||||
>
|
||||
<KeyRound className="mt-0.5 size-3 shrink-0" />
|
||||
<span className="min-w-0">
|
||||
{recipe.passEnv
|
||||
? t(
|
||||
'auto.components.settings.EphemeralVmRecipeRow.envExposedAllowlist',
|
||||
'Scripts run on this computer and receive these credentials from passEnv: {{names}}.',
|
||||
{ names }
|
||||
)
|
||||
: t(
|
||||
'auto.components.settings.EphemeralVmRecipeRow.envExposedAll',
|
||||
'Scripts run on this computer with your full environment, including {{names}}. Set passEnv on the recipe to pass only what it needs.',
|
||||
{ names }
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
+38
@@ -81,6 +81,7 @@ describe('EphemeralVmsPane', () => {
|
||||
]
|
||||
}
|
||||
]),
|
||||
listCredentialEnvNames: vi.fn().mockResolvedValue(['ANTHROPIC_API_KEY', 'GITHUB_TOKEN']),
|
||||
doctor: vi.fn().mockResolvedValue({
|
||||
recipeId: 'cloud-sandbox',
|
||||
repoPath: '/repo',
|
||||
@@ -165,4 +166,41 @@ describe('EphemeralVmsPane', () => {
|
||||
expect(container.textContent).toContain('Newest Recipe')
|
||||
expect(container.textContent).not.toContain('Stale Recipe')
|
||||
})
|
||||
|
||||
it('names the credential-like variables a recipe inherits, without values', async () => {
|
||||
const container = await renderPane()
|
||||
await vi.waitFor(() =>
|
||||
expect(container.querySelector('[data-testid="recipe-env-exposure"]')?.textContent).toContain(
|
||||
'ANTHROPIC_API_KEY, GITHUB_TOKEN'
|
||||
)
|
||||
)
|
||||
expect(container.textContent).toContain('Set passEnv on the recipe')
|
||||
})
|
||||
|
||||
it('lists only allowlisted credentials when the recipe sets passEnv', async () => {
|
||||
const listRecipeCatalog = window.api.ephemeralVm.listRecipeCatalog as ReturnType<typeof vi.fn>
|
||||
listRecipeCatalog.mockReset().mockResolvedValue([
|
||||
{
|
||||
repoId: 'repo-1',
|
||||
repoName: 'Repo',
|
||||
repoPath: '/repo',
|
||||
diagnostics: [],
|
||||
recipes: [
|
||||
{
|
||||
id: 'limited',
|
||||
name: 'Limited',
|
||||
create: 'create',
|
||||
passEnv: ['GITHUB_TOKEN']
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
const container = await renderPane()
|
||||
await vi.waitFor(() =>
|
||||
expect(container.querySelector('[data-testid="recipe-env-exposure"]')?.textContent).toContain(
|
||||
'from passEnv: GITHUB_TOKEN.'
|
||||
)
|
||||
)
|
||||
expect(container.textContent).not.toContain('ANTHROPIC_API_KEY')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -37,6 +37,7 @@ export function EphemeralVmsPane(): React.JSX.Element {
|
||||
const [agentLaunching, setAgentLaunching] = useState(false)
|
||||
const activeSkillRuntime = useActiveProjectSkillRuntime()
|
||||
const [catalog, setCatalog] = useState<RecipeCatalogEntry[]>([])
|
||||
const [credentialEnvNames, setCredentialEnvNames] = useState<string[] | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [promptCopied, setPromptCopied] = useState(false)
|
||||
const mountedRef = useMountedRef()
|
||||
@@ -93,6 +94,18 @@ export function EphemeralVmsPane(): React.JSX.Element {
|
||||
void refresh()
|
||||
}, [refresh])
|
||||
|
||||
useEffect(() => {
|
||||
// The warning is advisory; if the names can't be read the list still works without it.
|
||||
void window.api.ephemeralVm
|
||||
.listCredentialEnvNames()
|
||||
.then((names) => {
|
||||
if (mountedRef.current) {
|
||||
setCredentialEnvNames(names)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}, [mountedRef])
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.api.plugins?.onChanged) {
|
||||
return
|
||||
@@ -302,6 +315,7 @@ export function EphemeralVmsPane(): React.JSX.Element {
|
||||
key={`${entry.repoId}:${recipe.id}`}
|
||||
entry={entry}
|
||||
recipe={recipe}
|
||||
credentialEnvNames={credentialEnvNames}
|
||||
onUse={() => openWorkspaceComposerForRecipe(entry.repoId, recipe.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -12088,7 +12088,11 @@
|
||||
"deleteAnywayClicked": "Delete Anyway clicked"
|
||||
},
|
||||
"EphemeralVmRecipeRow": {
|
||||
"useInWorkspace": "Use in workspace"
|
||||
"useInWorkspace": "Use in workspace",
|
||||
"envLimited": "Scripts only receive the variables listed in passEnv, plus PATH, HOME and similar basics.",
|
||||
"envNamesMore": "{{names}} +{{count}} more",
|
||||
"envExposedAllowlist": "Scripts run on this computer and receive these credentials from passEnv: {{names}}.",
|
||||
"envExposedAll": "Scripts run on this computer with your full environment, including {{names}}. Set passEnv on the recipe to pass only what it needs."
|
||||
},
|
||||
"EphemeralVmRuntimesSection": {
|
||||
"cleanupFailed": "Cleanup failed",
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
buildRecipeInheritedEnv,
|
||||
isCredentialLikeEnvName,
|
||||
isRecipeEnvNamePassed,
|
||||
listCredentialLikeEnvNames,
|
||||
parseRecipePassEnv
|
||||
} from './ephemeral-vm-recipe-env'
|
||||
import { runRecipeCommand } from './ephemeral-vm-recipe-process'
|
||||
import { parseOrcaYaml } from './orca-yaml'
|
||||
|
||||
const tmpRoots: string[] = []
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
for (const root of tmpRoots.splice(0)) {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('isCredentialLikeEnvName', () => {
|
||||
it.each([
|
||||
'ANTHROPIC_API_KEY',
|
||||
'OPENAI_API_KEY',
|
||||
'GITHUB_TOKEN',
|
||||
'GH_TOKEN',
|
||||
'NPM_TOKEN',
|
||||
'STRIPE_SECRET',
|
||||
'DB_PASSWORD',
|
||||
'AWS_ACCESS_KEY_ID',
|
||||
'AWS_PROFILE',
|
||||
'GOOGLE_APPLICATION_CREDENTIALS',
|
||||
'AZURE_CLIENT_SECRET',
|
||||
'github_pat',
|
||||
'SSH_AUTH_SOCK'
|
||||
])('flags %s', (name) => {
|
||||
expect(isCredentialLikeEnvName(name)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'PATH',
|
||||
'HOME',
|
||||
'SHELL',
|
||||
'LANG',
|
||||
'KEYCHAIN_PATH',
|
||||
'TERM_PROGRAM',
|
||||
'NODE_ENV',
|
||||
'TOKENIZERS'
|
||||
])('does not flag %s', (name) => {
|
||||
expect(isCredentialLikeEnvName(name)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isRecipeEnvNamePassed', () => {
|
||||
it('passes everything when passEnv is not set', () => {
|
||||
expect(isRecipeEnvNamePassed('OPENAI_API_KEY', undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it('passes the base set, exact names and trailing-* prefixes', () => {
|
||||
const passEnv = ['GH_TOKEN', 'CLOUDSDK_*']
|
||||
expect(isRecipeEnvNamePassed('PATH', passEnv, 'linux')).toBe(true)
|
||||
expect(isRecipeEnvNamePassed('LC_MESSAGES', passEnv, 'linux')).toBe(true)
|
||||
expect(isRecipeEnvNamePassed('GH_TOKEN', passEnv, 'linux')).toBe(true)
|
||||
expect(isRecipeEnvNamePassed('CLOUDSDK_CORE_PROJECT', passEnv, 'linux')).toBe(true)
|
||||
expect(isRecipeEnvNamePassed('GITHUB_TOKEN', passEnv, 'linux')).toBe(false)
|
||||
expect(isRecipeEnvNamePassed('gh_token', passEnv, 'linux')).toBe(false)
|
||||
expect(isRecipeEnvNamePassed('path', passEnv, 'linux')).toBe(false)
|
||||
})
|
||||
|
||||
it('matches names case-insensitively on Windows', () => {
|
||||
expect(isRecipeEnvNamePassed('Path', [], 'win32')).toBe(true)
|
||||
expect(isRecipeEnvNamePassed('SystemRoot', [], 'win32')).toBe(true)
|
||||
expect(isRecipeEnvNamePassed('gh_token', ['GH_TOKEN'], 'win32')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildRecipeInheritedEnv', () => {
|
||||
const source = {
|
||||
PATH: '/usr/bin',
|
||||
HOME: '/home/me',
|
||||
OPENAI_API_KEY: 'sk-x',
|
||||
GH_TOKEN: 'gh-x',
|
||||
AWS_REGION: 'us-east-1'
|
||||
}
|
||||
|
||||
it('copies the full environment without passEnv', () => {
|
||||
expect(buildRecipeInheritedEnv(source, undefined, 'linux')).toEqual(source)
|
||||
})
|
||||
|
||||
it('keeps only the base set and listed names with passEnv', () => {
|
||||
expect(buildRecipeInheritedEnv(source, ['GH_TOKEN', 'AWS_*'], 'linux')).toEqual({
|
||||
PATH: '/usr/bin',
|
||||
HOME: '/home/me',
|
||||
GH_TOKEN: 'gh-x',
|
||||
AWS_REGION: 'us-east-1'
|
||||
})
|
||||
expect(buildRecipeInheritedEnv(source, [], 'linux')).toEqual({
|
||||
PATH: '/usr/bin',
|
||||
HOME: '/home/me'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('listCredentialLikeEnvNames', () => {
|
||||
it('returns sorted names with values, never values', () => {
|
||||
expect(
|
||||
listCredentialLikeEnvNames({
|
||||
PATH: '/usr/bin',
|
||||
OPENAI_API_KEY: 'sk-x',
|
||||
EMPTY_TOKEN: '',
|
||||
ANTHROPIC_API_KEY: 'sk-y'
|
||||
})
|
||||
).toEqual(['ANTHROPIC_API_KEY', 'OPENAI_API_KEY'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseRecipePassEnv', () => {
|
||||
it('accepts names and prefixes, dropping duplicates', () => {
|
||||
expect(parseRecipePassEnv(['GH_TOKEN', ' AWS_* ', 'GH_TOKEN'])).toEqual(['GH_TOKEN', 'AWS_*'])
|
||||
expect(parseRecipePassEnv([])).toEqual([])
|
||||
})
|
||||
|
||||
it.each([['GH_TOKEN=x'], 'GH_TOKEN', [1], ['A*B'], ['']])('rejects %j', (value) => {
|
||||
expect(parseRecipePassEnv(value)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('orca.yaml passEnv', () => {
|
||||
const recipeYaml = (passEnv: string): string =>
|
||||
[
|
||||
'environmentRecipes:',
|
||||
' - id: remote-vm',
|
||||
' name: Remote VM',
|
||||
' create: ./create.sh',
|
||||
...(passEnv ? [` passEnv: ${passEnv}`] : [])
|
||||
].join('\n')
|
||||
|
||||
it('parses a passEnv list onto the recipe', () => {
|
||||
expect(parseOrcaYaml(recipeYaml('[GH_TOKEN, "AWS_*"]'))?.environmentRecipes).toEqual([
|
||||
{
|
||||
id: 'remote-vm',
|
||||
name: 'Remote VM',
|
||||
create: './create.sh',
|
||||
passEnv: ['GH_TOKEN', 'AWS_*']
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves passEnv unset when omitted', () => {
|
||||
expect(parseOrcaYaml(recipeYaml(''))?.environmentRecipes?.[0]).not.toHaveProperty('passEnv')
|
||||
})
|
||||
|
||||
it('keeps a recipe with a malformed passEnv but limits it to the base environment', () => {
|
||||
const hooks = parseOrcaYaml(recipeYaml('GH_TOKEN'))
|
||||
expect(hooks?.environmentRecipes?.[0]?.passEnv).toEqual([])
|
||||
expect(hooks?.environmentRecipeDiagnostics).toEqual([
|
||||
expect.objectContaining({ index: 0, field: 'passEnv' })
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('runRecipeCommand passEnv', () => {
|
||||
async function runAndReadEnv(passEnv?: string[]): Promise<Record<string, string>> {
|
||||
const repoPath = mkdtempSync(join(tmpdir(), 'orca-vm-recipe-env-'))
|
||||
tmpRoots.push(repoPath)
|
||||
const scriptPath = join(repoPath, 'print-env.js')
|
||||
writeFileSync(scriptPath, 'process.stdout.write(JSON.stringify(process.env))\n')
|
||||
const result = await runRecipeCommand({
|
||||
command: `"${process.execPath}" "${scriptPath}"`,
|
||||
repoPath,
|
||||
context: { recipeId: 'remote-vm', repoPath },
|
||||
mode: 'create',
|
||||
resultSchemaVersion: 1,
|
||||
...(passEnv ? { passEnv } : {})
|
||||
})
|
||||
expect(result.exitCode).toBe(0)
|
||||
return JSON.parse(result.stdout)
|
||||
}
|
||||
|
||||
it('passes the full environment by default and only the allowlist with passEnv', async () => {
|
||||
vi.stubEnv('ORCA_TEST_RECIPE_API_KEY', 'secret-value')
|
||||
vi.stubEnv('ORCA_TEST_RECIPE_ALLOWED', 'allowed-value')
|
||||
|
||||
const full = await runAndReadEnv()
|
||||
expect(full.ORCA_TEST_RECIPE_API_KEY).toBe('secret-value')
|
||||
|
||||
const limited = await runAndReadEnv(['ORCA_TEST_RECIPE_ALLOWED'])
|
||||
expect(limited.ORCA_TEST_RECIPE_API_KEY).toBeUndefined()
|
||||
expect(limited.ORCA_TEST_RECIPE_ALLOWED).toBe('allowed-value')
|
||||
// Orca's own recipe variables are always set.
|
||||
expect(limited.ORCA_VM_MODE).toBe('create')
|
||||
expect(limited.ORCA_RECIPE_ID).toBe('remote-vm')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
// Recipe scripts run locally and inherit Orca's environment. These helpers let a recipe opt into a
|
||||
// narrower environment (`passEnv`) and let the UI name the credential-like variables a recipe sees.
|
||||
|
||||
/** Variables every recipe keeps even with `passEnv`, so shells and provider CLIs still start. */
|
||||
export const RECIPE_BASE_ENV_NAMES: readonly string[] = [
|
||||
'PATH',
|
||||
'HOME',
|
||||
'USER',
|
||||
'LOGNAME',
|
||||
'SHELL',
|
||||
'LANG',
|
||||
'LANGUAGE',
|
||||
'LC_ALL',
|
||||
'LC_CTYPE',
|
||||
'TERM',
|
||||
'TZ',
|
||||
'TMPDIR',
|
||||
'TEMP',
|
||||
'TMP',
|
||||
'XDG_CONFIG_HOME',
|
||||
'XDG_CACHE_HOME',
|
||||
'XDG_DATA_HOME',
|
||||
'XDG_RUNTIME_DIR',
|
||||
// Windows: cmd.exe and most CLIs fail to start without these.
|
||||
'SYSTEMROOT',
|
||||
'SYSTEMDRIVE',
|
||||
'WINDIR',
|
||||
'COMSPEC',
|
||||
'PATHEXT',
|
||||
'USERPROFILE',
|
||||
'USERNAME',
|
||||
'HOMEDRIVE',
|
||||
'HOMEPATH',
|
||||
'APPDATA',
|
||||
'LOCALAPPDATA',
|
||||
'PROGRAMDATA',
|
||||
'PROGRAMFILES',
|
||||
'PROGRAMFILES(X86)',
|
||||
'COMMONPROGRAMFILES',
|
||||
'OS',
|
||||
'PROCESSOR_ARCHITECTURE',
|
||||
'NUMBER_OF_PROCESSORS'
|
||||
]
|
||||
|
||||
const BASE_ENV_NAME_SET = new Set(RECIPE_BASE_ENV_NAMES)
|
||||
|
||||
const CREDENTIAL_ENV_NAME_PATTERN =
|
||||
/(^|_)(API_?KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|PASS|CREDENTIALS?|AUTH|PAT)(_|$)/
|
||||
|
||||
const CREDENTIAL_ENV_PREFIXES = ['AWS_', 'AZURE_', 'GOOGLE_', 'GCLOUD_', 'CLOUDSDK_AUTH_']
|
||||
|
||||
// Windows env names are case-insensitive; everywhere else they are exact.
|
||||
function normalizeEnvName(name: string, platform: NodeJS.Platform): string {
|
||||
return platform === 'win32' ? name.toUpperCase() : name
|
||||
}
|
||||
|
||||
/** Names only; a positive match means "show this name in the exposure warning", never its value. */
|
||||
export function isCredentialLikeEnvName(name: string): boolean {
|
||||
const upper = name.toUpperCase()
|
||||
return (
|
||||
CREDENTIAL_ENV_NAME_PATTERN.test(upper) ||
|
||||
CREDENTIAL_ENV_PREFIXES.some((prefix) => upper.startsWith(prefix))
|
||||
)
|
||||
}
|
||||
|
||||
/** `passEnv` entries are exact names or a prefix ending in `*` (e.g. `AWS_*`). */
|
||||
export function isRecipeEnvNamePassed(
|
||||
name: string,
|
||||
passEnv: readonly string[] | undefined,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): boolean {
|
||||
if (!passEnv) {
|
||||
return true
|
||||
}
|
||||
const target = normalizeEnvName(name, platform)
|
||||
// On Windows this is case-insensitive so `Path`/`SystemRoot` survive.
|
||||
if (BASE_ENV_NAME_SET.has(target) || target.startsWith('LC_')) {
|
||||
return true
|
||||
}
|
||||
return passEnv.some((entry) => {
|
||||
const pattern = normalizeEnvName(entry, platform)
|
||||
return pattern.endsWith('*') ? target.startsWith(pattern.slice(0, -1)) : target === pattern
|
||||
})
|
||||
}
|
||||
|
||||
/** The inherited part of a recipe script's environment. */
|
||||
export function buildRecipeInheritedEnv(
|
||||
source: NodeJS.ProcessEnv,
|
||||
passEnv: readonly string[] | undefined,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): NodeJS.ProcessEnv {
|
||||
if (!passEnv) {
|
||||
return { ...source }
|
||||
}
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
for (const [name, value] of Object.entries(source)) {
|
||||
if (value !== undefined && isRecipeEnvNamePassed(name, passEnv, platform)) {
|
||||
env[name] = value
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
/** Sorted credential-like names with a non-empty value in `source`. Never returns values. */
|
||||
export function listCredentialLikeEnvNames(source: NodeJS.ProcessEnv): string[] {
|
||||
return Object.entries(source)
|
||||
.filter(([name, value]) => Boolean(value) && isCredentialLikeEnvName(name))
|
||||
.map(([name]) => name)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
|
||||
/** Parse an orca.yaml `passEnv` list. Returns null when the value is malformed. */
|
||||
export function parseRecipePassEnv(value: unknown): string[] | null {
|
||||
if (!Array.isArray(value)) {
|
||||
return null
|
||||
}
|
||||
const names: string[] = []
|
||||
for (const entry of value) {
|
||||
if (typeof entry !== 'string' || !/^[A-Za-z_][A-Za-z0-9_()]*\*?$/.test(entry.trim())) {
|
||||
return null
|
||||
}
|
||||
names.push(entry.trim())
|
||||
}
|
||||
return [...new Set(names)]
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { quoteCmdDoubleQuoted, quotePosixShell } from './shell/launch/shell-argu
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
import { StringDecoder } from 'node:string_decoder'
|
||||
import type { EphemeralVmRecipeContext } from './ephemeral-vm-recipe-runner'
|
||||
import { buildRecipeInheritedEnv } from './ephemeral-vm-recipe-env'
|
||||
import { admitProcessTreeKill } from '@orca/process-host/process-tree-kill-gate'
|
||||
import { GrowingByteBuffer } from '@orca/process-host/growing-byte-buffer'
|
||||
|
||||
@@ -33,6 +34,8 @@ export async function runRecipeCommand(args: {
|
||||
resultSchemaVersion: 1 | 2
|
||||
stdin?: string
|
||||
env?: NodeJS.ProcessEnv
|
||||
/** When set, only these (plus a safe base set) of Orca's own variables reach the script. */
|
||||
passEnv?: readonly string[]
|
||||
maxCaptureBytes?: number
|
||||
signal?: AbortSignal
|
||||
/** Wall-clock limit; on expiry the process tree is stopped like a cancel and the result has timedOut. */
|
||||
@@ -53,10 +56,11 @@ export async function runRecipeCommand(args: {
|
||||
return new Promise((resolve, reject) => {
|
||||
let child: ChildProcessWithoutNullStreams
|
||||
try {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: spawn without a stdio option always pipes stdin/stdout/stderr.
|
||||
child = spawnCommand(args.command, {
|
||||
cwd: args.repoPath,
|
||||
detached: process.platform !== 'win32',
|
||||
env: buildRecipeEnv(args.env, args.mode, args.context, args.resultSchemaVersion),
|
||||
env: buildRecipeEnv(args, args.mode, args.context, args.resultSchemaVersion),
|
||||
shell: true,
|
||||
windowsHide: true
|
||||
}) as ChildProcessWithoutNullStreams
|
||||
@@ -247,13 +251,14 @@ export function killRecipeProcess(child: ChildProcessWithoutNullStreams, force =
|
||||
}
|
||||
|
||||
function buildRecipeEnv(
|
||||
env: NodeJS.ProcessEnv | undefined,
|
||||
{ env, passEnv }: { env?: NodeJS.ProcessEnv; passEnv?: readonly string[] },
|
||||
mode: 'create' | 'suspend' | 'resume' | 'destroy',
|
||||
context: EphemeralVmRecipeContext,
|
||||
resultSchemaVersion: 1 | 2
|
||||
): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...process.env,
|
||||
// Why: without passEnv recipes keep inheriting everything; provider CLIs and git auth rely on it.
|
||||
...buildRecipeInheritedEnv(process.env, passEnv),
|
||||
...env,
|
||||
ORCA_VM_MODE: mode,
|
||||
ORCA_VM_INSTANCE_ID: context.instanceId ?? '',
|
||||
|
||||
@@ -40,6 +40,7 @@ export function runRecipeStep(
|
||||
resultSchemaVersion: getEphemeralVmRecipeResultSchemaVersion(args.recipe),
|
||||
...(stdin === undefined ? {} : { stdin }),
|
||||
env: args.env,
|
||||
...(args.recipe.passEnv ? { passEnv: args.recipe.passEnv } : {}),
|
||||
maxCaptureBytes: args.maxCaptureBytes,
|
||||
signal: args.signal,
|
||||
onSpawn: args.onSpawn,
|
||||
|
||||
@@ -25,12 +25,12 @@ export function getEphemeralVmRecipeTimeoutSeconds(
|
||||
}
|
||||
|
||||
/** Runtime records keep a recipe snapshot under a strict schema that current and older builds
|
||||
* share, so the snapshot never carries timeoutSeconds; lifecycle calls re-read the limit from
|
||||
* the repo's orca.yaml instead (see getRuntimeRecipeContext). */
|
||||
* share, so the snapshot never carries timeoutSeconds or passEnv; lifecycle calls re-read them
|
||||
* from the repo's orca.yaml instead (see getRuntimeRecipeContext). */
|
||||
export function toEphemeralVmRuntimeRecipeSnapshot(
|
||||
recipe: OrcaVmRecipe
|
||||
): Omit<OrcaVmRecipe, 'timeoutSeconds'> {
|
||||
const { timeoutSeconds: _timeoutSeconds, ...snapshot } = recipe
|
||||
): Omit<OrcaVmRecipe, 'timeoutSeconds' | 'passEnv'> {
|
||||
const { timeoutSeconds: _timeoutSeconds, passEnv: _passEnv, ...snapshot } = recipe
|
||||
return snapshot
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,9 @@ export type OrcaVmRecipe = {
|
||||
destroyDisabled?: boolean
|
||||
// Per-mode wall-clock limits; orca.yaml accepts one number for every mode or a per-mode map.
|
||||
timeoutSeconds?: OrcaVmRecipeTimeoutSeconds
|
||||
// Opt-in allowlist of Orca's environment variables the scripts receive (plus a safe base set).
|
||||
// Omitted means scripts inherit the full environment.
|
||||
passEnv?: string[]
|
||||
}
|
||||
|
||||
export type OrcaVmRecipeLifecycleMode = 'create' | 'suspend' | 'resume' | 'destroy'
|
||||
|
||||
+15
-1
@@ -13,6 +13,7 @@ import {
|
||||
MAX_ORCA_YAML_COLLECTION_ENTRIES
|
||||
} from './orca-yaml-file-limit'
|
||||
import { parseEphemeralVmRecipeTimeoutSeconds } from './ephemeral-vm-recipe-timeouts'
|
||||
import { parseRecipePassEnv } from './ephemeral-vm-recipe-env'
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
@@ -182,6 +183,18 @@ function normalizeVmRecipes(value: unknown): VmRecipeParseResult {
|
||||
message: `Recipe "${id}" ${timeouts.message} Steps keep no time limit.`
|
||||
})
|
||||
}
|
||||
const parsedPassEnv =
|
||||
record.passEnv === undefined ? undefined : parseRecipePassEnv(record.passEnv)
|
||||
if (parsedPassEnv === null) {
|
||||
// Why: a typo must not widen the environment back to everything, and dropping the recipe
|
||||
// would let a same-id plugin recipe or a stored snapshot run with the full environment.
|
||||
diagnostics.push({
|
||||
index,
|
||||
field: 'passEnv',
|
||||
message: `Recipe "${id}" passEnv must be a list of environment variable names (a trailing * matches a prefix). Scripts only receive basic variables until it is fixed.`
|
||||
})
|
||||
}
|
||||
const passEnv = parsedPassEnv === null ? [] : parsedPassEnv
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
@@ -194,7 +207,8 @@ function normalizeVmRecipes(value: unknown): VmRecipeParseResult {
|
||||
...(destroyDisabled ? { destroyDisabled: true } : {}),
|
||||
...(timeouts.ok && timeouts.timeoutSeconds
|
||||
? { timeoutSeconds: timeouts.timeoutSeconds }
|
||||
: {})
|
||||
: {}),
|
||||
...(passEnv ? { passEnv } : {})
|
||||
}
|
||||
})
|
||||
.filter((entry): entry is OrcaVmRecipe => entry !== null)
|
||||
|
||||
Reference in New Issue
Block a user