diff --git a/packages/cli/package.json b/packages/cli/package.json index 8cb019127..bbab5149c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.17.1", + "version": "0.17.2", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/__tests__/openclaw-config.test.ts b/packages/cli/src/__tests__/openclaw-config.test.ts new file mode 100644 index 000000000..54d625d85 --- /dev/null +++ b/packages/cli/src/__tests__/openclaw-config.test.ts @@ -0,0 +1,591 @@ +/** + * openclaw-config.test.ts — Tests for OpenClaw config generation, gateway auth + * token threading, Telegram/WhatsApp setup, and USER.md content. + * + * Verifies that: + * - The gateway auth token in openclaw.json matches the browserUrl token + * - Browser config is included atomically (no separate `openclaw config set`) + * - Telegram bot tokens are written into the config JSON + * - USER.md includes messaging channel guidance when selected + * - Chrome install is gated by enabledSteps + */ + +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { readFileSync } from "node:fs"; +import { tryCatch } from "@openrouter/spawn-shared"; +import { toRecord } from "../shared/type-guards"; +import { mockClackPrompts } from "./test-helpers"; + +// ── Mock @clack/prompts (must be before importing agent-setup) ────────── +const clack = mockClackPrompts(); + +// ── Import the module under test ──────────────────────────────────────── +const { createCloudAgents } = await import("../shared/agent-setup"); + +import type { CloudRunner } from "../shared/agent-setup"; + +// ── Helpers ───────────────────────────────────────────────────────────── + +/** Tracks all commands and uploads sent to the mock runner. */ +interface RunnerCapture { + runner: CloudRunner; + commands: string[]; + /** Contents of files uploaded via runner.uploadFile, read at upload time. */ + uploadedContents: string[]; +} + +function createCapturingRunner(): RunnerCapture { + const commands: string[] = []; + const uploadedContents: string[] = []; + + const runner: CloudRunner = { + runServer: mock(async (cmd: string) => { + commands.push(cmd); + }), + uploadFile: mock(async (localPath: string, _remotePath: string) => { + // Read the file content immediately — uploadConfigFile deletes it right after + const r = tryCatch(() => readFileSync(localPath, "utf-8")); + if (r.ok) { + uploadedContents.push(r.data); + } + }), + }; + + return { + runner, + commands, + uploadedContents, + }; +} + +/** Find the openclaw.json config from uploaded files. */ +function findConfigJson(capture: RunnerCapture): Record | null { + for (const content of capture.uploadedContents) { + const r = tryCatch(() => JSON.parse(content)); + if (r.ok && r.data && typeof r.data === "object" && "gateway" in r.data) { + return toRecord(r.data); + } + } + return null; +} + +/** Find the USER.md content from uploaded files. */ +function findUserMd(capture: RunnerCapture): string | null { + for (const content of capture.uploadedContents) { + if (content.includes("# User")) { + return content; + } + } + return null; +} + +/** Safely drill into a nested config object. */ +function drill(obj: Record, ...keys: string[]): unknown { + let current: unknown = obj; + for (const key of keys) { + const rec = toRecord(current); + if (rec && key in rec) { + current = rec[key]; + } else { + return undefined; + } + } + return current; +} + +// ── Test suite ────────────────────────────────────────────────────────── + +describe("OpenClaw config (setupOpenclawConfig)", () => { + let stderrSpy: ReturnType; + let savedTelegramToken: string | undefined; + + beforeEach(() => { + stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true); + savedTelegramToken = process.env.SPAWN_TELEGRAM_BOT_TOKEN; + delete process.env.SPAWN_TELEGRAM_BOT_TOKEN; + }); + + afterEach(() => { + stderrSpy.mockRestore(); + if (savedTelegramToken !== undefined) { + process.env.SPAWN_TELEGRAM_BOT_TOKEN = savedTelegramToken; + } else { + delete process.env.SPAWN_TELEGRAM_BOT_TOKEN; + } + }); + + // ── Gateway auth token ────────────────────────────────────────────── + + describe("gateway auth token", () => { + it("writes gateway.auth.token to openclaw.json", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!("sk-test-key", "test-model", new Set([])); + + const config = findConfigJson(capture); + expect(config).not.toBeNull(); + + const token = drill(config!, "gateway", "auth", "token"); + expect(typeof token).toBe("string"); + expect(String(token).length).toBe(32); + }); + + it("browserUrl token matches the gateway.auth.token in config", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!("sk-test-key", "test-model", new Set([])); + + const config = findConfigJson(capture); + const configToken = String(drill(config!, "gateway", "auth", "token")); + + const browserUrl = agents.openclaw.tunnel!.browserUrl!(12345); + expect(browserUrl).toContain(`?token=${configToken}`); + }); + + it("token is stable across browserUrl calls (same agent instance)", () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + const url1 = agents.openclaw.tunnel!.browserUrl!(8080); + const url2 = agents.openclaw.tunnel!.browserUrl!(9090); + + const token1 = new URL(url1!).searchParams.get("token"); + const token2 = new URL(url2!).searchParams.get("token"); + expect(token1).toBe(token2); + }); + + it("different createCloudAgents calls generate different tokens", () => { + const capture1 = createCapturingRunner(); + const capture2 = createCapturingRunner(); + const { agents: agents1 } = createCloudAgents(capture1.runner); + const { agents: agents2 } = createCloudAgents(capture2.runner); + + const url1 = agents1.openclaw.tunnel!.browserUrl!(8000); + const url2 = agents2.openclaw.tunnel!.browserUrl!(8000); + + const token1 = new URL(url1!).searchParams.get("token"); + const token2 = new URL(url2!).searchParams.get("token"); + + expect(token1).not.toBe(token2); + expect(token1!.length).toBe(32); + expect(token2!.length).toBe(32); + }); + }); + + // ── Atomic config write ───────────────────────────────────────────── + + describe("atomic config write", () => { + it("writes API key, gateway token, and model in a single JSON upload", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!("sk-my-api-key", "anthropic/claude-3", new Set([])); + + const config = findConfigJson(capture); + expect(config).not.toBeNull(); + + expect(drill(config!, "env", "OPENROUTER_API_KEY")).toBe("sk-my-api-key"); + expect(drill(config!, "gateway", "mode")).toBe("local"); + expect(drill(config!, "gateway", "auth", "token")).toBeDefined(); + expect(drill(config!, "agents", "defaults", "model", "primary")).toBe("anthropic/claude-3"); + }); + + it("does not run openclaw config set commands", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "browser", + ]), + ); + + const configSetCmds = capture.commands.filter((c) => c.includes("openclaw config set")); + expect(configSetCmds).toHaveLength(0); + }); + + it("includes browser config in the JSON when browser step is enabled", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "browser", + ]), + ); + + const config = findConfigJson(capture); + expect(config!.browser).toBeDefined(); + expect(drill(config!, "browser", "executablePath")).toBe("/usr/bin/google-chrome-stable"); + expect(drill(config!, "browser", "noSandbox")).toBe(true); + expect(drill(config!, "browser", "headless")).toBe(true); + expect(drill(config!, "browser", "defaultProfile")).toBe("openclaw"); + }); + + it("includes browser config when enabledSteps is undefined (default)", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!("sk-key", "model", undefined); + + const config = findConfigJson(capture); + expect(config!.browser).toBeDefined(); + }); + + it("excludes browser config when browser step is not in enabledSteps", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "github", + ]), + ); + + const config = findConfigJson(capture); + expect(config!.browser).toBeUndefined(); + }); + + it("writes valid JSON with special characters in API key", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!('sk-key-with-"quotes"&special', "model/with/slashes", new Set([])); + + const config = findConfigJson(capture); + expect(config).not.toBeNull(); + expect(drill(config!, "env", "OPENROUTER_API_KEY")).toBe('sk-key-with-"quotes"&special'); + expect(drill(config!, "agents", "defaults", "model", "primary")).toBe("model/with/slashes"); + }); + }); + + // ── Chrome browser install gating ─────────────────────────────────── + + describe("Chrome browser install", () => { + it("installs Chrome when browser step is enabled", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "browser", + ]), + ); + + const chromeCmd = capture.commands.find((c) => c.includes("google-chrome")); + expect(chromeCmd).toBeDefined(); + }); + + it("installs Chrome when enabledSteps is undefined (default behavior)", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!("sk-key", "model", undefined); + + const chromeCmd = capture.commands.find((c) => c.includes("google-chrome")); + expect(chromeCmd).toBeDefined(); + }); + + it("skips Chrome install when browser step is not selected", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "github", + ]), + ); + + const chromeCmd = capture.commands.find((c) => c.includes("google-chrome")); + expect(chromeCmd).toBeUndefined(); + }); + }); + + // ── Telegram setup ────────────────────────────────────────────────── + + describe("Telegram bot token", () => { + it("includes Telegram bot token in config JSON when provided", async () => { + process.env.SPAWN_TELEGRAM_BOT_TOKEN = "12345:ABCdefGhIjKlMnOpQrStUvWxYz"; + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "telegram", + ]), + ); + + const config = findConfigJson(capture); + expect(drill(config!, "channels", "telegram", "botToken")).toBe("12345:ABCdefGhIjKlMnOpQrStUvWxYz"); + }); + + it("trims whitespace from Telegram bot token", async () => { + process.env.SPAWN_TELEGRAM_BOT_TOKEN = " bot-token-123 "; + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "telegram", + ]), + ); + + const config = findConfigJson(capture); + expect(drill(config!, "channels", "telegram", "botToken")).toBe("bot-token-123"); + }); + + it("omits channels from config when Telegram token is empty", async () => { + process.env.SPAWN_TELEGRAM_BOT_TOKEN = " "; + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "telegram", + ]), + ); + + const config = findConfigJson(capture); + expect(config!.channels).toBeUndefined(); + }); + + it("omits channels when no token is provided", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "telegram", + ]), + ); + + const config = findConfigJson(capture); + expect(config!.channels).toBeUndefined(); + }); + + it("omits channels from config when Telegram is not in enabledSteps", async () => { + process.env.SPAWN_TELEGRAM_BOT_TOKEN = "should-not-be-used"; + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "browser", + ]), + ); + + const config = findConfigJson(capture); + expect(config!.channels).toBeUndefined(); + }); + + it("gateway auth token is preserved when Telegram token is set", async () => { + process.env.SPAWN_TELEGRAM_BOT_TOKEN = "my-bot-token"; + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "telegram", + "browser", + ]), + ); + + const config = findConfigJson(capture); + + const token = drill(config!, "gateway", "auth", "token"); + expect(typeof token).toBe("string"); + expect(String(token).length).toBe(32); + expect(drill(config!, "channels", "telegram", "botToken")).toBe("my-bot-token"); + + const browserUrl = agents.openclaw.tunnel!.browserUrl!(8080); + expect(browserUrl).toContain(`?token=${token}`); + }); + + it("browser config coexists with Telegram config in same JSON", async () => { + process.env.SPAWN_TELEGRAM_BOT_TOKEN = "my-token"; + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "telegram", + "browser", + ]), + ); + + const config = findConfigJson(capture); + expect(config!.gateway).toBeDefined(); + expect(config!.browser).toBeDefined(); + expect(config!.channels).toBeDefined(); + }); + }); + + // ── USER.md content ───────────────────────────────────────────────── + + describe("USER.md generation", () => { + it("writes USER.md with web dashboard info", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!("sk-key", "model", new Set([])); + + const userMd = findUserMd(capture); + expect(userMd).not.toBeNull(); + expect(userMd).toContain("web dashboard"); + expect(userMd).toContain("18791"); + }); + + it("includes Telegram section when Telegram is enabled", async () => { + process.env.SPAWN_TELEGRAM_BOT_TOKEN = "test-token"; + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "telegram", + ]), + ); + + const userMd = findUserMd(capture); + expect(userMd).toContain("Messaging Channels"); + expect(userMd).toContain("Telegram"); + expect(userMd).toContain("openclaw config get channels.telegram.botToken"); + }); + + it("includes WhatsApp section when WhatsApp is enabled", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "whatsapp", + ]), + ); + + const userMd = findUserMd(capture); + expect(userMd).toContain("Messaging Channels"); + expect(userMd).toContain("WhatsApp"); + expect(userMd).toContain("QR code scanning"); + expect(userMd).toContain("http://localhost:18791"); + }); + + it("includes both Telegram and WhatsApp when both are enabled", async () => { + process.env.SPAWN_TELEGRAM_BOT_TOKEN = "bot-token"; + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "telegram", + "whatsapp", + ]), + ); + + const userMd = findUserMd(capture); + expect(userMd).toContain("Telegram"); + expect(userMd).toContain("WhatsApp"); + }); + + it("omits messaging section when neither Telegram nor WhatsApp is enabled", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!( + "sk-key", + "model", + new Set([ + "browser", + ]), + ); + + const userMd = findUserMd(capture); + expect(userMd).not.toBeNull(); + expect(userMd).not.toContain("Messaging Channels"); + }); + + it("creates .openclaw/workspace directory before uploading USER.md", async () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + await agents.openclaw.configure!("sk-key", "model", new Set([])); + + const mkdirCmd = capture.commands.find((c) => c.includes("mkdir -p ~/.openclaw/workspace")); + expect(mkdirCmd).toBeDefined(); + }); + }); + + // ── Tunnel config ─────────────────────────────────────────────────── + + describe("tunnel config", () => { + it("openclaw agent has tunnel config targeting port 18791", () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + expect(agents.openclaw.tunnel).toBeDefined(); + expect(agents.openclaw.tunnel!.remotePort).toBe(18791); + }); + + it("browserUrl includes the token as a query parameter", () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + const url = agents.openclaw.tunnel!.browserUrl!(54321); + expect(url).toMatch(/^http:\/\/localhost:54321\/\?token=[a-f0-9]{32}$/); + }); + + it("browserUrl uses the provided local port", () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + const url1 = agents.openclaw.tunnel!.browserUrl!(8080); + const url2 = agents.openclaw.tunnel!.browserUrl!(9999); + + expect(url1).toContain("localhost:8080"); + expect(url2).toContain("localhost:9999"); + }); + + it("no other agents define tunnel config", () => { + const capture = createCapturingRunner(); + const { agents } = createCloudAgents(capture.runner); + + for (const [name, agent] of Object.entries(agents)) { + if (name === "openclaw") { + continue; + } + expect(agent.tunnel).toBeUndefined(); + } + }); + }); +}); diff --git a/packages/cli/src/__tests__/orchestrate-messaging.test.ts b/packages/cli/src/__tests__/orchestrate-messaging.test.ts new file mode 100644 index 000000000..9ddc9bc48 --- /dev/null +++ b/packages/cli/src/__tests__/orchestrate-messaging.test.ts @@ -0,0 +1,335 @@ +/** + * orchestrate-messaging.test.ts — Tests for messaging channel flows + * (WhatsApp QR scan, enabledSteps-dependent behavior) and SSH tunnel + * + browser open sequencing in the orchestration pipeline. + * + * These tests complement orchestrate.test.ts by covering the enabledSteps + * branches that were previously untested. + */ + +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { asyncTryCatch, tryCatch } from "@openrouter/spawn-shared"; +import { isNumber } from "../shared/type-guards.js"; + +const mockGetOrPromptApiKey = mock(() => Promise.resolve("sk-or-v1-test-key")); +const mockTryTarballInstall = mock(() => Promise.resolve(false)); + +import type { AgentConfig } from "../shared/agents"; +import type { CloudOrchestrator, OrchestrationOptions } from "../shared/orchestrate"; + +import { runOrchestration } from "../shared/orchestrate"; + +// ── Helpers ─────────────────────────────────────────────────────────────── + +function createMockCloud(overrides: Partial = {}): CloudOrchestrator { + const mockRunner = { + runServer: mock(() => Promise.resolve()), + uploadFile: mock(() => Promise.resolve()), + }; + return { + cloudName: "testcloud", + cloudLabel: "Test Cloud", + runner: mockRunner, + authenticate: mock(() => Promise.resolve()), + promptSize: mock(() => Promise.resolve()), + createServer: mock(() => + Promise.resolve({ + ip: "10.0.0.1", + user: "root", + server_name: "test-server-1", + cloud: "testcloud", + }), + ), + getServerName: mock(() => Promise.resolve("test-server-1")), + waitForReady: mock(() => Promise.resolve()), + interactiveSession: mock(() => Promise.resolve(0)), + ...overrides, + }; +} + +function createMockAgent(overrides: Partial = {}): AgentConfig { + return { + name: "TestAgent", + install: mock(() => Promise.resolve()), + envVars: mock((key: string) => [ + `OPENROUTER_API_KEY=${key}`, + ]), + launchCmd: mock(() => "test-agent --start"), + ...overrides, + }; +} + +const defaultOpts: OrchestrationOptions = { + tryTarball: mockTryTarballInstall, + getApiKey: mockGetOrPromptApiKey, +}; + +async function runOrchestrationSafe( + cloud: CloudOrchestrator, + agent: AgentConfig, + agentName: string, + opts: OrchestrationOptions = defaultOpts, +): Promise { + const r = await asyncTryCatch(async () => runOrchestration(cloud, agent, agentName, opts)); + if (!r.ok) { + if (r.error.message.startsWith("__EXIT_")) { + return; + } + throw r.error; + } +} + +// ── Test suite ──────────────────────────────────────────────────────────── + +describe("orchestration — messaging and tunnel", () => { + let exitSpy: ReturnType; + let capturedExitCode: number | undefined; + let stderrSpy: ReturnType; + let testDir: string; + let savedSpawnHome: string | undefined; + let savedEnabledSteps: string | undefined; + + beforeEach(() => { + capturedExitCode = undefined; + testDir = join(process.env.HOME ?? "", `.spawn-test-msg-${Date.now()}-${Math.random()}`); + mkdirSync(testDir, { + recursive: true, + }); + savedSpawnHome = process.env.SPAWN_HOME; + savedEnabledSteps = process.env.SPAWN_ENABLED_STEPS; + process.env.SPAWN_HOME = testDir; + process.env.SPAWN_SKIP_GITHUB_AUTH = "1"; + delete process.env.SPAWN_ENABLED_STEPS; + delete process.env.SPAWN_BETA; + stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true); + exitSpy = spyOn(process, "exit").mockImplementation((code) => { + capturedExitCode = isNumber(code) ? code : 0; + throw new Error(`__EXIT_${capturedExitCode}__`); + }); + mockGetOrPromptApiKey.mockClear(); + mockGetOrPromptApiKey.mockImplementation(() => Promise.resolve("sk-or-v1-test-key")); + mockTryTarballInstall.mockClear(); + mockTryTarballInstall.mockImplementation(() => Promise.resolve(false)); + }); + + afterEach(() => { + if (savedSpawnHome !== undefined) { + process.env.SPAWN_HOME = savedSpawnHome; + } else { + delete process.env.SPAWN_HOME; + } + if (savedEnabledSteps !== undefined) { + process.env.SPAWN_ENABLED_STEPS = savedEnabledSteps; + } else { + delete process.env.SPAWN_ENABLED_STEPS; + } + tryCatch(() => + rmSync(testDir, { + recursive: true, + force: true, + }), + ); + stderrSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + // ── enabledSteps parsing ──────────────────────────────────────────── + + describe("SPAWN_ENABLED_STEPS parsing", () => { + it("passes enabledSteps from env to configure", async () => { + process.env.SPAWN_ENABLED_STEPS = "github,telegram,whatsapp"; + const configure = mock(() => Promise.resolve()); + const cloud = createMockCloud(); + const agent = createMockAgent({ + configure, + }); + + await runOrchestrationSafe(cloud, agent, "testagent"); + + const callArgs = configure.mock.calls[0]; + const enabledSteps = callArgs[2]; + expect(enabledSteps).toBeInstanceOf(Set); + expect(enabledSteps.has("github")).toBe(true); + expect(enabledSteps.has("telegram")).toBe(true); + expect(enabledSteps.has("whatsapp")).toBe(true); + }); + + it("passes undefined enabledSteps when env var is not set", async () => { + delete process.env.SPAWN_ENABLED_STEPS; + const configure = mock(() => Promise.resolve()); + const cloud = createMockCloud(); + const agent = createMockAgent({ + configure, + }); + + await runOrchestrationSafe(cloud, agent, "testagent"); + + const callArgs = configure.mock.calls[0]; + expect(callArgs[2]).toBeUndefined(); + }); + + it("handles empty SPAWN_ENABLED_STEPS as empty set", async () => { + process.env.SPAWN_ENABLED_STEPS = ""; + const configure = mock(() => Promise.resolve()); + const cloud = createMockCloud(); + const agent = createMockAgent({ + configure, + }); + + await runOrchestrationSafe(cloud, agent, "testagent"); + + const callArgs = configure.mock.calls[0]; + const enabledSteps = callArgs[2]; + expect(enabledSteps).toBeInstanceOf(Set); + expect(enabledSteps.size).toBe(0); + }); + }); + + // ── WhatsApp QR scan flow ─────────────────────────────────────────── + + describe("WhatsApp interactive session", () => { + it("runs WhatsApp QR scan session when whatsapp is in enabledSteps", async () => { + process.env.SPAWN_ENABLED_STEPS = "whatsapp"; + let whatsappSessionRun = false; + const interactiveSessionCalls: string[] = []; + const cloud = createMockCloud({ + interactiveSession: mock(async (cmd: string) => { + interactiveSessionCalls.push(cmd); + if (cmd.includes("openclaw channels login")) { + whatsappSessionRun = true; + } + return 0; + }), + }); + const agent = createMockAgent(); + + await runOrchestrationSafe(cloud, agent, "testagent"); + + expect(whatsappSessionRun).toBe(true); + // The WhatsApp command should include the channel flag + const whatsappCmd = interactiveSessionCalls.find((c) => c.includes("openclaw channels login")); + expect(whatsappCmd).toContain("--channel whatsapp"); + }); + + it("does not run WhatsApp session when whatsapp is not in enabledSteps", async () => { + process.env.SPAWN_ENABLED_STEPS = "github,browser"; + const interactiveSessionCalls: string[] = []; + const cloud = createMockCloud({ + interactiveSession: mock(async (cmd: string) => { + interactiveSessionCalls.push(cmd); + return 0; + }), + }); + const agent = createMockAgent(); + + await runOrchestrationSafe(cloud, agent, "testagent"); + + const whatsappCmd = interactiveSessionCalls.find((c) => c.includes("openclaw channels login")); + expect(whatsappCmd).toBeUndefined(); + }); + + it("does not run WhatsApp session when SPAWN_ENABLED_STEPS is not set", async () => { + delete process.env.SPAWN_ENABLED_STEPS; + const interactiveSessionCalls: string[] = []; + const cloud = createMockCloud({ + interactiveSession: mock(async (cmd: string) => { + interactiveSessionCalls.push(cmd); + return 0; + }), + }); + const agent = createMockAgent(); + + await runOrchestrationSafe(cloud, agent, "testagent"); + + const whatsappCmd = interactiveSessionCalls.find((c) => c.includes("openclaw channels login")); + expect(whatsappCmd).toBeUndefined(); + }); + + it("WhatsApp session runs before the main agent launch", async () => { + process.env.SPAWN_ENABLED_STEPS = "whatsapp"; + const callOrder: string[] = []; + const cloud = createMockCloud({ + interactiveSession: mock(async (cmd: string) => { + if (cmd.includes("openclaw channels login")) { + callOrder.push("whatsapp-qr"); + } else { + callOrder.push("agent-launch"); + } + return 0; + }), + }); + const agent = createMockAgent(); + + await runOrchestrationSafe(cloud, agent, "testagent"); + + const whatsappIdx = callOrder.indexOf("whatsapp-qr"); + const launchIdx = callOrder.indexOf("agent-launch"); + expect(whatsappIdx).toBeGreaterThanOrEqual(0); + expect(launchIdx).toBeGreaterThanOrEqual(0); + expect(whatsappIdx).toBeLessThan(launchIdx); + }); + }); + + // ── GitHub auth gating ────────────────────────────────────────────── + + describe("GitHub auth gating", () => { + it("skips GitHub auth when github is not in enabledSteps", async () => { + process.env.SPAWN_ENABLED_STEPS = "browser"; + // Remove the skip env var to actually test the gating logic + delete process.env.SPAWN_SKIP_GITHUB_AUTH; + const cloud = createMockCloud(); + const agent = createMockAgent(); + + await runOrchestrationSafe(cloud, agent, "testagent"); + + // GitHub auth should have been skipped — no github-related commands + // The runner shouldn't have github-related calls beyond agent setup + // (This is a negative test — we're verifying the branch wasn't taken) + expect(cloud.interactiveSession).toHaveBeenCalled(); + // Restore for other tests + process.env.SPAWN_SKIP_GITHUB_AUTH = "1"; + }); + }); + + // ── preLaunchMsg ──────────────────────────────────────────────────── + + describe("preLaunchMsg", () => { + it("outputs preLaunchMsg to stderr when defined", async () => { + stderrSpy.mockRestore(); + const stderrOutput: string[] = []; + stderrSpy = spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderrOutput.push(String(chunk)); + return true; + }); + + const cloud = createMockCloud(); + const agent = createMockAgent({ + preLaunchMsg: "Your web dashboard will open automatically", + }); + + await runOrchestrationSafe(cloud, agent, "testagent"); + + const allOutput = stderrOutput.join(""); + expect(allOutput).toContain("Your web dashboard will open automatically"); + }); + + it("does not output preLaunchMsg when not defined", async () => { + stderrSpy.mockRestore(); + const stderrOutput: string[] = []; + stderrSpy = spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderrOutput.push(String(chunk)); + return true; + }); + + const cloud = createMockCloud(); + const agent = createMockAgent(); // no preLaunchMsg + + await runOrchestrationSafe(cloud, agent, "testagent"); + + const allOutput = stderrOutput.join(""); + expect(allOutput).not.toContain("Tip:"); + }); + }); +}); diff --git a/packages/cli/src/commands/interactive.ts b/packages/cli/src/commands/interactive.ts index 0a22d6d36..23a0c8f7d 100644 --- a/packages/cli/src/commands/interactive.ts +++ b/packages/cli/src/commands/interactive.ts @@ -170,7 +170,7 @@ async function promptSetupOptions(agentName: string): Promise | unde } const selected = await p.multiselect({ - message: "Setup options", + message: "Setup options (↑/↓ navigate, space to select, enter to confirm)", options: filteredSteps.map((s) => ({ value: s.value, label: s.label, diff --git a/packages/cli/src/shared/agent-setup.ts b/packages/cli/src/shared/agent-setup.ts index 496525ab5..ba53d4a9b 100644 --- a/packages/cli/src/shared/agent-setup.ts +++ b/packages/cli/src/shared/agent-setup.ts @@ -325,69 +325,66 @@ async function setupOpenclawConfig( } const gatewayToken = token ?? crypto.randomUUID().replace(/-/g, ""); - const escapedKey = jsonEscape(apiKey); - const escapedToken = jsonEscape(gatewayToken); - const escapedModel = jsonEscape(modelId); - const config = `{ - "env": { - "OPENROUTER_API_KEY": ${escapedKey} - }, - "gateway": { - "mode": "local", - "auth": { - "token": ${escapedToken} - } - }, - "agents": { - "defaults": { - "model": { - "primary": ${escapedModel} - } + // Prompt for Telegram bot token before building the config JSON so we can + // include it in a single atomic write — avoids `openclaw config set` calls + // that can clobber the gateway auth token. + let telegramBotToken = ""; + if (enabledSteps?.has("telegram")) { + logStep("Setting up Telegram..."); + // Allow env var override for non-interactive / CI / testing scenarios + const envToken = process.env.SPAWN_TELEGRAM_BOT_TOKEN ?? ""; + const botToken = envToken || (await prompt("Telegram bot token (from @BotFather): ")); + telegramBotToken = botToken.trim(); + if (!telegramBotToken) { + logInfo("No token entered — set up Telegram via the web dashboard after launch"); } } -}`; - await uploadConfigFile(runner, config, "$HOME/.openclaw/openclaw.json"); - // Configure browser via CLI (openclaw config set) — the supported way to set - // browser options. Writing JSON directly may not be picked up by all versions. - const browserResult = await asyncTryCatchIf(isOperationalError, () => - runner.runServer( - "export PATH=$HOME/.npm-global/bin:$HOME/.bun/bin:$HOME/.local/bin:$PATH; " + - "openclaw config set browser.executablePath /usr/bin/google-chrome-stable; " + - "openclaw config set browser.noSandbox true; " + - "openclaw config set browser.headless true; " + - "openclaw config set browser.defaultProfile openclaw", - ), - ); - if (!browserResult.ok) { - logWarn("Browser config setup failed (non-fatal)"); + // Build the full config as a single JSON object. All settings — gateway auth, + // browser, channels — are written atomically to avoid `openclaw config set` + // calls that re-serialize the file and can drop the gateway auth token. + const hasBrowser = !enabledSteps || enabledSteps.has("browser"); + const configObj: Record = { + env: { + OPENROUTER_API_KEY: apiKey, + }, + gateway: { + mode: "local", + auth: { + token: gatewayToken, + }, + }, + agents: { + defaults: { + model: { + primary: modelId, + }, + }, + }, + }; + + if (hasBrowser) { + configObj.browser = { + executablePath: "/usr/bin/google-chrome-stable", + noSandbox: true, + headless: true, + defaultProfile: "openclaw", + }; } - // Telegram channel setup — check env var first, then prompt interactively - if (enabledSteps?.has("telegram")) { - logStep("Setting up Telegram..."); - const envToken = process.env.TELEGRAM_BOT_TOKEN; - const trimmedToken = envToken?.trim() || (await prompt("Telegram bot token (from @BotFather): ")).trim(); - - if (trimmedToken) { - const escapedBotToken = jsonEscape(trimmedToken); - const telegramResult = await asyncTryCatchIf(isOperationalError, () => - runner.runServer( - "export PATH=$HOME/.npm-global/bin:$HOME/.bun/bin:$HOME/.local/bin:$PATH; " + - `openclaw config set channels.telegram.botToken ${escapedBotToken}`, - ), - ); - if (telegramResult.ok) { - logInfo("Telegram bot token configured"); - } else { - logWarn("Telegram config failed — set it up via the web dashboard after launch"); - } - } else { - logInfo("No token entered — set up Telegram via the web dashboard after launch"); - } + if (telegramBotToken) { + configObj.channels = { + telegram: { + botToken: telegramBotToken, + }, + }; + logInfo("Telegram bot token configured"); } + const config = JSON.stringify(configObj, null, 2); + await uploadConfigFile(runner, config, "$HOME/.openclaw/openclaw.json"); + // WhatsApp — QR code scanning happens interactively in orchestrate.ts // after the gateway starts and tunnel is set up. No config needed here. diff --git a/packages/cli/src/shared/agents.ts b/packages/cli/src/shared/agents.ts index e75c6fd61..7ea1d67bf 100644 --- a/packages/cli/src/shared/agents.ts +++ b/packages/cli/src/shared/agents.ts @@ -80,6 +80,7 @@ const COMMON_STEPS: OptionalStep[] = [ { value: "github", label: "GitHub CLI", + hint: "install gh + authenticate on the remote server", }, { value: "reuse-api-key",