diff --git a/cloud/apps/acceptance/src/checks/platform.ts b/cloud/apps/acceptance/src/checks/platform.ts index 473ec679cdc..ec7534abf12 100644 --- a/cloud/apps/acceptance/src/checks/platform.ts +++ b/cloud/apps/acceptance/src/checks/platform.ts @@ -230,7 +230,7 @@ export const platformChecks: Record = { ctx.run.destroyed('other', s.envId) } } - return { status: ok ? 'partial' : 'fail', detail: 'install script ran during the build (marker in env.from-build); per-repo orca.yaml setup on each new workspace runs through the desktop hook runtime (Electron run)' } + return { status: ok ? 'partial' : 'fail', detail: 'install script ran during the build (marker in env.from-build); per-repo orca.yaml setup: desktop workspaces run it in orcad's worktree create (setup prompt), API/CLI/phone/routine workspaces once on create (sandbox-control src/project-setup.ts), skipped on environment builds' } }, // ---------------- W4 ---------------- diff --git a/cloud/apps/sandbox-control/src/api/workspaces.ts b/cloud/apps/sandbox-control/src/api/workspaces.ts index 096a13585e2..324e4fb0719 100644 --- a/cloud/apps/sandbox-control/src/api/workspaces.ts +++ b/cloud/apps/sandbox-control/src/api/workspaces.ts @@ -6,6 +6,7 @@ import { getSandbox, markUserSlept, type SandboxRow } from '../store' import { archiveSandbox, unarchiveSandbox } from '../workspace-lifecycle' import { ApiError } from './caller' import { randomId } from './keys' +import { startProjectSetup } from '../project-setup' export type WorkspaceOrigin = 'desktop' | 'api' | 'mcp' | 'routine' | 'vm' @@ -169,6 +170,14 @@ export async function createWorkspace( originRef: input.originRef ?? null, hidden: input.hidden ?? false }) + // Agents here work in the primary checkout, which no worktree create sets up: run orca.yaml setup + // once. Best effort: a workspace whose setup could not start still opens (the log says why). + if (ready.row.session_id) { + const setup = await startProjectSetup(life.provider, ready.row.session_id).catch(() => 'failed' as const) + if (setup !== 'not_configured') { + console.log(JSON.stringify({ event: 'project_setup', sandboxId: ready.row.id, origin: input.origin, state: setup })) + } + } return (await getSandbox(life.db, ready.row.id)) as WorkspaceRow } diff --git a/cloud/apps/sandbox-control/src/project-setup.ts b/cloud/apps/sandbox-control/src/project-setup.ts new file mode 100644 index 00000000000..4964afa4a60 --- /dev/null +++ b/cloud/apps/sandbox-control/src/project-setup.ts @@ -0,0 +1,82 @@ +// The repo's orca.yaml `scripts.setup` for workspaces nobody opens on a desktop first (API, CLI, phone, +// routines: `createWorkspace`). Those agents work in the VM's primary checkout, so no worktree create +// ever runs the setup hook there. A desktop-created cloud workspace is a linked worktree that orcad's +// worktree create sets up after the desktop's setup prompt, so the plain create route never calls this. +// +// Runs once per workspace (a marker under ~/.orca-sandbox, which the disk snapshot keeps across +// sleep), detached so a Worker never waits out a long install, with orcad's process env (managed +// ORCA_* names and the session's secrets) like the desktop's setup terminal. Skipped when the VM was +// booted from an org environment build: its install script already installed the dependencies. +// Log and exit code: ~/.orca-sandbox/project-setup/{setup.log,exit}. +// +// No setup prompt: nobody is there to answer it, and the caller chose this repo for this sandboxed VM, +// as unattended worktree creates run setup unless the project turned it off. +import { parseOrcaYaml } from '../../../../src/shared/orca-yaml' +import { PROJECT_ROOT } from './api/vm-agent' +import type { SandboxProviderApi } from './sandbox-provider' + +/** `bash -c LAUNCHER orca-project-setup `: both are data, written to files. */ +const LAUNCHER = String.raw`set -u +umask 077 +P=${PROJECT_ROOT} +D="$HOME/.orca-sandbox/project-setup" +mkdir -p "$D" +# noclobber makes the marker an atomic claim: a retried create never starts a second run. +if ! (set -C; : >"$D/started") 2>/dev/null; then echo '{"state":"already"}'; exit 0; fi +common="$(git -C "$P" rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)" +if [ -n "$common" ] && [ -e "$common/orca-env-deps" ]; then + echo 'orca: setup skipped: the environment build already installed dependencies' >"$D/setup.log" + echo skipped >"$D/exit" + echo '{"state":"skipped_environment_build"}' + exit 0 +fi +opid="$(cat "$HOME/.orca-sandbox/orcad.pid" 2>/dev/null || true)" +if [ -z "$opid" ] || [ ! -r "/proc/$opid/environ" ]; then + rm -f "$D/started" + echo '{"state":"no_orcad"}' + exit 0 +fi +printf '%s' "$1" >"$D/setup.sh" +printf '%s' "$2" >"$D/runner.sh" +setsid nohup bash "$D/runner.sh" "$D" "$opid" >"$D/setup.log" 2>&1 "$D/pid" +echo '{"state":"started"}'` + +const RUNNER = String.raw`set -u +D="$1" opid="$2" +while IFS= read -r -d '' kv; do + case "$kv" in [A-Za-z_]*=*) export "$kv" 2>/dev/null || true ;; esac +done <"/proc/$opid/environ" +export GIT_TERMINAL_PROMPT=0 +cd ${PROJECT_ROOT} || { echo 'orca: no project checkout'; echo 2 >"$D/exit"; exit 0; } +echo "orca: running orca.yaml setup in $PWD at $(date -u +%Y-%m-%dT%H:%M:%SZ)" +code=0 +timeout --kill-after=30 3600 bash "$D/setup.sh" "$D/exit.tmp" && mv "$D/exit.tmp" "$D/exit"` + +const STATES = ['started', 'already', 'skipped_environment_build', 'no_orcad'] as const +export type ProjectSetupState = 'not_configured' | 'failed' | (typeof STATES)[number] + +/** As the desktop's setup terminal runs it: bash, stopping at the first failing line. */ +function runnerScript(script: string): string { + return `#!/usr/bin/env bash\nset -e\n${script.replace(/\r\n/g, '\n')}\n` +} + +/** Reads the checkout's orca.yaml with the desktop's parser and starts its setup script in the VM. */ +export async function startProjectSetup(provider: SandboxProviderApi, sessionId: string): Promise { + const yaml = await provider.readFile(sessionId, `${PROJECT_ROOT}/orca.yaml`) + const script = yaml ? parseOrcaYaml(yaml)?.scripts.setup?.trim() : undefined + if (!script) { + return 'not_configured' + } + const result = await provider.runCommand(sessionId, { + command: 'bash', + args: ['-c', LAUNCHER, 'orca-project-setup', runnerScript(script), RUNNER], + timeoutMs: 30_000 + }) + const state = /"state":"([a-z_]+)"/.exec(result.stdout)?.[1] + return STATES.find((s) => s === state) ?? 'failed' +} + +export const PROJECT_SETUP_SCRIPTS_FOR_TESTS = { LAUNCHER, RUNNER } diff --git a/cloud/apps/sandbox-control/src/routines/vm-runner.ts b/cloud/apps/sandbox-control/src/routines/vm-runner.ts index 03ddc518c5f..6354ba9d63d 100644 --- a/cloud/apps/sandbox-control/src/routines/vm-runner.ts +++ b/cloud/apps/sandbox-control/src/routines/vm-runner.ts @@ -75,6 +75,13 @@ cd "$P" 2>/dev/null || { code=2; note=no_project; finish; } base="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" start_sha="$(git rev-parse HEAD 2>/dev/null || true)" echo "routine: agent=$agent post=$post limit=$limit base=$base" +# The workspace's orca.yaml setup (src/project-setup.ts) installs what the agent needs: let it finish. +S="$HOME/.orca-sandbox/project-setup" +if [ -e "$S/started" ] && [ ! -e "$S/exit" ]; then + echo "routine: waiting for orca.yaml setup" + while [ ! -e "$S/exit" ] && kill -0 "$(cat "$S/pid" 2>/dev/null || echo 0)" 2>/dev/null; do sleep 2; done +fi +[ -e "$S/exit" ] && echo "routine: orca.yaml setup $(cat "$S/exit")" case "$agent" in claude) # The prompt goes in on stdin: as an argv entry, text starting with "-" would parse as a flag. diff --git a/cloud/apps/sandbox-control/test/project-setup.test.ts b/cloud/apps/sandbox-control/test/project-setup.test.ts new file mode 100644 index 00000000000..5ce149c4e64 --- /dev/null +++ b/cloud/apps/sandbox-control/test/project-setup.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import type { RunCommandParams, SandboxProviderApi } from '../src/sandbox-provider' +import { PROJECT_SETUP_SCRIPTS_FOR_TESTS, startProjectSetup } from '../src/project-setup' + +function provider(files: Record, stdout = '{"state":"started"}\n') { + const calls: RunCommandParams[] = [] + const api = { + readFile: async (_session: string, path: string) => files[path] ?? null, + runCommand: async (_session: string, params: RunCommandParams) => { + calls.push(params) + return { exitCode: 0, stdout, stderr: '' } + } + } as unknown as SandboxProviderApi + return { api, calls } +} + +describe('startProjectSetup', () => { + it('does nothing without an orca.yaml setup script', async () => { + const none = provider({}) + expect(await startProjectSetup(none.api, 's1')).toBe('not_configured') + const tabsOnly = provider({ '/vercel/sandbox/project/orca.yaml': 'defaultTabs:\n - title: x\n' }) + expect(await startProjectSetup(tabsOnly.api, 's1')).toBe('not_configured') + expect([...none.calls, ...tabsOnly.calls]).toHaveLength(0) + }) + + it('starts the parsed setup script as data, never in the shell line', async () => { + const p = provider({ '/vercel/sandbox/project/orca.yaml': 'scripts:\n setup: |\n echo "$(whoami)" > .marker\n pnpm install\n' }) + expect(await startProjectSetup(p.api, 's1')).toBe('started') + const [call] = p.calls + expect(call!.command).toBe('bash') + const [flag, launcher, name, script, runner] = call!.args! + expect([flag, name]).toEqual(['-c', 'orca-project-setup']) + expect(launcher).toBe(PROJECT_SETUP_SCRIPTS_FOR_TESTS.LAUNCHER) + expect(runner).toBe(PROJECT_SETUP_SCRIPTS_FOR_TESTS.RUNNER) + expect(launcher).not.toContain('pnpm install') + // Same runner text as the desktop's setup terminal: bash with `set -e`. + expect(script).toBe('#!/usr/bin/env bash\nset -e\necho "$(whoami)" > .marker\npnpm install\n') + }) + + it('reports the launcher state, and failed on output it does not recognize', async () => { + const yaml = { '/vercel/sandbox/project/orca.yaml': 'scripts:\n setup: make deps\n' } + expect(await startProjectSetup(provider(yaml, '{"state":"already"}\n').api, 's1')).toBe('already') + expect(await startProjectSetup(provider(yaml, '{"state":"skipped_environment_build"}\n').api, 's1')).toBe('skipped_environment_build') + expect(await startProjectSetup(provider(yaml, 'bash: oops\n').api, 's1')).toBe('failed') + }) + + it('claims the run atomically and skips environment-build checkouts', () => { + const { LAUNCHER } = PROJECT_SETUP_SCRIPTS_FOR_TESTS + expect(LAUNCHER).toContain('(set -C; : >"$D/started")') + expect(LAUNCHER).toContain('orca-env-deps') + }) +})