Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.15.35",
"version": "0.15.36",
"type": "module",
"bin": {
"spawn": "cli.js"
Expand Down
282 changes: 282 additions & 0 deletions packages/cli/src/__tests__/sprite-keep-alive.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
/**
* sprite-keep-alive.test.ts — Tests for Sprite keep-alive integration.
*
* Verifies:
* - installSpriteKeepAlive() downloads and installs the keep-alive script
* - installSpriteKeepAlive() is gracefully non-fatal when download fails
* - interactiveSession() wraps the cmd in a session script with keep-alive support
*
* IMPORTANT: Only mock.module "../shared/ssh" here — NOT "../shared/ui" or
* "../shared/paths", as those are shared with other test files and would
* cause failures in history.test.ts, paths.test.ts, etc.
*/

import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";

// ── Mock only ../shared/ssh (not used directly by any other test file) ────────

const mockSpawnInteractive = mock((_args: string[]) => 0);
const mockKillWithTimeout = mock(() => {});
const mockSleep = mock(() => Promise.resolve());

mock.module("../shared/ssh", () => ({
spawnInteractive: mockSpawnInteractive,
killWithTimeout: mockKillWithTimeout,
sleep: mockSleep,
SSH_INTERACTIVE_OPTS: [],
}));

// ── Import module under test after mocks ──────────────────────────────────────

const { installSpriteKeepAlive, interactiveSession } = await import("../sprite/sprite");

// ── Helpers ───────────────────────────────────────────────────────────────────

/** Build a mock Bun.SubprocessResult for spawnSync. */
function makeSyncResult(exitCode: number, stdout = ""): ReturnType<typeof Bun.spawnSync> {
return {
exitCode,
stdout: new TextEncoder().encode(stdout),
stderr: new Uint8Array(),
success: exitCode === 0,
signalCode: null,
resourceUsage: undefined,
exited: exitCode,
pid: 1234,
};
}

/** Build a minimal mock subprocess for Bun.spawn. */
function makeSpawnResult(exitCode: number): {
exited: Promise<number>;
stderr: ReadableStream;
} {
return {
exited: Promise.resolve(exitCode),
stderr: new ReadableStream(),
};
}

// ── Tests: installSpriteKeepAlive ─────────────────────────────────────────────

describe("installSpriteKeepAlive", () => {
let spawnSyncSpy: ReturnType<typeof spyOn>;
let spawnSpy: ReturnType<typeof spyOn>;
let stderrSpy: ReturnType<typeof spyOn>;

beforeEach(() => {
stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true);

// Make getSpriteCmd() find "sprite" via `which sprite`
spawnSyncSpy = spyOn(Bun, "spawnSync").mockImplementation((args: string[]) => {
if (Array.isArray(args) && args[0] === "which" && args[1] === "sprite") {
return makeSyncResult(0, "sprite");
}
// sprite version call
return makeSyncResult(0, "sprite v1.0.0");
});

spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => makeSpawnResult(0));
});

afterEach(() => {
spawnSyncSpy.mockRestore();
spawnSpy.mockRestore();
stderrSpy.mockRestore();
});

it("calls runSprite with the keep-alive script URL", async () => {
const capturedCmds: string[] = [];
spawnSpy.mockImplementation((args: string[]) => {
const bashIdx = args.indexOf("bash");
if (bashIdx !== -1 && args[bashIdx + 1] === "-c") {
capturedCmds.push(args[bashIdx + 2]);
}
return makeSpawnResult(0);
});

await installSpriteKeepAlive();

expect(capturedCmds.some((cmd) => cmd.includes("kurt-claw-f.sprites.app/sprite-keep-running.sh"))).toBe(true);
expect(capturedCmds.some((cmd) => cmd.includes("sprite-keep-running"))).toBe(true);
});

it("installs to ~/.local/bin and makes script executable", async () => {
const capturedCmds: string[] = [];
spawnSpy.mockImplementation((args: string[]) => {
const bashIdx = args.indexOf("bash");
if (bashIdx !== -1 && args[bashIdx + 1] === "-c") {
capturedCmds.push(args[bashIdx + 2]);
}
return makeSpawnResult(0);
});

await installSpriteKeepAlive();

expect(capturedCmds.some((cmd) => cmd.includes(".local/bin/sprite-keep-running"))).toBe(true);
expect(capturedCmds.some((cmd) => cmd.includes("chmod +x"))).toBe(true);
});

it("does not throw when script download fails", async () => {
// Simulate runSprite throwing (process exits with code 1)
spawnSpy.mockImplementation(() => makeSpawnResult(1));

// Should resolve without throwing
await expect(installSpriteKeepAlive()).resolves.toBeUndefined();
});
});

