mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-04-28 03:49:31 +00:00
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:
parent
9280489ada
commit
f1f2667cb0
4 changed files with 61 additions and 5 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openrouter/spawn",
|
||||
"version": "0.25.14",
|
||||
"version": "0.25.15",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"spawn": "cli.js"
|
||||
|
|
|
|||
|
|
@ -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}__`);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue