fix: provision code agent codex runtime
Merge Code Agent real Codex runtime provisioning for HWLAB#377.
This commit is contained in:
@@ -208,8 +208,11 @@
|
||||
"HWLAB_CODE_AGENT_HWLAB_API_BASE_URL": "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667",
|
||||
"HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED": "1",
|
||||
"HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR": "repo-owned",
|
||||
"HWLAB_CODE_AGENT_WORKSPACE": "/workspace/hwlab",
|
||||
"HWLAB_CODE_AGENT_CODEX_WORKSPACE": "/workspace/hwlab",
|
||||
"HWLAB_CODE_AGENT_CODEX_SANDBOX": "workspace-write",
|
||||
"HWLAB_CODE_AGENT_CODEX_COMMAND": "/app/node_modules/.bin/codex",
|
||||
"CODEX_HOME": "/codex-home",
|
||||
"OPENAI_API_KEY": "secretRef:hwlab-code-agent-provider/openai-api-key"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -430,6 +430,10 @@
|
||||
"type": "string",
|
||||
"const": "repo-owned"
|
||||
},
|
||||
"HWLAB_CODE_AGENT_WORKSPACE": {
|
||||
"type": "string",
|
||||
"const": "/workspace/hwlab"
|
||||
},
|
||||
"HWLAB_CODE_AGENT_CODEX_WORKSPACE": {
|
||||
"type": "string",
|
||||
"const": "/workspace/hwlab"
|
||||
@@ -438,6 +442,14 @@
|
||||
"type": "string",
|
||||
"const": "workspace-write"
|
||||
},
|
||||
"HWLAB_CODE_AGENT_CODEX_COMMAND": {
|
||||
"type": "string",
|
||||
"const": "/app/node_modules/.bin/codex"
|
||||
},
|
||||
"CODEX_HOME": {
|
||||
"type": "string",
|
||||
"const": "/codex-home"
|
||||
},
|
||||
"OPENAI_API_KEY": {
|
||||
"type": "string",
|
||||
"const": "secretRef:hwlab-code-agent-provider/openai-api-key"
|
||||
|
||||
@@ -129,6 +129,10 @@
|
||||
"name": "HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR",
|
||||
"value": "repo-owned"
|
||||
},
|
||||
{
|
||||
"name": "HWLAB_CODE_AGENT_WORKSPACE",
|
||||
"value": "/workspace/hwlab"
|
||||
},
|
||||
{
|
||||
"name": "HWLAB_CODE_AGENT_CODEX_WORKSPACE",
|
||||
"value": "/workspace/hwlab"
|
||||
@@ -137,6 +141,14 @@
|
||||
"name": "HWLAB_CODE_AGENT_CODEX_SANDBOX",
|
||||
"value": "workspace-write"
|
||||
},
|
||||
{
|
||||
"name": "HWLAB_CODE_AGENT_CODEX_COMMAND",
|
||||
"value": "/app/node_modules/.bin/codex"
|
||||
},
|
||||
{
|
||||
"name": "CODEX_HOME",
|
||||
"value": "/codex-home"
|
||||
},
|
||||
{
|
||||
"name": "OPENAI_API_KEY",
|
||||
"valueFrom": {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { createCodeAgentSessionRegistry } from "./code-agent-session-registry.mjs";
|
||||
@@ -929,6 +932,53 @@ test("Codex stdio manager reports concrete blockers without falling back to read
|
||||
assert.equal(Object.hasOwn(blocked, "reply"), false);
|
||||
});
|
||||
|
||||
test("Codex stdio feasibility verifies writable workspace, CODEX_HOME, and version-probed command", async () => {
|
||||
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-stdio-workspace-"));
|
||||
const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-codex-home-"));
|
||||
try {
|
||||
const manager = createCodexStdioSessionManager();
|
||||
const codexCommand = path.join(process.cwd(), "node_modules", ".bin", "codex");
|
||||
const availability = manager.describe({
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
OPENAI_API_KEY: "test-openai-key-material",
|
||||
CODEX_HOME: codexHome,
|
||||
HWLAB_CODE_AGENT_CODEX_COMMAND: codexCommand,
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1",
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned",
|
||||
HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace,
|
||||
HWLAB_CODE_AGENT_CODEX_SANDBOX: "workspace-write",
|
||||
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses"
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(availability.command, codexCommand);
|
||||
assert.equal(availability.binary.present, true);
|
||||
assert.equal(availability.binary.versionDetected, true);
|
||||
assert.equal(availability.workspace, workspace);
|
||||
assert.equal(availability.workspaceState.exists, true);
|
||||
assert.equal(availability.workspaceState.readable, true);
|
||||
assert.equal(availability.workspaceState.writable, true);
|
||||
assert.equal(availability.runtimeContract.workspaceMount.status, "ready");
|
||||
assert.equal(availability.codexHome, codexHome);
|
||||
assert.equal(availability.codexHomeState.exists, true);
|
||||
assert.equal(availability.codexHomeState.readable, true);
|
||||
assert.equal(availability.codexHomeState.writable, true);
|
||||
assert.equal(availability.runtimeContract.codexHome.status, "ready");
|
||||
assert.equal(availability.startupReady, true);
|
||||
assert.equal(availability.canStartLongLivedCodexStdio, true);
|
||||
assert.equal(availability.ready, false);
|
||||
assert.ok(availability.blockerCodes.includes("stdio_protocol_not_wired"));
|
||||
assert.equal(availability.blockerCodes.includes("workspace_mount_missing"), false);
|
||||
assert.equal(availability.blockerCodes.includes("workspace_write_boundary_blocked"), false);
|
||||
assert.equal(availability.blockerCodes.includes("codex_home_missing"), false);
|
||||
assert.equal(availability.blockerCodes.includes("codex_home_write_blocked"), false);
|
||||
} finally {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
await rm(codexHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("repo-owned Codex stdio manager creates and reuses long-lived sessions with trace evidence", async () => {
|
||||
const calls = [];
|
||||
const manager = createCodexStdioSessionManager({
|
||||
@@ -955,6 +1005,7 @@ test("repo-owned Codex stdio manager creates and reuses long-lived sessions with
|
||||
const env = {
|
||||
PATH: process.env.PATH,
|
||||
OPENAI_API_KEY: "test-openai-key-material",
|
||||
CODEX_HOME: process.cwd(),
|
||||
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1",
|
||||
@@ -1025,6 +1076,7 @@ test("Codex stdio runner startup failure marks session failed and blocks long-li
|
||||
const env = {
|
||||
PATH: process.env.PATH,
|
||||
OPENAI_API_KEY: "test-openai-key-material",
|
||||
CODEX_HOME: process.cwd(),
|
||||
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1",
|
||||
@@ -1081,6 +1133,7 @@ test("Codex stdio protocol missing tools is a structured blocker, not a passed s
|
||||
const env = {
|
||||
PATH: process.env.PATH,
|
||||
OPENAI_API_KEY: "test-openai-key-material",
|
||||
CODEX_HOME: process.cwd(),
|
||||
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1",
|
||||
@@ -1110,6 +1163,6 @@ test("Codex stdio protocol missing tools is a structured blocker, not a passed s
|
||||
assert.ok(payload.longLivedSessionGate.blockers.some((blocker) => blocker.code === "session_failed"));
|
||||
assert.equal(payload.availability.codexStdio.ready, false);
|
||||
assert.ok(payload.availability.codexStdio.blockerCodes.includes("stdio_protocol_not_wired"));
|
||||
assert.deepEqual(payload.availability.codexStdio.protocol.toolsObserved, ["codex"]);
|
||||
assert.deepEqual(payload.codexStdioFeasibility.protocol.toolsObserved, ["codex"]);
|
||||
assert.equal(classifyCodexRunnerCapability(payload, { httpStatus: 200 }).capabilityPass, false);
|
||||
});
|
||||
|
||||
@@ -50,12 +50,14 @@ export function createCodexStdioSessionManager(options = {}) {
|
||||
const binary = codexBinaryState(command, env);
|
||||
const binaryOnPath = binary.present;
|
||||
const workspaceInfo = workspaceStateSync(workspace, sandbox);
|
||||
const codexHome = resolveCodexHome(env);
|
||||
const codexHomeInfo = directoryStateSync(codexHome, { writableRequired: true });
|
||||
const egress = egressState(env);
|
||||
const protocol = protocolState({
|
||||
command,
|
||||
binary,
|
||||
supervisor,
|
||||
toolsObserved: rpcToolNames
|
||||
toolsObserved: params.toolsObserved ?? rpcToolNames
|
||||
});
|
||||
const lifecycle = lifecycleState({
|
||||
supervisor,
|
||||
@@ -107,6 +109,20 @@ export function createCodexStdioSessionManager(options = {}) {
|
||||
summary: `Configured Codex workspace is not writable for workspace-write sandbox: ${workspace}.`
|
||||
});
|
||||
}
|
||||
if (!codexHomeInfo.exists || !codexHomeInfo.readable) {
|
||||
blockers.push({
|
||||
code: "codex_home_missing",
|
||||
sourceIssue: "pikasTech/HWLAB#377",
|
||||
summary: `Configured CODEX_HOME is not present/readable: ${codexHome}.`
|
||||
});
|
||||
}
|
||||
if (codexHomeInfo.writable !== true) {
|
||||
blockers.push({
|
||||
code: "codex_home_write_blocked",
|
||||
sourceIssue: "pikasTech/HWLAB#377",
|
||||
summary: `Configured CODEX_HOME is not writable for Codex session state: ${codexHome}.`
|
||||
});
|
||||
}
|
||||
if (!tokenBoundary.present) {
|
||||
blockers.push({
|
||||
code: "provider_token_boundary",
|
||||
@@ -123,18 +139,23 @@ export function createCodexStdioSessionManager(options = {}) {
|
||||
}
|
||||
|
||||
const ready = blockers.length === 0;
|
||||
const startupBlockers = blockers.filter((blocker) => blocker.code !== "stdio_protocol_not_wired");
|
||||
const startupReady = startupBlockers.length === 0 && protocol.probeReady === true;
|
||||
return {
|
||||
kind: CODEX_STDIO_RUNNER_KIND,
|
||||
provider: CODEX_STDIO_PROVIDER,
|
||||
backend: CODEX_STDIO_BACKEND,
|
||||
status: ready ? "feasible" : "blocked",
|
||||
ready,
|
||||
canStartLongLivedCodexStdio: ready,
|
||||
startupReady,
|
||||
canStartLongLivedCodexStdio: startupReady,
|
||||
command,
|
||||
binary,
|
||||
binaryOnPath,
|
||||
workspace,
|
||||
workspaceState: workspaceInfo,
|
||||
codexHome,
|
||||
codexHomeState: codexHomeInfo,
|
||||
sandbox,
|
||||
enabled,
|
||||
supervisor,
|
||||
@@ -154,6 +175,8 @@ export function createCodexStdioSessionManager(options = {}) {
|
||||
workspaceInfo,
|
||||
workspace,
|
||||
sandbox,
|
||||
codexHome,
|
||||
codexHomeInfo,
|
||||
tokenBoundary,
|
||||
egress
|
||||
}),
|
||||
@@ -305,6 +328,7 @@ export function createCodexStdioSessionManager(options = {}) {
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
closeRpcClient();
|
||||
session = failSession(session.sessionId, {
|
||||
now,
|
||||
traceId,
|
||||
@@ -352,10 +376,7 @@ export function createCodexStdioSessionManager(options = {}) {
|
||||
session.currentTraceId = null;
|
||||
session.statusReason = params.reason ?? "cancelled";
|
||||
if (rpcClient && typeof rpcClient.close === "function") {
|
||||
rpcClient.close();
|
||||
rpcClient = null;
|
||||
rpcStartedAt = null;
|
||||
rpcToolNames = null;
|
||||
closeRpcClient();
|
||||
}
|
||||
return publicSession(session, { conversationId: params.conversationId, reused: true });
|
||||
}
|
||||
@@ -371,11 +392,8 @@ export function createCodexStdioSessionManager(options = {}) {
|
||||
session.currentTraceId = null;
|
||||
reaped.push(session.sessionId);
|
||||
}
|
||||
if (sessionsBusyCount() === 0 && reaped.length > 0 && rpcClient && typeof rpcClient.close === "function") {
|
||||
rpcClient.close();
|
||||
rpcClient = null;
|
||||
rpcStartedAt = null;
|
||||
rpcToolNames = null;
|
||||
if (sessionsBusyCount() === 0 && reaped.length > 0) {
|
||||
closeRpcClient();
|
||||
}
|
||||
return {
|
||||
reapedCount: reaped.length,
|
||||
@@ -391,7 +409,13 @@ export function createCodexStdioSessionManager(options = {}) {
|
||||
function clear() {
|
||||
sessions.clear();
|
||||
conversations.clear();
|
||||
if (rpcClient && typeof rpcClient.close === "function") rpcClient.close();
|
||||
closeRpcClient();
|
||||
}
|
||||
|
||||
function closeRpcClient() {
|
||||
if (rpcClient && typeof rpcClient.close === "function") {
|
||||
rpcClient.close();
|
||||
}
|
||||
rpcClient = null;
|
||||
rpcStartedAt = null;
|
||||
rpcToolNames = null;
|
||||
@@ -415,12 +439,15 @@ export function createCodexStdioSessionManager(options = {}) {
|
||||
}
|
||||
const missingTools = CODEX_STDIO_REQUIRED_TOOLS.filter((name) => !rpcToolNames?.includes(name));
|
||||
if (missingTools.length > 0) {
|
||||
const observedTools = Array.isArray(rpcToolNames) ? [...rpcToolNames] : [];
|
||||
closeRpcClient();
|
||||
throw codexStdioError("codex_stdio_protocol_blocked", `Codex stdio server did not expose required tools: ${missingTools.join(", ")}.`, {
|
||||
availability: describe({
|
||||
env,
|
||||
workspace: availability.workspace,
|
||||
sandbox: availability.sandbox,
|
||||
command: availability.command
|
||||
command: availability.command,
|
||||
toolsObserved: observedTools
|
||||
}),
|
||||
missingTools
|
||||
});
|
||||
@@ -982,6 +1009,14 @@ function resolveCodexSandbox(env = process.env, options = {}) {
|
||||
return ["read-only", "workspace-write"].includes(value) ? value : CODEX_STDIO_SANDBOX;
|
||||
}
|
||||
|
||||
function resolveCodexHome(env = process.env) {
|
||||
return path.resolve(firstNonEmpty(
|
||||
env.CODEX_HOME,
|
||||
env.HWLAB_CODE_AGENT_CODEX_HOME,
|
||||
path.join(env.HOME || process.env.HOME || os.homedir(), ".codex")
|
||||
));
|
||||
}
|
||||
|
||||
function codexStdioEnabled(env, params, options) {
|
||||
if (params.enabled === true || options.enabled === true) return true;
|
||||
return env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED === "1" ||
|
||||
@@ -1008,7 +1043,6 @@ function supervisorState(env, params, options, enabled) {
|
||||
function tokenBoundaryState(env = process.env) {
|
||||
const present = hasEnvValue(env, "OPENAI_API_KEY") ||
|
||||
hasEnvValue(env, "CODEX_API_KEY") ||
|
||||
hasEnvValue(env, "CODEX_HOME") ||
|
||||
env.HWLAB_CODE_AGENT_CODEX_TOKEN_BOUNDARY === "configured" ||
|
||||
env.HWLAB_CODE_AGENT_CODEX_TOKEN_BOUNDARY === "present";
|
||||
return {
|
||||
@@ -1016,7 +1050,6 @@ function tokenBoundaryState(env = process.env) {
|
||||
sources: [
|
||||
hasEnvValue(env, "OPENAI_API_KEY") ? "OPENAI_API_KEY" : null,
|
||||
hasEnvValue(env, "CODEX_API_KEY") ? "CODEX_API_KEY" : null,
|
||||
hasEnvValue(env, "CODEX_HOME") ? "CODEX_HOME" : null,
|
||||
env.HWLAB_CODE_AGENT_CODEX_TOKEN_BOUNDARY ? "HWLAB_CODE_AGENT_CODEX_TOKEN_BOUNDARY" : null
|
||||
].filter(Boolean),
|
||||
secretMaterialRead: false,
|
||||
@@ -1053,6 +1086,25 @@ function workspaceStateSync(workspace, sandbox) {
|
||||
}
|
||||
}
|
||||
|
||||
function directoryStateSync(targetPath, { writableRequired = false } = {}) {
|
||||
const state = {
|
||||
path: targetPath,
|
||||
exists: false,
|
||||
readable: false,
|
||||
writable: false,
|
||||
writeRequired: writableRequired
|
||||
};
|
||||
try {
|
||||
state.exists = existsSync(targetPath);
|
||||
if (!state.exists) return state;
|
||||
state.readable = accessSyncBoolean(targetPath, fsConstants.R_OK);
|
||||
state.writable = accessSyncBoolean(targetPath, fsConstants.W_OK);
|
||||
return state;
|
||||
} catch {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function accessSyncBoolean(target, mode) {
|
||||
try {
|
||||
accessSync(target, mode);
|
||||
@@ -1218,6 +1270,8 @@ function runtimeContract({
|
||||
workspaceInfo,
|
||||
workspace,
|
||||
sandbox,
|
||||
codexHome,
|
||||
codexHomeInfo,
|
||||
tokenBoundary,
|
||||
egress
|
||||
}) {
|
||||
@@ -1253,6 +1307,7 @@ function runtimeContract({
|
||||
idleTimeoutMs: lifecycle.idleTimeoutMs
|
||||
},
|
||||
workspaceMount: workspaceContractState(workspaceInfo, sandbox, workspace),
|
||||
codexHome: codexHomeContractState(codexHomeInfo, codexHome),
|
||||
sandbox,
|
||||
tokenBoundary: {
|
||||
status: tokenBoundary.present ? "present" : "blocked",
|
||||
@@ -1277,11 +1332,26 @@ function runtimeContract({
|
||||
};
|
||||
}
|
||||
|
||||
function codexHomeContractState(codexHomeInfo, codexHome) {
|
||||
const ready = codexHomeInfo.exists && codexHomeInfo.readable && codexHomeInfo.writable;
|
||||
return {
|
||||
path: codexHome,
|
||||
status: ready ? "ready" : "blocked",
|
||||
exists: codexHomeInfo.exists,
|
||||
readable: codexHomeInfo.readable,
|
||||
writable: codexHomeInfo.writable,
|
||||
writeRequired: true,
|
||||
blocker: ready ? null : !codexHomeInfo.exists || !codexHomeInfo.readable ? "codex_home_missing" : "codex_home_write_blocked",
|
||||
secretMaterialRead: false,
|
||||
valuesRedacted: true
|
||||
};
|
||||
}
|
||||
|
||||
function childProcessEnv(env = process.env) {
|
||||
return {
|
||||
PATH: Object.hasOwn(env, "PATH") ? env.PATH : process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
|
||||
HOME: env.HOME || process.env.HOME || os.homedir(),
|
||||
...(env.CODEX_HOME ? { CODEX_HOME: env.CODEX_HOME } : {}),
|
||||
CODEX_HOME: resolveCodexHome(env),
|
||||
...(env.OPENAI_API_KEY ? { OPENAI_API_KEY: env.OPENAI_API_KEY } : {}),
|
||||
...(env.CODEX_API_KEY ? { CODEX_API_KEY: env.CODEX_API_KEY } : {}),
|
||||
...(env.HWLAB_CODE_AGENT_OPENAI_BASE_URL ? { OPENAI_BASE_URL: env.HWLAB_CODE_AGENT_OPENAI_BASE_URL } : {}),
|
||||
|
||||
@@ -38,7 +38,8 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => {
|
||||
env: {
|
||||
PATH: "",
|
||||
HWLAB_CODE_AGENT_PROVIDER: "codex-cli",
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test"
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
||||
HWLAB_CODE_AGENT_CODEX_COMMAND: "/tmp/hwlab-missing-codex"
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
@@ -942,7 +943,8 @@ test("cloud api /v1 describes Code Agent provider blocker without leaking secret
|
||||
env: {
|
||||
PATH: "",
|
||||
HWLAB_CODE_AGENT_PROVIDER: "openai",
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test"
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
||||
HWLAB_CODE_AGENT_CODEX_COMMAND: "/tmp/hwlab-missing-codex"
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
@@ -2056,7 +2058,8 @@ test("cloud api /v1/agent/chat reports provider gaps without faking a reply", as
|
||||
env: {
|
||||
PATH: "",
|
||||
HWLAB_CODE_AGENT_PROVIDER: "codex-cli",
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test"
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
||||
HWLAB_CODE_AGENT_CODEX_COMMAND: "/tmp/hwlab-missing-codex"
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
@@ -2088,10 +2091,10 @@ test("cloud api /v1/agent/chat reports provider gaps without faking a reply", as
|
||||
assert.match(payload.error.userMessage, /Codex CLI binary/u);
|
||||
assert.match(payload.error.message, /Codex CLI command is not available/);
|
||||
assert.match(payload.error.nextEvidence, /HWLAB_CODE_AGENT_CODEX_COMMAND|PATH/u);
|
||||
assert.deepEqual(payload.error.missingCommands, ["codex"]);
|
||||
assert.deepEqual(payload.error.missingCommands, ["/tmp/hwlab-missing-codex"]);
|
||||
assert.ok(payload.error.missingEnv.includes("OPENAI_API_KEY"));
|
||||
assert.ok(payload.error.missingConfig.includes("OPENAI_API_KEY"));
|
||||
assert.ok(payload.error.missingConfig.includes("command:codex"));
|
||||
assert.ok(payload.error.missingConfig.includes("command:/tmp/hwlab-missing-codex"));
|
||||
assert.equal(payload.availability.status, "partial");
|
||||
assert.match(payload.availability.blocker, /Codex CLI command/u);
|
||||
assert.equal(payload.availability.reason, "codex_cli_binary_missing");
|
||||
|
||||
@@ -97,7 +97,8 @@ async function runLocalContractSmoke() {
|
||||
env: {
|
||||
PATH: "",
|
||||
HWLAB_CODE_AGENT_PROVIDER: "codex-cli",
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test"
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
||||
HWLAB_CODE_AGENT_CODEX_COMMAND: "/tmp/hwlab-missing-codex"
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -117,7 +118,7 @@ async function runLocalContractSmoke() {
|
||||
assert.equal(failed.error.blocker.code, "codex_cli_binary_missing");
|
||||
assert.match(failed.error.nextEvidence, /HWLAB_CODE_AGENT_CODEX_COMMAND|PATH/u);
|
||||
assert.match(failed.error.message, /Codex CLI command is not available/);
|
||||
assert.deepEqual(failed.error.missingCommands, ["codex"]);
|
||||
assert.deepEqual(failed.error.missingCommands, ["/tmp/hwlab-missing-codex"]);
|
||||
assert.ok(failed.error.missingEnv.includes("OPENAI_API_KEY"));
|
||||
assert.equal(failed.availability.status, "partial");
|
||||
assert.match(failed.availability.blocker, /Codex CLI command/u);
|
||||
|
||||
@@ -303,6 +303,7 @@ function runtimeScriptBase64() {
|
||||
import { createServer } from "node:http";
|
||||
import http from "node:http";
|
||||
import { spawn } from "node:child_process";
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, symlinkSync } from "node:fs";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -323,6 +324,39 @@ const readOnlyRpcMethods = new Set([
|
||||
"evidence.record.query"
|
||||
]);
|
||||
|
||||
function ensureCodeAgentRuntimeBase() {
|
||||
if (serviceId !== "hwlab-cloud-api") return;
|
||||
const workspace = process.env.HWLAB_CODE_AGENT_CODEX_WORKSPACE || process.env.HWLAB_CODE_AGENT_WORKSPACE || "/workspace/hwlab";
|
||||
const codexHome = process.env.CODEX_HOME || process.env.HWLAB_CODE_AGENT_CODEX_HOME || "/codex-home";
|
||||
process.env.HWLAB_CODE_AGENT_WORKSPACE = process.env.HWLAB_CODE_AGENT_WORKSPACE || workspace;
|
||||
process.env.HWLAB_CODE_AGENT_CODEX_WORKSPACE = process.env.HWLAB_CODE_AGENT_CODEX_WORKSPACE || workspace;
|
||||
process.env.HWLAB_CODE_AGENT_CODEX_SANDBOX = process.env.HWLAB_CODE_AGENT_CODEX_SANDBOX || "workspace-write";
|
||||
process.env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED = process.env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED || "1";
|
||||
process.env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR = process.env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR || "repo-owned";
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
process.env.HWLAB_CODE_AGENT_CODEX_COMMAND = process.env.HWLAB_CODE_AGENT_CODEX_COMMAND || "/app/node_modules/.bin/codex";
|
||||
|
||||
mkdirSync(path.dirname(workspace), { recursive: true });
|
||||
if (!existsSync(workspace)) {
|
||||
if (workspace === "/workspace/hwlab" && existsSync("/app")) {
|
||||
symlinkSync("/app", workspace, "dir");
|
||||
} else {
|
||||
mkdirSync(workspace, { recursive: true });
|
||||
}
|
||||
}
|
||||
mkdirSync(codexHome, { recursive: true });
|
||||
for (const target of [workspace, codexHome]) {
|
||||
try {
|
||||
const info = lstatSync(target);
|
||||
chmodSync(info.isSymbolicLink() ? path.resolve(target) : target, 0o777);
|
||||
} catch {
|
||||
// Runtime readiness reports the concrete blocker without exposing secret values.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ensureCodeAgentRuntimeBase();
|
||||
|
||||
function sendJson(response, statusCode, body) {
|
||||
const payload = JSON.stringify(body, null, 2);
|
||||
response.writeHead(statusCode, {
|
||||
@@ -565,6 +599,14 @@ function dockerfile(baseImage, port) {
|
||||
"ARG HWLAB_COMMIT_ID",
|
||||
"ARG PORT",
|
||||
"ARG HWLAB_PORT",
|
||||
"ENV CODEX_HOME=/codex-home",
|
||||
"ENV HWLAB_CODE_AGENT_CODEX_HOME=/codex-home",
|
||||
"ENV HWLAB_CODE_AGENT_WORKSPACE=/workspace/hwlab",
|
||||
"ENV HWLAB_CODE_AGENT_CODEX_WORKSPACE=/workspace/hwlab",
|
||||
"ENV HWLAB_CODE_AGENT_CODEX_SANDBOX=workspace-write",
|
||||
"ENV HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED=1",
|
||||
"ENV HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR=repo-owned",
|
||||
"ENV HWLAB_CODE_AGENT_CODEX_COMMAND=/app/node_modules/.bin/codex",
|
||||
"ENV HWLAB_ENVIRONMENT=$HWLAB_ENVIRONMENT",
|
||||
"ENV HWLAB_SERVICE_ID=$HWLAB_SERVICE_ID",
|
||||
"ENV HWLAB_ARTIFACT_KIND=$HWLAB_ARTIFACT_KIND",
|
||||
@@ -581,6 +623,7 @@ function dockerfile(baseImage, port) {
|
||||
"COPY web ./web",
|
||||
"COPY tools ./tools",
|
||||
"COPY skills ./skills",
|
||||
"RUN mkdir -p /workspace /codex-home && rm -rf /workspace/hwlab && ln -s /app /workspace/hwlab && chmod -R a+rwX /app /workspace /codex-home && test -x /app/node_modules/.bin/codex && /app/node_modules/.bin/codex --version >/tmp/hwlab-codex-version.txt",
|
||||
`RUN node -e "const fs=require('node:fs'); fs.writeFileSync('/usr/local/bin/hwlab-dev-artifact-runtime.mjs', Buffer.from('${runtimeScriptBase64()}', 'base64')); fs.chmodSync('/usr/local/bin/hwlab-dev-artifact-runtime.mjs', 0o755);"`,
|
||||
`EXPOSE ${port}`,
|
||||
"CMD [\"node\", \"/usr/local/bin/hwlab-dev-artifact-runtime.mjs\"]",
|
||||
|
||||
@@ -191,6 +191,7 @@ async function smokeCloudApi() {
|
||||
HWLAB_CLOUD_DB_SSL_MODE: "",
|
||||
HWLAB_CODE_AGENT_PROVIDER: "codex-cli",
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-m1-local",
|
||||
HWLAB_CODE_AGENT_CODEX_COMMAND: "/tmp/hwlab-missing-codex",
|
||||
OPENAI_API_KEY: "",
|
||||
PATH: ""
|
||||
});
|
||||
@@ -282,7 +283,7 @@ async function smokeCloudApi() {
|
||||
assert.equal(agentChat.body.backend, "hwlab-cloud-api/codex-cli");
|
||||
assert.equal(agentChat.body.error.code, "codex_cli_binary_missing");
|
||||
assert.match(agentChat.body.error.message, /Codex CLI command is not available/);
|
||||
assert.deepEqual(agentChat.body.error.missingCommands, ["codex"]);
|
||||
assert.deepEqual(agentChat.body.error.missingCommands, ["/tmp/hwlab-missing-codex"]);
|
||||
assert.ok(agentChat.body.error.missingEnv.includes("OPENAI_API_KEY"));
|
||||
assert.equal(agentChat.body.availability.status, "partial");
|
||||
assert.match(agentChat.body.availability.blocker, /Codex stdio|long-lived|Codex CLI command/u);
|
||||
|
||||
@@ -390,8 +390,11 @@ function validateCloudApiCodeAgentSource(ctx, env) {
|
||||
);
|
||||
expectEqual(ctx, env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED, "1", "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED", "cloud API Codex stdio adapter enabled flag");
|
||||
expectEqual(ctx, env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR, "repo-owned", "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR", "cloud API Codex stdio supervisor mode");
|
||||
expectEqual(ctx, env.HWLAB_CODE_AGENT_WORKSPACE, "/workspace/hwlab", "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_WORKSPACE", "cloud API Code Agent workspace contract");
|
||||
expectEqual(ctx, env.HWLAB_CODE_AGENT_CODEX_WORKSPACE, "/workspace/hwlab", "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_WORKSPACE", "cloud API Codex stdio workspace mount contract");
|
||||
expectEqual(ctx, env.HWLAB_CODE_AGENT_CODEX_SANDBOX, "workspace-write", "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_SANDBOX", "cloud API Codex stdio sandbox contract");
|
||||
expectEqual(ctx, env.HWLAB_CODE_AGENT_CODEX_COMMAND, "/app/node_modules/.bin/codex", "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_COMMAND", "cloud API Codex command contract");
|
||||
expectEqual(ctx, env.CODEX_HOME, "/codex-home", "$.services.hwlab-cloud-api.env.CODEX_HOME", "cloud API CODEX_HOME contract");
|
||||
expect(
|
||||
ctx,
|
||||
env.HWLAB_CODE_AGENT_OPENAI_BASE_URL !== contract.egress.forbiddenDirectBaseUrl,
|
||||
@@ -556,8 +559,11 @@ function validateCloudApiCodeAgentArtifacts(ctx, workloads) {
|
||||
);
|
||||
expectEqual(ctx, env.get("HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED")?.value, "1", "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED", "cloud API workload Codex stdio adapter enabled flag");
|
||||
expectEqual(ctx, env.get("HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR")?.value, "repo-owned", "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR", "cloud API workload Codex stdio supervisor mode");
|
||||
expectEqual(ctx, env.get("HWLAB_CODE_AGENT_WORKSPACE")?.value, "/workspace/hwlab", "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_WORKSPACE", "cloud API workload Code Agent workspace contract");
|
||||
expectEqual(ctx, env.get("HWLAB_CODE_AGENT_CODEX_WORKSPACE")?.value, "/workspace/hwlab", "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_WORKSPACE", "cloud API workload Codex stdio workspace mount contract");
|
||||
expectEqual(ctx, env.get("HWLAB_CODE_AGENT_CODEX_SANDBOX")?.value, "workspace-write", "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_SANDBOX", "cloud API workload Codex stdio sandbox contract");
|
||||
expectEqual(ctx, env.get("HWLAB_CODE_AGENT_CODEX_COMMAND")?.value, "/app/node_modules/.bin/codex", "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_COMMAND", "cloud API workload Codex command contract");
|
||||
expectEqual(ctx, env.get("CODEX_HOME")?.value, "/codex-home", "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.CODEX_HOME", "cloud API workload CODEX_HOME contract");
|
||||
expect(
|
||||
ctx,
|
||||
env.get("HWLAB_CODE_AGENT_OPENAI_BASE_URL")?.value !== contract.egress.forbiddenDirectBaseUrl,
|
||||
|
||||
@@ -214,8 +214,11 @@ assert.equal(
|
||||
);
|
||||
assert.equal(cloudApi.env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED, "1", "cloud-api Codex stdio adapter enabled flag");
|
||||
assert.equal(cloudApi.env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR, "repo-owned", "cloud-api Codex stdio supervisor mode");
|
||||
assert.equal(cloudApi.env.HWLAB_CODE_AGENT_WORKSPACE, "/workspace/hwlab", "cloud-api Code Agent workspace contract");
|
||||
assert.equal(cloudApi.env.HWLAB_CODE_AGENT_CODEX_WORKSPACE, "/workspace/hwlab", "cloud-api Codex stdio workspace mount contract");
|
||||
assert.equal(cloudApi.env.HWLAB_CODE_AGENT_CODEX_SANDBOX, "workspace-write", "cloud-api Codex stdio sandbox contract");
|
||||
assert.equal(cloudApi.env.HWLAB_CODE_AGENT_CODEX_COMMAND, "/app/node_modules/.bin/codex", "cloud-api Codex command contract");
|
||||
assert.equal(cloudApi.env.CODEX_HOME, "/codex-home", "cloud-api CODEX_HOME contract");
|
||||
assert.notEqual(
|
||||
cloudApi.env.HWLAB_CODE_AGENT_OPENAI_BASE_URL,
|
||||
DEV_CODE_AGENT_PROVIDER_CONTRACT.egress.forbiddenDirectBaseUrl,
|
||||
@@ -276,8 +279,11 @@ function assertCodeAgentProviderWorkloadContract(env) {
|
||||
);
|
||||
assert.equal(env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED?.value, "1", "cloud-api workload Codex stdio adapter enabled flag");
|
||||
assert.equal(env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR?.value, "repo-owned", "cloud-api workload Codex stdio supervisor mode");
|
||||
assert.equal(env.HWLAB_CODE_AGENT_WORKSPACE?.value, "/workspace/hwlab", "cloud-api workload Code Agent workspace contract");
|
||||
assert.equal(env.HWLAB_CODE_AGENT_CODEX_WORKSPACE?.value, "/workspace/hwlab", "cloud-api workload Codex stdio workspace mount contract");
|
||||
assert.equal(env.HWLAB_CODE_AGENT_CODEX_SANDBOX?.value, "workspace-write", "cloud-api workload Codex stdio sandbox contract");
|
||||
assert.equal(env.HWLAB_CODE_AGENT_CODEX_COMMAND?.value, "/app/node_modules/.bin/codex", "cloud-api workload Codex command contract");
|
||||
assert.equal(env.CODEX_HOME?.value, "/codex-home", "cloud-api workload CODEX_HOME contract");
|
||||
assert.notEqual(
|
||||
env.HWLAB_CODE_AGENT_OPENAI_BASE_URL?.value,
|
||||
contract.egress.forbiddenDirectBaseUrl,
|
||||
|
||||
@@ -2948,6 +2948,8 @@ function codeAgentBlockerChineseLabel(code) {
|
||||
openai_responses_fallback_not_session: "文本 fallback 不是长会话",
|
||||
one_shot_runner_not_long_lived: "一次性执行不是可复用 session",
|
||||
provider_token_boundary: "token 边界未配置",
|
||||
codex_home_missing: "CODEX_HOME 不存在或不可读",
|
||||
codex_home_write_blocked: "CODEX_HOME 不可写",
|
||||
workspace_mount_missing: "工作区挂载不可读",
|
||||
workspace_write_boundary_blocked: "workspace-write 沙箱不可写",
|
||||
codex_stdio_egress_boundary: "DEV egress 边界不合规"
|
||||
|
||||
Reference in New Issue
Block a user