// ── Tests: interactiveSession ─────────────────────────────────────────────────

describe("interactiveSession (keep-alive wrapper)", () => {
let spawnSyncSpy: ReturnType<typeof spyOn>;
let stderrSpy: ReturnType<typeof spyOn>;

beforeEach(() => {
mockSpawnInteractive.mockClear();
mockSpawnInteractive.mockImplementation(() => 0);
stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true);

// Make getSpriteCmd() find "sprite"
spawnSyncSpy = spyOn(Bun, "spawnSync").mockImplementation((args: string[]) => {
if (Array.isArray(args) && args[0] === "which" && args[1] === "sprite") {
return makeSyncResult(0, "sprite");
}
return makeSyncResult(0, "sprite v1.0.0");
});
});

afterEach(() => {
spawnSyncSpy.mockRestore();
stderrSpy.mockRestore();
delete process.env.SPAWN_PROMPT;
});

it("base64-encodes the original cmd in the session script", async () => {
const testCmd = "openclaw tui";
const expectedB64 = Buffer.from(testCmd).toString("base64");

let capturedSessionScript = "";
mockSpawnInteractive.mockImplementation((args: string[]) => {
const bashIdx = args.indexOf("bash");
if (bashIdx !== -1 && args[bashIdx + 1] === "-c") {
capturedSessionScript = args[bashIdx + 2];
}
return 0;
});

await interactiveSession(testCmd);

expect(capturedSessionScript).toContain(expectedB64);
});

it("includes sprite-keep-running check in session script", async () => {
let capturedSessionScript = "";
mockSpawnInteractive.mockImplementation((args: string[]) => {
const bashIdx = args.indexOf("bash");
if (bashIdx !== -1 && args[bashIdx + 1] === "-c") {
capturedSessionScript = args[bashIdx + 2];
}
return 0;
});

await interactiveSession("my-agent --start");

expect(capturedSessionScript).toContain("sprite-keep-running");
expect(capturedSessionScript).toContain("command -v sprite-keep-running");
});

it("creates a temp file for the session script", async () => {
let capturedSessionScript = "";
mockSpawnInteractive.mockImplementation((args: string[]) => {
const bashIdx = args.indexOf("bash");
if (bashIdx !== -1 && args[bashIdx + 1] === "-c") {
capturedSessionScript = args[bashIdx + 2];
}
return 0;
});

await interactiveSession("agent cmd");

expect(capturedSessionScript).toContain("mktemp");
expect(capturedSessionScript).toContain("base64 -d");
expect(capturedSessionScript).toContain("trap");
});

it("includes else branch for fallback to plain bash", async () => {
let capturedSessionScript = "";
mockSpawnInteractive.mockImplementation((args: string[]) => {
const bashIdx = args.indexOf("bash");
if (bashIdx !== -1 && args[bashIdx + 1] === "-c") {
capturedSessionScript = args[bashIdx + 2];
}
return 0;
});

await interactiveSession("fallback-agent");

expect(capturedSessionScript).toContain("else");
expect(capturedSessionScript).toMatch(/else[\s\S]*bash/);
});

it("handles multi-line restart loop commands (base64-encoded as single token)", async () => {
const multilineCmd = [
"_spawn_restarts=0",
"while [ $_spawn_restarts -lt 10 ]; do",
" openclaw tui",
" _spawn_exit=$?",
" _spawn_restarts=$((_spawn_restarts + 1))",
"done",
].join("\n");

const expectedB64 = Buffer.from(multilineCmd).toString("base64");
let capturedSessionScript = "";
mockSpawnInteractive.mockImplementation((args: string[]) => {
const bashIdx = args.indexOf("bash");
if (bashIdx !== -1 && args[bashIdx + 1] === "-c") {
capturedSessionScript = args[bashIdx + 2];
}
return 0;
});

await interactiveSession(multilineCmd);

expect(capturedSessionScript).toContain(expectedB64);
});

it("uses -tty flag for interactive mode (SPAWN_PROMPT not set)", async () => {
delete process.env.SPAWN_PROMPT;

let capturedArgs: string[] = [];
mockSpawnInteractive.mockImplementation((args: string[]) => {
capturedArgs = args;
return 0;
});

await interactiveSession("agent-cmd");

expect(capturedArgs).toContain("-tty");
});

it("omits -tty flag when SPAWN_PROMPT is set", async () => {
process.env.SPAWN_PROMPT = "non-interactive";

let capturedArgs: string[] = [];
mockSpawnInteractive.mockImplementation((args: string[]) => {
capturedArgs = args;
return 0;
});

await interactiveSession("agent-cmd");

expect(capturedArgs).not.toContain("-tty");
});

it("returns the exit code from spawnInteractive", async () => {
mockSpawnInteractive.mockImplementation(() => 42);

const exitCode = await interactiveSession("agent-cmd");

expect(exitCode).toBe(42);
});
});
2 changes: 2 additions & 0 deletions packages/cli/src/sprite/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
ensureSpriteCli,
getServerName,
getVmConnection,
installSpriteKeepAlive,
interactiveSession,
promptSpawnName,
runSprite,
Expand Down Expand Up @@ -48,6 +49,7 @@ async function main() {
await createSprite(name);
await verifySpriteConnectivity();
await setupShellEnvironment();
await installSpriteKeepAlive();
return getVmConnection();
},
getServerName,
Expand Down
51 changes: 49 additions & 2 deletions packages/cli/src/sprite/sprite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -541,13 +541,60 @@ export async function uploadFileSprite(localPath: string, remotePath: string): P
});
}

