fix: skip interactive session in headless mode (#2895)

* fix: skip interactive session in headless mode (#2892)

When SPAWN_HEADLESS=1, the orchestrator now exits with code 0 after
provisioning completes instead of attempting to launch the agent
interactively. This fixes Claude Code (and other agents) failing with
"Input must be provided through stdin or --prompt" when spawned via
`--headless --output json` without a prompt.

The VM is fully provisioned and ready — callers can SSH in or use
`spawn connect` to start the agent manually.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: clean up SPAWN_HEADLESS env in test afterEach to prevent leaks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
This commit is contained in:
A 2026-03-22 21:38:53 -07:00 committed by GitHub
parent 9280489ada
commit f1f2667cb0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 61 additions and 5 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.25.14",
"version": "0.25.15",
"type": "module",
"bin": {
"spawn": "cli.js"

View file

@ -89,6 +89,7 @@ beforeEach(() => {
delete process.env.SPAWN_ENABLED_STEPS;
delete process.env.SPAWN_BETA;
delete process.env.MODEL_ID;
delete process.env.SPAWN_HEADLESS;
stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true);
exitSpy = spyOn(process, "exit").mockImplementation((code) => {
throw new Error(`__EXIT_${isNumber(code) ? code : 0}__`);

View file

@ -114,6 +114,7 @@ describe("runOrchestration", () => {
// Ensure no stale env leaks between tests
delete process.env.SPAWN_ENABLED_STEPS;
delete process.env.SPAWN_BETA;
delete process.env.SPAWN_HEADLESS;
stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true);
exitSpy = spyOn(process, "exit").mockImplementation((code) => {
capturedExitCode = isNumber(code) ? code : 0;
@ -131,6 +132,7 @@ describe("runOrchestration", () => {
} else {
delete process.env.SPAWN_HOME;
}
delete process.env.SPAWN_HEADLESS;
tryCatch(() =>
rmSync(testDir, {
recursive: true,
@ -873,4 +875,45 @@ describe("runOrchestration", () => {
exitSpy.mockRestore();
});
});
describe("headless mode", () => {
it("skips interactive session and exits 0 when SPAWN_HEADLESS=1", async () => {
process.env.SPAWN_HEADLESS = "1";
const cloud = createMockCloud();
const agent = createMockAgent();
await runOrchestrationSafe(cloud, agent, "testagent");
// Provisioning steps should still run
expect(cloud.authenticate).toHaveBeenCalledTimes(1);
expect(cloud.createServer).toHaveBeenCalledTimes(1);
expect(cloud.waitForReady).toHaveBeenCalledTimes(1);
expect(agent.install).toHaveBeenCalledTimes(1);
// Interactive session should NOT be called
expect(cloud.interactiveSession).toHaveBeenCalledTimes(0);
// Should exit with code 0
expect(capturedExitCode).toBe(0);
stderrSpy.mockRestore();
exitSpy.mockRestore();
});
it("still saves launch command in headless mode", async () => {
process.env.SPAWN_HEADLESS = "1";
const cloud = createMockCloud();
const agent = createMockAgent({
launchCmd: mock(() => "claude --print"),
});
await runOrchestrationSafe(cloud, agent, "testagent");
// launchCmd should be called (to save it for later `spawn connect`)
expect(agent.launchCmd).toHaveBeenCalledTimes(1);
expect(cloud.interactiveSession).toHaveBeenCalledTimes(0);
expect(capturedExitCode).toBe(0);
stderrSpy.mockRestore();
exitSpy.mockRestore();
});
});
});

View file

@ -531,18 +531,30 @@ async function postInstall(
logInfo(`Tip: ${agent.preLaunchMsg}`);
}
// Launch interactive session
// Launch agent
logInfo(`${agent.name} is ready`);
process.stderr.write("\n");
logInfo(`${cloud.cloudLabel} setup completed successfully!`);
process.stderr.write("\n");
logStep("Starting agent...");
prepareStdinForHandoff();
const launchCmd = agent.launchCmd();
saveLaunchCmd(launchCmd, spawnId);
// In headless mode, provisioning is done — skip the interactive session.
// The VM is healthy and the agent is installed; callers can SSH in or use `spawn connect`.
const isHeadless = process.env.SPAWN_HEADLESS === "1";
if (isHeadless) {
logInfo("Headless mode — provisioning complete. Skipping interactive session.");
if (tunnelHandle) {
tunnelHandle.stop();
}
process.exit(0);
}
logStep("Starting agent...");
prepareStdinForHandoff();
const sessionCmd = cloud.cloudName === "local" ? launchCmd : wrapWithRestartLoop(launchCmd);
// Auto-reconnect on connection drops. Ctrl+C (exit 0 or 130) exits immediately.