fix(setup): fail closed when setup start is unverified

This commit is contained in:
Merge Sim
2026-08-31 14:08:01 -07:00
parent e4e64551fb
commit f2f76227b6
5 changed files with 44 additions and 30 deletions
+5
View File
@@ -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`
}
+14 -12
View File
@@ -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, `'\\''`)}'`
}
@@ -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, "''")}'`
}
+17 -10
View File
@@ -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')(
+2 -3
View File
@@ -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