Closes #878 ## What Major refactor of the `coder-labs/codex` module to mirror the `coder/claude-code` v5 changes from #861. ## Changes ### Structural - Replace `module "agentapi"` with `module "coder_utils"` (`registry.coder.com/coder/coder-utils/coder v0.0.1`) - Replace `scripts/install.sh` with `scripts/install.sh.tftpl` (Terraform templatefile) - Delete `scripts/start.sh` - Module dir changed from `.codex-module` to `.coder-modules/coder-labs/codex` - Output changed from `task_app_id` to `scripts` (ordered list of coder exp sync names) - Extracted shared test helpers (`collectScripts`, `runScripts`) into `agentapi/coder-utils-test-helpers.ts` ### Removed variables All AgentAPI pass-throughs, boundary, and start-script-only variables: `order`, `group`, `report_tasks`, `subdomain`, `cli_app`, `web_app_display_name`, `cli_app_display_name`, `install_agentapi`, `agentapi_version`, `ai_prompt`, `continue`, `enable_state_persistence`, `codex_system_prompt`, `enable_boundary`, `boundary_config_path`, `boundary_version`, `compile_boundary_from_source`, `use_boundary_directly`, `codex_model` ### Retained `install_codex` (toggle for skipping npm install when CLI is pre-installed) ### Renamed - `enable_aibridge` -> `enable_ai_gateway` ### Changed - `workdir`: now optional (`default = null`) - `openai_api_key`: conditional env var with `count`, marked `sensitive = true` - `base_config_toml`: heredoc description documenting generated defaults; notes that `model_reasoning_effort` and workdir trust are only applied in default config - Default `config.toml`: stripped `sandbox_mode`, `approval_policy`, `sandbox_workspace_write`, `notice.model_migrations` - Install script: removed Node.js/NVM bootstrap (assumes npm pre-installed), sources NVM if present, fails with actionable error if npm missing - `ARG_CODEX_VERSION` and `ARG_WORKDIR` base64-encoded to prevent shell/TOML injection - Duplicate `[model_providers.aibridge]` guarded with grep before appending - Debug header uses user-facing variable names ### Tests - Terraform: 11 pass - Bun: 15 pass (rewritten to shared `collectScripts`/`runScripts` pattern) - Added: `model-reasoning-effort-standalone`, `ai-gateway-with-custom-base-config`, `ai-gateway-custom-config-no-duplicate-provider`, `install-codex-latest`, `workdir-trusted-project`, `no-workdir-no-project-section` - Negative assertions on `minimal-default-config` ### Docs - Migration guide (v4 to v5) in README - Quoted path in coder_app example - AI Gateway note about custom `base_config_toml` requiring manual `model_provider` > [!WARNING] > Breaking change. Drops support for Coder Tasks and Boundary. Keep using v4.x.x if you depend on them. --- *This PR was authored by Coder Agents.* --------- Co-authored-by: Jay Kumar <jay.kumar@coder.com> Co-authored-by: DevCats <christofer@coder.com>
448 lines
13 KiB
TypeScript
448 lines
13 KiB
TypeScript
import {
|
|
test,
|
|
afterEach,
|
|
describe,
|
|
setDefaultTimeout,
|
|
beforeAll,
|
|
expect,
|
|
} from "bun:test";
|
|
import {
|
|
execContainer,
|
|
readFileContainer,
|
|
removeContainer,
|
|
runContainer,
|
|
runTerraformApply,
|
|
runTerraformInit,
|
|
TerraformState,
|
|
} from "~test";
|
|
import {
|
|
extractCoderEnvVars,
|
|
writeExecutable,
|
|
} from "../../../coder/modules/agentapi/test-util";
|
|
import path from "path";
|
|
|
|
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<string, string> = {};
|
|
for (const resource of state.resources) {
|
|
if (resource.type !== "coder_script") continue;
|
|
for (const instance of resource.instances) {
|
|
const attrs = instance.attributes as Record<string, unknown>;
|
|
const displayName = attrs.display_name as string | undefined;
|
|
const script = attrs.script as string | undefined;
|
|
if (displayName && script) {
|
|
byDisplayName[displayName] = script;
|
|
}
|
|
}
|
|
}
|
|
const scripts: Partial<ModuleScripts> = {};
|
|
for (const suffix of SCRIPT_SUFFIXES) {
|
|
const key = `Codex: ${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<void>)[] = [];
|
|
const registerCleanup = (cleanup: () => Promise<void>) => {
|
|
cleanupFunctions.push(cleanup);
|
|
};
|
|
afterEach(async () => {
|
|
const cleanupFnsCopy = cleanupFunctions.slice().reverse();
|
|
cleanupFunctions = [];
|
|
for (const cleanup of cleanupFnsCopy) {
|
|
try {
|
|
await cleanup();
|
|
} catch (error) {
|
|
console.error("Error during cleanup:", error);
|
|
}
|
|
}
|
|
});
|
|
|
|
interface SetupProps {
|
|
skipCodexMock?: boolean;
|
|
moduleVariables?: Record<string, string>;
|
|
}
|
|
|
|
const setup = async (
|
|
props?: SetupProps,
|
|
): Promise<{
|
|
id: string;
|
|
coderEnvVars: Record<string, string>;
|
|
scripts: ModuleScripts;
|
|
}> => {
|
|
const projectDir = "/home/coder/project";
|
|
const moduleDir = path.resolve(import.meta.dir);
|
|
const state = await runTerraformApply(moduleDir, {
|
|
agent_id: "foo",
|
|
workdir: projectDir,
|
|
install_codex: "false",
|
|
...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 execContainer(id, ["bash", "-c", `mkdir -p '${projectDir}'`]);
|
|
await writeExecutable({
|
|
containerId: id,
|
|
filePath: "/usr/bin/coder",
|
|
content: "#!/bin/bash\nexit 0\n",
|
|
});
|
|
if (!props?.skipCodexMock) {
|
|
await writeExecutable({
|
|
containerId: id,
|
|
filePath: "/usr/bin/codex",
|
|
content: await Bun.file(
|
|
path.join(moduleDir, "testdata", "codex-mock.sh"),
|
|
).text(),
|
|
});
|
|
}
|
|
return { id, coderEnvVars, scripts };
|
|
};
|
|
|
|
const runScripts = async (
|
|
id: string,
|
|
scripts: ModuleScripts,
|
|
env?: Record<string, string>,
|
|
) => {
|
|
const entries = env ? Object.entries(env) : [];
|
|
const envArgs =
|
|
entries.length > 0
|
|
? entries
|
|
.map(
|
|
([key, value]) => `export ${key}="${value.replace(/"/g, '\\"')}"`,
|
|
)
|
|
.join(" && ") + " && "
|
|
: "";
|
|
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 target = `/tmp/coder-utils-${name}.sh`;
|
|
await writeExecutable({
|
|
containerId: id,
|
|
filePath: target,
|
|
content: script,
|
|
});
|
|
const resp = await execContainer(id, ["bash", "-c", `${envArgs}${target}`]);
|
|
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}`);
|
|
}
|
|
}
|
|
};
|
|
|
|
setDefaultTimeout(60 * 1000);
|
|
|
|
describe("codex", async () => {
|
|
beforeAll(async () => {
|
|
await runTerraformInit(import.meta.dir);
|
|
});
|
|
|
|
test("happy-path", async () => {
|
|
const { id, scripts } = await setup();
|
|
await runScripts(id, scripts);
|
|
const installLog = await readFileContainer(
|
|
id,
|
|
"/home/coder/.coder-modules/coder-labs/codex/logs/install.log",
|
|
);
|
|
expect(installLog).toContain("Skipping Codex installation");
|
|
});
|
|
|
|
test("install-codex-version", async () => {
|
|
const version = "0.10.0";
|
|
const { id, coderEnvVars, scripts } = await setup({
|
|
skipCodexMock: true,
|
|
moduleVariables: {
|
|
install_codex: "true",
|
|
codex_version: version,
|
|
},
|
|
});
|
|
await runScripts(id, scripts, coderEnvVars);
|
|
const installLog = await readFileContainer(
|
|
id,
|
|
"/home/coder/.coder-modules/coder-labs/codex/logs/install.log",
|
|
);
|
|
expect(installLog).toContain(version);
|
|
});
|
|
|
|
test("openai-api-key", async () => {
|
|
const apiKey = "test-api-key-123";
|
|
const { coderEnvVars } = await setup({
|
|
moduleVariables: {
|
|
openai_api_key: apiKey,
|
|
},
|
|
});
|
|
expect(coderEnvVars["OPENAI_API_KEY"]).toBe(apiKey);
|
|
});
|
|
|
|
test("base-config-toml", async () => {
|
|
const baseConfig = [
|
|
'sandbox_mode = "danger-full-access"',
|
|
'approval_policy = "never"',
|
|
'preferred_auth_method = "apikey"',
|
|
"",
|
|
"[custom_section]",
|
|
"new_feature = true",
|
|
].join("\n");
|
|
const { id, scripts } = await setup({
|
|
moduleVariables: {
|
|
base_config_toml: baseConfig,
|
|
},
|
|
});
|
|
await runScripts(id, scripts);
|
|
const resp = await readFileContainer(id, "/home/coder/.codex/config.toml");
|
|
expect(resp).toContain('sandbox_mode = "danger-full-access"');
|
|
expect(resp).toContain('preferred_auth_method = "apikey"');
|
|
expect(resp).toContain("[custom_section]");
|
|
});
|
|
|
|
test("additional-mcp-servers", async () => {
|
|
const additional = [
|
|
"[mcp_servers.GitHub]",
|
|
'command = "npx"',
|
|
'args = ["-y", "@modelcontextprotocol/server-github"]',
|
|
'type = "stdio"',
|
|
'description = "GitHub integration"',
|
|
].join("\n");
|
|
const { id, scripts } = await setup({
|
|
moduleVariables: {
|
|
additional_mcp_servers: additional,
|
|
},
|
|
});
|
|
await runScripts(id, scripts);
|
|
const resp = await readFileContainer(id, "/home/coder/.codex/config.toml");
|
|
expect(resp).toContain("[mcp_servers.GitHub]");
|
|
expect(resp).toContain("GitHub integration");
|
|
});
|
|
|
|
test("minimal-default-config", async () => {
|
|
const { id, scripts } = await setup();
|
|
await runScripts(id, scripts);
|
|
const resp = await readFileContainer(id, "/home/coder/.codex/config.toml");
|
|
expect(resp).toContain('preferred_auth_method = "apikey"');
|
|
expect(resp).not.toContain("model_provider");
|
|
expect(resp).not.toContain("[model_providers.");
|
|
expect(resp).not.toContain("model_reasoning_effort");
|
|
});
|
|
|
|
test("pre-post-install-scripts", async () => {
|
|
const { id, scripts } = await setup({
|
|
moduleVariables: {
|
|
pre_install_script: "#!/bin/bash\necho 'codex-pre-install-script'",
|
|
post_install_script: "#!/bin/bash\necho 'codex-post-install-script'",
|
|
},
|
|
});
|
|
await runScripts(id, scripts);
|
|
|
|
const preInstallLog = await readFileContainer(
|
|
id,
|
|
"/home/coder/.coder-modules/coder-labs/codex/logs/pre_install.log",
|
|
);
|
|
expect(preInstallLog).toContain("codex-pre-install-script");
|
|
|
|
const postInstallLog = await readFileContainer(
|
|
id,
|
|
"/home/coder/.coder-modules/coder-labs/codex/logs/post_install.log",
|
|
);
|
|
expect(postInstallLog).toContain("codex-post-install-script");
|
|
});
|
|
|
|
test("workdir-variable", async () => {
|
|
const workdir = "/home/coder/codex-test-folder";
|
|
const { id, scripts } = await setup({
|
|
moduleVariables: {
|
|
workdir,
|
|
},
|
|
});
|
|
await runScripts(id, scripts);
|
|
const installLog = await readFileContainer(
|
|
id,
|
|
"/home/coder/.coder-modules/coder-labs/codex/logs/install.log",
|
|
);
|
|
expect(installLog).toContain(workdir);
|
|
});
|
|
|
|
test("codex-with-ai-gateway", async () => {
|
|
const { id, coderEnvVars, scripts } = await setup({
|
|
moduleVariables: {
|
|
enable_ai_gateway: "true",
|
|
model_reasoning_effort: "none",
|
|
},
|
|
});
|
|
await runScripts(id, scripts, coderEnvVars);
|
|
const configToml = await readFileContainer(
|
|
id,
|
|
"/home/coder/.codex/config.toml",
|
|
);
|
|
expect(configToml).toContain('model_provider = "aigateway"');
|
|
expect(configToml).toContain('model_reasoning_effort = "none"');
|
|
expect(configToml).toContain("[model_providers.aigateway]");
|
|
});
|
|
|
|
test("model-reasoning-effort-standalone", async () => {
|
|
const { id, scripts } = await setup({
|
|
moduleVariables: {
|
|
model_reasoning_effort: "high",
|
|
},
|
|
});
|
|
await runScripts(id, scripts);
|
|
const configToml = await readFileContainer(
|
|
id,
|
|
"/home/coder/.codex/config.toml",
|
|
);
|
|
expect(configToml).toContain('model_reasoning_effort = "high"');
|
|
expect(configToml).not.toContain("model_provider");
|
|
});
|
|
|
|
test("workdir-trusted-project", async () => {
|
|
const workdir = "/home/coder/trusted-project";
|
|
const { id, scripts } = await setup({
|
|
moduleVariables: {
|
|
workdir,
|
|
},
|
|
});
|
|
await runScripts(id, scripts);
|
|
const configToml = await readFileContainer(
|
|
id,
|
|
"/home/coder/.codex/config.toml",
|
|
);
|
|
expect(configToml).toContain(`[projects."${workdir}"]`);
|
|
expect(configToml).toContain('trust_level = "trusted"');
|
|
});
|
|
|
|
test("no-workdir-no-project-section", async () => {
|
|
const { id, scripts } = await setup({
|
|
moduleVariables: {
|
|
workdir: "",
|
|
},
|
|
});
|
|
await runScripts(id, scripts);
|
|
const configToml = await readFileContainer(
|
|
id,
|
|
"/home/coder/.codex/config.toml",
|
|
);
|
|
expect(configToml).not.toContain("[projects.");
|
|
});
|
|
|
|
test("ai-gateway-with-custom-base-config", async () => {
|
|
const baseConfig = [
|
|
'sandbox_mode = "danger-full-access"',
|
|
'model_provider = "aigateway"',
|
|
].join("\n");
|
|
const { id, coderEnvVars, scripts } = await setup({
|
|
moduleVariables: {
|
|
enable_ai_gateway: "true",
|
|
base_config_toml: baseConfig,
|
|
},
|
|
});
|
|
await runScripts(id, scripts, coderEnvVars);
|
|
const configToml = await readFileContainer(
|
|
id,
|
|
"/home/coder/.codex/config.toml",
|
|
);
|
|
expect(configToml).toContain('model_provider = "aigateway"');
|
|
expect(configToml).toContain("[model_providers.aigateway]");
|
|
});
|
|
|
|
test("ai-gateway-custom-config-no-duplicate-provider", async () => {
|
|
const baseConfig = [
|
|
'model_provider = "aigateway"',
|
|
"",
|
|
"[model_providers.aigateway]",
|
|
'name = "Custom AI Bridge"',
|
|
'base_url = "https://custom.example.com"',
|
|
'env_key = "CODER_AIBRIDGE_SESSION_TOKEN"',
|
|
'wire_api = "responses"',
|
|
].join("\n");
|
|
const { id, coderEnvVars, scripts } = await setup({
|
|
moduleVariables: {
|
|
enable_ai_gateway: "true",
|
|
base_config_toml: baseConfig,
|
|
},
|
|
});
|
|
await runScripts(id, scripts, coderEnvVars);
|
|
const configToml = await readFileContainer(
|
|
id,
|
|
"/home/coder/.codex/config.toml",
|
|
);
|
|
const matches = configToml.match(/\[model_providers\.aigateway\]/g) || [];
|
|
expect(matches.length).toBe(1);
|
|
expect(configToml).toContain("Custom AI Bridge");
|
|
});
|
|
|
|
test("install-codex-latest", async () => {
|
|
const { id, coderEnvVars, scripts } = await setup({
|
|
skipCodexMock: true,
|
|
moduleVariables: {
|
|
install_codex: "true",
|
|
},
|
|
});
|
|
await runScripts(id, scripts, coderEnvVars);
|
|
const installLog = await readFileContainer(
|
|
id,
|
|
"/home/coder/.coder-modules/coder-labs/codex/logs/install.log",
|
|
);
|
|
expect(installLog).toContain("Installed Codex CLI");
|
|
});
|
|
|
|
test("custom-config-drops-reasoning-effort", async () => {
|
|
const baseConfig = [
|
|
'sandbox_mode = "danger-full-access"',
|
|
'preferred_auth_method = "apikey"',
|
|
].join("\n");
|
|
const { id, scripts } = await setup({
|
|
moduleVariables: {
|
|
base_config_toml: baseConfig,
|
|
model_reasoning_effort: "high",
|
|
},
|
|
});
|
|
await runScripts(id, scripts);
|
|
const configToml = await readFileContainer(
|
|
id,
|
|
"/home/coder/.codex/config.toml",
|
|
);
|
|
expect(configToml).toContain('sandbox_mode = "danger-full-access"');
|
|
expect(configToml).not.toContain("model_reasoning_effort");
|
|
});
|
|
});
|