From 2d0548818ffee3fa4f6166bc5e14900747744d97 Mon Sep 17 00:00:00 2001 From: "blink-so[bot]" <211532188+blink-so[bot]@users.noreply.github.com> Date: Wed, 3 Dec 2025 11:51:48 +0000 Subject: [PATCH 1/2] feat(scout-agent): add Coder workspace compute provider using HTTP API This adds support for using Coder workspaces as compute environments for the scout-agent, in addition to the existing Docker and Daytona providers. The Coder provider uses the HTTP API directly via fetch calls: - No CLI dependency required - Authenticates via Coder-Session-Token header - Creates/manages workspaces via REST API - Executes commands via WebSocket reconnecting PTY endpoint - Tunnels to compute server via PTY + netcat API endpoints used: - GET /api/v2/users/me - Current user info - GET /api/v2/workspaces/{id} - Workspace details - POST /api/v2/organizations/{org}/members/me/workspaces - Create workspace - POST /api/v2/workspaces/{id}/builds - Start/stop workspace - WS /api/v2/workspaceagents/{id}/pty - Command execution and tunneling Configuration options: - url: Coder deployment URL - sessionToken: Authentication token - template: Template for workspace creation - workspaceName, agentName: Workspace identification - richParameters: Template parameters - startTimeoutSeconds: Workspace startup timeout --- .../scout-agent/lib/compute/coder/README.md | 179 + .../lib/compute/coder/index.test.ts | 167 + .../scout-agent/lib/compute/coder/index.ts | 795 ++++ packages/scout-agent/lib/core.ts | 76 + packages/scout-agent/lib/index.ts | 1 + packages/scout-agent/pnpm-lock.yaml | 3865 +++++++++++++++++ pnpm-lock.yaml | 1014 +++++ 7 files changed, 6097 insertions(+) create mode 100644 packages/scout-agent/lib/compute/coder/README.md create mode 100644 packages/scout-agent/lib/compute/coder/index.test.ts create mode 100644 packages/scout-agent/lib/compute/coder/index.ts create mode 100644 packages/scout-agent/pnpm-lock.yaml create mode 100644 pnpm-lock.yaml diff --git a/packages/scout-agent/lib/compute/coder/README.md b/packages/scout-agent/lib/compute/coder/README.md new file mode 100644 index 0000000..d014422 --- /dev/null +++ b/packages/scout-agent/lib/compute/coder/README.md @@ -0,0 +1,179 @@ +# Coder Compute Provider + +This compute provider allows the Scout agent to use [Coder](https://coder.com) workspaces for code execution. + +## Prerequisites + +1. A running Coder deployment +2. A valid session token for authentication +3. A template configured for workspace creation (if creating new workspaces) +4. The template should have Node.js installed (required for the blink compute server) + +## How It Works + +This provider uses the Coder HTTP API directly via `fetch` calls - no CLI required. It: + +1. **Authenticates** using a session token via the `Coder-Session-Token` HTTP header +2. **Creates/manages workspaces** via REST API calls to `/api/v2/...` +3. **Executes commands** via WebSocket connection to the reconnecting PTY endpoint +4. **Connects to compute server** by tunneling through the PTY via netcat + +## Configuration + +```typescript +import { Scout } from "@blink-sdk/scout-agent"; + +const scout = new Scout({ + agent, + compute: { + type: "coder", + options: { + // Required: Your Coder deployment URL + url: "https://coder.example.com", + + // Required: Session token for authentication + // Can be obtained from `coder tokens create` or the Coder UI + sessionToken: process.env.CODER_SESSION_TOKEN, + + // Optional: Port for the blink compute server (default: 22137) + computeServerPort: 22137, + + // Optional: Template to create workspaces from + // Required if you want to create new workspaces + template: "my-dev-template", + + // Optional: Specific workspace name + // If not provided, a unique name will be generated + workspaceName: "my-workspace", + + // Optional: Agent name (if workspace has multiple agents) + agentName: "main", + + // Optional: Template parameters for workspace creation + richParameters: [ + { name: "cpu", value: "4" }, + { name: "memory", value: "8" }, + ], + + // Optional: Timeout for workspace startup (default: 300 seconds) + startTimeoutSeconds: 300, + }, + }, +}); +``` + +## API Endpoints Used + +| Endpoint | Purpose | +|----------|--------| +| `GET /api/v2/users/me` | Get current user | +| `GET /api/v2/users/{owner}/workspace/{name}` | Get workspace by owner and name | +| `GET /api/v2/workspaces/{id}` | Get workspace by ID | +| `GET /api/v2/organizations/default` | Get default organization | +| `GET /api/v2/organizations/{org}/templates/{name}` | Get template by name | +| `POST /api/v2/organizations/{org}/members/me/workspaces` | Create workspace | +| `POST /api/v2/workspaces/{id}/builds` | Start/stop workspace | +| `WS /api/v2/workspaceagents/{id}/pty` | Execute commands & tunnel traffic | + +## Workflow + +### Initialization + +When `initialize_workspace` is called: + +1. Checks if an existing workspace is stored and reusable +2. If workspace is stopped, starts it via the API +3. Waits for workspace and agent to be ready +4. Executes the blink compute server installation script via PTY +5. Waits for the compute server to start + +### Connection + +When executing tools: + +1. Verifies workspace is running and agent is connected +2. Opens WebSocket to PTY endpoint with `nc 127.0.0.1 {port}` command +3. This creates a tunnel: WebSocket ↔ PTY ↔ netcat ↔ TCP compute server +4. The compute protocol communicates through this tunnel + +## Requirements for Templates + +Your Coder template should have: + +1. **Node.js installed**: The blink compute server requires Node.js (v18+) +2. **netcat (nc) installed**: Used to tunnel traffic to the compute server +3. **Network access**: The workspace needs to be able to install npm packages + +Example minimal template requirements: +```hcl +resource "coder_agent" "main" { + # ... + startup_script = <<-EOF + # Ensure node and netcat are available + sudo apt-get update + sudo apt-get install -y nodejs npm netcat-openbsd + EOF +} +``` + +## Environment Variables + +You can use environment variables for configuration: + +| Variable | Description | +|----------|-------------| +| `CODER_URL` | Coder deployment URL | +| `CODER_SESSION_TOKEN` | Session token for authentication | + +## Getting a Session Token + +### Via CLI +```bash +coder tokens create +``` + +### Via UI +1. Log into your Coder deployment +2. Go to Account Settings → Tokens +3. Create a new token with appropriate permissions + +## Troubleshooting + +### "HTTP 401: Unauthorized" + +Your session token is invalid or expired. Create a new one. + +### "Template not found" + +The template name doesn't exist in the default organization. Check: +- The template name is spelled correctly +- You have permission to use the template +- The template is in the default organization + +### "Timeout waiting for workspace to be ready" + +Your workspace may be taking longer than expected to start. Try: +- Increasing `startTimeoutSeconds` +- Checking the workspace logs in the Coder UI +- Ensuring your template provisions resources quickly + +### "Agent not connected" + +The workspace agent may be having issues. Check: +- The agent logs in the Coder UI +- Network connectivity from the workspace +- The agent startup script + +### "Failed to connect to compute server" + +The blink compute server may not be running. Check: +- `/tmp/blink-compute.log` inside the workspace for errors +- Node.js is installed and accessible +- The specified port is not blocked +- netcat (nc) is installed in the workspace + +### Connection drops or hangs + +The PTY-based tunnel may have issues with binary data. Ensure: +- The workspace has `netcat-openbsd` installed (not `netcat-traditional`) +- The compute server port is correct diff --git a/packages/scout-agent/lib/compute/coder/index.test.ts b/packages/scout-agent/lib/compute/coder/index.test.ts new file mode 100644 index 0000000..26386c6 --- /dev/null +++ b/packages/scout-agent/lib/compute/coder/index.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test } from "bun:test"; +import type { CoderWorkspaceInfo } from "./index"; + +// Note: Full integration tests for the Coder compute provider require: +// 1. A running Coder deployment +// 2. Valid credentials (session token) +// 3. A template configured for workspace creation +// +// These unit tests focus on type definitions and basic structure. + +describe("CoderWorkspaceInfo", () => { + test("workspace info has required fields", () => { + const info: CoderWorkspaceInfo = { + workspaceId: "123e4567-e89b-12d3-a456-426614174000", + workspaceName: "my-workspace", + ownerName: "testuser", + agentId: "123e4567-e89b-12d3-a456-426614174001", + agentName: "main", + }; + + expect(info.workspaceId).toBe("123e4567-e89b-12d3-a456-426614174000"); + expect(info.workspaceName).toBe("my-workspace"); + expect(info.ownerName).toBe("testuser"); + expect(info.agentId).toBeDefined(); + expect(info.agentName).toBe("main"); + }); +}); + +describe("configuration validation", () => { + test("minimal config requires url and sessionToken", () => { + const config = { + coderUrl: "https://coder.example.com", + sessionToken: "session-token", + computeServerPort: 22137, + }; + + expect(config.coderUrl).toBeDefined(); + expect(config.sessionToken).toBeDefined(); + expect(config.computeServerPort).toBe(22137); + }); + + test("full config with all optional fields", () => { + const config = { + coderUrl: "https://coder.example.com", + sessionToken: "session-token", + computeServerPort: 22137, + template: "my-template", + workspaceName: "my-workspace", + agentName: "main", + richParameters: [ + { name: "cpu", value: "4" }, + { name: "memory", value: "8" }, + ], + startTimeoutSeconds: 600, + }; + + expect(config.template).toBe("my-template"); + expect(config.workspaceName).toBe("my-workspace"); + expect(config.agentName).toBe("main"); + expect(config.richParameters).toHaveLength(2); + expect(config.startTimeoutSeconds).toBe(600); + }); +}); + +describe("API URL construction", () => { + test("handles URLs with and without trailing slash", () => { + const withSlash = "https://coder.example.com/"; + const withoutSlash = "https://coder.example.com"; + + // Both should work - the implementation strips trailing slashes + expect(withSlash.replace(/\/$/, "")).toBe(withoutSlash); + expect(withoutSlash.replace(/\/$/, "")).toBe(withoutSlash); + }); + + test("WebSocket URL conversion", () => { + const httpUrl = "https://coder.example.com"; + const wsUrl = httpUrl.replace(/^http/, "ws"); + expect(wsUrl).toBe("wss://coder.example.com"); + + const httpUrlNoSsl = "http://localhost:3000"; + const wsUrlNoSsl = httpUrlNoSsl.replace(/^http/, "ws"); + expect(wsUrlNoSsl).toBe("ws://localhost:3000"); + }); +}); + +// Integration test examples (skipped by default - require real Coder deployment) +describe.skip("integration tests", () => { + // These tests require: + // - CODER_URL environment variable set to your Coder deployment + // - CODER_SESSION_TOKEN environment variable with a valid token + // - A template available for workspace creation + + const getEnvConfig = () => ({ + coderUrl: process.env.CODER_URL || "", + sessionToken: process.env.CODER_SESSION_TOKEN || "", + computeServerPort: 22137, + template: process.env.CODER_TEMPLATE || "default", + startTimeoutSeconds: 300, + }); + + test("can initialize a new workspace", async () => { + const { initializeCoderWorkspace } = await import("./index"); + const config = getEnvConfig(); + + if (!config.coderUrl || !config.sessionToken) { + console.log("Skipping: CODER_URL or CODER_SESSION_TOKEN not set"); + return; + } + + const noopLogger = { + info: () => {}, + warn: () => {}, + error: () => {}, + }; + + const result = await initializeCoderWorkspace( + noopLogger, + config, + undefined + ); + + expect(result.workspaceInfo.workspaceId).toBeDefined(); + expect(result.workspaceInfo.workspaceName).toBeDefined(); + expect(result.workspaceInfo.agentId).toBeDefined(); + expect(result.message).toInclude("initialized"); + }); + + test("can connect to an existing workspace", async () => { + const { initializeCoderWorkspace, getCoderWorkspaceClient } = await import( + "./index" + ); + const config = getEnvConfig(); + + if (!config.coderUrl || !config.sessionToken) { + console.log("Skipping: CODER_URL or CODER_SESSION_TOKEN not set"); + return; + } + + const noopLogger = { + info: () => {}, + warn: () => {}, + error: () => {}, + }; + + // First initialize + const initResult = await initializeCoderWorkspace( + noopLogger, + config, + undefined + ); + + // Then get client + const client = await getCoderWorkspaceClient( + { + coderUrl: config.coderUrl, + sessionToken: config.sessionToken, + computeServerPort: config.computeServerPort, + }, + initResult.workspaceInfo + ); + + expect(client).toBeDefined(); + + // Clean up + client.dispose(); + }); +}); diff --git a/packages/scout-agent/lib/compute/coder/index.ts b/packages/scout-agent/lib/compute/coder/index.ts new file mode 100644 index 0000000..193e149 --- /dev/null +++ b/packages/scout-agent/lib/compute/coder/index.ts @@ -0,0 +1,795 @@ +import type { Client } from "@blink-sdk/compute-protocol/client"; +import { WebSocket } from "ws"; +import type { Logger } from "../../types"; +import { newComputeClient } from "../common"; + +// ============================================================================ +// Coder API Types (based on codersdk) +// ============================================================================ + +type WorkspaceStatus = + | "pending" + | "starting" + | "running" + | "stopping" + | "stopped" + | "failed" + | "canceling" + | "canceled" + | "deleting" + | "deleted"; + +type WorkspaceTransition = "start" | "stop" | "delete"; + +type AgentStatus = "connecting" | "connected" | "disconnected" | "timeout"; + +interface WorkspaceAgent { + id: string; + name: string; + status: AgentStatus; +} + +interface WorkspaceResource { + id: string; + name: string; + type: string; + agents?: WorkspaceAgent[]; +} + +interface WorkspaceBuild { + id: string; + status: WorkspaceStatus; + resources: WorkspaceResource[]; +} + +interface Workspace { + id: string; + name: string; + owner_name: string; + template_id: string; + template_name: string; + latest_build: WorkspaceBuild; +} + +interface Template { + id: string; + name: string; + organization_id: string; + active_version_id: string; +} + +interface WorkspaceBuildParameter { + name: string; + value: string; +} + +interface CreateWorkspaceRequest { + template_id?: string; + template_version_id?: string; + name: string; + rich_parameter_values?: WorkspaceBuildParameter[]; +} + +interface CreateWorkspaceBuildRequest { + transition: WorkspaceTransition; + rich_parameter_values?: WorkspaceBuildParameter[]; +} + +interface CoderApiError { + message: string; + detail?: string; +} + +// ============================================================================ +// Coder HTTP API Client +// ============================================================================ + +class CoderApiClient { + private readonly baseUrl: string; + private readonly sessionToken: string; + + constructor(baseUrl: string, sessionToken: string) { + // Remove trailing slash if present + this.baseUrl = baseUrl.replace(/\/$/, ""); + this.sessionToken = sessionToken; + } + + private async request( + method: string, + path: string, + body?: unknown + ): Promise { + const url = `${this.baseUrl}${path}`; + const headers: Record = { + "Coder-Session-Token": this.sessionToken, + Accept: "application/json", + }; + + if (body) { + headers["Content-Type"] = "application/json"; + } + + const response = await fetch(url, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + let errorMessage = `HTTP ${response.status}: ${response.statusText}`; + try { + const errorBody = (await response.json()) as CoderApiError; + errorMessage = errorBody.message || errorMessage; + if (errorBody.detail) { + errorMessage += ` - ${errorBody.detail}`; + } + } catch { + // Ignore JSON parse errors, use default message + } + throw new Error(errorMessage); + } + + // Handle empty responses (204 No Content, etc.) + const contentType = response.headers.get("content-type"); + if ( + response.status === 204 || + !contentType?.includes("application/json") + ) { + return {} as T; + } + + return response.json() as Promise; + } + + // Get current authenticated user + async getMe(): Promise<{ id: string; username: string }> { + return this.request("GET", "/api/v2/users/me"); + } + + // Get workspace by owner and name + async getWorkspaceByOwnerAndName( + owner: string, + name: string + ): Promise { + try { + return await this.request( + "GET", + `/api/v2/users/${encodeURIComponent(owner)}/workspace/${encodeURIComponent(name)}` + ); + } catch (err) { + if (err instanceof Error && err.message.includes("404")) { + return undefined; + } + throw err; + } + } + + // Get workspace by ID + async getWorkspace(workspaceId: string): Promise { + return this.request( + "GET", + `/api/v2/workspaces/${encodeURIComponent(workspaceId)}` + ); + } + + // Get template by name in organization + async getTemplateByName( + organizationId: string, + templateName: string + ): Promise