Merge branch 'main' into 35C4n0r/chore-copilot-maintenance-1
This commit is contained in:
commit
236c1ccd02
@ -13,7 +13,7 @@ Run Codex CLI in your workspace to access OpenAI's models through the Codex inte
|
|||||||
```tf
|
```tf
|
||||||
module "codex" {
|
module "codex" {
|
||||||
source = "registry.coder.com/coder-labs/codex/coder"
|
source = "registry.coder.com/coder-labs/codex/coder"
|
||||||
version = "4.2.0"
|
version = "4.3.0"
|
||||||
agent_id = coder_agent.example.id
|
agent_id = coder_agent.example.id
|
||||||
openai_api_key = var.openai_api_key
|
openai_api_key = var.openai_api_key
|
||||||
workdir = "/home/coder/project"
|
workdir = "/home/coder/project"
|
||||||
@ -32,7 +32,7 @@ module "codex" {
|
|||||||
module "codex" {
|
module "codex" {
|
||||||
count = data.coder_workspace.me.start_count
|
count = data.coder_workspace.me.start_count
|
||||||
source = "registry.coder.com/coder-labs/codex/coder"
|
source = "registry.coder.com/coder-labs/codex/coder"
|
||||||
version = "4.2.0"
|
version = "4.3.0"
|
||||||
agent_id = coder_agent.example.id
|
agent_id = coder_agent.example.id
|
||||||
openai_api_key = "..."
|
openai_api_key = "..."
|
||||||
workdir = "/home/coder/project"
|
workdir = "/home/coder/project"
|
||||||
@ -51,7 +51,7 @@ For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage
|
|||||||
```tf
|
```tf
|
||||||
module "codex" {
|
module "codex" {
|
||||||
source = "registry.coder.com/coder-labs/codex/coder"
|
source = "registry.coder.com/coder-labs/codex/coder"
|
||||||
version = "4.2.0"
|
version = "4.3.0"
|
||||||
agent_id = coder_agent.example.id
|
agent_id = coder_agent.example.id
|
||||||
workdir = "/home/coder/project"
|
workdir = "/home/coder/project"
|
||||||
enable_aibridge = true
|
enable_aibridge = true
|
||||||
@ -94,7 +94,7 @@ data "coder_task" "me" {}
|
|||||||
|
|
||||||
module "codex" {
|
module "codex" {
|
||||||
source = "registry.coder.com/coder-labs/codex/coder"
|
source = "registry.coder.com/coder-labs/codex/coder"
|
||||||
version = "4.2.0"
|
version = "4.3.0"
|
||||||
agent_id = coder_agent.example.id
|
agent_id = coder_agent.example.id
|
||||||
openai_api_key = "..."
|
openai_api_key = "..."
|
||||||
ai_prompt = data.coder_task.me.prompt
|
ai_prompt = data.coder_task.me.prompt
|
||||||
@ -105,6 +105,26 @@ module "codex" {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Usage with Agent Boundaries
|
||||||
|
|
||||||
|
This example shows how to configure the Codex module to run the agent behind a process-level boundary that restricts its network access.
|
||||||
|
|
||||||
|
By default, when `enable_boundary = true`, the module uses `coder boundary` subcommand (provided by Coder) without requiring any installation.
|
||||||
|
|
||||||
|
```tf
|
||||||
|
module "codex" {
|
||||||
|
source = "registry.coder.com/coder-labs/codex/coder"
|
||||||
|
version = "4.3.0"
|
||||||
|
agent_id = coder_agent.main.id
|
||||||
|
openai_api_key = var.openai_api_key
|
||||||
|
workdir = "/home/coder/project"
|
||||||
|
enable_boundary = true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> For developers: The module also supports installing boundary from a release version (`use_boundary_directly = true`) or compiling from source (`compile_boundary_from_source = true`). These are escape hatches for development and testing purposes.
|
||||||
|
|
||||||
### Advanced Configuration
|
### Advanced Configuration
|
||||||
|
|
||||||
This example shows additional configuration options for custom models, MCP servers, and base configuration.
|
This example shows additional configuration options for custom models, MCP servers, and base configuration.
|
||||||
@ -112,7 +132,7 @@ This example shows additional configuration options for custom models, MCP serve
|
|||||||
```tf
|
```tf
|
||||||
module "codex" {
|
module "codex" {
|
||||||
source = "registry.coder.com/coder-labs/codex/coder"
|
source = "registry.coder.com/coder-labs/codex/coder"
|
||||||
version = "4.2.0"
|
version = "4.3.0"
|
||||||
agent_id = coder_agent.example.id
|
agent_id = coder_agent.example.id
|
||||||
openai_api_key = "..."
|
openai_api_key = "..."
|
||||||
workdir = "/home/coder/project"
|
workdir = "/home/coder/project"
|
||||||
|
|||||||
@ -473,4 +473,47 @@ describe("codex", async () => {
|
|||||||
);
|
);
|
||||||
expect(configToml).toContain('profile = "aibridge"');
|
expect(configToml).toContain('profile = "aibridge"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("boundary-enabled", async () => {
|
||||||
|
const { id } = await setup({
|
||||||
|
moduleVariables: {
|
||||||
|
enable_boundary: "true",
|
||||||
|
boundary_config_path: "/tmp/test-boundary.yaml",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Write boundary config
|
||||||
|
await execContainer(id, [
|
||||||
|
"bash",
|
||||||
|
"-c",
|
||||||
|
`cat > /tmp/test-boundary.yaml <<'EOF'
|
||||||
|
jail_type: landjail
|
||||||
|
proxy_port: 8087
|
||||||
|
log_level: warn
|
||||||
|
allowlist:
|
||||||
|
- "domain=api.openai.com"
|
||||||
|
EOF`,
|
||||||
|
]);
|
||||||
|
// Add mock coder binary for boundary setup
|
||||||
|
await writeExecutable({
|
||||||
|
containerId: id,
|
||||||
|
filePath: "/usr/bin/coder",
|
||||||
|
content: `#!/bin/bash
|
||||||
|
if [ "$1" = "boundary" ]; then
|
||||||
|
if [ "$2" = "--help" ]; then
|
||||||
|
echo "boundary help"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
shift; shift; exec "$@"
|
||||||
|
fi
|
||||||
|
echo "mock coder"`,
|
||||||
|
});
|
||||||
|
await execModuleScript(id);
|
||||||
|
await expectAgentAPIStarted(id);
|
||||||
|
// Verify boundary wrapper was used in start script
|
||||||
|
const startLog = await readFileContainer(
|
||||||
|
id,
|
||||||
|
"/home/coder/.codex-module/agentapi-start.log",
|
||||||
|
);
|
||||||
|
expect(startLog).toContain("boundary");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -176,6 +176,36 @@ variable "codex_system_prompt" {
|
|||||||
default = "You are a helpful coding assistant. Start every response with `Codex says:`"
|
default = "You are a helpful coding assistant. Start every response with `Codex says:`"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
variable "enable_boundary" {
|
||||||
|
type = bool
|
||||||
|
description = "Enable coder boundary for network filtering."
|
||||||
|
default = false
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "boundary_config_path" {
|
||||||
|
type = string
|
||||||
|
description = "Path to boundary config.yaml inside the workspace. If provided, exposed as BOUNDARY_CONFIG env var."
|
||||||
|
default = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "boundary_version" {
|
||||||
|
type = string
|
||||||
|
description = "Boundary version. When use_boundary_directly is true, a release version should be provided or 'latest' for the latest release."
|
||||||
|
default = "latest"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "compile_boundary_from_source" {
|
||||||
|
type = bool
|
||||||
|
description = "Whether to compile boundary from source instead of using the official install script."
|
||||||
|
default = false
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "use_boundary_directly" {
|
||||||
|
type = bool
|
||||||
|
description = "Whether to use boundary binary directly instead of coder boundary subcommand."
|
||||||
|
default = false
|
||||||
|
}
|
||||||
|
|
||||||
resource "coder_env" "openai_api_key" {
|
resource "coder_env" "openai_api_key" {
|
||||||
agent_id = var.agent_id
|
agent_id = var.agent_id
|
||||||
name = "OPENAI_API_KEY"
|
name = "OPENAI_API_KEY"
|
||||||
@ -212,26 +242,31 @@ locals {
|
|||||||
|
|
||||||
module "agentapi" {
|
module "agentapi" {
|
||||||
source = "registry.coder.com/coder/agentapi/coder"
|
source = "registry.coder.com/coder/agentapi/coder"
|
||||||
version = "2.2.0"
|
version = "2.3.0"
|
||||||
|
|
||||||
agent_id = var.agent_id
|
agent_id = var.agent_id
|
||||||
folder = local.workdir
|
folder = local.workdir
|
||||||
web_app_slug = local.app_slug
|
web_app_slug = local.app_slug
|
||||||
web_app_order = var.order
|
web_app_order = var.order
|
||||||
web_app_group = var.group
|
web_app_group = var.group
|
||||||
web_app_icon = var.icon
|
web_app_icon = var.icon
|
||||||
web_app_display_name = var.web_app_display_name
|
web_app_display_name = var.web_app_display_name
|
||||||
cli_app = var.cli_app
|
cli_app = var.cli_app
|
||||||
cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null
|
cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null
|
||||||
cli_app_display_name = var.cli_app ? var.cli_app_display_name : null
|
cli_app_display_name = var.cli_app ? var.cli_app_display_name : null
|
||||||
module_dir_name = local.module_dir_name
|
module_dir_name = local.module_dir_name
|
||||||
install_agentapi = var.install_agentapi
|
install_agentapi = var.install_agentapi
|
||||||
agentapi_subdomain = var.subdomain
|
agentapi_subdomain = var.subdomain
|
||||||
agentapi_version = var.agentapi_version
|
agentapi_version = var.agentapi_version
|
||||||
enable_state_persistence = var.enable_state_persistence
|
enable_state_persistence = var.enable_state_persistence
|
||||||
pre_install_script = var.pre_install_script
|
pre_install_script = var.pre_install_script
|
||||||
post_install_script = var.post_install_script
|
post_install_script = var.post_install_script
|
||||||
start_script = <<-EOT
|
enable_boundary = var.enable_boundary
|
||||||
|
boundary_config_path = var.boundary_config_path
|
||||||
|
boundary_version = var.boundary_version
|
||||||
|
compile_boundary_from_source = var.compile_boundary_from_source
|
||||||
|
use_boundary_directly = var.use_boundary_directly
|
||||||
|
start_script = <<-EOT
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -o errexit
|
set -o errexit
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
|
|||||||
@ -210,7 +210,16 @@ capture_session_id() {
|
|||||||
|
|
||||||
start_codex() {
|
start_codex() {
|
||||||
printf "Starting Codex with arguments: %s\n" "${CODEX_ARGS[*]}"
|
printf "Starting Codex with arguments: %s\n" "${CODEX_ARGS[*]}"
|
||||||
agentapi server --type codex --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}" &
|
# AGENTAPI_BOUNDARY_PREFIX is set by the agentapi module's main.sh when
|
||||||
|
# enable_boundary=true. It points to a wrapper script that runs the command
|
||||||
|
# through coder boundary, sandboxing only the agent process.
|
||||||
|
if [ -n "${AGENTAPI_BOUNDARY_PREFIX:-}" ]; then
|
||||||
|
printf "Starting with coder boundary enabled\n"
|
||||||
|
agentapi server --type codex --term-width 67 --term-height 1190 -- \
|
||||||
|
"${AGENTAPI_BOUNDARY_PREFIX}" codex "${CODEX_ARGS[@]}" &
|
||||||
|
else
|
||||||
|
agentapi server --type codex --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}" &
|
||||||
|
fi
|
||||||
capture_session_id
|
capture_session_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,7 +16,7 @@ The AgentAPI module is a building block for modules that need to run an AgentAPI
|
|||||||
```tf
|
```tf
|
||||||
module "agentapi" {
|
module "agentapi" {
|
||||||
source = "registry.coder.com/coder/agentapi/coder"
|
source = "registry.coder.com/coder/agentapi/coder"
|
||||||
version = "2.2.0"
|
version = "2.3.0"
|
||||||
|
|
||||||
agent_id = var.agent_id
|
agent_id = var.agent_id
|
||||||
web_app_slug = local.app_slug
|
web_app_slug = local.app_slug
|
||||||
@ -67,8 +67,7 @@ module "agentapi" {
|
|||||||
AgentAPI can save and restore conversation state across workspace restarts.
|
AgentAPI can save and restore conversation state across workspace restarts.
|
||||||
This is disabled by default and requires agentapi binary >= v0.12.0.
|
This is disabled by default and requires agentapi binary >= v0.12.0.
|
||||||
|
|
||||||
State and PID files are stored in `$HOME/<module_dir_name>/` alongside other
|
State and PID files are stored in `$HOME/<module_dir_name>/` alongside other module files (e.g. `$HOME/.claude-module/agentapi-state.json`).
|
||||||
module files (e.g. `$HOME/.claude-module/agentapi-state.json`).
|
|
||||||
|
|
||||||
To enable:
|
To enable:
|
||||||
|
|
||||||
@ -89,6 +88,47 @@ module "agentapi" {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Boundary (Network Filtering)
|
||||||
|
|
||||||
|
The agentapi module supports optional [Agent Boundaries](https://coder.com/docs/ai-coder/agent-boundaries)
|
||||||
|
for network filtering. When enabled, the module sets up a `AGENTAPI_BOUNDARY_PREFIX` environment
|
||||||
|
variable that points to a wrapper script. Agent modules should use this prefix in their
|
||||||
|
start scripts to run the agent process through boundary.
|
||||||
|
|
||||||
|
Boundary requires a `config.yaml` file with your allowlist, jail type, proxy port, and log
|
||||||
|
level. See the [Agent Boundaries documentation](https://coder.com/docs/ai-coder/agent-boundaries)
|
||||||
|
for configuration details.
|
||||||
|
To enable:
|
||||||
|
|
||||||
|
```tf
|
||||||
|
module "agentapi" {
|
||||||
|
# ... other config
|
||||||
|
enable_boundary = true
|
||||||
|
boundary_config_path = "/home/coder/.config/coder_boundary/config.yaml"
|
||||||
|
|
||||||
|
# Optional: install boundary binary instead of using coder subcommand
|
||||||
|
# use_boundary_directly = true
|
||||||
|
# boundary_version = "0.6.0"
|
||||||
|
# compile_boundary_from_source = false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Contract for agent modules
|
||||||
|
|
||||||
|
When `enable_boundary = true`, the agentapi module exports `AGENTAPI_BOUNDARY_PREFIX`
|
||||||
|
as an environment variable pointing to a wrapper script. Agent module start scripts
|
||||||
|
should check for this variable and use it to prefix the agent command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
if [ -n "${AGENTAPI_BOUNDARY_PREFIX:-}" ]; then
|
||||||
|
agentapi server -- "${AGENTAPI_BOUNDARY_PREFIX}" my-agent "${ARGS[@]}" &
|
||||||
|
else
|
||||||
|
agentapi server -- my-agent "${ARGS[@]}" &
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures only the agent process is sandboxed while agentapi itself runs unrestricted.
|
||||||
|
|
||||||
## For module developers
|
## For module developers
|
||||||
|
|
||||||
For a complete example of how to use this module, see the [Goose module](https://github.com/coder/registry/blob/main/registry/coder/modules/goose/main.tf).
|
For a complete example of how to use this module, see the [Goose module](https://github.com/coder/registry/blob/main/registry/coder/modules/goose/main.tf).
|
||||||
|
|||||||
@ -613,4 +613,109 @@ describe("agentapi", async () => {
|
|||||||
expect(result.stdout).toContain("Sending SIGTERM to AgentAPI");
|
expect(result.stdout).toContain("Sending SIGTERM to AgentAPI");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("boundary", async () => {
|
||||||
|
test("boundary-disabled-by-default", async () => {
|
||||||
|
const { id } = await setup();
|
||||||
|
await execModuleScript(id);
|
||||||
|
await expectAgentAPIStarted(id);
|
||||||
|
// Config file should NOT exist when boundary is disabled
|
||||||
|
const configCheck = await execContainer(id, [
|
||||||
|
"bash",
|
||||||
|
"-c",
|
||||||
|
"test -f /home/coder/.config/coder_boundary/config.yaml && echo exists || echo missing",
|
||||||
|
]);
|
||||||
|
expect(configCheck.stdout.trim()).toBe("missing");
|
||||||
|
// AGENTAPI_BOUNDARY_PREFIX should NOT be in the mock log
|
||||||
|
const mockLog = await readFileContainer(
|
||||||
|
id,
|
||||||
|
"/home/coder/agentapi-mock.log",
|
||||||
|
);
|
||||||
|
expect(mockLog).not.toContain("AGENTAPI_BOUNDARY_PREFIX:");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("boundary-enabled", async () => {
|
||||||
|
const { id } = await setup({
|
||||||
|
moduleVariables: {
|
||||||
|
enable_boundary: "true",
|
||||||
|
boundary_config_path: "/tmp/test-boundary.yaml",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Write boundary config to the path before running the module
|
||||||
|
await execContainer(id, [
|
||||||
|
"bash",
|
||||||
|
"-c",
|
||||||
|
`cat > /tmp/test-boundary.yaml <<'EOF'
|
||||||
|
jail_type: landjail
|
||||||
|
proxy_port: 8087
|
||||||
|
log_level: warn
|
||||||
|
allowlist:
|
||||||
|
- "domain=api.example.com"
|
||||||
|
EOF`,
|
||||||
|
]);
|
||||||
|
// Add mock coder binary for boundary setup
|
||||||
|
await writeExecutable({
|
||||||
|
containerId: id,
|
||||||
|
filePath: "/usr/bin/coder",
|
||||||
|
content: `#!/bin/bash
|
||||||
|
if [ "$1" = "boundary" ]; then
|
||||||
|
shift; shift; exec "$@"
|
||||||
|
fi
|
||||||
|
echo "mock coder"`,
|
||||||
|
});
|
||||||
|
await execModuleScript(id);
|
||||||
|
await expectAgentAPIStarted(id);
|
||||||
|
// Verify the config file exists at the specified path
|
||||||
|
const config = await readFileContainer(id, "/tmp/test-boundary.yaml");
|
||||||
|
expect(config).toContain("jail_type: landjail");
|
||||||
|
expect(config).toContain("proxy_port: 8087");
|
||||||
|
expect(config).toContain("domain=api.example.com");
|
||||||
|
// AGENTAPI_BOUNDARY_PREFIX should be exported
|
||||||
|
const mockLog = await readFileContainer(
|
||||||
|
id,
|
||||||
|
"/home/coder/agentapi-mock.log",
|
||||||
|
);
|
||||||
|
expect(mockLog).toContain("AGENTAPI_BOUNDARY_PREFIX:");
|
||||||
|
// E2E: start script should have used the wrapper
|
||||||
|
const startLog = await readFileContainer(
|
||||||
|
id,
|
||||||
|
"/home/coder/test-agentapi-start.log",
|
||||||
|
);
|
||||||
|
expect(startLog).toContain("Starting with boundary:");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("boundary-enabled-no-coder-binary", async () => {
|
||||||
|
const { id } = await setup({
|
||||||
|
moduleVariables: {
|
||||||
|
enable_boundary: "true",
|
||||||
|
boundary_config_path: "/tmp/test-boundary.yaml",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Write boundary config
|
||||||
|
await execContainer(id, [
|
||||||
|
"bash",
|
||||||
|
"-c",
|
||||||
|
`cat > /tmp/test-boundary.yaml <<'EOF'
|
||||||
|
jail_type: landjail
|
||||||
|
proxy_port: 8087
|
||||||
|
log_level: warn
|
||||||
|
EOF`,
|
||||||
|
]);
|
||||||
|
// Remove coder binary to simulate it not being available
|
||||||
|
await execContainer(
|
||||||
|
id,
|
||||||
|
[
|
||||||
|
"bash",
|
||||||
|
"-c",
|
||||||
|
"rm -f /usr/bin/coder /usr/local/bin/coder 2>/dev/null; hash -r",
|
||||||
|
],
|
||||||
|
["--user", "root"],
|
||||||
|
);
|
||||||
|
const resp = await execModuleScript(id);
|
||||||
|
// Script should fail because coder binary is required
|
||||||
|
expect(resp.exitCode).not.toBe(0);
|
||||||
|
const scriptLog = await readFileContainer(id, "/home/coder/script.log");
|
||||||
|
expect(scriptLog).toContain("Boundary cannot be enabled");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -164,6 +164,36 @@ variable "module_dir_name" {
|
|||||||
description = "Name of the subdirectory in the home directory for module files."
|
description = "Name of the subdirectory in the home directory for module files."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
variable "enable_boundary" {
|
||||||
|
type = bool
|
||||||
|
description = "Enable coder boundary for network filtering. Requires boundary_config to be set."
|
||||||
|
default = false
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "boundary_config_path" {
|
||||||
|
type = string
|
||||||
|
description = "Path to boundary config.yaml inside the workspace. If provided, exposed as BOUNDARY_CONFIG env var."
|
||||||
|
default = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "boundary_version" {
|
||||||
|
type = string
|
||||||
|
description = "Boundary version. When use_boundary_directly is true, a release version should be provided or 'latest' for the latest release. When compile_boundary_from_source is true, a valid git reference should be provided (tag, commit, branch)."
|
||||||
|
default = "latest"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "compile_boundary_from_source" {
|
||||||
|
type = bool
|
||||||
|
description = "Whether to compile boundary from source instead of using the official install script."
|
||||||
|
default = false
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "use_boundary_directly" {
|
||||||
|
type = bool
|
||||||
|
description = "Whether to use boundary binary directly instead of coder boundary subcommand. When false (default), uses coder boundary subcommand. When true, installs and uses boundary binary from release."
|
||||||
|
default = false
|
||||||
|
}
|
||||||
|
|
||||||
variable "enable_state_persistence" {
|
variable "enable_state_persistence" {
|
||||||
type = bool
|
type = bool
|
||||||
description = "Enable AgentAPI conversation state persistence across restarts."
|
description = "Enable AgentAPI conversation state persistence across restarts."
|
||||||
@ -182,6 +212,13 @@ variable "pid_file_path" {
|
|||||||
default = ""
|
default = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resource "coder_env" "boundary_config" {
|
||||||
|
count = var.enable_boundary && var.boundary_config_path != "" ? 1 : 0
|
||||||
|
agent_id = var.agent_id
|
||||||
|
name = "BOUNDARY_CONFIG"
|
||||||
|
value = var.boundary_config_path
|
||||||
|
}
|
||||||
|
|
||||||
locals {
|
locals {
|
||||||
# we always trim the slash for consistency
|
# we always trim the slash for consistency
|
||||||
workdir = trimsuffix(var.folder, "/")
|
workdir = trimsuffix(var.folder, "/")
|
||||||
@ -200,6 +237,7 @@ locals {
|
|||||||
main_script = file("${path.module}/scripts/main.sh")
|
main_script = file("${path.module}/scripts/main.sh")
|
||||||
shutdown_script = file("${path.module}/scripts/agentapi-shutdown.sh")
|
shutdown_script = file("${path.module}/scripts/agentapi-shutdown.sh")
|
||||||
lib_script = file("${path.module}/scripts/lib.sh")
|
lib_script = file("${path.module}/scripts/lib.sh")
|
||||||
|
boundary_script = file("${path.module}/scripts/boundary.sh")
|
||||||
}
|
}
|
||||||
|
|
||||||
resource "coder_script" "agentapi" {
|
resource "coder_script" "agentapi" {
|
||||||
@ -214,6 +252,9 @@ resource "coder_script" "agentapi" {
|
|||||||
echo -n '${base64encode(local.main_script)}' | base64 -d > /tmp/main.sh
|
echo -n '${base64encode(local.main_script)}' | base64 -d > /tmp/main.sh
|
||||||
chmod +x /tmp/main.sh
|
chmod +x /tmp/main.sh
|
||||||
echo -n '${base64encode(local.lib_script)}' | base64 -d > /tmp/agentapi-lib.sh
|
echo -n '${base64encode(local.lib_script)}' | base64 -d > /tmp/agentapi-lib.sh
|
||||||
|
|
||||||
|
echo -n '${base64encode(local.boundary_script)}' | base64 -d > /tmp/agentapi-boundary.sh
|
||||||
|
chmod +x /tmp/agentapi-boundary.sh
|
||||||
|
|
||||||
ARG_MODULE_DIR_NAME='${var.module_dir_name}' \
|
ARG_MODULE_DIR_NAME='${var.module_dir_name}' \
|
||||||
ARG_WORKDIR="$(echo -n '${base64encode(local.workdir)}' | base64 -d)" \
|
ARG_WORKDIR="$(echo -n '${base64encode(local.workdir)}' | base64 -d)" \
|
||||||
@ -228,6 +269,10 @@ resource "coder_script" "agentapi" {
|
|||||||
ARG_AGENTAPI_CHAT_BASE_PATH='${local.agentapi_chat_base_path}' \
|
ARG_AGENTAPI_CHAT_BASE_PATH='${local.agentapi_chat_base_path}' \
|
||||||
ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \
|
ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \
|
||||||
ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \
|
ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \
|
||||||
|
ARG_ENABLE_BOUNDARY='${var.enable_boundary}' \
|
||||||
|
ARG_BOUNDARY_VERSION='${var.boundary_version}' \
|
||||||
|
ARG_COMPILE_BOUNDARY_FROM_SOURCE='${var.compile_boundary_from_source}' \
|
||||||
|
ARG_USE_BOUNDARY_DIRECTLY='${var.use_boundary_directly}' \
|
||||||
ARG_ENABLE_STATE_PERSISTENCE='${var.enable_state_persistence}' \
|
ARG_ENABLE_STATE_PERSISTENCE='${var.enable_state_persistence}' \
|
||||||
ARG_STATE_FILE_PATH='${var.state_file_path}' \
|
ARG_STATE_FILE_PATH='${var.state_file_path}' \
|
||||||
ARG_PID_FILE_PATH='${var.pid_file_path}' \
|
ARG_PID_FILE_PATH='${var.pid_file_path}' \
|
||||||
|
|||||||
95
registry/coder/modules/agentapi/scripts/boundary.sh
Normal file
95
registry/coder/modules/agentapi/scripts/boundary.sh
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# boundary.sh - Boundary installation and setup for agentapi module.
|
||||||
|
# Sourced by main.sh when ENABLE_BOUNDARY=true.
|
||||||
|
# Exports AGENTAPI_BOUNDARY_PREFIX for use by module start scripts.
|
||||||
|
|
||||||
|
validate_boundary_subcommand() {
|
||||||
|
if command_exists coder; then
|
||||||
|
if coder boundary --help > /dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
echo "Error: 'coder' command found but does not support 'boundary' subcommand. Please enable install_boundary."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Error: ENABLE_BOUNDARY=true, but 'coder' command not found. Boundary cannot be enabled." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Install boundary binary if needed.
|
||||||
|
# Uses one of three strategies:
|
||||||
|
# 1. Compile from source (compile_boundary_from_source=true)
|
||||||
|
# 2. Install from release (use_boundary_directly=true)
|
||||||
|
# 3. Use coder boundary subcommand (default, no installation needed)
|
||||||
|
install_boundary() {
|
||||||
|
if [ "${COMPILE_BOUNDARY_FROM_SOURCE}" = "true" ]; then
|
||||||
|
echo "Compiling boundary from source (version: ${BOUNDARY_VERSION})"
|
||||||
|
|
||||||
|
# Remove existing boundary directory to allow re-running safely
|
||||||
|
if [ -d boundary ]; then
|
||||||
|
rm -rf boundary
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Cloning boundary repository"
|
||||||
|
git clone https://github.com/coder/boundary.git
|
||||||
|
cd boundary || exit 1
|
||||||
|
git checkout "${BOUNDARY_VERSION}"
|
||||||
|
|
||||||
|
make build
|
||||||
|
|
||||||
|
sudo cp boundary /usr/local/bin/
|
||||||
|
sudo chmod +x /usr/local/bin/boundary
|
||||||
|
cd - || exit 1
|
||||||
|
elif [ "${USE_BOUNDARY_DIRECTLY}" = "true" ]; then
|
||||||
|
echo "Installing boundary using official install script (version: ${BOUNDARY_VERSION})"
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/coder/boundary/main/install.sh | bash -s -- --version "${BOUNDARY_VERSION}"
|
||||||
|
else
|
||||||
|
validate_boundary_subcommand
|
||||||
|
echo "Using coder boundary subcommand (provided by Coder)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set up boundary: install, write config, create wrapper script.
|
||||||
|
# Exports AGENTAPI_BOUNDARY_PREFIX pointing to the wrapper script.
|
||||||
|
setup_boundary() {
|
||||||
|
local module_path="$1"
|
||||||
|
|
||||||
|
echo "Setting up coder boundary..."
|
||||||
|
|
||||||
|
# Install boundary binary if needed
|
||||||
|
install_boundary
|
||||||
|
|
||||||
|
# Determine which boundary command to use and create wrapper script
|
||||||
|
BOUNDARY_WRAPPER_SCRIPT="$module_path/boundary-wrapper.sh"
|
||||||
|
|
||||||
|
if [ "${COMPILE_BOUNDARY_FROM_SOURCE}" = "true" ] || [ "${USE_BOUNDARY_DIRECTLY}" = "true" ]; then
|
||||||
|
# Use boundary binary directly (from compilation or release installation)
|
||||||
|
cat > "${BOUNDARY_WRAPPER_SCRIPT}" << 'WRAPPER_EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
exec boundary -- "$@"
|
||||||
|
WRAPPER_EOF
|
||||||
|
else
|
||||||
|
# Use coder boundary subcommand (default)
|
||||||
|
# Copy coder binary to strip CAP_NET_ADMIN capabilities.
|
||||||
|
# This is necessary because boundary doesn't work with privileged binaries
|
||||||
|
# (you can't launch privileged binaries inside network namespaces unless
|
||||||
|
# you have sys_admin).
|
||||||
|
CODER_NO_CAPS="$module_path/coder-no-caps"
|
||||||
|
if ! cp "$(which coder)" "$CODER_NO_CAPS"; then
|
||||||
|
echo "Error: Failed to copy coder binary to ${CODER_NO_CAPS}. Boundary cannot be enabled." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cat > "${BOUNDARY_WRAPPER_SCRIPT}" << 'WRAPPER_EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
exec "${SCRIPT_DIR}/coder-no-caps" boundary -- "$@"
|
||||||
|
WRAPPER_EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
chmod +x "${BOUNDARY_WRAPPER_SCRIPT}"
|
||||||
|
export AGENTAPI_BOUNDARY_PREFIX="${BOUNDARY_WRAPPER_SCRIPT}"
|
||||||
|
echo "Boundary wrapper configured: ${AGENTAPI_BOUNDARY_PREFIX}"
|
||||||
|
}
|
||||||
@ -16,6 +16,10 @@ AGENTAPI_PORT="$ARG_AGENTAPI_PORT"
|
|||||||
AGENTAPI_CHAT_BASE_PATH="${ARG_AGENTAPI_CHAT_BASE_PATH:-}"
|
AGENTAPI_CHAT_BASE_PATH="${ARG_AGENTAPI_CHAT_BASE_PATH:-}"
|
||||||
TASK_ID="${ARG_TASK_ID:-}"
|
TASK_ID="${ARG_TASK_ID:-}"
|
||||||
TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}"
|
TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}"
|
||||||
|
ENABLE_BOUNDARY="${ARG_ENABLE_BOUNDARY:-false}"
|
||||||
|
BOUNDARY_VERSION="${ARG_BOUNDARY_VERSION:-latest}"
|
||||||
|
COMPILE_BOUNDARY_FROM_SOURCE="${ARG_COMPILE_BOUNDARY_FROM_SOURCE:-false}"
|
||||||
|
USE_BOUNDARY_DIRECTLY="${ARG_USE_BOUNDARY_DIRECTLY:-false}"
|
||||||
ENABLE_STATE_PERSISTENCE="${ARG_ENABLE_STATE_PERSISTENCE:-false}"
|
ENABLE_STATE_PERSISTENCE="${ARG_ENABLE_STATE_PERSISTENCE:-false}"
|
||||||
STATE_FILE_PATH="${ARG_STATE_FILE_PATH:-}"
|
STATE_FILE_PATH="${ARG_STATE_FILE_PATH:-}"
|
||||||
PID_FILE_PATH="${ARG_PID_FILE_PATH:-}"
|
PID_FILE_PATH="${ARG_PID_FILE_PATH:-}"
|
||||||
@ -109,9 +113,18 @@ export LC_ALL=en_US.UTF-8
|
|||||||
|
|
||||||
cd "${WORKDIR}"
|
cd "${WORKDIR}"
|
||||||
|
|
||||||
|
# Set up boundary if enabled
|
||||||
|
export AGENTAPI_BOUNDARY_PREFIX=""
|
||||||
|
if [ "${ENABLE_BOUNDARY}" = "true" ]; then
|
||||||
|
# shellcheck source=boundary.sh
|
||||||
|
source /tmp/agentapi-boundary.sh
|
||||||
|
setup_boundary "$module_path"
|
||||||
|
fi
|
||||||
|
|
||||||
export AGENTAPI_CHAT_BASE_PATH="${AGENTAPI_CHAT_BASE_PATH:-}"
|
export AGENTAPI_CHAT_BASE_PATH="${AGENTAPI_CHAT_BASE_PATH:-}"
|
||||||
# Disable host header check since AgentAPI is proxied by Coder (which does its own validation)
|
# Disable host header check since AgentAPI is proxied by Coder (which does its own validation)
|
||||||
export AGENTAPI_ALLOWED_HOSTS="*"
|
export AGENTAPI_ALLOWED_HOSTS="*"
|
||||||
|
|
||||||
export AGENTAPI_PID_FILE="${PID_FILE_PATH:-$module_path/agentapi.pid}"
|
export AGENTAPI_PID_FILE="${PID_FILE_PATH:-$module_path/agentapi.pid}"
|
||||||
# Only set state env vars when persistence is enabled and the binary supports
|
# Only set state env vars when persistence is enabled and the binary supports
|
||||||
# it. State persistence requires agentapi >= v0.12.0.
|
# it. State persistence requires agentapi >= v0.12.0.
|
||||||
|
|||||||
@ -31,6 +31,15 @@ for (const v of [
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Log boundary env vars.
|
||||||
|
for (const v of ["AGENTAPI_BOUNDARY_PREFIX"]) {
|
||||||
|
if (process.env[v]) {
|
||||||
|
fs.appendFileSync(
|
||||||
|
"/home/coder/agentapi-mock.log",
|
||||||
|
`\n${v}: ${process.env[v]}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Write PID file for shutdown script.
|
// Write PID file for shutdown script.
|
||||||
if (process.env.AGENTAPI_PID_FILE) {
|
if (process.env.AGENTAPI_PID_FILE) {
|
||||||
|
|||||||
@ -17,6 +17,16 @@ if [ -n "$AGENTAPI_CHAT_BASE_PATH" ]; then
|
|||||||
export AGENTAPI_CHAT_BASE_PATH
|
export AGENTAPI_CHAT_BASE_PATH
|
||||||
fi
|
fi
|
||||||
|
|
||||||
agentapi server --port "$port" --term-width 67 --term-height 1190 -- \
|
# Use boundary wrapper if configured by agentapi module.
|
||||||
bash -c aiagent \
|
# AGENTAPI_BOUNDARY_PREFIX is set by the agentapi module's main.sh
|
||||||
> "$log_file_path" 2>&1
|
# and points to a wrapper script that runs the command through coder boundary.
|
||||||
|
if [ -n "${AGENTAPI_BOUNDARY_PREFIX:-}" ]; then
|
||||||
|
echo "Starting with boundary: ${AGENTAPI_BOUNDARY_PREFIX}" >> /home/coder/test-agentapi-start.log
|
||||||
|
agentapi server --port "$port" --term-width 67 --term-height 1190 -- \
|
||||||
|
"${AGENTAPI_BOUNDARY_PREFIX}" bash -c aiagent \
|
||||||
|
> "$log_file_path" 2>&1
|
||||||
|
else
|
||||||
|
agentapi server --port "$port" --term-width 67 --term-height 1190 -- \
|
||||||
|
bash -c aiagent \
|
||||||
|
> "$log_file_path" 2>&1
|
||||||
|
fi
|
||||||
|
|||||||
@ -18,7 +18,7 @@ Under the hood, this module uses the [coder dotfiles](https://coder.com/docs/v2/
|
|||||||
module "dotfiles" {
|
module "dotfiles" {
|
||||||
count = data.coder_workspace.me.start_count
|
count = data.coder_workspace.me.start_count
|
||||||
source = "registry.coder.com/coder/dotfiles/coder"
|
source = "registry.coder.com/coder/dotfiles/coder"
|
||||||
version = "1.3.2"
|
version = "1.4.0"
|
||||||
agent_id = coder_agent.example.id
|
agent_id = coder_agent.example.id
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@ -31,7 +31,7 @@ module "dotfiles" {
|
|||||||
module "dotfiles" {
|
module "dotfiles" {
|
||||||
count = data.coder_workspace.me.start_count
|
count = data.coder_workspace.me.start_count
|
||||||
source = "registry.coder.com/coder/dotfiles/coder"
|
source = "registry.coder.com/coder/dotfiles/coder"
|
||||||
version = "1.3.2"
|
version = "1.4.0"
|
||||||
agent_id = coder_agent.example.id
|
agent_id = coder_agent.example.id
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@ -42,7 +42,7 @@ module "dotfiles" {
|
|||||||
module "dotfiles" {
|
module "dotfiles" {
|
||||||
count = data.coder_workspace.me.start_count
|
count = data.coder_workspace.me.start_count
|
||||||
source = "registry.coder.com/coder/dotfiles/coder"
|
source = "registry.coder.com/coder/dotfiles/coder"
|
||||||
version = "1.3.2"
|
version = "1.4.0"
|
||||||
agent_id = coder_agent.example.id
|
agent_id = coder_agent.example.id
|
||||||
user = "root"
|
user = "root"
|
||||||
}
|
}
|
||||||
@ -54,14 +54,14 @@ module "dotfiles" {
|
|||||||
module "dotfiles" {
|
module "dotfiles" {
|
||||||
count = data.coder_workspace.me.start_count
|
count = data.coder_workspace.me.start_count
|
||||||
source = "registry.coder.com/coder/dotfiles/coder"
|
source = "registry.coder.com/coder/dotfiles/coder"
|
||||||
version = "1.3.2"
|
version = "1.4.0"
|
||||||
agent_id = coder_agent.example.id
|
agent_id = coder_agent.example.id
|
||||||
}
|
}
|
||||||
|
|
||||||
module "dotfiles-root" {
|
module "dotfiles-root" {
|
||||||
count = data.coder_workspace.me.start_count
|
count = data.coder_workspace.me.start_count
|
||||||
source = "registry.coder.com/coder/dotfiles/coder"
|
source = "registry.coder.com/coder/dotfiles/coder"
|
||||||
version = "1.3.2"
|
version = "1.4.0"
|
||||||
agent_id = coder_agent.example.id
|
agent_id = coder_agent.example.id
|
||||||
user = "root"
|
user = "root"
|
||||||
dotfiles_uri = module.dotfiles.dotfiles_uri
|
dotfiles_uri = module.dotfiles.dotfiles_uri
|
||||||
@ -90,7 +90,7 @@ You can set a default dotfiles repository for all users by setting the `default_
|
|||||||
module "dotfiles" {
|
module "dotfiles" {
|
||||||
count = data.coder_workspace.me.start_count
|
count = data.coder_workspace.me.start_count
|
||||||
source = "registry.coder.com/coder/dotfiles/coder"
|
source = "registry.coder.com/coder/dotfiles/coder"
|
||||||
version = "1.3.2"
|
version = "1.4.0"
|
||||||
agent_id = coder_agent.example.id
|
agent_id = coder_agent.example.id
|
||||||
default_dotfiles_uri = "https://github.com/coder/dotfiles"
|
default_dotfiles_uri = "https://github.com/coder/dotfiles"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -62,7 +62,41 @@ describe("dotfiles", async () => {
|
|||||||
agent_id: "foo",
|
agent_id: "foo",
|
||||||
coder_parameter_order: order.toString(),
|
coder_parameter_order: order.toString(),
|
||||||
});
|
});
|
||||||
|
expect(state.resources).toHaveLength(3);
|
||||||
|
const parameters = state.resources.filter(
|
||||||
|
(r) => r.type === "coder_parameter",
|
||||||
|
);
|
||||||
|
for (const param of parameters) {
|
||||||
|
expect(param.instances[0].attributes.order).toBe(order);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("set custom dotfiles_branch", async () => {
|
||||||
|
const branch = "develop";
|
||||||
|
const state = await runTerraformApply(import.meta.dir, {
|
||||||
|
agent_id: "foo",
|
||||||
|
dotfiles_branch: branch,
|
||||||
|
});
|
||||||
expect(state.resources).toHaveLength(2);
|
expect(state.resources).toHaveLength(2);
|
||||||
expect(state.resources[0].instances[0].attributes.order).toBe(order);
|
const scriptResource = state.resources.find(
|
||||||
|
(r) => r.type === "coder_script",
|
||||||
|
);
|
||||||
|
expect(scriptResource?.instances[0].attributes.script).toContain(
|
||||||
|
`DOTFILES_BRANCH="${branch}"`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("default dotfiles_branch creates parameter", async () => {
|
||||||
|
const state = await runTerraformApply(import.meta.dir, {
|
||||||
|
agent_id: "foo",
|
||||||
|
});
|
||||||
|
expect(state.resources).toHaveLength(3);
|
||||||
|
const branchParameter = state.resources.find(
|
||||||
|
(r) =>
|
||||||
|
r.type === "coder_parameter" &&
|
||||||
|
r.instances[0].attributes.name === "dotfiles_branch",
|
||||||
|
);
|
||||||
|
expect(branchParameter).toBeDefined();
|
||||||
|
expect(branchParameter?.instances[0].attributes.default).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -46,6 +46,12 @@ variable "default_dotfiles_uri" {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
variable "default_dotfiles_branch" {
|
||||||
|
type = string
|
||||||
|
description = "The default dotfiles branch if the workspace user does not provide one"
|
||||||
|
default = ""
|
||||||
|
}
|
||||||
|
|
||||||
variable "dotfiles_uri" {
|
variable "dotfiles_uri" {
|
||||||
type = string
|
type = string
|
||||||
description = "The URL to a dotfiles repository. (optional, when set, the user isn't prompted for their dotfiles)"
|
description = "The URL to a dotfiles repository. (optional, when set, the user isn't prompted for their dotfiles)"
|
||||||
@ -61,6 +67,17 @@ variable "dotfiles_uri" {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
variable "dotfiles_branch" {
|
||||||
|
type = string
|
||||||
|
description = "The branch to use for the dotfiles repository (optional, when set, the user isn't prompted for the branch)"
|
||||||
|
default = null
|
||||||
|
|
||||||
|
validation {
|
||||||
|
condition = var.dotfiles_branch == null || var.dotfiles_branch != ""
|
||||||
|
error_message = "dotfiles_branch cannot be an empty string. Use null to prompt the user or provide a valid branch name."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
variable "user" {
|
variable "user" {
|
||||||
type = string
|
type = string
|
||||||
description = "The name of the user to apply the dotfiles to. (optional, applies to the current user by default)"
|
description = "The name of the user to apply the dotfiles to. (optional, applies to the current user by default)"
|
||||||
@ -107,8 +124,21 @@ data "coder_parameter" "dotfiles_uri" {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data "coder_parameter" "dotfiles_branch" {
|
||||||
|
count = var.dotfiles_branch == null ? 1 : 0
|
||||||
|
type = "string"
|
||||||
|
name = "dotfiles_branch"
|
||||||
|
display_name = "Dotfiles Branch"
|
||||||
|
order = var.coder_parameter_order
|
||||||
|
default = var.default_dotfiles_branch
|
||||||
|
description = "The branch to use for the dotfiles repository"
|
||||||
|
mutable = true
|
||||||
|
icon = "/icon/dotfiles.svg"
|
||||||
|
}
|
||||||
|
|
||||||
locals {
|
locals {
|
||||||
dotfiles_uri = var.dotfiles_uri != null ? var.dotfiles_uri : data.coder_parameter.dotfiles_uri[0].value
|
dotfiles_uri = var.dotfiles_uri != null ? var.dotfiles_uri : data.coder_parameter.dotfiles_uri[0].value
|
||||||
|
dotfiles_branch = var.dotfiles_branch != null ? var.dotfiles_branch : data.coder_parameter.dotfiles_branch[0].value
|
||||||
user = var.user != null ? var.user : ""
|
user = var.user != null ? var.user : ""
|
||||||
encoded_post_clone_script = var.post_clone_script != null ? base64encode(var.post_clone_script) : ""
|
encoded_post_clone_script = var.post_clone_script != null ? base64encode(var.post_clone_script) : ""
|
||||||
}
|
}
|
||||||
@ -118,6 +148,7 @@ resource "coder_script" "dotfiles" {
|
|||||||
script = templatefile("${path.module}/run.sh", {
|
script = templatefile("${path.module}/run.sh", {
|
||||||
DOTFILES_URI : local.dotfiles_uri,
|
DOTFILES_URI : local.dotfiles_uri,
|
||||||
DOTFILES_USER : local.user,
|
DOTFILES_USER : local.user,
|
||||||
|
DOTFILES_BRANCH : local.dotfiles_branch,
|
||||||
POST_CLONE_SCRIPT : local.encoded_post_clone_script
|
POST_CLONE_SCRIPT : local.encoded_post_clone_script
|
||||||
})
|
})
|
||||||
display_name = "Dotfiles"
|
display_name = "Dotfiles"
|
||||||
@ -136,6 +167,7 @@ resource "coder_app" "dotfiles" {
|
|||||||
command = templatefile("${path.module}/run.sh", {
|
command = templatefile("${path.module}/run.sh", {
|
||||||
DOTFILES_URI : local.dotfiles_uri,
|
DOTFILES_URI : local.dotfiles_uri,
|
||||||
DOTFILES_USER : local.user,
|
DOTFILES_USER : local.user,
|
||||||
|
DOTFILES_BRANCH : local.dotfiles_branch,
|
||||||
POST_CLONE_SCRIPT : local.encoded_post_clone_script
|
POST_CLONE_SCRIPT : local.encoded_post_clone_script
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ set -euo pipefail
|
|||||||
|
|
||||||
DOTFILES_URI="${DOTFILES_URI}"
|
DOTFILES_URI="${DOTFILES_URI}"
|
||||||
DOTFILES_USER="${DOTFILES_USER}"
|
DOTFILES_USER="${DOTFILES_USER}"
|
||||||
|
DOTFILES_BRANCH="${DOTFILES_BRANCH}"
|
||||||
|
|
||||||
# Validate DOTFILES_URI to prevent command injection (defense in depth)
|
# Validate DOTFILES_URI to prevent command injection (defense in depth)
|
||||||
if [ -n "$DOTFILES_URI" ]; then
|
if [ -n "$DOTFILES_URI" ]; then
|
||||||
@ -24,10 +25,18 @@ if [ -n "$${DOTFILES_URI// }" ]; then
|
|||||||
DOTFILES_USER="$USER"
|
DOTFILES_USER="$USER"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "✨ Applying dotfiles for user $DOTFILES_USER"
|
if [ -n "$DOTFILES_BRANCH" ]; then
|
||||||
|
echo "✨ Applying dotfiles for user $DOTFILES_USER from branch $DOTFILES_BRANCH"
|
||||||
|
else
|
||||||
|
echo "✨ Applying dotfiles for user $DOTFILES_USER"
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$DOTFILES_USER" = "$USER" ]; then
|
if [ "$DOTFILES_USER" = "$USER" ]; then
|
||||||
coder dotfiles "$DOTFILES_URI" -y 2>&1 | tee ~/.dotfiles.log
|
if [ -n "$DOTFILES_BRANCH" ]; then
|
||||||
|
coder dotfiles "$DOTFILES_URI" --branch "$DOTFILES_BRANCH" -y 2>&1 | tee ~/.dotfiles.log
|
||||||
|
else
|
||||||
|
coder dotfiles "$DOTFILES_URI" -y 2>&1 | tee ~/.dotfiles.log
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
if command -v getent > /dev/null 2>&1; then
|
if command -v getent > /dev/null 2>&1; then
|
||||||
DOTFILES_USER_HOME=$(getent passwd "$DOTFILES_USER" | cut -d: -f6)
|
DOTFILES_USER_HOME=$(getent passwd "$DOTFILES_USER" | cut -d: -f6)
|
||||||
@ -40,7 +49,11 @@ if [ -n "$${DOTFILES_URI// }" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
CODER_BIN=$(command -v coder)
|
CODER_BIN=$(command -v coder)
|
||||||
sudo -u "$DOTFILES_USER" "$CODER_BIN" dotfiles "$DOTFILES_URI" -y 2>&1 | tee "$DOTFILES_USER_HOME/.dotfiles.log"
|
if [ -n "$DOTFILES_BRANCH" ]; then
|
||||||
|
sudo -u "$DOTFILES_USER" "$CODER_BIN" dotfiles "$DOTFILES_URI" --branch "$DOTFILES_BRANCH" -y 2>&1 | tee "$DOTFILES_USER_HOME/.dotfiles.log"
|
||||||
|
else
|
||||||
|
sudo -u "$DOTFILES_USER" "$CODER_BIN" dotfiles "$DOTFILES_URI" -y 2>&1 | tee "$DOTFILES_USER_HOME/.dotfiles.log"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@ -8,13 +8,13 @@ tags: [ai, agents, development, multiplexer]
|
|||||||
|
|
||||||
# Mux
|
# Mux
|
||||||
|
|
||||||
Automatically install and run [Mux](https://github.com/coder/mux) in a Coder workspace. By default, the module auto-detects an available package manager (`npm`, `pnpm`, or `bun`) to install `mux@next` (with a fallback to downloading the npm tarball if none is found). You can also force a specific package manager via `package_manager` and point to a custom registry with `registry_url`. Mux is a desktop application for parallel agentic development that enables developers to run multiple AI agents simultaneously across isolated workspaces.
|
Automatically install and run [Mux](https://github.com/coder/mux) in a Coder workspace. By default, the module auto-detects an available package manager (`npm`, `pnpm`, or `bun`) to install `mux@next` (with a fallback to downloading the npm tarball if none is found). You can also force a specific package manager via `package_manager` and point to a custom registry with `registry_url`. The launcher now keeps watching the mux process after startup and appends signal/exit-code diagnostics to the mux log when the server is killed outside the Node runtime. Mux is a desktop application for parallel agentic development that enables developers to run multiple AI agents simultaneously across isolated workspaces.
|
||||||
|
|
||||||
```tf
|
```tf
|
||||||
module "mux" {
|
module "mux" {
|
||||||
count = data.coder_workspace.me.start_count
|
count = data.coder_workspace.me.start_count
|
||||||
source = "registry.coder.com/coder/mux/coder"
|
source = "registry.coder.com/coder/mux/coder"
|
||||||
version = "1.3.1"
|
version = "1.4.0"
|
||||||
agent_id = coder_agent.main.id
|
agent_id = coder_agent.main.id
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@ -37,7 +37,7 @@ module "mux" {
|
|||||||
module "mux" {
|
module "mux" {
|
||||||
count = data.coder_workspace.me.start_count
|
count = data.coder_workspace.me.start_count
|
||||||
source = "registry.coder.com/coder/mux/coder"
|
source = "registry.coder.com/coder/mux/coder"
|
||||||
version = "1.3.1"
|
version = "1.4.0"
|
||||||
agent_id = coder_agent.main.id
|
agent_id = coder_agent.main.id
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@ -48,7 +48,7 @@ module "mux" {
|
|||||||
module "mux" {
|
module "mux" {
|
||||||
count = data.coder_workspace.me.start_count
|
count = data.coder_workspace.me.start_count
|
||||||
source = "registry.coder.com/coder/mux/coder"
|
source = "registry.coder.com/coder/mux/coder"
|
||||||
version = "1.3.1"
|
version = "1.4.0"
|
||||||
agent_id = coder_agent.main.id
|
agent_id = coder_agent.main.id
|
||||||
# Default is "latest"; set to a specific version to pin
|
# Default is "latest"; set to a specific version to pin
|
||||||
install_version = "0.4.0"
|
install_version = "0.4.0"
|
||||||
@ -63,7 +63,7 @@ Start Mux with `mux server --add-project /path/to/project`:
|
|||||||
module "mux" {
|
module "mux" {
|
||||||
count = data.coder_workspace.me.start_count
|
count = data.coder_workspace.me.start_count
|
||||||
source = "registry.coder.com/coder/mux/coder"
|
source = "registry.coder.com/coder/mux/coder"
|
||||||
version = "1.3.1"
|
version = "1.4.0"
|
||||||
agent_id = coder_agent.main.id
|
agent_id = coder_agent.main.id
|
||||||
add_project = "/path/to/project"
|
add_project = "/path/to/project"
|
||||||
}
|
}
|
||||||
@ -78,7 +78,7 @@ The module parses quoted values, so grouped arguments remain intact.
|
|||||||
module "mux" {
|
module "mux" {
|
||||||
count = data.coder_workspace.me.start_count
|
count = data.coder_workspace.me.start_count
|
||||||
source = "registry.coder.com/coder/mux/coder"
|
source = "registry.coder.com/coder/mux/coder"
|
||||||
version = "1.3.1"
|
version = "1.4.0"
|
||||||
agent_id = coder_agent.main.id
|
agent_id = coder_agent.main.id
|
||||||
additional_arguments = "--open-mode pinned --add-project '/workspaces/my repo'"
|
additional_arguments = "--open-mode pinned --add-project '/workspaces/my repo'"
|
||||||
}
|
}
|
||||||
@ -90,7 +90,7 @@ module "mux" {
|
|||||||
module "mux" {
|
module "mux" {
|
||||||
count = data.coder_workspace.me.start_count
|
count = data.coder_workspace.me.start_count
|
||||||
source = "registry.coder.com/coder/mux/coder"
|
source = "registry.coder.com/coder/mux/coder"
|
||||||
version = "1.3.1"
|
version = "1.4.0"
|
||||||
agent_id = coder_agent.main.id
|
agent_id = coder_agent.main.id
|
||||||
port = 8080
|
port = 8080
|
||||||
}
|
}
|
||||||
@ -104,7 +104,7 @@ Force a specific package manager instead of auto-detection:
|
|||||||
module "mux" {
|
module "mux" {
|
||||||
count = data.coder_workspace.me.start_count
|
count = data.coder_workspace.me.start_count
|
||||||
source = "registry.coder.com/coder/mux/coder"
|
source = "registry.coder.com/coder/mux/coder"
|
||||||
version = "1.3.1"
|
version = "1.4.0"
|
||||||
agent_id = coder_agent.main.id
|
agent_id = coder_agent.main.id
|
||||||
package_manager = "pnpm" # or "npm", "bun"
|
package_manager = "pnpm" # or "npm", "bun"
|
||||||
}
|
}
|
||||||
@ -118,7 +118,7 @@ Use a private or mirrored npm registry:
|
|||||||
module "mux" {
|
module "mux" {
|
||||||
count = data.coder_workspace.me.start_count
|
count = data.coder_workspace.me.start_count
|
||||||
source = "registry.coder.com/coder/mux/coder"
|
source = "registry.coder.com/coder/mux/coder"
|
||||||
version = "1.3.1"
|
version = "1.4.0"
|
||||||
agent_id = coder_agent.main.id
|
agent_id = coder_agent.main.id
|
||||||
registry_url = "https://npm.pkg.github.com"
|
registry_url = "https://npm.pkg.github.com"
|
||||||
}
|
}
|
||||||
@ -132,7 +132,7 @@ Run an existing copy of Mux if found, otherwise install from npm:
|
|||||||
module "mux" {
|
module "mux" {
|
||||||
count = data.coder_workspace.me.start_count
|
count = data.coder_workspace.me.start_count
|
||||||
source = "registry.coder.com/coder/mux/coder"
|
source = "registry.coder.com/coder/mux/coder"
|
||||||
version = "1.3.1"
|
version = "1.4.0"
|
||||||
agent_id = coder_agent.main.id
|
agent_id = coder_agent.main.id
|
||||||
use_cached = true
|
use_cached = true
|
||||||
}
|
}
|
||||||
@ -146,7 +146,7 @@ Run without installing from the network (requires Mux to be pre-installed):
|
|||||||
module "mux" {
|
module "mux" {
|
||||||
count = data.coder_workspace.me.start_count
|
count = data.coder_workspace.me.start_count
|
||||||
source = "registry.coder.com/coder/mux/coder"
|
source = "registry.coder.com/coder/mux/coder"
|
||||||
version = "1.3.1"
|
version = "1.4.0"
|
||||||
agent_id = coder_agent.main.id
|
agent_id = coder_agent.main.id
|
||||||
install = false
|
install = false
|
||||||
}
|
}
|
||||||
@ -163,3 +163,4 @@ module "mux" {
|
|||||||
- Auto-detects `npm`, `pnpm`, or `bun` by default; set `package_manager` to force a specific one
|
- Auto-detects `npm`, `pnpm`, or `bun` by default; set `package_manager` to force a specific one
|
||||||
- Installs `mux@next` from the npm registry by default; set `registry_url` to use a private or mirrored registry
|
- Installs `mux@next` from the npm registry by default; set `registry_url` to use a private or mirrored registry
|
||||||
- Falls back to a direct tarball download when no package manager is found
|
- Falls back to a direct tarball download when no package manager is found
|
||||||
|
- Appends best-effort signal and external-kill diagnostics to `log_path` if the mux process dies after startup
|
||||||
|
|||||||
@ -96,6 +96,55 @@ chmod +x /tmp/mux/mux`,
|
|||||||
}
|
}
|
||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
|
it("logs signal-based exits after startup", async () => {
|
||||||
|
const state = await runTerraformApply(import.meta.dir, {
|
||||||
|
agent_id: "foo",
|
||||||
|
install: false,
|
||||||
|
log_path: "/tmp/mux.log",
|
||||||
|
});
|
||||||
|
|
||||||
|
const instance = findResourceInstance(state, "coder_script");
|
||||||
|
const id = await runContainer("alpine/curl");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const setup = await execContainer(id, [
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
`apk add --no-cache bash >/dev/null
|
||||||
|
mkdir -p /tmp/mux
|
||||||
|
cat <<'EOF' > /tmp/mux/mux
|
||||||
|
#!/usr/bin/env sh
|
||||||
|
target_pid="$$"
|
||||||
|
(
|
||||||
|
sleep 1
|
||||||
|
kill -9 "$target_pid"
|
||||||
|
) &
|
||||||
|
while true; do
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
EOF
|
||||||
|
chmod +x /tmp/mux/mux`,
|
||||||
|
]);
|
||||||
|
expect(setup.exitCode).toBe(0);
|
||||||
|
|
||||||
|
const output = await execContainer(id, ["sh", "-c", instance.script]);
|
||||||
|
if (output.exitCode !== 0) {
|
||||||
|
console.log("STDOUT:\n" + output.stdout);
|
||||||
|
console.log("STDERR:\n" + output.stderr);
|
||||||
|
}
|
||||||
|
expect(output.exitCode).toBe(0);
|
||||||
|
|
||||||
|
await execContainer(id, ["sh", "-c", "sleep 2"]);
|
||||||
|
const log = await readFileContainer(id, "/tmp/mux.log");
|
||||||
|
expect(log).toContain("shell exit code 137");
|
||||||
|
expect(log).toContain(
|
||||||
|
"SIGKILL usually means the process was killed externally or by the OOM killer.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await removeContainer(id);
|
||||||
|
}
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
it("runs with npm present", async () => {
|
it("runs with npm present", async () => {
|
||||||
const state = await runTerraformApply(import.meta.dir, {
|
const state = await runTerraformApply(import.meta.dir, {
|
||||||
agent_id: "foo",
|
agent_id: "foo",
|
||||||
|
|||||||
@ -93,6 +93,24 @@ run "custom_additional_arguments" {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
run "launcher_logs_external_kills" {
|
||||||
|
command = plan
|
||||||
|
|
||||||
|
variables {
|
||||||
|
agent_id = "foo"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {
|
||||||
|
condition = strcontains(resource.coder_script.mux.script, "shell exit code $exit_code")
|
||||||
|
error_message = "mux launcher must log the shell exit code when the server dies unexpectedly"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {
|
||||||
|
condition = strcontains(resource.coder_script.mux.script, "SIGKILL usually means the process was killed externally or by the OOM killer.")
|
||||||
|
error_message = "mux launcher must explain SIGKILL exits in the log"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
run "custom_version" {
|
run "custom_version" {
|
||||||
command = plan
|
command = plan
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,9 @@ function run_mux() {
|
|||||||
if [ -z "$port_value" ]; then
|
if [ -z "$port_value" ]; then
|
||||||
port_value="4000"
|
port_value="4000"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "${LOG_PATH}")"
|
||||||
|
|
||||||
# Build args for mux (POSIX-compatible, avoid bash arrays)
|
# Build args for mux (POSIX-compatible, avoid bash arrays)
|
||||||
set -- server --port "$port_value"
|
set -- server --port "$port_value"
|
||||||
if [ -n "${ADD_PROJECT}" ]; then
|
if [ -n "${ADD_PROJECT}" ]; then
|
||||||
@ -31,16 +34,93 @@ function run_mux() {
|
|||||||
while IFS= read -r parsed_arg; do
|
while IFS= read -r parsed_arg; do
|
||||||
[ -n "$parsed_arg" ] || continue
|
[ -n "$parsed_arg" ] || continue
|
||||||
set -- "$@" "$parsed_arg"
|
set -- "$@" "$parsed_arg"
|
||||||
done << EOF
|
done << EOF_ARGS
|
||||||
$${parsed_additional_arguments}
|
$${parsed_additional_arguments}
|
||||||
EOF
|
EOF_ARGS
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "🚀 Starting mux server on port $port_value..."
|
echo "🚀 Starting mux server on port $port_value..."
|
||||||
echo "Check logs at ${LOG_PATH}!"
|
echo "Check logs at ${LOG_PATH}!"
|
||||||
MUX_SERVER_AUTH_TOKEN="$auth_token_value" PORT="$port_value" "$MUX_BINARY" "$@" > "${LOG_PATH}" 2>&1 &
|
echo "ℹ️ Unexpected exits will be appended to ${LOG_PATH} by the launcher."
|
||||||
|
|
||||||
|
nohup env \
|
||||||
|
LOG_PATH="${LOG_PATH}" \
|
||||||
|
MUX_BINARY="$MUX_BINARY" \
|
||||||
|
AUTH_TOKEN="$auth_token_value" \
|
||||||
|
PORT_VALUE="$port_value" \
|
||||||
|
bash -s -- "$@" > /dev/null 2>&1 << 'EOF_LAUNCHER' &
|
||||||
|
signal_name() {
|
||||||
|
local signal_number="$1"
|
||||||
|
local resolved_signal
|
||||||
|
|
||||||
|
resolved_signal="$(kill -l "$signal_number" 2> /dev/null || true)"
|
||||||
|
if [ -n "$resolved_signal" ]; then
|
||||||
|
printf '%s' "$resolved_signal"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'SIG%s' "$signal_number"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
append_kernel_kill_context() {
|
||||||
|
local mux_pid="$1"
|
||||||
|
local kernel_context=""
|
||||||
|
|
||||||
|
if command -v dmesg > /dev/null 2>&1; then
|
||||||
|
kernel_context="$(dmesg -T 2> /dev/null | grep -Ei "Killed process $mux_pid|out of memory|oom-killer|oom reaper" | tail -n 10 || true)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$kernel_context" ] && command -v journalctl > /dev/null 2>&1; then
|
||||||
|
kernel_context="$(journalctl -k -n 200 --no-pager 2> /dev/null | grep -Ei "Killed process $mux_pid|out of memory|oom-killer|oom reaper" | tail -n 10 || true)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$kernel_context" ]; then
|
||||||
|
echo "Recent kernel kill context:"
|
||||||
|
echo "$kernel_context"
|
||||||
|
else
|
||||||
|
echo "No kernel OOM/kill context was available (dmesg/journalctl unavailable or permission denied)."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
log_mux_exit() {
|
||||||
|
local mux_pid="$1"
|
||||||
|
local exit_code="$2"
|
||||||
|
local timestamp
|
||||||
|
|
||||||
|
timestamp="$(date -Iseconds 2> /dev/null || date)"
|
||||||
|
|
||||||
|
if [ "$exit_code" -eq 0 ]; then
|
||||||
|
echo "[$timestamp] mux server exited cleanly."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$exit_code" -gt 128 ]; then
|
||||||
|
local signal_number=$((exit_code - 128))
|
||||||
|
local signal_label
|
||||||
|
|
||||||
|
signal_label="$(signal_name "$signal_number")"
|
||||||
|
echo "[$timestamp] mux server exited due to signal $signal_label ($signal_number); shell exit code $exit_code."
|
||||||
|
|
||||||
|
if [ "$signal_number" -eq 9 ]; then
|
||||||
|
echo "[$timestamp] SIGKILL usually means the process was killed externally or by the OOM killer."
|
||||||
|
append_kernel_kill_context "$mux_pid"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[$timestamp] Check the earlier mux log lines for any in-process crash breadcrumbs from mux itself."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[$timestamp] mux server exited with code $exit_code."
|
||||||
|
echo "[$timestamp] Check the earlier mux log lines for any in-process crash breadcrumbs from mux itself."
|
||||||
|
}
|
||||||
|
|
||||||
|
MUX_SERVER_AUTH_TOKEN="$AUTH_TOKEN" PORT="$PORT_VALUE" "$MUX_BINARY" "$@" >> "$LOG_PATH" 2>&1 &
|
||||||
|
mux_pid=$!
|
||||||
|
wait "$mux_pid"
|
||||||
|
exit_code=$?
|
||||||
|
log_mux_exit "$mux_pid" "$exit_code" >> "$LOG_PATH" 2>&1
|
||||||
|
EOF_LAUNCHER
|
||||||
|
}
|
||||||
# Check if mux is already installed for offline mode
|
# Check if mux is already installed for offline mode
|
||||||
if [ "${OFFLINE}" = true ]; then
|
if [ "${OFFLINE}" = true ]; then
|
||||||
if [ -f "$MUX_BINARY" ]; then
|
if [ -f "$MUX_BINARY" ]; then
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user