diff --git a/registry/coder/modules/goose/README.md b/registry/coder/modules/goose/README.md index 0c3c2e434f..20b831c3db 100644 --- a/registry/coder/modules/goose/README.md +++ b/registry/coder/modules/goose/README.md @@ -1,128 +1,148 @@ --- display_name: Goose -description: Run Goose in your workspace +description: Install and configure the Goose AI agent in your workspace. icon: ../../../../.icons/goose.svg verified: true -tags: [agent, goose, ai, tasks] +tags: [agent, goose, ai] --- # Goose -Run the [Goose](https://block.github.io/goose/) agent in your workspace to generate code and perform tasks. +Install and configure the [Goose](https://goose-docs.ai/) agent in your workspace. ```tf module "goose" { - source = "registry.coder.com/coder/goose/coder" - version = "3.0.2" - agent_id = coder_agent.main.id - folder = "/home/coder" - install_goose = true - goose_version = "v1.0.31" - goose_provider = "anthropic" - goose_model = "claude-3-5-sonnet-latest" - agentapi_version = "latest" + source = "registry.coder.com/coder/goose/coder" + version = "4.0.0" + agent_id = coder_agent.main.id + goose_provider = "anthropic" + goose_model = "claude-sonnet-4-6" } ``` -## Prerequisites - -- You must add the [Coder Login](https://registry.coder.com/modules/coder-login) module to your template - -The `codercom/oss-dogfood:latest` container image can be used for testing on container-based workspaces. +> [!WARNING] +> If upgrading from v3.x.x of this module: v4 is a major refactor that drops support for Coder Tasks and AgentAPI. The module now only installs and configures Goose; launch it with your own `coder_app`. `order`, `group`, `subdomain`, `install_agentapi`, `agentapi_version`, the `task_app_id` output, the `GOOSE_TASK_PROMPT` environment variable, and the built-in `coder` extension are removed, and `folder` is renamed to `workdir`. Keep using v3.x.x if you depend on Coder Tasks. ## Examples -### Run in the background and report tasks +### Standalone mode with a launcher app ```tf -module "coder-login" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/coder/coder-login/coder" - version = "1.0.15" - agent_id = coder_agent.main.id +locals { + goose_workdir = "/home/coder/project" } -variable "anthropic_api_key" { - type = string - description = "The Anthropic API key" - sensitive = true +module "goose" { + source = "registry.coder.com/coder/goose/coder" + version = "4.0.0" + agent_id = coder_agent.main.id + workdir = local.goose_workdir + goose_provider = "anthropic" + goose_model = "claude-sonnet-4-6" } -data "coder_parameter" "ai_prompt" { - type = "string" - name = "AI Prompt" - default = "" - description = "Write a prompt for Goose" - mutable = true +resource "coder_env" "anthropic_api_key" { + agent_id = coder_agent.main.id + name = "ANTHROPIC_API_KEY" + value = var.anthropic_api_key } -# Set the prompt and system prompt for Goose via environment variables -resource "coder_agent" "main" { - # ... - env = { - GOOSE_SYSTEM_PROMPT = <<-EOT - You are a helpful assistant that can help write code. +resource "coder_app" "goose" { + agent_id = coder_agent.main.id + slug = "goose" + display_name = "Goose" + icon = "/icon/goose.svg" + open_in = "slim-window" + command = <<-EOT + #!/usr/bin/env bash + set -e + cd "${local.goose_workdir}" + exec goose session + EOT +} +``` - Run all long running tasks (e.g. npm run dev) in the background and not in the foreground. +Goose reads provider credentials from environment variables such as `ANTHROPIC_API_KEY`. See [Goose providers](https://goose-docs.ai/docs/getting-started/providers). - Periodically check in on background tasks. +### Extensions (MCP) - Notify Coder of the status of the task before and after your steps. - EOT - GOOSE_TASK_PROMPT = data.coder_parameter.ai_prompt.value +```tf +module "goose" { + source = "registry.coder.com/coder/goose/coder" + version = "4.0.0" + agent_id = coder_agent.main.id + goose_provider = "anthropic" + goose_model = "claude-sonnet-4-6" - # See https://block.github.io/goose/docs/getting-started/providers - ANTHROPIC_API_KEY = var.anthropic_api_key # or use a coder_parameter - } + additional_extensions = <<-EOT + playwright: + name: playwright + type: stdio + cmd: npx + args: ["-y", "@playwright/mcp@latest", "--headless", "--isolated"] + enabled: true + timeout: 300 + EOT } +``` -module "goose" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/coder/goose/coder" - version = "3.0.2" - agent_id = coder_agent.main.id - folder = "/home/coder" - install_goose = true - goose_version = "v1.0.31" - agentapi_version = "latest" +`additional_extensions` uses the [`extensions`](https://goose-docs.ai/docs/guides/config-files#extensions-configuration) format of `config.yaml`. The module overwrites `config.yaml` with `goose_provider`, `goose_model`, the built-in `developer` extension, and `additional_extensions`. + +### Pinned version +```tf +module "goose" { + source = "registry.coder.com/coder/goose/coder" + version = "4.0.0" + agent_id = coder_agent.main.id + goose_version = "v1.54.0" goose_provider = "anthropic" - goose_model = "claude-3-5-sonnet-latest" + goose_model = "claude-sonnet-4-6" } ``` -### Adding Custom Extensions (MCP) +### Serialize a downstream `coder_script` after the install pipeline -You can extend Goose's capabilities by adding custom extensions. For example, to add the desktop-commander extension: +The module exposes the `scripts` output: an ordered list of `coder exp sync` names for the scripts this module creates (pre_install, install, post_install). Scripts that were not configured are absent. ```tf module "goose" { - # ... other configuration ... - - pre_install_script = <<-EOT - npm i -g @wonderwhy-er/desktop-commander@latest - EOT + source = "registry.coder.com/coder/goose/coder" + version = "4.0.0" + agent_id = coder_agent.main.id + goose_provider = "anthropic" + goose_model = "claude-sonnet-4-6" +} - additional_extensions = <<-EOT - desktop-commander: - args: [] - cmd: desktop-commander - description: Ideal for background tasks - enabled: true - envs: {} - name: desktop-commander - timeout: 300 - type: stdio +resource "coder_script" "post_goose" { + agent_id = coder_agent.main.id + display_name = "Run after Goose install" + run_on_start = true + script = <<-EOT + #!/usr/bin/env bash + set -euo pipefail + trap 'coder exp sync complete post-goose' EXIT + coder exp sync want post-goose ${join(" ", module.goose.scripts)} + coder exp sync start post-goose + + goose --version EOT } ``` -This will add the desktop-commander extension to Goose, allowing it to run commands in the background. The extension will be available in the Goose interface and can be used to run long-running processes like development servers. +## Troubleshooting -Note: The indentation in the heredoc is preserved, so you can write the YAML naturally. +Check the log files in `~/.coder-modules/coder/goose/logs/` for detailed information. -## Troubleshooting +```bash +cat ~/.coder-modules/coder/goose/logs/install.log +cat ~/.coder-modules/coder/goose/logs/pre_install.log +cat ~/.coder-modules/coder/goose/logs/post_install.log +``` -By default, this module is configured to run the embedded chat interface as a path-based application. In production, we recommend that you configure a [wildcard access URL](https://coder.com/docs/admin/setup#wildcard-access-url) and set `subdomain = true`. See [here](https://coder.com/docs/tutorials/best-practices/security-best-practices#disable-path-based-apps) for more details. +## References -The module will create log files in the workspace's `~/.goose-module` directory. If you run into any issues, look at them for more information. +- [Goose documentation](https://goose-docs.ai/docs/) +- [Goose configuration files](https://goose-docs.ai/docs/guides/config-files) +- [Goose environment variables](https://goose-docs.ai/docs/guides/environment-variables) +- [Using extensions](https://goose-docs.ai/docs/getting-started/using-extensions) diff --git a/registry/coder/modules/goose/main.test.ts b/registry/coder/modules/goose/main.test.ts index ae5635b00c..709886dfdc 100644 --- a/registry/coder/modules/goose/main.test.ts +++ b/registry/coder/modules/goose/main.test.ts @@ -2,34 +2,75 @@ import { test, afterEach, describe, - it, setDefaultTimeout, beforeAll, expect, } from "bun:test"; -import { execContainer, readFileContainer, runTerraformInit } from "~test"; import { - loadTestFile, - writeExecutable, - setup as setupUtil, - execModuleScript, - expectAgentAPIStarted, -} from "../agentapi/test-util"; + execContainer, + readFileContainer, + removeContainer, + runContainer, + runTerraformApply, + runTerraformInit, + TerraformState, +} from "~test"; +import { extractCoderEnvVars, writeExecutable } from "../agentapi/test-util"; import dedent from "dedent"; +import path from "path"; -let cleanupFunctions: (() => Promise)[] = []; +interface ModuleScripts { + pre_install?: string; + install: string; + post_install?: string; +} + +const SCRIPT_SUFFIXES = [ + "Pre-Install Script", + "Install Script", + "Post-Install Script", +] as const; + +const collectScripts = (state: TerraformState): ModuleScripts => { + const byDisplayName: Record = {}; + for (const resource of state.resources) { + if (resource.type !== "coder_script") continue; + for (const instance of resource.instances) { + const attrs = instance.attributes as Record; + const displayName = attrs.display_name as string | undefined; + const script = attrs.script as string | undefined; + if (displayName && script) { + byDisplayName[displayName] = script; + } + } + } + const scripts: Partial = {}; + for (const suffix of SCRIPT_SUFFIXES) { + const key = `Goose: ${suffix}`; + if (!(key in byDisplayName)) continue; + switch (suffix) { + case "Pre-Install Script": + scripts.pre_install = byDisplayName[key]; + break; + case "Install Script": + scripts.install = byDisplayName[key]; + break; + case "Post-Install Script": + scripts.post_install = byDisplayName[key]; + break; + } + } + if (!scripts.install) { + throw new Error("install script not found in terraform state"); + } + return scripts as ModuleScripts; +}; +let cleanupFunctions: (() => Promise)[] = []; const registerCleanup = (cleanup: () => Promise) => { cleanupFunctions.push(cleanup); }; - -// Cleanup logic depends on the fact that bun's built-in test runner -// runs tests sequentially. -// https://bun.sh/docs/test/discovery#execution-order -// Weird things would happen if tried to run tests in parallel. -// One test could clean up resources that another test was still using. afterEach(async () => { - // reverse the cleanup functions so that they are run in the correct order const cleanupFnsCopy = cleanupFunctions.slice().reverse(); cleanupFunctions = []; for (const cleanup of cleanupFnsCopy) { @@ -42,253 +83,303 @@ afterEach(async () => { }); interface SetupProps { - skipAgentAPIMock?: boolean; skipGooseMock?: boolean; moduleVariables?: Record; - agentapiMockScript?: string; } -const setup = async (props?: SetupProps): Promise<{ id: string }> => { - const projectDir = "/home/coder/project"; - const { id } = await setupUtil({ - moduleDir: import.meta.dir, - moduleVariables: { - install_goose: props?.skipGooseMock ? "true" : "false", - install_agentapi: props?.skipAgentAPIMock ? "true" : "false", - goose_provider: "test-provider", - goose_model: "test-model", - ...props?.moduleVariables, - }, - registerCleanup, - projectDir, - skipAgentAPIMock: props?.skipAgentAPIMock, - agentapiMockScript: props?.agentapiMockScript, +const projectDir = "/home/coder/project"; + +const setup = async ( + props?: SetupProps, +): Promise<{ + id: string; + coderEnvVars: Record; + scripts: ModuleScripts; +}> => { + const moduleDir = path.resolve(import.meta.dir); + const state = await runTerraformApply(moduleDir, { + agent_id: "foo", + workdir: projectDir, + install_goose: "false", + goose_provider: "anthropic", + goose_model: "claude-sonnet-4-6", + ...props?.moduleVariables, + }); + const scripts = collectScripts(state); + const coderEnvVars = extractCoderEnvVars(state); + + const id = await runContainer("codercom/enterprise-node:latest"); + registerCleanup(async () => { + if (process.env["DEBUG"] === "true" || process.env["DEBUG"] === "1") { + console.log(`Not removing container ${id} in debug mode`); + return; + } + await removeContainer(id); + }); + + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/coder", + content: "#!/bin/bash\nexit 0\n", }); if (!props?.skipGooseMock) { await writeExecutable({ containerId: id, filePath: "/usr/bin/goose", - content: await loadTestFile(import.meta.dir, "goose-mock.sh"), + content: await Bun.file( + path.join(moduleDir, "testdata", "goose-mock.sh"), + ).text(), }); } - return { id }; + return { id, coderEnvVars, scripts }; +}; + +// coder_env values reach coder_script processes through the agent environment. +const envArgs = (env: Record) => + Object.entries(env).flatMap(([key, value]) => ["--env", `${key}=${value}`]); + +const runScript = async ( + id: string, + name: string, + script: string, + env: Record = {}, +) => { + const target = `/tmp/coder-utils-${name}.sh`; + await writeExecutable({ containerId: id, filePath: target, content: script }); + return execContainer(id, ["bash", "-c", target], envArgs(env)); +}; + +const runScripts = async ( + id: string, + scripts: ModuleScripts, + env: Record = {}, +) => { + const ordered: [string, string | undefined][] = [ + ["pre_install", scripts.pre_install], + ["install", scripts.install], + ["post_install", scripts.post_install], + ]; + for (const [name, script] of ordered) { + if (!script) continue; + const resp = await runScript(id, name, script, env); + if (resp.exitCode !== 0) { + console.log(`script ${name} failed:`); + console.log(resp.stdout); + console.log(resp.stderr); + throw new Error(`coder-utils ${name} script exited ${resp.exitCode}`); + } + } +}; + +const moduleLogDir = "/home/coder/.coder-modules/coder/goose/logs"; +const installLog = (id: string) => + readFileContainer(id, `${moduleLogDir}/install.log`); +const configPath = "/home/coder/.config/goose/config.yaml"; + +const seedFile = async (id: string, filePath: string, content: string) => { + const encoded = Buffer.from(content).toString("base64"); + await execContainer(id, [ + "bash", + "-c", + `mkdir -p "$(dirname '${filePath}')" && echo '${encoded}' | base64 -d > '${filePath}'`, + ]); +}; + +// Replaces curl with a stub that prints `installer` as the downloaded script. +const mockInstallerDownload = async (id: string, installer: string[]) => { + await writeExecutable({ + containerId: id, + filePath: "/tmp/goose-installer-fixture.sh", + content: ["#!/usr/bin/env bash", ...installer].join("\n"), + }); + await writeExecutable({ + containerId: id, + filePath: "/usr/local/bin/curl", + content: [ + "#!/bin/bash", + "printf '%s\\n' \"$*\" > /tmp/goose-curl-args", + "cat /tmp/goose-installer-fixture.sh", + ].join("\n"), + }); }; -// increase the default timeout to 60 seconds -setDefaultTimeout(60 * 1000); +const installFixtureBinary = [ + 'mkdir -p "$HOME/.local/bin"', + `printf '#!/bin/sh\\necho " 1.54.0"\\n' > "$HOME/.local/bin/goose"`, + 'chmod +x "$HOME/.local/bin/goose"', +]; + +const recordInstallerEnv = [ + 'printf "GOOSE_VERSION=[%s] CONFIGURE=%s\\n" "$GOOSE_VERSION" "$CONFIGURE" > /tmp/goose-installer-env', +]; + +const everythingYaml = dedent` + everything: + name: everything + type: stdio + cmd: npx + args: ["-y", "@modelcontextprotocol/server-everything"] + enabled: true + timeout: 300 +`; + +const developerYaml = dedent` + extensions: + developer: + display_name: Developer + enabled: true + name: developer + timeout: 300 + type: builtin +`; + +setDefaultTimeout(120 * 1000); describe("goose", async () => { beforeAll(async () => { await runTerraformInit(import.meta.dir); }); - test("happy-path", async () => { - const { id } = await setup(); - - await execModuleScript(id); + test("happy-path-writes-config", async () => { + const { id, scripts } = await setup(); + await runScripts(id, scripts); + const log = await installLog(id); + expect(log).toContain("Skipping Goose installation"); + expect(log).not.toContain("agentapi"); + expect(await readFileContainer(id, configPath)).toBe( + `GOOSE_PROVIDER: anthropic\nGOOSE_MODEL: claude-sonnet-4-6\n${developerYaml}\n`, + ); + }); - await expectAgentAPIStarted(id); + test("preinstalled-binary-required-when-install-disabled", async () => { + const { id, scripts } = await setup({ skipGooseMock: true }); + const resp = await runScript(id, "install", scripts.install); + expect(resp.exitCode).not.toBe(0); + expect(await installLog(id)).toContain("Error: Goose is not installed."); }); - test("install-version", async () => { - const { id } = await setup({ + test("official-installer-is-used-for-stable", async () => { + const { id, scripts } = await setup({ skipGooseMock: true, - moduleVariables: { - install_goose: "true", - goose_version: "v1.0.24", - }, + moduleVariables: { install_goose: "true" }, }); - - await execModuleScript(id); - - const resp = await execContainer(id, [ - "bash", - "-c", - `"$HOME/.local/bin/goose" --version`, + await mockInstallerDownload(id, [ + ...recordInstallerEnv, + ...installFixtureBinary, ]); - if (resp.exitCode !== 0) { - console.log(resp.stdout); - console.log(resp.stderr); - } - expect(resp.exitCode).toBe(0); - expect(resp.stdout).toContain("1.0.24"); + await runScripts(id, scripts); + expect(await installLog(id)).toContain("Goose installed"); + expect(await readFileContainer(id, "/tmp/goose-curl-args")).toContain( + "https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh", + ); + expect(await readFileContainer(id, "/tmp/goose-installer-env")).toContain( + "GOOSE_VERSION=[] CONFIGURE=false", + ); }); - test("install-stable", async () => { - const { id } = await setup({ + test("pinned-version-is-passed-to-installer", async () => { + const { id, scripts } = await setup({ skipGooseMock: true, - moduleVariables: { - install_goose: "true", - goose_version: "stable", - }, + moduleVariables: { install_goose: "true", goose_version: "v1.53.0" }, }); - - await execModuleScript(id); - - const resp = await execContainer(id, [ - "bash", - "-c", - `"$HOME/.local/bin/goose" --version`, + await mockInstallerDownload(id, [ + ...recordInstallerEnv, + ...installFixtureBinary, ]); - if (resp.exitCode !== 0) { - console.log(resp.stdout); - console.log(resp.stderr); - } - expect(resp.exitCode).toBe(0); - await expectAgentAPIStarted(id); - }); - - test("config", async () => { - const expected = - dedent` - GOOSE_PROVIDER: anthropic - GOOSE_MODEL: claude-3-5-sonnet-latest - extensions: - coder: - args: - - exp - - mcp - - server - cmd: coder - description: Report ALL tasks and statuses (in progress, done, failed) you are working on. - enabled: true - envs: - CODER_MCP_APP_STATUS_SLUG: goose - CODER_MCP_AI_AGENTAPI_URL: http://localhost:3284 - name: Coder - timeout: 3000 - type: stdio - developer: - display_name: Developer - enabled: true - name: developer - timeout: 300 - type: builtin - custom-stuff: - enabled: true - name: custom-stuff - timeout: 300 - type: builtin - `.trim() + "\n"; - - const { id } = await setup({ - moduleVariables: { - goose_provider: "anthropic", - goose_model: "claude-3-5-sonnet-latest", - additional_extensions: dedent` - custom-stuff: - enabled: true - name: custom-stuff - timeout: 300 - type: builtin - `.trim(), - }, - }); - await execModuleScript(id); - const resp = await readFileContainer( - id, - "/home/coder/.config/goose/config.yaml", + await runScripts(id, scripts); + expect(await readFileContainer(id, "/tmp/goose-installer-env")).toContain( + "GOOSE_VERSION=[v1.53.0]", ); - expect(resp).toEqual(expected); }); - test("pre-post-install-scripts", async () => { - const { id } = await setup({ - moduleVariables: { - pre_install_script: "#!/bin/bash\necho 'pre-install-script'", - post_install_script: "#!/bin/bash\necho 'post-install-script'", - }, + test("installer-download-failure-is-terminal", async () => { + const { id, scripts } = await setup({ + skipGooseMock: true, + moduleVariables: { install_goose: "true" }, }); + await writeExecutable({ + containerId: id, + filePath: "/usr/local/bin/curl", + content: "#!/bin/bash\nexit 22\n", + }); + const resp = await runScript(id, "install", scripts.install); + expect(resp.exitCode).not.toBe(0); + expect(await installLog(id)).not.toContain("Goose installed"); + }); - await execModuleScript(id); - - const preInstallLog = await readFileContainer( - id, - "/home/coder/.goose-module/pre_install.log", - ); - expect(preInstallLog).toContain("pre-install-script"); - - const postInstallLog = await readFileContainer( - id, - "/home/coder/.goose-module/post_install.log", - ); - expect(postInstallLog).toContain("post-install-script"); + test("symlinks-into-coder-script-bin-dir", async () => { + const { id, scripts } = await setup(); + await execContainer(id, ["mkdir", "-p", "/tmp/coder-bin"]); + await runScripts(id, scripts, { CODER_SCRIPT_BIN_DIR: "/tmp/coder-bin" }); + const link = await execContainer(id, ["readlink", "/tmp/coder-bin/goose"]); + expect(link.stdout.trim()).toBe("/usr/bin/goose"); }); - const promptFile = "/home/coder/.goose-module/prompt.txt"; - const agentapiStartLog = "/home/coder/.goose-module/agentapi-start.log"; + test("workdir-is-created", async () => { + const { id, scripts } = await setup(); + await runScripts(id, scripts); + const dir = await execContainer(id, ["test", "-d", projectDir]); + expect(dir.exitCode).toBe(0); + }); - test("start-with-prompt", async () => { - const { id } = await setup({ - agentapiMockScript: await loadTestFile( - import.meta.dir, - "agentapi-mock-print-args.js", - ), + test("additional-extensions-overwrite-config", async () => { + const { id, scripts } = await setup({ + moduleVariables: { additional_extensions: everythingYaml }, }); - await execModuleScript(id, { - GOOSE_TASK_PROMPT: "custom-test-prompt", - }); - const prompt = await readFileContainer(id, promptFile); - expect(prompt).toContain("custom-test-prompt"); - - const agentapiMockOutput = await readFileContainer(id, agentapiStartLog); - expect(agentapiMockOutput).toContain( - "'goose run --interactive --instructions /home/coder/.goose-module/prompt.txt '", + await seedFile(id, configPath, "GOOSE_MODE: approve\n"); + await runScripts(id, scripts); + const cfg = await readFileContainer(id, configPath); + expect(cfg).not.toContain("approve"); + expect(cfg).toContain( + `${developerYaml}\n everything:\n name: everything\n`, ); + expect(cfg).not.toContain("coder"); }); - test("start-without-prompt", async () => { - const { id } = await setup({ - agentapiMockScript: await loadTestFile( - import.meta.dir, - "agentapi-mock-print-args.js", - ), - }); - await execModuleScript(id); - - const agentapiMockOutput = await readFileContainer(id, agentapiStartLog); - expect(agentapiMockOutput).toContain("'goose '"); - - const prompt = await execContainer(id, ["ls", "-l", promptFile]); - expect(prompt.exitCode).not.toBe(0); - expect(prompt.stderr).toContain("No such file or directory"); + test("system-prompt-is-written-to-goosehints", async () => { + const { id, scripts } = await setup(); + await runScripts(id, scripts, { GOOSE_SYSTEM_PROMPT: "be brief" }); + expect( + await readFileContainer(id, "/home/coder/.config/goose/.goosehints"), + ).toBe("be brief\n"); }); - describe("subdomain", async () => { - it("sets AGENTAPI_CHAT_BASE_PATH when false", async () => { - const { id } = await setup({ - agentapiMockScript: await loadTestFile( - import.meta.dir, - "agentapi-mock-print-args.js", - ), - moduleVariables: { - subdomain: "false", - }, - }); - - await execModuleScript(id); - - const agentapiMockOutput = await readFileContainer(id, agentapiStartLog); - expect(agentapiMockOutput).toContain( - "AGENTAPI_CHAT_BASE_PATH=/@default/default.foo/apps/goose/chat", - ); + test("real-goose-loads-config", async () => { + const { id, scripts } = await setup({ + skipGooseMock: true, + moduleVariables: { + install_goose: "true", + goose_version: "v1.54.0", + additional_extensions: everythingYaml, + }, }); + await runScripts(id, scripts); + const info = await execContainer(id, [ + "bash", + "-c", + "$HOME/.local/bin/goose info -v", + ]); + expect(info.exitCode).toBe(0); + expect(info.stdout).toContain("1.54.0"); + expect(info.stdout).toContain("GOOSE_PROVIDER: anthropic"); + expect(info.stdout).toContain("GOOSE_MODEL: claude-sonnet-4-6"); + expect(info.stdout).toContain(" developer:"); + expect(info.stdout).toContain(" everything:"); + }); - it("does not set AGENTAPI_CHAT_BASE_PATH when true", async () => { - const { id } = await setup({ - agentapiMockScript: await loadTestFile( - import.meta.dir, - "agentapi-mock-print-args.js", - ), - moduleVariables: { - subdomain: "true", - }, - }); - - await execModuleScript(id); - - const agentapiMockOutput = await readFileContainer(id, agentapiStartLog); - expect(agentapiMockOutput).toMatch(/AGENTAPI_CHAT_BASE_PATH=$/m); + test("pre-post-install-scripts", async () => { + const { id, scripts } = await setup({ + moduleVariables: { + pre_install_script: "#!/bin/bash\necho 'goose-pre-install-script'", + post_install_script: "#!/bin/bash\necho 'goose-post-install-script'", + }, }); + await runScripts(id, scripts); + expect( + await readFileContainer(id, `${moduleLogDir}/pre_install.log`), + ).toContain("goose-pre-install-script"); + expect( + await readFileContainer(id, `${moduleLogDir}/post_install.log`), + ).toContain("goose-post-install-script"); }); }); diff --git a/registry/coder/modules/goose/main.tf b/registry/coder/modules/goose/main.tf index 87d3cce74e..cca2e8ce5a 100644 --- a/registry/coder/modules/goose/main.tf +++ b/registry/coder/modules/goose/main.tf @@ -14,64 +14,30 @@ variable "agent_id" { description = "The ID of a Coder agent." } -data "coder_workspace" "me" {} - -data "coder_workspace_owner" "me" {} - -variable "order" { - type = number - description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." - default = null -} - -variable "group" { - type = string - description = "The name of a group that this app belongs to." - default = null -} - variable "icon" { type = string - description = "The icon to use for the app." + description = "The icon to use for the install scripts." default = "/icon/goose.svg" } -variable "folder" { +variable "workdir" { type = string - description = "The folder to run Goose in." - default = "/home/coder" + description = "Optional project directory. When set, the module creates it if missing." + default = null } variable "install_goose" { type = bool - description = "Whether to install Goose." + description = "Whether to install Goose with the official installer (https://goose-docs.ai/docs/getting-started/installation). When false, goose must already be on PATH." default = true } variable "goose_version" { type = string - description = "The version of Goose to install." + description = "The version of Goose to install, for example 'v1.54.0'. Use 'stable' for the latest stable release." default = "stable" } -variable "install_agentapi" { - type = bool - description = "Whether to install AgentAPI." - default = true -} - -variable "agentapi_version" { - type = string - description = "The version of AgentAPI to install." - default = "v0.10.0" -} - -variable "subdomain" { - type = bool - description = "Whether to use a subdomain for AgentAPI." - default = false -} - variable "goose_provider" { type = string description = "The provider to use for Goose (e.g., anthropic)." @@ -79,7 +45,7 @@ variable "goose_provider" { variable "goose_model" { type = string - description = "The model to use for Goose (e.g., claude-3-5-sonnet-latest)." + description = "The model to use for Goose (e.g., claude-sonnet-4-6)." } variable "pre_install_script" { @@ -101,22 +67,8 @@ variable "additional_extensions" { } locals { - app_slug = "goose" + workdir = var.workdir != null ? trimsuffix(var.workdir, "/") : "" base_extensions = <<-EOT -coder: - args: - - exp - - mcp - - server - cmd: coder - description: Report ALL tasks and statuses (in progress, done, failed) you are working on. - enabled: true - envs: - CODER_MCP_APP_STATUS_SLUG: ${local.app_slug} - CODER_MCP_AI_AGENTAPI_URL: http://localhost:3284 - name: Coder - timeout: 3000 - type: stdio developer: display_name: Developer enabled: true @@ -132,49 +84,31 @@ EOT extensions: ${local.formatted_base}${local.additional_extensions} EOT - install_script = file("${path.module}/scripts/install.sh") - start_script = file("${path.module}/scripts/start.sh") - module_dir_name = ".goose-module" - folder = trimsuffix(var.folder, "/") -} - -module "agentapi" { - source = "registry.coder.com/coder/agentapi/coder" - version = "2.0.0" - - agent_id = var.agent_id - web_app_slug = local.app_slug - web_app_order = var.order - web_app_group = var.group - web_app_icon = var.icon - web_app_display_name = "Goose" - cli_app_slug = "${local.app_slug}-cli" - cli_app_display_name = "Goose CLI" - module_dir_name = local.module_dir_name - install_agentapi = var.install_agentapi - agentapi_version = var.agentapi_version - agentapi_subdomain = var.subdomain - pre_install_script = var.pre_install_script - post_install_script = var.post_install_script - start_script = local.start_script - folder = local.folder - install_script = <<-EOT - #!/usr/bin/env bash - set -o errexit - set -o pipefail - - echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh - chmod +x /tmp/install.sh - - ARG_PROVIDER='${var.goose_provider}' \ - ARG_MODEL='${var.goose_model}' \ - ARG_GOOSE_CONFIG="$(echo -n '${base64encode(local.combined_extensions)}' | base64 -d)" \ - ARG_INSTALL='${var.install_goose}' \ - ARG_GOOSE_VERSION='${var.goose_version}' \ - /tmp/install.sh - EOT -} - -output "task_app_id" { - value = module.agentapi.task_app_id + install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { + ARG_PROVIDER = base64encode(var.goose_provider) + ARG_MODEL = base64encode(var.goose_model) + ARG_GOOSE_CONFIG = base64encode(local.combined_extensions) + ARG_INSTALL = tostring(var.install_goose) + ARG_GOOSE_VERSION = base64encode(var.goose_version) + ARG_WORKDIR = local.workdir != "" ? base64encode(local.workdir) : "" + }) + module_dir_name = ".coder-modules/coder/goose" +} + +module "coder_utils" { + source = "registry.coder.com/coder/coder-utils/coder" + version = "0.0.1" + + agent_id = var.agent_id + module_directory = "$HOME/${local.module_dir_name}" + display_name_prefix = "Goose" + icon = var.icon + pre_install_script = var.pre_install_script + post_install_script = var.post_install_script + install_script = local.install_script +} + +output "scripts" { + description = "Ordered list of coder exp sync names for the coder_script resources this module creates, in run order (pre_install, install, post_install). Scripts that were not configured are absent from the list." + value = module.coder_utils.scripts } diff --git a/registry/coder/modules/goose/main.tftest.hcl b/registry/coder/modules/goose/main.tftest.hcl new file mode 100644 index 0000000000..8f125c0e91 --- /dev/null +++ b/registry/coder/modules/goose/main.tftest.hcl @@ -0,0 +1,115 @@ +variables { + agent_id = "test-agent" + goose_provider = "anthropic" + goose_model = "claude-sonnet-4-6" +} + +run "defaults_are_correct" { + command = plan + + assert { + condition = var.install_goose == true + error_message = "install_goose should default to true" + } + + assert { + condition = var.goose_version == "stable" + error_message = "goose_version should default to 'stable'" + } + + assert { + condition = local.workdir == "" + error_message = "workdir should be empty by default" + } + + assert { + condition = local.module_dir_name == ".coder-modules/coder/goose" + error_message = "module_dir_name should be '.coder-modules/coder/goose'" + } + + assert { + condition = strcontains(local.install_script, base64encode("stable")) + error_message = "install script should receive the goose version" + } + + assert { + condition = local.combined_extensions == "extensions:\n developer:\n display_name: Developer\n enabled: true\n name: developer\n timeout: 300\n type: builtin\n" + error_message = "config should only contain the developer extension by default" + } +} + +run "workdir_trailing_slash_is_trimmed" { + command = plan + + variables { + workdir = "/home/coder/project/" + } + + assert { + condition = local.workdir == "/home/coder/project" + error_message = "workdir should have its trailing slash trimmed" + } + + assert { + condition = strcontains(local.install_script, base64encode("/home/coder/project")) + error_message = "install script should receive the base64-encoded workdir" + } +} + +run "additional_extensions_are_appended" { + command = plan + + variables { + additional_extensions = <<-EOT + everything: + name: everything + type: stdio + EOT + } + + assert { + condition = strcontains(local.combined_extensions, " type: builtin\n everything:\n name: everything\n type: stdio\n") + error_message = "additional_extensions should be appended after the developer extension" + } + + assert { + condition = strcontains(local.install_script, base64encode(local.combined_extensions)) + error_message = "install script should receive the combined extensions" + } +} + +run "pinned_goose_version_is_passed" { + command = plan + + variables { + goose_version = "v1.53.0" + } + + assert { + condition = strcontains(local.install_script, base64encode("v1.53.0")) + error_message = "install script should receive the pinned goose version" + } +} + +run "scripts_output_is_ordered" { + command = plan + + variables { + pre_install_script = "echo pre" + post_install_script = "echo post" + } + + assert { + condition = output.scripts == ["coder-goose-pre_install_script", "coder-goose-install_script", "coder-goose-post_install_script"] + error_message = "scripts output should be ordered pre_install, install, post_install" + } +} + +run "scripts_output_install_only" { + command = plan + + assert { + condition = output.scripts == ["coder-goose-install_script"] + error_message = "scripts output should only contain install when no pre/post scripts are set" + } +} diff --git a/registry/coder/modules/goose/scripts/install.sh b/registry/coder/modules/goose/scripts/install.sh deleted file mode 100644 index 340d02eb81..0000000000 --- a/registry/coder/modules/goose/scripts/install.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env bash - -# Function to check if a command exists -command_exists() { - command -v "$1" > /dev/null 2>&1 -} - -set -o nounset - -echo "--------------------------------" -echo "provider: $ARG_PROVIDER" -echo "model: $ARG_MODEL" -echo "goose_config: $ARG_GOOSE_CONFIG" -echo "install: $ARG_INSTALL" -echo "goose_version: $ARG_GOOSE_VERSION" -echo "--------------------------------" - -set +o nounset - -if [ "${ARG_INSTALL}" = "true" ]; then - echo "Installing Goose..." - parsed_version="${ARG_GOOSE_VERSION}" - if [ "${ARG_GOOSE_VERSION}" = "stable" ]; then - parsed_version="" - fi - curl -fsSL https://github.com/block/goose/releases/download/stable/download_cli.sh | GOOSE_VERSION="${parsed_version}" CONFIGURE=false bash - echo "Goose installed" -else - echo "Skipping Goose installation" -fi - -if [ "${ARG_GOOSE_CONFIG}" != "" ]; then - echo "Configuring Goose..." - mkdir -p "$HOME/.config/goose" - echo "GOOSE_PROVIDER: $ARG_PROVIDER" > "$HOME/.config/goose/config.yaml" - echo "GOOSE_MODEL: $ARG_MODEL" >> "$HOME/.config/goose/config.yaml" - echo "$ARG_GOOSE_CONFIG" >> "$HOME/.config/goose/config.yaml" -else - echo "Skipping Goose configuration" -fi - -if [ "${GOOSE_SYSTEM_PROMPT}" != "" ]; then - echo "Setting Goose system prompt..." - mkdir -p "$HOME/.config/goose" - echo "$GOOSE_SYSTEM_PROMPT" > "$HOME/.config/goose/.goosehints" -else - echo "Goose system prompt not set. use the GOOSE_SYSTEM_PROMPT environment variable to set it." -fi - -if command_exists goose; then - GOOSE_CMD=goose -elif [ -f "$HOME/.local/bin/goose" ]; then - GOOSE_CMD="$HOME/.local/bin/goose" -else - echo "Error: Goose is not installed. Please enable install_goose or install it manually." - exit 1 -fi diff --git a/registry/coder/modules/goose/scripts/install.sh.tftpl b/registry/coder/modules/goose/scripts/install.sh.tftpl new file mode 100644 index 0000000000..48b0f5fe6a --- /dev/null +++ b/registry/coder/modules/goose/scripts/install.sh.tftpl @@ -0,0 +1,72 @@ +#!/usr/bin/env bash + +set -euo pipefail + +command_exists() { + command -v "$1" > /dev/null 2>&1 +} + +ARG_PROVIDER=$(echo -n '${ARG_PROVIDER}' | base64 -d) +ARG_MODEL=$(echo -n '${ARG_MODEL}' | base64 -d) +ARG_GOOSE_CONFIG=$(echo -n '${ARG_GOOSE_CONFIG}' | base64 -d) +ARG_INSTALL='${ARG_INSTALL}' +ARG_GOOSE_VERSION=$(echo -n '${ARG_GOOSE_VERSION}' | base64 -d) +ARG_WORKDIR=$(echo -n '${ARG_WORKDIR}' | base64 -d) + +echo "--------------------------------" +echo "provider: $${ARG_PROVIDER}" +echo "model: $${ARG_MODEL}" +echo "goose_config: $${ARG_GOOSE_CONFIG}" +echo "install: $${ARG_INSTALL}" +echo "goose_version: $${ARG_GOOSE_VERSION}" +echo "--------------------------------" + +if [ "$${ARG_INSTALL}" = "true" ]; then + echo "Installing Goose..." + parsed_version="$${ARG_GOOSE_VERSION}" + if [ "$${ARG_GOOSE_VERSION}" = "stable" ]; then + parsed_version="" + fi + # The installer downloads the release archive into the current directory. + (cd "$(mktemp -d)" && curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh | GOOSE_VERSION="$${parsed_version}" CONFIGURE=false bash) + echo "Goose installed" +else + echo "Skipping Goose installation" +fi + +if [ -n "$${ARG_GOOSE_CONFIG}" ]; then + echo "Configuring Goose..." + mkdir -p "$HOME/.config/goose" + echo "GOOSE_PROVIDER: $${ARG_PROVIDER}" > "$HOME/.config/goose/config.yaml" + echo "GOOSE_MODEL: $${ARG_MODEL}" >> "$HOME/.config/goose/config.yaml" + echo "$${ARG_GOOSE_CONFIG}" >> "$HOME/.config/goose/config.yaml" +else + echo "Skipping Goose configuration" +fi + +if [ -n "$${GOOSE_SYSTEM_PROMPT:-}" ]; then + echo "Setting Goose system prompt..." + mkdir -p "$HOME/.config/goose" + echo "$${GOOSE_SYSTEM_PROMPT}" > "$HOME/.config/goose/.goosehints" +else + echo "Goose system prompt not set. use the GOOSE_SYSTEM_PROMPT environment variable to set it." +fi + +if [ -n "$${ARG_WORKDIR}" ] && [ ! -d "$${ARG_WORKDIR}" ]; then + echo "Creating workdir: $${ARG_WORKDIR}" + mkdir -p "$${ARG_WORKDIR}" +fi + +if command_exists goose; then + GOOSE_CMD=$(command -v goose) +elif [ -f "$HOME/.local/bin/goose" ]; then + GOOSE_CMD="$HOME/.local/bin/goose" +else + echo "Error: Goose is not installed. Please enable install_goose or install it manually." + exit 1 +fi + +# The module no longer launches Goose, so expose it on PATH for apps and scripts. +if [ -n "$${CODER_SCRIPT_BIN_DIR:-}" ] && [ ! -e "$${CODER_SCRIPT_BIN_DIR}/goose" ]; then + ln -s "$${GOOSE_CMD}" "$${CODER_SCRIPT_BIN_DIR}/goose" +fi diff --git a/registry/coder/modules/goose/scripts/start.sh b/registry/coder/modules/goose/scripts/start.sh deleted file mode 100644 index 7813624496..0000000000 --- a/registry/coder/modules/goose/scripts/start.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env bash - -set -o errexit -set -o pipefail - -command_exists() { - command -v "$1" > /dev/null 2>&1 -} - -if command_exists goose; then - GOOSE_CMD=goose -elif [ -f "$HOME/.local/bin/goose" ]; then - GOOSE_CMD="$HOME/.local/bin/goose" -else - echo "Error: Goose is not installed. Please enable install_goose or install it manually." - exit 1 -fi - -# this must be kept up to date with main.tf -MODULE_DIR="$HOME/.goose-module" -mkdir -p "$MODULE_DIR" - -if [ ! -z "$GOOSE_TASK_PROMPT" ]; then - echo "Starting with a prompt" - PROMPT="Review your goosehints. Every step of the way, report tasks to Coder with proper descriptions and statuses. Your task at hand: $GOOSE_TASK_PROMPT" - PROMPT_FILE="$MODULE_DIR/prompt.txt" - echo -n "$PROMPT" > "$PROMPT_FILE" - GOOSE_ARGS=(run --interactive --instructions "$PROMPT_FILE") -else - echo "Starting without a prompt" - GOOSE_ARGS=() -fi - -agentapi server --term-width 67 --term-height 1190 -- \ - bash -c "$(printf '%q ' "$GOOSE_CMD" "${GOOSE_ARGS[@]}")" diff --git a/registry/coder/modules/goose/testdata/agentapi-mock-print-args.js b/registry/coder/modules/goose/testdata/agentapi-mock-print-args.js deleted file mode 100644 index fb86ca583f..0000000000 --- a/registry/coder/modules/goose/testdata/agentapi-mock-print-args.js +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env node - -const http = require("http"); -const args = process.argv.slice(2); -console.log(args); -console.log( - `AGENTAPI_CHAT_BASE_PATH=${process.env["AGENTAPI_CHAT_BASE_PATH"]}`, -); -const port = 3284; - -console.log(`starting server on port ${port}`); - -http - .createServer(function (_request, response) { - response.writeHead(200); - response.end( - JSON.stringify({ - status: "stable", - }), - ); - }) - .listen(port); diff --git a/registry/coder/modules/goose/testdata/goose-mock.sh b/registry/coder/modules/goose/testdata/goose-mock.sh index b6b3e38c9c..fafd40ca10 100644 --- a/registry/coder/modules/goose/testdata/goose-mock.sh +++ b/registry/coder/modules/goose/testdata/goose-mock.sh @@ -1,8 +1,11 @@ -#!/bin/bash +#!/usr/bin/env bash -set -e - -while true; do - echo "$(date) - goose-mock" - sleep 15 -done +case "$1" in + --version | -V) + echo " 1.54.0-mock" + ;; + *) + echo "goose invoked with: $*" + ;; +esac +exit 0