diff --git a/src/tools/remote_agents.ts b/src/tools/remote_agents.ts index 69fa8b0..39bb28d 100644 --- a/src/tools/remote_agents.ts +++ b/src/tools/remote_agents.ts @@ -90,13 +90,14 @@ export async function handleGetRemoteAgentSetupCommand(params: { `WORKSPACE_ID=${w.id} \\`, `PLATFORM_URL=${targetUrl} \\`, `python3 -c "from molecule_agent import RemoteAgentClient; \\`, - ` c = RemoteAgentClient.register_from_env(); \\`, + ` c = RemoteAgentClient(workspace_id='${w.id}', platform_url='${targetUrl}'); \\`, + ` if c.load_token() is None: c.register(); \\`, ` c.pull_secrets(); \\`, ` c.run_heartbeat_loop()"`, ``, `# For a richer demo (logging, graceful shutdown) see`, `# examples/remote-agent/run.py in the molecule-sdk-python checkout.`, - `# The agent will register, mint its bearer token (cached at`, + `# The agent will register (mint + cache bearer token at`, `# ~/.molecule/${w.id}/.auth_token), pull secrets, then heartbeat.`, ].join("\n"); return toMcpResult({ diff --git a/tests/__tests__/api.test.ts b/tests/__tests__/api.test.ts index a1c8085..bfde1aa 100644 --- a/tests/__tests__/api.test.ts +++ b/tests/__tests__/api.test.ts @@ -290,3 +290,99 @@ describe("platformGet", () => { }); }); }); + +// --------------------------------------------------------------------------- +// remote_agents — handleGetRemoteAgentSetupCommand +// --------------------------------------------------------------------------- + +// remote_agents.ts reads PLATFORM_URL at module-load time from process.env. +// We use jest.isolateModules so each test gets a fresh module context with +// the right env var set before the module is loaded. +const originalEnv = process.env.MOLECULE_API_URL; + +describe("handleGetRemoteAgentSetupCommand", () => { + beforeEach(() => { + jest.resetModules(); + process.env.MOLECULE_API_URL = "http://localhost:8080"; + }); + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.MOLECULE_API_URL; + } else { + process.env.MOLECULE_API_URL = originalEnv; + } + }); + + async function loadHandlerAndMock(workspace: Record) { + let handler!: typeof import("../../src/tools/remote_agents").handleGetRemoteAgentSetupCommand; + let mockGet!: jest.Mock; + await new Promise((resolve) => { + jest.isolateModules(() => { + mockGet = jest.fn().mockResolvedValue(workspace); + jest.mock("../../src/api", () => ({ + ...jest.requireActual("../../src/api"), + platformGet: mockGet, + })); + const mod = require("../../src/tools/remote_agents"); + handler = mod.handleGetRemoteAgentSetupCommand; + resolve(); + }); + }); + return { handler, mockGet }; + } + + it("generates valid Python command with constructor + register pattern", async () => { + const { handler } = await loadHandlerAndMock({ + id: "ws-abc123", + name: "my-agent", + runtime: "external", + }); + const result = await handler({ workspace_id: "ws-abc123" }); + const parsed = JSON.parse((result.content[0] as { text: string }).text); + expect(parsed.workspace_id).toBe("ws-abc123"); + expect(parsed.workspace_name).toBe("my-agent"); + expect(parsed.setup_command).toContain("RemoteAgentClient(workspace_id='ws-abc123'"); + expect(parsed.setup_command).not.toContain("register_from_env"); + expect(parsed.setup_command).toContain("register()"); + }); + + it("warns when PLATFORM_URL is localhost and no override is given", async () => { + const { handler } = await loadHandlerAndMock({ + id: "ws-abc123", + name: "my-agent", + runtime: "external", + }); + const result = await handler({ workspace_id: "ws-abc123" }); + const parsed = JSON.parse((result.content[0] as { text: string }).text); + expect(parsed.warnings).toBeDefined(); + expect(parsed.warnings![0]).toContain("localhost"); + }); + + it("uses platform_url_override when provided", async () => { + const { handler } = await loadHandlerAndMock({ + id: "ws-abc123", + name: "my-agent", + runtime: "external", + }); + const result = await handler({ + workspace_id: "ws-abc123", + platform_url_override: "https://platform.example.com", + }); + const parsed = JSON.parse((result.content[0] as { text: string }).text); + expect(parsed.setup_command).toContain("platform_url='https://platform.example.com'"); + expect(parsed.warnings).toBeUndefined(); + }); + + it("returns error when workspace is not runtime=external", async () => { + const { handler } = await loadHandlerAndMock({ + id: "ws-abc123", + name: "my-agent", + runtime: "docker", + }); + const result = await handler({ workspace_id: "ws-abc123" }); + const parsed = JSON.parse((result.content[0] as { text: string }).text); + expect(parsed.error).toContain("not external"); + expect(parsed.setup_command).toBeUndefined(); + }); +}); diff --git a/tests/__tests__/remote_agents.test.ts b/tests/__tests__/remote_agents.test.ts new file mode 100644 index 0000000..e30e49e --- /dev/null +++ b/tests/__tests__/remote_agents.test.ts @@ -0,0 +1,164 @@ +/** + * Unit tests for src/tools/remote_agents.ts + * + * Tests handleGetRemoteAgentSetupCommand which generates a Python bootstrap + * command for remote agents. Key edge cases: + * - localhost warning when PLATFORM_URL is localhost and no override given + * - platform_url_override bypasses localhost warning + * - non-external runtime returns error + * - workspace not found returns error + */ + +import { toMcpResult } from "../../src/api"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Factory so each fetch call gets a fresh Response (bodies can only be read once). */ +function makeFetchResponse(body: unknown, init: ResponseInit = {}): Response { + const text = typeof body === "string" ? body : JSON.stringify(body); + return new Response(text, { + status: init.status ?? 200, + statusText: init.statusText, + headers: init.headers as HeadersInit, + }); +} + +type RemoteAgentsHandler = { + handleGetRemoteAgentSetupCommand: ( + params: { workspace_id: string; platform_url_override?: string } + ) => Promise>; +}; + +/** + * Dynamically import the remote_agents module with a mocked platformGet. + * Must be called inside jest.isolateModules() with MOLECULE_API_URL set. + */ +async function loadHandlerWithMock( + mockPlatformGet: jest.Mock, +): Promise { + let handler!: RemoteAgentsHandler; + await new Promise((resolve) => { + jest.isolateModules(() => { + jest.mock("../../src/api", () => ({ + ...jest.requireActual("../../src/api"), + platformGet: mockPlatformGet, + })); + const mod = require("../../src/tools/remote_agents") as RemoteAgentsHandler; + handler = mod; + resolve(); + }); + }); + return handler; +} + +// --------------------------------------------------------------------------- +// handleGetRemoteAgentSetupCommand tests +// --------------------------------------------------------------------------- + +describe("handleGetRemoteAgentSetupCommand", () => { + beforeEach(() => { + jest.resetModules(); + }); + + it("returns a setup command with correct RemoteAgentClient API call", async () => { + const mockGet = jest.fn().mockResolvedValue({ + id: "ws-abc123", + name: "test-agent", + runtime: "external", + }); + + const handler = await loadHandlerWithMock(mockGet); + const result = await handler.handleGetRemoteAgentSetupCommand({ + workspace_id: "ws-abc123", + }); + + expect(result.content[0].text).toContain("ws-abc123"); + expect(result.content[0].text).toContain("molecule_agent import RemoteAgentClient"); + // Must use constructor + load_token pattern, NOT the non-existent register_from_env() + expect(result.content[0].text).not.toContain("register_from_env()"); + expect(result.content[0].text).toContain("load_token()"); + expect(result.content[0].text).toContain("pull_secrets()"); + expect(result.content[0].text).toContain("run_heartbeat_loop()"); + }); + + it("returns a localhost warning when PLATFORM_URL is localhost and no override given", async () => { + // Set localhost as the platform URL before loading the module + process.env.MOLECULE_API_URL = "http://localhost:8080"; + + const mockGet = jest.fn().mockResolvedValue({ + id: "ws-abc123", + name: "test-agent", + runtime: "external", + }); + + const handler = await loadHandlerWithMock(mockGet); + const result = await handler.handleGetRemoteAgentSetupCommand({ + workspace_id: "ws-abc123", + }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.warnings).toBeDefined(); + expect(parsed.warnings[0]).toContain("localhost"); + expect(parsed.warnings[0]).toContain("platform_url_override"); + + delete process.env.MOLECULE_API_URL; + }); + + it("platform_url_override bypasses the localhost warning", async () => { + // Even with localhost as the base URL, passing an override suppresses the warning + process.env.MOLECULE_API_URL = "http://localhost:8080"; + + const mockGet = jest.fn().mockResolvedValue({ + id: "ws-abc123", + name: "test-agent", + runtime: "external", + }); + + const handler = await loadHandlerWithMock(mockGet); + const result = await handler.handleGetRemoteAgentSetupCommand({ + workspace_id: "ws-abc123", + platform_url_override: "https://platform.example.com", + }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.warnings).toBeUndefined(); + expect(parsed.platform_url).toBe("https://platform.example.com"); + + delete process.env.MOLECULE_API_URL; + }); + + it("returns error when workspace runtime is not 'external'", async () => { + const mockGet = jest.fn().mockResolvedValue({ + id: "ws-abc123", + name: "docker-agent", + runtime: "docker", + }); + + const handler = await loadHandlerWithMock(mockGet); + const result = await handler.handleGetRemoteAgentSetupCommand({ + workspace_id: "ws-abc123", + }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.error).toContain("not external"); + expect(parsed.error).toContain("runtime='external'"); + expect(parsed.actual_runtime).toBe("docker"); + }); + + it("returns error when workspace is not found", async () => { + const mockGet = jest.fn().mockResolvedValue({ + error: "not found", + detail: "workspace ws-missing does not exist", + }); + + const handler = await loadHandlerWithMock(mockGet); + const result = await handler.handleGetRemoteAgentSetupCommand({ + workspace_id: "ws-missing", + }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.error).toBeDefined(); + }); +});