From 3b554e6154ddcea00b3240dc4510501c36f9cf01 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 23 May 2026 04:22:42 +0000 Subject: [PATCH] fix: redact gh auth output in code queue --- docs/reference/code-queue-supervision.md | 2 +- ...e-queue-gh-auth-redaction-contract-test.ts | 121 ++++++++++++++++++ scripts/src/check.ts | 4 + .../code-queue/src/output-redaction.ts | 40 ++++++ .../microservices/code-queue/src/queue-api.ts | 10 +- .../code-queue/src/task-output.ts | 10 +- .../microservices/code-queue/src/task-view.ts | 12 +- 7 files changed, 186 insertions(+), 13 deletions(-) create mode 100644 scripts/code-queue-gh-auth-redaction-contract-test.ts create mode 100644 src/components/microservices/code-queue/src/output-redaction.ts diff --git a/docs/reference/code-queue-supervision.md b/docs/reference/code-queue-supervision.md index 9725881c..23d2d9d7 100644 --- a/docs/reference/code-queue-supervision.md +++ b/docs/reference/code-queue-supervision.md @@ -132,7 +132,7 @@ issue 内容必须自包含,至少写清楚背景、外部收益、当前观 `#20` 当前仍受 body profile 保护,正文必须保留 `## 看板(OPEN)` heading;该 heading 可用于承载紧凑的 P0/P1 直达表或当前活跃入口,不再要求恢复旧式 OPEN/CLOSED 全覆盖明细表。`gh issue board-audit` 只做只读结构审计,不再负责检查 GitHub open/closed issue 是否被表格完全覆盖;维护旧式表格时才使用 `board-row` 系列命令。 -GitHub issue/PR 操作应优先使用 UniDesk CLI 的安全入口:`bun scripts/cli.ts gh auth status`、`gh issue list/read/view/create/update/comment create/comment delete/close/reopen/scan-escape/cleanup-plan/board-audit/board-row list/board-row get/board-row update`、`gh pr list/read/view/create/update/comment create/comment delete/close/reopen`。该入口默认 repo 是 `pikasTech/unidesk`,支持 `--repo owner/name`,输出稳定 JSON,并把 `missing-binary`、`missing-token`、`auth-failed`、`network-proxy-failed`、`permission-denied`、`repo-not-found`、`repo-forbidden`、`issue-not-found`、`pr-not-found`、`scope-insufficient`、`validation-failed`、`invalid-response`、`unsupported-command` 等失败原因结构化。失败对象必须包含 `runnerDisposition=infra-blocked|business-failed`,runner 应用它区分基础设施阻塞和业务/参数失败。`gh issue list --state open --limit N --json number,title,state,url` 是有界 issue 发现入口,`--state` 只接受 `open|closed|all`,list 字段白名单是 `number,title,state,url,updatedAt,createdAt,author,labels`;未知 state 或未知字段必须失败,不能静默返回空数组。`gh issue read --json body` 是 canonical 入口,正文仍应从 `.data.issue.body` 读取;`view` 只保留为兼容别名。未知 `--json` 字段必须失败,不得让调用方把空正文误判为读取成功。`gh issue scan-escape --limit N [--dry-run]` 与 `gh issue cleanup-plan` 只读扫描 issue body/comments 的字面量 `\n`、shell escape、短 body、blank/null body,输出 `classification=suspected-pollution|explanatory-mention|risk`、body/comment id、预览和清理建议;说明性提到 `\n` 不应被当成污染,cleanup-plan 永远不真实清理历史评论。`gh issue board-audit --board-issue 20 --limit N --dry-run` 只读审计目标 board issue 正文结构,返回正文长度、行数、body SHA、可解析 Markdown board sections、section 行数和 parser warnings;它不再拉取 GitHub open/closed issue 列表,也不再校验 OPEN/CLOSED 表覆盖关系。兼容字段 `missingOpenIssues`、`closedInOpenRows`、`missingClosedRows`、`rowValidationWarnings`、`ignoredIssues` 和 `recommendedActions` 仍保留为空数组或 0。显式 `gh issue update --body-profile commander-brief` 可用于 #24 legacy 简报和每日滚动简报 issue;每日简报 issue 应用标题 `YYYY-MM-DD 指挥简报(北京时间)` 或在既有正文首行/关键 heading 中标明简报身份,且新正文必须包含 `## 常驻观察与长期建议`。对非简报 issue 使用该 profile 应失败为 `profile-issue-mismatch`。需要维护旧式 OPEN/CLOSED 明细表时,继续使用 `gh issue board-row list --board-issue 20 --state open|closed|all`、`gh issue board-row get --board-issue 20` 和 `gh issue board-row update --board-issue 20 --field progress|status|validation|branch|tasks|focus --value `;`board-row update` 只替换一行一个单元格,输出 old/new row、body SHA、body guard 和 request plan,且默认 dry-run,正式写入必须带 `--expect-body-sha` 或 `--expect-updated-at`。字段映射中 `status`/`validation` 都指向 `验收状态`,`tasks` 指向 `相关 Code Queue 任务`,`focus` 指向 `当前关注点`;单元格管道会转义、真实换行会折叠为空格,避免新增字面量 `\n`。`gh issue board-row upsert` 可更新既有行或按 section 生成完整新行;`board-row add/move/delete` 已支持行级新增、OPEN/CLOSED 迁移和删除,全部默认 dry-run,正式 PATCH 必须带 `--expect-body-sha` 或 `--expect-updated-at`。`gh pr list --json ...` 支持 `body,title,state,number,url,author,head,base,draft,createdAt,updatedAt` 字段白名单;`gh pr read|view --json ...` 还支持 `headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup`,其中 mergeability/check rollup 只在请求时通过 GitHub GraphQL 补取,适合 PR 收口前判断分支、可合并性和检查汇总。GraphQL 权限不足、网络失败、GitHub 仍返回 `UNKNOWN`/null、或需要 UniDesk CLI 尚未开放的官方字段、review/merge 操作时,回退系统 `gh` 只读观察或 GitHub UI;不要把缺失元数据当成已可合并。issue/PR 创建、更新、评论、评论删除、关闭和重开使用 GitHub REST API;只要有 `GH_TOKEN` 或 `GITHUB_TOKEN`,就不依赖系统 `gh` binary。`gh` binary 只作为状态探测和 `gh auth token` fallback,不是写操作的主路径。GitHub 不支持 issue/PR 硬删除,`gh issue delete` 和 `gh pr delete` 必须结构化返回 `unsupported-command`;生命周期删除语义使用 `close`。`gh pr merge` 仍然不开放。 +GitHub issue/PR 操作应优先使用 UniDesk CLI 的安全入口:`bun scripts/cli.ts gh auth status`、`gh issue list/read/view/create/update/comment create/comment delete/close/reopen/scan-escape/cleanup-plan/board-audit/board-row list/board-row get/board-row update`、`gh pr list/read/view/create/update/comment create/comment delete/close/reopen`。该入口默认 repo 是 `pikasTech/unidesk`,支持 `--repo owner/name`,输出稳定 JSON,并把 `missing-binary`、`missing-token`、`auth-failed`、`network-proxy-failed`、`permission-denied`、`repo-not-found`、`repo-forbidden`、`issue-not-found`、`pr-not-found`、`scope-insufficient`、`validation-failed`、`invalid-response`、`unsupported-command` 等失败原因结构化。失败对象必须包含 `runnerDisposition=infra-blocked|business-failed`,runner 应用它区分基础设施阻塞和业务/参数失败。runner 不应直接运行系统 `gh auth status` 并把输出贴入 Code Queue 日志;系统 `gh` 的 masked token 行仍会暴露 token 前缀和 scope 片段。需要验证当前 runner GitHub auth 时使用 `bun scripts/cli.ts gh auth status --repo pikasTech/unidesk` 或 `bun scripts/cli.ts codex pr-preflight --remote`,输出只能保留 token 是否存在、来源、长度和掩码,不得打印 token 值或 token 片段。Code Queue 输出层必须在保留 command output、trace、raw output 页面和 commander 摘要前 redaction `gh auth status` 风格 token 行,并给出 UniDesk CLI wrapper 提示。`gh issue list --state open --limit N --json number,title,state,url` 是有界 issue 发现入口,`--state` 只接受 `open|closed|all`,list 字段白名单是 `number,title,state,url,updatedAt,createdAt,author,labels`;未知 state 或未知字段必须失败,不能静默返回空数组。`gh issue read --json body` 是 canonical 入口,正文仍应从 `.data.issue.body` 读取;`view` 只保留为兼容别名。未知 `--json` 字段必须失败,不得让调用方把空正文误判为读取成功。`gh issue scan-escape --limit N [--dry-run]` 与 `gh issue cleanup-plan` 只读扫描 issue body/comments 的字面量 `\n`、shell escape、短 body、blank/null body,输出 `classification=suspected-pollution|explanatory-mention|risk`、body/comment id、预览和清理建议;说明性提到 `\n` 不应被当成污染,cleanup-plan 永远不真实清理历史评论。`gh issue board-audit --board-issue 20 --limit N --dry-run` 只读审计目标 board issue 正文结构,返回正文长度、行数、body SHA、可解析 Markdown board sections、section 行数和 parser warnings;它不再拉取 GitHub open/closed issue 列表,也不再校验 OPEN/CLOSED 表覆盖关系。兼容字段 `missingOpenIssues`、`closedInOpenRows`、`missingClosedRows`、`rowValidationWarnings`、`ignoredIssues` 和 `recommendedActions` 仍保留为空数组或 0。显式 `gh issue update --body-profile commander-brief` 可用于 #24 legacy 简报和每日滚动简报 issue;每日简报 issue 应用标题 `YYYY-MM-DD 指挥简报(北京时间)` 或在既有正文首行/关键 heading 中标明简报身份,且新正文必须包含 `## 常驻观察与长期建议`。对非简报 issue 使用该 profile 应失败为 `profile-issue-mismatch`。需要维护旧式 OPEN/CLOSED 明细表时,继续使用 `gh issue board-row list --board-issue 20 --state open|closed|all`、`gh issue board-row get --board-issue 20` 和 `gh issue board-row update --board-issue 20 --field progress|status|validation|branch|tasks|focus --value `;`board-row update` 只替换一行一个单元格,输出 old/new row、body SHA、body guard 和 request plan,且默认 dry-run,正式写入必须带 `--expect-body-sha` 或 `--expect-updated-at`。字段映射中 `status`/`validation` 都指向 `验收状态`,`tasks` 指向 `相关 Code Queue 任务`,`focus` 指向 `当前关注点`;单元格管道会转义、真实换行会折叠为空格,避免新增字面量 `\n`。`gh issue board-row upsert` 可更新既有行或按 section 生成完整新行;`board-row add/move/delete` 已支持行级新增、OPEN/CLOSED 迁移和删除,全部默认 dry-run,正式 PATCH 必须带 `--expect-body-sha` 或 `--expect-updated-at`。`gh pr list --json ...` 支持 `body,title,state,number,url,author,head,base,draft,createdAt,updatedAt` 字段白名单;`gh pr read|view --json ...` 还支持 `headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup`,其中 mergeability/check rollup 只在请求时通过 GitHub GraphQL 补取,适合 PR 收口前判断分支、可合并性和检查汇总。GraphQL 权限不足、网络失败、GitHub 仍返回 `UNKNOWN`/null、或需要 UniDesk CLI 尚未开放的官方字段、review/merge 操作时,回退系统 `gh` 只读观察或 GitHub UI;不要把缺失元数据当成已可合并。issue/PR 创建、更新、评论、评论删除、关闭和重开使用 GitHub REST API;只要有 `GH_TOKEN` 或 `GITHUB_TOKEN`,就不依赖系统 `gh` binary。`gh` binary 只作为状态探测和 `gh auth token` fallback,不是写操作的主路径。GitHub 不支持 issue/PR 硬删除,`gh issue delete` 和 `gh pr delete` 必须结构化返回 `unsupported-command`;生命周期删除语义使用 `close`。`gh pr merge` 仍然不开放。 CLI 是短 shout 的需求原语,不是长驻服务器进程。CLI 功能不好用、兼容性不足、安全 guard 不够或输出不利于 runner/指挥官使用时,应默认创建 GitHub issue 并用 Code Queue 推进;这类 CLI 问题走 `master`、remote commit、轻量 contract test 和文档更新,不套用 backend-core、Code Queue runtime 这类运行态服务的重部署门禁。若 CLI 缺陷已经阻塞当前指挥,可以先做最小安全绕行,同时把长期修复写入 issue 并派单。 diff --git a/scripts/code-queue-gh-auth-redaction-contract-test.ts b/scripts/code-queue-gh-auth-redaction-contract-test.ts new file mode 100644 index 00000000..e2e13b71 --- /dev/null +++ b/scripts/code-queue-gh-auth-redaction-contract-test.ts @@ -0,0 +1,121 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { appendOutput, configureTaskOutput, taskFullOutput } from "../src/components/microservices/code-queue/src/task-output"; +import { sanitizeTaskOutputText } from "../src/components/microservices/code-queue/src/output-redaction"; +import type { JsonValue, QueueTask } from "../src/components/microservices/code-queue/src/types"; + +type JsonRecord = Record; + +function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { + if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); +} + +function fixtureTask(): QueueTask { + const at = "2026-05-23T00:00:00.000Z"; + return { + id: "codex_gh_auth_redaction_contract", + queueId: "default", + queueEnteredAt: at, + prompt: "redaction fixture", + basePrompt: "redaction fixture", + referenceTaskIds: [], + referenceInjection: null, + providerId: "D601", + cwd: "/workspace", + model: "gpt-5.5", + reasoningEffort: null, + executionMode: "default", + maxAttempts: 1, + status: "running", + createdAt: at, + updatedAt: at, + startedAt: at, + finishedAt: null, + readAt: null, + currentAttempt: 1, + currentMode: "initial", + codexThreadId: null, + activeTurnId: null, + finalResponse: "", + lastError: null, + lastJudge: null, + judgeFailCount: 0, + promptHistory: [], + output: [], + events: [], + attempts: [], + cancelRequested: false, + nextPrompt: null, + nextMode: null, + }; +} + +function assertNoTokenFragments(value: string, label: string): void { + assertCondition(!/\bgh[pousr]_[A-Za-z0-9_]{6,}\b/u.test(value), `${label} must redact gh token-like values`, value); + assertCondition(!/\bgithub_pat_[A-Za-z0-9_]{6,}\b/u.test(value), `${label} must redact GitHub PAT-like values`, value); + assertCondition(!/Token:\s*\S+/iu.test(value), `${label} must not expose raw gh auth token lines`, value); + assertCondition(!/Token scopes?:\s*\S+/iu.test(value), `${label} must not expose raw gh auth scope lines`, value); +} + +export function runCodeQueueGhAuthRedactionContract(): JsonRecord { + const tmp = mkdtempSync(join(tmpdir(), "code-queue-gh-auth-redaction-")); + const task = fixtureTask(); + let seq = 0; + try { + configureTaskOutput({ + config: { maxInMemoryOutputRecords: 1000, outputArchiveDir: tmp }, + allocateSeq: () => { + seq += 1; + return seq; + }, + errorToJson: (error: unknown): JsonValue => error instanceof Error ? { message: error.message } : String(error), + logger: () => undefined, + markTaskDirty: () => undefined, + nowIso: () => "2026-05-23T00:00:01.000Z", + schedulePersistState: () => undefined, + }); + + const rawGhAuth = [ + "github.com", + " \u2713 Logged in to github.com account example (keyring)", + " - Active account: true", + " - Git operations protocol: ssh", + " - Token: ghp_abcdef1234567890abcdef1234567890", + " - Token scopes: 'repo', 'read:org'", + "generic token=github_pat_abcdef1234567890abcdef1234567890", + ].join("\n"); + const sanitized = sanitizeTaskOutputText(rawGhAuth); + assertNoTokenFragments(sanitized, "direct sanitizer output"); + assertCondition(sanitized.includes("[redacted gh auth status line]"), "sanitizer must redact gh auth status lines", sanitized); + assertCondition(sanitized.includes("bun scripts/cli.ts gh auth status"), "sanitizer must include UniDesk gh wrapper hint", sanitized); + + appendOutput(task, "command", `${rawGhAuth}\n`, "item/commandExecution/outputDelta", "call-gh-auth", true); + const retained = JSON.stringify(task.output); + const archived = readFileSync(join(tmp, `${task.id}.jsonl`), "utf8"); + const full = JSON.stringify(taskFullOutput(task)); + assertNoTokenFragments(retained, "retained output"); + assertNoTokenFragments(archived, "archived output"); + assertNoTokenFragments(full, "full output replay"); + assertCondition(full.includes("bun scripts/cli.ts gh auth status"), "full output replay must keep wrapper hint", full); + + return { + ok: true, + checks: [ + "raw gh auth status token and scope lines are redacted", + "token-like GitHub values are redacted before retained output persistence", + "output archive replay remains redacted", + "redacted output nudges runner toward bun scripts/cli.ts gh auth status", + ], + }; + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +} + +try { + process.stdout.write(`${JSON.stringify(runCodeQueueGhAuthRedactionContract(), null, 2)}\n`); +} catch (error) { + process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`); + process.exit(1); +} diff --git a/scripts/src/check.ts b/scripts/src/check.ts index c8042f68..b5f5f54e 100644 --- a/scripts/src/check.ts +++ b/scripts/src/check.ts @@ -33,6 +33,7 @@ const syntaxFiles = [ "scripts/code-queue-cli-disclosure-contract-test.ts", "scripts/code-queue-cli-steer-test.ts", "scripts/code-queue-cli-submit-prompt-contract-test.ts", + "scripts/code-queue-gh-auth-redaction-contract-test.ts", "scripts/code-queue-supervisor-disclosure-contract-test.ts", "src/components/frontend/src/index.ts", "src/components/frontend/src/app.tsx", @@ -310,6 +311,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default fileItem("scripts/code-queue-cli-disclosure-contract-test.ts"), fileItem("scripts/code-queue-cli-steer-test.ts"), fileItem("scripts/code-queue-submit-routing-contract-test.ts"), + fileItem("scripts/code-queue-gh-auth-redaction-contract-test.ts"), fileItem("scripts/code-queue-supervisor-disclosure-contract-test.ts"), fileItem("scripts/host-codex-commander-skeleton-contract-test.ts"), fileItem("scripts/host-codex-commander-no-daemon-smoke-contract-test.ts"), @@ -350,6 +352,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default items.push(commandItem("code-queue:cli-steer-contract", ["bun", "scripts/code-queue-cli-steer-test.ts"], 30_000)); items.push(commandItem("code-queue:submit-prompt-contract", ["bun", "scripts/code-queue-cli-submit-prompt-contract-test.ts"], 30_000)); items.push(commandItem("code-queue:submit-routing-contract", ["bun", "scripts/code-queue-submit-routing-contract-test.ts"], 30_000)); + items.push(commandItem("code-queue:gh-auth-redaction-contract", ["bun", "scripts/code-queue-gh-auth-redaction-contract-test.ts"], 30_000)); items.push(commandItem("code-queue:supervisor-disclosure-contract", ["bun", "scripts/code-queue-supervisor-disclosure-contract-test.ts"], 30_000)); items.push(commandItem("host-codex-commander:skeleton-contract", ["bun", "scripts/host-codex-commander-skeleton-contract-test.ts"], 30_000)); items.push(commandItem("host-codex-commander:no-daemon-smoke-contract", ["bun", "scripts/host-codex-commander-no-daemon-smoke-contract-test.ts"], 30_000)); @@ -379,6 +382,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default items.push(skippedItem("code-queue:cli-steer-contract", "Code Queue steer CLI contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("code-queue:submit-prompt-contract", "Code Queue submit prompt contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("code-queue:submit-routing-contract", "Code Queue submit routing contract is opt-in with script checks", "--scripts-typecheck or --full")); + items.push(skippedItem("code-queue:gh-auth-redaction-contract", "Code Queue GitHub auth output redaction contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("code-queue:supervisor-disclosure-contract", "Code Queue supervisor disclosure contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("host-codex-commander:skeleton-contract", "host Codex commander skeleton contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("host-codex-commander:no-daemon-smoke-contract", "host Codex commander no-daemon smoke contract is opt-in with script checks", "--scripts-typecheck or --full")); diff --git a/src/components/microservices/code-queue/src/output-redaction.ts b/src/components/microservices/code-queue/src/output-redaction.ts new file mode 100644 index 00000000..1d49c337 --- /dev/null +++ b/src/components/microservices/code-queue/src/output-redaction.ts @@ -0,0 +1,40 @@ +const ghAuthStatusLinePatterns = [ + /^\s*(?:[\u2713\u2714]\s*)?Logged in to\s+\S+\s+account\s+\S+\s+\([^)]+\)\s*$/iu, + /^\s*(?:[\u2713\u2714]\s*)?Token:\s+\S+\s*$/iu, + /^\s*(?:[\u2713\u2714]\s*)?Token scopes?:\s+.*$/iu, + /^\s*(?:-\s*)?Token(?:\s+(?:scopes?|source|preview|value))?\s*[:=]\s*\S+.*$/iu, +] as const; + +const tokenLikePatterns = [ + /\bgh[pousr]_[A-Za-z0-9_]{6,}\b/gu, + /\bgithub_pat_[A-Za-z0-9_]{6,}\b/gu, + /\b(?:sk|xoxb|xoxp|AKIA)[A-Za-z0-9_=-]{8,}\b/gu, + /\b(?:token|secret|password|passwd|authorization|cookie|api[_-]?key)\s*[:=]\s*[^,\s]+/giu, + /\bBearer\s+[A-Za-z0-9._~+/-]+=*\b/giu, + /https?:\/\/[^/\s]+:[^@\s]+@[^/\s]+/giu, +] as const; + +export const ghAuthStatusWrapperHint = "Use bun scripts/cli.ts gh auth status --repo pikasTech/unidesk for structured redacted GitHub auth diagnostics."; + +export function sanitizeTaskOutputText(text: string): string { + let redactionsApplied = 0; + let ghAuthStatusRedactions = 0; + const lines = String(text || "").split(/\r?\n/u).map((line) => { + if (ghAuthStatusLinePatterns.some((pattern) => pattern.test(line))) { + redactionsApplied += 1; + ghAuthStatusRedactions += 1; + return "[redacted gh auth status line]"; + } + let next = line; + for (const pattern of tokenLikePatterns) { + next = next.replace(pattern, () => { + redactionsApplied += 1; + return ""; + }); + } + return next; + }); + const sanitized = lines.join("\n"); + if (redactionsApplied === 0 || ghAuthStatusRedactions === 0 || sanitized.includes(ghAuthStatusWrapperHint)) return sanitized; + return `${sanitized}\n${ghAuthStatusWrapperHint}`; +} diff --git a/src/components/microservices/code-queue/src/queue-api.ts b/src/components/microservices/code-queue/src/queue-api.ts index 0a99c783..8fa0255d 100644 --- a/src/components/microservices/code-queue/src/queue-api.ts +++ b/src/components/microservices/code-queue/src/queue-api.ts @@ -5,6 +5,7 @@ import { codeAgentPortForModel, codeAgentPortInfo, codeExecutionModeInfo, codeEx import { claudeQqNotificationOutboxStats, notificationTargetConfigured, notificationTargetLabel } from "./notifications"; import { executionModeOptions, executionProviderOptions } from "./provider-runtime"; import { taskFullOutput } from "./task-output"; +import { sanitizeTaskOutputText } from "./output-redaction"; import { applyOaTraceStatsToTaskJson, taskScopeId, type OaTraceStats, type TraceStatsFallback } from "./oa-events"; import { buildExecutionDiagnostics, schedulerHeartbeatStaleMs } from "./execution-diagnostics"; import { buildCompactTaskTranscript, buildTaskTranscript, cachedPreviewTranscript, fullTranscript, prefixPreview, safePreview, statsDaysFromUrl, taskForCompactMetaResponse, taskForMetaResponse, taskListStepCount, taskStatisticsSummary, taskTiming, timestampMs } from "./task-view"; @@ -167,13 +168,14 @@ function outputChunkResponse(task: QueueTask, url: URL): Response { const fullOutput = taskFullOutput(task); const page = ctx().pageBySeq(fullOutput, url, limit); const output = page.chunk.map((item) => { - const truncated = !fullText && item.text.length > maxTextChars; + const text = sanitizeTaskOutputText(item.text); + const truncated = !fullText && text.length > maxTextChars; return { ...item, - text: truncated ? item.text.slice(0, maxTextChars) : item.text, - textChars: item.text.length, + text: truncated ? text.slice(0, maxTextChars) : text, + textChars: text.length, textTruncated: truncated, - omittedChars: truncated ? item.text.length - maxTextChars : 0, + omittedChars: truncated ? text.length - maxTextChars : 0, }; }); return ctx().jsonResponse({ diff --git a/src/components/microservices/code-queue/src/task-output.ts b/src/components/microservices/code-queue/src/task-output.ts index ca78c026..0480adeb 100644 --- a/src/components/microservices/code-queue/src/task-output.ts +++ b/src/components/microservices/code-queue/src/task-output.ts @@ -3,6 +3,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; import { resolve } from "node:path"; import type { ArchivedLiveOutput, JsonValue, LiveOutput, OutputChannel, QueueTask, RuntimeConfig } from "./types"; +import { sanitizeTaskOutputText } from "./output-redaction"; export interface TaskOutputContext { config: Pick; @@ -187,7 +188,8 @@ function outputArchiveSignature(task: QueueTask): string { } function appendOutput(task: QueueTask, channel: OutputChannel, text: string, method?: string, itemId?: string, append = false): LiveOutput | null { - if (text.length === 0) return null; + const safeText = sanitizeTaskOutputText(text); + if (safeText.length === 0) return null; try { ensureTaskOutputArchiveSeeded(task); } catch (error) { @@ -196,14 +198,14 @@ function appendOutput(task: QueueTask, channel: OutputChannel, text: string, met const last = task.output[task.output.length - 1]; let output: LiveOutput; let archiveOp: ArchivedLiveOutput["op"] = "set"; - let archiveText = text; + let archiveText = safeText; if (append && last !== undefined && last.channel === channel && last.itemId === itemId && last.method === method && last.text.length < 24_000) { - last.text += text; + last.text += safeText; last.at = ctx().nowIso(); output = last; archiveOp = "append"; } else { - output = { seq: ctx().allocateSeq(), at: ctx().nowIso(), channel, text, method, itemId }; + output = { seq: ctx().allocateSeq(), at: ctx().nowIso(), channel, text: safeText, method, itemId }; task.output.push(output); } appendOutputArchive(task, output, archiveOp, archiveText); diff --git a/src/components/microservices/code-queue/src/task-view.ts b/src/components/microservices/code-queue/src/task-view.ts index fdd85db7..21e354a4 100644 --- a/src/components/microservices/code-queue/src/task-view.ts +++ b/src/components/microservices/code-queue/src/task-view.ts @@ -20,6 +20,7 @@ import type { } from "./types"; import { codeAgentPortForModel, codeAgentPortInfo, codeExecutionModeInfo, extractRecord } from "./code-agent/common"; import { currentTaskPromptMarker, resolvedReferenceContextTitle, stripCodeQueueEnvironmentHint, userPromptForDisplay } from "./prompts"; +import { sanitizeTaskOutputText } from "./output-redaction"; import { outputArchiveSignature, taskFullOutput } from "./task-output"; import { retryPrompt } from "./judge"; import { readOaTraceStepsForTask, type OaTraceStepSummary } from "./oa-events"; @@ -118,7 +119,7 @@ function prefixPreview(value: string, max = 900): string { } function linePreview(text: string, maxLines: number, maxChars: number): { text: string; omittedLines: number } { - const clean = text.replace(/\u001b\[[0-9;]*m/gu, "").trimEnd(); + const clean = sanitizeTaskOutputText(text).replace(/\u001b\[[0-9;]*m/gu, "").trimEnd(); if (clean.length === 0) return { text: "", omittedLines: 0 }; const lines = clean.split(/\r?\n/u); const kept: string[] = []; @@ -132,7 +133,7 @@ function linePreview(text: string, maxLines: number, maxChars: number): { text: } function completeTraceText(text: string): { text: string; omittedLines: number } { - return { text: text.replace(/\u001b\[[0-9;]*m/gu, "").trimEnd(), omittedLines: 0 }; + return { text: sanitizeTaskOutputText(text).replace(/\u001b\[[0-9;]*m/gu, "").trimEnd(), omittedLines: 0 }; } function editedOutputPreview(text: string): { text: string; omittedLines: number } { @@ -1324,8 +1325,11 @@ function cachedPreviewTranscript(task: QueueTask): TranscriptLine[] { } function outputForResponse(task: QueueTask, includeRaw: boolean): LiveOutput[] { - if (includeRaw) return taskFullOutput(task); - return task.output.slice(-80).map((item) => ({ ...item, text: safePreview(item.text, 4000) })); + const output = includeRaw ? taskFullOutput(task) : task.output.slice(-80); + return output.map((item) => ({ + ...item, + text: includeRaw ? sanitizeTaskOutputText(item.text) : safePreview(sanitizeTaskOutputText(item.text), 4000), + })); } function attemptForResponse(attempt: AttemptSummary, full = false): JsonValue {