diff --git a/src/shared/setup-agent-sequencing-env.ts b/src/shared/setup-agent-sequencing-env.ts index 90506fd9221..61320b6fd47 100644 --- a/src/shared/setup-agent-sequencing-env.ts +++ b/src/shared/setup-agent-sequencing-env.ts @@ -4,3 +4,8 @@ export const SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV = 'ORCA_SEQUENCED_STARTUP_COMMAND' export const SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV = 'ORCA_SEQUENCED_STARTUP_SCRIPT' export const SETUP_AGENT_SEQUENCE_SETUP_SCRIPT_ENV = 'ORCA_SEQUENCED_SETUP_SCRIPT' + +/** Sentinel the setup half writes before running, shared by both platform gates. */ +export function setupStartedPath(markerPath: string): string { + return `${markerPath}.started` +} diff --git a/src/shared/setup-agent-sequencing-posix-gate.ts b/src/shared/setup-agent-sequencing-posix-gate.ts index 362cbdbbdc4..3503a15e4c4 100644 --- a/src/shared/setup-agent-sequencing-posix-gate.ts +++ b/src/shared/setup-agent-sequencing-posix-gate.ts @@ -1,11 +1,11 @@ -import { SETUP_AGENT_SEQUENCE_SETUP_SCRIPT_ENV, SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from './setup-agent-sequencing-env' +import { + SETUP_AGENT_SEQUENCE_SETUP_SCRIPT_ENV, + SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV, + setupStartedPath +} from './setup-agent-sequencing-env' /** Shell fragments for the POSIX half of the setup-to-agent gate: the setup launch that records * an outcome, and the agent launch that waits for one. */ -export function setupStartedPath(markerPath: string): string { - return `${markerPath}.started` -} - export function buildPosixSetupCommand(bareRunnerCommand: string): string { const script = [ `if [ -n "\${${SETUP_AGENT_SEQUENCE_SETUP_SCRIPT_ENV}:-}" ]; then`, @@ -17,7 +17,11 @@ export function buildPosixSetupCommand(bareRunnerCommand: string): string { return `bash -lc ${quotePosixArg(script)}` } -export function buildPosixSetupScript(setupCommand: string, markerPath: string, nonce: string): string { +export function buildPosixSetupScript( + setupCommand: string, + markerPath: string, + nonce: string +): string { const marker = quotePosixArg(markerPath) const tmp = quotePosixArg(`${markerPath}.tmp`) const started = quotePosixArg(setupStartedPath(markerPath)) @@ -79,11 +83,10 @@ export function buildPosixStartupScript( 'echo "Setup started; waiting for it to finish." >&2;', 'fi;', 'if [ "$setup_started" = "0" ] && [ "$SECONDS" -ge "$start_deadline" ]; then', - // Why: nothing reported starting, so we cannot claim setup failed — only that this terminal - // has no way to observe it. Starting the agent unsequenced beats a terminal that waits out - // the whole bound for an outcome no one is going to record. - `echo "Setup never reported starting within ${grace}s, so this terminal cannot tell whether it ran. Starting the agent without waiting for setup." >&2;`, - `${launchAgent}`, + // Why: no start evidence is not success evidence. Stop here rather than allowing an + // unsequenced agent to run against an environment whose setup outcome is unknown. + `echo "Setup never reported starting within ${grace}s; the agent was not started because setup could not be verified. Open the Setup tab and retry once setup is running." >&2;`, + 'exit 125;', 'fi;', 'if [ "$SECONDS" -ge "$deadline" ]; then', `echo "Timed out waiting for setup before starting agent. Waited ${timeout}s without a result; the agent was not started. Open the Setup tab for its output." >&2;`, @@ -154,4 +157,3 @@ function quotePosixArg(value: string): string { } return `'${value.replace(/'/g, `'\\''`)}'` } - diff --git a/src/shared/setup-agent-sequencing-windows-gate.ts b/src/shared/setup-agent-sequencing-windows-gate.ts index cc9d08d6d29..e4c09aacc43 100644 --- a/src/shared/setup-agent-sequencing-windows-gate.ts +++ b/src/shared/setup-agent-sequencing-windows-gate.ts @@ -1,6 +1,8 @@ import { encodePowerShellCommand } from './powershell-command-encoding' -import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from './setup-agent-sequencing-env' -import { setupStartedPath } from './setup-agent-sequencing-posix-gate' +import { + SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV, + setupStartedPath +} from './setup-agent-sequencing-env' /** Shell fragments for the native-Windows half of the setup-to-agent gate. Both sides run under * PowerShell so the marker write and the bounded poll stay parseable without a batch label loop. */ @@ -97,8 +99,8 @@ export function buildWindowsStartupCommand( ' [Console]::Error.WriteLine("Setup started; waiting for it to finish.")', ' }', ' if (-not $setupStarted -and (Get-Date) -ge $startDeadline) {', - ` [Console]::Error.WriteLine("Setup never reported starting within ${grace}s, so this terminal cannot tell whether it ran. Starting the agent without waiting for setup.")`, - ' & $launchAgent', + ` [Console]::Error.WriteLine("Setup never reported starting within ${grace}s; the agent was not started because setup could not be verified. Open the Setup tab and retry once setup is running.")`, + ' exit 125', ' }', ' if ((Get-Date) -ge $deadline) {', ` [Console]::Error.WriteLine("Timed out waiting for setup before starting agent. Waited ${timeout}s without a result; the agent was not started. Open the Setup tab for its output.")`, @@ -123,4 +125,3 @@ function encodePowerShellInvocation(script: string): string { function quotePowerShellString(value: string): string { return `'${value.replace(/'/g, "''")}'` } - diff --git a/src/shared/setup-agent-sequencing.test.ts b/src/shared/setup-agent-sequencing.test.ts index eced2d6b0cd..176a96341ec 100644 --- a/src/shared/setup-agent-sequencing.test.ts +++ b/src/shared/setup-agent-sequencing.test.ts @@ -776,7 +776,7 @@ describe('setup outcome recording', () => { ) it.skipIf(process.platform === 'win32')( - 'starts the agent unsequenced when setup is never started at all', + 'fails closed when setup is never started at all', async () => { const tempDir = makeTempDir() const runnerScriptPath = join(tempDir, 'setup-runner.sh') @@ -801,10 +801,13 @@ describe('setup outcome recording', () => { }) ) - expect(startupExit.code).toBe(0) - expect(startupExit.stderr).toContain('Setup never reported starting within 1s') - expect(readFileSync(logPath, 'utf8')).toBe('agent-start\n') - } + expect(startupExit.code).toBe(125) + expect(startupExit.stderr).toContain( + 'Setup never reported starting within 1s; the agent was not started' + ) + expect(readIfExists(logPath)).toBe('') + }, + 15_000 ) it.skipIf(process.platform === 'win32')( @@ -836,7 +839,8 @@ describe('setup outcome recording', () => { }) ) // The wrapped launch record was dropped on the way to the setup terminal, so the gated - // script is absent from its env; the fallback must still run setup. + // script is absent from its env. Setup may still run, but the agent must fail closed because + // no authoritative setup result was recorded. const setupExit = await waitForExit( spawn('bash', ['-lc', commands.setupCommand], { stdio: 'pipe', env: { ...process.env } }) ) @@ -844,10 +848,13 @@ describe('setup outcome recording', () => { expect(setupExit.code).toBe(0) expect(readFileSync(setupLogPath, 'utf8')).toBe('setup-ran\n') - expect(startupExit.code).toBe(0) - expect(startupExit.stderr).toContain('Setup never reported starting within 2s') - expect(readFileSync(logPath, 'utf8')).toBe('agent-start\n') - } + expect(startupExit.code).toBe(125) + expect(startupExit.stderr).toContain( + 'Setup never reported starting within 2s; the agent was not started' + ) + expect(readIfExists(logPath)).toBe('') + }, + 15_000 ) it.skipIf(process.platform === 'win32')( diff --git a/src/shared/setup-agent-sequencing.ts b/src/shared/setup-agent-sequencing.ts index 064464ae7a9..7e0250fd8af 100644 --- a/src/shared/setup-agent-sequencing.ts +++ b/src/shared/setup-agent-sequencing.ts @@ -29,9 +29,8 @@ import { const DEFAULT_WAIT_TIMEOUT_SECONDS = 30 * 60 // Why: the setup terminal is spawned in the same host operation as the agent terminal and writes // its start sentinel before running a single line of the script, so anything past a slow shell -// profile plus an SSH round trip means nobody is going to run setup at all. Expiring does not -// fail the launch — it starts the agent unsequenced and says so — so a false positive costs a -// warning line, never a dead terminal. +// profile plus an SSH round trip means nobody is going to run setup at all. Expiring fails closed +// with a clear retry message; an unknown setup outcome must never authorize an agent launch. const SETUP_START_GRACE_SECONDS = 45 // Why: a silent terminal reads as a hang, so the wait reports itself on a human interval. const WAIT_PROGRESS_INTERVAL_SECONDS = 15