diff --git a/cmd/hwlab-cloud-api/main.mjs b/cmd/hwlab-cloud-api/main.ts similarity index 59% rename from cmd/hwlab-cloud-api/main.mjs rename to cmd/hwlab-cloud-api/main.ts index 03fc52ab..5d038f83 100644 --- a/cmd/hwlab-cloud-api/main.mjs +++ b/cmd/hwlab-cloud-api/main.ts @@ -1,9 +1,9 @@ -#!/usr/bin/env node -import { createCloudApiServer } from "../../internal/cloud/server.mjs"; +#!/usr/bin/env bun +import { createCloudApiServer } from "../../internal/cloud/server.ts"; +import { applyCloudApiImageEnv, parsePort, parseTimeout } from "./runtime-options.ts"; const host = process.env.HWLAB_CLOUD_API_HOST || "0.0.0.0"; const port = parsePort(process.env.HWLAB_CLOUD_API_PORT, parsePort(process.env.PORT, 6667)); -const commitId = process.env.HWLAB_COMMIT_ID || process.env.HWLAB_GIT_SHA; const codeAgentTimeoutMs = parseTimeout(process.env.HWLAB_CODE_AGENT_TIMEOUT_MS, 1200000, { min: 1000, max: 2400000 @@ -12,32 +12,13 @@ const codeAgentHardTimeoutMs = parseTimeout(process.env.HWLAB_CODE_AGENT_HARD_TI min: codeAgentTimeoutMs, max: 3600000 }); - -if (!process.env.HWLAB_IMAGE && commitId) { - process.env.HWLAB_IMAGE = `ghcr.io/pikastech/hwlab-cloud-api:${commitId.slice(0, 7)}`; -} - -if (!process.env.HWLAB_IMAGE_TAG && commitId) { - process.env.HWLAB_IMAGE_TAG = commitId.slice(0, 7); -} +applyCloudApiImageEnv(process.env); const server = createCloudApiServer({ codeAgentTimeoutMs, codeAgentHardTimeoutMs }); -function parseTimeout(value, fallback, { min, max }) { - const parsed = Number.parseInt(value || "", 10); - if (!Number.isInteger(parsed)) return fallback; - return Math.min(Math.max(parsed, min), max); -} - -function parsePort(value, fallback) { - const parsed = Number.parseInt(value || "", 10); - if (!Number.isInteger(parsed) || parsed < 0 || parsed >= 65536) return fallback; - return parsed; -} - server.listen(port, host, () => { const address = server.address(); const resolvedPort = typeof address === "object" && address ? address.port : port; diff --git a/cmd/hwlab-cloud-api/migrate.mjs b/cmd/hwlab-cloud-api/migrate.ts similarity index 94% rename from cmd/hwlab-cloud-api/migrate.mjs rename to cmd/hwlab-cloud-api/migrate.ts index 619319f6..acd42aa6 100644 --- a/cmd/hwlab-cloud-api/migrate.mjs +++ b/cmd/hwlab-cloud-api/migrate.ts @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import { formatDevRuntimeMigrationFailure, runDevRuntimeMigrationCli diff --git a/cmd/hwlab-cloud-api/provision.mjs b/cmd/hwlab-cloud-api/provision.ts similarity index 94% rename from cmd/hwlab-cloud-api/provision.mjs rename to cmd/hwlab-cloud-api/provision.ts index a698ff4f..05406362 100644 --- a/cmd/hwlab-cloud-api/provision.mjs +++ b/cmd/hwlab-cloud-api/provision.ts @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import { formatDevRuntimeProvisioningFailure, runDevRuntimeProvisioningCli diff --git a/cmd/hwlab-cloud-api/runtime-options.test.ts b/cmd/hwlab-cloud-api/runtime-options.test.ts new file mode 100644 index 00000000..614295f0 --- /dev/null +++ b/cmd/hwlab-cloud-api/runtime-options.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import { test } from "bun:test"; + +import { applyCloudApiImageEnv, parsePort, parseTimeout } from "./runtime-options.ts"; + +test("parsePort preserves current hwlab-cloud-api port fallback behavior", () => { + assert.equal(parsePort(undefined, 6667), 6667); + assert.equal(parsePort("", 6667), 6667); + assert.equal(parsePort("abc", 6667), 6667); + assert.equal(parsePort("-1", 6667), 6667); + assert.equal(parsePort("65536", 6667), 6667); + assert.equal(parsePort("0", 6667), 0); + assert.equal(parsePort("19667", 6667), 19667); +}); + +test("parseTimeout clamps parsed integer values to the configured range", () => { + assert.equal(parseTimeout(undefined, 1200000, { min: 1000, max: 2400000 }), 1200000); + assert.equal(parseTimeout("abc", 1200000, { min: 1000, max: 2400000 }), 1200000); + assert.equal(parseTimeout("1", 1200000, { min: 1000, max: 2400000 }), 1000); + assert.equal(parseTimeout("99999999", 1200000, { min: 1000, max: 2400000 }), 2400000); + assert.equal(parseTimeout("1500", 1200000, { min: 1000, max: 2400000 }), 1500); +}); + +test("applyCloudApiImageEnv derives image identity from commit without overriding explicit values", () => { + const env = { HWLAB_COMMIT_ID: "abcdef1234567890" }; + applyCloudApiImageEnv(env); + assert.equal(env.HWLAB_IMAGE, "ghcr.io/pikastech/hwlab-cloud-api:abcdef1"); + assert.equal(env.HWLAB_IMAGE_TAG, "abcdef1"); + + const explicit = { + HWLAB_GIT_SHA: "11111112222222", + HWLAB_IMAGE: "registry.local/custom:tag", + HWLAB_IMAGE_TAG: "custom" + }; + applyCloudApiImageEnv(explicit); + assert.equal(explicit.HWLAB_IMAGE, "registry.local/custom:tag"); + assert.equal(explicit.HWLAB_IMAGE_TAG, "custom"); +}); diff --git a/cmd/hwlab-cloud-api/runtime-options.ts b/cmd/hwlab-cloud-api/runtime-options.ts new file mode 100644 index 00000000..6f7991bd --- /dev/null +++ b/cmd/hwlab-cloud-api/runtime-options.ts @@ -0,0 +1,22 @@ +export function parseTimeout(value, fallback, { min, max }) { + const parsed = Number.parseInt(value || "", 10); + if (!Number.isInteger(parsed)) return fallback; + return Math.min(Math.max(parsed, min), max); +} + +export function parsePort(value, fallback) { + const parsed = Number.parseInt(value || "", 10); + if (!Number.isInteger(parsed) || parsed < 0 || parsed >= 65536) return fallback; + return parsed; +} + +export function applyCloudApiImageEnv(env = process.env) { + const commitId = env.HWLAB_COMMIT_ID || env.HWLAB_GIT_SHA; + if (!env.HWLAB_IMAGE && commitId) { + env.HWLAB_IMAGE = `ghcr.io/pikastech/hwlab-cloud-api:${commitId.slice(0, 7)}`; + } + + if (!env.HWLAB_IMAGE_TAG && commitId) { + env.HWLAB_IMAGE_TAG = commitId.slice(0, 7); + } +} diff --git a/deploy/artifact-catalog.dev.json b/deploy/artifact-catalog.dev.json index 1371903f..c1ae39c3 100644 --- a/deploy/artifact-catalog.dev.json +++ b/deploy/artifact-catalog.dev.json @@ -97,7 +97,9 @@ "digest": "sha256:fe724e72e7cea3c93abe17085c4d885c78d34d18eb2a3c01f987d1971e2f4425", "reusedFrom": "7498160c36db86db76d3c1b103e397f2b2304248136d3b79eafe1ab69d4abeff" }, - "reusedFrom": "7498160c36db86db76d3c1b103e397f2b2304248136d3b79eafe1ab69d4abeff" + "reusedFrom": "7498160c36db86db76d3c1b103e397f2b2304248136d3b79eafe1ab69d4abeff", + "runtimeKind": "bun-command", + "entrypoint": "cmd/hwlab-cloud-api/main.ts" }, { "serviceId": "hwlab-cloud-web", @@ -578,10 +580,10 @@ "publishEnabled": true, "artifactRequired": true, "artifactScope": "required", - "runtimeKind": "node-command", + "runtimeKind": "bun-command", "implementationState": "repo-entrypoint", "sourceState": "source-present", - "entrypoint": "cmd/hwlab-cloud-api/main.mjs", + "entrypoint": "cmd/hwlab-cloud-api/main.ts", "disabledReason": null }, { diff --git a/docs/reference/code-agent-chat-readiness.md b/docs/reference/code-agent-chat-readiness.md index eebc0cb7..cf884070 100644 --- a/docs/reference/code-agent-chat-readiness.md +++ b/docs/reference/code-agent-chat-readiness.md @@ -10,7 +10,7 @@ Secret 或 token。 `/v1/agent/chat`。 - DEV API/edge 入口是 `http://74.48.78.17:17667/`;它不能替代 Workbench 同源聊天入口的真实回复证据。 -- `internal/cloud/code-agent-chat.mjs` 是 `/v1/agent/chat` 的后端处理入口。 +- `internal/cloud/code-agent-chat.ts` 是 `/v1/agent/chat` 的后端处理入口。 - `scripts/code-agent-chat-smoke.mjs` 是 Code Agent chat schema 与 readiness 合同检查。 - `scripts/dev-cloud-workbench-smoke.mjs --static` 只验证 Workbench 源码合同和 diff --git a/docs/reference/dev-runtime-boundary.md b/docs/reference/dev-runtime-boundary.md index 03590f56..35840cab 100644 --- a/docs/reference/dev-runtime-boundary.md +++ b/docs/reference/dev-runtime-boundary.md @@ -193,7 +193,7 @@ When health reports: - `runtime.status: "degraded"` the Cloud API is using the process-local runtime store in -`internal/db/runtime-store.mjs`. Gateway sessions, box resources, operations, +`internal/db/runtime-store.ts`. Gateway sessions, box resources, operations, audit events, and evidence records accepted through the runtime can be lost on pod restart, redeploy, or scale-out, and cannot be treated as durable M3/M4/M5 evidence. Users may see accepted operations or evidence disappear after a @@ -238,9 +238,9 @@ below are true. The cloud-api image owns a stable DEV DB provisioning entrypoint: ```sh -node cmd/hwlab-cloud-api/provision.mjs --check -node cmd/hwlab-cloud-api/provision.mjs --dry-run --allow-live-db-read --confirm-dev --report /tmp/hwlab-dev-gate/dev-runtime-provisioning-report.json -node cmd/hwlab-cloud-api/provision.mjs --apply --confirm-dev --confirmed-non-production --report /tmp/hwlab-dev-gate/dev-runtime-provisioning-report.json +bun cmd/hwlab-cloud-api/provision.ts --check +bun cmd/hwlab-cloud-api/provision.ts --dry-run --allow-live-db-read --confirm-dev --report /tmp/hwlab-dev-gate/dev-runtime-provisioning-report.json +bun cmd/hwlab-cloud-api/provision.ts --apply --confirm-dev --confirmed-non-production --report /tmp/hwlab-dev-gate/dev-runtime-provisioning-report.json ``` This is the preferred image-internal operator command for `hwlab-cloud-api`. @@ -288,9 +288,9 @@ untracked manual DB write. The cloud-api image owns a stable runtime migration entrypoint: ```sh -node cmd/hwlab-cloud-api/migrate.mjs --check -node cmd/hwlab-cloud-api/migrate.mjs --dry-run --report /tmp/hwlab-dev-gate/dev-runtime-migration-report.json -node cmd/hwlab-cloud-api/migrate.mjs --apply --confirm-dev --confirmed-non-production --report /tmp/hwlab-dev-gate/dev-runtime-migration-report.json +bun cmd/hwlab-cloud-api/migrate.ts --check +bun cmd/hwlab-cloud-api/migrate.ts --dry-run --report /tmp/hwlab-dev-gate/dev-runtime-migration-report.json +bun cmd/hwlab-cloud-api/migrate.ts --apply --confirm-dev --confirmed-non-production --report /tmp/hwlab-dev-gate/dev-runtime-migration-report.json ``` This is the preferred image-internal operator command for `hwlab-cloud-api`. @@ -298,7 +298,7 @@ It delegates to the same repo-owned migration implementation as the root script, so there is only one migration contract and one output format. The cloud-api artifact build copies `cmd/`, `scripts/`, `internal/`, `package.json`, and runtime dependencies into the image; the DEV CD runtime migration Job uses -`node cmd/hwlab-cloud-api/migrate.mjs` from the current cloud-api image and +`bun cmd/hwlab-cloud-api/migrate.ts` from the current cloud-api image and injects DB inputs only through SecretRef-backed env. The repo-level compatibility entrypoint remains: diff --git a/docs/reference/dev-runtime-hotfix-runbook.md b/docs/reference/dev-runtime-hotfix-runbook.md index f673bd50..5802c758 100644 --- a/docs/reference/dev-runtime-hotfix-runbook.md +++ b/docs/reference/dev-runtime-hotfix-runbook.md @@ -94,7 +94,7 @@ SCRIPT | --- | --- | | Deployment | `hwlab-dev/hwlab-cloud-api` | | ConfigMap | `hwlab-cloud-api-code-agent-hotfix` | -| 覆盖文件 | `/app/internal/cloud/code-agent-chat.mjs` | +| 覆盖文件 | `/app/internal/cloud/code-agent-chat.ts` | | subPath | `code-agent-chat.mjs` | | marker | `runtime-hotfix-pc-gateway-shell` | | annotation | `hwlab.pikastech.local/pc-gateway-shell-hotfix` | @@ -125,7 +125,7 @@ node scripts/dev-runtime-hotfix-audit.mjs --collect-readonly --pretty ## 回滚口径 -优先回滚方式是用源码化 artifact/CD 覆盖 runtime hotfix:#460/#461 合并并发布后,Deployment 应消费正式镜像,不再通过 ConfigMap 覆盖 `/app/internal/cloud/code-agent-chat.mjs`。注意 `kubectl apply -k` 可能保留运行面 patch 写入、且源码 desired-state 不拥有的 hotfix `volumes`、`volumeMounts` 或 template annotations;正式 DEV CD apply 应先识别这种 unmanaged hotfix 覆盖,删除对应 desired Deployment,再由同一次 apply 从源码重新创建。 +优先回滚方式是用源码化 artifact/CD 覆盖 runtime hotfix:#460/#461 合并并发布后,Deployment 应消费正式镜像,不再通过 ConfigMap 覆盖 `/app/internal/cloud/code-agent-chat.ts`。注意 `kubectl apply -k` 可能保留运行面 patch 写入、且源码 desired-state 不拥有的 hotfix `volumes`、`volumeMounts` 或 template annotations;正式 DEV CD apply 应先识别这种 unmanaged hotfix 覆盖,删除对应 desired Deployment,再由同一次 apply 从源码重新创建。 只读确认口径是通过当前 G14 k3s route 读取目标对象;不要从 master server 裸跑 `kubectl`,也不要把 D601 kubeconfig 当作当前 DEV/v0.2 控制面: @@ -139,7 +139,7 @@ node scripts/dev-runtime-hotfix-audit.mjs --collect-readonly --pretty 如果必须直接移除运行态覆盖,授权操作者应先从 `kubectl -n hwlab-dev get deployment hwlab-cloud-api -o json` 中定位精确 indexes,再移除: - 指向 hotfix ConfigMap 的 `spec.template.spec.volumes[]`; -- 指向 `/app/internal/cloud/code-agent-chat.mjs` 的 `containers[].volumeMounts[]`; +- 指向 `/app/internal/cloud/code-agent-chat.ts` 的 `containers[].volumeMounts[]`; - `hwlab.pikastech.local/pc-gateway-shell-hotfix` 等 hotfix annotation; - 不再需要的 `hwlab-cloud-api-code-agent-hotfix` ConfigMap。 diff --git a/docs/reference/spec-v02-hwlab-cloud-api.md b/docs/reference/spec-v02-hwlab-cloud-api.md index 79e1030f..825c1b38 100644 --- a/docs/reference/spec-v02-hwlab-cloud-api.md +++ b/docs/reference/spec-v02-hwlab-cloud-api.md @@ -10,10 +10,10 @@ ## 内部架构 -- `cmd/hwlab-cloud-api/main.mjs` 负责启动 HTTP server、解析端口和 Code Agent timeout。 -- `internal/cloud/server.mjs` 负责 HTTP route、REST/RPC bridge、health、live-builds、device-pod proxy、gateway poll/result 和 Code Agent chat。 -- `internal/db/runtime-store.mjs` 和 `internal/cloud/db-contract.mjs` 负责 Postgres runtime store 与 readiness 分层。 -- `internal/cloud/code-agent-*.mjs` 负责 Codex stdio session、trace store、result cache、provider profile 和取消/轮询。 +- `cmd/hwlab-cloud-api/main.ts` 负责启动 HTTP server、解析端口和 Code Agent timeout。 +- `internal/cloud/server.ts` 负责 HTTP route、REST/RPC bridge、health、live-builds、device-pod proxy、gateway poll/result 和 Code Agent chat。 +- `internal/db/runtime-store.ts` 和 `internal/cloud/db-contract.ts` 负责 Postgres runtime store 与 readiness 分层。 +- `internal/cloud/code-agent-*.ts` 负责 Codex stdio session、trace store、result cache、provider profile 和取消/轮询。 - 同 Pod sidecar `hwlab-codex-api-forwarder` 监听 `127.0.0.1:49280/responses`,用于 `codex-api` profile 直连 hyueapi,并保持 hyueapi 在 `NO_PROXY` 中。 - `hwlab-code-agent-workspace` PVC 挂载到 `/workspace/hwlab`,用于长会话 workspace;它是 cloud-api 运行资源,不是独立用户入口。 diff --git a/docs/reference/spec-v02-hwlab-gateway.md b/docs/reference/spec-v02-hwlab-gateway.md index 27519b07..e5ed71f8 100644 --- a/docs/reference/spec-v02-hwlab-gateway.md +++ b/docs/reference/spec-v02-hwlab-gateway.md @@ -11,7 +11,7 @@ ## 内部架构 - `cmd/hwlab-gateway/main.mjs` 维护 gateway state、outbound poll loop、inflight request map 和 command execution。 -- `internal/cloud/gateway-demo-registry.mjs` 在 cloud-api 内保存 gateway session、队列和 pending result。 +- `internal/cloud/gateway-demo-registry.ts` 在 cloud-api 内保存 gateway session、队列和 pending result。 - command execution 默认关闭;只有显式 `HWLAB_GATEWAY_CMD_EXEC_ENABLED=1` 或 demo open 时才执行 shell。 ## API 接口说明 diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.ts similarity index 99% rename from internal/cloud/code-agent-chat.mjs rename to internal/cloud/code-agent-chat.ts index 181f6d4f..0cef327b 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.ts @@ -8,7 +8,7 @@ import { DEV_CODE_AGENT_PROVIDER_CONTRACT, codeAgentSecretRefPlaceholder, inspectCodeAgentProviderEnv -} from "./code-agent-contract.mjs"; +} from "./code-agent-contract.ts"; import { CODEX_STDIO_BACKEND, CODEX_STDIO_CAPABILITY_LEVEL, @@ -18,19 +18,19 @@ import { CODEX_STDIO_SESSION_MODE, createCodexStdioSessionManager, longLivedSessionGate as codexLongLivedSessionGate -} from "./codex-stdio-session.mjs"; +} from "./codex-stdio-session.ts"; import { createCodeAgentTraceRecorder, defaultCodeAgentTraceStore -} from "./code-agent-trace-store.mjs"; +} from "./code-agent-trace-store.ts"; import { DEFAULT_CODE_AGENT_SESSION_IDLE_TIMEOUT_MS, createCodeAgentSessionRegistry -} from "./code-agent-session-registry.mjs"; +} from "./code-agent-session-registry.ts"; import { codeAgentSessionLifecycleSummary, decorateCodeAgentSession -} from "./code-agent-session-lifecycle.mjs"; +} from "./code-agent-session-lifecycle.ts"; const DEFAULT_MODEL = DEV_CODE_AGENT_PROVIDER_CONTRACT.model; const DEFAULT_PROJECT_ID = "prj_hwlab-cloud-workbench"; const READONLY_RUNNER_PROVIDER = "codex-readonly-runner"; diff --git a/internal/cloud/code-agent-contract.mjs b/internal/cloud/code-agent-contract.ts similarity index 100% rename from internal/cloud/code-agent-contract.mjs rename to internal/cloud/code-agent-contract.ts diff --git a/internal/cloud/code-agent-session-lifecycle.mjs b/internal/cloud/code-agent-session-lifecycle.ts similarity index 100% rename from internal/cloud/code-agent-session-lifecycle.mjs rename to internal/cloud/code-agent-session-lifecycle.ts diff --git a/internal/cloud/code-agent-session-registry.test.mjs b/internal/cloud/code-agent-session-registry.test.ts similarity index 99% rename from internal/cloud/code-agent-session-registry.test.mjs rename to internal/cloud/code-agent-session-registry.test.ts index 3ff2fc07..d2bc5c1a 100644 --- a/internal/cloud/code-agent-session-registry.test.mjs +++ b/internal/cloud/code-agent-session-registry.test.ts @@ -3,15 +3,15 @@ import { existsSync } from "node:fs"; import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import test from "node:test"; +import { test } from "bun:test"; -import { createCodeAgentSessionRegistry } from "./code-agent-session-registry.mjs"; -import { handleCodeAgentChat, validateCodeAgentChatSchema } from "./code-agent-chat.mjs"; +import { createCodeAgentSessionRegistry } from "./code-agent-session-registry.ts"; +import { handleCodeAgentChat, validateCodeAgentChatSchema } from "./code-agent-chat.ts"; import { codexAppServerArgs, codexAppServerProviderBaseUrl, createCodexStdioSessionManager -} from "./codex-stdio-session.mjs"; +} from "./codex-stdio-session.ts"; import { classifyCodeAgentChatReadiness, classifyCodexRunnerCapability diff --git a/internal/cloud/code-agent-session-registry.mjs b/internal/cloud/code-agent-session-registry.ts similarity index 99% rename from internal/cloud/code-agent-session-registry.mjs rename to internal/cloud/code-agent-session-registry.ts index 897249d6..347f202d 100644 --- a/internal/cloud/code-agent-session-registry.mjs +++ b/internal/cloud/code-agent-session-registry.ts @@ -5,7 +5,7 @@ import { CODE_AGENT_SESSION_STATUS_ALIASES, codeAgentSessionLifecycleSummary, decorateCodeAgentSession -} from "./code-agent-session-lifecycle.mjs"; +} from "./code-agent-session-lifecycle.ts"; export const DEFAULT_CODE_AGENT_SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1000; export const DEFAULT_CODE_AGENT_MAX_SESSIONS = 200; diff --git a/internal/cloud/code-agent-trace-store.test.ts b/internal/cloud/code-agent-trace-store.test.ts new file mode 100644 index 00000000..d4b4bcd6 --- /dev/null +++ b/internal/cloud/code-agent-trace-store.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import { test } from "bun:test"; + +import { createCodeAgentTraceStore } from "./code-agent-trace-store.ts"; + +test("code agent trace store keeps assistant deltas outside event count", () => { + const traceStore = createCodeAgentTraceStore({ maxEvents: 6000 }); + const traceId = "trc_trace-store-assistant-stream"; + traceStore.append(traceId, { type: "request", status: "accepted", label: "request:accepted" }); + for (let index = 0; index < 6200; index += 1) { + traceStore.appendAssistantDelta(traceId, { + itemId: "item_assistant_stream", + chunk: `chunk-${index} `, + waitingFor: "turn/completed" + }); + } + const snapshot = traceStore.snapshot(traceId); + assert.equal(snapshot.eventCount, 1); + assert.equal(snapshot.events.length, 1); + assert.equal(snapshot.events[0].label, "request:accepted"); + assert.equal(snapshot.assistantStreams.length, 1); + assert.equal(snapshot.assistantStreams[0].chunkCount, 6200); + assert.equal(snapshot.waitingFor, "turn/completed"); +}); + +test("code agent trace store retains six thousand regular events", () => { + const traceStore = createCodeAgentTraceStore({ maxEvents: 6000 }); + const traceId = "trc_trace-store-regular-events"; + for (let index = 0; index < 6001; index += 1) { + traceStore.append(traceId, { type: "trace", status: "observed", label: `event:${index}` }); + } + const snapshot = traceStore.snapshot(traceId); + assert.equal(snapshot.eventCount, 6000); + assert.equal(snapshot.events.length, 6000); + assert.equal(snapshot.events[0].label, "event:1"); + assert.equal(snapshot.events.at(-1).label, "event:6000"); +}); diff --git a/internal/cloud/code-agent-trace-store.mjs b/internal/cloud/code-agent-trace-store.ts similarity index 100% rename from internal/cloud/code-agent-trace-store.mjs rename to internal/cloud/code-agent-trace-store.ts diff --git a/internal/cloud/codex-stdio-session-helpers.ts b/internal/cloud/codex-stdio-session-helpers.ts new file mode 100644 index 00000000..82576773 --- /dev/null +++ b/internal/cloud/codex-stdio-session-helpers.ts @@ -0,0 +1,1843 @@ + +import { 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 { + codeAgentSessionLifecycleSummary, + decorateCodeAgentSession +} from "./code-agent-session-lifecycle.ts"; +import { runnerTraceFromSnapshot } from "./code-agent-trace-store.ts"; +import { + CODEX_APP_SERVER_PROTOCOL, + CODEX_APP_SERVER_REQUIRED_METHODS, + CODEX_APP_SERVER_WIRE_API, + CODEX_CHILD_NO_PROXY_REQUIRED, + CODEX_CHILD_STRIPPED_ENV_KEYS, + CODEX_STDIO_BACKEND, + CODEX_STDIO_BOUNDARY_INSTRUCTIONS, + CODEX_STDIO_CAPABILITY_LEVEL, + CODEX_STDIO_COMMAND_PROBE_TIMEOUT_MS, + CODEX_STDIO_IMPLEMENTATION_TYPE, + CODEX_STDIO_PROVIDER, + CODEX_STDIO_RUNNER_KIND, + CODEX_STDIO_SANDBOX, + CODEX_STDIO_SESSION_MODE, + CODEX_STDIO_SKILL_LIMIT, + CODEX_STDIO_SKILL_REPLY_LIMIT, + CODEX_STDIO_SKILL_SUMMARY_LIMIT, + CODEX_STDIO_TOOL_OUTPUT_LIMIT, + codexAppServerProviderBaseUrl, + DEFAULT_CODEX_STDIO_COMMAND, + DEFAULT_CODEX_STDIO_IDLE_TIMEOUT_MS, + DEFAULT_CODEX_STDIO_MAX_SESSIONS, + DEFAULT_CODEX_STDIO_PROBE_TTL_MS, + DEFAULT_CODEX_STDIO_TURN_ACTIVITY_TIMEOUT_MS, + DEFAULT_CODEX_STDIO_TURN_HARD_TIMEOUT_MS +} from "./codex-stdio-session.ts"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + +export 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; +} + +export 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; +} + +export 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 + }; +} + +export 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(" "); +} + +export 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 + }; +} + +export function codexAppServerTextInput(text) { + return [{ type: "text", text: String(text ?? ""), text_elements: [] }]; +} + +export function extractAppServerRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null; +} + +export function extractAppServerString(record, key) { + return typeof record?.[key] === "string" && record[key].trim() ? record[key].trim() : null; +} + +export 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; +} + +export 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"; +} + +export function commandExecutionCommandText(item) { + return firstCommandExecutionText( + item?.command, + item?.cmd, + item?.argv, + item?.input, + item?.metadata?.command + ); +} + +export function commandExecutionOutputText(item) { + return firstCommandExecutionText( + item?.aggregatedOutput, + item?.output, + item?.stdout, + item?.result, + item?.metadata?.output + ); +} + +export 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; +} + +export function firstCommandExecutionText(...values) { + for (const value of values) { + const text = commandExecutionTextValue(value); + if (text) return text; + } + return ""; +} + +export 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." + }; +} + +export 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 }); +} + +export 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 + } + }; +} + +export 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() + }; +} + +export 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." + }); +} + +export 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 + }; +} + +export 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: "" + }; +} + +export 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; +} + +export 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"; +} + +export 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 + }; +} + +export 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 + }; +} + +export 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) + }; +} + +export 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 || "."; +} + +export 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 + }; +} + +export 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 }); + } +} + +export 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; +} + +export 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 + }; +} + +export 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")]; +} + +export 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; +} + +export 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; + } +} + +export 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; +} + +export function firstMarkdownSummary(body) { + return String(body ?? "") + .split(/\r?\n/u) + .map((line) => line.trim()) + .find((line) => line && !line.startsWith("#") && !line.startsWith("-")) ?? null; +} + +export function firstManifestField(frontmatter, keys) { + for (const key of keys) { + const value = String(frontmatter?.[key] ?? "").trim(); + if (value) return boundSkillText(value, 120); + } + return null; +} + +export 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))}...`; +} + +export 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; +} + +export function notRequestedSkills() { + return { + status: "not_requested", + items: [], + count: 0, + blockers: [] + }; +} + +export function codexReplyWithSidecar(content, sidecar = {}) { + if (sidecar.intent?.skills && sidecar.skills?.status === "ready") { + return skillsDiscoveryReply(sidecar.skills); + } + return String(content ?? "").trim(); +} + +export 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(); +} + +export 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"; + } + } + }; +} + +export function isPathInside(child, parent) { + const relative = path.relative(parent, child); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +export 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); +} + +export 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 + }; +} + +export function safeDisplayPath(value) { + return redactText(String(value ?? ".")).replace(/\s+/gu, " "); +} + +export function buildCodexUserPrompt(message, { conversationId, traceId, conversationFacts, sidecar, gatewayShellTimeoutMs }) { + return [ + `conversationId: ${conversationId}`, + `traceId: ${traceId}`, + `gatewayShellTimeoutMs: ${gatewayShellTimeoutMs}`, + "", + CODEX_STDIO_BOUNDARY_INSTRUCTIONS.replace(//gu, String(gatewayShellTimeoutMs)), + codexConversationFactsPrompt(conversationFacts), + codexSidecarSkillsPrompt(sidecar), + "", + "User message:", + String(message ?? "").trim() + ].filter((line) => line !== null && line !== undefined).join("\n"); +} + +export 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); +} + +export 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"); +} + +export 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"); +} + +export 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(",")}` : ""}`; +} + +export 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"; +} + +export function promptList(values, limit) { + return Array.isArray(values) && values.length > 0 + ? values.filter(Boolean).slice(-limit).map(safePromptFact).join(",") + : "none"; +} + +export function safePromptFact(value) { + if (value === undefined || value === null || value === "") return "none"; + return redactText(String(value).replace(/\s+/gu, " ").trim()).slice(0, 180); +} + +export 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 + )); +} + +export 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; +} + +export 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; +} + +export 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") + )); +} + +export 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"; +} + +export 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 + }; +} + +export 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 + }; +} + +export 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 + }; +} + +export 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; + } +} + +export 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; + } +} + +export 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 + }; +} + +export function filePresentSync(targetPath) { + try { + return existsSync(targetPath) && statSync(targetPath).isFile(); + } catch { + return false; + } +} + +export function accessSyncBoolean(target, mode) { + try { + accessSync(target, mode); + return true; + } catch { + return false; + } +} + +export function pathReadableSync(targetPath) { + return accessSyncBoolean(targetPath, fsConstants.R_OK); +} + +export 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 + }; +} + +export 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." + }; +} + +export 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; +} + +export 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; +} + +export function commandOnPathSync(command, env = process.env) { + return Boolean(commandPathSync(command, env)); +} + +export 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) + }; + } +} + +export 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 + }; +} + +export 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; +} + +export 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; +} + +export 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 + }; +} + +export 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 + }; +} + +export 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 + }; +} + +export 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 + }; +} + +export 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" + }; +} + +export 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 + } + }; +} + +export 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 + }; +} + +export 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) : {}) + }; +} + +export 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; +} + +export 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 + }; +} + +export 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 []; + } +} + +export function rpcClientConfigSignature({ availability, args = [], childEnv = {} } = {}) { + return JSON.stringify({ + command: availability?.command ?? null, + workspace: availability?.workspace ?? null, + codexHome: childEnv.CODEX_HOME ?? null, + args + }); +} + +export 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(","); +} + +export function splitNoProxy(value) { + return String(value ?? "") + .split(/[,;\s]+/u) + .map((item) => item.trim()) + .filter(Boolean); +} + +export 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 + }; +} + +export 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; +} + +export function sessionExpired(session, timestampMs) { + if (!Number.isFinite(timestampMs) || session.status === "expired") return session.status === "expired"; + return Date.parse(session.expiresAt) <= timestampMs; +} + +export 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(); +} + +export function plusMs(timestampMs, offsetMs) { + return new Date(timestampMs + offsetMs).toISOString(); +} + +export function requiredId(value, fallbackPrefix) { + const id = optionalId(value); + if (id) return id; + return `${fallbackPrefix}_${randomUUID()}`; +} + +export function optionalId(value) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +export function positiveInteger(value, fallback) { + return Number.isInteger(value) && value > 0 ? value : fallback; +} + +export function firstNonEmpty(...values) { + for (const value of values) { + if (typeof value === "string" && value.trim()) return value.trim(); + } + return ""; +} + +export function hasEnvValue(env, name) { + return typeof env?.[name] === "string" && env[name].trim().length > 0; +} + +export function effectiveTimeout(timeoutMs) { + return effectiveActivityTimeout(timeoutMs); +} + +export function effectiveActivityTimeout(timeoutMs) { + return Number.isInteger(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_CODEX_STDIO_TURN_ACTIVITY_TIMEOUT_MS; +} + +export 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); +} + +export function dropEmpty(value) { + return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== null && item !== "")); +} + +export function parseJsonOrNull(value) { + try { + return JSON.parse(value); + } catch { + return null; + } +} + +export function tailText(value, maxLength = 1200) { + const text = String(value ?? "").trim(); + if (text.length <= maxLength) return text; + return text.slice(text.length - maxLength); +} + +export 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 + }; +} + +export 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***"); +} diff --git a/internal/cloud/codex-stdio-session-turn-state.ts b/internal/cloud/codex-stdio-session-turn-state.ts new file mode 100644 index 00000000..1d6a9ea9 --- /dev/null +++ b/internal/cloud/codex-stdio-session-turn-state.ts @@ -0,0 +1,419 @@ + +import { CODEX_STDIO_FIRST_TOKEN_PROGRESS_MS } from "./codex-stdio-session.ts"; +import { + appServerActivityTimeoutError, + appServerCommandStatus, + appServerHardTimeoutError, + appServerTerminalStatus, + commandExecutionCommandText, + commandExecutionErrorText, + commandExecutionOutputText, + extractAppServerRecord, + extractAppServerString, + firstNonEmpty, + optionalId, + redactText, + tailText +} from "./codex-stdio-session-helpers.ts"; + +export 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"; +} diff --git a/internal/cloud/codex-stdio-session.mjs b/internal/cloud/codex-stdio-session.mjs deleted file mode 100644 index 1fe59fff..00000000 --- a/internal/cloud/codex-stdio-session.mjs +++ /dev/null @@ -1,3770 +0,0 @@ -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 [options] -- . 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 -- . For PowerShell, call tran as: node /app/tools/hwlab-gateway-tran.mjs gws_DESKTOP-1MHOD9I:/f/work ps --