Files
pikasTech-HWLAB/internal/cloud/code-agent-chat.mjs
T
Lyon d7e84f359b feat: gate Code Agent session registry
Implements #317 session-gated read-only runner phase with structured long-lived Codex stdio blockers.
2026-05-23 18:19:58 +08:00

2251 lines
78 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { constants as fsConstants, existsSync } from "node:fs";
import { access, mkdir, open, readFile, readdir, rm, stat } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
DEV_CODE_AGENT_PROVIDER_CONTRACT,
codeAgentSecretRefPlaceholder,
inspectCodeAgentProviderEnv
} from "./code-agent-contract.mjs";
import {
DEFAULT_CODE_AGENT_SESSION_IDLE_TIMEOUT_MS,
createCodeAgentSessionRegistry
} from "./code-agent-session-registry.mjs";
const DEFAULT_CODE_AGENT_TIMEOUT_MS = 120000;
const DEFAULT_CODEX_COMMAND = "codex";
const DEFAULT_MODEL = DEV_CODE_AGENT_PROVIDER_CONTRACT.model;
const DEFAULT_PROJECT_ID = "prj_hwlab-cloud-workbench";
const READONLY_RUNNER_PROVIDER = "codex-readonly-runner";
const READONLY_RUNNER_BACKEND = "hwlab-cloud-api/codex-readonly-runner";
const READONLY_RUNNER_MODEL = "read-only-tools";
const READONLY_RUNNER_KIND = "hwlab-readonly-runner";
const READONLY_RUNNER_SANDBOX = "read-only";
const READONLY_SESSION_MODE = "controlled-readonly-session-registry";
const READONLY_IMPLEMENTATION_TYPE = "controlled-readonly-session-registry";
const READONLY_SESSION_CAPABILITY_LEVEL = "read-only-session-tools";
const OPENAI_FALLBACK_RUNNER_KIND = "openai-responses-fallback";
const CODEX_CLI_ONE_SHOT_RUNNER_KIND = "codex-cli-one-shot-ephemeral";
const READONLY_TOOL_OUTPUT_LIMIT = 4000;
const READONLY_FILE_READ_LIMIT = 64 * 1024;
const READONLY_FILE_ENTRY_LIMIT = 200;
const READONLY_FILE_TREE_DEPTH = 8;
const MAX_READONLY_SESSIONS = 200;
const MAX_SKILLS_RETURNED = 40;
const CODE_AGENT_PROVIDER_SECRET_REF = codeAgentSecretRefPlaceholder().replace("secretRef:", "");
const READONLY_LIMITATION_FLAGS = Object.freeze([
"not-codex-stdio",
"not-write-capable",
"not-durable-session"
]);
const SKIPPED_READONLY_DIRS = new Set([
".git",
".hg",
".svn",
".cache",
".worktrees",
"node_modules",
"dist",
"build",
"coverage"
]);
const CODE_AGENT_SYSTEM_PROMPT = [
"你是 HWLAB 云工作台的 Code Agent。",
"请用中文直接回答用户的工作台问题。",
"当前最小版本可以帮助用户整理任务、查看云工作台资源和说明下一步;不要声称已经执行硬件变更。",
"不要声称已通过 M3、M4 或 M5 验收。",
"不要声称 OpenAI Responses 文本 fallback 具备 Codex runner、workspace、tools 或 skills 能力。",
"不要输出 secret、token、kubeconfig 或环境变量原文。"
].join("\n");
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const defaultCodeAgentSessionRegistry = createCodeAgentSessionRegistry({
idleTimeoutMs: DEFAULT_CODE_AGENT_SESSION_IDLE_TIMEOUT_MS,
maxSessions: MAX_READONLY_SESSIONS
});
export async function handleCodeAgentChat(params = {}, options = {}) {
const timestamp = nowIso(options.now);
const { conversationId, sessionId, requestedSessionId } = resolveConversationSessionIds(params);
const messageId = `msg_${randomUUID()}`;
const traceId = cleanProtocolId(params.traceId, "trc") || `trc_${randomUUID()}`;
const providerPlan = resolveProviderPlan(options.env ?? process.env, options);
const base = {
conversationId,
sessionId,
messageId,
status: "running",
createdAt: timestamp,
updatedAt: timestamp,
traceId,
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend,
projectId: cleanProjectId(params.projectId) || DEFAULT_PROJECT_ID
};
try {
const message = normalizeUserMessage(params.message);
const runnerIntent = detectReadOnlyRunnerIntent(message);
if (runnerIntent.kind !== "none") {
const runnerResult = await callReadOnlyRunner({
intent: runnerIntent,
conversationId,
traceId,
sessionId: requestedSessionId,
env: options.env ?? process.env,
now: options.now,
workspace: options.workspace,
skillsDirs: options.skillsDirs,
skillsDirsExact: options.skillsDirsExact,
sessionRegistry: options.sessionRegistry
});
const completedAt = nowIso(options.now);
return {
...base,
sessionId: runnerResult.session?.sessionId ?? base.sessionId,
status: "completed",
updatedAt: completedAt,
provider: runnerResult.provider,
model: runnerResult.model,
backend: runnerResult.backend,
workspace: runnerResult.workspace,
sandbox: runnerResult.sandbox,
session: runnerResult.session,
sessionMode: runnerResult.sessionMode,
sessionReuse: runnerResult.sessionReuse,
implementationType: runnerResult.implementationType,
runnerLimitations: runnerResult.runnerLimitations,
codexStdioFeasibility: runnerResult.codexStdioFeasibility,
longLivedSessionGate: runnerResult.longLivedSessionGate,
toolCalls: runnerResult.toolCalls,
skills: runnerResult.skills,
runner: runnerResult.runner,
runnerTrace: runnerResult.runnerTrace,
capabilityLevel: runnerResult.capabilityLevel,
reply: {
messageId,
role: "assistant",
content: runnerResult.content,
createdAt: completedAt
},
usage: null,
providerTrace: runnerResult.providerTrace
};
}
const providerResult = await callConfiguredProvider({
providerPlan,
message,
conversationId,
traceId,
timeoutMs: options.timeoutMs,
env: options.env ?? process.env,
now: options.now,
callProvider: options.callProvider
});
const content = typeof providerResult.content === "string" ? providerResult.content.trim() : "";
if (!content) {
throw providerUnavailable("Code Agent provider returned no assistant text", {
provider: providerResult.provider ?? base.provider,
model: providerResult.model ?? base.model,
backend: providerResult.backend ?? base.backend
});
}
const completedAt = nowIso(options.now);
return {
...base,
status: "completed",
updatedAt: completedAt,
provider: providerResult.provider ?? base.provider,
model: providerResult.model ?? base.model,
backend: providerResult.backend ?? base.backend,
workspace: providerResult.workspace ?? null,
sandbox: providerResult.sandbox ?? "none",
session: providerResult.session ?? null,
sessionMode: providerResult.sessionMode ?? "provider-text-request",
sessionReuse: providerResult.sessionReuse ?? null,
implementationType: providerResult.implementationType ?? OPENAI_FALLBACK_RUNNER_KIND,
runnerLimitations: providerResult.runnerLimitations ?? [
"text-chat-only",
"not-codex-stdio",
"not-workspace-tools",
"not-durable-session"
],
codexStdioFeasibility: providerResult.codexStdioFeasibility ?? inspectCodexStdioFeasibility(envForFeasibility(options.env ?? process.env)),
longLivedSessionGate: providerResult.longLivedSessionGate ?? longLivedSessionGate({
provider: providerResult.provider ?? base.provider,
runnerKind: providerResult.runner?.kind ?? OPENAI_FALLBACK_RUNNER_KIND,
session: providerResult.session ?? null,
sessionMode: providerResult.sessionMode ?? "provider-text-request",
implementationType: providerResult.implementationType ?? OPENAI_FALLBACK_RUNNER_KIND,
codexStdioFeasibility: providerResult.codexStdioFeasibility ?? inspectCodexStdioFeasibility(envForFeasibility(options.env ?? process.env))
}),
toolCalls: Array.isArray(providerResult.toolCalls) ? providerResult.toolCalls : [],
skills: providerResult.skills ?? {
status: "not_requested",
items: [],
blockers: []
},
runner: providerResult.runner ?? openAiFallbackRunnerEvidence({ providerResult, traceId, conversationId, sessionId }),
runnerTrace: providerResult.runnerTrace ?? {
traceId,
runnerKind: OPENAI_FALLBACK_RUNNER_KIND,
events: ["fallback:text-chat-only"],
note: "OpenAI Responses fallback can answer ordinary chat, but it does not satisfy the Codex runner capability gate."
},
capabilityLevel: providerResult.capabilityLevel ?? "text-chat-only",
reply: {
messageId,
role: "assistant",
content,
createdAt: completedAt
},
usage: providerResult.usage ?? null,
providerTrace: providerResult.providerTrace ?? null
};
} catch (error) {
const failedAt = nowIso(options.now);
const payload = {
...base,
status: "failed",
updatedAt: failedAt,
error: normalizeChatError(error)
};
if (error.provider) payload.provider = error.provider;
if (error.model) payload.model = error.model;
if (error.backend) payload.backend = error.backend;
if (error.workspace !== undefined) payload.workspace = error.workspace;
if (error.sandbox !== undefined) payload.sandbox = error.sandbox;
if (error.session !== undefined) payload.session = error.session;
if (error.toolCalls !== undefined) payload.toolCalls = error.toolCalls;
if (error.skills !== undefined) payload.skills = error.skills;
if (error.runner !== undefined) payload.runner = error.runner;
if (error.runnerTrace !== undefined) payload.runnerTrace = error.runnerTrace;
if (error.capabilityLevel !== undefined) payload.capabilityLevel = error.capabilityLevel;
if (error.sessionMode !== undefined) payload.sessionMode = error.sessionMode;
if (error.sessionReuse !== undefined) payload.sessionReuse = error.sessionReuse;
if (error.implementationType !== undefined) payload.implementationType = error.implementationType;
if (error.runnerLimitations !== undefined) payload.runnerLimitations = error.runnerLimitations;
if (error.codexStdioFeasibility !== undefined) payload.codexStdioFeasibility = error.codexStdioFeasibility;
if (error.longLivedSessionGate !== undefined) payload.longLivedSessionGate = error.longLivedSessionGate;
if (error.code === "provider_unavailable") {
payload.availability = describeCodeAgentAvailability(options.env ?? process.env, options);
} else if (["runner_unavailable", "tool_unavailable", "skills_unavailable", "security_blocked"].includes(error.code)) {
payload.availability = describeCodeAgentAvailability(options.env ?? process.env, options);
}
return payload;
}
}
export function validateCodeAgentChatSchema(payload) {
const required = [
"conversationId",
"sessionId",
"messageId",
"status",
"createdAt",
"updatedAt",
"traceId",
"provider",
"model",
"backend"
];
for (const field of required) {
if (!payload || typeof payload !== "object" || !Object.hasOwn(payload, field)) {
throw new Error(`code agent chat response missing ${field}`);
}
}
if (!["running", "completed", "failed"].includes(payload.status)) {
throw new Error(`code agent chat response has invalid status ${JSON.stringify(payload.status)}`);
}
if (Number.isNaN(Date.parse(payload.createdAt)) || Number.isNaN(Date.parse(payload.updatedAt))) {
throw new Error("code agent chat response timestamps must be RFC 3339 strings");
}
if (payload.status === "failed" && typeof payload.error?.message !== "string") {
throw new Error("failed code agent chat response must include error.message");
}
if (payload.status === "completed" && (typeof payload.reply?.content !== "string" || !payload.reply.content.trim())) {
throw new Error("completed code agent chat response must include non-empty reply.content");
}
}
export function describeCodeAgentAvailability(env = process.env, options = {}) {
const providerPlan = resolveProviderPlan(env, options);
const providerContract = inspectCodeAgentProviderEnv(env);
const missingEnv = providerPlan.mode === "openai"
? providerContract.missingEnv
: [];
if (providerPlan.mode !== "openai" && !env.OPENAI_API_KEY) {
missingEnv.push("OPENAI_API_KEY");
}
const blocked = providerPlan.mode === "openai"
? !providerContract.ready
: missingEnv.length > 0;
const sessionRegistry = resolveCodeAgentSessionRegistry(options);
const runnerAvailability = inspectReadOnlyRunnerAvailability(env, {
...options,
sessionRegistry
});
return {
endpoint: "POST /v1/agent/chat",
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend,
mode: providerPlan.mode,
schema: [
"conversationId",
"sessionId",
"messageId",
"status",
"createdAt",
"updatedAt",
"traceId",
"provider",
"model",
"backend",
"runner",
"workspace",
"sandbox",
"toolCalls",
"skills",
"runnerTrace",
"capabilityLevel",
"session",
"sessionMode",
"sessionReuse",
"implementationType",
"runnerLimitations",
"codexStdioFeasibility",
"longLivedSessionGate",
"error.message",
"error.code",
"error.missingEnv",
"availability.status"
],
runner: runnerAvailability,
status: blocked && !runnerAvailability.ready ? "blocked" : "available",
blocker: blocked && !runnerAvailability.ready ? (providerPlan.mode === "openai" ? providerContract.blocker : "凭证缺口") : null,
reason: blocked && !runnerAvailability.ready ? "provider_unavailable" : null,
summary: blocked
? `受控只读 runner 可用于 pwd/skills 等工作区能力;OpenAI Responses fallback 当前受 DEV provider Secret ${CODE_AGENT_PROVIDER_SECRET_REF} 或 DEV egress/base-url contract 影响。`
: "真实后端已接入;pwd/skills 走受控只读 runner,普通聊天可走 OpenAI Responses fallback。OpenAI fallback 不满足 Codex runner capability gate。",
missingEnv,
secretRefs: blocked ? providerContract.secretRefs : [],
egress: providerContract.egress,
safety: providerContract.safety,
capabilityLevel: runnerAvailability.ready ? READONLY_SESSION_CAPABILITY_LEVEL : blocked ? "blocked" : "text-chat-only",
sessionRegistry: runnerAvailability.sessionRegistry,
sessionMode: runnerAvailability.sessionMode,
implementationType: runnerAvailability.implementationType,
runnerLimitations: runnerAvailability.runnerLimitations,
codexStdioFeasibility: runnerAvailability.codexStdioFeasibility,
longLivedSessionGate: runnerAvailability.longLivedSessionGate,
ready: !blocked || runnerAvailability.ready
};
}
function resolveProviderPlan(env, options = {}) {
const provider = String(env.HWLAB_CODE_AGENT_PROVIDER || "auto").trim().toLowerCase();
const model = firstNonEmpty(
options.model,
env.HWLAB_CODE_AGENT_MODEL,
env.OPENAI_MODEL,
env.CODE_QUEUE_DEFAULT_MODEL,
DEFAULT_MODEL
);
if (provider === "openai") {
return {
provider: "openai-responses",
model,
backend: "hwlab-cloud-api/openai-responses",
mode: "openai"
};
}
if (provider === "codex-cli" || provider === "codex") {
return {
provider: "codex-cli",
model,
backend: "hwlab-cloud-api/codex-cli",
mode: "codex-cli"
};
}
return {
provider: env.OPENAI_API_KEY ? "openai-responses" : "codex-cli",
model,
backend: env.OPENAI_API_KEY ? "hwlab-cloud-api/openai-responses" : "hwlab-cloud-api/codex-cli",
mode: env.OPENAI_API_KEY ? "openai" : "codex-cli",
auto: true
};
}
async function callConfiguredProvider({
providerPlan,
message,
conversationId,
traceId,
timeoutMs,
env,
now,
callProvider
}) {
if (callProvider) {
return callProvider({ providerPlan, message, conversationId, traceId, timeoutMs, env, now });
}
if (providerPlan.mode === "openai") {
return callOpenAiResponses({ providerPlan, message, conversationId, traceId, timeoutMs, env });
}
return callCodexCli({ providerPlan, message, conversationId, traceId, timeoutMs, env });
}
function inspectReadOnlyRunnerAvailability(env, options = {}) {
const workspace = resolveRunnerWorkspace(env, options);
const workspaceReady = Boolean(workspace && existsSync(workspace));
const skillsDirs = resolveSkillDirs(env, options);
const skillsDirsPresent = skillsDirs.filter((dir) => existsSync(dir));
const codexStdioFeasibility = inspectCodexStdioFeasibility(envForFeasibility(env));
const sessionRegistry = resolveCodeAgentSessionRegistry(options).describe();
return {
kind: READONLY_RUNNER_KIND,
backend: READONLY_RUNNER_BACKEND,
provider: READONLY_RUNNER_PROVIDER,
workspace,
sandbox: READONLY_RUNNER_SANDBOX,
mode: READONLY_SESSION_MODE,
sessionMode: READONLY_SESSION_MODE,
session: READONLY_SESSION_MODE,
status: workspaceReady ? "available" : "blocked",
ready: workspaceReady,
capabilityLevel: workspaceReady ? READONLY_SESSION_CAPABILITY_LEVEL : "blocked",
implementationType: READONLY_IMPLEMENTATION_TYPE,
longLivedSession: false,
durableSession: false,
codexStdio: false,
writeCapable: false,
runnerLimitations: [...READONLY_LIMITATION_FLAGS],
codexStdioFeasibility,
longLivedSessionGate: longLivedSessionGate({
provider: READONLY_RUNNER_PROVIDER,
runnerKind: READONLY_RUNNER_KIND,
sessionMode: READONLY_SESSION_MODE,
implementationType: READONLY_IMPLEMENTATION_TYPE,
codexStdioFeasibility
}),
sessionRegistry,
skillsDirs,
skillsDirsPresent,
safety: runnerSafetyContract()
};
}
async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, env, now, workspace, skillsDirs, skillsDirsExact, sessionRegistry }) {
const resolvedWorkspace = resolveRunnerWorkspace(env, { workspace });
const registry = resolveCodeAgentSessionRegistry({ sessionRegistry });
const sessionAcquire = registry.acquire({
conversationId,
sessionId,
workspace: resolvedWorkspace,
sandbox: READONLY_RUNNER_SANDBOX,
runnerKind: READONLY_RUNNER_KIND,
capabilityLevel: READONLY_SESSION_CAPABILITY_LEVEL,
implementationType: READONLY_IMPLEMENTATION_TYPE,
traceId,
now
});
if (!sessionAcquire.ok) {
const blockedSession = sessionAcquire.session;
const blockedTrace = runnerTrace({
traceId,
workspace: resolvedWorkspace ?? repoRoot,
session: blockedSession,
events: [`blocked:${sessionAcquire.code}`],
startedAt: nowIso(now),
outputTruncated: false
});
throw runnerError(sessionAcquire.code, sessionAcquire.message, {
workspace: resolvedWorkspace ?? null,
session: blockedSession,
toolCalls: [],
skills: notRequestedSkills(),
runner: runnerDescriptor({ workspace: resolvedWorkspace, session: blockedSession }),
runnerTrace: blockedTrace,
capabilityLevel: "blocked",
sessionMode: READONLY_SESSION_MODE,
sessionReuse: sessionReuseEvidence(blockedSession),
implementationType: READONLY_IMPLEMENTATION_TYPE,
runnerLimitations: [...READONLY_LIMITATION_FLAGS],
codexStdioFeasibility: inspectCodexStdioFeasibility(envForFeasibility(env)),
longLivedSessionGate: longLivedSessionGate({
provider: READONLY_RUNNER_PROVIDER,
runnerKind: READONLY_RUNNER_KIND,
session: blockedSession,
sessionMode: READONLY_SESSION_MODE,
implementationType: READONLY_IMPLEMENTATION_TYPE,
codexStdioFeasibility: inspectCodexStdioFeasibility(envForFeasibility(env))
}),
blockers: [sessionAcquire.blocker]
});
}
let session = sessionAcquire.session;
const runner = runnerDescriptor({ workspace: resolvedWorkspace, session });
const startedAt = nowIso(now);
const codexStdioFeasibility = inspectCodexStdioFeasibility(envForFeasibility(env));
const events = [
`intent:${intent.kind}`,
"sandbox:read-only",
`sessionMode:${READONLY_SESSION_MODE}`,
session.reused ? "session:reused" : "session:created",
`turn:${session.turn}`
];
const baseEvidence = {
sessionMode: READONLY_SESSION_MODE,
sessionReuse: sessionReuseEvidence(session),
implementationType: READONLY_IMPLEMENTATION_TYPE,
runnerLimitations: [...READONLY_LIMITATION_FLAGS],
codexStdioFeasibility,
longLivedSessionGate: longLivedSessionGate({
provider: READONLY_RUNNER_PROVIDER,
runnerKind: READONLY_RUNNER_KIND,
session,
sessionMode: READONLY_SESSION_MODE,
implementationType: READONLY_IMPLEMENTATION_TYPE,
codexStdioFeasibility
})
};
if (!resolvedWorkspace || !(await pathReadable(resolvedWorkspace))) {
session = registry.fail(session.sessionId, {
now,
traceId,
conversationId,
reused: session.reused,
statusReason: "workspace_unreadable"
}) ?? session;
throw runnerError("runner_unavailable", `Read-only runner workspace is not readable: ${resolvedWorkspace || "missing"}`, {
workspace: resolvedWorkspace ?? null,
session,
toolCalls: [],
skills: notRequestedSkills(),
runner,
runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace ?? repoRoot, session, events: [...events, "blocked:workspace_unreadable"], startedAt, outputTruncated: false }),
capabilityLevel: "blocked",
...baseEvidence
});
}
if (intent.kind === "security") {
session = registry.fail(session.sessionId, {
now,
traceId,
conversationId,
reused: session.reused,
statusReason: "security_blocked"
}) ?? session;
throw runnerError("security_blocked", intent.reason ?? "The read-only runner blocked a request that could expose secrets or mutate hardware", {
workspace: resolvedWorkspace,
session,
toolCalls: [{
id: `tool_${randomUUID()}`,
type: "security",
name: intent.toolName ?? "security.guard",
status: "blocked",
cwd: resolvedWorkspace,
exitCode: 1,
stdout: "",
stderrSummary: "security_blocked",
outputTruncated: false
}],
skills: notRequestedSkills(),
runner,
runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, `blocked:${intent.toolName ?? "security.guard"}`], startedAt, outputTruncated: false }),
capabilityLevel: "blocked",
...baseEvidence,
blockers: [{
code: "security_blocked",
sourceIssue: "pikasTech/HWLAB#275",
summary: intent.reason ?? "Read-only runner guardrail blocked the request."
}]
});
}
if (intent.kind === "pwd") {
const toolCall = await runPwdTool({ workspace: resolvedWorkspace, traceId, env });
session = releaseReadOnlySession(registry, session, { now, traceId, conversationId });
return readOnlyRunnerResult({
content: [
"当前受控只读 runner 工作目录:",
toolCall.stdout,
"",
sessionReplyLine(session),
limitationReplyLine()
].join("\n"),
workspace: resolvedWorkspace,
toolCalls: [toolCall],
skills: notRequestedSkills(),
runner,
runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:pwd:completed"], startedAt, outputTruncated: toolCall.outputTruncated }),
session,
codexStdioFeasibility,
outputTruncated: toolCall.outputTruncated
});
}
if (intent.kind === "ls") {
const toolCall = await runLsTool({ workspace: resolvedWorkspace, target: intent.target, traceId });
assertReadOnlyToolCompleted(toolCall, {
session,
workspace: resolvedWorkspace,
skills: notRequestedSkills(),
runner,
runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:ls:blocked"], startedAt, outputTruncated: false }),
baseEvidence
});
session = releaseReadOnlySession(registry, session, { now, traceId, conversationId });
return readOnlyRunnerResult({
content: [
"受控只读 ls 结果:",
toolCall.stdout || "(empty)",
"",
sessionReplyLine(session),
limitationReplyLine()
].join("\n"),
workspace: resolvedWorkspace,
toolCalls: [toolCall],
skills: notRequestedSkills(),
runner,
runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:ls:completed"], startedAt, outputTruncated: toolCall.outputTruncated }),
session,
codexStdioFeasibility,
outputTruncated: toolCall.outputTruncated
});
}
if (intent.kind === "rg_files") {
const toolCall = await runRgFilesTool({ workspace: resolvedWorkspace, target: intent.target, traceId, env });
assertReadOnlyToolCompleted(toolCall, {
session,
workspace: resolvedWorkspace,
skills: notRequestedSkills(),
runner,
runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:rg --files:blocked"], startedAt, outputTruncated: false }),
baseEvidence
});
session = releaseReadOnlySession(registry, session, { now, traceId, conversationId });
return readOnlyRunnerResult({
content: [
"受控只读 rg --files 结果:",
toolCall.stdout || "(empty)",
"",
sessionReplyLine(session),
limitationReplyLine()
].join("\n"),
workspace: resolvedWorkspace,
toolCalls: [toolCall],
skills: notRequestedSkills(),
runner,
runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:rg --files:completed"], startedAt, outputTruncated: toolCall.outputTruncated }),
session,
codexStdioFeasibility,
outputTruncated: toolCall.outputTruncated
});
}
if (intent.kind === "cat") {
const toolCall = await runCatTool({ workspace: resolvedWorkspace, target: intent.target, traceId });
assertReadOnlyToolCompleted(toolCall, {
session,
workspace: resolvedWorkspace,
skills: notRequestedSkills(),
runner,
runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:cat:blocked"], startedAt, outputTruncated: false }),
baseEvidence
});
session = releaseReadOnlySession(registry, session, { now, traceId, conversationId });
return readOnlyRunnerResult({
content: [
"受控只读 cat 结果:",
toolCall.stdout || "(empty)",
"",
sessionReplyLine(session),
limitationReplyLine()
].join("\n"),
workspace: resolvedWorkspace,
toolCalls: [toolCall],
skills: notRequestedSkills(),
runner,
runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:cat:completed"], startedAt, outputTruncated: toolCall.outputTruncated }),
session,
codexStdioFeasibility,
outputTruncated: toolCall.outputTruncated
});
}
if (intent.kind === "skills") {
const skills = await discoverSkills({ env, skillsDirs, skillsDirsExact, traceId });
if (skills.status === "blocked") {
session = registry.fail(session.sessionId, {
now,
traceId,
conversationId,
reused: session.reused,
statusReason: "skills_unavailable"
}) ?? session;
throw runnerError("skills_unavailable", "No usable SKILL.md manifest was found for the read-only runner", {
workspace: resolvedWorkspace,
session,
toolCalls: [{
id: `tool_${randomUUID()}`,
type: "file-read",
name: "skills.discover",
status: "blocked",
cwd: resolvedWorkspace,
exitCode: 1,
stdout: "",
stderrSummary: "skills_unavailable",
outputTruncated: false
}],
skills,
runner,
runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:skills.discover:blocked"], startedAt, outputTruncated: false }),
capabilityLevel: "blocked",
...baseEvidence,
blockers: skills.blockers
});
}
session = releaseReadOnlySession(registry, session, { now, traceId, conversationId });
return readOnlyRunnerResult({
content: skillsReply(skills),
workspace: resolvedWorkspace,
toolCalls: [{
id: `tool_${randomUUID()}`,
type: "file-read",
name: "skills.discover",
status: "completed",
cwd: resolvedWorkspace,
exitCode: 0,
stdout: `skills=${skills.items.length}`,
stderrSummary: "",
outputTruncated: Boolean(skills.truncated)
}],
skills,
runner,
runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:skills.discover:completed"], startedAt, outputTruncated: Boolean(skills.truncated) }),
session,
codexStdioFeasibility,
outputTruncated: Boolean(skills.truncated)
});
}
session = registry.fail(session.sessionId, {
now,
traceId,
conversationId,
reused: session.reused,
statusReason: "tool_unavailable"
}) ?? session;
throw runnerError("tool_unavailable", `Read-only runner does not expose requested tool: ${intent.toolName ?? "unknown"}`, {
workspace: resolvedWorkspace,
session,
toolCalls: [{
id: `tool_${randomUUID()}`,
type: "tool",
name: intent.toolName ?? "unsupported",
status: "blocked",
cwd: resolvedWorkspace,
exitCode: 1,
stdout: "",
stderrSummary: "tool_unavailable",
outputTruncated: false
}],
skills: notRequestedSkills(),
runner,
runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, `tool:${intent.toolName ?? "unsupported"}:blocked`], startedAt, outputTruncated: false }),
capabilityLevel: "blocked",
...baseEvidence
});
}
function detectReadOnlyRunnerIntent(message) {
const text = String(message ?? "").trim();
const lower = text.toLowerCase();
if (isSecretReadRequest(text)) {
return {
kind: "security",
toolName: "security.secret-redaction",
reason: "只读 runner 不读取或输出 secret、token、kubeconfig、密码、私钥或环境变量原文。"
};
}
if (isHardwareWriteOrAcceptanceRequest(text)) {
return {
kind: "security",
toolName: "security.hardware-boundary",
reason: "只读 runner 不直接调用 gateway/box-simu/patch-panel、硬件写接口或宣称 M3/M4/M5 验收通过。"
};
}
if (/\bpwd\b/u.test(lower) || /当前.*(?:工作目录|目录)|工作目录|当前路径|workspace path|工作区路径/iu.test(text)) {
return { kind: "pwd", toolName: "pwd" };
}
if (/(?:可用|能使用|加载|列出|所有).{0,16}(?:skills?|skill|技能)|(?:skills?|skill|技能).{0,16}(?:可用|能使用|加载|列出|所有)/iu.test(text)) {
return { kind: "skills", toolName: "skills.discover" };
}
if (/\brg\s+--files\b/u.test(lower) || /(?:列出|查看).{0,12}(?:文件列表|所有文件|源码文件|代码文件)/u.test(text)) {
return { kind: "rg_files", toolName: "rg --files", target: extractReadOnlyTarget(text, { defaultTarget: "." }) };
}
if (/\bls\b/u.test(lower) || /(?:列出|查看).{0,12}(?:目录|文件)/u.test(text)) {
return { kind: "ls", toolName: "ls", target: extractReadOnlyTarget(text, { defaultTarget: "." }) };
}
if (/\bcat\b/u.test(lower) || /(?:读取|查看).{0,10}(?:文件|源码|代码)/u.test(text)) {
const target = extractReadOnlyTarget(text, { preferFile: true });
if (!target) {
return { kind: "unsupported", toolName: "cat" };
}
return { kind: "cat", toolName: "cat", target };
}
if (/\b(?:grep|find|sed|awk)\b/u.test(lower)) {
const tool = lower.match(/\b(?:grep|find|sed|awk)\b/u)?.[0] ?? "file.read";
return { kind: "unsupported", toolName: tool };
}
return { kind: "none" };
}
function extractReadOnlyTarget(text, { defaultTarget = null, preferFile = false } = {}) {
const value = String(text ?? "");
const quoted = value.match(/[`"']([^`"']{1,240})[`"']/u)?.[1];
if (quoted) return quoted.trim();
const tokenPattern = preferFile
? /(?:^|\s)((?:\.{1,2}\/|\/)?[A-Za-z0-9._@:/+=-]+\/?[A-Za-z0-9._@:/+=-]*(?:\.[A-Za-z0-9._-]+)?)(?:\s|$)/gu
: /(?:^|\s)((?:\.{1,2}\/|\/)?[A-Za-z0-9._@:/+=-]+)(?:\s|$)/gu;
const stopWords = new Set(["cat", "ls", "rg", "--files", "读取", "查看", "列出", "文件", "目录", "源码", "代码", "你", "请用"]);
for (const match of value.matchAll(tokenPattern)) {
const candidate = match[1]?.trim();
if (!candidate || stopWords.has(candidate)) continue;
if (/^(?:pwd|skills?|skill)$/iu.test(candidate)) continue;
if (!preferFile && !/[/.]/u.test(candidate)) continue;
return candidate;
}
const inlinePath = value.match(/((?:\.{1,2}\/|\/)[A-Za-z0-9._@:/+=-]+|[A-Za-z0-9._@+=-]+\.[A-Za-z0-9._-]+)/u)?.[1];
if (inlinePath && !stopWords.has(inlinePath) && !/^(?:pwd|skills?|skill)$/iu.test(inlinePath)) {
return inlinePath;
}
return defaultTarget;
}
function isSecretReadRequest(text) {
const asksToRead = /(?:print|show|cat|read|list|dump|echo|输出|显示|读取|列出|查看|打印)/iu.test(text);
const secretTerm = /(?:secret|token|kubeconfig|OPENAI_API_KEY|DATABASE_URL|password|passwd|credential|private key|私钥|密码|凭证|环境变量|密钥)/iu.test(text);
return asksToRead && secretTerm;
}
function isHardwareWriteOrAcceptanceRequest(text) {
const hardwareTarget = /(?:hardware\.operation\.request|hardware\.invoke\.shell|audit\.event\.write|evidence\.record\.write|gateway-simu|box-simu|patch-panel|hwlab-patch-panel|硬件写|直接调用)/iu.test(text);
const mutationVerb = /(?:call|invoke|write|mutate|apply|rollout|accept|pass|验收|通过|写入|调用|变更|操作)/iu.test(text);
const acceptanceClaim = /(?:M3|M4|M5).{0,20}(?:pass|accept|green|通过|验收|完成)/iu.test(text);
return (hardwareTarget && mutationVerb) || acceptanceClaim;
}
function resolveRunnerWorkspace(env = process.env, options = {}) {
const configured = firstNonEmpty(
options.workspace,
env.HWLAB_CODE_AGENT_WORKSPACE,
env.HWLAB_RUNNER_WORKSPACE,
env.WORKSPACE
);
return path.resolve(configured || repoRoot);
}
function resolveSkillDirs(env = process.env, options = {}) {
const configured = Array.isArray(options.skillsDirs)
? options.skillsDirs
: String(firstNonEmpty(env.HWLAB_CODE_AGENT_SKILLS_DIRS, env.UNIDESK_SKILLS_PATH, ""))
.split(/[,;]/u)
.flatMap((part) => part.split(path.delimiter));
const strict = options.skillsDirsExact === true || env.HWLAB_CODE_AGENT_SKILLS_STRICT === "1";
if (strict) {
return [...new Set(configured
.filter((dir) => typeof dir === "string" && dir.trim())
.map((dir) => path.resolve(dir.trim())))];
}
return [...new Set([
...configured,
path.join(os.homedir(), ".agents", "skills"),
"/root/.agents/skills",
"/home/ubuntu/.agents/skills",
path.join(repoRoot, "skills")
]
.filter((dir) => typeof dir === "string" && dir.trim())
.map((dir) => path.resolve(dir.trim())))];
}
async function pathReadable(targetPath) {
try {
await access(targetPath, fsConstants.R_OK);
return true;
} catch {
return false;
}
}
async function runPwdTool({ workspace, traceId, env }) {
const result = await spawnWithInput("pwd", [], "", {
cwd: workspace,
env: runnerCommandEnv(env),
timeoutMs: 3000
});
const output = redactText((result.stdout || workspace).trim() || workspace);
const bounded = boundToolOutput(output);
return {
id: `tool_${randomUUID()}`,
type: "shell",
name: "pwd",
status: result.code === 0 ? "completed" : "blocked",
cwd: workspace,
command: "pwd",
exitCode: result.code,
stdout: bounded.text,
stderrSummary: redactText(tailText(result.stderr, 300)),
outputTruncated: bounded.truncated,
traceId
};
}
async function runLsTool({ workspace, target = ".", traceId }) {
const targetInfo = resolveReadOnlyTarget(workspace, target, { mustExist: true });
const blockedReason = targetInfo.blocked ? targetInfo.reason : await targetInfo.check();
if (blockedReason) {
return blockedToolCall({ name: "ls", type: "file-list", workspace, traceId, reason: blockedReason });
}
let entries = [];
try {
const targetStat = await stat(targetInfo.path);
if (targetStat.isDirectory()) {
entries = await readdir(targetInfo.path, { withFileTypes: true });
} else {
entries = [{ name: path.basename(targetInfo.path), isDirectory: () => false, isSymbolicLink: () => false }];
}
} catch (error) {
return blockedToolCall({ name: "ls", type: "file-list", workspace, traceId, reason: redactText(error.message) });
}
const lines = entries
.sort((a, b) => a.name.localeCompare(b.name, "en"))
.slice(0, READONLY_FILE_ENTRY_LIMIT)
.map((entry) => `${entry.isDirectory() ? "dir " : entry.isSymbolicLink() ? "link" : "file"} ${entry.name}`);
const overflow = entries.length > lines.length;
const bounded = boundToolOutput(redactText(lines.join("\n")));
return {
id: `tool_${randomUUID()}`,
type: "file-list",
name: "ls",
status: "completed",
cwd: workspace,
command: `ls ${safeDisplayPath(targetInfo.relative || ".")}`,
exitCode: 0,
stdout: bounded.text,
stderrSummary: overflow ? `entry limit ${READONLY_FILE_ENTRY_LIMIT} reached` : "",
outputTruncated: bounded.truncated || overflow,
traceId
};
}
async function runRgFilesTool({ workspace, target = ".", traceId, env }) {
const targetInfo = resolveReadOnlyTarget(workspace, target, { mustExist: true });
const blockedReason = targetInfo.blocked ? targetInfo.reason : await targetInfo.check();
if (blockedReason) {
return blockedToolCall({ name: "rg --files", type: "file-list", workspace, traceId, reason: blockedReason });
}
let files = [];
let usedFallback = true;
if (await commandExists("rg", runnerCommandEnv(env))) {
const result = await spawnWithInput("rg", ["--files", targetInfo.path], "", {
cwd: workspace,
env: runnerCommandEnv(env),
timeoutMs: 3000
});
if (result.code === 0) {
files = result.stdout.split(/\r?\n/u).filter(Boolean).map((filePath) => path.relative(workspace, path.resolve(filePath)));
usedFallback = false;
}
}
if (usedFallback) {
files = await listFilesRecursively(targetInfo.path, workspace);
}
const returned = files
.filter((filePath) => filePath && !filePath.startsWith("..") && !path.isAbsolute(filePath))
.sort((a, b) => a.localeCompare(b, "en"))
.slice(0, READONLY_FILE_ENTRY_LIMIT);
const bounded = boundToolOutput(redactText(returned.join("\n")));
return {
id: `tool_${randomUUID()}`,
type: "file-list",
name: "rg --files",
status: "completed",
cwd: workspace,
command: `rg --files ${safeDisplayPath(targetInfo.relative || ".")}`,
exitCode: 0,
stdout: bounded.text,
stderrSummary: usedFallback ? "node-fallback-used" : "",
outputTruncated: bounded.truncated || files.length > returned.length,
traceId
};
}
async function runCatTool({ workspace, target, traceId }) {
const targetInfo = resolveReadOnlyTarget(workspace, target, { mustExist: true, requireFile: true });
const blockedReason = targetInfo.blocked ? targetInfo.reason : await targetInfo.check();
if (blockedReason) {
return blockedToolCall({ name: "cat", type: "file-read", workspace, traceId, reason: blockedReason });
}
let content = "";
let truncatedByReadLimit = false;
try {
const file = await open(targetInfo.path, "r");
try {
const buffer = Buffer.alloc(READONLY_FILE_READ_LIMIT + 1);
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
truncatedByReadLimit = bytesRead > READONLY_FILE_READ_LIMIT;
content = buffer.subarray(0, Math.min(bytesRead, READONLY_FILE_READ_LIMIT)).toString("utf8");
} finally {
await file.close();
}
} catch (error) {
return blockedToolCall({ name: "cat", type: "file-read", workspace, traceId, reason: redactText(error.message) });
}
const bounded = boundToolOutput(redactText(content));
return {
id: `tool_${randomUUID()}`,
type: "file-read",
name: "cat",
status: "completed",
cwd: workspace,
command: `cat ${safeDisplayPath(targetInfo.relative)}`,
exitCode: 0,
stdout: bounded.text,
stderrSummary: truncatedByReadLimit ? `file read limited at ${READONLY_FILE_READ_LIMIT} bytes` : "",
outputTruncated: bounded.truncated || truncatedByReadLimit,
traceId
};
}
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 assertReadOnlyToolCompleted(toolCall, { workspace, skills, runner, runnerTrace, baseEvidence }) {
if (toolCall.status === "completed") return;
throw runnerError("security_blocked", toolCall.stderrSummary || `${toolCall.name} was blocked by the read-only runner policy`, {
workspace,
toolCalls: [toolCall],
skills,
runner,
runnerTrace,
capabilityLevel: "blocked",
blockers: [{
code: "security_blocked",
sourceIssue: "pikasTech/HWLAB#275",
summary: toolCall.stderrSummary || "Read-only runner blocked the requested file path."
}],
...baseEvidence
});
}
async function discoverSkills({ env, skillsDirs, skillsDirsExact, traceId }) {
const checkedDirs = resolveSkillDirs(env, { skillsDirs, skillsDirsExact });
const sourceSummaries = [];
const items = [];
for (const skillsDir of checkedDirs) {
if (!(await pathReadable(skillsDir))) {
sourceSummaries.push({
path: skillsDir,
status: "missing_or_unreadable",
commit: null,
version: null
});
continue;
}
const commit = await gitCommitFor(skillsDir, env);
const version = firstNonEmpty(env.HWLAB_SKILLS_VERSION, env.HWLAB_SKILL_VERSION, null);
sourceSummaries.push({
path: skillsDir,
status: "readable",
commit,
version
});
const manifests = await skillManifestPaths(skillsDir);
for (const manifestPath of manifests) {
const manifest = await readSkillManifest(manifestPath);
if (!manifest) continue;
items.push({
name: manifest.name ?? path.basename(path.dirname(manifestPath)),
summary: manifest.description ?? firstMarkdownSummary(manifest.body) ?? "No description provided.",
source: manifestPath,
sourceRoot: skillsDir,
version: manifest.version ?? version,
commit: manifest.commit ?? commit,
traceId
});
}
}
const uniqueItems = dedupeSkills(items)
.sort((a, b) => a.name.localeCompare(b.name, "en"));
const returned = uniqueItems.slice(0, MAX_SKILLS_RETURNED);
if (returned.length === 0) {
return {
status: "blocked",
code: "skills_unavailable",
items: [],
count: 0,
totalCount: 0,
checkedDirs,
sources: sourceSummaries,
blockers: [{
code: "skills_unavailable",
sourceIssue: "pikasTech/HWLAB#136",
linkedIssues: ["pikasTech/HWLAB#136", "pikasTech/HWLAB#237"],
summary: "No readable SKILL.md files were found in the configured runner skills directories.",
nextTask: "Mount or sync canonical ~/.agents/skills or repo-owned skills into the Code Agent runner image/runtime, then rerun /v1/agent/chat skills discovery."
}],
valuesPrinted: false
};
}
return {
status: "ready",
code: "skills_ready",
items: returned,
count: returned.length,
totalCount: uniqueItems.length,
truncated: uniqueItems.length > returned.length,
checkedDirs,
sources: sourceSummaries,
blockers: [],
valuesPrinted: false
};
}
async function skillManifestPaths(skillsDir) {
const direct = path.join(skillsDir, "SKILL.md");
const manifests = [];
if (await pathReadable(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 (await pathReadable(manifestPath)) manifests.push(manifestPath);
}
return manifests;
}
async function readSkillManifest(manifestPath) {
let text = "";
try {
text = await readFile(manifestPath, "utf8");
} catch {
return null;
}
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));
if (!name) return null;
return {
name,
description: frontmatter.description,
version: frontmatter.version,
commit: frontmatter.commit ?? frontmatter.commitId,
body
};
}
function parseFrontmatter(text) {
const match = String(text ?? "").match(/^---\s*\n([\s\S]*?)\n---\s*(?:\n|$)/u);
if (!match) return {};
const data = {};
for (const line of match[1].split(/\r?\n/u)) {
const field = line.match(/^([A-Za-z0-9_-]+):\s*(.*)\s*$/u);
if (!field) continue;
data[field[1]] = field[2].replace(/^["']|["']$/gu, "").trim();
}
return data;
}
function firstMarkdownSummary(body) {
return String(body ?? "")
.split(/\r?\n/u)
.map((line) => line.trim())
.find((line) => line && !line.startsWith("#") && !line.startsWith("-")) ?? null;
}
function dedupeSkills(items) {
const seen = new Set();
const deduped = [];
for (const item of items) {
const key = item.name.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
deduped.push(item);
}
return deduped;
}
async function gitCommitFor(targetPath, env = process.env) {
if (isPathInside(targetPath, repoRoot)) {
return firstNonEmpty(env.HWLAB_SKILLS_COMMIT_ID, env.HWLAB_COMMIT_ID, env.HWLAB_GIT_SHA, await gitRevParse(repoRoot));
}
return firstNonEmpty(await gitRevParse(targetPath), null);
}
async function gitRevParse(targetPath) {
const result = await spawnWithInput("git", ["-C", targetPath, "rev-parse", "--short=12", "HEAD"], "", {
cwd: targetPath,
env: runnerCommandEnv(process.env),
timeoutMs: 3000
});
return result.code === 0 ? result.stdout.trim() : null;
}
function isPathInside(child, parent) {
const relative = path.relative(parent, child);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function resolveReadOnlyTarget(workspace, target, { mustExist = false, requireFile = 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);
if (!isPathInside(resolved, workspace)) {
return { blocked: true, reason: "security_blocked: target path is outside the runner workspace" };
}
if (isForbiddenPath(path.relative(workspace, resolved))) {
return { blocked: true, reason: "security_blocked: target path is not allowed" };
}
const relative = path.relative(workspace, resolved) || ".";
return {
path: resolved,
relative,
async check() {
if (!mustExist && !requireFile) return null;
try {
const targetStat = await stat(resolved);
if (requireFile && !targetStat.isFile()) {
return "target is not a regular file";
}
return null;
} catch {
return "target path is not readable";
}
}
};
}
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);
}
async function listFilesRecursively(rootPath, workspace) {
const results = [];
async function visit(currentPath, depth) {
if (results.length >= READONLY_FILE_ENTRY_LIMIT || depth > READONLY_FILE_TREE_DEPTH) return;
let entries = [];
try {
const currentStat = await stat(currentPath);
if (currentStat.isFile()) {
results.push(path.relative(workspace, currentPath));
return;
}
if (!currentStat.isDirectory()) return;
entries = await readdir(currentPath, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name, "en"))) {
if (results.length >= READONLY_FILE_ENTRY_LIMIT) break;
if (SKIPPED_READONLY_DIRS.has(entry.name) || isForbiddenPath(entry.name)) continue;
const nextPath = path.join(currentPath, entry.name);
if (entry.isDirectory()) {
await visit(nextPath, depth + 1);
} else if (entry.isFile()) {
results.push(path.relative(workspace, nextPath));
}
}
}
await visit(rootPath, 0);
return results;
}
function safeDisplayPath(value) {
return redactText(String(value ?? ".")).replace(/\s+/gu, " ");
}
function skillsReply(skills) {
const lines = [
`当前只读 runner 已加载 ${skills.totalCount} 个 skill${skills.truncated ? `,本次显示前 ${skills.count} 个` : ""}。`
];
for (const skill of skills.items) {
const meta = [
`source=${skill.source}`,
skill.version ? `version=${skill.version}` : null,
skill.commit ? `commit=${skill.commit}` : null
].filter(Boolean).join(" / ");
lines.push(`- ${skill.name}: ${skill.summary} (${meta})`);
}
lines.push("说明:这是 read-only skills discovery;未读取 secret/token/kubeconfig,也未触发硬件写操作。");
return boundToolOutput(lines.join("\n"), READONLY_TOOL_OUTPUT_LIMIT).text;
}
function notRequestedSkills() {
return {
status: "not_requested",
items: [],
count: 0,
blockers: []
};
}
function readOnlyRunnerResult({ content, workspace, toolCalls, skills, runner, runnerTrace, session, codexStdioFeasibility, outputTruncated }) {
return {
provider: READONLY_RUNNER_PROVIDER,
model: READONLY_RUNNER_MODEL,
backend: READONLY_RUNNER_BACKEND,
content,
workspace,
sandbox: READONLY_RUNNER_SANDBOX,
session,
sessionMode: READONLY_SESSION_MODE,
sessionReuse: sessionReuseEvidence(session),
implementationType: READONLY_IMPLEMENTATION_TYPE,
runnerLimitations: [...READONLY_LIMITATION_FLAGS],
codexStdioFeasibility,
longLivedSessionGate: longLivedSessionGate({
provider: READONLY_RUNNER_PROVIDER,
runnerKind: READONLY_RUNNER_KIND,
session,
sessionMode: READONLY_SESSION_MODE,
implementationType: READONLY_IMPLEMENTATION_TYPE,
codexStdioFeasibility
}),
toolCalls,
skills,
runner,
runnerTrace,
capabilityLevel: READONLY_SESSION_CAPABILITY_LEVEL,
providerTrace: {
runnerKind: READONLY_RUNNER_KIND,
toolCalls: toolCalls.length,
mode: READONLY_SESSION_MODE,
sessionId: session.sessionId,
turn: session.turn,
reused: session.reused,
outputTruncated: Boolean(outputTruncated)
}
};
}
function sessionReuseEvidence(session) {
return {
conversationId: session.conversationId,
sessionId: session.sessionId,
mapped: true,
reused: session.reused,
turn: session.turn,
previousTurns: Math.max(0, session.turn - 1),
workspace: session.workspace,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
idleTimeoutMs: session.idleTimeoutMs,
expiresAt: session.expiresAt,
lastTraceId: session.lastTraceId,
status: session.status
};
}
function sessionReplyLine(session) {
return `Session registry: mode=${READONLY_SESSION_MODE}; sessionId=${session.sessionId}; status=${session.status}; reused=${session.reused}; turn=${session.turn}; idleTimeoutMs=${session.idleTimeoutMs}; lastTraceId=${session.lastTraceId}.`;
}
function limitationReplyLine() {
return "边界:controlled-readonly-session-registrynot-codex-stdionot-write-capablenot-durable-session。";
}
function releaseReadOnlySession(registry, session, { now, traceId, conversationId } = {}) {
return registry.release(session.sessionId, {
now,
traceId,
conversationId,
reused: session.reused,
status: "idle"
}) ?? session;
}
function resolveCodeAgentSessionRegistry(options = {}) {
return options.sessionRegistry &&
typeof options.sessionRegistry.acquire === "function" &&
typeof options.sessionRegistry.release === "function" &&
typeof options.sessionRegistry.describe === "function"
? options.sessionRegistry
: defaultCodeAgentSessionRegistry;
}
function runnerDescriptor({ workspace, kind = READONLY_RUNNER_KIND, session = null } = {}) {
return {
kind,
provider: kind === READONLY_RUNNER_KIND ? READONLY_RUNNER_PROVIDER : "codex-cli",
backend: kind === READONLY_RUNNER_KIND ? READONLY_RUNNER_BACKEND : "hwlab-cloud-api/codex-cli",
workspace: workspace ?? repoRoot,
sandbox: READONLY_RUNNER_SANDBOX,
session: kind === CODEX_CLI_ONE_SHOT_RUNNER_KIND ? "ephemeral-one-shot" : READONLY_SESSION_MODE,
sessionMode: kind === CODEX_CLI_ONE_SHOT_RUNNER_KIND ? "ephemeral-one-shot" : READONLY_SESSION_MODE,
sessionId: session?.sessionId ?? null,
turn: session?.turn ?? null,
sessionReused: session?.reused ?? false,
implementationType: kind === READONLY_RUNNER_KIND ? READONLY_IMPLEMENTATION_TYPE : "codex-cli-one-shot-ephemeral",
codexStdio: false,
longLivedSession: false,
durableSession: false,
writeCapable: false,
readOnly: true,
capabilityLevel: kind === READONLY_RUNNER_KIND ? READONLY_SESSION_CAPABILITY_LEVEL : "text-chat-only",
toolPolicy: {
allowed: ["pwd", "skills.discover", "ls", "rg --files", "cat"],
blocked: ["secret-read", "kubeconfig-read", "hardware-write", "gateway-direct-call", "box-simu-direct-call", "patch-panel-direct-call", "M3/M4/M5-acceptance-claim"]
},
runnerLimitations: kind === READONLY_RUNNER_KIND ? [...READONLY_LIMITATION_FLAGS] : ["one-shot", "not-durable-session"],
safety: runnerSafetyContract()
};
}
function runnerTrace({ traceId, events, startedAt, outputTruncated, workspace = repoRoot, runnerKind = READONLY_RUNNER_KIND, session = null }) {
return {
traceId,
runnerKind,
workspace,
sandbox: READONLY_RUNNER_SANDBOX,
sessionMode: runnerKind === READONLY_RUNNER_KIND ? READONLY_SESSION_MODE : "ephemeral-one-shot",
sessionId: session?.sessionId ?? null,
sessionStatus: session?.status ?? null,
idleTimeoutMs: session?.idleTimeoutMs ?? null,
lastTraceId: session?.lastTraceId ?? null,
turn: session?.turn ?? null,
sessionReused: session?.reused ?? false,
implementationType: runnerKind === READONLY_RUNNER_KIND ? READONLY_IMPLEMENTATION_TYPE : "codex-cli-one-shot-ephemeral",
limitations: runnerKind === READONLY_RUNNER_KIND ? [...READONLY_LIMITATION_FLAGS] : ["one-shot", "not-durable-session"],
startedAt,
finishedAt: startedAt,
events,
outputTruncated: Boolean(outputTruncated),
valuesPrinted: false,
note: runnerKind === CODEX_CLI_ONE_SHOT_RUNNER_KIND
? "codex exec --ephemeral is a one-shot/read-only bridge, not a long-lived stdio session."
: "controlled-readonly-session-registry stores conversation/session mapping and turn counters only; it is not Codex stdio, write-capable, or durable."
};
}
function runnerSafetyContract() {
return {
secretsRead: false,
secretValuesPrinted: false,
kubeconfigRead: false,
hardwareWritesAllowed: false,
directGatewayCallsAllowed: false,
directBoxSimuCallsAllowed: false,
directPatchPanelCallsAllowed: false,
m3m4m5AcceptanceClaimsAllowed: false,
outputLimitBytes: READONLY_TOOL_OUTPUT_LIMIT
};
}
function inspectCodexStdioFeasibility(env = process.env) {
const workspace = resolveRunnerWorkspace(env);
const command = firstNonEmpty(env.HWLAB_CODE_AGENT_CODEX_COMMAND, DEFAULT_CODEX_COMMAND);
const binaryOnPath = commandOnPathSync(command, env);
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 DEV cloud-api runtime PATH.`
});
}
if (!existsSync(workspace)) {
blockers.push({
code: "workspace_mount_missing",
sourceIssue: "pikasTech/HWLAB#275",
summary: `Configured runner workspace is not mounted/readable: ${workspace}.`
});
}
if (!env.OPENAI_API_KEY) {
blockers.push({
code: "provider_token_boundary",
sourceIssue: "pikasTech/HWLAB#275",
summary: "A long-lived Codex runner would need a repo-owned token/secret injection boundary; this endpoint does not read or print provider secrets."
});
}
blockers.push({
code: "runner_lifecycle_missing",
sourceIssue: "pikasTech/HWLAB#275",
summary: "cloud-api currently owns request/response HTTP handling only; no repo-owned supervisor exists to spawn, monitor, reap, and isolate long-lived Codex stdio sessions."
});
blockers.push({
code: "stdio_protocol_not_wired",
sourceIssue: "pikasTech/HWLAB#275",
summary: "No Codex stdio protocol adapter is implemented for multi-turn session IO, cancellation, trace capture, and bounded tool evidence."
});
return {
status: blockers.length === 0 ? "ready" : "blocked",
canStartLongLivedCodexStdio: blockers.length === 0,
checked: true,
implementationRequired: "codex-stdio-or-equivalent-long-lived-runner",
currentImplementation: READONLY_IMPLEMENTATION_TYPE,
command,
binaryOnPath,
workspace,
sandbox: READONLY_RUNNER_SANDBOX,
blockers,
safety: {
secretsRead: false,
secretValuesPrinted: false,
prodTouched: false,
hardwareWritesAllowed: false
}
};
}
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_FALLBACK_RUNNER_KIND) {
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_RUNNER_KIND || 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 === READONLY_IMPLEMENTATION_TYPE) {
blockers.push({
code: "stdio_protocol_not_wired",
sourceIssue: "pikasTech/HWLAB#317",
summary: "This response is backed by the controlled read-only session registry, not Codex stdio or an equivalent long-lived protocol adapter."
});
}
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 pass =
normalizedProvider === READONLY_RUNNER_PROVIDER &&
normalizedRunnerKind !== OPENAI_FALLBACK_RUNNER_KIND &&
normalizedRunnerKind !== CODEX_CLI_ONE_SHOT_RUNNER_KIND &&
session?.longLivedSession === true &&
session?.codexStdio === true &&
codexStdioFeasibility?.canStartLongLivedCodexStdio === true &&
blockers.length === 0;
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 #317 completion."
};
}
function envForFeasibility(env = process.env) {
return {
PATH: env.PATH || process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
HWLAB_CODE_AGENT_CODEX_COMMAND: env.HWLAB_CODE_AGENT_CODEX_COMMAND,
HWLAB_CODE_AGENT_WORKSPACE: env.HWLAB_CODE_AGENT_WORKSPACE,
HWLAB_RUNNER_WORKSPACE: env.HWLAB_RUNNER_WORKSPACE,
WORKSPACE: env.WORKSPACE,
OPENAI_API_KEY: env.OPENAI_API_KEY ? "__present__" : ""
};
}
function commandOnPathSync(command, env = process.env) {
if (!command) return false;
if (command.includes("/") || command.includes("\\")) {
try {
return existsSync(command);
} catch {
return false;
}
}
const paths = String(env.PATH || process.env.PATH || "").split(path.delimiter).filter(Boolean);
return paths.some((dir) => existsSync(path.join(dir, command)));
}
function openAiFallbackRunnerEvidence({ providerResult, traceId, conversationId, sessionId }) {
return {
kind: OPENAI_FALLBACK_RUNNER_KIND,
provider: providerResult.provider ?? "openai-responses",
backend: providerResult.backend ?? "hwlab-cloud-api/openai-responses",
workspace: null,
sandbox: "none",
session: "provider-text-request",
sessionMode: "provider-text-request",
sessionId,
conversationId,
implementationType: OPENAI_FALLBACK_RUNNER_KIND,
codexStdio: false,
longLivedSession: false,
durableSession: false,
writeCapable: false,
readOnly: false,
capabilityLevel: "text-chat-only",
toolPolicy: {
allowed: [],
blocked: ["workspace-tools", "skills-discovery", "shell-tools", "file-tools", "hardware-write"]
},
capabilityGate: {
codexRunner: false,
reason: "OpenAI Responses fallback is text chat only and cannot satisfy the Codex runner capability gate."
},
runnerLimitations: [
"text-chat-only",
"not-codex-stdio",
"not-workspace-tools",
"not-durable-session"
],
traceId
};
}
function runnerError(code, message, details = {}) {
const error = new Error(message);
error.code = code;
Object.assign(error, {
provider: READONLY_RUNNER_PROVIDER,
model: READONLY_RUNNER_MODEL,
backend: READONLY_RUNNER_BACKEND,
sandbox: READONLY_RUNNER_SANDBOX,
...details
});
return error;
}
function runnerCommandEnv(env = process.env) {
return {
PATH: env.PATH || process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
HOME: env.HOME || process.env.HOME || os.homedir()
};
}
function boundToolOutput(value, maxLength = READONLY_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
};
}
async function callOpenAiResponses({ providerPlan, message, conversationId, traceId, timeoutMs, env }) {
const providerContract = inspectCodeAgentProviderEnv(env);
if (!providerContract.ready) {
throw providerUnavailable(providerContract.blocker, {
missingEnv: providerContract.missingEnv,
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend,
egress: providerContract.egress
});
}
const endpoint = env.HWLAB_CODE_AGENT_OPENAI_BASE_URL;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), effectiveTimeout(timeoutMs));
let response;
let payload;
try {
response = await fetch(endpoint, {
method: "POST",
headers: {
"content-type": "application/json",
accept: "text/event-stream",
authorization: `Bearer ${env.OPENAI_API_KEY}`
},
body: JSON.stringify({
model: providerPlan.model,
instructions: CODE_AGENT_SYSTEM_PROMPT,
input: [
{
role: "user",
content: [
{
type: "input_text",
text: buildAgentPrompt({ message, conversationId, traceId })
}
]
}
],
store: false,
stream: true
}),
signal: controller.signal
});
payload = parseOpenAiResponseBody(await response.text());
} catch (error) {
if (error.name === "AbortError") {
throw providerUnavailable(`OpenAI Responses request timed out after ${effectiveTimeout(timeoutMs)}ms`, {
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend
});
}
throw providerUnavailable(`OpenAI Responses request failed: ${error.message}`, {
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend
});
} finally {
clearTimeout(timer);
}
const providerError = payload.error ?? payload.raw?.error ?? null;
if (!response.ok) {
throw providerUnavailable(`OpenAI Responses returned HTTP ${response.status}: ${providerError?.message || "request rejected"}`, {
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend,
providerStatus: response.status
});
}
if (providerError) {
throw providerUnavailable(`OpenAI Responses stream error: ${providerError.message || "request rejected"}`, {
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend
});
}
const content = payload.content || extractOpenAiOutputText(payload.raw);
if (!content) {
throw providerUnavailable("OpenAI Responses returned no assistant text", {
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend
});
}
return {
provider: providerPlan.provider,
model: payload.model || providerPlan.model,
backend: providerPlan.backend,
content,
usage: payload.usage ?? null,
providerTrace: {
responseId: payload.responseId ?? null
}
};
}
async function callCodexCli({ providerPlan, message, conversationId, traceId, timeoutMs, env }) {
const command = firstNonEmpty(env.HWLAB_CODE_AGENT_CODEX_COMMAND, DEFAULT_CODEX_COMMAND);
if (!(await commandExists(command, env))) {
throw providerUnavailable(`Codex CLI command is not available: ${command}`, {
missingCommands: [command],
missingEnv: env.OPENAI_API_KEY ? [] : ["OPENAI_API_KEY"],
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend,
command
});
}
const outputDir = await makeTempDir();
const outputFile = path.join(outputDir, "last-message.txt");
const args = [
"exec",
"--cd",
repoRoot,
"--skip-git-repo-check",
"--sandbox",
"read-only",
"--ephemeral",
"--output-last-message",
outputFile
];
if (providerPlan.model) {
args.push("--model", providerPlan.model);
}
args.push("-");
const result = await spawnWithInput(command, args, buildAgentPrompt({ message, conversationId, traceId }), {
env,
timeoutMs: effectiveTimeout(timeoutMs)
});
try {
if (result.code !== 0) {
throw providerUnavailable(`Codex CLI exited with code ${result.code}`, {
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend,
command: commandLine(command, args),
exitCode: result.code,
stderrSummary: redactText(tailText(result.stderr || result.stdout))
});
}
const content = (await readFile(outputFile, "utf8")).trim();
if (!content) {
throw providerUnavailable("Codex CLI completed without an assistant message", {
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend,
command: commandLine(command, args),
stderrSummary: redactText(tailText(result.stderr || result.stdout))
});
}
return {
provider: providerPlan.provider,
model: providerPlan.model,
backend: providerPlan.backend,
content,
usage: null,
workspace: repoRoot,
sandbox: READONLY_RUNNER_SANDBOX,
sessionMode: "ephemeral-one-shot",
session: {
sessionId: conversationId,
conversationId,
status: "expired",
workspace: repoRoot,
sandbox: READONLY_RUNNER_SANDBOX,
runnerKind: CODEX_CLI_ONE_SHOT_RUNNER_KIND,
capabilityLevel: "text-chat-only",
implementationType: "codex-cli-one-shot-ephemeral",
createdAt: nowIso(),
updatedAt: nowIso(),
idleTimeoutMs: 0,
expiresAt: nowIso(),
lastTraceId: traceId,
turn: 1,
longLivedSession: false,
codexStdio: false,
durable: false,
secretMaterialStored: false,
valuesRedacted: true
},
sessionReuse: {
conversationId,
sessionId: conversationId,
mapped: true,
reused: false,
turn: 1,
previousTurns: 0,
workspace: repoRoot
},
implementationType: "codex-cli-one-shot-ephemeral",
runnerLimitations: ["one-shot", "not-durable-session", "not-controlled-session-registry"],
codexStdioFeasibility: inspectCodexStdioFeasibility(envForFeasibility(env)),
longLivedSessionGate: longLivedSessionGate({
provider: providerPlan.provider,
runnerKind: CODEX_CLI_ONE_SHOT_RUNNER_KIND,
sessionMode: "ephemeral-one-shot",
implementationType: "codex-cli-one-shot-ephemeral",
codexStdioFeasibility: inspectCodexStdioFeasibility(envForFeasibility(env))
}),
toolCalls: [],
skills: notRequestedSkills(),
runner: runnerDescriptor({ workspace: repoRoot, kind: CODEX_CLI_ONE_SHOT_RUNNER_KIND }),
runnerTrace: {
traceId,
runnerKind: CODEX_CLI_ONE_SHOT_RUNNER_KIND,
workspace: repoRoot,
sandbox: READONLY_RUNNER_SANDBOX,
events: ["codex-cli:exec", "codex-cli:ephemeral", "codex-cli:completed"],
outputTruncated: false,
valuesPrinted: false,
note: "codex exec --ephemeral is a one-shot/read-only bridge, not a long-lived stdio session."
},
capabilityLevel: "text-chat-only",
providerTrace: {
command: commandLine(command, args),
stdoutSummary: redactText(tailText(result.stdout, 600))
}
};
} finally {
await rm(outputDir, { recursive: true, force: true });
}
}
function buildAgentPrompt({ message, conversationId, traceId }) {
return [
CODE_AGENT_SYSTEM_PROMPT,
"",
`conversationId: ${conversationId}`,
`traceId: ${traceId}`,
"",
"用户消息:",
message
].join("\n");
}
function normalizeUserMessage(value) {
if (typeof value !== "string") {
throw badRequest("message must be a string");
}
const message = value.trim();
if (!message) {
throw badRequest("message is required");
}
if (message.length > 4000) {
throw badRequest("message exceeds 4000 characters");
}
return message;
}
function normalizeChatError(error) {
const normalized = {
code: error.code || "code_agent_failed",
message: error.message || "Code Agent request failed"
};
for (const key of [
"missingEnv",
"missingCommands",
"provider",
"model",
"backend",
"command",
"exitCode",
"providerStatus",
"stderrSummary",
"blockers"
]) {
if (error[key] !== undefined) {
normalized[key] = error[key];
}
}
return normalized;
}
function badRequest(message) {
const error = new Error(message);
error.code = "invalid_params";
return error;
}
function providerUnavailable(message, details = {}) {
const error = new Error(message);
error.code = "provider_unavailable";
Object.assign(error, details);
return error;
}
function extractOpenAiOutputText(payload) {
if (typeof payload?.output_text === "string" && payload.output_text.trim()) {
return payload.output_text.trim();
}
const chunks = [];
for (const item of payload?.output ?? []) {
for (const content of item.content ?? []) {
if (typeof content.text === "string") chunks.push(content.text);
else if (typeof content.output_text === "string") chunks.push(content.output_text);
}
}
return chunks.join("\n").trim();
}
function parseOpenAiResponseBody(text) {
const raw = parseJsonOrNull(text);
if (raw) {
return {
raw,
content: extractOpenAiOutputText(raw),
responseId: raw.id ?? null,
model: raw.model ?? null,
usage: raw.usage ?? null,
error: raw.error ?? null
};
}
const stream = parseOpenAiResponsesSse(text);
return {
raw: stream.finalResponse,
content: stream.content,
responseId: stream.responseId,
model: stream.model,
usage: stream.usage,
error: stream.error
};
}
function parseOpenAiResponsesSse(text) {
const deltas = [];
let finalResponse = null;
let responseId = null;
let model = null;
let usage = null;
let error = null;
for (const event of parseSseDataMessages(text)) {
const payload = parseJsonOrNull(event);
if (!payload) continue;
const response = payload.response && typeof payload.response === "object" ? payload.response : null;
if (response) {
finalResponse = response;
responseId = response.id ?? responseId;
model = response.model ?? model;
usage = response.usage ?? usage;
if (response.error) error = response.error;
}
if (payload.response_id) responseId = payload.response_id;
if (payload.item?.id) responseId = responseId ?? payload.item.id;
if (payload.error) error = payload.error;
if (payload.type === "error" && !payload.error) error = payload;
if (typeof payload.delta === "string") deltas.push(payload.delta);
else if (typeof payload.text === "string" && payload.type === "response.output_text.done" && deltas.length === 0) {
deltas.push(payload.text);
}
}
const content = deltas.join("").trim() || extractOpenAiOutputText(finalResponse);
return {
finalResponse,
content,
responseId,
model,
usage,
error
};
}
function parseSseDataMessages(text) {
const messages = [];
for (const block of String(text ?? "").split(/\r?\n\r?\n/u)) {
const data = [];
for (const line of block.split(/\r?\n/u)) {
if (line.startsWith("data:")) data.push(line.slice(5).trimStart());
}
const message = data.join("\n").trim();
if (message && message !== "[DONE]") messages.push(message);
}
return messages;
}
function parseJsonOrNull(value) {
try {
return JSON.parse(value);
} catch {
return null;
}
}
async function commandExists(command, env) {
if (command.includes("/") || command.includes("\\")) {
try {
await access(command, fsConstants.X_OK);
return true;
} catch {
return false;
}
}
const result = await spawnWithInput("sh", ["-lc", `command -v ${shellQuote(command)}`], "", {
env,
timeoutMs: 3000
});
return result.code === 0;
}
function spawnWithInput(command, args, input, { env, timeoutMs, cwd = repoRoot }) {
return new Promise((resolve) => {
const child = spawn(command, args, {
cwd,
env,
stdio: ["pipe", "pipe", "pipe"]
});
let stdout = "";
let stderr = "";
let settled = false;
const timer = setTimeout(() => {
if (!settled) {
child.kill("SIGTERM");
}
}, timeoutMs);
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.on("error", (error) => {
settled = true;
clearTimeout(timer);
resolve({ code: 127, stdout, stderr: `${stderr}\n${error.message}` });
});
child.on("close", (code) => {
settled = true;
clearTimeout(timer);
resolve({ code: code ?? 1, stdout, stderr });
});
child.stdin.on("error", (error) => {
if (error?.code !== "EPIPE") {
stderr += `\n${error.message}`;
}
});
child.stdin.end(input);
});
}
async function makeTempDir() {
const dir = path.join(os.tmpdir(), `hwlab-code-agent-${randomUUID()}`);
await mkdir(dir, { recursive: true });
return dir;
}
function cleanProtocolId(value, prefix) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed) return null;
if (/^[a-z][a-z0-9]*_[A-Za-z0-9._:-]+$/u.test(trimmed)) return trimmed;
return `${prefix}_${trimmed.replace(/[^A-Za-z0-9._:-]/gu, "-").slice(0, 80)}`;
}
function cleanProjectId(value) {
return cleanProtocolId(value, "prj");
}
function resolveConversationSessionIds(params = {}) {
const requestedConversationId = cleanProtocolId(params.conversationId, "cnv");
const requestedSessionId = cleanProtocolId(params.sessionId, "ses");
if (requestedConversationId && requestedSessionId) {
return {
conversationId: requestedConversationId,
sessionId: requestedSessionId,
requestedSessionId
};
}
if (requestedConversationId) {
return {
conversationId: requestedConversationId,
sessionId: requestedConversationId,
requestedSessionId: null
};
}
if (requestedSessionId) {
const conversationId = `cnv_${requestedSessionId.replace(/^[a-z][a-z0-9]*_/u, "")}`;
return {
conversationId,
sessionId: requestedSessionId,
requestedSessionId
};
}
const conversationId = `cnv_${randomUUID()}`;
return {
conversationId,
sessionId: conversationId,
requestedSessionId: null
};
}
function nowIso(now) {
return typeof now === "function" ? now() : new Date().toISOString();
}
function effectiveTimeout(timeoutMs) {
return Number.isInteger(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_CODE_AGENT_TIMEOUT_MS;
}
function firstNonEmpty(...values) {
for (const value of values) {
if (typeof value === "string" && value.trim()) return value.trim();
}
return "";
}
function commandLine(command, args) {
return [command, ...args].map((part) => (/\s/u.test(part) ? JSON.stringify(part) : part)).join(" ");
}
function tailText(value, maxLength = 1200) {
const text = String(value ?? "").trim();
if (text.length <= maxLength) return text;
return text.slice(text.length - maxLength);
}
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***");
}
function shellQuote(value) {
return `'${String(value).replaceAll("'", "'\\''")}'`;
}