diff --git a/docs/reference/codex-deploy.md b/docs/reference/codex-deploy.md index ad6187a3..badf6ec9 100644 --- a/docs/reference/codex-deploy.md +++ b/docs/reference/codex-deploy.md @@ -41,6 +41,23 @@ D601 原生 k3s 的人工诊断必须显式使用 host kubeconfig:`KUBECONFIG= `bun scripts/cli.ts microservice diagnostics code-queue` 必须作为 Code Queue 的长期诊断入口。它必须显式报告 `d601-provider-egress-proxy` 和 `d601-tcp-egress-gateway` 的 Deployment available、Endpoint non-empty、scheduler 到 PostgreSQL route、以及 stale active/retry_wait reconcile;任何一项失败都应让诊断结果 degraded/failing,而不是只显示 scheduler HTTP healthy。 +## Pull Request Delivery + +Code Queue worker 可以在任务明确要求审查型交付时创建 Pull Request。PR 交付不是默认出口;默认集成仍遵循项目当前 master-only 规则,直到具体任务或指挥官要求改为 PR。PR 型任务必须从最新目标线创建短生命周期分支,报告源分支、目标分支、PR URL、关联 issue、验证证据和未完成风险;分支命名应使用 `probe/`、`code-queue/` 或其他明确任务前缀,禁止把隐藏分支当成长期交付状态。 + +Code Queue runtime 提供 `/api/runtime-preflight` 作为 PR 能力探测入口。默认请求只检查本地工具、凭证可见性、Git worktree、HOME、known_hosts、agent port 和 proxy DNS;`/api/runtime-preflight?remote=1` 会增加 GitHub 网络、issue API、SSH/HTTPS `git ls-remote`、GitHub SSH、`gh auth status`、`gh repo view`、`gh issue view` 和只读 `gh pr list` 探测;`/api/runtime-preflight?remote=1&pushDryRun=1&pushDryRunRef=refs/heads/probe/` 会额外执行 `git push --dry-run`,验证远端写权限但不创建分支。`issue=` 可覆盖默认 issue #20 探针。探测输出只报告 `GH_TOKEN`/`GITHUB_TOKEN` 是否存在,不得输出 token 内容。 + +PR 创建依赖以下最小运行时能力: + +- runtime image 必须包含 `git`、`gh`、`jq`、`ca-certificates`、`curl` 和 `openssh-client`;D601 provider dev container 准备脚本也必须补齐这些工具。 +- GitHub 凭证只能通过运行时 secret、环境变量或已有 SSH identity 注入。优先使用 `GH_TOKEN`,兼容 `GITHUB_TOKEN`;不得把 token 写入 Git remote、任务 prompt、日志、镜像或仓库文件。 +- `CODE_QUEUE_REMOTE_CODEX_ENV_KEYS` 默认允许把 `GH_TOKEN`/`GITHUB_TOKEN` 以及 `GH_HOST`、`GITHUB_API_URL`、`GH_REPO` 从 scheduler 传给 provider dev container,供隔离执行环境内的 `gh repo view`、`gh issue view`、`gh pr list` 和 `gh pr create` 使用。 +- DEV 的持久注入路径复用 `unidesk-dev-runtime-secrets`:把 `GH_TOKEN` 或 `GITHUB_TOKEN` 写入该 Secret 后,通过受控 `deploy apply --env dev --service code-queue` 或等价 dev-only rollout 重启 Code Queue scheduler/read/write。不要在 PROD Code Queue 上直接 patch Secret 或 rollout。 + +安全验证顺序固定为先只读、再 dry-run、最后才创建真实 PR。优先执行 `/api/runtime-preflight`、`/api/runtime-preflight?remote=1` 和带 `pushDryRun=1` 的 probe ref;只有工具、token、网络和 push dry-run 都满足且任务明确允许时,才创建 draft PR 或普通 PR。若创建真实 probe PR,最终报告必须记录 URL 并说明保留或关闭状态。 + +preflight failure 必须定位到具体缺失项。已知必须区分的状态包括:`gh`/`hub` 缺失、`GH_TOKEN`/`GITHUB_TOKEN`/`GH_HOST`/`GITHUB_API_URL`/`GH_REPO` 未注入、`/root/.config/gh/hosts.yml` 与 `/root/.git-credentials` 缺失、SSH Git 可读但 GitHub issue/API 匿名访问返回 404、匿名 HTTPS Git 不可用、以及 `HTTP_PROXY`/`HTTPS_PROXY` 指向的 egress proxy 在 runner 内不可解析。发现这类缺口时,worker 应报告 preflight error 和阻塞项,不得强行创建 PR。 + ## Boundaries Code Queue 由 D601 k3s/k8s 控制面代管,不再通过 `server rebuild`、`codex deploy`、维护通道直连 D601 或手工 `docker compose up` 作为正式部署路径。Code Queue 部署必须在自身正在执行任务时仍可运行;服务重启后由 restart-recovery 恢复任务状态,不能等待当前 Code Queue task 退出后再部署。 diff --git a/src/components/microservices/code-queue/Dockerfile b/src/components/microservices/code-queue/Dockerfile index 54153229..148315bc 100644 --- a/src/components/microservices/code-queue/Dockerfile +++ b/src/components/microservices/code-queue/Dockerfile @@ -4,7 +4,7 @@ FROM ${CODE_QUEUE_BASE_IMAGE} ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright ENV UNIDESK_SKILLS_PATH=/root/.agents/skills -RUN (command -v codex >/dev/null 2>&1 && command -v opencode >/dev/null 2>&1 && command -v docker >/dev/null 2>&1 && command -v rg >/dev/null 2>&1 && command -v cargo >/dev/null 2>&1 && command -v rustc >/dev/null 2>&1 && command -v rustfmt >/dev/null 2>&1 && test -x "$PLAYWRIGHT_BROWSERS_PATH/chromium_headless_shell-1217/chrome-headless-shell-linux64/chrome-headless-shell") \ +RUN (command -v codex >/dev/null 2>&1 && command -v opencode >/dev/null 2>&1 && command -v docker >/dev/null 2>&1 && command -v gh >/dev/null 2>&1 && command -v rg >/dev/null 2>&1 && command -v cargo >/dev/null 2>&1 && command -v rustc >/dev/null 2>&1 && command -v rustfmt >/dev/null 2>&1 && test -x "$PLAYWRIGHT_BROWSERS_PATH/chromium_headless_shell-1217/chrome-headless-shell-linux64/chrome-headless-shell") \ || (apt-get update \ && apt-get install -y --no-install-recommends \ bash \ @@ -16,6 +16,7 @@ RUN (command -v codex >/dev/null 2>&1 && command -v opencode >/dev/null 2>&1 && file \ g++ \ gcc \ + gh \ git \ gzip \ iproute2 \ diff --git a/src/components/microservices/code-queue/src/index.ts b/src/components/microservices/code-queue/src/index.ts index d4aefc88..0de20934 100644 --- a/src/components/microservices/code-queue/src/index.ts +++ b/src/components/microservices/code-queue/src/index.ts @@ -132,6 +132,7 @@ import { readOaTraceStatsForTasks, readOaTraceStepsForTask, } from "./oa-events"; +import { collectRuntimePreflight, runtimePreflightJson } from "./runtime-preflight"; import { configureSelfTests, runJudgeInfraSelfTest, runQueueClaimMoveSelfTest, runQueueOrderingSelfTest, runReferenceInjectionSelfTest, runTracePortSelfTest, runTraceSummaryContractSelfTest } from "./self-tests"; import { codexToolLifecycleStartedBeforeIn, @@ -384,7 +385,7 @@ function readConfig(): RuntimeConfig { mainProviderId, remoteDefaultWorkdir, executionProviderIds, - remoteCodexEnvKeys: envList("CODE_QUEUE_REMOTE_CODEX_ENV_KEYS", ["OPENAI_API_KEY", "CRS_OAI_KEY", "OPENAI_BASE_URL", "OPENAI_API_BASE", "MINIMAX_API_KEY", "MINIMAX_API_BASE", "MINIMAX_MODEL"]), + remoteCodexEnvKeys: envList("CODE_QUEUE_REMOTE_CODEX_ENV_KEYS", ["OPENAI_API_KEY", "CRS_OAI_KEY", "OPENAI_BASE_URL", "OPENAI_API_BASE", "MINIMAX_API_KEY", "MINIMAX_API_BASE", "MINIMAX_MODEL", "GH_TOKEN", "GITHUB_TOKEN", "GH_HOST", "GITHUB_API_URL", "GH_REPO"]), skillsPath: envString("UNIDESK_SKILLS_PATH", "/root/.agents/skills"), codexHome: envString("CODE_QUEUE_CODEX_HOME", "/var/lib/unidesk/code-queue/codex-home"), opencodeXdgDir: envString("CODE_QUEUE_OPENCODE_XDG_DIR", resolve(dataDir, "opencode-xdg")), @@ -2501,6 +2502,7 @@ function collectDevReady(): JsonValue { "pip3", "docker", "docker-compose", + "gh", "jq", "ssh", "rsync", @@ -2529,6 +2531,7 @@ function collectDevReady(): JsonValue { const sshSharedReady = existsSync("/root/.ssh") && sshKeyProbe.ok && sshKeyProbe.output.trim().length > 0; const skills = collectSkillsStatus() as Record; const skillsReady = skills.available === true && skills.readonly === true && skills.cliSpecAvailable === true; + const runtimePreflight = runtimePreflightJson(collectRuntimePreflight({ includeRemote: false, includePushDryRun: false })); const ok = missingTools.length === 0 && dockerProbe.ok && composeProbe.ok && workdirExists && dockerSocketExists && codexConfigReady && sshSharedReady && skillsReady; const value: JsonValue = { ok, @@ -2562,6 +2565,7 @@ function collectDevReady(): JsonValue { ready: sshSharedReady, }, skills, + runtimePreflight, }; devReadyCache = { checkedAtMs: now, value }; return value; @@ -5273,6 +5277,17 @@ async function route(req: Request): Promise { if (url.pathname === "/logs") return jsonResponse({ ok: true, logs: recentLogs.slice(-parseLimit(url)) }); if (url.pathname === "/api/events" && req.method === "GET") return jsonResponse({ ok: false, error: "Code Queue private SSE was removed; subscribe to oa-event-flow /api/events/stream with service:code-queue tags." }, 410); if (url.pathname === "/api/dev-ready" && req.method === "GET") return jsonResponse({ ok: true, devReady: collectDevReady() }); + if (url.pathname === "/api/runtime-preflight" && req.method === "GET") { + return jsonResponse({ + ok: true, + runtimePreflight: runtimePreflightJson(collectRuntimePreflight({ + includeRemote: url.searchParams.get("remote") === "1", + includePushDryRun: url.searchParams.get("pushDryRun") === "1", + pushDryRunRef: url.searchParams.get("pushDryRunRef") ?? undefined, + issueNumber: Number(url.searchParams.get("issue") ?? url.searchParams.get("issueNumber") ?? NaN), + })), + }); + } if (url.pathname === "/api/dev-containers" && req.method === "GET") { const plan = buildDevContainerPlan(normalizeProviderId(config.devContainerDefaultProviderId) ?? "D601", {}); return jsonResponse({ diff --git a/src/components/microservices/code-queue/src/provider-runtime.ts b/src/components/microservices/code-queue/src/provider-runtime.ts index 6b3150d2..d64efc7c 100644 --- a/src/components/microservices/code-queue/src/provider-runtime.ts +++ b/src/components/microservices/code-queue/src/provider-runtime.ts @@ -976,11 +976,19 @@ OPENCODE_XDG_DIR=${shellQuote(plan.remoteOpencodeXdgDir)} export PATH=/tmp/unidesk-tools:$PATH if command -v apt-get >/dev/null 2>&1; then missing="" - for c in git rg curl python3 pip3 jq rsync patch make gcc g++ tar gzip unzip; do command -v "$c" >/dev/null 2>&1 || missing="$missing $c"; done + for c in git gh rg curl python3 pip3 jq rsync patch make gcc g++ tar gzip unzip; do command -v "$c" >/dev/null 2>&1 || missing="$missing $c"; done if [ -n "$missing" ]; then export DEBIAN_FRONTEND=noninteractive apt-get update - apt-get install -y --no-install-recommends git ripgrep curl python3 python3-pip jq rsync patch make gcc g++ tar gzip unzip ca-certificates + packages="git ripgrep curl python3 python3-pip jq rsync patch make gcc g++ tar gzip unzip ca-certificates" + if echo "$missing" | grep -qw gh; then + if apt-cache show gh >/dev/null 2>&1; then + packages="$packages gh" + else + echo "warning: apt package gh is unavailable in this container base image" >&2 + fi + fi + apt-get install -y --no-install-recommends $packages rm -rf /var/lib/apt/lists/* fi fi diff --git a/src/components/microservices/code-queue/src/runtime-preflight.ts b/src/components/microservices/code-queue/src/runtime-preflight.ts new file mode 100644 index 00000000..cfcca1d2 --- /dev/null +++ b/src/components/microservices/code-queue/src/runtime-preflight.ts @@ -0,0 +1,506 @@ +// 重构前 index.ts 只读参考:commit 6a04144d3f5103014f75b637d7e6bc2f45bf007f,blob 56e590c1a6b5ca7ad128bf2c992f60e46c355a58;可用 `git show 6a04144d3f5103014f75b637d7e6bc2f45bf007f:src/components/microservices/code-queue/src/index.ts` 查看。 + +import { spawnSync } from "node:child_process"; +import type { JsonValue } from "./types"; + +export type RuntimePreflightAgentPort = "codex" | "opencode"; + +export interface RuntimePreflightOptions { + includeRemote?: boolean; + includePushDryRun?: boolean; + pushDryRunRef?: string; + issueNumber?: number; +} + +export interface RuntimeCommandProbe { + command: string; + args: string[]; + ok: boolean; + exitCode: number | null; + signal: string | null; + error: string | null; + stdout: string; + stderr: string; +} + +export interface RuntimePreflightPortStatus { + agentPort: RuntimePreflightAgentPort; + ok: boolean; + path: string; + commandPath: string | null; + version: string | null; + appServerDryRun?: RuntimeCommandProbe; + probes: { + commandV: RuntimeCommandProbe; + version: RuntimeCommandProbe; + }; + errors: string[]; + checkedAt: string; +} + +export interface RuntimePreflightReport { + ok: boolean; + checkedAt: string; + cwd: string; + pid: number; + node: { + platform: NodeJS.Platform; + arch: string; + version: string; + }; + path: string; + ports: Record; + pullRequestDelivery: PullRequestDeliveryPreflight; +} + +export interface PullRequestDeliveryPreflight { + ok: boolean; + checkedAt: string; + tools: Record; + credentials: { + ghTokenPresent: boolean; + githubTokenPresent: boolean; + ghHostPresent: boolean; + githubApiUrlPresent: boolean; + ghRepoPresent: boolean; + sshAuthSockPresent: boolean; + gitAskpassPresent: boolean; + ghHostsConfigPresent: boolean; + gitCredentialsPresent: boolean; + }; + githubContext: { + host: string; + apiBaseUrl: string; + repo: string | null; + issueProbeNumber: number; + }; + egress: { + proxy: { + httpProxy: string | null; + httpsProxy: string | null; + allProxy: string | null; + noProxy: string | null; + selectedProxyHost: string | null; + selectedProxyPort: string | null; + selectedProxyHostProbe: RuntimeCommandProbe | null; + selectedProxyHostResolvable: boolean | null; + }; + githubDefault: RuntimeCommandProbe; + githubDirect: RuntimeCommandProbe; + apiDefault: RuntimeCommandProbe; + apiDirect: RuntimeCommandProbe; + issueApi: RuntimeCommandProbe | null; + }; + git: { + insideWorktree: boolean; + branch: string | null; + head: string | null; + originMaster: string | null; + remoteOrigin: string | null; + userName: string | null; + userEmail: string | null; + home: string | null; + homeWritable: boolean; + knownHostsPresent: boolean; + privateKeyPresent: boolean; + }; + remote?: { + gitLsRemote: RuntimeCommandProbe; + gitHttpsLsRemote: RuntimeCommandProbe | null; + githubSshAuth: RuntimeCommandProbe; + githubSshAuthenticated: boolean; + ghAuthStatus: RuntimeCommandProbe | null; + ghRepoView: RuntimeCommandProbe | null; + ghIssueView: RuntimeCommandProbe | null; + ghPrReadOnly: RuntimeCommandProbe | null; + }; + pushDryRun?: RuntimeCommandProbe; + limitations: string[]; + risks: string[]; +} + +export interface RuntimeToolStatus { + name: string; + ok: boolean; + path: string | null; + version: string | null; + probe: RuntimeCommandProbe; +} + +function redactSensitive(value: string): string { + return value + .replace(/gh[pousr]_[A-Za-z0-9_]{20,}/gu, "***") + .replace(/github_pat_[A-Za-z0-9_]+/gu, "***") + .replace(/(https?:\/\/)([^/\s:@]+):([^/\s@]+)@/giu, "$1***:***@") + .replace(/(https?:\/\/)([^/\s@]+)@/giu, "$1***@") + .replace(/([?&](?:access_token|token)=)[^&\s]+/giu, "$1***"); +} + +function compactOutput(value: string, maxChars = 4000): string { + const text = redactSensitive(value).replace(/\u001b\[[0-9;]*m/gu, "").trim(); + return text.length <= maxChars ? text : `${text.slice(0, maxChars)}...`; +} + +function commandProbe(command: string, args: string[], options: { timeoutMs?: number } = {}): RuntimeCommandProbe { + const run = spawnSync(command, args, { + encoding: "utf8", + timeout: options.timeoutMs ?? 5000, + maxBuffer: 256 * 1024, + env: process.env, + shell: false, + }); + const error = run.error instanceof Error ? run.error.message : null; + const timedOut = run.error instanceof Error && run.error.message.includes("ETIMEDOUT"); + return { + command, + args, + ok: error === null && !timedOut && run.status === 0, + exitCode: run.status, + signal: run.signal, + error, + stdout: compactOutput(typeof run.stdout === "string" ? run.stdout : ""), + stderr: compactOutput(typeof run.stderr === "string" ? run.stderr : ""), + }; +} + +function shellProbe(script: string, timeoutMs = 5000): RuntimeCommandProbe { + return commandProbe("sh", ["-lc", script], { timeoutMs }); +} + +function skippedProbe(name: string): RuntimeCommandProbe { + return { + command: "preflight", + args: [name], + ok: true, + exitCode: 0, + signal: null, + error: null, + stdout: "skipped; pass remote=1 to run this probe", + stderr: "", + }; +} + +function firstLine(value: string): string | null { + const line = value.split(/\r?\n/u).map((item) => item.trim()).find((item) => item.length > 0); + return line ?? null; +} + +function errorSummary(probe: RuntimeCommandProbe): string | null { + if (probe.ok) return null; + const detail = firstLine([probe.error ?? "", probe.stderr, probe.stdout].filter(Boolean).join("\n")); + return `${probe.command} ${probe.args.join(" ")} failed${detail === null ? "" : `: ${detail}`}`; +} + +function commandPath(command: string): string | null { + const probe = shellProbe(`command -v ${shellQuote(command)}`, 2000); + return probe.ok ? firstLine(probe.stdout) : null; +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/gu, "'\\''")}'`; +} + +function toolStatus(name: string, versionArgs: string[] = ["--version"]): RuntimeToolStatus { + const path = commandPath(name); + const probe = path === null ? shellProbe(`command -v ${shellQuote(name)}`, 2000) : commandProbe(name, versionArgs, { timeoutMs: 5000 }); + return { + name, + ok: path !== null, + path, + version: path === null ? null : firstLine(probe.stdout) ?? firstLine(probe.stderr), + probe, + }; +} + +function gitValue(args: string[]): string | null { + const probe = commandProbe("git", args, { timeoutMs: 5000 }); + return probe.ok ? firstLine(probe.stdout) : null; +} + +function homeWritable(): boolean { + const home = process.env.HOME; + if (home === undefined || home.trim().length === 0) return false; + return shellProbe(`test -w ${shellQuote(home)}`, 2000).ok; +} + +function boolEnv(name: string): boolean { + const value = process.env[name]; + return value !== undefined && value.trim().length > 0; +} + +function fileExistsProbe(path: string): boolean { + return shellProbe(`test -s ${shellQuote(path)}`, 2000).ok; +} + +function parseGitHubRepo(remote: string | null): string | null { + const envRepo = process.env.GH_REPO?.trim(); + if (envRepo !== undefined && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(envRepo)) return envRepo; + if (remote === null) return null; + const sshMatch = remote.match(/^git@github\.com:([^/]+\/[^/]+?)(?:\.git)?$/u); + if (sshMatch !== null) return sshMatch[1] ?? null; + const httpsMatch = remote.match(/^https:\/\/github\.com\/([^/]+\/[^/]+?)(?:\.git)?(?:\/)?$/u); + if (httpsMatch !== null) return httpsMatch[1] ?? null; + return null; +} + +function envUrl(name: string): URL | null { + const raw = process.env[name]?.trim(); + if (raw === undefined || raw.length === 0) return null; + try { + return new URL(raw); + } catch { + return null; + } +} + +function proxySummary(): PullRequestDeliveryPreflight["egress"]["proxy"] { + const httpProxy = process.env.HTTP_PROXY || process.env.http_proxy || null; + const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy || null; + const allProxy = process.env.ALL_PROXY || process.env.all_proxy || null; + const noProxy = process.env.NO_PROXY || process.env.no_proxy || null; + const selected = envUrl("HTTPS_PROXY") ?? envUrl("https_proxy") ?? envUrl("HTTP_PROXY") ?? envUrl("http_proxy") ?? envUrl("ALL_PROXY") ?? envUrl("all_proxy"); + const selectedProxyHost = selected?.hostname ?? null; + const selectedProxyPort = selected === null ? null : selected.port || (selected.protocol === "https:" ? "443" : "80"); + const selectedProxyHostProbe = selectedProxyHost === null ? null : commandProbe("getent", ["ahosts", selectedProxyHost], { timeoutMs: 5000 }); + return { + httpProxy: httpProxy === null ? null : compactOutput(httpProxy, 1000), + httpsProxy: httpsProxy === null ? null : compactOutput(httpsProxy, 1000), + allProxy: allProxy === null ? null : compactOutput(allProxy, 1000), + noProxy: noProxy === null ? null : compactOutput(noProxy, 1000), + selectedProxyHost, + selectedProxyPort, + selectedProxyHostProbe, + selectedProxyHostResolvable: selectedProxyHostProbe === null ? null : selectedProxyHostProbe.ok, + }; +} + +function authCurlProbe(url: string): RuntimeCommandProbe { + const script = [ + "set -eu", + "status_file=/tmp/unidesk-github-api-probe.status", + "body_file=/tmp/unidesk-github-api-probe.json", + "rm -f \"$status_file\" \"$body_file\"", + "if [ -n \"${GH_TOKEN:-}\" ]; then", + ` curl -sS -o "$body_file" -w 'http_status=%{http_code}\\n' -H "Authorization: Bearer \${GH_TOKEN}" ${shellQuote(url)} > "$status_file"`, + "elif [ -n \"${GITHUB_TOKEN:-}\" ]; then", + ` curl -sS -o "$body_file" -w 'http_status=%{http_code}\\n' -H "Authorization: Bearer \${GITHUB_TOKEN}" ${shellQuote(url)} > "$status_file"`, + "else", + ` curl -sS -o "$body_file" -w 'http_status=%{http_code}\\n' ${shellQuote(url)} > "$status_file"`, + "fi", + "cat \"$status_file\"", + "status=$(sed -n 's/^http_status=//p' \"$status_file\" | tail -1)", + "rm -f /tmp/unidesk-github-api-probe.json", + "rm -f \"$status_file\"", + "case \"$status\" in 2*) exit 0 ;; *) exit 1 ;; esac", + ].join("\n"); + return shellProbe(script, 15_000); +} + +function issueApiUrl(apiBaseUrl: string, repo: string | null, issueNumber: number): string | null { + if (repo === null) return null; + return `${apiBaseUrl.replace(/\/+$/u, "")}/repos/${repo}/issues/${issueNumber}`; +} + +function ghRepoArgs(repo: string | null): string[] { + return repo === null ? ["repo", "view", "--json", "nameWithOwner,visibility,isPrivate"] : ["repo", "view", repo, "--json", "nameWithOwner,visibility,isPrivate"]; +} + +function httpsGitUrl(repo: string | null, host: string): string | null { + return repo === null ? null : `https://${host}/${repo}.git`; +} + +function collectPullRequestDeliveryPreflight(options: RuntimePreflightOptions, checkedAt: string): PullRequestDeliveryPreflight { + const tools = { + git: toolStatus("git", ["--version"]), + gh: toolStatus("gh", ["--version"]), + hub: toolStatus("hub", ["version"]), + jq: toolStatus("jq", ["--version"]), + ssh: toolStatus("ssh", ["-V"]), + curl: toolStatus("curl", ["--version"]), + }; + const insideProbe = commandProbe("git", ["rev-parse", "--is-inside-work-tree"], { timeoutMs: 5000 }); + const insideWorktree = insideProbe.ok && firstLine(insideProbe.stdout) === "true"; + const home = process.env.HOME ?? null; + const remoteOrigin = gitValue(["remote", "get-url", "origin"]); + const githubHost = process.env.GH_HOST?.trim() || "github.com"; + const githubApiBaseUrl = (process.env.GITHUB_API_URL?.trim() || "https://api.github.com").replace(/\/+$/u, ""); + const githubRepo = parseGitHubRepo(remoteOrigin); + const issueProbeNumberRaw = Number(options.issueNumber ?? process.env.CODE_QUEUE_PR_PREFLIGHT_ISSUE_NUMBER ?? 20); + const issueProbeNumber = Number.isInteger(issueProbeNumberRaw) && issueProbeNumberRaw > 0 ? issueProbeNumberRaw : 20; + const privateKeyProbe = shellProbe("test -d \"$HOME/.ssh\" && find \"$HOME/.ssh\" -maxdepth 1 -type f \\( -name 'id_*' ! -name '*.pub' -o -name '*.pem' \\) -print -quit", 2000); + const knownHostsProbe = shellProbe("test -s \"$HOME/.ssh/known_hosts\"", 2000); + const gitInfo = { + insideWorktree, + branch: gitValue(["branch", "--show-current"]), + head: gitValue(["rev-parse", "--short", "HEAD"]), + originMaster: gitValue(["rev-parse", "--short", "origin/master"]), + remoteOrigin: remoteOrigin === null ? null : compactOutput(remoteOrigin, 800), + userName: gitValue(["config", "--get", "user.name"]), + userEmail: gitValue(["config", "--get", "user.email"]), + home, + homeWritable: homeWritable(), + knownHostsPresent: knownHostsProbe.ok, + privateKeyPresent: privateKeyProbe.ok && privateKeyProbe.stdout.trim().length > 0, + }; + const credentials = { + ghTokenPresent: boolEnv("GH_TOKEN"), + githubTokenPresent: boolEnv("GITHUB_TOKEN"), + ghHostPresent: boolEnv("GH_HOST"), + githubApiUrlPresent: boolEnv("GITHUB_API_URL"), + ghRepoPresent: boolEnv("GH_REPO"), + sshAuthSockPresent: boolEnv("SSH_AUTH_SOCK"), + gitAskpassPresent: boolEnv("GIT_ASKPASS"), + ghHostsConfigPresent: fileExistsProbe(`${home ?? "/root"}/.config/gh/hosts.yml`), + gitCredentialsPresent: fileExistsProbe(`${home ?? "/root"}/.git-credentials`), + }; + const egressProxy = proxySummary(); + const issueUrl = issueApiUrl(githubApiBaseUrl, githubRepo, issueProbeNumber); + const egress = { + proxy: egressProxy, + githubDefault: options.includeRemote === true ? commandProbe("curl", ["-IsS", "--max-time", "10", `https://${githubHost}`], { timeoutMs: 15_000 }) : skippedProbe("github-default-network"), + githubDirect: options.includeRemote === true ? commandProbe("curl", ["--noproxy", "*", "-IsS", "--max-time", "10", `https://${githubHost}`], { timeoutMs: 15_000 }) : skippedProbe("github-direct-network"), + apiDefault: options.includeRemote === true ? commandProbe("curl", ["-IsS", "--max-time", "10", githubApiBaseUrl], { timeoutMs: 15_000 }) : skippedProbe("github-api-default-network"), + apiDirect: options.includeRemote === true ? commandProbe("curl", ["--noproxy", "*", "-IsS", "--max-time", "10", githubApiBaseUrl], { timeoutMs: 15_000 }) : skippedProbe("github-api-direct-network"), + issueApi: options.includeRemote === true && issueUrl !== null ? authCurlProbe(issueUrl) : null, + }; + const githubSshAuthProbe = options.includeRemote === true + ? commandProbe("ssh", ["-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-T", `git@${githubHost}`], { timeoutMs: 20_000 }) + : null; + const githubSshAuthenticated = githubSshAuthProbe !== null + && (githubSshAuthProbe.ok || /successfully authenticated/iu.test(`${githubSshAuthProbe.stdout}\n${githubSshAuthProbe.stderr}`)); + const ghRepoView = options.includeRemote === true && tools.gh.ok + ? commandProbe("gh", ghRepoArgs(githubRepo), { timeoutMs: 15_000 }) + : null; + const ghIssueView = options.includeRemote === true && tools.gh.ok && githubRepo !== null + ? commandProbe("gh", ["issue", "view", String(issueProbeNumber), "--repo", githubRepo, "--json", "number,title,state"], { timeoutMs: 15_000 }) + : null; + const ghPrReadOnly = options.includeRemote === true && tools.gh.ok && githubRepo !== null + ? commandProbe("gh", ["pr", "list", "--repo", githubRepo, "--limit", "1", "--json", "number,title,state,headRefName,baseRefName"], { timeoutMs: 15_000 }) + : null; + const gitHttpsUrl = httpsGitUrl(githubRepo, githubHost); + const remote = options.includeRemote === true ? { + gitLsRemote: commandProbe("git", ["ls-remote", "--heads", "origin", "master"], { timeoutMs: 20_000 }), + gitHttpsLsRemote: gitHttpsUrl === null ? null : commandProbe("git", ["-c", "credential.helper=", "ls-remote", "--heads", gitHttpsUrl, "master"], { timeoutMs: 20_000 }), + githubSshAuth: githubSshAuthProbe ?? shellProbe("exit 1", 1000), + githubSshAuthenticated, + ghAuthStatus: tools.gh.ok ? commandProbe("gh", ["auth", "status", "-h", githubHost], { timeoutMs: 10_000 }) : null, + ghRepoView, + ghIssueView, + ghPrReadOnly, + } : undefined; + const pushDryRunRef = options.pushDryRunRef?.trim() || "refs/heads/probe/code-queue-pr-capability-dryrun"; + const pushDryRun = options.includePushDryRun === true + ? commandProbe("git", ["push", "--dry-run", "origin", `HEAD:${pushDryRunRef}`], { timeoutMs: 30_000 }) + : undefined; + const limitations: string[] = []; + const risks: string[] = []; + if (!tools.git.ok) limitations.push("missing git CLI"); + if (!tools.jq.ok) limitations.push("missing jq CLI"); + if (!tools.gh.ok && !tools.hub.ok) limitations.push("missing PR CLI: install gh or hub"); + if (!credentials.ghTokenPresent && !credentials.githubTokenPresent) limitations.push("GH_TOKEN/GITHUB_TOKEN is not present; gh cannot create PRs unless another gh credential store is mounted"); + if (!credentials.ghHostsConfigPresent && !credentials.gitCredentialsPresent && !credentials.ghTokenPresent && !credentials.githubTokenPresent) limitations.push("no gh hosts config, git credentials file, GH_TOKEN, or GITHUB_TOKEN is visible for GitHub API/issue/PR access"); + if (!gitInfo.insideWorktree) limitations.push("current directory is not a Git worktree"); + if (gitInfo.remoteOrigin === null) limitations.push("git remote origin is not configured"); + if (githubRepo === null) limitations.push("cannot infer GitHub owner/repo from GH_REPO or git remote origin"); + if (!gitInfo.homeWritable) limitations.push("HOME is not writable"); + if (!gitInfo.knownHostsPresent) risks.push("GitHub known_hosts is not pre-seeded; first SSH use may need accept-new behavior"); + if (!gitInfo.privateKeyPresent && !credentials.sshAuthSockPresent && !credentials.ghTokenPresent && !credentials.githubTokenPresent) limitations.push("no SSH key, SSH agent, or GitHub token credential is visible"); + if (egress.proxy.selectedProxyHost !== null && egress.proxy.selectedProxyHostResolvable === false) limitations.push(`configured GitHub egress proxy host is not resolvable: ${egress.proxy.selectedProxyHost}`); + if (options.includeRemote === true && !egress.githubDefault.ok) limitations.push("GitHub HTTPS probe failed with the default environment/proxy"); + if (options.includeRemote === true && !egress.apiDefault.ok) limitations.push("GitHub API probe failed with the default environment/proxy"); + if (options.includeRemote === true && egress.issueApi !== null && !egress.issueApi.ok) limitations.push(`GitHub issue API probe for #${issueProbeNumber} failed; private repo access likely lacks token or egress`); + if (remote !== undefined) { + if (!remote.gitLsRemote.ok) limitations.push("git ls-remote origin master failed"); + if (remote.gitHttpsLsRemote !== null && !remote.gitHttpsLsRemote.ok) limitations.push("anonymous HTTPS git ls-remote failed; HTTPS Git access likely needs token credentials"); + if (!remote.githubSshAuthenticated) limitations.push("GitHub SSH auth probe did not authenticate"); + if (remote.ghAuthStatus !== null && !remote.ghAuthStatus.ok) limitations.push("gh auth status failed"); + if (tools.gh.ok && remote.ghRepoView !== null && !remote.ghRepoView.ok) limitations.push("gh repo view failed"); + if (tools.gh.ok && remote.ghIssueView !== null && !remote.ghIssueView.ok) limitations.push(`gh issue view #${issueProbeNumber} failed`); + if (tools.gh.ok && remote.ghPrReadOnly !== null && !remote.ghPrReadOnly.ok) limitations.push("gh pr read-only probe failed"); + } + if (pushDryRun !== undefined && !pushDryRun.ok) limitations.push("git push --dry-run failed for probe branch"); + const prCliReady = tools.gh.ok || tools.hub.ok; + const tokenOrGhStore = credentials.ghTokenPresent || credentials.githubTokenPresent || credentials.ghHostsConfigPresent || (remote?.ghAuthStatus?.ok === true); + const remoteReady = remote === undefined || (egress.githubDefault.ok && egress.apiDefault.ok && remote.gitLsRemote.ok && remote.githubSshAuthenticated); + const pushReady = pushDryRun === undefined || pushDryRun.ok; + return { + ok: tools.git.ok && tools.jq.ok && prCliReady && tokenOrGhStore && gitInfo.insideWorktree && gitInfo.remoteOrigin !== null && gitInfo.homeWritable && remoteReady && pushReady, + checkedAt, + tools, + credentials, + githubContext: { + host: githubHost, + apiBaseUrl: githubApiBaseUrl, + repo: githubRepo, + issueProbeNumber, + }, + egress, + git: gitInfo, + remote, + pushDryRun, + limitations, + risks, + }; +} + +function codexStatus(path: string, checkedAt: string): RuntimePreflightPortStatus { + const commandV = shellProbe("command -v codex"); + const version = commandProbe("codex", ["--version"]); + const appServerDryRun = commandProbe("codex", ["app-server", "--help"]); + const errors = [commandV, version, appServerDryRun].map(errorSummary).filter((value): value is string => value !== null); + return { + agentPort: "codex", + ok: errors.length === 0, + path, + commandPath: firstLine(commandV.stdout), + version: firstLine(version.stdout) ?? firstLine(version.stderr), + appServerDryRun, + probes: { commandV, version }, + errors, + checkedAt, + }; +} + +function opencodeStatus(path: string, checkedAt: string): RuntimePreflightPortStatus { + const commandV = shellProbe("command -v opencode"); + const version = commandProbe("opencode", ["--version"]); + const errors = [commandV, version].map(errorSummary).filter((value): value is string => value !== null); + return { + agentPort: "opencode", + ok: errors.length === 0, + path, + commandPath: firstLine(commandV.stdout), + version: firstLine(version.stdout) ?? firstLine(version.stderr), + probes: { commandV, version }, + errors, + checkedAt, + }; +} + +export function collectRuntimePreflight(options: RuntimePreflightOptions = {}): RuntimePreflightReport { + const checkedAt = new Date().toISOString(); + const path = process.env.PATH ?? ""; + const ports = { + codex: codexStatus(path, checkedAt), + opencode: opencodeStatus(path, checkedAt), + }; + const pullRequestDelivery = collectPullRequestDeliveryPreflight(options, checkedAt); + return { + ok: ports.codex.ok && ports.opencode.ok && pullRequestDelivery.ok, + checkedAt, + cwd: process.cwd(), + pid: process.pid, + node: { + platform: process.platform, + arch: process.arch, + version: process.version, + }, + path, + ports, + pullRequestDelivery, + }; +} + +export function runtimePreflightJson(report: RuntimePreflightReport): JsonValue { + return report as unknown as JsonValue; +}