Fix Vercel recipe failures on stock macOS Bash (#28257)

Guard empty argument-array expansion so the bundled setup recipe runs under Bash 3.2 with unset-variable checks. Preserve exact arguments and cleanup exit behavior; normalize only test fixture line endings.

Co-authored-by: outof-place <contact@outofplace.space>
This commit is contained in:
Neil
2026-10-13 18:18:50 -07:00
committed by GitHub
co-authored by outof-place
parent 3f81541188
commit 7b31e739d2
4 changed files with 219 additions and 92 deletions
@@ -247,9 +247,11 @@ describe('bundled skill guide generator', () => {
const marker = `<!-- bundled-reference: references/${reference} -->`
expect(guide.fullMarkdown.split(marker)).toHaveLength(2)
expect(guide.fullMarkdown).toContain(
await readFile(
path.join(projectDir, 'skill-guides', guide.name, 'references', reference),
'utf8'
normalizeMarkdown(
await readFile(
path.join(projectDir, 'skill-guides', guide.name, 'references', reference),
'utf8'
)
)
)
}
@@ -289,33 +291,43 @@ describe('bundled skill guide generator', () => {
await expect(verifyArtifacts(first, projectDir)).resolves.toBeUndefined()
})
it('generates platform-identical output from CRLF guide sources', async () => {
const expected = await buildArtifacts(projectDir)
const root = await createFixture()
for (const name of CANONICAL_GUIDE_NAMES) {
const sourcePath = path.join(root, 'skill-guides', `${name}.md`)
const source = await readFile(sourcePath, 'utf8')
await writeFile(sourcePath, source.replaceAll('\n', '\r\n'))
}
for (const name of STUB_TOPICS) {
const stubPath = path.join(root, 'skill-stubs', `${name}.md`)
const stubSource = await readFile(stubPath, 'utf8')
await writeFile(stubPath, stubSource.replaceAll('\n', '\r\n'))
}
const sharedStubPath = path.join(root, ...SHARED_STUB_SOURCE.split('/'))
const sharedStubSource = await readFile(sharedStubPath, 'utf8')
await writeFile(sharedStubPath, sharedStubSource.replaceAll('\n', '\r\n'))
for (const [guide, reference] of GUIDE_REFERENCE_PATHS) {
const referencePath = path.join(root, 'skill-guides', guide, 'references', reference)
const source = await readFile(referencePath, 'utf8')
await writeFile(referencePath, source.replaceAll('\n', '\r\n'))
}
it.each([false, true])(
'generates platform-identical output from CRLF guide sources (existing CRLF: %s)',
async (alreadyCrlf) => {
const expected = await buildArtifacts(projectDir)
const root = await createFixture()
if (alreadyCrlf) {
for (const [guide, reference] of GUIDE_REFERENCE_PATHS) {
const file = path.join(root, 'skill-guides', guide, 'references', reference)
const source = normalizeMarkdown(await readFile(file, 'utf8'))
await writeFile(file, source.replaceAll('\n', '\r\n'))
}
}
for (const name of CANONICAL_GUIDE_NAMES) {
const sourcePath = path.join(root, 'skill-guides', `${name}.md`)
const source = await readFile(sourcePath, 'utf8')
await writeFile(sourcePath, normalizeMarkdown(source).replaceAll('\n', '\r\n'))
}
for (const name of STUB_TOPICS) {
const stubPath = path.join(root, 'skill-stubs', `${name}.md`)
const stubSource = await readFile(stubPath, 'utf8')
await writeFile(stubPath, normalizeMarkdown(stubSource).replaceAll('\n', '\r\n'))
}
const sharedStubPath = path.join(root, ...SHARED_STUB_SOURCE.split('/'))
const sharedStubSource = await readFile(sharedStubPath, 'utf8')
await writeFile(sharedStubPath, normalizeMarkdown(sharedStubSource).replaceAll('\n', '\r\n'))
for (const [guide, reference] of GUIDE_REFERENCE_PATHS) {
const referencePath = path.join(root, 'skill-guides', guide, 'references', reference)
const source = await readFile(referencePath, 'utf8')
await writeFile(referencePath, normalizeMarkdown(source).replaceAll('\n', '\r\n'))
}
const actual = await buildArtifacts(root)
expect(actual.map((artifact) => artifact.content)).toEqual(
expected.map((artifact) => artifact.content)
)
})
const actual = await buildArtifacts(root)
expect(actual.map((artifact) => artifact.content)).toEqual(
expected.map((artifact) => artifact.content)
)
}
)
it('pins guide sources, projections, and embedded output to LF in Git', async () => {
const attributes = await readFile(path.join(projectDir, '.gitattributes'), 'utf8')
+160 -48
View File
@@ -1,6 +1,8 @@
import { execFile } from 'node:child_process'
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { existsSync } from 'node:fs'
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { delimiter, join, resolve } from 'node:path'
import { promisify } from 'node:util'
import { describe, expect, it } from 'vitest'
@@ -13,9 +15,9 @@ const vercel = await readFile(resolve(referenceRoot, 'provider-vercel.md'), 'utf
const ssh = await readFile(resolve(referenceRoot, 'ssh-host.md'), 'utf8')
const cleanup = vercel.match(/```bash\n(cleanup_snapshot\(\) \{[\s\S]*?\n\})\n```/u)?.[1]
async function runShell(script, env = {}) {
async function executeShell(shell, script, env = {}) {
try {
const output = await run('bash', ['-c', script], {
const output = await run(shell, ['-c', script], {
env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1', ...env }
})
return { ...output, code: 0 }
@@ -24,56 +26,114 @@ async function runShell(script, env = {}) {
}
}
describe.skipIf(process.platform === 'win32')('recipe shell examples', () => {
it.each(['base', 'auth'])('cleans the %s sandbox on failure and success', async (phase) => {
expect(cleanup).toBeDefined()
const trap = vercel.match(new RegExp(`trap 'cleanup_snapshot "\\$${phase}"' EXIT`, 'u'))?.[0]
expect(trap).toBeDefined()
expect(vercel.indexOf(trap)).toBeLessThan(
vercel.indexOf(`vercel sandbox create --name "$${phase}"`)
)
for (const exitCode of [0, 7]) {
const result = await runShell(`set -euo pipefail
// Stock macOS Bash must run even when PATH starts with a newer Bash.
const shells = existsSync('/bin/bash') ? ['/bin/bash', 'bash'] : ['bash']
describe.skipIf(process.platform === 'win32').each(shells)(
'recipe shell examples (%s)',
(shell) => {
const runShell = (script, env) => executeShell(shell, script, env)
it.each(['base', 'auth'])('cleans the %s sandbox on failure and success', async (phase) => {
expect(cleanup).toBeDefined()
const trap = vercel.match(new RegExp(`trap 'cleanup_snapshot "\\$${phase}"' EXIT`, 'u'))?.[0]
expect(trap).toBeDefined()
expect(vercel.indexOf(trap)).toBeLessThan(
vercel.indexOf(`vercel sandbox create --name "$${phase}"`)
)
for (const exitCode of [0, 7]) {
const result = await runShell(`set -euo pipefail
${cleanup}
vercel_args=(--scope test-scope)
${phase}=unique-test-sandbox
vercel() { printf '%s\\n' "$@"; }
${trap}
exit ${exitCode}`)
expect(result.code).toBe(exitCode)
expect(result.stderr).toBe('sandbox\nremove\nunique-test-sandbox\n--scope\ntest-scope\n')
}
})
expect(result.code).toBe(exitCode)
expect(result.stderr).toBe('sandbox\nremove\nunique-test-sandbox\n--scope\ntest-scope\n')
}
})
it('reports failed cleanup even after an otherwise successful snapshot', async () => {
const result = await runShell(`set -euo pipefail
it('reports failed cleanup even after an otherwise successful snapshot', async () => {
const result = await runShell(`set -euo pipefail
${cleanup}
vercel_args=()
vercel() { return 9; }
trap 'cleanup_snapshot unique-test-sandbox' EXIT
exit 0`)
expect(result.code).toBe(1)
expect(result.stderr).toContain('Sandbox cleanup failed for unique-test-sandbox')
})
expect(result.code).toBe(1)
expect(result.stderr).toContain('Sandbox cleanup failed for unique-test-sandbox')
})
it('disables Git prompts when the Vercel token is absent', async () => {
const prefix = vercel.match(
/-- bash -lc 'set -euo pipefail; cd "\$ORCA_PROJECT_ROOT"; \\\n([\s\S]*?) git fetch/u
)?.[1]
expect(prefix).toBeDefined()
const result = await runShell(
`set -euo pipefail\nunset GH_TOKEN\n${prefix}\nprintf '%s' "$GIT_TERMINAL_PROMPT"`
it.each([
{ scope: '', project: '' },
{ scope: 'scope with spaces *', project: '' },
{ scope: '', project: 'project with spaces ?' },
{ scope: 'scope with spaces *', project: 'project with spaces ?' }
])(
'runs the full create recipe with scope "$scope" and project "$project"',
async ({ scope, project }) => {
const result = await runCreateRecipe(shell, {
VERCEL_SCOPE: scope,
VERCEL_PROJECT: project
})
expect(result.code).toBe(0)
const output = JSON.parse(result.stdout)
expect(output.userData).toMatchObject({
provider: 'vercel-sandbox',
resourceId: 'orca-fixture-123',
snapshotId: 'snap_fixture'
})
expect(result.calls.map((args) => args.slice(0, 2))).toEqual([
['sandbox', 'create'],
['sandbox', 'exec'],
['sandbox', 'exec']
])
const expectedFlags = [
...(scope ? ['--scope', scope] : []),
...(project ? ['--project', project] : [])
]
for (const args of result.calls) {
const start = args.findIndex((arg) => arg === '--scope' || arg === '--project')
if (expectedFlags.length) {
expect(args.slice(start, start + expectedFlags.length)).toEqual(expectedFlags)
} else {
expect(start).toBe(-1)
}
expect(args).not.toContain('')
}
}
)
expect(result.code).toBe(0)
expect(result.stdout).toBe('0')
})
it('uses host credentials and refuses unverified SSH hosts without forwarding tokens', async () => {
const script = ssh.match(/```bash\n(#!\/usr\/bin\/env bash[\s\S]*?)\n```/u)?.[1]
expect(script).toBeDefined()
const sync = script.slice(0, script.indexOf('# 2. print'))
const result = await runShell(
`ssh() { printf '%s\\n' "$@"; }
it('removes a failed create with no optional flags', async () => {
const result = await runCreateRecipe(shell, { VERCEL_CREATE_EXIT: '7' })
expect(result.code).toBe(7)
expect(result.calls.map((args) => args.slice(0, 2))).toEqual([
['sandbox', 'create'],
['sandbox', 'remove']
])
expect(result.calls[1]).toEqual(['sandbox', 'remove', 'orca-fixture-123'])
expect(result.stderr).not.toContain('unbound variable')
})
it('disables Git prompts when the Vercel token is absent', async () => {
const prefix = vercel.match(
/-- bash -lc 'set -euo pipefail; cd "\$ORCA_PROJECT_ROOT"; \\\n([\s\S]*?) git fetch/u
)?.[1]
expect(prefix).toBeDefined()
const result = await runShell(
`set -euo pipefail\nunset GH_TOKEN\n${prefix}\nprintf '%s' "$GIT_TERMINAL_PROMPT"`
)
expect(result.code).toBe(0)
expect(result.stdout).toBe('0')
})
it('uses host credentials and refuses unverified SSH hosts without forwarding tokens', async () => {
const script = ssh.match(/```bash\n(#!\/usr\/bin\/env bash[\s\S]*?)\n```/u)?.[1]
expect(script).toBeDefined()
const sync = script.slice(0, script.indexOf('# 2. print'))
const result = await runShell(
`ssh() { printf '%s\\n' "$@"; }
ssh_username=worker
host=example.test
ssh_port=2222
@@ -81,13 +141,65 @@ project_root='/remote/path with spaces'
repo_url=https://example.test/org/repo.git
repo_ref=main
${sync}`,
{ GH_TOKEN: 'test-token-must-not-be-forwarded' }
{ GH_TOKEN: 'test-token-must-not-be-forwarded' }
)
expect(result.code).toBe(0)
expect(result.stderr).toContain('StrictHostKeyChecking=yes')
expect(result.stderr).toContain('BatchMode=yes')
expect(result.stderr).not.toContain('test-token-must-not-be-forwarded')
expect(result.stderr).not.toContain('GH_TOKEN=')
expect(script).toContain('export GIT_TERMINAL_PROMPT=0')
})
}
)
async function runCreateRecipe(shell, overrides) {
const script = vercel.match(/```bash\n(#!\/usr\/bin\/env bash[\s\S]*?)\n```/u)?.[1]
expect(script).toBeDefined()
const directory = await mkdtemp(join(tmpdir(), 'orca-vercel-recipe-'))
const bin = join(directory, 'bin')
const log = join(directory, 'calls.jsonl')
try {
await mkdir(bin)
const command = join(bin, 'vercel')
await writeFile(
command,
`#!/usr/bin/env node
const { appendFileSync } = require('node:fs')
const args = process.argv.slice(2)
appendFileSync(process.env.VERCEL_FIXTURE_LOG, JSON.stringify(args) + '\\n')
if (args[1] === 'create') {
if (process.env.VERCEL_CREATE_EXIT) process.exit(Number(process.env.VERCEL_CREATE_EXIT))
console.log('https://fixture.vercel.run')
} else if (args[1] === 'exec' && args.includes('60s')) {
console.log(JSON.stringify({ userData: {} }))
}
`
)
expect(result.code).toBe(0)
expect(result.stderr).toContain('StrictHostKeyChecking=yes')
expect(result.stderr).toContain('BatchMode=yes')
expect(result.stderr).not.toContain('test-token-must-not-be-forwarded')
expect(result.stderr).not.toContain('GH_TOKEN=')
expect(script).toContain('export GIT_TERMINAL_PROMPT=0')
})
})
await chmod(command, 0o755)
const result = await executeShell(
shell,
`scope="$VERCEL_SCOPE"; project="$VERCEL_PROJECT"
snapshot_id=snap_fixture; port=6768; repo_url=https://example.test/repo.git; repo_ref=main; project_root='/fixture repo'
${script}`,
{
PATH: `${bin}${delimiter}${process.env.PATH}`,
VERCEL_FIXTURE_LOG: log,
VERCEL_SCOPE: '',
VERCEL_PROJECT: '',
VERCEL_CREATE_EXIT: '',
GH_TOKEN: 'fixture-token',
ORCA_RECIPE_ID: 'fixture',
ORCA_VM_INSTANCE_ID: '123',
...overrides
}
)
const calls = (await readFile(log, 'utf8'))
.trim()
.split('\n')
.map((line) => JSON.parse(line))
return { ...result, calls }
} finally {
await rm(directory, { recursive: true, force: true })
}
}
@@ -14,11 +14,14 @@ The base and auth excerpts each belong to one `set -euo pipefail` script. Includ
in both scripts and arm the trap before creating their temporary sandbox. Keep it armed through
verification, snapshot creation, and writing state; cleanup failure must remain visible.
Use the guarded array expansion below so an empty `vercel_args` array works under `set -u`
on stock macOS Bash 3.2 while preserving each configured argument.
```bash
cleanup_snapshot() {
snapshot_exit=$?
trap - EXIT
if ! vercel sandbox remove "$1" "${vercel_args[@]}" >&2; then
if ! vercel sandbox remove "$1" ${vercel_args[@]+"${vercel_args[@]}"} >&2; then
echo "Sandbox cleanup failed for $1; inspect and remove it before continuing" >&2
snapshot_exit=1
fi
@@ -36,14 +39,14 @@ Provision, install tools and clone, build headless, then snapshot.
# provision a fresh build sandbox (retain a couple of snapshots)
trap 'cleanup_snapshot "$base"' EXIT
vercel sandbox create --name "$base" --runtime node24 --timeout 30m --vcpus 4 --publish-port "$port" \
--snapshot-expiration 30d --keep-last-snapshots 2 "${vercel_args[@]}" >&2
--snapshot-expiration 30d --keep-last-snapshots 2 ${vercel_args[@]+"${vercel_args[@]}"} >&2
# remote build (long timeout): install pkgs+gh+pnpm+agent CLI, clone with GIT_ASKPASS (the helper's
# \$1/\$GH_TOKEN escaping is load-bearing — see the guide's Credentials section — then
# `rm -f /tmp/askpass.sh`), write the headless main-only build config (drop the renderer), dev setup,
# build CLI + headless main, smoke-check
vercel sandbox exec "$base" "${vercel_args[@]}" --timeout 25m --env "GH_TOKEN=$gh_token" … -- bash -lc '…build…' >&2
vercel sandbox exec "$base" ${vercel_args[@]+"${vercel_args[@]}"} --timeout 25m --env "GH_TOKEN=$gh_token" … -- bash -lc '…build…' >&2
# snapshot the STOPPED sandbox and parse the id from CLI output (fail if unparseable)
out="$(vercel sandbox snapshot "$base" --stop --expiration 30d "${vercel_args[@]}" 2>&1)"; printf '%s\n' "$out" >&2
out="$(vercel sandbox snapshot "$base" --stop --expiration 30d ${vercel_args[@]+"${vercel_args[@]}"} 2>&1)"; printf '%s\n' "$out" >&2
snapshot_id="$(printf '%s\n' "$out" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\1/p' | tail -1)"
[ -n "$snapshot_id" ] || { echo "snapshot id missing" >&2; exit 1; }
# merge { baseName, snapshotId, scope, project, port, repoUrl, repoRef, projectRoot } into state; print state JSON
@@ -56,16 +59,16 @@ substitute the user's chosen agent's login and status verbs.
```bash
trap 'cleanup_snapshot "$auth"' EXIT
vercel sandbox create --name "$auth" --snapshot "$snapshot_id" --timeout 30m --publish-port "$port" "${vercel_args[@]}" >&2
vercel sandbox create --name "$auth" --snapshot "$snapshot_id" --timeout 30m --publish-port "$port" ${vercel_args[@]+"${vercel_args[@]}"} >&2
# The USER runs this in their own terminal and completes the URL/code on the HOST.
vercel sandbox exec --interactive --tty "$auth" "${vercel_args[@]}" -- bash -lc 'codex login --device-auth'
vercel sandbox exec --interactive --tty "$auth" ${vercel_args[@]+"${vercel_args[@]}"} -- bash -lc 'codex login --device-auth'
```
Verify by exit code. The remote command prints a sentinel instead of relying on the exit code,
because a provider CLI may not propagate remote exit codes:
```bash
verdict="$(vercel sandbox exec "$auth" "${vercel_args[@]}" --timeout 30s \
verdict="$(vercel sandbox exec "$auth" ${vercel_args[@]+"${vercel_args[@]}"} --timeout 30s \
-- bash -lc 'if codex login status >/dev/null 2>&1; then echo ORCA_AGENT_LOGGED_IN; else echo ORCA_AGENT_LOGGED_OUT; fi')"
case "$verdict" in
*ORCA_AGENT_LOGGED_IN*) ;;
@@ -78,7 +81,7 @@ stderr folded in and match the agent's exact success line. Match a variable, not
provider process cannot take SIGPIPE:
```bash
status="$(vercel sandbox exec "$auth" "${vercel_args[@]}" --timeout 30s -- bash -lc 'codex login status 2>&1')"
status="$(vercel sandbox exec "$auth" ${vercel_args[@]+"${vercel_args[@]}"} --timeout 30s -- bash -lc 'codex login status 2>&1')"
grep -Eq 'Logged in using ChatGPT|Logged in via device' <<<"$status" \
|| { echo "agent not logged in; not snapshotting" >&2; exit 1; }
```
@@ -86,7 +89,7 @@ grep -Eq 'Logged in using ChatGPT|Logged in via device' <<<"$status" \
Then re-snapshot and record the new id:
```bash
out="$(vercel sandbox snapshot "$auth" --stop --expiration 30d "${vercel_args[@]}" 2>&1)"; printf '%s\n' "$out" >&2
out="$(vercel sandbox snapshot "$auth" --stop --expiration 30d ${vercel_args[@]+"${vercel_args[@]}"} 2>&1)"; printf '%s\n' "$out" >&2
new_id="$(printf '%s\n' "$out" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\1/p' | tail -1)"
[ -n "$new_id" ] || { echo "authenticated snapshot id missing" >&2; exit 1; }
# overwrite state.snapshotId = new_id, record authSourceSnapshotId = snapshot_id; remove the auth sandbox
@@ -109,19 +112,19 @@ max_recipe_id_length=$((128 - ${#instance_id} - 6)) # Preserve the unique insta
name="orca-${recipe_id:0:max_recipe_id_length}-${instance_id}"
# Arm cleanup BEFORE create so a failing create can't leak a half-built paid sandbox.
cleanup_on_error() { [ "$?" -ne 0 ] && vercel sandbox remove "$name" "${vercel_args[@]}" >/dev/null 2>&1 || true; }
cleanup_on_error() { [ "$?" -ne 0 ] && vercel sandbox remove "$name" ${vercel_args[@]+"${vercel_args[@]}"} >/dev/null 2>&1 || true; }
trap cleanup_on_error EXIT
# 1. boot from the authenticated snapshot, publish the serve port
create_output="$(vercel sandbox create --name "$name" --snapshot "$snapshot_id" \
--timeout 30m --publish-port "$port" "${vercel_args[@]}" 2>&1)"; printf '%s\n' "$create_output" >&2
--timeout 30m --publish-port "$port" ${vercel_args[@]+"${vercel_args[@]}"} 2>&1)"; printf '%s\n' "$create_output" >&2
# Vercel prints the published https URL; derive the external wss:// pairing address from it
public_url="$(printf '%s\n' "$create_output" | sed -nE 's#.*(https://[^[:space:]]+\.vercel\.run).*#\1#p' | head -1)"
[ -n "$public_url" ] || { echo "no published URL in create output" >&2; exit 1; }
pairing_ws="${public_url/https:\/\//wss://}"
# 2. (remote) ensure the repo is at the right commit; rebuild only if the commit changed (cache marker)
vercel sandbox exec "$name" "${vercel_args[@]}" --timeout 20m \
vercel sandbox exec "$name" ${vercel_args[@]+"${vercel_args[@]}"} --timeout 20m \
--env "GH_TOKEN=$gh_token" --env "ORCA_PROJECT_ROOT=$project_root" \
--env "ORCA_REPO_URL=$repo_url" --env "ORCA_REPO_REF=$repo_ref" \
-- bash -lc 'set -euo pipefail; cd "$ORCA_PROJECT_ROOT"; \
@@ -139,7 +142,7 @@ vercel sandbox exec "$name" "${vercel_args[@]}" --timeout 20m \
printf "%s" "$c" > .orca-built; }' >&2
# 3. (remote) start orca serve in the background, writing recipe JSON to a file; poll until it parses
recipe_json="$(vercel sandbox exec "$name" "${vercel_args[@]}" --timeout 60s \
recipe_json="$(vercel sandbox exec "$name" ${vercel_args[@]+"${vercel_args[@]}"} --timeout 60s \
--env "ORCA_PORT=$port" --env "ORCA_PROJECT_ROOT=$project_root" --env "ORCA_PAIRING_ADDRESS=$pairing_ws" \
-- bash -lc 'set -euo pipefail; cd "$ORCA_PROJECT_ROOT"; rm -f /tmp/orca-recipe.json /tmp/orca-serve.log; \
nohup pnpm exec orca-dev serve --port "$ORCA_PORT" --project-root "$ORCA_PROJECT_ROOT" \
File diff suppressed because one or more lines are too long