From 5340e50e4855cb7528b0970af3a2bebede6d19fc Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Thu, 26 Jun 2025 18:35:29 +0200 Subject: [PATCH] refactor --- .../coder/modules/claude-code/main.test.ts | 322 ++++++++++++++++++ registry/coder/modules/claude-code/main.tf | 177 ++++------ .../claude-code/scripts/agentapi-start.sh | 63 ++++ .../scripts/agentapi-wait-for-start.sh | 30 ++ .../scripts/remove-last-session-id.js | 40 +++ .../claude-code/testdata/agentapi-mock.js | 34 ++ .../claude-code/testdata/claude-mock.js | 9 + .../claude-code/testdata/coder-mock.js | 14 + test/test.ts | 50 ++- 9 files changed, 621 insertions(+), 118 deletions(-) create mode 100644 registry/coder/modules/claude-code/main.test.ts create mode 100644 registry/coder/modules/claude-code/scripts/agentapi-start.sh create mode 100644 registry/coder/modules/claude-code/scripts/agentapi-wait-for-start.sh create mode 100644 registry/coder/modules/claude-code/scripts/remove-last-session-id.js create mode 100644 registry/coder/modules/claude-code/testdata/agentapi-mock.js create mode 100644 registry/coder/modules/claude-code/testdata/claude-mock.js create mode 100644 registry/coder/modules/claude-code/testdata/coder-mock.js diff --git a/registry/coder/modules/claude-code/main.test.ts b/registry/coder/modules/claude-code/main.test.ts new file mode 100644 index 00000000..d9538d45 --- /dev/null +++ b/registry/coder/modules/claude-code/main.test.ts @@ -0,0 +1,322 @@ +import { + test, + afterEach, + expect, + describe, + setDefaultTimeout, + beforeAll, +} from "bun:test"; +import path from "path"; +import { + execContainer, + findResourceInstance, + removeContainer, + runContainer, + runTerraformApply, + runTerraformInit, + writeCoder, + writeFileContainer, +} from "~test"; + +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) { + try { + await cleanup(); + } catch (error) { + console.error("Error during cleanup:", error); + } + } +}); + +const setupContainer = async ({ + image, + vars, +}: { + image?: string; + vars?: Record; +} = {}) => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + ...vars, + }); + const coderScript = findResourceInstance(state, "coder_script"); + const id = await runContainer(image ?? "codercom/enterprise-node:latest"); + registerCleanup(() => removeContainer(id)); + return { id, coderScript }; +}; + +const loadTestFile = async (...relativePath: string[]) => { + return await Bun.file( + path.join(import.meta.dir, "testdata", ...relativePath), + ).text(); +}; + +const writeExecutable = async ({ + containerId, + filePath, + content, +}: { + containerId: string; + filePath: string; + content: string; +}) => { + await writeFileContainer(containerId, filePath, content, { + user: "root", + }); + await execContainer( + containerId, + ["bash", "-c", `chmod 755 ${filePath}`], + ["--user", "root"], + ); +}; + +const writeAgentAPIMockControl = async ({ + containerId, + content, +}: { + containerId: string; + content: string; +}) => { + await writeFileContainer(containerId, "/tmp/agentapi-mock.control", content, { + user: "coder", + }); +}; + +interface SetupProps { + skipAgentAPIMock?: boolean; + skipClaudeMock?: boolean; +} + +const projectDir = "/home/coder/project"; + +const setup = async (props?: SetupProps): Promise<{ id: string }> => { + const { id, coderScript } = await setupContainer({ + vars: { + experiment_report_tasks: "true", + install_agentapi: props?.skipAgentAPIMock ? "true" : "false", + install_claude_code: "false", + agentapi_version: "preview", + folder: projectDir, + }, + }); + await execContainer(id, ["bash", "-c", `mkdir -p '${projectDir}'`]); + // the module script assumes that there is a coder executable in the PATH + await writeCoder(id, await loadTestFile("coder-mock.js")); + if (!props?.skipAgentAPIMock) { + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/agentapi", + content: await loadTestFile("agentapi-mock.js"), + }); + } + if (!props?.skipClaudeMock) { + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/claude", + content: await loadTestFile("claude-mock.js"), + }); + } + await writeExecutable({ + containerId: id, + filePath: "/home/coder/script.sh", + content: coderScript.script, + }); + return { id }; +}; + +const expectAgentAPIStarted = async (id: string) => { + const resp = await execContainer(id, [ + "bash", + "-c", + `curl -fs -o /dev/null "http://localhost:3284/status"`, + ]); + if (resp.exitCode !== 0) { + console.log("agentapi not started"); + console.log(resp.stdout); + console.log(resp.stderr); + } + expect(resp.exitCode).toBe(0); +}; + +const execModuleScript = async (id: string) => { + const resp = await execContainer(id, [ + "bash", + "-c", + `set -o errexit; set -o pipefail; cd /home/coder && ./script.sh 2>&1 | tee /home/coder/script.log`, + ]); + if (resp.exitCode !== 0) { + console.log(resp.stdout); + console.log(resp.stderr); + } + return resp; +}; + +// increase the default timeout to 60 seconds +setDefaultTimeout(60 * 1000); + +// we don't run these tests in CI because they take too long and make network +// calls. they are dedicated for local development. +describe("claude-code", async () => { + beforeAll(async () => { + await runTerraformInit(import.meta.dir); + }); + + // test that the script runs successfully if claude starts without any errors + test("happy-path", async () => { + const { id } = await setup(); + + const resp = await execContainer(id, [ + "bash", + "-c", + "sudo /home/coder/script.sh", + ]); + expect(resp.exitCode).toBe(0); + + await expectAgentAPIStarted(id); + }); + + // test that the script removes lastSessionId from the .claude.json file + test("last-session-id-removed", async () => { + const { id } = await setup(); + + await writeFileContainer( + id, + "/home/coder/.claude.json", + JSON.stringify({ + projects: { + [projectDir]: { + lastSessionId: "123", + }, + }, + }), + ); + + const catResp = await execContainer(id, [ + "bash", + "-c", + "cat /home/coder/.claude.json", + ]); + expect(catResp.exitCode).toBe(0); + expect(catResp.stdout).toContain("lastSessionId"); + + const respModuleScript = await execModuleScript(id); + expect(respModuleScript.exitCode).toBe(0); + + await expectAgentAPIStarted(id); + + const catResp2 = await execContainer(id, [ + "bash", + "-c", + "cat /home/coder/.claude.json", + ]); + expect(catResp2.exitCode).toBe(0); + expect(catResp2.stdout).not.toContain("lastSessionId"); + }); + + // test that the script handles a .claude.json file that doesn't contain + // a lastSessionId field + test("last-session-id-not-found", async () => { + const { id } = await setup(); + + await writeFileContainer( + id, + "/home/coder/.claude.json", + JSON.stringify({ + projects: { + "/home/coder": {}, + }, + }), + ); + + const respModuleScript = await execModuleScript(id); + expect(respModuleScript.exitCode).toBe(0); + + await expectAgentAPIStarted(id); + + const catResp = await execContainer(id, [ + "bash", + "-c", + "cat /home/coder/.claude-module/agentapi-start.log", + ]); + expect(catResp.exitCode).toBe(0); + expect(catResp.stdout).toContain( + "No lastSessionId found in .claude.json - nothing to do", + ); + }); + + // test that if claude fails to run with the --continue flag and returns a + // no conversation found error, then the module script retries without the flag + test("no-conversation-found", async () => { + const { id } = await setup(); + await writeAgentAPIMockControl({ + containerId: id, + content: "no-conversation-found", + }); + // check that mocking works + const respAgentAPI = await execContainer(id, [ + "bash", + "-c", + "agentapi --continue", + ]); + expect(respAgentAPI.exitCode).toBe(1); + expect(respAgentAPI.stderr).toContain("No conversation found to continue"); + + const respModuleScript = await execModuleScript(id); + expect(respModuleScript.exitCode).toBe(0); + + await expectAgentAPIStarted(id); + }); + + test("install-agentapi", async () => { + const { id } = await setup({ skipAgentAPIMock: true }); + + const respModuleScript = await execModuleScript(id); + expect(respModuleScript.exitCode).toBe(0); + + await expectAgentAPIStarted(id); + const respAgentAPI = await execContainer(id, [ + "bash", + "-c", + "agentapi --version", + ]); + expect(respAgentAPI.exitCode).toBe(0); + }); + + // the coder binary should be executed with specific env vars + // that are set by the module script + test("coder-env-vars", async () => { + const { id } = await setup(); + + const respModuleScript = await execModuleScript(id); + expect(respModuleScript.exitCode).toBe(0); + + const respCoderMock = await execContainer(id, [ + "bash", + "-c", + "cat /home/coder/coder-mock-output.json", + ]); + if (respCoderMock.exitCode !== 0) { + console.log(respCoderMock.stdout); + console.log(respCoderMock.stderr); + } + expect(respCoderMock.exitCode).toBe(0); + expect(JSON.parse(respCoderMock.stdout)).toEqual({ + statusSlug: "ccw", + agentApiUrl: "http://localhost:3284", + }); + }); +}); diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf index 84b36ae5..19496eff 100644 --- a/registry/coder/modules/claude-code/main.tf +++ b/registry/coder/modules/claude-code/main.tf @@ -54,12 +54,24 @@ variable "claude_code_version" { default = "latest" } -variable "experiment_use_screen" { +variable "experiment_cli_app" { type = bool - description = "Whether to use screen for running Claude Code in the background." + description = "Whether to create the CLI workspace app." default = false } +variable "experiment_cli_app_order" { + type = number + description = "The order of the CLI workspace app." + default = null +} + +variable "experiment_cli_app_group" { + type = string + description = "The group of the CLI workspace app." + default = null +} + variable "experiment_report_tasks" { type = bool description = "Whether to enable task reporting." @@ -94,60 +106,13 @@ variable "agentapi_version" { locals { # we have to trim the slash because otherwise coder exp mcp will # set up an invalid claude config - workdir = trimsuffix(var.folder, "/") - encoded_pre_install_script = var.experiment_pre_install_script != null ? base64encode(var.experiment_pre_install_script) : "" - encoded_post_install_script = var.experiment_post_install_script != null ? base64encode(var.experiment_post_install_script) : "" - agentapi_start_command = <<-EOT - #!/bin/bash - set -e - - # if the first argument is not empty, start claude with the prompt - if [ -n "$1" ]; then - prompt="$(cat ~/.claude-code-prompt)" - cp ~/.claude-code-prompt /tmp/claude-code-prompt - else - rm -f /tmp/claude-code-prompt - fi - - # We need to check if there's a session to use --continue. If there's no session, - # using this flag would cause claude to exit with an error. - # warning: this is a hack and will break if claude changes the format of the .claude.json file. - # Also, this solution is not ideal: a user has to quit claude in order for the session id to appear - # in .claude.json. If they just restart the workspace, the session id will not be available. - continue_flag="" - if grep -q '"lastSessionId":' ~/.claude.json; then - echo "Found a Claude Code session to continue." - continue_flag="--continue" - else - echo "No Claude Code session to continue." - fi - - # use low width to fit in the tasks UI sidebar. height is adjusted so that width x height ~= 80x1000 characters - # visible in the terminal screen by default. - prompt_subshell='"$(cat /tmp/claude-code-prompt)"' - agentapi server --term-width 67 --term-height 1190 -- bash -c "claude $continue_flag --dangerously-skip-permissions $prompt_subshell" - EOT - agentapi_wait_for_start_command = <<-EOT - #!/bin/bash - set -o errexit - set -o pipefail - - echo "Waiting for agentapi server to start on port 3284..." - for i in $(seq 1 15); do - if lsof -i :3284 | grep -q 'LISTEN'; then - echo "agentapi server started on port 3284." - break - fi - echo "Waiting... ($i/15)" - sleep 1 - done - if ! lsof -i :3284 | grep -q 'LISTEN'; then - echo "Error: agentapi server did not start on port 3284 after 15 seconds." - exit 1 - fi - EOT - agentapi_start_command_base64 = base64encode(local.agentapi_start_command) - agentapi_wait_for_start_command_base64 = base64encode(local.agentapi_wait_for_start_command) + workdir = trimsuffix(var.folder, "/") + encoded_pre_install_script = var.experiment_pre_install_script != null ? base64encode(var.experiment_pre_install_script) : "" + encoded_post_install_script = var.experiment_post_install_script != null ? base64encode(var.experiment_post_install_script) : "" + agentapi_start_script_b64 = base64encode(file("${path.module}/scripts/agentapi-start.sh")) + agentapi_wait_for_start_script_b64 = base64encode(file("${path.module}/scripts/agentapi-wait-for-start.sh")) + remove_last_session_id_script_b64 = base64encode(file("${path.module}/scripts/remove-last-session-id.js")) + claude_code_app_slug = "ccw" } # Install and Initialize Claude Code @@ -158,6 +123,7 @@ resource "coder_script" "claude_code" { script = <<-EOT #!/bin/bash set -e + set -x command_exists() { command -v "$1" >/dev/null 2>&1 @@ -166,8 +132,6 @@ resource "coder_script" "claude_code" { if [ ! -d "${local.workdir}" ]; then echo "Warning: The specified folder '${local.workdir}' does not exist." echo "Creating the folder..." - # The folder must exist before tmux is started or else claude will start - # in the home directory. mkdir -p "${local.workdir}" echo "Folder created successfully." fi @@ -207,6 +171,11 @@ resource "coder_script" "claude_code" { npm install -g @anthropic-ai/claude-code@${var.claude_code_version} fi + if ! command_exists node; then + echo "Error: Node.js is not installed. Please install Node.js manually." + exit 1 + fi + # Install AgentAPI if enabled if [ "${var.install_agentapi}" = "true" ]; then echo "Installing AgentAPI..." @@ -219,26 +188,41 @@ resource "coder_script" "claude_code" { echo "Error: Unsupported architecture: $arch" exit 1 fi - wget "https://github.com/coder/agentapi/releases/download/${var.agentapi_version}/$binary_name" - chmod +x "$binary_name" - sudo mv "$binary_name" /usr/local/bin/agentapi + curl \ + --retry 5 \ + --retry-delay 5 \ + --fail \ + --retry-all-errors \ + -L \ + -C - \ + -o agentapi \ + "https://github.com/coder/agentapi/releases/download/${var.agentapi_version}/$binary_name" + chmod +x agentapi + sudo mv agentapi /usr/local/bin/agentapi fi if ! command_exists agentapi; then echo "Error: AgentAPI is not installed. Please enable install_agentapi or install it manually." exit 1 fi - # save the prompt for the agentapi start command - echo -n "$CODER_MCP_CLAUDE_TASK_PROMPT" > ~/.claude-code-prompt + # this must be kept in sync with the agentapi-start.sh script + module_path="$HOME/.claude-module" + mkdir -p "$module_path/scripts" - echo -n "${local.agentapi_start_command_base64}" | base64 -d > ~/.agentapi-start-command - chmod +x ~/.agentapi-start-command - echo -n "${local.agentapi_wait_for_start_command_base64}" | base64 -d > ~/.agentapi-wait-for-start-command - chmod +x ~/.agentapi-wait-for-start-command + # save the prompt for the agentapi start command + echo -n "$CODER_MCP_CLAUDE_TASK_PROMPT" > "$module_path/prompt.txt" + + echo -n "${local.agentapi_start_script_b64}" | base64 -d > "$module_path/scripts/agentapi-start.sh" + echo -n "${local.agentapi_wait_for_start_script_b64}" | base64 -d > "$module_path/scripts/agentapi-wait-for-start.sh" + echo -n "${local.remove_last_session_id_script_b64}" | base64 -d > "$module_path/scripts/remove-last-session-id.js" + chmod +x "$module_path/scripts/agentapi-start.sh" + chmod +x "$module_path/scripts/agentapi-wait-for-start.sh" if [ "${var.experiment_report_tasks}" = "true" ]; then echo "Configuring Claude Code to report tasks via Coder MCP..." - coder exp mcp configure claude-code ${local.workdir} --ai-agentapi-url http://localhost:3284 + export CODER_MCP_APP_STATUS_SLUG="${local.claude_code_app_slug}" + export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284" + coder exp mcp configure claude-code "${local.workdir}" fi if [ -n "${local.encoded_post_install_script}" ]; then @@ -253,60 +237,38 @@ resource "coder_script" "claude_code" { exit 1 fi - echo "Running Claude Code in the background..." - if ! command_exists screen; then - echo "Error: screen is not installed. Please install screen manually." - exit 1 - fi - - touch "$HOME/.claude-code.log" - if [ ! -f "$HOME/.screenrc" ]; then - echo "Creating ~/.screenrc and adding multiuser settings..." | tee -a "$HOME/.claude-code.log" - echo -e "multiuser on\nacladd $(whoami)" > "$HOME/.screenrc" - fi - - if ! grep -q "^multiuser on$" "$HOME/.screenrc"; then - echo "Adding 'multiuser on' to ~/.screenrc..." | tee -a "$HOME/.claude-code.log" - echo "multiuser on" >> "$HOME/.screenrc" - fi - - if ! grep -q "^acladd $(whoami)$" "$HOME/.screenrc"; then - echo "Adding 'acladd $(whoami)' to ~/.screenrc..." | tee -a "$HOME/.claude-code.log" - echo "acladd $(whoami)" >> "$HOME/.screenrc" - fi - export LANG=en_US.UTF-8 export LC_ALL=en_US.UTF-8 - screen -U -dmS agentapi-cc bash -c ' - cd ${local.workdir} - # setting the first argument will make claude use the prompt - ~/.agentapi-start-command true - exec bash - ' - ~/.agentapi-wait-for-start-command + cd "${local.workdir}" + nohup "$module_path/scripts/agentapi-start.sh" use_prompt &> "$module_path/agentapi-start.log" & + "$module_path/scripts/agentapi-wait-for-start.sh" EOT run_on_start = true } resource "coder_app" "claude_code_web" { # use a short slug to mitigate https://github.com/coder/coder/issues/15178 - slug = "ccw" + slug = local.claude_code_app_slug display_name = "Claude Code Web" agent_id = var.agent_id url = "http://localhost:3284/" icon = var.icon + order = var.order + group = var.group subdomain = true healthcheck { url = "http://localhost:3284/status" - interval = 6 - threshold = 10 + interval = 3 + threshold = 20 } } resource "coder_app" "claude_code" { + count = var.experiment_cli_app ? 1 : 0 + slug = "claude-code" - display_name = "Claude Code" + display_name = "Claude Code CLI" agent_id = var.agent_id command = <<-EOT #!/bin/bash @@ -315,20 +277,11 @@ resource "coder_app" "claude_code" { export LANG=en_US.UTF-8 export LC_ALL=en_US.UTF-8 - if ! screen -list | grep -q "agentapi-cc"; then - screen -S agentapi-cc bash -c ' - cd ${local.workdir} - # start agentapi without claude using the prompt (no argument) - ~/.agentapi-start-command - exec bash - ' - fi - agentapi attach EOT icon = var.icon - order = var.order - group = var.group + order = var.experiment_cli_app_order + group = var.experiment_cli_app_group } resource "coder_ai_task" "claude_code" { diff --git a/registry/coder/modules/claude-code/scripts/agentapi-start.sh b/registry/coder/modules/claude-code/scripts/agentapi-start.sh new file mode 100644 index 00000000..c66b7f35 --- /dev/null +++ b/registry/coder/modules/claude-code/scripts/agentapi-start.sh @@ -0,0 +1,63 @@ +#!/bin/bash +set -o errexit +set -o pipefail + +# this must be kept in sync with the main.tf file +module_path="$HOME/.claude-module" +scripts_dir="$module_path/scripts" +log_file_path="$module_path/agentapi.log" + +# if the first argument is not empty, start claude with the prompt +if [ -n "$1" ]; then + cp "$module_path/prompt.txt" /tmp/claude-code-prompt +else + rm -f /tmp/claude-code-prompt +fi + +# if the log file already exists, archive it +if [ -f "$log_file_path" ]; then + mv "$log_file_path" "$log_file_path"".$(date +%s)" +fi + +# see the remove-last-session-id.js script for details +# about why we need it +# avoid exiting if the script fails +node "$scripts_dir/remove-last-session-id.js" "$(pwd)" || true + +# we'll be manually handling errors from this point on +set +o errexit + +function start_agentapi() { + local continue_flag="$1" + local prompt_subshell='"$(cat /tmp/claude-code-prompt)"' + + # use low width to fit in the tasks UI sidebar. height is adjusted so that width x height ~= 80x1000 characters + # visible in the terminal screen by default. + agentapi server --term-width 67 --term-height 1190 -- \ + bash -c "claude $continue_flag --dangerously-skip-permissions $prompt_subshell" \ + > "$log_file_path" 2>&1 +} + +echo "Starting AgentAPI..." + +# attempt to start claude with the --continue flag +start_agentapi --continue +exit_code=$? + +echo "First AgentAPI exit code: $exit_code" + +if [ $exit_code -eq 0 ]; then + exit 0 +fi + +# if there was no conversation to continue, claude exited with an error. +# start claude without the --continue flag. +if grep -q "No conversation found to continue" "$log_file_path"; then + echo "AgentAPI with --continue flag failed, starting claude without it." + start_agentapi + exit_code=$? +fi + +echo "Second AgentAPI exit code: $exit_code" + +exit $exit_code diff --git a/registry/coder/modules/claude-code/scripts/agentapi-wait-for-start.sh b/registry/coder/modules/claude-code/scripts/agentapi-wait-for-start.sh new file mode 100644 index 00000000..2eb84975 --- /dev/null +++ b/registry/coder/modules/claude-code/scripts/agentapi-wait-for-start.sh @@ -0,0 +1,30 @@ +#!/bin/bash +set -o errexit +set -o pipefail + +# This script waits for the agentapi server to start on port 3284. +# It considers the server started after 3 consecutive successful responses. + +agentapi_started=false + +echo "Waiting for agentapi server to start on port 3284..." +for i in $(seq 1 150); do + for j in $(seq 1 3); do + sleep 0.1 + if curl -fs -o /dev/null "http://localhost:3284/status"; then + echo "agentapi response received ($j/3)" + else + echo "agentapi server not responding ($i/15)" + continue 2 + fi + done + agentapi_started=true + break +done + +if [ "$agentapi_started" != "true" ]; then + echo "Error: agentapi server did not start on port 3284 after 15 seconds." + exit 1 +fi + +echo "agentapi server started on port 3284." diff --git a/registry/coder/modules/claude-code/scripts/remove-last-session-id.js b/registry/coder/modules/claude-code/scripts/remove-last-session-id.js new file mode 100644 index 00000000..0b66edfe --- /dev/null +++ b/registry/coder/modules/claude-code/scripts/remove-last-session-id.js @@ -0,0 +1,40 @@ +// If lastSessionId is present in .claude.json, claude --continue will start a +// conversation starting from that session. The problem is that lastSessionId +// doesn't always point to the last session. The field is updated by claude only +// at the point of normal CLI exit. If Claude exits with an error, or if the user +// restarts the Coder workspace, lastSessionId will be stale, and claude --continue +// will start from an old session. +// +// If lastSessionId is missing, claude seems to accurately figure out where to +// start using the conversation history - even if the CLI previously exited with +// an error. +// +// This script removes the lastSessionId field from .claude.json. +const path = require("path") +const fs = require("fs") + +const workingDirArg = process.argv[2] +if (!workingDirArg) { + console.log("No working directory provided - it must be the first argument") + process.exit(1) +} + +const workingDir = path.resolve(workingDirArg) +console.log("workingDir", workingDir) + + +const claudeJsonPath = path.join(process.env.HOME, ".claude.json") +console.log(".claude.json path", claudeJsonPath) +if (!fs.existsSync(claudeJsonPath)) { + console.log("No .claude.json file found") + process.exit(0) +} + +const claudeJson = JSON.parse(fs.readFileSync(claudeJsonPath, "utf8")) +if ("projects" in claudeJson && workingDir in claudeJson.projects && "lastSessionId" in claudeJson.projects[workingDir]) { + delete claudeJson.projects[workingDir].lastSessionId + fs.writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2)) + console.log("Removed lastSessionId from .claude.json") +} else { + console.log("No lastSessionId found in .claude.json - nothing to do") +} diff --git a/registry/coder/modules/claude-code/testdata/agentapi-mock.js b/registry/coder/modules/claude-code/testdata/agentapi-mock.js new file mode 100644 index 00000000..4ea17b5f --- /dev/null +++ b/registry/coder/modules/claude-code/testdata/agentapi-mock.js @@ -0,0 +1,34 @@ +#!/usr/bin/env node + +const http = require("http"); +const fs = require("fs"); +const args = process.argv.slice(2); +const port = 3284; + +const controlFile = "/tmp/agentapi-mock.control"; +let control = ""; +if (fs.existsSync(controlFile)) { + control = fs.readFileSync(controlFile, "utf8"); +} + +if ( + control === "no-conversation-found" && + args.join(" ").includes("--continue") +) { + // this must match the error message in the agentapi-start.sh script + console.error("No conversation found to continue"); + process.exit(1); +} + +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/claude-code/testdata/claude-mock.js b/registry/coder/modules/claude-code/testdata/claude-mock.js new file mode 100644 index 00000000..ea9f9aa9 --- /dev/null +++ b/registry/coder/modules/claude-code/testdata/claude-mock.js @@ -0,0 +1,9 @@ +#!/usr/bin/env node + +const main = async () => { + console.log("mocking claude"); + // sleep for 30 minutes + await new Promise((resolve) => setTimeout(resolve, 30 * 60 * 1000)); +}; + +main(); diff --git a/registry/coder/modules/claude-code/testdata/coder-mock.js b/registry/coder/modules/claude-code/testdata/coder-mock.js new file mode 100644 index 00000000..cc479f43 --- /dev/null +++ b/registry/coder/modules/claude-code/testdata/coder-mock.js @@ -0,0 +1,14 @@ +#!/usr/bin/env node + +const fs = require("fs"); + +const statusSlugEnvVar = "CODER_MCP_APP_STATUS_SLUG"; +const agentApiUrlEnvVar = "CODER_MCP_AI_AGENTAPI_URL"; + +fs.writeFileSync( + "/home/coder/coder-mock-output.json", + JSON.stringify({ + statusSlug: process.env[statusSlugEnvVar] ?? "env var not set", + agentApiUrl: process.env[agentApiUrlEnvVar] ?? "env var not set", + }), +); diff --git a/test/test.ts b/test/test.ts index 4f413180..0de9fb04 100644 --- a/test/test.ts +++ b/test/test.ts @@ -30,6 +30,21 @@ export const runContainer = async ( return containerID.trim(); }; +export const removeContainer = async (id: string) => { + const proc = spawn(["docker", "rm", "-f", id], { + stderr: "pipe", + stdout: "pipe", + }); + const exitCode = await proc.exited; + const [stderr, stdout] = await Promise.all([ + readableStreamToText(proc.stderr ?? new ReadableStream()), + readableStreamToText(proc.stdout ?? new ReadableStream()), + ]); + if (exitCode !== 0) { + throw new Error(`${stderr}\n${stdout}`); + } +}; + export interface scriptOutput { exitCode: number; stdout: string[]; @@ -279,10 +294,33 @@ export const createJSONResponse = (obj: object, statusCode = 200): Response => { }; export const writeCoder = async (id: string, script: string) => { - const exec = await execContainer(id, [ - "sh", - "-c", - `echo '${script}' > /usr/bin/coder && chmod +x /usr/bin/coder`, - ]); - expect(exec.exitCode).toBe(0); + await writeFileContainer(id, "/usr/bin/coder", script, { + user: "root", + }); + const execResult = await execContainer( + id, + ["chmod", "755", "/usr/bin/coder"], + ["--user", "root"], + ); + expect(execResult.exitCode).toBe(0); +}; + +export const writeFileContainer = async ( + id: string, + path: string, + content: string, + options?: { + user?: string; + }, +) => { + const contentBase64 = Buffer.from(content).toString("base64"); + const proc = await execContainer( + id, + ["sh", "-c", `echo '${contentBase64}' | base64 -d > '${path}'`], + options?.user ? ["--user", options.user] : undefined, + ); + if (proc.exitCode !== 0) { + throw new Error(`Failed to write file: ${proc.stderr}`); + } + expect(proc.exitCode).toBe(0); };