2448 lines
85 KiB
JavaScript
2448 lines
85 KiB
JavaScript
import { spawn, spawnSync } from "node:child_process";
|
||
import { randomUUID } from "node:crypto";
|
||
import { accessSync, constants as fsConstants, existsSync, realpathSync } from "node:fs";
|
||
import { mkdir, readdir, readFile, rm, rmdir, stat, writeFile } from "node:fs/promises";
|
||
import os from "node:os";
|
||
import path from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
|
||
export const CODEX_STDIO_PROVIDER = "codex-stdio";
|
||
export const CODEX_STDIO_BACKEND = "hwlab-cloud-api/codex-mcp-stdio";
|
||
export const CODEX_STDIO_RUNNER_KIND = "codex-mcp-stdio-runner";
|
||
export const CODEX_STDIO_SESSION_MODE = "codex-mcp-stdio-long-lived";
|
||
export const CODEX_STDIO_IMPLEMENTATION_TYPE = "repo-owned-codex-mcp-stdio-session";
|
||
export const CODEX_STDIO_CAPABILITY_LEVEL = "long-lived-codex-stdio-session";
|
||
export const CODEX_STDIO_SANDBOX = "workspace-write";
|
||
export const DEFAULT_CODEX_STDIO_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
||
export const DEFAULT_CODEX_STDIO_MAX_SESSIONS = 64;
|
||
export const DEFAULT_CODEX_STDIO_COMMAND = "codex";
|
||
export const DEFAULT_CODEX_STDIO_PROBE_TTL_MS = 30 * 1000;
|
||
|
||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||
const MCP_PROTOCOL_VERSION = "2024-11-05";
|
||
const CODEX_STDIO_REQUIRED_TOOLS = Object.freeze(["codex", "codex-reply"]);
|
||
const CODEX_STDIO_TOOL_OUTPUT_LIMIT = 4000;
|
||
const CODEX_STDIO_SKILL_LIMIT = 40;
|
||
const CODEX_STDIO_COMMAND_PROBE_TIMEOUT_MS = 3000;
|
||
const CODEX_STDIO_COMMAND_PROBE_TRACE_ID = "trc_codex_stdio_command_probe";
|
||
const CODEX_STDIO_COMMAND_PROBE_CONVERSATION_ID = "cnv_codex_stdio_command_probe";
|
||
const CODEX_STDIO_BOUNDARY_INSTRUCTIONS = [
|
||
"You are the HWLAB Cloud Workbench Code Agent.",
|
||
"Use the provided workspace and repo-owned Codex stdio session only.",
|
||
"Do not read or print secrets, tokens, kubeconfig files, DB URLs, private keys, or raw environment values.",
|
||
"Do not call gateway, box-simu, or patch-panel directly.",
|
||
"Hardware control requests must go through cloud-api/HWLAB API/skill CLI controlled paths.",
|
||
"Do not claim M3, M4, or M5 acceptance unless the response includes live HWLAB evidence."
|
||
].join("\n");
|
||
|
||
export function createCodexStdioSessionManager(options = {}) {
|
||
const idleTimeoutMs = positiveInteger(options.idleTimeoutMs, DEFAULT_CODEX_STDIO_IDLE_TIMEOUT_MS);
|
||
const maxSessions = positiveInteger(options.maxSessions, DEFAULT_CODEX_STDIO_MAX_SESSIONS);
|
||
const probeCacheTtlMs = positiveInteger(options.probeCacheTtlMs, DEFAULT_CODEX_STDIO_PROBE_TTL_MS);
|
||
const idFactory = typeof options.idFactory === "function" ? options.idFactory : () => `ses_${randomUUID()}`;
|
||
const nowDefault = options.now;
|
||
const sessions = new Map();
|
||
const conversations = new Map();
|
||
let rpcClient = options.rpcClient ?? null;
|
||
let rpcStartedAt = null;
|
||
let rpcToolNames = null;
|
||
let protocolProbe = null;
|
||
let commandProbe = null;
|
||
|
||
function describe(params = {}) {
|
||
const env = params.env ?? options.env ?? process.env;
|
||
const workspace = resolveCodexWorkspace(env, params);
|
||
const command = resolveCodexCommand(env, params, options);
|
||
const sandbox = resolveCodexSandbox(env, params);
|
||
const enabled = codexStdioEnabled(env, params, options);
|
||
const supervisor = supervisorState(env, params, options, enabled);
|
||
const tokenBoundary = tokenBoundaryState(env);
|
||
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: params.toolsObserved ?? rpcToolNames ?? cachedProbeTools({ command, workspace, codexHome, probe: protocolProbe })
|
||
});
|
||
const lifecycle = lifecycleState({
|
||
supervisor,
|
||
idleTimeoutMs,
|
||
maxSessions,
|
||
activeSessions: sessions.size
|
||
});
|
||
const blockers = [];
|
||
|
||
if (!binaryOnPath) {
|
||
blockers.push({
|
||
code: "codex_cli_binary_missing",
|
||
sourceIssue: "pikasTech/HWLAB#275",
|
||
summary: `Codex CLI command ${command} is not present on the runtime PATH.`,
|
||
evidence: binary.nextEvidence
|
||
});
|
||
} else if (binary.executable !== true) {
|
||
blockers.push({
|
||
code: "codex_cli_not_executable",
|
||
sourceIssue: "pikasTech/HWLAB#377",
|
||
summary: `Codex CLI command ${command} is present but not executable or --version failed.`,
|
||
evidence: binary.nextEvidence
|
||
});
|
||
} else if (binary.nativeDependencyPresent !== true) {
|
||
blockers.push({
|
||
code: "codex_cli_native_dependency_missing",
|
||
sourceIssue: "pikasTech/HWLAB#377",
|
||
summary: `Codex CLI command ${command} is present, but the native @openai/codex executable dependency was not found.`,
|
||
evidence: binary.nativeDependencyEvidence
|
||
});
|
||
}
|
||
if (!enabled || supervisor.configured !== true) {
|
||
blockers.push({
|
||
code: "codex_stdio_supervisor_disabled",
|
||
sourceIssue: "pikasTech/HWLAB#275",
|
||
summary: "Repo-owned Codex stdio supervisor is not enabled for this runtime."
|
||
});
|
||
blockers.push({
|
||
code: "runner_lifecycle_missing",
|
||
sourceIssue: "pikasTech/HWLAB#275",
|
||
summary: "Repo-owned lifecycle supervisor is not configured, so create/reuse/cancel/reap/trace readiness cannot pass."
|
||
});
|
||
}
|
||
if (!protocol.wired) {
|
||
blockers.push({
|
||
code: "stdio_protocol_not_wired",
|
||
sourceIssue: "pikasTech/HWLAB#275",
|
||
summary: protocol.summary,
|
||
evidence: protocol.nextEvidence
|
||
});
|
||
}
|
||
const commandProbeState = commandProbeSummary({ command, workspace, codexHome, probe: commandProbe });
|
||
if (commandProbeState.status === "blocked") {
|
||
blockers.push({
|
||
code: "codex_stdio_command_probe_failed",
|
||
sourceIssue: "pikasTech/HWLAB#275",
|
||
summary: "Codex stdio command/session probe failed on the same controlled workspace tool path used by /v1/agent/chat.",
|
||
evidence: commandProbeState.error
|
||
});
|
||
}
|
||
if (!workspaceInfo.exists || !workspaceInfo.readable) {
|
||
blockers.push({
|
||
code: "workspace_mount_missing",
|
||
sourceIssue: "pikasTech/HWLAB#275",
|
||
summary: `Configured Codex workspace is not mounted/readable: ${workspace}.`
|
||
});
|
||
}
|
||
if (sandbox === "workspace-write" && workspaceInfo.writable !== true) {
|
||
blockers.push({
|
||
code: "workspace_write_boundary_blocked",
|
||
sourceIssue: "pikasTech/HWLAB#275",
|
||
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",
|
||
sourceIssue: "pikasTech/HWLAB#275",
|
||
summary: "A long-lived Codex stdio runner needs a repo-owned provider token boundary; this endpoint only reports token presence and never reads or prints token material."
|
||
});
|
||
}
|
||
if (egress.directPublicOpenAi) {
|
||
blockers.push({
|
||
code: "codex_stdio_egress_boundary",
|
||
sourceIssue: "pikasTech/HWLAB#275",
|
||
summary: "Codex stdio provider egress must use the DEV egress/proxy boundary, not direct public api.openai.com."
|
||
});
|
||
}
|
||
|
||
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,
|
||
startupReady,
|
||
canStartLongLivedCodexStdio: startupReady,
|
||
command,
|
||
binary,
|
||
binaryOnPath,
|
||
workspace,
|
||
workspaceState: workspaceInfo,
|
||
codexHome,
|
||
codexHomeState: codexHomeInfo,
|
||
sandbox,
|
||
enabled,
|
||
supervisor,
|
||
tokenBoundary,
|
||
egress,
|
||
protocol,
|
||
stdioProtocol: protocol,
|
||
protocolProbe: protocolProbeSummary({ command, workspace, codexHome, probe: protocolProbe }),
|
||
commandProbe: commandProbeState,
|
||
sessionLifecycle: lifecycle,
|
||
lifecycleSupervisor: lifecycle,
|
||
workspaceMount: workspaceContractState(workspaceInfo, sandbox, workspace),
|
||
cancelReapTraceReadiness: cancelReapTraceState(lifecycle),
|
||
runtimeContract: runtimeContract({
|
||
ready,
|
||
binary,
|
||
protocol,
|
||
lifecycle,
|
||
workspaceInfo,
|
||
workspace,
|
||
sandbox,
|
||
codexHome,
|
||
codexHomeInfo,
|
||
tokenBoundary,
|
||
egress,
|
||
commandProbe: commandProbeState
|
||
}),
|
||
blockers,
|
||
blockerCodes: blockers.map((blocker) => blocker.code),
|
||
safety: codexStdioSafety()
|
||
};
|
||
}
|
||
|
||
async function chat(params = {}) {
|
||
const env = params.env ?? options.env ?? process.env;
|
||
const now = params.now ?? nowDefault;
|
||
const timestamp = timestampFor(now);
|
||
const traceId = optionalId(params.traceId) ?? `trc_${randomUUID()}`;
|
||
const conversationId = requiredId(params.conversationId, "cnv");
|
||
const workspace = resolveCodexWorkspace(env, params);
|
||
const sandbox = resolveCodexSandbox(env, params);
|
||
let availability = describe({ ...params, env, workspace, sandbox });
|
||
const startupBlockers = availability.blockers.filter((blocker) => blocker.code !== "stdio_protocol_not_wired");
|
||
if (startupBlockers.length > 0) {
|
||
throw codexStdioError("codex_stdio_blocked", "Codex stdio session is blocked by runtime readiness gates.", {
|
||
availability: {
|
||
...availability,
|
||
blockers: startupBlockers,
|
||
blockerCodes: startupBlockers.map((blocker) => blocker.code)
|
||
},
|
||
blockers: startupBlockers
|
||
});
|
||
}
|
||
|
||
const acquire = await acquireSession({
|
||
conversationId,
|
||
requestedSessionId: params.sessionId,
|
||
traceId,
|
||
workspace,
|
||
sandbox,
|
||
now
|
||
});
|
||
if (!acquire.ok) {
|
||
throw codexStdioError(acquire.code, acquire.message, {
|
||
availability,
|
||
session: acquire.session,
|
||
blockers: [acquire.blocker]
|
||
});
|
||
}
|
||
|
||
let session = acquire.session;
|
||
const events = [
|
||
"stdio:acquire",
|
||
session.reused ? "session:reused" : "session:created",
|
||
session.threadId ? "codex-thread:reused" : "codex-thread:create",
|
||
`turn:${session.turn}`
|
||
];
|
||
const startedAt = timestamp;
|
||
|
||
try {
|
||
const client = await ensureRpcClient({ env, availability, timeoutMs: params.timeoutMs });
|
||
availability = describe({ ...params, env, workspace, sandbox });
|
||
events.push("stdio:ready");
|
||
const sidecar = await collectWorkspaceSidecarEvidence({
|
||
message: params.message,
|
||
workspace,
|
||
traceId,
|
||
env,
|
||
now
|
||
});
|
||
if (shouldReturnSidecarOnly(sidecar)) {
|
||
if (sidecar.skills.status === "blocked") {
|
||
throw codexStdioError("skills_unavailable", "No usable SKILL.md manifest was found for the Codex stdio runner.", {
|
||
availability,
|
||
session,
|
||
toolCalls: sidecar.toolCalls,
|
||
skills: sidecar.skills,
|
||
blockers: sidecar.skills.blockers,
|
||
route: null,
|
||
toolName: "skills.discover"
|
||
});
|
||
}
|
||
session = releaseSession(session.sessionId, {
|
||
now,
|
||
traceId,
|
||
conversationId,
|
||
reused: session.reused,
|
||
status: "idle"
|
||
}) ?? session;
|
||
events.push(...sidecar.events);
|
||
return codexStdioSidecarResult({
|
||
message: params.message,
|
||
workspace,
|
||
sandbox,
|
||
session,
|
||
sidecar,
|
||
availability,
|
||
traceId,
|
||
startedAt,
|
||
events,
|
||
model: params.model,
|
||
env,
|
||
providerTraceToolName: "workspace-sidecar"
|
||
});
|
||
}
|
||
const toolName = session.threadId ? "codex-reply" : "codex";
|
||
const toolArguments = session.threadId
|
||
? {
|
||
threadId: session.threadId,
|
||
prompt: buildCodexUserPrompt(params.message, { conversationId, traceId, conversationFacts: params.conversationFacts })
|
||
}
|
||
: {
|
||
prompt: buildCodexUserPrompt(params.message, { conversationId, traceId, conversationFacts: params.conversationFacts }),
|
||
cwd: workspace,
|
||
sandbox,
|
||
model: params.model,
|
||
"approval-policy": "never",
|
||
"developer-instructions": CODEX_STDIO_BOUNDARY_INSTRUCTIONS
|
||
};
|
||
const toolResult = await client.callTool(toolName, dropEmpty(toolArguments), effectiveTimeout(params.timeoutMs));
|
||
const codexOutput = extractCodexToolOutput(toolResult);
|
||
if (!codexOutput.content) {
|
||
throw codexStdioError("codex_stdio_empty_response", "Codex stdio tool call completed without assistant content.", {
|
||
availability,
|
||
session
|
||
});
|
||
}
|
||
session = releaseSession(session.sessionId, {
|
||
now,
|
||
traceId,
|
||
conversationId,
|
||
reused: session.reused,
|
||
threadId: codexOutput.threadId ?? session.threadId,
|
||
status: "idle"
|
||
}) ?? session;
|
||
events.push(`tool:${toolName}:completed`);
|
||
return {
|
||
provider: CODEX_STDIO_PROVIDER,
|
||
model: firstNonEmpty(params.model, env.HWLAB_CODE_AGENT_MODEL, env.OPENAI_MODEL, "codex-default"),
|
||
backend: CODEX_STDIO_BACKEND,
|
||
content: codexReplyWithSidecar(codexOutput.content, sidecar),
|
||
workspace,
|
||
sandbox,
|
||
session,
|
||
sessionMode: CODEX_STDIO_SESSION_MODE,
|
||
sessionReuse: sessionReuseEvidence(session),
|
||
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
|
||
runnerLimitations: ["hardware-control-via-cloud-api-only", "secret-values-redacted"],
|
||
codexStdioFeasibility: availability,
|
||
longLivedSessionGate: longLivedSessionGate({
|
||
provider: CODEX_STDIO_PROVIDER,
|
||
runnerKind: CODEX_STDIO_RUNNER_KIND,
|
||
session,
|
||
sessionMode: CODEX_STDIO_SESSION_MODE,
|
||
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
|
||
codexStdioFeasibility: availability
|
||
}),
|
||
toolCalls: [{
|
||
id: `tool_${randomUUID()}`,
|
||
type: "codex-stdio",
|
||
name: toolName,
|
||
status: "completed",
|
||
cwd: workspace,
|
||
command: "codex mcp-server tools/call",
|
||
exitCode: 0,
|
||
stdout: codexOutput.threadId ? `threadId=${codexOutput.threadId}` : "threadId=captured",
|
||
stderrSummary: "",
|
||
outputTruncated: false,
|
||
traceId
|
||
}, ...sidecar.toolCalls],
|
||
skills: sidecar.skills,
|
||
runner: runnerDescriptor({ workspace, sandbox, session }),
|
||
runnerTrace: runnerTrace({
|
||
traceId,
|
||
workspace,
|
||
sandbox,
|
||
session,
|
||
events: [...events, ...sidecar.events],
|
||
startedAt,
|
||
outputTruncated: sidecar.outputTruncated
|
||
}),
|
||
capabilityLevel: CODEX_STDIO_CAPABILITY_LEVEL,
|
||
providerTrace: {
|
||
transport: "stdio",
|
||
protocol: "mcp",
|
||
command: `${availability.command} mcp-server`,
|
||
toolName,
|
||
threadId: codexOutput.threadId ?? session.threadId ?? null,
|
||
valuesPrinted: false
|
||
}
|
||
};
|
||
} catch (error) {
|
||
closeRpcClient();
|
||
session = failSession(session.sessionId, {
|
||
now,
|
||
traceId,
|
||
conversationId,
|
||
reused: session.reused,
|
||
statusReason: error.code ?? "codex_stdio_failed"
|
||
}) ?? session;
|
||
if (error.code && (error.code.startsWith("codex_stdio") || ["skills_unavailable"].includes(error.code))) {
|
||
error.session = session;
|
||
error.availability = error.availability ?? describe({ ...params, env, workspace, sandbox });
|
||
error.runnerTrace = runnerTrace({
|
||
traceId,
|
||
workspace,
|
||
sandbox,
|
||
session,
|
||
events: [...events, `blocked:${error.code}`],
|
||
startedAt,
|
||
outputTruncated: false
|
||
});
|
||
throw error;
|
||
}
|
||
throw codexStdioError("codex_stdio_failed", redactText(error.message || "Codex stdio session failed."), {
|
||
availability,
|
||
session,
|
||
runnerTrace: runnerTrace({
|
||
traceId,
|
||
workspace,
|
||
sandbox,
|
||
session,
|
||
events: [...events, "blocked:codex_stdio_failed"],
|
||
startedAt,
|
||
outputTruncated: false
|
||
})
|
||
});
|
||
}
|
||
}
|
||
|
||
async function probe(params = {}) {
|
||
const env = params.env ?? options.env ?? process.env;
|
||
const workspace = resolveCodexWorkspace(env, params);
|
||
const command = resolveCodexCommand(env, params, options);
|
||
const sandbox = resolveCodexSandbox(env, params);
|
||
const codexHome = resolveCodexHome(env);
|
||
let availability = describe({ ...params, env, workspace, command, sandbox });
|
||
const startupBlockers = availability.blockers.filter((blocker) => blocker.code !== "stdio_protocol_not_wired");
|
||
if (startupBlockers.length > 0) return availability;
|
||
if (rpcClient && Array.isArray(rpcToolNames) && rpcToolNames.length > 0) {
|
||
availability = describe({ ...params, env, workspace, command, sandbox, toolsObserved: rpcToolNames });
|
||
return ensureCommandProbeReady({
|
||
env,
|
||
workspace,
|
||
command,
|
||
sandbox,
|
||
codexHome,
|
||
availability,
|
||
now: params.now ?? nowDefault,
|
||
probeTimeoutMs: params.commandProbeTimeoutMs
|
||
});
|
||
}
|
||
if (!params.forceProbe && cachedProbeTools({ command, workspace, codexHome, probe: protocolProbe })) {
|
||
availability = describe({ ...params, env, workspace, command, sandbox, toolsObserved: protocolProbe.toolsObserved });
|
||
return ensureCommandProbeReady({
|
||
env,
|
||
workspace,
|
||
command,
|
||
sandbox,
|
||
codexHome,
|
||
availability,
|
||
now: params.now ?? nowDefault,
|
||
probeTimeoutMs: params.commandProbeTimeoutMs
|
||
});
|
||
}
|
||
|
||
let probeClient = null;
|
||
const startedAt = timestampFor(params.now ?? nowDefault);
|
||
try {
|
||
probeClient = options.createProbeRpcClient
|
||
? await options.createProbeRpcClient({ env, availability })
|
||
: options.createRpcClient
|
||
? await options.createRpcClient({ env, availability, probe: true })
|
||
: createCodexMcpJsonLineClient({
|
||
command: availability.command,
|
||
env: childProcessEnv(env),
|
||
cwd: availability.workspace
|
||
});
|
||
const timeoutMs = positiveInteger(params.probeTimeoutMs, 10000);
|
||
const init = typeof probeClient.initialize === "function" ? await probeClient.initialize(timeoutMs) : null;
|
||
const toolsObserved = Array.isArray(init?.tools)
|
||
? init.tools
|
||
: typeof probeClient.listTools === "function"
|
||
? await probeClient.listTools(timeoutMs)
|
||
: [];
|
||
rpcToolNames = toolsObserved;
|
||
protocolProbe = {
|
||
status: "ready",
|
||
ready: true,
|
||
command,
|
||
workspace,
|
||
codexHome,
|
||
toolsObserved,
|
||
startedAt,
|
||
finishedAt: timestampFor(params.now ?? nowDefault),
|
||
expiresAtMs: Date.now() + probeCacheTtlMs,
|
||
secretMaterialRead: false,
|
||
valuesRedacted: true
|
||
};
|
||
availability = describe({ ...params, env, workspace, command, sandbox, toolsObserved });
|
||
return ensureCommandProbeReady({
|
||
env,
|
||
workspace,
|
||
command,
|
||
sandbox,
|
||
codexHome,
|
||
availability,
|
||
now: params.now ?? nowDefault,
|
||
probeTimeoutMs: params.commandProbeTimeoutMs
|
||
});
|
||
} catch (error) {
|
||
protocolProbe = {
|
||
status: "blocked",
|
||
ready: false,
|
||
command,
|
||
workspace,
|
||
codexHome,
|
||
toolsObserved: [],
|
||
startedAt,
|
||
finishedAt: timestampFor(params.now ?? nowDefault),
|
||
expiresAtMs: Date.now() + Math.min(probeCacheTtlMs, 5000),
|
||
error: redactText(error.message),
|
||
secretMaterialRead: false,
|
||
valuesRedacted: true
|
||
};
|
||
return describe({ ...params, env, workspace, command, sandbox, toolsObserved: [] });
|
||
} finally {
|
||
if (probeClient && typeof probeClient.close === "function") {
|
||
probeClient.close();
|
||
}
|
||
}
|
||
}
|
||
|
||
function cancel(sessionId, params = {}) {
|
||
const session = sessions.get(requiredId(sessionId, "ses")) ?? null;
|
||
if (!session) return null;
|
||
const timestamp = timestampFor(params.now ?? nowDefault);
|
||
session.status = "interrupted";
|
||
session.updatedAt = timestamp;
|
||
session.lastTraceId = optionalId(params.traceId) ?? session.lastTraceId;
|
||
session.currentTraceId = null;
|
||
session.statusReason = params.reason ?? "cancelled";
|
||
if (rpcClient && typeof rpcClient.close === "function") {
|
||
closeRpcClient();
|
||
}
|
||
return publicSession(session, { conversationId: params.conversationId, reused: true });
|
||
}
|
||
|
||
function reapIdle(params = {}) {
|
||
const timestamp = timestampFor(params.now ?? nowDefault);
|
||
const timestampMs = Date.parse(timestamp);
|
||
const reaped = [];
|
||
for (const session of sessions.values()) {
|
||
if (!sessionExpired(session, timestampMs)) continue;
|
||
session.status = "expired";
|
||
session.updatedAt = timestamp;
|
||
session.currentTraceId = null;
|
||
reaped.push(session.sessionId);
|
||
}
|
||
if (sessionsBusyCount() === 0 && reaped.length > 0) {
|
||
closeRpcClient();
|
||
}
|
||
return {
|
||
reapedCount: reaped.length,
|
||
sessionIds: reaped
|
||
};
|
||
}
|
||
|
||
function get(sessionId, params = {}) {
|
||
const session = sessions.get(requiredId(sessionId, "ses")) ?? null;
|
||
return session ? publicSession(session, params) : null;
|
||
}
|
||
|
||
function clear() {
|
||
sessions.clear();
|
||
conversations.clear();
|
||
closeRpcClient();
|
||
}
|
||
|
||
function closeRpcClient() {
|
||
if (rpcClient && typeof rpcClient.close === "function") {
|
||
rpcClient.close();
|
||
}
|
||
rpcClient = null;
|
||
rpcStartedAt = null;
|
||
rpcToolNames = null;
|
||
protocolProbe = null;
|
||
commandProbe = null;
|
||
}
|
||
|
||
async function ensureCommandProbeReady({
|
||
env,
|
||
workspace,
|
||
command,
|
||
sandbox,
|
||
codexHome,
|
||
availability,
|
||
now,
|
||
probeTimeoutMs
|
||
} = {}) {
|
||
if (cachedCommandProbe({ command, workspace, codexHome, probe: commandProbe })) {
|
||
return describe({ env, workspace, command, sandbox, toolsObserved: availability.protocol?.toolsObserved ?? rpcToolNames });
|
||
}
|
||
const startedAt = timestampFor(now);
|
||
try {
|
||
const sidecar = await collectWorkspaceSidecarEvidence({
|
||
message: "用pwd列出你当前的工作目录",
|
||
workspace,
|
||
traceId: CODEX_STDIO_COMMAND_PROBE_TRACE_ID,
|
||
env,
|
||
now
|
||
});
|
||
const pwdCall = sidecar.toolCalls.find((toolCall) => toolCall.name === "pwd");
|
||
if (pwdCall?.status === "blocked") {
|
||
throw new Error(pwdCall.stderrSummary || "controlled pwd probe was blocked");
|
||
}
|
||
if (!pwdCall || pwdCall.status !== "completed" || String(pwdCall.stdout ?? "").trim() !== workspace) {
|
||
throw new Error("controlled pwd probe did not return the configured workspace");
|
||
}
|
||
commandProbe = {
|
||
status: "ready",
|
||
ready: true,
|
||
command,
|
||
workspace,
|
||
codexHome,
|
||
probe: "workspace.pwd",
|
||
conversationId: CODEX_STDIO_COMMAND_PROBE_CONVERSATION_ID,
|
||
traceId: CODEX_STDIO_COMMAND_PROBE_TRACE_ID,
|
||
toolCalls: [{
|
||
name: pwdCall.name,
|
||
status: pwdCall.status,
|
||
cwd: pwdCall.cwd,
|
||
command: pwdCall.command,
|
||
exitCode: pwdCall.exitCode,
|
||
stdoutSummary: "workspace path matched",
|
||
outputTruncated: pwdCall.outputTruncated === true
|
||
}],
|
||
startedAt,
|
||
finishedAt: timestampFor(now),
|
||
expiresAtMs: Date.now() + probeCacheTtlMs,
|
||
timeoutMs: positiveInteger(probeTimeoutMs, CODEX_STDIO_COMMAND_PROBE_TIMEOUT_MS),
|
||
secretMaterialRead: false,
|
||
valuesRedacted: true
|
||
};
|
||
} catch (error) {
|
||
commandProbe = {
|
||
status: "blocked",
|
||
ready: false,
|
||
command,
|
||
workspace,
|
||
codexHome,
|
||
probe: "workspace.pwd",
|
||
conversationId: CODEX_STDIO_COMMAND_PROBE_CONVERSATION_ID,
|
||
traceId: CODEX_STDIO_COMMAND_PROBE_TRACE_ID,
|
||
toolCalls: [],
|
||
startedAt,
|
||
finishedAt: timestampFor(now),
|
||
expiresAtMs: Date.now() + Math.min(probeCacheTtlMs, 5000),
|
||
timeoutMs: positiveInteger(probeTimeoutMs, CODEX_STDIO_COMMAND_PROBE_TIMEOUT_MS),
|
||
error: redactText(error.message),
|
||
secretMaterialRead: false,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
return describe({ env, workspace, command, sandbox, toolsObserved: availability.protocol?.toolsObserved ?? rpcToolNames });
|
||
}
|
||
|
||
async function ensureRpcClient({ env, availability, timeoutMs } = {}) {
|
||
if (rpcClient && typeof rpcClient.callTool === "function") return rpcClient;
|
||
rpcClient = options.createRpcClient
|
||
? await options.createRpcClient({ env, availability })
|
||
: createCodexMcpJsonLineClient({
|
||
command: availability.command,
|
||
env: childProcessEnv(env),
|
||
cwd: availability.workspace
|
||
});
|
||
if (typeof rpcClient.initialize === "function") {
|
||
const init = await rpcClient.initialize(effectiveTimeout(timeoutMs));
|
||
rpcToolNames = Array.isArray(init?.tools) ? init.tools : rpcToolNames;
|
||
}
|
||
if (!rpcToolNames && typeof rpcClient.listTools === "function") {
|
||
rpcToolNames = await rpcClient.listTools(effectiveTimeout(timeoutMs));
|
||
}
|
||
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,
|
||
toolsObserved: observedTools
|
||
}),
|
||
missingTools
|
||
});
|
||
}
|
||
rpcStartedAt = timestampFor(nowDefault);
|
||
return rpcClient;
|
||
}
|
||
|
||
async function acquireSession(params = {}) {
|
||
const timestamp = timestampFor(params.now ?? nowDefault);
|
||
const timestampMs = Date.parse(timestamp);
|
||
const conversationId = requiredId(params.conversationId, "cnv");
|
||
const requestedSessionId = optionalId(params.requestedSessionId);
|
||
const mappedSessionId = conversations.get(conversationId) ?? null;
|
||
|
||
if (mappedSessionId && requestedSessionId && mappedSessionId !== requestedSessionId) {
|
||
const mappedSession = sessions.get(mappedSessionId) ?? null;
|
||
return blockedAcquire({
|
||
code: "session_reuse_conflict",
|
||
summary: `conversationId ${conversationId} is already bound to sessionId ${mappedSessionId}; requested ${requestedSessionId}.`,
|
||
session: mappedSession,
|
||
timestamp,
|
||
traceId: params.traceId
|
||
});
|
||
}
|
||
|
||
const effectiveSessionId = mappedSessionId || requestedSessionId || requiredId(idFactory(), "ses");
|
||
let session = sessions.get(effectiveSessionId) ?? null;
|
||
const reused = Boolean(session);
|
||
|
||
if (session && sessionExpired(session, timestampMs)) {
|
||
session.status = "expired";
|
||
session.updatedAt = timestamp;
|
||
session.currentTraceId = null;
|
||
session.lastTraceId = optionalId(params.traceId) ?? session.lastTraceId;
|
||
return blockedAcquire({
|
||
code: "session_expired",
|
||
summary: `Codex stdio session ${effectiveSessionId} expired after ${session.idleTimeoutMs}ms idle timeout.`,
|
||
session,
|
||
timestamp,
|
||
traceId: params.traceId
|
||
});
|
||
}
|
||
|
||
if (session?.status === "busy") {
|
||
return blockedAcquire({
|
||
code: "session_busy",
|
||
summary: `Codex stdio session ${effectiveSessionId} is already busy with traceId ${session.currentTraceId ?? "unknown"}.`,
|
||
session,
|
||
timestamp,
|
||
traceId: params.traceId
|
||
});
|
||
}
|
||
|
||
if (!session) {
|
||
session = {
|
||
sessionId: effectiveSessionId,
|
||
conversationIds: new Set(),
|
||
status: "creating",
|
||
workspace: params.workspace,
|
||
sandbox: params.sandbox,
|
||
runnerKind: CODEX_STDIO_RUNNER_KIND,
|
||
sessionMode: CODEX_STDIO_SESSION_MODE,
|
||
capabilityLevel: CODEX_STDIO_CAPABILITY_LEVEL,
|
||
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
|
||
createdAt: timestamp,
|
||
updatedAt: timestamp,
|
||
idleTimeoutMs,
|
||
expiresAt: plusMs(timestampMs, idleTimeoutMs),
|
||
lastTraceId: optionalId(params.traceId),
|
||
currentTraceId: null,
|
||
turn: 0,
|
||
threadId: null,
|
||
durable: true,
|
||
longLivedSession: true,
|
||
codexStdio: true,
|
||
writeCapable: true,
|
||
secretMaterialStored: false
|
||
};
|
||
sessions.set(effectiveSessionId, session);
|
||
}
|
||
|
||
session.conversationIds.add(conversationId);
|
||
session.workspace = params.workspace ?? session.workspace;
|
||
session.sandbox = params.sandbox ?? session.sandbox;
|
||
session.status = "busy";
|
||
session.updatedAt = timestamp;
|
||
session.expiresAt = plusMs(timestampMs, idleTimeoutMs);
|
||
session.lastTraceId = optionalId(params.traceId) ?? session.lastTraceId;
|
||
session.currentTraceId = optionalId(params.traceId);
|
||
session.turn += 1;
|
||
conversations.set(conversationId, effectiveSessionId);
|
||
pruneSessions();
|
||
|
||
return {
|
||
ok: true,
|
||
reused,
|
||
session: publicSession(session, { conversationId, reused })
|
||
};
|
||
}
|
||
|
||
function releaseSession(sessionId, params = {}) {
|
||
const session = sessions.get(requiredId(sessionId, "ses")) ?? null;
|
||
if (!session) return null;
|
||
const timestamp = timestampFor(params.now ?? nowDefault);
|
||
const timestampMs = Date.parse(timestamp);
|
||
session.status = params.status ?? "idle";
|
||
session.updatedAt = timestamp;
|
||
session.expiresAt = plusMs(timestampMs, idleTimeoutMs);
|
||
session.lastTraceId = optionalId(params.traceId) ?? session.lastTraceId;
|
||
session.currentTraceId = null;
|
||
session.threadId = optionalId(params.threadId) ?? session.threadId;
|
||
session.statusReason = params.statusReason ?? null;
|
||
return publicSession(session, {
|
||
conversationId: params.conversationId,
|
||
reused: params.reused
|
||
});
|
||
}
|
||
|
||
function failSession(sessionId, params = {}) {
|
||
return releaseSession(sessionId, { ...params, status: "failed" });
|
||
}
|
||
|
||
function pruneSessions() {
|
||
if (sessions.size <= maxSessions) return;
|
||
const sorted = [...sessions.entries()]
|
||
.sort((a, b) => String(a[1].updatedAt).localeCompare(String(b[1].updatedAt)));
|
||
for (const [sessionId, session] of sorted.slice(0, sessions.size - maxSessions)) {
|
||
sessions.delete(sessionId);
|
||
for (const conversationId of session.conversationIds) {
|
||
if (conversations.get(conversationId) === sessionId) conversations.delete(conversationId);
|
||
}
|
||
}
|
||
}
|
||
|
||
function sessionsBusyCount() {
|
||
return [...sessions.values()].filter((session) => session.status === "busy").length;
|
||
}
|
||
|
||
return {
|
||
describe,
|
||
probe,
|
||
chat,
|
||
cancel,
|
||
reapIdle,
|
||
get,
|
||
clear,
|
||
longLivedSessionGate,
|
||
get rpcStartedAt() {
|
||
return rpcStartedAt;
|
||
}
|
||
};
|
||
}
|
||
|
||
export function createCodexMcpJsonLineClient({ command = DEFAULT_CODEX_STDIO_COMMAND, env = process.env, cwd = repoRoot } = {}) {
|
||
const child = spawn(command, ["mcp-server"], {
|
||
cwd,
|
||
env,
|
||
stdio: ["pipe", "pipe", "pipe"]
|
||
});
|
||
let nextId = 1;
|
||
let stdoutBuffer = "";
|
||
let stderr = "";
|
||
const pending = new Map();
|
||
let initialized = false;
|
||
|
||
child.stdout.on("data", (chunk) => {
|
||
stdoutBuffer += chunk.toString("utf8");
|
||
let newlineIndex = stdoutBuffer.indexOf("\n");
|
||
while (newlineIndex >= 0) {
|
||
const line = stdoutBuffer.slice(0, newlineIndex).trim();
|
||
stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
|
||
if (line) handleLine(line);
|
||
newlineIndex = stdoutBuffer.indexOf("\n");
|
||
}
|
||
});
|
||
child.stderr.on("data", (chunk) => {
|
||
stderr = tailText(`${stderr}${chunk.toString("utf8")}`, 4000);
|
||
});
|
||
child.on("error", (error) => rejectAll(`Codex stdio process error: ${error.message}`));
|
||
child.on("close", (code, signal) => rejectAll(`Codex stdio process closed code=${code ?? "null"} signal=${signal ?? "null"}`));
|
||
|
||
function handleLine(line) {
|
||
let payload = null;
|
||
try {
|
||
payload = JSON.parse(line);
|
||
} catch {
|
||
return;
|
||
}
|
||
const request = pending.get(payload.id);
|
||
if (!request) return;
|
||
pending.delete(payload.id);
|
||
clearTimeout(request.timer);
|
||
if (payload.error) {
|
||
request.reject(new Error(redactText(payload.error.message ?? JSON.stringify(payload.error))));
|
||
} else {
|
||
request.resolve(payload.result);
|
||
}
|
||
}
|
||
|
||
function rejectAll(message) {
|
||
for (const [id, request] of pending.entries()) {
|
||
pending.delete(id);
|
||
clearTimeout(request.timer);
|
||
request.reject(new Error(redactText(`${message}; stderr=${tailText(stderr, 800)}`)));
|
||
}
|
||
}
|
||
|
||
function request(method, params = {}, timeoutMs = 30000) {
|
||
const id = nextId;
|
||
nextId += 1;
|
||
const message = JSON.stringify({
|
||
jsonrpc: "2.0",
|
||
id,
|
||
method,
|
||
params
|
||
});
|
||
return new Promise((resolve, reject) => {
|
||
const timer = setTimeout(() => {
|
||
pending.delete(id);
|
||
reject(new Error(`Codex stdio request ${method} timed out after ${timeoutMs}ms`));
|
||
}, timeoutMs);
|
||
pending.set(id, { resolve, reject, timer });
|
||
child.stdin.write(`${message}\n`, "utf8", (error) => {
|
||
if (!error) return;
|
||
pending.delete(id);
|
||
clearTimeout(timer);
|
||
reject(error);
|
||
});
|
||
});
|
||
}
|
||
|
||
async function initialize(timeoutMs = 30000) {
|
||
if (initialized) return { tools: await listTools(timeoutMs) };
|
||
await request("initialize", {
|
||
protocolVersion: MCP_PROTOCOL_VERSION,
|
||
capabilities: {},
|
||
clientInfo: {
|
||
name: "hwlab-cloud-api",
|
||
version: "0"
|
||
}
|
||
}, timeoutMs);
|
||
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized", params: {} })}\n`);
|
||
initialized = true;
|
||
return { tools: await listTools(timeoutMs) };
|
||
}
|
||
|
||
async function listTools(timeoutMs = 30000) {
|
||
const result = await request("tools/list", {}, timeoutMs);
|
||
return Array.isArray(result?.tools) ? result.tools.map((tool) => tool?.name).filter(Boolean) : [];
|
||
}
|
||
|
||
async function callTool(name, args = {}, timeoutMs = 150000) {
|
||
return request("tools/call", {
|
||
name,
|
||
arguments: args
|
||
}, timeoutMs);
|
||
}
|
||
|
||
function close() {
|
||
child.kill("SIGTERM");
|
||
}
|
||
|
||
return {
|
||
initialize,
|
||
listTools,
|
||
callTool,
|
||
close
|
||
};
|
||
}
|
||
|
||
export function longLivedSessionGate({
|
||
provider,
|
||
runnerKind,
|
||
session,
|
||
sessionMode,
|
||
implementationType,
|
||
codexStdioFeasibility
|
||
} = {}) {
|
||
const normalizedProvider = String(provider ?? "").trim();
|
||
const normalizedRunnerKind = String(runnerKind ?? "").trim();
|
||
const normalizedSessionMode = String(sessionMode ?? "").trim();
|
||
const normalizedImplementation = String(implementationType ?? "").trim();
|
||
const blockers = [];
|
||
if (normalizedProvider === "openai-responses" || normalizedRunnerKind === "openai-responses-fallback") {
|
||
blockers.push({
|
||
code: "openai_responses_fallback_not_session",
|
||
sourceIssue: "pikasTech/HWLAB#317",
|
||
summary: "OpenAI Responses fallback is text-chat-only and cannot pass the long-lived Codex session gate."
|
||
});
|
||
}
|
||
if (normalizedRunnerKind === "codex-cli-one-shot-ephemeral" || normalizedSessionMode === "ephemeral-one-shot") {
|
||
blockers.push({
|
||
code: "one_shot_runner_not_long_lived",
|
||
sourceIssue: "pikasTech/HWLAB#317",
|
||
summary: "codex exec --ephemeral / one-shot runner output is not a reusable long-lived runner session."
|
||
});
|
||
}
|
||
if (normalizedImplementation === "controlled-readonly-session-registry") {
|
||
blockers.push({
|
||
code: "codex_stdio_blocked_readonly_session_available",
|
||
sourceIssue: "pikasTech/HWLAB#317",
|
||
summary: "This response is backed by a reusable controlled read-only session registry, not Codex stdio or an equivalent full Code Agent protocol adapter."
|
||
});
|
||
}
|
||
if (session && !["idle", "ready", "busy"].includes(session.status)) {
|
||
blockers.push({
|
||
code: `session_${session.status || "inactive"}`,
|
||
sourceIssue: "pikasTech/HWLAB#317",
|
||
summary: `Long-lived Codex stdio session is not active: status=${session.status || "unknown"}.`
|
||
});
|
||
}
|
||
for (const blocker of codexStdioFeasibility?.blockers ?? []) {
|
||
if (!blocker?.code || blockers.some((item) => item.code === blocker.code)) continue;
|
||
blockers.push({
|
||
code: blocker.code,
|
||
sourceIssue: blocker.sourceIssue ?? "pikasTech/HWLAB#317",
|
||
summary: blocker.summary ?? `Codex stdio feasibility blocker: ${blocker.code}.`
|
||
});
|
||
}
|
||
|
||
const feasible = codexStdioFeasibility?.canStartLongLivedCodexStdio === true || codexStdioFeasibility?.ready === true;
|
||
const sessionPass =
|
||
normalizedProvider === CODEX_STDIO_PROVIDER &&
|
||
normalizedRunnerKind === CODEX_STDIO_RUNNER_KIND &&
|
||
normalizedSessionMode === CODEX_STDIO_SESSION_MODE &&
|
||
normalizedImplementation === CODEX_STDIO_IMPLEMENTATION_TYPE &&
|
||
session?.longLivedSession === true &&
|
||
session?.codexStdio === true &&
|
||
session?.writeCapable === true &&
|
||
session?.durable === true &&
|
||
["idle", "ready", "busy"].includes(session?.status) &&
|
||
feasible &&
|
||
blockers.length === 0;
|
||
const feasiblePass =
|
||
normalizedProvider === CODEX_STDIO_PROVIDER &&
|
||
normalizedRunnerKind === CODEX_STDIO_RUNNER_KIND &&
|
||
normalizedSessionMode === CODEX_STDIO_SESSION_MODE &&
|
||
normalizedImplementation === CODEX_STDIO_IMPLEMENTATION_TYPE &&
|
||
!session &&
|
||
feasible &&
|
||
blockers.length === 0;
|
||
const pass = sessionPass || feasiblePass;
|
||
|
||
return {
|
||
status: pass ? "pass" : "blocked",
|
||
pass,
|
||
requiredCapability: "long-lived-codex-stdio-session",
|
||
currentCapability: normalizedImplementation || normalizedSessionMode || normalizedRunnerKind || "unknown",
|
||
sessionId: session?.sessionId ?? null,
|
||
sessionStatus: session?.status ?? null,
|
||
runnerKind: normalizedRunnerKind || null,
|
||
provider: normalizedProvider || null,
|
||
sessionMode: normalizedSessionMode || null,
|
||
implementationType: normalizedImplementation || null,
|
||
blockers,
|
||
sourceIssue: "pikasTech/HWLAB#317",
|
||
summary: pass
|
||
? "Long-lived Codex stdio/session gate passed."
|
||
: "Long-lived Codex stdio/session gate remains blocked; current response must not be reported as full Code Agent completion."
|
||
};
|
||
}
|
||
|
||
function publicSession(session, { conversationId = null, reused = null } = {}) {
|
||
const conversationIds = [...session.conversationIds];
|
||
return {
|
||
sessionId: session.sessionId,
|
||
conversationId: conversationId ?? conversationIds[0] ?? null,
|
||
conversationIds,
|
||
status: session.status,
|
||
workspace: session.workspace,
|
||
sandbox: session.sandbox,
|
||
runnerKind: session.runnerKind,
|
||
sessionMode: session.sessionMode,
|
||
capabilityLevel: session.capabilityLevel,
|
||
implementationType: session.implementationType,
|
||
createdAt: session.createdAt,
|
||
updatedAt: session.updatedAt,
|
||
idleTimeoutMs: session.idleTimeoutMs,
|
||
expiresAt: session.expiresAt,
|
||
lastTraceId: session.lastTraceId,
|
||
currentTraceId: session.currentTraceId,
|
||
turn: session.turn,
|
||
threadId: session.threadId ?? null,
|
||
reused: reused === null ? undefined : Boolean(reused),
|
||
durable: session.durable === true,
|
||
longLivedSession: session.longLivedSession === true,
|
||
codexStdio: session.codexStdio === true,
|
||
writeCapable: session.writeCapable === true,
|
||
secretMaterialStored: false,
|
||
valuesRedacted: true,
|
||
...(session.statusReason ? { statusReason: session.statusReason } : {})
|
||
};
|
||
}
|
||
|
||
function blockedAcquire({ code, summary, session, timestamp, traceId }) {
|
||
const publicEvidence = session
|
||
? publicSession(session, { timestamp, reused: true })
|
||
: {
|
||
status: "failed",
|
||
updatedAt: timestamp,
|
||
lastTraceId: optionalId(traceId),
|
||
secretMaterialStored: false,
|
||
valuesRedacted: true
|
||
};
|
||
return {
|
||
ok: false,
|
||
code,
|
||
message: summary,
|
||
session: publicEvidence,
|
||
blocker: {
|
||
code,
|
||
sourceIssue: "pikasTech/HWLAB#317",
|
||
summary
|
||
}
|
||
};
|
||
}
|
||
|
||
function runnerDescriptor({ workspace, sandbox, session }) {
|
||
return {
|
||
kind: CODEX_STDIO_RUNNER_KIND,
|
||
provider: CODEX_STDIO_PROVIDER,
|
||
backend: CODEX_STDIO_BACKEND,
|
||
workspace,
|
||
sandbox,
|
||
session: CODEX_STDIO_SESSION_MODE,
|
||
sessionMode: CODEX_STDIO_SESSION_MODE,
|
||
sessionId: session?.sessionId ?? null,
|
||
turn: session?.turn ?? null,
|
||
sessionReused: session?.reused ?? false,
|
||
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
|
||
codexStdio: true,
|
||
longLivedSession: true,
|
||
durableSession: true,
|
||
writeCapable: true,
|
||
readOnly: false,
|
||
capabilityLevel: CODEX_STDIO_CAPABILITY_LEVEL,
|
||
toolPolicy: {
|
||
allowed: ["codex", "codex-reply", "workspace-read", "workspace-write"],
|
||
blocked: ["secret-read", "kubeconfig-read", "gateway-direct-call", "box-simu-direct-call", "patch-panel-direct-call", "M3/M4/M5-acceptance-claim-without-evidence"]
|
||
},
|
||
runnerLimitations: ["hardware-control-via-cloud-api-only", "secret-values-redacted"],
|
||
safety: codexStdioSafety()
|
||
};
|
||
}
|
||
|
||
function runnerTrace({ traceId, workspace, sandbox, session, events, startedAt, outputTruncated }) {
|
||
return {
|
||
traceId,
|
||
runnerKind: CODEX_STDIO_RUNNER_KIND,
|
||
workspace,
|
||
sandbox,
|
||
sessionMode: CODEX_STDIO_SESSION_MODE,
|
||
sessionId: session?.sessionId ?? null,
|
||
sessionStatus: session?.status ?? null,
|
||
idleTimeoutMs: session?.idleTimeoutMs ?? null,
|
||
lastTraceId: session?.lastTraceId ?? null,
|
||
turn: session?.turn ?? null,
|
||
sessionReused: session?.reused ?? false,
|
||
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
|
||
limitations: ["hardware-control-via-cloud-api-only", "secret-values-redacted"],
|
||
startedAt,
|
||
finishedAt: timestampFor(),
|
||
events,
|
||
outputTruncated: Boolean(outputTruncated),
|
||
valuesPrinted: false,
|
||
note: "Repo-owned Codex MCP stdio supervisor manages create/reuse/cancel/reap/idle timeout and trace capture; hardware control remains routed through cloud-api/HWLAB API/skill CLI boundaries."
|
||
};
|
||
}
|
||
|
||
function codexStdioSidecarResult({
|
||
message,
|
||
workspace,
|
||
sandbox,
|
||
session,
|
||
sidecar,
|
||
availability,
|
||
traceId,
|
||
startedAt,
|
||
events,
|
||
model,
|
||
env,
|
||
providerTraceToolName
|
||
}) {
|
||
return {
|
||
provider: CODEX_STDIO_PROVIDER,
|
||
model: firstNonEmpty(model, env.HWLAB_CODE_AGENT_MODEL, env.OPENAI_MODEL, "codex-default"),
|
||
backend: CODEX_STDIO_BACKEND,
|
||
content: sidecarOnlyReply({ message, sidecar, session }),
|
||
workspace,
|
||
sandbox,
|
||
session,
|
||
sessionMode: CODEX_STDIO_SESSION_MODE,
|
||
sessionReuse: sessionReuseEvidence(session),
|
||
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
|
||
runnerLimitations: ["hardware-control-via-cloud-api-only", "secret-values-redacted"],
|
||
codexStdioFeasibility: availability,
|
||
longLivedSessionGate: longLivedSessionGate({
|
||
provider: CODEX_STDIO_PROVIDER,
|
||
runnerKind: CODEX_STDIO_RUNNER_KIND,
|
||
session,
|
||
sessionMode: CODEX_STDIO_SESSION_MODE,
|
||
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
|
||
codexStdioFeasibility: availability
|
||
}),
|
||
toolCalls: sidecar.toolCalls,
|
||
skills: sidecar.skills,
|
||
runner: runnerDescriptor({ workspace, sandbox, session }),
|
||
runnerTrace: runnerTrace({
|
||
traceId,
|
||
workspace,
|
||
sandbox,
|
||
session,
|
||
events,
|
||
startedAt,
|
||
outputTruncated: sidecar.outputTruncated
|
||
}),
|
||
capabilityLevel: CODEX_STDIO_CAPABILITY_LEVEL,
|
||
providerTrace: {
|
||
transport: "stdio",
|
||
protocol: "mcp",
|
||
command: `${availability.command} mcp-server`,
|
||
toolName: providerTraceToolName,
|
||
threadId: session.threadId ?? null,
|
||
sidecarOnly: true,
|
||
valuesPrinted: false
|
||
}
|
||
};
|
||
}
|
||
|
||
function shouldReturnSidecarOnly(sidecar = {}) {
|
||
const intent = sidecar.intent ?? {};
|
||
const requested = ["pwd", "ls", "skills", "smoke"].filter((name) => intent[name] === true);
|
||
if (requested.length === 0) return false;
|
||
return sidecar.toolCalls.length > 0;
|
||
}
|
||
|
||
function sidecarOnlyReply({ message, sidecar, session }) {
|
||
const intent = sidecar.intent ?? {};
|
||
const lines = [];
|
||
if (intent.pwd) {
|
||
const pwd = sidecar.toolCalls.find((toolCall) => toolCall.name === "pwd");
|
||
lines.push("当前 Codex stdio 受控 workspace 目录:", pwd?.stdout ?? "");
|
||
}
|
||
if (intent.ls) {
|
||
const ls = sidecar.toolCalls.find((toolCall) => toolCall.name === "ls");
|
||
lines.push(lines.length ? "" : "", "目录列表:", ls?.stdout || "(empty)");
|
||
}
|
||
if (sidecar.skills.status === "ready") {
|
||
lines.push(lines.length ? "" : "", stdioSkillsReply(sidecar.skills));
|
||
}
|
||
if (intent.smoke) {
|
||
const smokeCalls = sidecar.toolCalls
|
||
.filter((toolCall) => toolCall.name.startsWith("workspace.smoke."))
|
||
.map((toolCall) => `${toolCall.name}:${toolCall.status}`);
|
||
if (smokeCalls.length > 0) lines.push(lines.length ? "" : "", `workspace smoke: ${smokeCalls.join(", ")}`);
|
||
}
|
||
lines.push(
|
||
lines.length ? "" : "",
|
||
`Session: mode=${CODEX_STDIO_SESSION_MODE}; sessionId=${session.sessionId}; status=${session.status}; reused=${session.reused}; turn=${session.turn}.`,
|
||
"边界:provider=codex-stdio;backend=hwlab-cloud-api/codex-mcp-stdio;硬件控制仅允许 cloud-api/HWLAB API/skill CLI;未读取 secret/token/kubeconfig。"
|
||
);
|
||
return boundToolOutput(lines.filter((line, index) => line !== "" || index > 0).join("\n")).text || String(message ?? "").trim();
|
||
}
|
||
|
||
function stdioSkillsReply(skills) {
|
||
const lines = [
|
||
`已发现 ${skills.totalCount} 个可用 skill${skills.truncated ? `,本次显示前 ${skills.count} 个` : ""}:`
|
||
];
|
||
for (const skill of skills.items) {
|
||
const meta = [
|
||
`source=${skill.source}`,
|
||
skill.version ? `version=${skill.version}` : null,
|
||
skill.commit ? `commit=${skill.commit}` : null
|
||
].filter(Boolean).join(" / ");
|
||
lines.push(`- ${skill.name}: ${skill.summary}${meta ? ` (${meta})` : ""}`);
|
||
}
|
||
lines.push("说明:这是 Codex stdio runner 的受控 skills discovery,未读取 secret/token/kubeconfig。");
|
||
return lines.join("\n");
|
||
}
|
||
|
||
function sessionReuseEvidence(session) {
|
||
return {
|
||
conversationId: session.conversationId,
|
||
sessionId: session.sessionId,
|
||
threadId: session.threadId,
|
||
mapped: true,
|
||
reused: session.reused,
|
||
turn: session.turn,
|
||
previousTurns: Math.max(0, session.turn - 1),
|
||
workspace: session.workspace,
|
||
createdAt: session.createdAt,
|
||
updatedAt: session.updatedAt,
|
||
idleTimeoutMs: session.idleTimeoutMs,
|
||
expiresAt: session.expiresAt,
|
||
lastTraceId: session.lastTraceId,
|
||
status: session.status
|
||
};
|
||
}
|
||
|
||
function extractCodexToolOutput(toolResult) {
|
||
const direct = toolResult?.structuredContent ?? toolResult?.output ?? toolResult;
|
||
const directThreadId = optionalId(direct?.threadId ?? direct?.conversationId);
|
||
const directContent = firstNonEmpty(direct?.content, direct?.message, direct?.text);
|
||
if (directThreadId || directContent) {
|
||
return {
|
||
threadId: directThreadId,
|
||
content: redactText(directContent)
|
||
};
|
||
}
|
||
|
||
for (const item of toolResult?.content ?? []) {
|
||
if (typeof item?.text !== "string") continue;
|
||
const parsed = parseJsonOrNull(item.text);
|
||
if (parsed) {
|
||
const threadId = optionalId(parsed.threadId ?? parsed.conversationId);
|
||
const content = firstNonEmpty(parsed.content, parsed.message, parsed.text);
|
||
if (threadId || content) {
|
||
return {
|
||
threadId,
|
||
content: redactText(content)
|
||
};
|
||
}
|
||
}
|
||
if (item.text.trim()) {
|
||
return {
|
||
threadId: null,
|
||
content: redactText(item.text.trim())
|
||
};
|
||
}
|
||
}
|
||
|
||
return {
|
||
threadId: null,
|
||
content: ""
|
||
};
|
||
}
|
||
|
||
async function collectWorkspaceSidecarEvidence({ message, workspace, traceId, env, now } = {}) {
|
||
const intent = detectWorkspaceSidecarIntent(message);
|
||
const toolCalls = [];
|
||
const events = [];
|
||
let skills = notRequestedSkills();
|
||
let outputTruncated = false;
|
||
|
||
if (intent.pwd) {
|
||
const toolCall = await pwdToolCall({ workspace, traceId });
|
||
toolCalls.push(toolCall);
|
||
events.push(`tool:workspace.pwd:${toolCall.status}`);
|
||
}
|
||
|
||
if (intent.ls) {
|
||
const toolCall = await lsToolCall({ workspace, target: intent.target, traceId });
|
||
toolCalls.push(toolCall);
|
||
events.push(`tool:workspace.ls:${toolCall.status}`);
|
||
outputTruncated = outputTruncated || toolCall.outputTruncated;
|
||
}
|
||
|
||
if (intent.skills) {
|
||
skills = await discoverSkillsForStdio({ env, traceId });
|
||
toolCalls.push({
|
||
id: `tool_${randomUUID()}`,
|
||
type: "file-read",
|
||
name: "skills.discover",
|
||
status: skills.status === "ready" ? "completed" : "blocked",
|
||
cwd: workspace,
|
||
exitCode: skills.status === "ready" ? 0 : 1,
|
||
stdout: skills.status === "ready" ? `skills=${skills.count}` : "",
|
||
stderrSummary: skills.status === "ready" ? "" : "skills_unavailable",
|
||
outputTruncated: Boolean(skills.truncated),
|
||
traceId
|
||
});
|
||
events.push(`tool:skills.discover:${skills.status === "ready" ? "completed" : "blocked"}`);
|
||
outputTruncated = outputTruncated || Boolean(skills.truncated);
|
||
}
|
||
|
||
if (intent.smoke) {
|
||
const smokeCalls = await workspaceSmokeToolCalls({ workspace, traceId, now });
|
||
toolCalls.push(...smokeCalls);
|
||
for (const call of smokeCalls) events.push(`tool:${call.name}:${call.status}`);
|
||
outputTruncated = outputTruncated || smokeCalls.some((call) => call.outputTruncated);
|
||
}
|
||
|
||
return {
|
||
intent,
|
||
toolCalls,
|
||
skills,
|
||
events,
|
||
outputTruncated
|
||
};
|
||
}
|
||
|
||
function detectWorkspaceSidecarIntent(message) {
|
||
const text = String(message ?? "");
|
||
const lower = text.toLowerCase();
|
||
const wantsPwd = /\bpwd\b/u.test(lower) || /(?:当前|打印|显示|查看).{0,12}(?:工作目录|目录|路径|workspace)/iu.test(text);
|
||
const explicitLs = /\bls\b/u.test(lower) || /(?:列出|查看).{0,12}(?:目录|文件)/u.test(text);
|
||
return {
|
||
pwd: wantsPwd,
|
||
ls: explicitLs && (!wantsPwd || /\bls\b/u.test(lower) || /(?:文件|目录列表|所有文件)/u.test(text)),
|
||
skills: /(?:可用|能使用|加载|列出|所有).{0,16}(?:skills?|skill|技能)|(?:skills?|skill|技能).{0,16}(?:可用|能使用|加载|列出|所有)/iu.test(text),
|
||
smoke: /(?:write|写入|读取|清理|cleanup|smoke|冒烟).{0,24}(?:workspace|工作区|临时|tmp)|(?:workspace|工作区).{0,24}(?:write|写入|读取|清理|smoke|冒烟)/iu.test(text),
|
||
target: extractWorkspaceTarget(text)
|
||
};
|
||
}
|
||
|
||
function extractWorkspaceTarget(text) {
|
||
const value = String(text ?? "");
|
||
const quoted = value.match(/[`"']([^`"']{1,240})[`"']/u)?.[1];
|
||
if (quoted) return quoted.trim();
|
||
const inlinePath = value.match(/((?:\.{1,2}\/|\/)[A-Za-z0-9._@:/+=-]+|[A-Za-z0-9._@+=-]+\.[A-Za-z0-9._-]+)/u)?.[1];
|
||
return inlinePath || ".";
|
||
}
|
||
|
||
async function pwdToolCall({ workspace, traceId }) {
|
||
try {
|
||
const workspaceStat = await stat(workspace);
|
||
if (!workspaceStat.isDirectory()) {
|
||
return blockedToolCall({
|
||
name: "pwd",
|
||
type: "workspace-read",
|
||
workspace,
|
||
traceId,
|
||
reason: "configured workspace is not a directory"
|
||
});
|
||
}
|
||
} catch (error) {
|
||
return blockedToolCall({
|
||
name: "pwd",
|
||
type: "workspace-read",
|
||
workspace,
|
||
traceId,
|
||
reason: error.message || "configured workspace is not readable"
|
||
});
|
||
}
|
||
return {
|
||
id: `tool_${randomUUID()}`,
|
||
type: "workspace-read",
|
||
name: "pwd",
|
||
status: "completed",
|
||
cwd: workspace,
|
||
command: "pwd",
|
||
exitCode: 0,
|
||
stdout: redactText(workspace),
|
||
stderrSummary: "",
|
||
outputTruncated: false,
|
||
traceId
|
||
};
|
||
}
|
||
|
||
async function lsToolCall({ workspace, target = ".", traceId }) {
|
||
const targetInfo = resolveWorkspaceTarget(workspace, target, { mustExist: true });
|
||
const blockedReason = targetInfo.blocked ? targetInfo.reason : await targetInfo.check();
|
||
if (blockedReason) {
|
||
return blockedToolCall({ name: "ls", type: "workspace-read", workspace, traceId, reason: blockedReason });
|
||
}
|
||
try {
|
||
const info = await stat(targetInfo.path);
|
||
const entries = info.isDirectory()
|
||
? await readdir(targetInfo.path, { withFileTypes: true })
|
||
: [{ name: path.basename(targetInfo.path), isDirectory: () => false, isSymbolicLink: () => false }];
|
||
const lines = entries
|
||
.sort((a, b) => a.name.localeCompare(b.name, "en"))
|
||
.slice(0, 200)
|
||
.map((entry) => `${entry.isDirectory() ? "dir " : entry.isSymbolicLink() ? "link" : "file"} ${entry.name}`);
|
||
const bounded = boundToolOutput(redactText(lines.join("\n")));
|
||
return {
|
||
id: `tool_${randomUUID()}`,
|
||
type: "workspace-read",
|
||
name: "ls",
|
||
status: "completed",
|
||
cwd: workspace,
|
||
command: `ls ${safeDisplayPath(targetInfo.relative || ".")}`,
|
||
exitCode: 0,
|
||
stdout: bounded.text,
|
||
stderrSummary: entries.length > 200 ? "entry limit 200 reached" : "",
|
||
outputTruncated: bounded.truncated || entries.length > 200,
|
||
traceId
|
||
};
|
||
} catch (error) {
|
||
return blockedToolCall({ name: "ls", type: "workspace-read", workspace, traceId, reason: error.message });
|
||
}
|
||
}
|
||
|
||
async function workspaceSmokeToolCalls({ workspace, traceId, now }) {
|
||
const parentDir = path.join(workspace, ".hwlab-code-agent-smoke");
|
||
const dir = path.join(parentDir, `run-${randomUUID()}`);
|
||
const filename = "stdio-smoke.txt";
|
||
const filePath = path.join(dir, filename);
|
||
const content = `traceId=${traceId}\ncreatedAt=${timestampFor(now)}\n`;
|
||
const calls = [];
|
||
try {
|
||
await mkdir(dir, { recursive: true });
|
||
await writeFile(filePath, content, { encoding: "utf8", flag: "wx" });
|
||
calls.push({
|
||
id: `tool_${randomUUID()}`,
|
||
type: "workspace-write",
|
||
name: "workspace.smoke.write",
|
||
status: "completed",
|
||
cwd: workspace,
|
||
command: `write ${safeDisplayPath(path.relative(workspace, filePath))}`,
|
||
exitCode: 0,
|
||
stdout: "written",
|
||
stderrSummary: "",
|
||
outputTruncated: false,
|
||
traceId
|
||
});
|
||
const readBack = await readFile(filePath, "utf8");
|
||
const bounded = boundToolOutput(redactText(readBack), 300);
|
||
calls.push({
|
||
id: `tool_${randomUUID()}`,
|
||
type: "workspace-read",
|
||
name: "workspace.smoke.read",
|
||
status: "completed",
|
||
cwd: workspace,
|
||
command: `read ${safeDisplayPath(path.relative(workspace, filePath))}`,
|
||
exitCode: 0,
|
||
stdout: bounded.text,
|
||
stderrSummary: "",
|
||
outputTruncated: bounded.truncated,
|
||
traceId
|
||
});
|
||
await rm(filePath, { force: true });
|
||
await rmdir(dir);
|
||
await rmdir(parentDir).catch(() => {});
|
||
calls.push({
|
||
id: `tool_${randomUUID()}`,
|
||
type: "workspace-write",
|
||
name: "workspace.smoke.cleanup",
|
||
status: "completed",
|
||
cwd: workspace,
|
||
command: `rm ${safeDisplayPath(path.relative(workspace, filePath))}`,
|
||
exitCode: 0,
|
||
stdout: "removed",
|
||
stderrSummary: "",
|
||
outputTruncated: false,
|
||
traceId
|
||
});
|
||
} catch (error) {
|
||
calls.push(blockedToolCall({
|
||
name: "workspace.smoke",
|
||
type: "workspace-write",
|
||
workspace,
|
||
traceId,
|
||
reason: error.message
|
||
}));
|
||
try {
|
||
await rm(dir, { recursive: true, force: true });
|
||
await rmdir(parentDir).catch(() => {});
|
||
} catch {
|
||
// Best-effort cleanup; blocked tool evidence keeps readiness precise.
|
||
}
|
||
}
|
||
return calls;
|
||
}
|
||
|
||
async function discoverSkillsForStdio({ env = process.env, traceId } = {}) {
|
||
const checkedDirs = resolveSkillDirs(env);
|
||
const sources = [];
|
||
const items = [];
|
||
for (const dir of checkedDirs) {
|
||
if (!pathReadableSync(dir)) {
|
||
sources.push({ path: dir, status: "missing_or_unreadable" });
|
||
continue;
|
||
}
|
||
sources.push({ path: dir, status: "readable" });
|
||
const manifests = await skillManifestPaths(dir);
|
||
for (const manifestPath of manifests) {
|
||
const manifest = await readSkillManifest(manifestPath);
|
||
if (!manifest) continue;
|
||
items.push({
|
||
name: manifest.name,
|
||
summary: manifest.description ?? firstMarkdownSummary(manifest.body) ?? "No description provided.",
|
||
source: manifestPath,
|
||
sourceRoot: dir,
|
||
traceId
|
||
});
|
||
}
|
||
}
|
||
const unique = dedupeSkills(items).sort((a, b) => a.name.localeCompare(b.name, "en"));
|
||
const returned = unique.slice(0, CODEX_STDIO_SKILL_LIMIT);
|
||
if (returned.length === 0) {
|
||
return {
|
||
status: "blocked",
|
||
code: "skills_unavailable",
|
||
items: [],
|
||
count: 0,
|
||
totalCount: 0,
|
||
checkedDirs,
|
||
sources,
|
||
blockers: [{
|
||
code: "skills_unavailable",
|
||
sourceIssue: "pikasTech/HWLAB#136",
|
||
summary: "No readable SKILL.md files were found in the configured Codex stdio skills directories."
|
||
}],
|
||
valuesPrinted: false
|
||
};
|
||
}
|
||
return {
|
||
status: "ready",
|
||
code: "skills_ready",
|
||
items: returned,
|
||
count: returned.length,
|
||
totalCount: unique.length,
|
||
truncated: unique.length > returned.length,
|
||
checkedDirs,
|
||
sources,
|
||
blockers: [],
|
||
valuesPrinted: false
|
||
};
|
||
}
|
||
|
||
function resolveSkillDirs(env = process.env) {
|
||
const configured = String(firstNonEmpty(env.HWLAB_CODE_AGENT_SKILLS_DIRS, env.UNIDESK_SKILLS_PATH, ""))
|
||
.split(/[,;]/u)
|
||
.flatMap((part) => part.split(path.delimiter))
|
||
.map((dir) => dir.trim())
|
||
.filter(Boolean);
|
||
if (env.HWLAB_CODE_AGENT_SKILLS_STRICT === "1") {
|
||
return [...new Set(configured.map((dir) => path.resolve(dir)))];
|
||
}
|
||
return [...new Set([
|
||
...configured,
|
||
"/app/skills",
|
||
path.join(repoRoot, "skills"),
|
||
path.join(os.homedir(), ".agents", "skills"),
|
||
"/root/.agents/skills",
|
||
"/home/ubuntu/.agents/skills"
|
||
].map((dir) => path.resolve(dir)))];
|
||
}
|
||
|
||
async function skillManifestPaths(skillsDir) {
|
||
const direct = path.join(skillsDir, "SKILL.md");
|
||
const manifests = [];
|
||
if (pathReadableSync(direct)) manifests.push(direct);
|
||
let entries = [];
|
||
try {
|
||
entries = await readdir(skillsDir, { withFileTypes: true });
|
||
} catch {
|
||
return manifests;
|
||
}
|
||
for (const entry of entries) {
|
||
if (!entry.isDirectory()) continue;
|
||
const manifestPath = path.join(skillsDir, entry.name, "SKILL.md");
|
||
if (pathReadableSync(manifestPath)) manifests.push(manifestPath);
|
||
}
|
||
return manifests;
|
||
}
|
||
|
||
async function readSkillManifest(manifestPath) {
|
||
try {
|
||
const text = await readFile(manifestPath, "utf8");
|
||
const frontmatter = parseFrontmatter(text);
|
||
const body = text.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/u, "");
|
||
const name = frontmatter.name ?? path.basename(path.dirname(manifestPath));
|
||
return name ? { name, description: frontmatter.description, body } : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function parseFrontmatter(text) {
|
||
const match = String(text ?? "").match(/^---\s*\n([\s\S]*?)\n---\s*(?:\n|$)/u);
|
||
if (!match) return {};
|
||
const data = {};
|
||
for (const line of match[1].split(/\r?\n/u)) {
|
||
const field = line.match(/^([A-Za-z0-9_-]+):\s*(.*)\s*$/u);
|
||
if (!field) continue;
|
||
data[field[1]] = field[2].replace(/^["']|["']$/gu, "").trim();
|
||
}
|
||
return data;
|
||
}
|
||
|
||
function firstMarkdownSummary(body) {
|
||
return String(body ?? "")
|
||
.split(/\r?\n/u)
|
||
.map((line) => line.trim())
|
||
.find((line) => line && !line.startsWith("#") && !line.startsWith("-")) ?? null;
|
||
}
|
||
|
||
function dedupeSkills(items) {
|
||
const seen = new Set();
|
||
const deduped = [];
|
||
for (const item of items) {
|
||
const key = item.name.toLowerCase();
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
deduped.push(item);
|
||
}
|
||
return deduped;
|
||
}
|
||
|
||
function notRequestedSkills() {
|
||
return {
|
||
status: "not_requested",
|
||
items: [],
|
||
count: 0,
|
||
blockers: []
|
||
};
|
||
}
|
||
|
||
function codexReplyWithSidecar(content, sidecar) {
|
||
const lines = [String(content ?? "").trim()].filter(Boolean);
|
||
const callNames = sidecar.toolCalls.map((call) => `${call.name}:${call.status}`);
|
||
if (callNames.length > 0) {
|
||
lines.push("", `runner tool evidence: ${callNames.join(", ")}`);
|
||
}
|
||
if (sidecar.skills.status === "ready") {
|
||
lines.push(`skills discovered: ${sidecar.skills.count}/${sidecar.skills.totalCount}`);
|
||
}
|
||
return lines.join("\n");
|
||
}
|
||
|
||
function resolveWorkspaceTarget(workspace, target, { mustExist = false } = {}) {
|
||
const rawTarget = String(target || ".").trim();
|
||
if (!rawTarget) return { blocked: true, reason: "missing target path" };
|
||
if (isForbiddenPath(rawTarget)) {
|
||
return { blocked: true, reason: "security_blocked: target path may expose secrets or forbidden runtime material" };
|
||
}
|
||
const resolved = path.resolve(workspace, rawTarget);
|
||
let realWorkspace = workspace;
|
||
let realResolved = resolved;
|
||
try {
|
||
realWorkspace = realpathSync(workspace);
|
||
realResolved = existsSync(resolved) ? realpathSync(resolved) : path.resolve(realWorkspace, path.relative(workspace, resolved));
|
||
} catch {
|
||
realWorkspace = path.resolve(workspace);
|
||
realResolved = path.resolve(resolved);
|
||
}
|
||
if (!isPathInside(realResolved, realWorkspace)) {
|
||
return { blocked: true, reason: "security_blocked: target path is outside the runner workspace" };
|
||
}
|
||
const relative = path.relative(realWorkspace, realResolved) || ".";
|
||
if (isForbiddenPath(relative)) {
|
||
return { blocked: true, reason: "security_blocked: target path is not allowed" };
|
||
}
|
||
return {
|
||
path: realResolved,
|
||
relative,
|
||
async check() {
|
||
if (!mustExist) return null;
|
||
try {
|
||
await stat(realResolved);
|
||
return null;
|
||
} catch {
|
||
return "target path is not readable";
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
function isPathInside(child, parent) {
|
||
const relative = path.relative(parent, child);
|
||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||
}
|
||
|
||
function isForbiddenPath(value) {
|
||
const normalized = String(value ?? "").replaceAll("\\", "/").toLowerCase();
|
||
return /(^|\/)(?:\.env(?:\.|$)|\.npmrc$|\.pypirc$|id_rsa$|id_ed25519$|kubeconfig$|k3s\.yaml$|credentials?$|secrets?$|token(?:s)?$|database-url$)/u.test(normalized) ||
|
||
/(?:secret|token|password|passwd|private[_-]?key|openai_api_key|database_url|kubeconfig)/u.test(normalized);
|
||
}
|
||
|
||
function blockedToolCall({ name, type, workspace, traceId, reason }) {
|
||
return {
|
||
id: `tool_${randomUUID()}`,
|
||
type,
|
||
name,
|
||
status: "blocked",
|
||
cwd: workspace,
|
||
exitCode: 1,
|
||
stdout: "",
|
||
stderrSummary: `security_blocked: ${redactText(reason)}`,
|
||
outputTruncated: false,
|
||
traceId
|
||
};
|
||
}
|
||
|
||
function safeDisplayPath(value) {
|
||
return redactText(String(value ?? ".")).replace(/\s+/gu, " ");
|
||
}
|
||
|
||
function buildCodexUserPrompt(message, { conversationId, traceId, conversationFacts }) {
|
||
return [
|
||
`conversationId: ${conversationId}`,
|
||
`traceId: ${traceId}`,
|
||
codexConversationFactsPrompt(conversationFacts),
|
||
"",
|
||
"User message:",
|
||
String(message ?? "").trim()
|
||
].filter((line) => line !== null && line !== undefined).join("\n");
|
||
}
|
||
|
||
function codexConversationFactsPrompt(conversationFacts) {
|
||
if (!conversationFacts || typeof conversationFacts !== "object" || Number(conversationFacts.turnCount ?? 0) <= 0) {
|
||
return null;
|
||
}
|
||
return [
|
||
"Prior session facts (bounded, redacted; use for Chinese answers about previous turns):",
|
||
`- sessionId=${safePromptFact(conversationFacts.sessionId)}; sessionStatus=${safePromptFact(conversationFacts.sessionStatus)}; sessionMode=${safePromptFact(conversationFacts.sessionMode)}; turnCount=${safePromptFact(conversationFacts.turnCount)}.`,
|
||
`- provider/backend/runner=${safePromptFact(conversationFacts.latestProvider)}/${safePromptFact(conversationFacts.latestBackend)}/${safePromptFact(conversationFacts.runnerKind)}; capabilityLevel=${safePromptFact(conversationFacts.capabilityLevel)}.`,
|
||
`- workspace=${safePromptFact(conversationFacts.workspace)}; sandbox=${safePromptFact(conversationFacts.sandbox)}.`,
|
||
`- traceIds=${promptList(conversationFacts.traceIds, 6)}; latestTraceId=${safePromptFact(conversationFacts.latestTraceId)}.`,
|
||
`- skills=${promptSkills(conversationFacts.latestSkills)}.`,
|
||
`- toolCalls=${promptToolCalls(conversationFacts.recentToolCalls)}.`
|
||
].join("\n");
|
||
}
|
||
|
||
function promptSkills(skills) {
|
||
if (!skills || typeof skills !== "object") return "not_requested";
|
||
const names = Array.isArray(skills.names) ? skills.names.filter(Boolean).slice(0, 8) : [];
|
||
const count = skills.totalCount ?? skills.count ?? names.length;
|
||
return `${safePromptFact(skills.status)} count=${safePromptFact(count)}${names.length ? ` names=${names.map(safePromptFact).join(",")}` : ""}`;
|
||
}
|
||
|
||
function promptToolCalls(toolCalls) {
|
||
if (!Array.isArray(toolCalls) || toolCalls.length === 0) return "none";
|
||
return toolCalls
|
||
.slice(-6)
|
||
.map((toolCall) => [toolCall?.name, toolCall?.status, toolCall?.route].filter(Boolean).map(safePromptFact).join(":"))
|
||
.filter(Boolean)
|
||
.join(",") || "none";
|
||
}
|
||
|
||
function promptList(values, limit) {
|
||
return Array.isArray(values) && values.length > 0
|
||
? values.filter(Boolean).slice(-limit).map(safePromptFact).join(",")
|
||
: "none";
|
||
}
|
||
|
||
function safePromptFact(value) {
|
||
if (value === undefined || value === null || value === "") return "none";
|
||
return redactText(String(value).replace(/\s+/gu, " ").trim()).slice(0, 180);
|
||
}
|
||
|
||
function resolveCodexWorkspace(env = process.env, options = {}) {
|
||
return path.resolve(firstNonEmpty(
|
||
options.workspace,
|
||
env.HWLAB_CODE_AGENT_CODEX_WORKSPACE,
|
||
env.HWLAB_CODE_AGENT_WORKSPACE,
|
||
env.HWLAB_RUNNER_WORKSPACE,
|
||
env.WORKSPACE,
|
||
repoRoot
|
||
));
|
||
}
|
||
|
||
function resolveCodexCommand(env = process.env, params = {}, options = {}) {
|
||
const configured = firstNonEmpty(params.command, options.command, env.HWLAB_CODE_AGENT_CODEX_COMMAND);
|
||
if (configured) return configured;
|
||
|
||
const nodeModulesCodex = path.join(repoRoot, "node_modules", ".bin", "codex");
|
||
if (existsSync(nodeModulesCodex)) return nodeModulesCodex;
|
||
return DEFAULT_CODEX_STDIO_COMMAND;
|
||
}
|
||
|
||
function resolveCodexSandbox(env = process.env, options = {}) {
|
||
const value = firstNonEmpty(options.sandbox, env.HWLAB_CODE_AGENT_CODEX_SANDBOX, CODEX_STDIO_SANDBOX);
|
||
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" ||
|
||
env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED === "true";
|
||
}
|
||
|
||
function supervisorState(env, params, options, enabled) {
|
||
const configured = params.supervisorEnabled === true ||
|
||
options.supervisorEnabled === true ||
|
||
env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR === "repo-owned" ||
|
||
env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR === "enabled" ||
|
||
(enabled && env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR !== "disabled");
|
||
return {
|
||
configured,
|
||
mode: configured ? "repo-owned-node-supervisor" : "disabled",
|
||
processModel: "codex mcp-server over stdio",
|
||
cancelSupported: true,
|
||
reapSupported: true,
|
||
secretMaterialStored: false,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function tokenBoundaryState(env = process.env) {
|
||
const present = hasEnvValue(env, "OPENAI_API_KEY") ||
|
||
hasEnvValue(env, "CODEX_API_KEY") ||
|
||
env.HWLAB_CODE_AGENT_CODEX_TOKEN_BOUNDARY === "configured" ||
|
||
env.HWLAB_CODE_AGENT_CODEX_TOKEN_BOUNDARY === "present";
|
||
return {
|
||
present,
|
||
sources: [
|
||
hasEnvValue(env, "OPENAI_API_KEY") ? "OPENAI_API_KEY" : null,
|
||
hasEnvValue(env, "CODEX_API_KEY") ? "CODEX_API_KEY" : null,
|
||
env.HWLAB_CODE_AGENT_CODEX_TOKEN_BOUNDARY ? "HWLAB_CODE_AGENT_CODEX_TOKEN_BOUNDARY" : null
|
||
].filter(Boolean),
|
||
secretMaterialRead: false,
|
||
secretValuesPrinted: false,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function egressState(env = process.env) {
|
||
const baseUrl = firstNonEmpty(env.HWLAB_CODE_AGENT_OPENAI_BASE_URL, env.OPENAI_BASE_URL);
|
||
const directPublicOpenAi = /^https:\/\/api\.openai\.com\/v1\/?/u.test(baseUrl);
|
||
return {
|
||
configured: Boolean(baseUrl),
|
||
directPublicOpenAi,
|
||
valueRedacted: true
|
||
};
|
||
}
|
||
|
||
function workspaceStateSync(workspace, sandbox) {
|
||
const state = {
|
||
exists: false,
|
||
readable: false,
|
||
writable: false,
|
||
writeRequired: sandbox === "workspace-write"
|
||
};
|
||
try {
|
||
state.exists = existsSync(workspace);
|
||
if (!state.exists) return state;
|
||
state.readable = accessSyncBoolean(workspace, fsConstants.R_OK);
|
||
state.writable = accessSyncBoolean(workspace, fsConstants.W_OK);
|
||
return state;
|
||
} catch {
|
||
return state;
|
||
}
|
||
}
|
||
|
||
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);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function pathReadableSync(targetPath) {
|
||
return accessSyncBoolean(targetPath, fsConstants.R_OK);
|
||
}
|
||
|
||
function codexBinaryState(command, env = process.env) {
|
||
const resolvedPath = commandPathSync(command, env);
|
||
const present = Boolean(resolvedPath);
|
||
const versionProbe = present ? codexVersionProbe(command, env) : {
|
||
attempted: false,
|
||
ok: false,
|
||
version: null,
|
||
error: null,
|
||
exitCode: null
|
||
};
|
||
const nativeDependency = present ? codexNativeDependencyState(resolvedPath) : {
|
||
present: false,
|
||
path: null,
|
||
evidence: "Codex CLI command was not present, so native dependency was not checked."
|
||
};
|
||
return {
|
||
command,
|
||
present,
|
||
binaryPresent: present,
|
||
path: resolvedPath,
|
||
executable: versionProbe.ok,
|
||
version: versionProbe.version,
|
||
versionDetected: versionProbe.ok,
|
||
versionProbe,
|
||
nativeDependencyPresent: nativeDependency.present,
|
||
nativeDependencyPath: nativeDependency.path,
|
||
nativeDependencyEvidence: nativeDependency.evidence,
|
||
nextEvidence: present
|
||
? versionProbe.ok
|
||
? "codex --version was probed without printing secrets; stdio protocol readiness is checked separately."
|
||
: `Codex CLI command ${command} exists but --version did not complete successfully.`
|
||
: `Install/provide a repo-controlled Codex CLI binary on PATH or set HWLAB_CODE_AGENT_CODEX_COMMAND to an approved binary path; checked command=${command}.`,
|
||
secretMaterialRead: false,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function codexNativeDependencyState(resolvedCommandPath) {
|
||
const candidates = [];
|
||
let resolved = path.resolve(resolvedCommandPath);
|
||
try {
|
||
resolved = realpathSync(resolved);
|
||
} catch {
|
||
// Fall back to the resolved command path; the binary blocker will report executability.
|
||
}
|
||
const packageRoot = findPackageRoot(resolved);
|
||
if (packageRoot) {
|
||
candidates.push(
|
||
path.join(packageRoot, "..", "codex-linux-x64", "vendor", "x86_64-unknown-linux-musl", "codex", "codex"),
|
||
path.join(packageRoot, "..", "codex-linux-arm64", "vendor", "aarch64-unknown-linux-musl", "codex", "codex"),
|
||
path.join(packageRoot, "vendor", "x86_64-unknown-linux-musl", "codex", "codex"),
|
||
path.join(packageRoot, "vendor", "aarch64-unknown-linux-musl", "codex", "codex")
|
||
);
|
||
}
|
||
const existing = candidates.find((candidate) => existsSync(candidate) && accessSyncBoolean(candidate, fsConstants.X_OK)) ?? null;
|
||
return {
|
||
present: Boolean(existing),
|
||
path: existing,
|
||
evidence: existing
|
||
? "Native @openai/codex executable dependency exists and is executable."
|
||
: "Expected native @openai/codex executable dependency was not found next to the CLI package."
|
||
};
|
||
}
|
||
|
||
function findPackageRoot(resolvedCommandPath) {
|
||
let current = path.dirname(resolvedCommandPath);
|
||
for (let depth = 0; depth < 8; depth += 1) {
|
||
if (existsSync(path.join(current, "package.json"))) return current;
|
||
const next = path.dirname(current);
|
||
if (next === current) return null;
|
||
current = next;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function commandPathSync(command, env = process.env) {
|
||
if (!command) return null;
|
||
if (command.includes("/") || command.includes("\\")) {
|
||
return existsSync(command) ? command : null;
|
||
}
|
||
const paths = String(Object.hasOwn(env, "PATH") ? env.PATH : process.env.PATH || "").split(path.delimiter).filter(Boolean);
|
||
const resolved = paths.map((dir) => path.join(dir, command)).find((candidate) => existsSync(candidate));
|
||
return resolved ?? null;
|
||
}
|
||
|
||
function commandOnPathSync(command, env = process.env) {
|
||
return Boolean(commandPathSync(command, env));
|
||
}
|
||
|
||
function codexVersionProbe(command, env = process.env) {
|
||
try {
|
||
const result = spawnSync(command, ["--version"], {
|
||
env: childProcessEnv(env),
|
||
encoding: "utf8",
|
||
timeout: 3000,
|
||
windowsHide: true
|
||
});
|
||
const output = redactText(`${result.stdout ?? ""}\n${result.stderr ?? ""}`).trim();
|
||
const version = output.split(/\s+/u).find((part) => /\d+\.\d+(?:\.\d+)?/u.test(part)) ?? (output ? tailText(output, 160) : null);
|
||
return {
|
||
attempted: true,
|
||
ok: result.status === 0 && Boolean(version),
|
||
version,
|
||
exitCode: result.status,
|
||
error: result.error ? redactText(result.error.message) : null
|
||
};
|
||
} catch (error) {
|
||
return {
|
||
attempted: true,
|
||
ok: false,
|
||
version: null,
|
||
exitCode: null,
|
||
error: redactText(error.message)
|
||
};
|
||
}
|
||
}
|
||
|
||
function protocolState({ command, binary, supervisor, toolsObserved }) {
|
||
const observedTools = Array.isArray(toolsObserved) ? toolsObserved.filter(Boolean) : [];
|
||
const missingTools = CODEX_STDIO_REQUIRED_TOOLS.filter((tool) => !observedTools.includes(tool));
|
||
const wired = binary.present === true && supervisor.configured === true && observedTools.length > 0 && missingTools.length === 0;
|
||
const probeReady = binary.present === true && supervisor.configured === true;
|
||
return {
|
||
transport: "stdio",
|
||
command: `${command} mcp-server`,
|
||
protocolVersion: MCP_PROTOCOL_VERSION,
|
||
adapter: "Model Context Protocol tools/call",
|
||
requiredTools: [...CODEX_STDIO_REQUIRED_TOOLS],
|
||
toolsObserved: observedTools,
|
||
missingTools,
|
||
wired,
|
||
probeReady,
|
||
status: wired ? "wired" : "blocked",
|
||
blocker: wired ? null : "stdio_protocol_not_wired",
|
||
summary: wired
|
||
? "Codex stdio MCP protocol is wired and required tools were observed."
|
||
: "Codex stdio MCP protocol has not been proven: required tools codex/codex-reply were not observed through repo-owned stdio.",
|
||
nextEvidence: "Start `codex mcp-server`, run MCP initialize plus tools/list, and observe codex and codex-reply without printing token material.",
|
||
secretMaterialRead: false,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function cachedProbeTools({ command, workspace, codexHome, probe }) {
|
||
if (!probe || probe.ready !== true) return null;
|
||
if (probe.command !== command || probe.workspace !== workspace || probe.codexHome !== codexHome) return null;
|
||
if (Number.isFinite(probe.expiresAtMs) && probe.expiresAtMs <= Date.now()) return null;
|
||
return Array.isArray(probe.toolsObserved) ? probe.toolsObserved : null;
|
||
}
|
||
|
||
function cachedCommandProbe({ command, workspace, codexHome, probe }) {
|
||
if (!probe || probe.ready !== true) return null;
|
||
if (probe.command !== command || probe.workspace !== workspace || probe.codexHome !== codexHome) return null;
|
||
if (Number.isFinite(probe.expiresAtMs) && probe.expiresAtMs <= Date.now()) return null;
|
||
return probe;
|
||
}
|
||
|
||
function protocolProbeSummary({ command, workspace, codexHome, probe }) {
|
||
if (!probe || probe.command !== command || probe.workspace !== workspace || probe.codexHome !== codexHome) {
|
||
return {
|
||
status: "not_run",
|
||
ready: false,
|
||
toolsObserved: [],
|
||
secretMaterialRead: false,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
return {
|
||
status: probe.status,
|
||
ready: probe.ready === true,
|
||
toolsObserved: Array.isArray(probe.toolsObserved) ? [...probe.toolsObserved] : [],
|
||
startedAt: probe.startedAt,
|
||
finishedAt: probe.finishedAt,
|
||
error: probe.error ?? null,
|
||
secretMaterialRead: false,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function commandProbeSummary({ command, workspace, codexHome, probe }) {
|
||
if (!probe || probe.command !== command || probe.workspace !== workspace || probe.codexHome !== codexHome) {
|
||
return {
|
||
status: "not_run",
|
||
ready: false,
|
||
probe: "workspace.pwd",
|
||
toolCalls: [],
|
||
secretMaterialRead: false,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
return {
|
||
status: probe.status,
|
||
ready: probe.ready === true,
|
||
probe: probe.probe ?? "workspace.pwd",
|
||
conversationId: probe.conversationId ?? null,
|
||
traceId: probe.traceId ?? null,
|
||
toolCalls: Array.isArray(probe.toolCalls) ? [...probe.toolCalls] : [],
|
||
startedAt: probe.startedAt,
|
||
finishedAt: probe.finishedAt,
|
||
error: probe.error ?? null,
|
||
timeoutMs: probe.timeoutMs ?? CODEX_STDIO_COMMAND_PROBE_TIMEOUT_MS,
|
||
secretMaterialRead: false,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function lifecycleState({ supervisor, idleTimeoutMs, maxSessions, activeSessions }) {
|
||
const ready = supervisor.configured === true;
|
||
return {
|
||
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
|
||
status: ready ? "present" : "blocked",
|
||
present: ready,
|
||
create: ready,
|
||
reuse: ready,
|
||
cancel: ready,
|
||
reap: ready,
|
||
idleTimeout: ready,
|
||
idleTimeoutMs,
|
||
traceCapture: ready,
|
||
maxSessions,
|
||
activeSessions,
|
||
inMemoryIndex: ready,
|
||
codexThreadIdCaptured: ready,
|
||
blocker: ready ? null : "runner_lifecycle_missing",
|
||
summary: ready
|
||
? "Repo-owned supervisor contract covers create/reuse/cancel/reap/idle timeout/trace capture."
|
||
: "Repo-owned lifecycle supervisor is missing; long-lived Code Agent session gate must remain blocked.",
|
||
secretMaterialStored: false,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function workspaceContractState(workspaceInfo, sandbox, workspace) {
|
||
const ready = workspaceInfo.exists && workspaceInfo.readable && (sandbox !== "workspace-write" || workspaceInfo.writable === true);
|
||
return {
|
||
path: workspace,
|
||
status: ready ? "ready" : "blocked",
|
||
mounted: workspaceInfo.exists,
|
||
readable: workspaceInfo.readable,
|
||
writable: workspaceInfo.writable,
|
||
sandbox,
|
||
writeRequired: sandbox === "workspace-write",
|
||
blocker: ready ? null : "workspace_mount_missing",
|
||
secretMaterialRead: false,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function cancelReapTraceState(lifecycle) {
|
||
const ready = lifecycle.cancel === true && lifecycle.reap === true && lifecycle.traceCapture === true;
|
||
return {
|
||
status: ready ? "ready" : "blocked",
|
||
cancel: lifecycle.cancel === true,
|
||
reap: lifecycle.reap === true,
|
||
traceCapture: lifecycle.traceCapture === true,
|
||
idleTimeout: lifecycle.idleTimeout === true,
|
||
blocker: ready ? null : "runner_lifecycle_missing"
|
||
};
|
||
}
|
||
|
||
function runtimeContract({
|
||
ready,
|
||
binary,
|
||
protocol,
|
||
lifecycle,
|
||
workspaceInfo,
|
||
workspace,
|
||
sandbox,
|
||
codexHome,
|
||
codexHomeInfo,
|
||
tokenBoundary,
|
||
egress,
|
||
commandProbe
|
||
}) {
|
||
return {
|
||
contractVersion: "codex-runtime-feasibility-v1",
|
||
status: ready ? "ready" : "blocked",
|
||
ready,
|
||
binary: {
|
||
status: binary.present && binary.executable && binary.nativeDependencyPresent ? "present" : binary.present ? "blocked" : "missing",
|
||
command: binary.command,
|
||
present: binary.present,
|
||
executable: binary.executable === true,
|
||
nativeDependencyPresent: binary.nativeDependencyPresent === true,
|
||
version: binary.version,
|
||
versionDetected: binary.versionDetected,
|
||
nextEvidence: binary.nextEvidence
|
||
},
|
||
stdioProtocol: {
|
||
status: protocol.status,
|
||
wired: protocol.wired,
|
||
command: protocol.command,
|
||
requiredTools: [...protocol.requiredTools],
|
||
toolsObserved: [...protocol.toolsObserved],
|
||
missingTools: [...protocol.missingTools],
|
||
nextEvidence: protocol.nextEvidence
|
||
},
|
||
commandProbe: commandProbe ?? {
|
||
status: "not_run",
|
||
ready: false,
|
||
probe: "workspace.pwd",
|
||
toolCalls: [],
|
||
secretMaterialRead: false,
|
||
valuesRedacted: true
|
||
},
|
||
lifecycleSupervisor: {
|
||
status: lifecycle.status,
|
||
present: lifecycle.present,
|
||
create: lifecycle.create,
|
||
reuse: lifecycle.reuse,
|
||
cancel: lifecycle.cancel,
|
||
reap: lifecycle.reap,
|
||
traceCapture: lifecycle.traceCapture,
|
||
idleTimeoutMs: lifecycle.idleTimeoutMs
|
||
},
|
||
workspaceMount: workspaceContractState(workspaceInfo, sandbox, workspace),
|
||
codexHome: codexHomeContractState(codexHomeInfo, codexHome),
|
||
sandbox,
|
||
tokenBoundary: {
|
||
status: tokenBoundary.present ? "present" : "blocked",
|
||
present: tokenBoundary.present,
|
||
sources: tokenBoundary.sources,
|
||
secretMaterialRead: false,
|
||
valuesRedacted: true
|
||
},
|
||
egress: {
|
||
status: egress.directPublicOpenAi ? "blocked" : egress.configured ? "configured" : "not_configured",
|
||
configured: egress.configured,
|
||
directPublicOpenAi: egress.directPublicOpenAi,
|
||
valueRedacted: true
|
||
},
|
||
cancelReapTraceReadiness: cancelReapTraceState(lifecycle),
|
||
secretBoundary: {
|
||
secretsRead: false,
|
||
secretValuesPrinted: false,
|
||
kubeconfigRead: false,
|
||
valuesRedacted: true
|
||
}
|
||
};
|
||
}
|
||
|
||
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(),
|
||
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 } : {}),
|
||
...(env.OPENAI_BASE_URL ? { OPENAI_BASE_URL: env.OPENAI_BASE_URL } : {})
|
||
};
|
||
}
|
||
|
||
function codexStdioSafety() {
|
||
return {
|
||
secretsRead: false,
|
||
secretValuesPrinted: false,
|
||
kubeconfigRead: false,
|
||
prodTouched: false,
|
||
hardwareWritesAllowed: false,
|
||
directGatewayCallsAllowed: false,
|
||
directBoxSimuCallsAllowed: false,
|
||
directPatchPanelCallsAllowed: false,
|
||
hardwareControlPath: "cloud-api/HWLAB API/skill CLI only",
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function codexStdioError(code, message, details = {}) {
|
||
const error = new Error(message);
|
||
error.code = code;
|
||
Object.assign(error, {
|
||
provider: CODEX_STDIO_PROVIDER,
|
||
backend: CODEX_STDIO_BACKEND,
|
||
capabilityLevel: "blocked",
|
||
...details
|
||
});
|
||
return error;
|
||
}
|
||
|
||
function sessionExpired(session, timestampMs) {
|
||
if (!Number.isFinite(timestampMs) || session.status === "expired") return session.status === "expired";
|
||
return Date.parse(session.expiresAt) <= timestampMs;
|
||
}
|
||
|
||
function timestampFor(now) {
|
||
const value = typeof now === "function" ? now() : now;
|
||
if (typeof value === "string" && !Number.isNaN(Date.parse(value))) return value;
|
||
if (value instanceof Date && !Number.isNaN(value.valueOf())) return value.toISOString();
|
||
return new Date().toISOString();
|
||
}
|
||
|
||
function plusMs(timestampMs, offsetMs) {
|
||
return new Date(timestampMs + offsetMs).toISOString();
|
||
}
|
||
|
||
function requiredId(value, fallbackPrefix) {
|
||
const id = optionalId(value);
|
||
if (id) return id;
|
||
return `${fallbackPrefix}_${randomUUID()}`;
|
||
}
|
||
|
||
function optionalId(value) {
|
||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||
}
|
||
|
||
function positiveInteger(value, fallback) {
|
||
return Number.isInteger(value) && value > 0 ? value : fallback;
|
||
}
|
||
|
||
function firstNonEmpty(...values) {
|
||
for (const value of values) {
|
||
if (typeof value === "string" && value.trim()) return value.trim();
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function hasEnvValue(env, name) {
|
||
return typeof env?.[name] === "string" && env[name].trim().length > 0;
|
||
}
|
||
|
||
function effectiveTimeout(timeoutMs) {
|
||
return Number.isInteger(timeoutMs) && timeoutMs > 0 ? timeoutMs : 150000;
|
||
}
|
||
|
||
function dropEmpty(value) {
|
||
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== null && item !== ""));
|
||
}
|
||
|
||
function parseJsonOrNull(value) {
|
||
try {
|
||
return JSON.parse(value);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function tailText(value, maxLength = 1200) {
|
||
const text = String(value ?? "").trim();
|
||
if (text.length <= maxLength) return text;
|
||
return text.slice(text.length - maxLength);
|
||
}
|
||
|
||
function boundToolOutput(value, maxLength = CODEX_STDIO_TOOL_OUTPUT_LIMIT) {
|
||
const text = String(value ?? "");
|
||
if (text.length <= maxLength) return { text, truncated: false };
|
||
return {
|
||
text: `${text.slice(0, maxLength)}\n[output truncated at ${maxLength} bytes]`,
|
||
truncated: true
|
||
};
|
||
}
|
||
|
||
function redactText(value) {
|
||
return String(value ?? "")
|
||
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gu, "Bearer ***")
|
||
.replace(/sk-[A-Za-z0-9._-]+/gu, "sk-***")
|
||
.replace(/([A-Za-z0-9_]*TOKEN[A-Za-z0-9_]*=)[^\s]+/giu, "$1***")
|
||
.replace(/([A-Za-z0-9_]*KEY[A-Za-z0-9_]*=)[^\s]+/giu, "$1***");
|
||
}
|