// ─── Keep-Alive ───────────────────────────────────────────────────────────────

/**
* Download and install sprite-keep-running on the remote sprite.
* This script wraps a command and keeps the sprite alive (via Sprite's /v1/tasks API)
* as long as the agent is running — preventing inactivity shutdown.
*
* Non-fatal: logs a warning if download fails so deployment still proceeds.
* Reference: https://kurt-claw-f.sprites.app/sprite-keep-running.sh
*/
export async function installSpriteKeepAlive(): Promise<void> {
logStep("Installing Sprite keep-alive...");
const scriptUrl = "https://kurt-claw-f.sprites.app/sprite-keep-running.sh";
try {
await runSprite(
"mkdir -p ~/.local/bin && " +
`curl -fsSL '${scriptUrl}' -o ~/.local/bin/sprite-keep-running && ` +
"chmod +x ~/.local/bin/sprite-keep-running",
60,
);
logInfo("Sprite keep-alive installed");
} catch {
logWarn("Could not install Sprite keep-alive — sprite may shut down during inactivity");
}
}

/**
* Launch an interactive session on the sprite.
* Uses -tty for interactive mode, plain exec when SPAWN_PROMPT is set.
*
* The session command is base64-encoded and written to a temp file to avoid
* quoting issues with multi-line restart loop scripts. If sprite-keep-running
* is installed, it wraps the command to keep the sprite alive via Sprite's
* /v1/tasks API for the duration of the session.
*/
export async function interactiveSession(cmd: string): Promise<number> {
const spriteCmd = getSpriteCmd()!;

// Encode the session command to handle multi-line restart loop scripts safely
const cmdB64 = Buffer.from(cmd).toString("base64");

// Write cmd to a temp file and exec with keep-alive wrapper if available
const sessionScript = [
"_f=$(mktemp /tmp/spawn_XXXXXX.sh)",
`printf '%s' '${cmdB64}' | base64 -d > "$_f"`,
'chmod +x "$_f"',
"trap 'rm -f \"$_f\"' EXIT INT TERM",
"if command -v sprite-keep-running >/dev/null 2>&1; then",
' sprite-keep-running bash "$_f"',
"else",
' bash "$_f"',
"fi",
].join("\n");

const args = process.env.SPAWN_PROMPT
? [
spriteCmd,
Expand All @@ -558,7 +605,7 @@ export async function interactiveSession(cmd: string): Promise<number> {
"--",
"bash",
"-c",
cmd,
sessionScript,
]
: [
spriteCmd,
Expand All @@ -570,7 +617,7 @@ export async function interactiveSession(cmd: string): Promise<number> {
"--",
"bash",
"-c",
cmd,
sessionScript,
];

const exitCode = spawnInteractive(args);
Expand Down