3771 lines
136 KiB
JavaScript
3771 lines
136 KiB
JavaScript
import { spawn, spawnSync } from "node:child_process";
|
||
import { randomUUID } from "node:crypto";
|
||
import { accessSync, constants as fsConstants, existsSync, realpathSync, statSync } 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";
|
||
|
||
import {
|
||
createCodeAgentTraceRecorder,
|
||
defaultCodeAgentTraceStore,
|
||
runnerTraceFromSnapshot
|
||
} from "./code-agent-trace-store.mjs";
|
||
import {
|
||
CODE_AGENT_SESSION_LIFECYCLE_STATUSES,
|
||
CODE_AGENT_SESSION_STATUS_ALIASES,
|
||
codeAgentSessionLifecycleSummary,
|
||
decorateCodeAgentSession
|
||
} from "./code-agent-session-lifecycle.mjs";
|
||
|
||
export const CODEX_STDIO_PROVIDER = "codex-stdio";
|
||
export const CODEX_STDIO_BACKEND = "hwlab-cloud-api/codex-app-server-stdio";
|
||
export const CODEX_STDIO_RUNNER_KIND = "codex-app-server-stdio-runner";
|
||
export const CODEX_STDIO_SESSION_MODE = "codex-app-server-stdio-long-lived";
|
||
export const CODEX_STDIO_IMPLEMENTATION_TYPE = "repo-owned-codex-app-server-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 DEFAULT_CODEX_STDIO_TURN_ACTIVITY_TIMEOUT_MS = 10 * 60 * 1000;
|
||
const DEFAULT_CODEX_STDIO_TURN_HARD_TIMEOUT_MS = 10 * 60 * 1000;
|
||
export const CODEX_STDIO_SESSION_STATUSES = Object.freeze([
|
||
"creating",
|
||
"ready",
|
||
"busy",
|
||
"idle",
|
||
"timeout",
|
||
"error",
|
||
"canceled",
|
||
"interrupted",
|
||
"expired",
|
||
"failed"
|
||
]);
|
||
|
||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||
const CODEX_APP_SERVER_PROTOCOL = "codex-app-server-jsonrpc-stdio";
|
||
const CODEX_APP_SERVER_WIRE_API = "responses";
|
||
const CODEX_APP_SERVER_REQUIRED_METHODS = Object.freeze(["initialize", "thread/start", "thread/resume", "turn/start"]);
|
||
const CODEX_STDIO_TOOL_OUTPUT_LIMIT = 4000;
|
||
const CODEX_STDIO_SKILL_LIMIT = 40;
|
||
const CODEX_STDIO_SKILL_REPLY_LIMIT = 20;
|
||
const CODEX_STDIO_SKILL_SUMMARY_LIMIT = 180;
|
||
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_FIRST_TOKEN_PROGRESS_MS = 15 * 1000;
|
||
const CODEX_CHILD_STRIPPED_ENV_KEYS = Object.freeze([
|
||
"OPENAI_API_KEY",
|
||
"CODEX_API_KEY",
|
||
"HWLAB_CODE_AGENT_OPENAI_BASE_URL",
|
||
"OPENAI_BASE_URL",
|
||
"OPENAI_API_BASE",
|
||
"OPENAI_RESPONSES_URL",
|
||
"CODEX_OPENAI_BASE_URL",
|
||
"CODEX_BASE_URL",
|
||
"HTTP_PROXY",
|
||
"HTTPS_PROXY",
|
||
"ALL_PROXY",
|
||
"http_proxy",
|
||
"https_proxy",
|
||
"all_proxy"
|
||
]);
|
||
const CODEX_CHILD_NO_PROXY_REQUIRED = Object.freeze([
|
||
"hyueapi.com",
|
||
".hyueapi.com",
|
||
"hyui.com",
|
||
".hyui.com",
|
||
"127.0.0.1",
|
||
"localhost",
|
||
"::1",
|
||
"api.minimaxi.com",
|
||
".minimaxi.com"
|
||
]);
|
||
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.",
|
||
"For hardware, gateway, box-simu, patch-panel, DAP, PWM, Keil, serial, and Windows skill requests, execute the requested work through the repo-owned Codex stdio session with the available tran wrapper, skill CLI, or project tool that actually reaches the target.",
|
||
"For any request that mentions device-pod, device-pod-cli, device-host-cli, a device-pod profile, device-pod-71-freq, device-pod-71-00075-11, or a registered device-pod Keil build/download/UART/debug-probe operation, first use the HWLAB internal skill named device-pod-cli. The canonical skill location is /app/skills/device-pod-cli/SKILL.md; read that manifest and run node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs for the operation.",
|
||
"When a matching device-pod profile exists, do not bypass device-pod-cli with direct hwlab-gateway-tran.mjs, Windows Keil skills, serial-monitor skills, keil-cli.py, or ad hoc path discovery. Only drop to tran or Windows-side skills when the device-pod-cli skill explicitly says to bootstrap, install, or repair the lower layer.",
|
||
"For registered PC gateway Windows command or skill requests, use the preloaded tran wrapper: node /app/tools/hwlab-gateway-tran.mjs gws_DESKTOP-1MHOD9I:/f/work <cmd|ps|upload|download> [options] -- <args>. The locator before ':' is the gateway session and the path after ':' is the Windows workspace, with /f/work and f:/work both mapping to F:\\work.",
|
||
"For Windows cmd, call tran as: node /app/tools/hwlab-gateway-tran.mjs gws_DESKTOP-1MHOD9I:/f/work cmd -- <command>. For PowerShell, call tran as: node /app/tools/hwlab-gateway-tran.mjs gws_DESKTOP-1MHOD9I:/f/work ps -- <script>. The wrapper owns UTF-8, quoting, cwd, EncodedCommand, and stdout/stderr passthrough; do not hand-build nested cmd /c, cd &&, pipes, or quoting workarounds.",
|
||
"For reliable Windows file transfer, use tran upload/download instead of pasting large base64 through chat or shell output. Prefer the canonical tool workspace gws_DESKTOP-1MHOD9I:f:/work/hwlab/.tmp when updating PC-side helper files.",
|
||
"If /app/tools/hwlab-gateway-tran.mjs itself is awkward, unreliable, or causes quoting, Chinese text, encoding, cwd, stdout/stderr, upload, or download friction, improve /app/tools/hwlab-gateway-tran.mjs first, then update the gateway-side copy at gws_DESKTOP-1MHOD9I:f:/work/hwlab/.tmp before continuing the user task.",
|
||
"For Windows-side skills under C:\\Users\\liang\\.agents\\skills\\, read the skill manifest when needed and call its CLI through hwlab-gateway-tran.mjs using explicit paths or argument arrays. Do not reimplement skill logic.",
|
||
"For F:\\work or F:\\work\\ConStart discovery, first list top-level project markers and *.uvprojx candidates with bounded output. For Keil build/program requests, use the Windows skill CLI at C:\\Users\\liang\\.agents\\skills\\keil with py -3 keil-cli.py through hwlab-gateway-tran.mjs; do not reimplement Keil build logic.",
|
||
"For boot logs and UART capture, use the Windows skill CLI at C:\\Users\\liang\\.agents\\skills\\serial-monitor with npm run cli -- server status/start, monitor start, and fetch through hwlab-gateway-tran.mjs; for 71-FREQ prefer the skill-documented baud rate unless live evidence says otherwise.",
|
||
"For long Keil build/program/download jobs, prefer the skill CLI async job flow: start the job with a short bounded wrapper call, then poll job-status, state files, or logs with short wrapper calls. Do not use gateway --wait long polling unless the user explicitly asks for synchronous waiting and sets a sufficient gateway timeout.",
|
||
"If a Windows gateway command reaches the gateway but fails due script syntax or output size, simplify once and report the failed operationId plus the corrected bounded command evidence. Do not spend multiple turns on exploratory rewrites.",
|
||
"Use the Windows-side skill CLIs under C:\\Users\\liang\\.agents\\skills\\ from that cmd command when they exist; do not reimplement those tools in the prompt.",
|
||
"Do not satisfy PC gateway requests by string matching, assistant-only claims, internal shortcut dispatch, or direct gateway URLs; the Codex turn must run the wrapper and report command evidence.",
|
||
"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 traceStore = options.traceStore ?? defaultCodeAgentTraceStore;
|
||
const sessions = new Map();
|
||
const conversations = new Map();
|
||
let rpcClient = options.rpcClient ?? null;
|
||
let rpcClientSignature = options.rpcClient ? "injected" : null;
|
||
let rpcStartedAt = null;
|
||
let rpcInitialized = false;
|
||
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 binary = codexBinaryState(command, env);
|
||
const binaryOnPath = binary.present;
|
||
const workspaceInfo = workspaceStateSync(workspace, sandbox);
|
||
const codexHome = resolveCodexHome(env);
|
||
const codexHomeInfo = directoryStateSync(codexHome, { writableRequired: true });
|
||
const codexHomeFiles = codexHomeFilesStateSync(codexHome);
|
||
const tokenBoundary = tokenBoundaryState(env, codexHomeFiles);
|
||
const egress = egressState(env);
|
||
const childEnvBoundary = childProcessEnvState(env);
|
||
const protocol = protocolState({
|
||
command,
|
||
binary,
|
||
supervisor,
|
||
initialized: params.protocolInitialized ?? (rpcInitialized || cachedProtocolReady({ 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 (codexHomeFiles.configPresent !== true || codexHomeFiles.configReadable !== true) {
|
||
blockers.push({
|
||
code: "codex_home_config_missing",
|
||
sourceIssue: "pikasTech/HWLAB#426",
|
||
summary: `Configured CODEX_HOME does not expose readable Codex config.toml: ${codexHomeFiles.configPath}.`
|
||
});
|
||
}
|
||
if (codexHomeFiles.authPresent !== true || codexHomeFiles.authReadable !== true) {
|
||
blockers.push({
|
||
code: "codex_home_auth_missing",
|
||
sourceIssue: "pikasTech/HWLAB#426",
|
||
summary: `Configured CODEX_HOME does not expose readable Codex auth.json: ${codexHomeFiles.authPath}.`
|
||
});
|
||
}
|
||
if (!tokenBoundary.present) {
|
||
blockers.push({
|
||
code: "provider_token_boundary",
|
||
sourceIssue: "pikasTech/HWLAB#426",
|
||
summary: "A long-lived Codex app-server runner needs a repo-owned /codex-home auth boundary; this endpoint only reports file presence and never reads or prints token material."
|
||
});
|
||
}
|
||
if (childEnvBoundary.forbiddenEnvPresent) {
|
||
blockers.push({
|
||
code: "codex_child_env_polluted",
|
||
sourceIssue: "pikasTech/HWLAB#426",
|
||
summary: "Codex child process environment still contains provider/proxy env keys that must be stripped before app-server startup."
|
||
});
|
||
}
|
||
if (egress.directPublicOpenAi) {
|
||
blockers.push({
|
||
code: "codex_stdio_egress_boundary",
|
||
sourceIssue: "pikasTech/HWLAB#426",
|
||
summary: "Codex app-server provider egress must use /codex-home config/auth and DEV egress boundary, not direct public api.openai.com env fallback."
|
||
});
|
||
}
|
||
|
||
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,
|
||
codexHomeFiles,
|
||
sandbox,
|
||
enabled,
|
||
supervisor,
|
||
tokenBoundary,
|
||
egress,
|
||
childEnvBoundary,
|
||
protocol,
|
||
stdioProtocol: protocol,
|
||
protocolProbe: protocolProbeSummary({ command, workspace, codexHome, probe: protocolProbe }),
|
||
commandProbe: commandProbeState,
|
||
sessionLifecycle: lifecycle,
|
||
lifecycleStatuses: [...CODE_AGENT_SESSION_LIFECYCLE_STATUSES],
|
||
statusAliases: { ...CODE_AGENT_SESSION_STATUS_ALIASES },
|
||
lifecycleSupervisor: lifecycle,
|
||
recentSessions: recentSessions(),
|
||
statusCounts: sessionStatusCounts(),
|
||
workspaceMount: workspaceContractState(workspaceInfo, sandbox, workspace),
|
||
cancelReapTraceReadiness: cancelReapTraceState(lifecycle),
|
||
runtimeContract: runtimeContract({
|
||
ready,
|
||
binary,
|
||
protocol,
|
||
lifecycle,
|
||
workspaceInfo,
|
||
workspace,
|
||
sandbox,
|
||
codexHome,
|
||
codexHomeInfo,
|
||
codexHomeFiles,
|
||
tokenBoundary,
|
||
egress,
|
||
childEnvBoundary,
|
||
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);
|
||
const traceRecorder = params.traceRecorder ?? createCodeAgentTraceRecorder({
|
||
traceStore,
|
||
traceId,
|
||
now,
|
||
runnerKind: CODEX_STDIO_RUNNER_KIND,
|
||
workspace,
|
||
sandbox,
|
||
sessionMode: CODEX_STDIO_SESSION_MODE,
|
||
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE
|
||
});
|
||
traceRecorder.append({
|
||
type: "request",
|
||
status: "received",
|
||
label: "request:received",
|
||
promptSummary: summarizePrompt(params.message),
|
||
waitingFor: "runtime-readiness"
|
||
});
|
||
let availability = describe({ ...params, env, workspace, sandbox });
|
||
const startupBlockers = availability.blockers.filter((blocker) => blocker.code !== "stdio_protocol_not_wired");
|
||
if (startupBlockers.length > 0) {
|
||
traceRecorder.append({
|
||
type: "error",
|
||
status: "blocked",
|
||
label: "runtime-readiness:blocked",
|
||
errorCode: startupBlockers[0]?.code ?? "codex_stdio_blocked",
|
||
message: startupBlockers[0]?.summary ?? "Codex stdio session is blocked by runtime readiness gates.",
|
||
terminal: true
|
||
});
|
||
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) {
|
||
traceRecorder.append({
|
||
type: "session",
|
||
status: "blocked",
|
||
label: `session:${acquire.code}`,
|
||
errorCode: acquire.code,
|
||
message: acquire.message,
|
||
sessionId: acquire.session?.sessionId,
|
||
sessionStatus: acquire.session?.status,
|
||
terminal: true
|
||
});
|
||
throw codexStdioError(acquire.code, acquire.message, {
|
||
availability,
|
||
session: acquire.session,
|
||
blockers: [acquire.blocker]
|
||
});
|
||
}
|
||
|
||
let session = acquire.session;
|
||
const startedAt = timestamp;
|
||
traceRecorder.append({
|
||
type: "session",
|
||
status: session.reused ? "reused" : "created",
|
||
label: session.reused ? "session:reused" : "session:created",
|
||
sessionId: session.sessionId,
|
||
sessionStatus: session.status,
|
||
sessionReused: session.reused,
|
||
turn: session.turn,
|
||
waitingFor: "stdio-client"
|
||
});
|
||
|
||
try {
|
||
const client = await ensureRpcClient({ env, availability, timeoutMs: params.timeoutMs });
|
||
availability = describe({ ...params, env, workspace, sandbox });
|
||
traceRecorder.append({
|
||
type: "stdio",
|
||
status: "ready",
|
||
label: "stdio:ready",
|
||
sessionId: session.sessionId,
|
||
sessionStatus: session.status,
|
||
turn: session.turn,
|
||
waitingFor: "prompt-send"
|
||
});
|
||
const sidecar = await collectWorkspaceSidecarEvidence({
|
||
message: params.message,
|
||
workspace,
|
||
traceId,
|
||
env,
|
||
now
|
||
});
|
||
for (const event of sidecar.events) traceRecorder.append(event);
|
||
if (sidecar.intent?.skills && sidecar.skills?.status !== "ready") {
|
||
session = releaseSession(session.sessionId, {
|
||
now,
|
||
traceId,
|
||
conversationId,
|
||
reused: session.reused,
|
||
status: "idle"
|
||
}) ?? session;
|
||
traceRecorder.append({
|
||
type: "error",
|
||
status: "blocked",
|
||
label: "skills.discover:blocked",
|
||
toolName: "skills.discover",
|
||
errorCode: "skills_unavailable",
|
||
message: "No readable SKILL.md files were found in configured skills directories; skills discovery is blocked before any generic model answer.",
|
||
sessionId: session.sessionId,
|
||
sessionStatus: session.status,
|
||
turn: session.turn,
|
||
waitingFor: "skills-manifest",
|
||
terminal: true
|
||
});
|
||
throw codexStdioError("skills_unavailable", "Code Agent Skill 清单未挂载或不可读;已通过 Codex stdio runner 建立 trace,但不能编造 skills 列表。", {
|
||
availability,
|
||
session,
|
||
toolCalls: sidecar.toolCalls,
|
||
skills: sidecar.skills,
|
||
blockers: sidecar.skills.blockers,
|
||
runnerTrace: runnerTrace({
|
||
traceRecorder,
|
||
traceId,
|
||
workspace,
|
||
sandbox,
|
||
session,
|
||
startedAt,
|
||
outputTruncated: sidecar.outputTruncated
|
||
}),
|
||
preserveSessionStatus: true,
|
||
retryable: false,
|
||
userMessage: "技能发现受阻:运行时没有返回可读取的 SKILL.md 清单;本次不会用泛化文本编造可用 skill。请关联 #136/#237 补齐 skills 挂载或 manifest。",
|
||
route: "/v1/agent/chat",
|
||
toolName: "skills.discover",
|
||
sessionMode: CODEX_STDIO_SESSION_MODE,
|
||
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
|
||
runnerLimitations: ["skills-manifest-unavailable", "no-text-fallback-for-skills-discovery"]
|
||
});
|
||
}
|
||
const prompt = buildCodexUserPrompt(params.message, {
|
||
conversationId,
|
||
traceId,
|
||
conversationFacts: params.conversationFacts,
|
||
sidecar,
|
||
gatewayShellTimeoutMs: normalizeGatewayShellTimeoutMs(params.gatewayShellTimeoutMs, env)
|
||
});
|
||
const toolName = session.threadId ? "codex-app-server.thread/resume+turn/start" : "codex-app-server.thread/start+turn/start";
|
||
traceRecorder.append({
|
||
type: "prompt",
|
||
status: "sent",
|
||
label: "prompt:sent",
|
||
toolName,
|
||
promptSummary: summarizePrompt(params.message),
|
||
sessionId: session.sessionId,
|
||
sessionStatus: session.status,
|
||
turn: session.turn,
|
||
waitingFor: "app-server-thread"
|
||
});
|
||
traceRecorder.append({
|
||
type: "tool_call",
|
||
status: "started",
|
||
label: `tool:${toolName}:started`,
|
||
toolName,
|
||
sessionId: session.sessionId,
|
||
sessionStatus: session.status,
|
||
turn: session.turn,
|
||
waitingFor: "app-server-turn"
|
||
});
|
||
const turnResult = await runAppServerTurn({
|
||
client,
|
||
session,
|
||
prompt,
|
||
workspace,
|
||
sandbox,
|
||
model: firstNonEmpty(params.model, env.HWLAB_CODE_AGENT_MODEL, env.OPENAI_MODEL, "codex-default"),
|
||
traceRecorder,
|
||
timeoutMs: effectiveActivityTimeout(params.timeoutMs),
|
||
hardTimeoutMs: effectiveHardTimeout(params.hardTimeoutMs, params.timeoutMs)
|
||
});
|
||
traceRecorder.append({
|
||
type: "tool_call",
|
||
status: "output_chunk",
|
||
label: `tool:${toolName}:output_chunk`,
|
||
toolName,
|
||
outputSummary: summarizeAppServerTurn(turnResult),
|
||
sessionId: session.sessionId,
|
||
sessionStatus: session.status,
|
||
turn: session.turn,
|
||
waitingFor: "assistant-message"
|
||
});
|
||
if (turnResult.terminalStatus !== "completed") {
|
||
const providerTrace = codexStdioProviderTrace({ availability, toolName, turnResult, session });
|
||
throw codexStdioError("codex_stdio_failed", redactText(turnResult.terminalError || `Codex app-server turn finished with status=${turnResult.terminalStatus || "unknown"}.`), {
|
||
availability,
|
||
session,
|
||
terminalStatus: turnResult.terminalStatus,
|
||
threadId: turnResult.threadId,
|
||
turnId: turnResult.turnId,
|
||
appServerExit: turnResult.appServerExit,
|
||
idleMs: turnResult.idleMs,
|
||
lastActivityAt: turnResult.lastActivityAt,
|
||
waitingFor: turnResult.waitingFor,
|
||
providerTrace
|
||
});
|
||
}
|
||
const assistantContent = redactText(firstNonEmpty(turnResult.finalResponse, turnResult.assistantText));
|
||
if (!assistantContent) {
|
||
throw codexStdioError("codex_stdio_empty_response", "Codex app-server turn completed without assistant content.", {
|
||
availability,
|
||
session,
|
||
terminalStatus: turnResult.terminalStatus,
|
||
threadId: turnResult.threadId,
|
||
turnId: turnResult.turnId
|
||
});
|
||
}
|
||
session = releaseSession(session.sessionId, {
|
||
now,
|
||
traceId,
|
||
conversationId,
|
||
reused: session.reused,
|
||
threadId: turnResult.threadId ?? session.threadId,
|
||
status: "idle"
|
||
}) ?? session;
|
||
const codexToolCall = {
|
||
id: `tool_${randomUUID()}`,
|
||
type: "codex-app-server-stdio",
|
||
name: toolName,
|
||
status: "completed",
|
||
cwd: workspace,
|
||
command: "codex app-server --listen stdio://",
|
||
exitCode: 0,
|
||
stdout: [
|
||
turnResult.threadId ? `threadId=${turnResult.threadId}` : "threadId=captured",
|
||
turnResult.turnId ? `turnId=${turnResult.turnId}` : null,
|
||
`terminalStatus=${turnResult.terminalStatus}`
|
||
].filter(Boolean).join(" "),
|
||
stderrSummary: "",
|
||
outputTruncated: false,
|
||
traceId
|
||
};
|
||
const toolCalls = [codexToolCall, ...sidecar.toolCalls];
|
||
traceRecorder.append({
|
||
type: "tool_call",
|
||
status: "completed",
|
||
label: `tool:${toolName}:completed`,
|
||
toolName,
|
||
outputSummary: turnResult.threadId ? `threadId=${turnResult.threadId} turnId=${turnResult.turnId ?? "unknown"}` : "threadId=captured",
|
||
sessionId: session.sessionId,
|
||
sessionStatus: session.status,
|
||
turn: session.turn
|
||
});
|
||
traceRecorder.appendAssistantDelta?.({
|
||
itemId: turnResult.turnId ?? "assistant-message",
|
||
chunk: assistantContent,
|
||
sessionId: session.sessionId,
|
||
sessionStatus: session.status,
|
||
turn: session.turn,
|
||
waitingFor: "assistant-message-complete"
|
||
});
|
||
traceRecorder.append({
|
||
type: "assistant_message",
|
||
status: "completed",
|
||
label: "assistant:completed",
|
||
sessionId: session.sessionId,
|
||
sessionStatus: session.status,
|
||
turn: session.turn,
|
||
terminal: true
|
||
});
|
||
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(assistantContent, sidecar),
|
||
workspace,
|
||
sandbox,
|
||
session,
|
||
sessionMode: CODEX_STDIO_SESSION_MODE,
|
||
sessionReuse: sessionReuseEvidence(session),
|
||
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
|
||
runnerLimitations: ["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,
|
||
skills: sidecar.skills,
|
||
runner: runnerDescriptor({ workspace, sandbox, session }),
|
||
runnerTrace: runnerTrace({
|
||
traceRecorder,
|
||
traceId,
|
||
workspace,
|
||
sandbox,
|
||
session,
|
||
startedAt,
|
||
outputTruncated: sidecar.outputTruncated
|
||
}),
|
||
capabilityLevel: CODEX_STDIO_CAPABILITY_LEVEL,
|
||
providerTrace: codexStdioProviderTrace({ availability, toolName, turnResult, session })
|
||
};
|
||
} catch (error) {
|
||
closeRpcClient();
|
||
const timeout = /timed out|timeout|超时/iu.test(String(error.message ?? ""));
|
||
const currentSession = sessions.get(session.sessionId) ?? null;
|
||
const canceled = currentSession?.status === "canceled";
|
||
const preserveSessionStatus = error.preserveSessionStatus === true && error.session?.status && error.session.status !== "busy";
|
||
session = canceled
|
||
? publicSession(currentSession, { conversationId, reused: session.reused })
|
||
: preserveSessionStatus
|
||
? error.session
|
||
: failSession(session.sessionId, {
|
||
now,
|
||
traceId,
|
||
conversationId,
|
||
reused: session.reused,
|
||
status: timeout ? "timeout" : "failed",
|
||
statusReason: timeout ? "codex_stdio_timeout" : error.code ?? "codex_stdio_failed"
|
||
}) ?? session;
|
||
traceRecorder.append({
|
||
type: canceled ? "cancel" : timeout ? "timeout" : "error",
|
||
status: canceled ? "canceled" : "failed",
|
||
label: canceled ? "cancel:canceled" : timeout ? "timeout" : `error:${error.code ?? "codex_stdio_failed"}`,
|
||
errorCode: canceled ? "codex_stdio_canceled" : error.code ?? (timeout ? "codex_stdio_timeout" : "codex_stdio_failed"),
|
||
message: canceled ? "Codex stdio request was canceled by the user." : error.message || "Codex stdio session failed.",
|
||
timeoutMs: timeout ? error.timeoutMs ?? effectiveActivityTimeout(params.timeoutMs) : undefined,
|
||
idleMs: timeout ? error.idleMs : undefined,
|
||
lastActivityAt: timeout ? error.lastActivityAt : undefined,
|
||
hardTimeoutMs: timeout ? error.hardTimeoutMs : undefined,
|
||
sessionId: session.sessionId,
|
||
sessionStatus: session.status,
|
||
turn: session.turn,
|
||
waitingFor: canceled ? "user-retry" : timeout ? error.waitingFor ?? "codex-stdio-tool-response" : null,
|
||
terminal: true
|
||
});
|
||
if (canceled) {
|
||
throw codexStdioError("codex_stdio_canceled", "Codex stdio request was canceled by the user.", {
|
||
availability,
|
||
session,
|
||
runnerTrace: runnerTrace({
|
||
traceRecorder,
|
||
traceId,
|
||
workspace,
|
||
sandbox,
|
||
session,
|
||
startedAt,
|
||
outputTruncated: false
|
||
})
|
||
});
|
||
}
|
||
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({
|
||
traceRecorder,
|
||
traceId,
|
||
workspace,
|
||
sandbox,
|
||
session,
|
||
startedAt,
|
||
outputTruncated: false
|
||
});
|
||
throw error;
|
||
}
|
||
throw codexStdioError("codex_stdio_failed", redactText(error.message || "Codex stdio session failed."), {
|
||
availability,
|
||
session,
|
||
runnerTrace: runnerTrace({
|
||
traceRecorder,
|
||
traceId,
|
||
workspace,
|
||
sandbox,
|
||
session,
|
||
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 && rpcInitialized === true) {
|
||
availability = describe({ ...params, env, workspace, command, sandbox, protocolInitialized: true });
|
||
return ensureCommandProbeReady({
|
||
env,
|
||
workspace,
|
||
command,
|
||
sandbox,
|
||
codexHome,
|
||
availability,
|
||
now: params.now ?? nowDefault,
|
||
probeTimeoutMs: params.commandProbeTimeoutMs
|
||
});
|
||
}
|
||
if (!params.forceProbe && cachedProtocolReady({ command, workspace, codexHome, probe: protocolProbe })) {
|
||
availability = describe({ ...params, env, workspace, command, sandbox, protocolInitialized: true });
|
||
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 {
|
||
if (options.createProbeRpcClient) {
|
||
probeClient = await options.createProbeRpcClient({ env, availability });
|
||
} else if (options.createRpcClient) {
|
||
probeClient = await options.createRpcClient({ env, availability, probe: true });
|
||
} else {
|
||
probeClient = createCodexAppServerJsonLineClient({
|
||
command: availability.command,
|
||
args: codexAppServerArgs(env),
|
||
env: childProcessEnv(env),
|
||
cwd: availability.workspace
|
||
});
|
||
}
|
||
const timeoutMs = positiveInteger(params.probeTimeoutMs, 10000);
|
||
if (typeof probeClient.initialize === "function") await probeClient.initialize(timeoutMs);
|
||
protocolProbe = {
|
||
status: "ready",
|
||
ready: true,
|
||
command,
|
||
workspace,
|
||
codexHome,
|
||
initialized: true,
|
||
requiredMethods: [...CODEX_APP_SERVER_REQUIRED_METHODS],
|
||
startedAt,
|
||
finishedAt: timestampFor(params.now ?? nowDefault),
|
||
expiresAtMs: Date.now() + probeCacheTtlMs,
|
||
secretMaterialRead: false,
|
||
valuesRedacted: true
|
||
};
|
||
availability = describe({ ...params, env, workspace, command, sandbox, protocolInitialized: true });
|
||
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,
|
||
initialized: false,
|
||
requiredMethods: [...CODEX_APP_SERVER_REQUIRED_METHODS],
|
||
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, protocolInitialized: false });
|
||
} 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 = "canceled";
|
||
session.updatedAt = timestamp;
|
||
session.lastTraceId = optionalId(params.traceId) ?? session.lastTraceId;
|
||
session.currentTraceId = null;
|
||
session.statusReason = params.reason ?? "user_canceled";
|
||
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;
|
||
rpcInitialized = false;
|
||
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, protocolInitialized: availability.protocol?.initialized ?? rpcInitialized });
|
||
}
|
||
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, protocolInitialized: availability.protocol?.initialized ?? rpcInitialized });
|
||
}
|
||
|
||
async function ensureRpcClient({ env, availability, timeoutMs } = {}) {
|
||
const args = codexAppServerArgs(env);
|
||
const childEnv = childProcessEnv(env);
|
||
const nextSignature = rpcClientConfigSignature({ availability, args, childEnv });
|
||
if (rpcClient && typeof rpcClient.startTurn === "function" && rpcClientSignature === nextSignature) return rpcClient;
|
||
if (rpcClient && rpcClientSignature !== nextSignature && typeof rpcClient.close === "function") {
|
||
rpcClient.close();
|
||
}
|
||
if (rpcClientSignature !== nextSignature) {
|
||
rpcClient = null;
|
||
rpcInitialized = false;
|
||
rpcClientSignature = null;
|
||
}
|
||
rpcClient = options.createRpcClient
|
||
? await options.createRpcClient({ env, availability })
|
||
: createCodexAppServerJsonLineClient({
|
||
command: availability.command,
|
||
args,
|
||
env: childEnv,
|
||
cwd: availability.workspace
|
||
});
|
||
if (typeof rpcClient.initialize === "function") {
|
||
await rpcClient.initialize(effectiveTimeout(timeoutMs));
|
||
}
|
||
rpcClientSignature = nextSignature;
|
||
rpcInitialized = true;
|
||
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
|
||
});
|
||
}
|
||
|
||
let effectiveSessionId = mappedSessionId || requestedSessionId || requiredId(idFactory(), "ses");
|
||
let session = sessions.get(effectiveSessionId) ?? null;
|
||
let 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 && ["failed", "interrupted", "timeout", "error", "canceled"].includes(session.status) && !requestedSessionId && mappedSessionId) {
|
||
conversations.delete(conversationId);
|
||
effectiveSessionId = requiredId(idFactory(), "ses");
|
||
session = null;
|
||
reused = false;
|
||
}
|
||
|
||
if (session && ["failed", "interrupted", "timeout", "error", "canceled"].includes(session.status)) {
|
||
return blockedAcquire({
|
||
code: `session_${session.status}`,
|
||
summary: `Codex stdio session ${effectiveSessionId} is ${session.status}; create a new session before retrying.`,
|
||
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 = CODEX_STDIO_SESSION_STATUSES.includes(params.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: 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;
|
||
}
|
||
|
||
function recentSessions() {
|
||
return [...sessions.values()]
|
||
.sort((left, right) => String(right.updatedAt).localeCompare(String(left.updatedAt)))
|
||
.slice(0, 10)
|
||
.map((session) => publicSession(session));
|
||
}
|
||
|
||
function sessionStatusCounts() {
|
||
const counts = {};
|
||
for (const status of CODEX_STDIO_SESSION_STATUSES) counts[status] = 0;
|
||
for (const session of sessions.values()) {
|
||
counts[session.status] = (counts[session.status] ?? 0) + 1;
|
||
}
|
||
return counts;
|
||
}
|
||
|
||
return {
|
||
describe,
|
||
probe,
|
||
chat,
|
||
cancel,
|
||
reapIdle,
|
||
get,
|
||
clear,
|
||
longLivedSessionGate,
|
||
get rpcStartedAt() {
|
||
return rpcStartedAt;
|
||
}
|
||
};
|
||
}
|
||
|
||
export function codexAppServerArgs(env = process.env) {
|
||
return [
|
||
...codexAppServerProviderConfigArgs(env),
|
||
"app-server",
|
||
"--listen",
|
||
"stdio://"
|
||
];
|
||
}
|
||
|
||
export function codexAppServerProviderBaseUrl(env = process.env) {
|
||
const configured = firstNonEmpty(env.HWLAB_CODE_AGENT_OPENAI_BASE_URL, env.OPENAI_BASE_URL);
|
||
if (!configured) return null;
|
||
try {
|
||
const url = new URL(configured);
|
||
url.hash = "";
|
||
url.search = "";
|
||
const normalizedPath = url.pathname.replace(/\/+$/u, "");
|
||
if (normalizedPath.endsWith("/responses")) {
|
||
url.pathname = normalizedPath.slice(0, -"/responses".length) || "/";
|
||
}
|
||
const serialized = url.toString();
|
||
return serialized.endsWith("/") && url.pathname === "/" ? serialized.slice(0, -1) : serialized.replace(/\/$/u, "");
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function codexAppServerProviderConfigArgs(env = process.env) {
|
||
const baseUrl = codexAppServerProviderBaseUrl(env);
|
||
if (!baseUrl) return [];
|
||
const model = firstNonEmpty(env.HWLAB_CODE_AGENT_MODEL, env.OPENAI_MODEL);
|
||
const args = [
|
||
"-c",
|
||
"model_provider=\"OpenAI\"",
|
||
"-c",
|
||
`model_providers.OpenAI.base_url=${tomlString(baseUrl)}`,
|
||
"-c",
|
||
"model_providers.OpenAI.name=\"OpenAI\"",
|
||
"-c",
|
||
"model_providers.OpenAI.wire_api=\"responses\"",
|
||
"-c",
|
||
"model_providers.OpenAI.requires_openai_auth=true"
|
||
];
|
||
if (model) {
|
||
args.push("-c", `model=${tomlString(model)}`);
|
||
args.push("-c", `review_model=${tomlString(model)}`);
|
||
}
|
||
return args;
|
||
}
|
||
|
||
function tomlString(value) {
|
||
return JSON.stringify(String(value ?? ""));
|
||
}
|
||
|
||
export function createCodexAppServerJsonLineClient({ command = DEFAULT_CODEX_STDIO_COMMAND, args = null, env = process.env, cwd = repoRoot, onNotification = null } = {}) {
|
||
const spawnArgs = Array.isArray(args) ? args : codexAppServerArgs(env);
|
||
const childDetached = process.platform !== "win32";
|
||
const child = spawn(command, spawnArgs, {
|
||
cwd,
|
||
env,
|
||
detached: childDetached,
|
||
stdio: ["pipe", "pipe", "pipe"]
|
||
});
|
||
let nextId = 1;
|
||
let stdoutBuffer = "";
|
||
let stderr = "";
|
||
const pending = new Map();
|
||
let initialized = false;
|
||
let notificationHandler = typeof onNotification === "function" ? onNotification : null;
|
||
let closed = false;
|
||
let closeResolve;
|
||
const closedPromise = new Promise((resolve) => {
|
||
closeResolve = resolve;
|
||
});
|
||
|
||
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) => closeWith(`Codex app-server process error: ${error.message}`, 127, "error"));
|
||
child.on("close", (code, signal) => closeWith(`Codex app-server process closed code=${code ?? "null"} signal=${signal ?? "null"}`, code, signal));
|
||
|
||
function handleLine(line) {
|
||
let payload = null;
|
||
try {
|
||
payload = JSON.parse(line);
|
||
} catch {
|
||
return;
|
||
}
|
||
const id = typeof payload.id === "number" ? payload.id : null;
|
||
const method = typeof payload.method === "string" ? payload.method : null;
|
||
if (id !== null && method === null) {
|
||
const request = pending.get(id);
|
||
if (!request) return;
|
||
pending.delete(id);
|
||
clearTimeout(request.timer);
|
||
if (payload.error) {
|
||
request.reject(new Error(redactText(payload.error.message ?? JSON.stringify(payload.error))));
|
||
} else {
|
||
request.resolve(payload.result);
|
||
}
|
||
return;
|
||
}
|
||
if (id !== null && method !== null) {
|
||
handleServerRequest(id, method);
|
||
return;
|
||
}
|
||
if (method !== null && notificationHandler) {
|
||
notificationHandler(payload);
|
||
}
|
||
}
|
||
|
||
function closeWith(message, code, signal) {
|
||
if (closed) return;
|
||
closed = true;
|
||
for (const [id, request] of pending.entries()) {
|
||
pending.delete(id);
|
||
clearTimeout(request.timer);
|
||
request.reject(new Error(redactText(`${message}; stderr=${tailText(stderr, 800)}`)));
|
||
}
|
||
closeResolve({
|
||
code: Number.isInteger(code) ? code : null,
|
||
signal: typeof signal === "string" ? signal : null,
|
||
stderrTail: redactText(tailText(stderr, 8000))
|
||
});
|
||
}
|
||
|
||
function request(method, params = {}, timeoutMs = 30000) {
|
||
if (closed) {
|
||
return Promise.reject(new Error("Codex app-server process is already closed."));
|
||
}
|
||
const id = nextId;
|
||
nextId += 1;
|
||
const message = JSON.stringify({
|
||
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 { initialized: true, requiredMethods: [...CODEX_APP_SERVER_REQUIRED_METHODS] };
|
||
await request("initialize", {
|
||
clientInfo: {
|
||
name: "hwlab-cloud-api",
|
||
title: "HWLAB Cloud API Code Agent",
|
||
version: "0"
|
||
},
|
||
capabilities: { experimentalApi: true }
|
||
}, timeoutMs);
|
||
notify("initialized", {});
|
||
initialized = true;
|
||
return { initialized: true, requiredMethods: [...CODEX_APP_SERVER_REQUIRED_METHODS] };
|
||
}
|
||
|
||
function notify(method, params = {}) {
|
||
child.stdin.write(`${JSON.stringify({ method, params })}\n`);
|
||
}
|
||
|
||
function handleServerRequest(id, method) {
|
||
if (method === "item/commandExecution/requestApproval" || method === "item/fileChange/requestApproval") {
|
||
child.stdin.write(`${JSON.stringify({ id, result: { decision: "decline" } })}\n`);
|
||
return;
|
||
}
|
||
child.stdin.write(`${JSON.stringify({ id, error: { code: -32601, message: `Unsupported client-side request: ${method}` } })}\n`);
|
||
}
|
||
|
||
async function startThread({ model, cwd: threadCwd, sandbox, approvalPolicy = "never", serviceName = "hwlab-cloud-api" } = {}, timeoutMs = 30000) {
|
||
const result = await request("thread/start", dropEmpty({
|
||
model,
|
||
cwd: threadCwd,
|
||
sandbox,
|
||
approvalPolicy,
|
||
serviceName
|
||
}), timeoutMs);
|
||
const threadId = extractAppServerString(extractAppServerRecord(result)?.thread, "id");
|
||
if (!threadId) throw new Error("thread/start response did not include thread.id");
|
||
return { threadId, result };
|
||
}
|
||
|
||
async function resumeThread({ threadId, model, cwd: threadCwd, sandbox, approvalPolicy = "never" } = {}, timeoutMs = 30000) {
|
||
const result = await request("thread/resume", dropEmpty({
|
||
threadId,
|
||
model,
|
||
cwd: threadCwd,
|
||
sandbox,
|
||
approvalPolicy
|
||
}), timeoutMs);
|
||
const resumedThreadId = extractAppServerString(extractAppServerRecord(result)?.thread, "id") ?? optionalId(threadId);
|
||
if (!resumedThreadId) throw new Error("thread/resume response did not include thread.id");
|
||
return { threadId: resumedThreadId, result };
|
||
}
|
||
|
||
async function startTurn({ threadId, prompt, model, cwd: turnCwd, approvalPolicy = "never", effort = null } = {}, timeoutMs = 30000) {
|
||
const result = await request("turn/start", dropEmpty({
|
||
threadId,
|
||
input: codexAppServerTextInput(prompt),
|
||
cwd: turnCwd,
|
||
approvalPolicy,
|
||
model,
|
||
effort
|
||
}), timeoutMs);
|
||
const turnId = extractAppServerString(extractAppServerRecord(result)?.turn, "id");
|
||
if (!turnId) throw new Error("turn/start response did not include turn.id");
|
||
return { turnId, result };
|
||
}
|
||
|
||
function setNotificationHandler(handler) {
|
||
notificationHandler = typeof handler === "function" ? handler : null;
|
||
}
|
||
|
||
function close() {
|
||
if (closed) return;
|
||
terminateChildProcess(child, { detached: childDetached });
|
||
}
|
||
|
||
return {
|
||
closedPromise,
|
||
initialize,
|
||
startThread,
|
||
resumeThread,
|
||
startTurn,
|
||
setNotificationHandler,
|
||
close
|
||
};
|
||
}
|
||
|
||
function terminateChildProcess(child, { detached = false } = {}) {
|
||
if (!child || child.killed) return;
|
||
try {
|
||
if (detached && Number.isInteger(child.pid)) {
|
||
process.kill(-child.pid, "SIGTERM");
|
||
} else {
|
||
child.kill("SIGTERM");
|
||
}
|
||
} catch {
|
||
try {
|
||
child.kill("SIGTERM");
|
||
} catch {
|
||
return;
|
||
}
|
||
}
|
||
setTimeout(() => {
|
||
if (child.killed) return;
|
||
try {
|
||
if (detached && Number.isInteger(child.pid)) {
|
||
process.kill(-child.pid, "SIGKILL");
|
||
} else {
|
||
child.kill("SIGKILL");
|
||
}
|
||
} catch {
|
||
// Process already exited or the runtime does not allow group signaling.
|
||
}
|
||
}, 2000).unref?.();
|
||
}
|
||
|
||
async function runAppServerTurn({
|
||
client,
|
||
session,
|
||
prompt,
|
||
workspace,
|
||
sandbox,
|
||
model,
|
||
traceRecorder,
|
||
timeoutMs,
|
||
hardTimeoutMs
|
||
} = {}) {
|
||
const state = createAppServerTurnState({ traceRecorder, session });
|
||
const effectiveMs = effectiveActivityTimeout(timeoutMs);
|
||
const effectiveHardMs = effectiveHardTimeout(hardTimeoutMs, effectiveMs);
|
||
if (typeof client.setNotificationHandler === "function") {
|
||
client.setNotificationHandler((message) => state.handle(message));
|
||
}
|
||
try {
|
||
let threadId = optionalId(session?.threadId);
|
||
if (threadId) {
|
||
state.appendTrace({
|
||
type: "thread",
|
||
status: "started",
|
||
label: "thread:resume:started",
|
||
sessionId: session.sessionId,
|
||
sessionStatus: session.status,
|
||
turn: session.turn,
|
||
threadId,
|
||
waitingFor: "thread/resume"
|
||
});
|
||
const resumed = await client.resumeThread({
|
||
threadId,
|
||
model,
|
||
cwd: workspace,
|
||
sandbox,
|
||
approvalPolicy: "never"
|
||
}, effectiveMs);
|
||
threadId = optionalId(resumed?.threadId) ?? threadId;
|
||
state.setThreadId(threadId);
|
||
state.appendTrace({
|
||
type: "thread",
|
||
status: "completed",
|
||
label: "thread:resume:completed",
|
||
sessionId: session.sessionId,
|
||
sessionStatus: session.status,
|
||
turn: session.turn,
|
||
threadId,
|
||
waitingFor: "turn/start"
|
||
});
|
||
} else {
|
||
state.appendTrace({
|
||
type: "thread",
|
||
status: "started",
|
||
label: "thread:start:started",
|
||
sessionId: session.sessionId,
|
||
sessionStatus: session.status,
|
||
turn: session.turn,
|
||
waitingFor: "thread/start"
|
||
});
|
||
const started = await client.startThread({
|
||
model,
|
||
cwd: workspace,
|
||
sandbox,
|
||
approvalPolicy: "never",
|
||
serviceName: "hwlab-cloud-api"
|
||
}, effectiveMs);
|
||
threadId = optionalId(started?.threadId);
|
||
if (!threadId) throw new Error("thread/start response did not include thread.id");
|
||
state.setThreadId(threadId);
|
||
state.appendTrace({
|
||
type: "thread",
|
||
status: "completed",
|
||
label: "thread:start:completed",
|
||
sessionId: session.sessionId,
|
||
sessionStatus: session.status,
|
||
turn: session.turn,
|
||
threadId,
|
||
waitingFor: "turn/start"
|
||
});
|
||
}
|
||
|
||
state.appendTrace({
|
||
type: "turn",
|
||
status: "started",
|
||
label: "turn:start:started",
|
||
sessionId: session.sessionId,
|
||
sessionStatus: session.status,
|
||
turn: session.turn,
|
||
threadId,
|
||
waitingFor: "turn/start"
|
||
});
|
||
const turnStart = await client.startTurn({
|
||
threadId,
|
||
prompt,
|
||
model,
|
||
cwd: workspace,
|
||
sandbox,
|
||
approvalPolicy: "never"
|
||
}, effectiveMs);
|
||
const turnId = optionalId(turnStart?.turnId);
|
||
if (!turnId) throw new Error("turn/start response did not include turn.id");
|
||
state.setTurnId(turnId);
|
||
state.appendTrace({
|
||
type: "turn",
|
||
status: "started",
|
||
label: "turn:start:completed",
|
||
sessionId: session.sessionId,
|
||
sessionStatus: session.status,
|
||
turn: session.turn,
|
||
threadId,
|
||
turnId,
|
||
waitingFor: "turn/completed"
|
||
});
|
||
|
||
const rawResult = await state.wait(effectiveMs, client.closedPromise, { hardTimeoutMs: effectiveHardMs });
|
||
return normalizeAppServerTurnResult(rawResult, state.snapshot(), { threadId, turnId });
|
||
} finally {
|
||
if (typeof client.setNotificationHandler === "function") {
|
||
client.setNotificationHandler(null);
|
||
}
|
||
}
|
||
}
|
||
|
||
function createAppServerTurnState({ traceRecorder, session } = {}) {
|
||
let threadId = optionalId(session?.threadId);
|
||
let turnId = null;
|
||
let assistantText = "";
|
||
let finalResponse = "";
|
||
let terminal = null;
|
||
let terminalResolve;
|
||
const terminalPromise = new Promise((resolve) => {
|
||
terminalResolve = resolve;
|
||
});
|
||
let lastActivityAt = Date.now();
|
||
let lastActivityLabel = "turn-state:created";
|
||
let lastWaitingFor = "app-server-turn";
|
||
|
||
function setThreadId(value) {
|
||
threadId = optionalId(value) ?? threadId;
|
||
}
|
||
|
||
function setTurnId(value) {
|
||
turnId = optionalId(value) ?? turnId;
|
||
}
|
||
|
||
function activitySnapshot(referenceNow = Date.now()) {
|
||
return {
|
||
lastActivityAt: new Date(lastActivityAt).toISOString(),
|
||
lastActivityLabel,
|
||
idleMs: Math.max(0, referenceNow - lastActivityAt),
|
||
waitingFor: lastWaitingFor
|
||
};
|
||
}
|
||
|
||
function observeActivity({ label = "app-server:activity", waitingFor = null } = {}) {
|
||
lastActivityAt = Date.now();
|
||
lastActivityLabel = label;
|
||
if (waitingFor) lastWaitingFor = waitingFor;
|
||
return activitySnapshot(lastActivityAt);
|
||
}
|
||
|
||
function appendTrace(event = {}) {
|
||
const appended = traceRecorder?.append(event);
|
||
observeActivity({
|
||
label: appended?.label ?? event.label ?? event.type ?? "trace:event",
|
||
waitingFor: appended?.waitingFor ?? event.waitingFor ?? null
|
||
});
|
||
return appended;
|
||
}
|
||
|
||
function appendProgressTrace(event = {}) {
|
||
return traceRecorder?.append(event);
|
||
}
|
||
|
||
function hasAssistantOutput() {
|
||
return Boolean(firstNonEmpty(finalResponse, assistantText));
|
||
}
|
||
|
||
function appendFirstTokenProgressTrace(referenceNow = Date.now()) {
|
||
const activity = activitySnapshot(referenceNow);
|
||
if (hasAssistantOutput() || !turnId || terminal) return null;
|
||
if (!["assistant-message", "turn/completed"].includes(activity.waitingFor)) return null;
|
||
return appendProgressTrace({
|
||
type: "turn",
|
||
status: "running",
|
||
label: "turn:waiting:first_assistant_token",
|
||
message: "Codex app-server is waiting for the first assistant token; this progress trace does not reset the backend idle timer.",
|
||
progressOnly: true,
|
||
idleMs: activity.idleMs,
|
||
lastActivityAt: activity.lastActivityAt,
|
||
sessionId: session?.sessionId,
|
||
sessionStatus: session?.status,
|
||
turn: session?.turn,
|
||
threadId,
|
||
turnId,
|
||
waitingFor: "assistant-message"
|
||
});
|
||
}
|
||
|
||
function finish(status, error = null, extra = {}) {
|
||
if (terminal) return;
|
||
terminal = {
|
||
terminalStatus: appServerTerminalStatus(status),
|
||
terminalError: error ? redactText(error) : null,
|
||
threadId,
|
||
turnId,
|
||
...activitySnapshot(),
|
||
...extra
|
||
};
|
||
terminalResolve(terminal);
|
||
}
|
||
|
||
function handle(message) {
|
||
const method = typeof message?.method === "string" ? message.method : "unknown";
|
||
observeActivity({
|
||
label: `app-server:${method}`,
|
||
waitingFor: appServerWaitingForMethod(method)
|
||
});
|
||
const params = extractAppServerRecord(message?.params);
|
||
const item = extractAppServerRecord(params?.item);
|
||
const turn = extractAppServerRecord(params?.turn);
|
||
if (method === "thread/started") {
|
||
setThreadId(extractAppServerString(extractAppServerRecord(params?.thread), "id"));
|
||
appendTrace({
|
||
type: "thread",
|
||
status: "completed",
|
||
label: "thread:started",
|
||
sessionId: session?.sessionId,
|
||
sessionStatus: session?.status,
|
||
turn: session?.turn,
|
||
threadId,
|
||
waitingFor: "turn/start"
|
||
});
|
||
return;
|
||
}
|
||
if (method === "turn/started") {
|
||
setTurnId(extractAppServerString(turn, "id"));
|
||
appendTrace({
|
||
type: "turn",
|
||
status: "started",
|
||
label: "turn:started",
|
||
sessionId: session?.sessionId,
|
||
sessionStatus: session?.status,
|
||
turn: session?.turn,
|
||
threadId,
|
||
turnId,
|
||
waitingFor: "assistant-message"
|
||
});
|
||
return;
|
||
}
|
||
if (method === "item/agentMessage/delta") {
|
||
const delta = String(params?.delta ?? "");
|
||
assistantText += delta;
|
||
if (delta) {
|
||
traceRecorder?.appendAssistantDelta?.({
|
||
itemId: optionalId(params?.itemId),
|
||
chunk: delta,
|
||
sessionId: session?.sessionId,
|
||
sessionStatus: session?.status,
|
||
turn: session?.turn,
|
||
threadId,
|
||
turnId,
|
||
waitingFor: "turn/completed"
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
if (method === "item/completed" && item?.type === "agentMessage") {
|
||
if (typeof item.text === "string") finalResponse = item.text;
|
||
appendTrace({
|
||
type: "assistant_message",
|
||
status: "completed",
|
||
label: "assistant:item_completed",
|
||
sessionId: session?.sessionId,
|
||
sessionStatus: session?.status,
|
||
turn: session?.turn,
|
||
threadId,
|
||
turnId,
|
||
waitingFor: "turn/completed"
|
||
});
|
||
return;
|
||
}
|
||
if (method === "item/started" && item?.type === "commandExecution") {
|
||
appendCommandExecutionTrace(item, {
|
||
status: "started",
|
||
label: "item/commandExecution:started"
|
||
});
|
||
return;
|
||
}
|
||
if (method === "item/completed" && item?.type === "commandExecution") {
|
||
appendCommandExecutionTrace(item, {
|
||
status: appServerCommandStatus(item),
|
||
label: "item/commandExecution:completed"
|
||
});
|
||
return;
|
||
}
|
||
if (method === "item/commandExecution/outputDelta" || method === "item/reasoning/summaryTextDelta" || method === "item/reasoning/textDelta") {
|
||
appendTrace({
|
||
type: method.startsWith("item/reasoning/") ? "reasoning" : "tool_call",
|
||
status: "output_chunk",
|
||
label: method,
|
||
outputSummary: String(params?.delta ?? "").slice(0, 400),
|
||
sessionId: session?.sessionId,
|
||
sessionStatus: session?.status,
|
||
turn: session?.turn,
|
||
threadId,
|
||
turnId,
|
||
waitingFor: "turn/completed"
|
||
});
|
||
return;
|
||
}
|
||
if (method === "error") {
|
||
const error = extractAppServerRecord(params?.error);
|
||
const message = typeof error?.message === "string" ? error.message : "Codex app-server error";
|
||
const willRetry = params?.willRetry === true;
|
||
setThreadId(extractAppServerString(params, "threadId"));
|
||
setTurnId(extractAppServerString(params, "turnId"));
|
||
appendTrace({
|
||
type: willRetry ? "provider_retry" : "error",
|
||
status: willRetry ? "retrying" : "failed",
|
||
label: willRetry ? "app-server:retrying" : "app-server:error",
|
||
errorCode: willRetry ? "codex_stdio_provider_retry" : "codex_stdio_failed",
|
||
message: redactText(message),
|
||
sessionId: session?.sessionId,
|
||
sessionStatus: session?.status,
|
||
turn: session?.turn,
|
||
threadId,
|
||
turnId,
|
||
waitingFor: willRetry ? "turn/completed" : "user-retry",
|
||
terminal: !willRetry
|
||
});
|
||
if (!willRetry) finish("failed", message);
|
||
return;
|
||
}
|
||
if (method === "turn/completed") {
|
||
const status = appServerTerminalStatus(turn?.status);
|
||
const error = extractAppServerRecord(turn?.error);
|
||
const message = typeof error?.message === "string" ? error.message : null;
|
||
appendTrace({
|
||
type: "turn",
|
||
status,
|
||
label: `turn:completed:${status ?? "unknown"}`,
|
||
sessionId: session?.sessionId,
|
||
sessionStatus: session?.status,
|
||
turn: session?.turn,
|
||
threadId,
|
||
turnId,
|
||
errorCode: status === "completed" ? null : "codex_stdio_failed",
|
||
message: message ? redactText(message) : undefined,
|
||
terminal: status !== "completed"
|
||
});
|
||
finish(status, message);
|
||
}
|
||
}
|
||
|
||
function appendCommandExecutionTrace(item, { status, label }) {
|
||
const output = commandExecutionOutputText(item);
|
||
appendTrace({
|
||
type: "tool_call",
|
||
stage: "tool_call",
|
||
status,
|
||
label,
|
||
itemId: optionalId(item.id),
|
||
toolName: "commandExecution",
|
||
command: redactText(commandExecutionCommandText(item)),
|
||
exitCode: Number.isInteger(item.exitCode) ? item.exitCode : undefined,
|
||
durationMs: typeof item.durationMs === "number" ? item.durationMs : undefined,
|
||
outputBytes: output.length,
|
||
stdoutSummary: output ? tailText(redactText(output), 600) : undefined,
|
||
stderrSummary: commandExecutionErrorText(item),
|
||
sessionId: session?.sessionId,
|
||
sessionStatus: session?.status,
|
||
turn: session?.turn,
|
||
threadId,
|
||
turnId,
|
||
waitingFor: status === "completed" ? "turn/completed" : "commandExecution/completed"
|
||
});
|
||
}
|
||
|
||
async function wait(timeoutMs, closedPromise, { hardTimeoutMs = null } = {}) {
|
||
let activityTimer;
|
||
let hardTimer;
|
||
let progressTimer;
|
||
const timeoutPromise = new Promise((_, reject) => {
|
||
const checkActivity = () => {
|
||
const now = Date.now();
|
||
const activity = activitySnapshot(now);
|
||
if (activity.idleMs >= timeoutMs) {
|
||
const error = appServerActivityTimeoutError({
|
||
timeoutMs,
|
||
idleMs: activity.idleMs,
|
||
lastActivityAt: activity.lastActivityAt,
|
||
waitingFor: activity.waitingFor,
|
||
threadId,
|
||
turnId,
|
||
partialAssistant: hasAssistantOutput()
|
||
});
|
||
appendTrace({
|
||
type: "timeout",
|
||
status: "failed",
|
||
label: "timeout:no_activity",
|
||
errorCode: error.code,
|
||
message: error.message,
|
||
timeoutMs,
|
||
idleMs: activity.idleMs,
|
||
lastActivityAt: activity.lastActivityAt,
|
||
sessionId: session?.sessionId,
|
||
sessionStatus: session?.status,
|
||
turn: session?.turn,
|
||
threadId,
|
||
turnId,
|
||
waitingFor: activity.waitingFor,
|
||
terminal: true
|
||
});
|
||
reject(error);
|
||
return;
|
||
}
|
||
activityTimer = setTimeout(checkActivity, Math.max(1, timeoutMs - activity.idleMs));
|
||
};
|
||
activityTimer = setTimeout(checkActivity, timeoutMs);
|
||
});
|
||
const progressPromise = new Promise(() => {
|
||
const tick = () => {
|
||
appendFirstTokenProgressTrace();
|
||
progressTimer = setTimeout(tick, CODEX_STDIO_FIRST_TOKEN_PROGRESS_MS);
|
||
};
|
||
progressTimer = setTimeout(tick, CODEX_STDIO_FIRST_TOKEN_PROGRESS_MS);
|
||
});
|
||
const hardTimeoutPromise = hardTimeoutMs
|
||
? new Promise((_, reject) => {
|
||
hardTimer = setTimeout(() => {
|
||
const activity = activitySnapshot();
|
||
const error = appServerHardTimeoutError({
|
||
hardTimeoutMs,
|
||
timeoutMs,
|
||
idleMs: activity.idleMs,
|
||
lastActivityAt: activity.lastActivityAt,
|
||
waitingFor: activity.waitingFor,
|
||
threadId,
|
||
turnId
|
||
});
|
||
appendTrace({
|
||
type: "timeout",
|
||
status: "failed",
|
||
label: "timeout:hard_cap",
|
||
errorCode: error.code,
|
||
message: error.message,
|
||
timeoutMs,
|
||
hardTimeoutMs,
|
||
idleMs: activity.idleMs,
|
||
lastActivityAt: activity.lastActivityAt,
|
||
sessionId: session?.sessionId,
|
||
sessionStatus: session?.status,
|
||
turn: session?.turn,
|
||
threadId,
|
||
turnId,
|
||
waitingFor: activity.waitingFor,
|
||
terminal: true
|
||
});
|
||
reject(error);
|
||
}, hardTimeoutMs);
|
||
})
|
||
: null;
|
||
const candidates = [terminalPromise];
|
||
if (closedPromise && typeof closedPromise.then === "function") {
|
||
candidates.push(closedPromise.then((exit) => {
|
||
if (terminal) return terminal;
|
||
return {
|
||
terminalStatus: "failed",
|
||
terminalError: hasAssistantOutput()
|
||
? "Codex app-server transport closed after partial assistant output but before turn/completed."
|
||
: "Codex app-server transport closed before turn/completed.",
|
||
threadId,
|
||
turnId,
|
||
...activitySnapshot(),
|
||
appServerExit: exit
|
||
};
|
||
}));
|
||
}
|
||
try {
|
||
return await Promise.race([...candidates, timeoutPromise, hardTimeoutPromise, progressPromise].filter(Boolean));
|
||
} finally {
|
||
clearTimeout(activityTimer);
|
||
clearTimeout(hardTimer);
|
||
clearTimeout(progressTimer);
|
||
}
|
||
}
|
||
|
||
function snapshot() {
|
||
const activity = activitySnapshot();
|
||
return {
|
||
threadId,
|
||
turnId,
|
||
assistantText: redactText(assistantText),
|
||
finalResponse: redactText(firstNonEmpty(finalResponse, assistantText)),
|
||
terminalStatus: terminal?.terminalStatus ?? null,
|
||
terminalError: terminal?.terminalError ?? null,
|
||
lastActivityAt: terminal?.lastActivityAt ?? activity.lastActivityAt,
|
||
idleMs: terminal?.idleMs ?? activity.idleMs,
|
||
waitingFor: terminal?.waitingFor ?? activity.waitingFor
|
||
};
|
||
}
|
||
|
||
return {
|
||
appendTrace,
|
||
handle,
|
||
setThreadId,
|
||
setTurnId,
|
||
wait,
|
||
snapshot
|
||
};
|
||
}
|
||
|
||
function appServerWaitingForMethod(method) {
|
||
if (method === "thread/started") return "turn/start";
|
||
if (method === "turn/started") return "assistant-message";
|
||
if (method === "item/agentMessage/delta") return "turn/completed";
|
||
if (method === "item/completed") return "turn/completed";
|
||
if (method === "item/commandExecution/outputDelta") return "turn/completed";
|
||
if (method === "item/reasoning/summaryTextDelta" || method === "item/reasoning/textDelta") return "turn/completed";
|
||
if (method === "error") return "turn/completed";
|
||
if (method === "turn/completed") return "turn/completed";
|
||
return "app-server-notification";
|
||
}
|
||
|
||
function appServerActivityTimeoutError({ timeoutMs, idleMs, lastActivityAt, waitingFor, threadId, turnId, partialAssistant = false }) {
|
||
const error = new Error(partialAssistant
|
||
? `Codex app-server idle timeout after partial assistant output: no turn/completed for ${idleMs}ms, threshold ${timeoutMs}ms; waitingFor=${waitingFor ?? "unknown"}.`
|
||
: `Codex app-server idle timeout: no new events for ${idleMs}ms, threshold ${timeoutMs}ms; waitingFor=${waitingFor ?? "unknown"}.`);
|
||
Object.assign(error, {
|
||
code: "codex_stdio_idle_timeout",
|
||
timeoutMs,
|
||
idleMs,
|
||
lastActivityAt,
|
||
waitingFor,
|
||
threadId,
|
||
turnId,
|
||
partialAssistant,
|
||
stage: "app-server-turn"
|
||
});
|
||
return error;
|
||
}
|
||
|
||
function appServerHardTimeoutError({ hardTimeoutMs, timeoutMs, idleMs, lastActivityAt, waitingFor, threadId, turnId }) {
|
||
const error = new Error(`Codex app-server hard timeout reached after ${hardTimeoutMs}ms; last activity was ${idleMs}ms ago; waitingFor=${waitingFor ?? "unknown"}.`);
|
||
Object.assign(error, {
|
||
code: "codex_stdio_hard_timeout",
|
||
timeoutMs,
|
||
hardTimeoutMs,
|
||
idleMs,
|
||
lastActivityAt,
|
||
waitingFor,
|
||
threadId,
|
||
turnId,
|
||
stage: "app-server-turn"
|
||
});
|
||
return error;
|
||
}
|
||
|
||
function normalizeAppServerTurnResult(rawResult, state, fallback) {
|
||
const raw = extractAppServerRecord(rawResult) ?? {};
|
||
return {
|
||
threadId: optionalId(raw.threadId) ?? state.threadId ?? fallback.threadId ?? null,
|
||
turnId: optionalId(raw.turnId) ?? state.turnId ?? fallback.turnId ?? null,
|
||
assistantText: redactText(firstNonEmpty(raw.assistantText, state.assistantText)),
|
||
finalResponse: redactText(firstNonEmpty(raw.finalResponse, raw.content, raw.text, state.finalResponse, state.assistantText)),
|
||
terminalStatus: appServerTerminalStatus(raw.terminalStatus ?? raw.status ?? state.terminalStatus),
|
||
terminalError: raw.terminalError ? redactText(raw.terminalError) : state.terminalError ?? null,
|
||
lastActivityAt: raw.lastActivityAt ?? state.lastActivityAt ?? null,
|
||
idleMs: raw.idleMs ?? state.idleMs ?? null,
|
||
waitingFor: raw.waitingFor ?? state.waitingFor ?? null,
|
||
appServerExit: raw.appServerExit ?? null
|
||
};
|
||
}
|
||
|
||
function summarizeAppServerTurn(turnResult) {
|
||
return [
|
||
turnResult?.threadId ? `threadId=${turnResult.threadId}` : "threadId=not_observed",
|
||
turnResult?.turnId ? `turnId=${turnResult.turnId}` : null,
|
||
`terminalStatus=${turnResult?.terminalStatus ?? "unknown"}`,
|
||
firstNonEmpty(turnResult?.finalResponse, turnResult?.assistantText) ? `assistantChars=${String(firstNonEmpty(turnResult.finalResponse, turnResult.assistantText)).length}` : null
|
||
].filter(Boolean).join(" ");
|
||
}
|
||
|
||
function codexStdioProviderTrace({ availability, toolName, turnResult, session } = {}) {
|
||
return {
|
||
transport: "stdio",
|
||
protocol: CODEX_APP_SERVER_PROTOCOL,
|
||
wireApi: CODEX_APP_SERVER_WIRE_API,
|
||
runnerKind: CODEX_STDIO_RUNNER_KIND,
|
||
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
|
||
command: `${availability?.command || "/app/node_modules/.bin/codex"} app-server --listen stdio://`,
|
||
toolName,
|
||
threadId: turnResult?.threadId ?? session?.threadId ?? null,
|
||
turnId: turnResult?.turnId ?? null,
|
||
terminalStatus: turnResult?.terminalStatus ?? null,
|
||
appServerExit: turnResult?.appServerExit ?? null,
|
||
valuesPrinted: false
|
||
};
|
||
}
|
||
|
||
function codexAppServerTextInput(text) {
|
||
return [{ type: "text", text: String(text ?? ""), text_elements: [] }];
|
||
}
|
||
|
||
function extractAppServerRecord(value) {
|
||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
||
}
|
||
|
||
function extractAppServerString(record, key) {
|
||
return typeof record?.[key] === "string" && record[key].trim() ? record[key].trim() : null;
|
||
}
|
||
|
||
function appServerTerminalStatus(value) {
|
||
const raw = String(value ?? "").trim().toLowerCase();
|
||
if (raw === "completed" || raw === "complete" || raw === "success" || raw === "succeeded") return "completed";
|
||
if (raw === "canceled" || raw === "cancelled") return "canceled";
|
||
if (raw === "interrupted") return "interrupted";
|
||
if (raw === "timeout" || raw === "timed_out") return "timeout";
|
||
if (raw === "failed" || raw === "error") return "failed";
|
||
return raw ? "failed" : null;
|
||
}
|
||
|
||
function appServerCommandStatus(item) {
|
||
const raw = String(item?.status ?? "").trim().toLowerCase();
|
||
if (raw === "completed" || raw === "complete" || raw === "success" || raw === "succeeded") return "completed";
|
||
if (raw === "failed" || raw === "error") return "failed";
|
||
if (Number.isInteger(item?.exitCode)) return item.exitCode === 0 ? "completed" : "failed";
|
||
return raw || "completed";
|
||
}
|
||
|
||
function commandExecutionCommandText(item) {
|
||
return firstCommandExecutionText(
|
||
item?.command,
|
||
item?.cmd,
|
||
item?.argv,
|
||
item?.input,
|
||
item?.metadata?.command
|
||
);
|
||
}
|
||
|
||
function commandExecutionOutputText(item) {
|
||
return firstCommandExecutionText(
|
||
item?.aggregatedOutput,
|
||
item?.output,
|
||
item?.stdout,
|
||
item?.result,
|
||
item?.metadata?.output
|
||
);
|
||
}
|
||
|
||
function commandExecutionErrorText(item) {
|
||
const error = item?.error;
|
||
const message = typeof error === "string" ? error : typeof error?.message === "string" ? error.message : "";
|
||
const text = firstCommandExecutionText(item?.stderr, message, item?.metadata?.stderr);
|
||
return text ? tailText(redactText(text), 600) : undefined;
|
||
}
|
||
|
||
function firstCommandExecutionText(...values) {
|
||
for (const value of values) {
|
||
const text = commandExecutionTextValue(value);
|
||
if (text) return text;
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function commandExecutionTextValue(value) {
|
||
if (typeof value === "string") return value.trim();
|
||
if (Array.isArray(value)) return value.map((item) => commandExecutionTextValue(item)).filter(Boolean).join(" ").trim();
|
||
if (value && typeof value === "object") {
|
||
return firstCommandExecutionText(value.text, value.content, value.message, value.stdout, value.stderr, value.command, value.cmd);
|
||
}
|
||
return "";
|
||
}
|
||
|
||
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 decorateCodeAgentSession({
|
||
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 } : {})
|
||
}, { reused });
|
||
}
|
||
|
||
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-app-server.thread/start", "codex-app-server.thread/resume", "codex-app-server.turn/start", "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: ["secret-values-redacted"],
|
||
safety: codexStdioSafety()
|
||
};
|
||
}
|
||
|
||
function runnerTrace({ traceRecorder, traceId, workspace, sandbox, session, startedAt, outputTruncated }) {
|
||
const sessionLifecycleStatus = codeAgentSessionLifecycleSummary({ session }).status;
|
||
const snapshot = traceRecorder?.snapshot({
|
||
sessionId: session?.sessionId,
|
||
sessionStatus: session?.status,
|
||
sessionLifecycleStatus,
|
||
turn: session?.turn,
|
||
workspace,
|
||
sandbox,
|
||
runnerKind: CODEX_STDIO_RUNNER_KIND,
|
||
sessionMode: CODEX_STDIO_SESSION_MODE,
|
||
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE
|
||
});
|
||
return runnerTraceFromSnapshot(snapshot ?? {
|
||
traceId,
|
||
events: [],
|
||
eventLabels: [],
|
||
updatedAt: timestampFor(),
|
||
startedAt
|
||
}, {
|
||
runnerKind: CODEX_STDIO_RUNNER_KIND,
|
||
workspace,
|
||
sandbox,
|
||
sessionMode: CODEX_STDIO_SESSION_MODE,
|
||
sessionId: session?.sessionId ?? null,
|
||
sessionStatus: session?.status ?? null,
|
||
sessionLifecycleStatus,
|
||
idleTimeoutMs: session?.idleTimeoutMs ?? null,
|
||
lastTraceId: session?.lastTraceId ?? traceId,
|
||
turn: session?.turn ?? null,
|
||
sessionReused: session?.reused ?? false,
|
||
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
|
||
limitations: ["secret-values-redacted"],
|
||
startedAt,
|
||
outputTruncated: Boolean(outputTruncated),
|
||
note: "Repo-owned Codex app-server stdio supervisor manages create/reuse/cancel/reap/idle timeout and real-time trace capture; hardware control remains routed through cloud-api/HWLAB API/skill CLI boundaries."
|
||
});
|
||
}
|
||
|
||
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,
|
||
lifecycleStatus: session.lifecycleStatus ?? codeAgentSessionLifecycleSummary({ 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: ""
|
||
};
|
||
}
|
||
|
||
function summarizePrompt(message) {
|
||
const value = String(message ?? "").replace(/\s+/gu, " ").trim();
|
||
if (!value) return "empty prompt";
|
||
return value.length > 160 ? `${value.slice(0, 157)}...` : value;
|
||
}
|
||
|
||
function summarizeToolResult(toolResult) {
|
||
const content = extractCodexToolOutput(toolResult);
|
||
if (content.threadId || content.content) {
|
||
return [
|
||
content.threadId ? `threadId=${content.threadId}` : "threadId=not_observed",
|
||
content.content ? `assistantChars=${content.content.length}` : null
|
||
].filter(Boolean).join(" ");
|
||
}
|
||
if (Array.isArray(toolResult?.content)) return `contentItems=${toolResult.content.length}`;
|
||
return "tool result captured";
|
||
}
|
||
|
||
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(toolTraceEvent(toolCall, { labelPrefix: "sidecar" }));
|
||
}
|
||
|
||
if (intent.ls) {
|
||
const toolCall = await lsToolCall({ workspace, target: intent.target, traceId });
|
||
toolCalls.push(toolCall);
|
||
events.push(toolTraceEvent(toolCall, { labelPrefix: "sidecar" }));
|
||
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({
|
||
type: "tool_call",
|
||
status: skills.status === "ready" ? "completed" : "blocked",
|
||
label: `sidecar:skills.discover:${skills.status === "ready" ? "completed" : "blocked"}`,
|
||
toolName: "skills.discover",
|
||
outputSummary: skills.status === "ready" ? `skills=${skills.count}` : "skills_unavailable",
|
||
outputTruncated: Boolean(skills.truncated)
|
||
});
|
||
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(toolTraceEvent(call, { labelPrefix: "sidecar" }));
|
||
outputTruncated = outputTruncated || smokeCalls.some((call) => call.outputTruncated);
|
||
}
|
||
|
||
return {
|
||
intent,
|
||
toolCalls,
|
||
skills,
|
||
events,
|
||
outputTruncated
|
||
};
|
||
}
|
||
|
||
function toolTraceEvent(toolCall, { labelPrefix = "tool" } = {}) {
|
||
return {
|
||
type: "tool_call",
|
||
status: toolCall.status,
|
||
label: `${labelPrefix}:${toolCall.name}:${toolCall.status}`,
|
||
toolName: toolCall.name,
|
||
outputSummary: firstNonEmpty(toolCall.stderrSummary, toolCall.stdout ? `${toolCall.name} output captured` : null),
|
||
outputTruncated: toolCall.outputTruncated === true
|
||
};
|
||
}
|
||
|
||
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: boundSkillText(manifest.description ?? firstMarkdownSummary(manifest.body) ?? "No description provided."),
|
||
source: manifestPath,
|
||
manifestPath,
|
||
manifest: {
|
||
type: "SKILL.md",
|
||
path: manifestPath,
|
||
root: dir,
|
||
relativePath: path.relative(dir, manifestPath) || "SKILL.md"
|
||
},
|
||
sourceRoot: dir,
|
||
version: manifest.version ?? null,
|
||
commitId: manifest.commitId ?? null,
|
||
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,
|
||
sourceIssues: ["pikasTech/HWLAB#136", "pikasTech/HWLAB#237"],
|
||
blockers: [
|
||
{
|
||
code: "skills_unavailable",
|
||
sourceIssue: "pikasTech/HWLAB#136",
|
||
summary: "No readable SKILL.md files were found in the configured Codex stdio skills directories."
|
||
},
|
||
{
|
||
code: "skills_manifest_missing",
|
||
sourceIssue: "pikasTech/HWLAB#237",
|
||
summary: "Codex stdio skills discovery requires mounted skill manifests; generic text fallback must not invent skills."
|
||
}
|
||
],
|
||
valuesPrinted: false
|
||
};
|
||
}
|
||
return {
|
||
status: "ready",
|
||
code: "skills_ready",
|
||
items: returned,
|
||
count: returned.length,
|
||
totalCount: unique.length,
|
||
truncated: unique.length > returned.length,
|
||
checkedDirs,
|
||
sources,
|
||
sourceIssues: [],
|
||
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 [path.resolve("/app/skills")];
|
||
}
|
||
|
||
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,
|
||
version: firstManifestField(frontmatter, ["version", "skillVersion"]),
|
||
commitId: firstManifestField(frontmatter, ["commitId", "commit", "gitCommit", "revision"]),
|
||
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 firstManifestField(frontmatter, keys) {
|
||
for (const key of keys) {
|
||
const value = String(frontmatter?.[key] ?? "").trim();
|
||
if (value) return boundSkillText(value, 120);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function boundSkillText(value, limit = CODEX_STDIO_SKILL_SUMMARY_LIMIT) {
|
||
const text = redactText(String(value ?? "").replace(/\s+/gu, " ").trim());
|
||
if (text.length <= limit) return text;
|
||
return `${text.slice(0, Math.max(0, limit - 3))}...`;
|
||
}
|
||
|
||
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 = {}) {
|
||
if (sidecar.intent?.skills && sidecar.skills?.status === "ready") {
|
||
return skillsDiscoveryReply(sidecar.skills);
|
||
}
|
||
return String(content ?? "").trim();
|
||
}
|
||
|
||
function skillsDiscoveryReply(skills) {
|
||
const items = Array.isArray(skills?.items) ? skills.items : [];
|
||
const listed = items.slice(0, CODEX_STDIO_SKILL_REPLY_LIMIT);
|
||
const sourcePaths = Array.isArray(skills?.sources)
|
||
? skills.sources.map((source) => `${source.status}:${source.path}`).slice(0, 8)
|
||
: [];
|
||
const lines = [
|
||
"这是当前 Codex stdio runner 读取到的真实 skill manifest 清单;没有使用文本 fallback。",
|
||
`数量:本次返回 ${skills.count ?? listed.length} 个;已发现总数 ${skills.totalCount ?? items.length} 个${skills.truncated ? `;列表已截断到 ${skills.count ?? listed.length} 个` : ""}。`,
|
||
sourcePaths.length ? `来源目录:${sourcePaths.join(";")}` : null,
|
||
""
|
||
].filter((line) => line !== null);
|
||
for (const [index, item] of listed.entries()) {
|
||
const metadata = [
|
||
item.version ? `version=${item.version}` : null,
|
||
item.commitId ? `commitId=${item.commitId}` : null,
|
||
item.manifestPath ? `manifest=${item.manifestPath}` : item.source ? `source=${item.source}` : null
|
||
].filter(Boolean).join(";");
|
||
lines.push(`${index + 1}. ${item.name}:${item.summary}${metadata ? `(${metadata})` : ""}`);
|
||
}
|
||
if (items.length > listed.length) {
|
||
lines.push(`其余 ${items.length - listed.length} 个 skill 未在正文展开,可在 payload.skills.items 查看。`);
|
||
}
|
||
return lines.join("\n").trim();
|
||
}
|
||
|
||
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, sidecar, gatewayShellTimeoutMs }) {
|
||
return [
|
||
`conversationId: ${conversationId}`,
|
||
`traceId: ${traceId}`,
|
||
`gatewayShellTimeoutMs: ${gatewayShellTimeoutMs}`,
|
||
"",
|
||
CODEX_STDIO_BOUNDARY_INSTRUCTIONS.replace(/<gatewayShellTimeoutMs>/gu, String(gatewayShellTimeoutMs)),
|
||
codexConversationFactsPrompt(conversationFacts),
|
||
codexSidecarSkillsPrompt(sidecar),
|
||
"",
|
||
"User message:",
|
||
String(message ?? "").trim()
|
||
].filter((line) => line !== null && line !== undefined).join("\n");
|
||
}
|
||
|
||
function normalizeGatewayShellTimeoutMs(value, env = process.env) {
|
||
const parsed = Number.parseInt(value ?? env.HWLAB_GATEWAY_SHELL_TIMEOUT_MS ?? "", 10);
|
||
if (!Number.isInteger(parsed) || parsed <= 0) return 120000;
|
||
return Math.min(Math.max(parsed, 30000), 600000);
|
||
}
|
||
|
||
function codexSidecarSkillsPrompt(sidecar) {
|
||
if (!sidecar?.intent?.skills) return null;
|
||
const skills = sidecar.skills;
|
||
if (!skills || typeof skills !== "object") return null;
|
||
if (skills.status !== "ready") {
|
||
const blockers = Array.isArray(skills.blockers)
|
||
? skills.blockers.map((blocker) => safePromptFact(`${blocker?.code ?? "blocked"}:${blocker?.sourceIssue ?? ""}`)).slice(0, 4).join(",")
|
||
: "skills_unavailable";
|
||
return [
|
||
"Current skills discovery facts (authoritative, bounded, redacted):",
|
||
`- status=${safePromptFact(skills.status)}; count=0; blockers=${blockers}.`,
|
||
"- Do not invent skill names. The API will return a structured skills_unavailable blocker."
|
||
].join("\n");
|
||
}
|
||
const items = Array.isArray(skills.items) ? skills.items.slice(0, 12) : [];
|
||
return [
|
||
"Current skills discovery facts (authoritative, bounded, redacted):",
|
||
`- status=ready; returned=${safePromptFact(skills.count)}; total=${safePromptFact(skills.totalCount)}; truncated=${skills.truncated === true ? "true" : "false"}.`,
|
||
...items.map((item) => `- ${safePromptFact(item.name)} | ${safePromptFact(item.summary)} | version=${safePromptFact(item.version)} | commitId=${safePromptFact(item.commitId)} | manifest=${safePromptFact(item.manifestPath ?? item.source)}`)
|
||
].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", "danger-full-access"].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 app-server --listen stdio://",
|
||
cancelSupported: true,
|
||
reapSupported: true,
|
||
secretMaterialStored: false,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function tokenBoundaryState(env = process.env, codexHomeFiles = null) {
|
||
const authPresent = codexHomeFiles?.authPresent === true && codexHomeFiles?.authReadable === true;
|
||
const present = authPresent ||
|
||
env.HWLAB_CODE_AGENT_CODEX_TOKEN_BOUNDARY === "configured" ||
|
||
env.HWLAB_CODE_AGENT_CODEX_TOKEN_BOUNDARY === "present";
|
||
return {
|
||
present,
|
||
sources: [
|
||
authPresent ? "CODEX_HOME/auth.json" : null,
|
||
env.HWLAB_CODE_AGENT_CODEX_TOKEN_BOUNDARY ? "HWLAB_CODE_AGENT_CODEX_TOKEN_BOUNDARY" : null
|
||
].filter(Boolean),
|
||
childEnvStripsProviderSecrets: true,
|
||
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 codexHomeFilesStateSync(codexHome) {
|
||
const configPath = path.join(codexHome, "config.toml");
|
||
const authPath = path.join(codexHome, "auth.json");
|
||
const configPresent = filePresentSync(configPath);
|
||
const authPresent = filePresentSync(authPath);
|
||
return {
|
||
configPath,
|
||
authPath,
|
||
configPresent,
|
||
configReadable: configPresent && accessSyncBoolean(configPath, fsConstants.R_OK),
|
||
authPresent,
|
||
authReadable: authPresent && accessSyncBoolean(authPath, fsConstants.R_OK),
|
||
mountContract: "writable CODEX_HOME emptyDir with read-only config.toml/auth.json file mounts",
|
||
secretMaterialRead: false,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function filePresentSync(targetPath) {
|
||
try {
|
||
return existsSync(targetPath) && statSync(targetPath).isFile();
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
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, initialized }) {
|
||
const protocolInitialized = initialized === true;
|
||
const wired = binary.present === true && supervisor.configured === true && protocolInitialized;
|
||
const probeReady = binary.present === true && supervisor.configured === true;
|
||
return {
|
||
transport: "stdio",
|
||
command: `${command} app-server --listen stdio://`,
|
||
protocolVersion: CODEX_APP_SERVER_PROTOCOL,
|
||
adapter: "Codex app-server JSON-RPC line protocol",
|
||
requiredMethods: [...CODEX_APP_SERVER_REQUIRED_METHODS],
|
||
initialized: protocolInitialized,
|
||
toolsObserved: [],
|
||
missingTools: [],
|
||
wired,
|
||
probeReady,
|
||
status: wired ? "wired" : "blocked",
|
||
blocker: wired ? null : "stdio_protocol_not_wired",
|
||
summary: wired
|
||
? "Codex app-server stdio protocol is initialized through the repo-owned JSON-RPC client."
|
||
: "Codex app-server stdio protocol has not been proven through initialize/thread/start/turn/start.",
|
||
nextEvidence: "Start `codex app-server --listen stdio://`, run initialize, then thread/start or thread/resume plus turn/start without printing token material.",
|
||
secretMaterialRead: false,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function cachedProtocolReady({ 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.initialized === true;
|
||
}
|
||
|
||
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,
|
||
initialized: false,
|
||
requiredMethods: [...CODEX_APP_SERVER_REQUIRED_METHODS],
|
||
secretMaterialRead: false,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
return {
|
||
status: probe.status,
|
||
ready: probe.ready === true,
|
||
initialized: probe.initialized === true,
|
||
requiredMethods: Array.isArray(probe.requiredMethods) ? [...probe.requiredMethods] : [...CODEX_APP_SERVER_REQUIRED_METHODS],
|
||
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,
|
||
codexHomeFiles,
|
||
tokenBoundary,
|
||
egress,
|
||
childEnvBoundary,
|
||
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,
|
||
protocolVersion: protocol.protocolVersion,
|
||
requiredMethods: [...protocol.requiredMethods],
|
||
initialized: protocol.initialized === true,
|
||
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, codexHomeFiles),
|
||
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
|
||
},
|
||
childEnvBoundary,
|
||
cancelReapTraceReadiness: cancelReapTraceState(lifecycle),
|
||
secretBoundary: {
|
||
secretsRead: false,
|
||
secretValuesPrinted: false,
|
||
kubeconfigRead: false,
|
||
valuesRedacted: true
|
||
}
|
||
};
|
||
}
|
||
|
||
function codexHomeContractState(codexHomeInfo, codexHome, codexHomeFiles = null) {
|
||
const filesReady = codexHomeFiles?.configPresent === true &&
|
||
codexHomeFiles?.configReadable === true &&
|
||
codexHomeFiles?.authPresent === true &&
|
||
codexHomeFiles?.authReadable === true;
|
||
const ready = codexHomeInfo.exists && codexHomeInfo.readable && codexHomeInfo.writable && filesReady;
|
||
return {
|
||
path: codexHome,
|
||
status: ready ? "ready" : "blocked",
|
||
exists: codexHomeInfo.exists,
|
||
readable: codexHomeInfo.readable,
|
||
writable: codexHomeInfo.writable,
|
||
configToml: {
|
||
path: codexHomeFiles?.configPath ?? path.join(codexHome, "config.toml"),
|
||
present: codexHomeFiles?.configPresent === true,
|
||
readable: codexHomeFiles?.configReadable === true
|
||
},
|
||
authJson: {
|
||
path: codexHomeFiles?.authPath ?? path.join(codexHome, "auth.json"),
|
||
present: codexHomeFiles?.authPresent === true,
|
||
readable: codexHomeFiles?.authReadable === true,
|
||
valuesRedacted: true
|
||
},
|
||
writeRequired: true,
|
||
blocker: ready
|
||
? null
|
||
: !codexHomeInfo.exists || !codexHomeInfo.readable
|
||
? "codex_home_missing"
|
||
: codexHomeInfo.writable !== true
|
||
? "codex_home_write_blocked"
|
||
: codexHomeFiles?.configPresent !== true || codexHomeFiles?.configReadable !== true
|
||
? "codex_home_config_missing"
|
||
: "codex_home_auth_missing",
|
||
secretMaterialRead: false,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function childProcessEnv(env = process.env) {
|
||
const noProxy = mergeNoProxy(env.NO_PROXY, env.no_proxy, CODEX_CHILD_NO_PROXY_REQUIRED, codexProviderNoProxyEntries(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),
|
||
CODEX_INTERNAL_ORIGINATOR_OVERRIDE: "hwlab_code_agent",
|
||
UNIDESK_SKILLS_PATH: "/app/skills",
|
||
NO_PROXY: noProxy,
|
||
no_proxy: noProxy,
|
||
...(env.LANG ? { LANG: env.LANG } : {}),
|
||
...(env.LC_ALL ? { LC_ALL: env.LC_ALL } : {}),
|
||
...(env.SSL_CERT_FILE ? { SSL_CERT_FILE: env.SSL_CERT_FILE } : {}),
|
||
...(env.NODE_EXTRA_CA_CERTS ? { NODE_EXTRA_CA_CERTS: env.NODE_EXTRA_CA_CERTS } : {}),
|
||
...(env.GIT_CONFIG_COUNT ? gitConfigEnv(env) : {})
|
||
};
|
||
}
|
||
|
||
function gitConfigEnv(env = process.env) {
|
||
const count = Number.parseInt(env.GIT_CONFIG_COUNT ?? "", 10);
|
||
if (!Number.isInteger(count) || count < 0 || count > 64) return {};
|
||
const result = { GIT_CONFIG_COUNT: String(count) };
|
||
for (let index = 0; index < count; index += 1) {
|
||
const keyName = `GIT_CONFIG_KEY_${index}`;
|
||
const valueName = `GIT_CONFIG_VALUE_${index}`;
|
||
if (typeof env[keyName] === "string") result[keyName] = env[keyName];
|
||
if (typeof env[valueName] === "string") result[valueName] = env[valueName];
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function childProcessEnvState(env = process.env) {
|
||
const child = childProcessEnv(env);
|
||
const inheritedForbidden = CODEX_CHILD_STRIPPED_ENV_KEYS.filter((key) => hasEnvValue(child, key));
|
||
const sourceForbiddenPresent = CODEX_CHILD_STRIPPED_ENV_KEYS.filter((key) => hasEnvValue(env, key));
|
||
const noProxyEntries = splitNoProxy(child.NO_PROXY);
|
||
const requiredMissing = CODEX_CHILD_NO_PROXY_REQUIRED.filter((entry) => !noProxyEntries.includes(entry.toLowerCase()));
|
||
const providerNoProxy = codexProviderNoProxyEntries(env);
|
||
return {
|
||
status: inheritedForbidden.length === 0 && requiredMissing.length === 0 ? "ready" : "blocked",
|
||
forbiddenSourceEnvKeysPresent: sourceForbiddenPresent,
|
||
forbiddenEnvPresent: inheritedForbidden.length > 0,
|
||
strippedEnvKeys: sourceForbiddenPresent.filter((key) => !inheritedForbidden.includes(key)),
|
||
inheritedForbiddenEnvKeys: inheritedForbidden,
|
||
noProxy: {
|
||
present: Boolean(child.NO_PROXY),
|
||
required: [...CODEX_CHILD_NO_PROXY_REQUIRED],
|
||
provider: providerNoProxy,
|
||
missing: requiredMissing,
|
||
merged: noProxyEntries
|
||
},
|
||
codeXHome: child.CODEX_HOME,
|
||
secretMaterialRead: false,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function codexProviderNoProxyEntries(env = process.env) {
|
||
const baseUrl = codexAppServerProviderBaseUrl(env);
|
||
if (!baseUrl) return [];
|
||
try {
|
||
const url = new URL(baseUrl);
|
||
const hostname = url.hostname.replace(/^\[|\]$/gu, "");
|
||
return [hostname, url.host].filter((entry, index, items) => entry && items.indexOf(entry) === index);
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function rpcClientConfigSignature({ availability, args = [], childEnv = {} } = {}) {
|
||
return JSON.stringify({
|
||
command: availability?.command ?? null,
|
||
workspace: availability?.workspace ?? null,
|
||
codexHome: childEnv.CODEX_HOME ?? null,
|
||
args
|
||
});
|
||
}
|
||
|
||
function mergeNoProxy(...parts) {
|
||
const entries = [];
|
||
const seen = new Set();
|
||
for (const entry of parts.flatMap((part) => Array.isArray(part) ? part : splitNoProxy(part))) {
|
||
const normalized = String(entry ?? "").trim();
|
||
if (!normalized) continue;
|
||
const key = normalized.toLowerCase();
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
entries.push(normalized);
|
||
}
|
||
return entries.join(",");
|
||
}
|
||
|
||
function splitNoProxy(value) {
|
||
return String(value ?? "")
|
||
.split(/[,;\s]+/u)
|
||
.map((item) => item.trim())
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function codexStdioSafety() {
|
||
return {
|
||
secretsRead: false,
|
||
secretValuesPrinted: false,
|
||
kubeconfigRead: false,
|
||
prodTouched: false,
|
||
hardwareWritesAllowed: true,
|
||
directGatewayCallsAllowed: true,
|
||
directBoxSimuCallsAllowed: true,
|
||
directPatchPanelCallsAllowed: true,
|
||
hardwareControlPath: "codex-stdio-full-access",
|
||
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 effectiveActivityTimeout(timeoutMs);
|
||
}
|
||
|
||
function effectiveActivityTimeout(timeoutMs) {
|
||
return Number.isInteger(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_CODEX_STDIO_TURN_ACTIVITY_TIMEOUT_MS;
|
||
}
|
||
|
||
function effectiveHardTimeout(hardTimeoutMs, activityTimeoutMs = null) {
|
||
const activityMs = effectiveActivityTimeout(activityTimeoutMs);
|
||
const configured = Number.isInteger(hardTimeoutMs) && hardTimeoutMs > 0
|
||
? hardTimeoutMs
|
||
: DEFAULT_CODEX_STDIO_TURN_HARD_TIMEOUT_MS;
|
||
return Math.max(configured, activityMs);
|
||
}
|
||
|
||
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***");
|
||
}
|