From f130598ca25b4f37eb0ae0a03847639bd5fc90e9 Mon Sep 17 00:00:00 2001 From: AgentRun Codex Date: Mon, 13 Jul 2026 08:16:55 +0000 Subject: [PATCH 01/21] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=20PaC=20registr?= =?UTF-8?q?y=20=E7=8A=B6=E6=80=81=E6=8A=95=E5=BD=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/unidesk-cicd/SKILL.md | 2 +- scripts/native/cicd/pac-status-evaluator.cjs | 23 ++++++- scripts/src/cicd-node-status.test.ts | 66 +++++++++++++++++++ scripts/src/cicd-node-status.ts | 14 +++- .../src/platform-infra-pac-provenance.test.ts | 2 +- ...platform-infra-pipelines-as-code-remote.sh | 6 +- .../src/platform-infra-pipelines-as-code.ts | 22 +++++-- 7 files changed, 121 insertions(+), 14 deletions(-) create mode 100644 scripts/src/cicd-node-status.test.ts diff --git a/.agents/skills/unidesk-cicd/SKILL.md b/.agents/skills/unidesk-cicd/SKILL.md index 0d22ba22..bd8812fe 100644 --- a/.agents/skills/unidesk-cicd/SKILL.md +++ b/.agents/skills/unidesk-cicd/SKILL.md @@ -40,7 +40,7 @@ bun scripts/cli.ts agentrun control-plane legacy-cicd --help bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help ``` -节点级只读状态必须优先用 `cicd status --node `。它从 `config/platform-infra/pipelines-as-code.yaml` 找到该 node 的所有当前 PaC consumer,一次性汇总 PipelineRun、Argo/GitOps、runtime readiness 和诊断;不要再靠阅读源码或手动拼三条 consumer 命令来回答 “NC01 的 CI/CD 流水线情况”。`platform-infra pipelines-as-code status --target --consumer ` 只作为单 consumer drill-down。 +节点级只读状态必须优先用 `cicd status --node `。它从 `config/platform-infra/pipelines-as-code.yaml` 找到该 node 的所有当前 PaC consumer,一次性汇总 PipelineRun、Argo/GitOps、runtime readiness 和诊断;不要再靠阅读源码或手动拼三条 consumer 命令来回答 “NC01 的 CI/CD 流水线情况”。text 输出在零 consumer、ready、warning 与失败时均返回非空 typed 摘要和精确下钻命令;GitOps-only consumer 的 registry 显示 `N/A`,只有 owning YAML 为该 consumer 对应 pipeline 声明 image repository 时,registry missing 才是 blocker。`platform-infra pipelines-as-code status --target --consumer ` 只作为单 consumer drill-down。 - 新增 PaC consumer 的首次引导: - 先执行一次 `pipelines-as-code bootstrap --dry-run`; diff --git a/scripts/native/cicd/pac-status-evaluator.cjs b/scripts/native/cicd/pac-status-evaluator.cjs index f14ffb46..f60a62c3 100644 --- a/scripts/native/cicd/pac-status-evaluator.cjs +++ b/scripts/native/cicd/pac-status-evaluator.cjs @@ -567,6 +567,11 @@ function extractPacArtifactEvidence(recordsInput, logText) { function evaluatePacStatus(inputValue) { const input = record(inputValue); + const registryApplicability = input.registryApplicability === "not-configured" + ? "not-configured" + : input.registryApplicability === "configured" || stringOrNull(input.imageRepository) !== null + ? "configured" + : "not-configured"; const artifact = record(input.artifact); const argo = record(input.argo); const runtime = record(input.runtime); @@ -704,7 +709,7 @@ function evaluatePacStatus(inputValue) { code = "pac-artifact-catalog-digest-missing"; phase = "artifact-catalog-digest-missing"; hint = "the HWLAB artifact catalog did not expose any sha256 digest"; - } else if (stringOrNull(input.imageRepository) !== null && !registryEvidenceReady) { + } else if (registryApplicability === "configured" && stringOrNull(input.imageRepository) !== null && !registryEvidenceReady) { ok = false; code = "pac-registry-missing"; phase = "source-ready-registry-missing"; @@ -779,7 +784,9 @@ function evaluatePacStatus(inputValue) { registryProbeBase: stringOrNull(input.registryProbeBase), imageTag: stringOrNull(input.imageTag), registry: { - present: registryEvidenceReady, + applicability: registryApplicability, + status: registryApplicability === "configured" ? (registryEvidenceReady ? "present" : "missing") : "not-configured", + present: registryApplicability === "configured" ? registryEvidenceReady : null, probePresent: input.registryPresent === true, catalogVerified: catalogDeliveryEvidence, digest: registryDigest, @@ -915,6 +922,18 @@ function runPacStatusFixtureChecks() { expectedCode: "pac-ready-gitops-exact", input: fixtureInput({ artifact: { imageStatus: "built", digest: digestA, digests: [digestA], gitopsCommit: revision, sourceObservation: delivery, catalog: publishedCatalog() }, argo: exactArgo, runtime: { image: `registry/service@${digestA}`, digest: digestA, replicas: 1, readyReplicas: 1 } }), }, + { + id: "gitops-only-registry-not-configured", + expectedOk: true, + expectedCode: "pac-ready-gitops-exact", + input: fixtureInput({ registryApplicability: "not-configured", imageRepository: "registry/shared-repository-image", artifact: { gitopsCommit: revision }, argo: exactArgo }), + }, + { + id: "image-registry-missing-fails-closed", + expectedOk: false, + expectedCode: "pac-registry-missing", + input: fixtureInput({ registryApplicability: "configured", imageRepository: "registry/service", artifact: { imageStatus: "built", digest: digestA, gitopsCommit: revision }, argo: exactArgo }), + }, { id: "delivery-ready-descendant", expectedOk: true, diff --git a/scripts/src/cicd-node-status.test.ts b/scripts/src/cicd-node-status.test.ts new file mode 100644 index 00000000..eafa1ff7 --- /dev/null +++ b/scripts/src/cicd-node-status.test.ts @@ -0,0 +1,66 @@ +import { expect, test } from "bun:test"; + +import { renderNodeStatus } from "./cicd-node-status"; + +const next = { + status: "bun scripts/cli.ts cicd status --node NC01", + history: "bun scripts/cli.ts platform-infra pipelines-as-code history --target NC01", + fixAutomaticDelivery: { reference: ".agents/skills/unidesk-cicd/SKILL.md" }, +}; + +function result(status: "ready" | "warning" | "blocked", consumers: Array>): Record { + return { + ok: status === "ready", + node: "NC01", + target: { id: "NC01" }, + summary: { + ready: status === "ready", + status, + code: status === "ready" ? "cicd-node-ready" : status === "warning" ? "cicd-node-no-consumers" : "cicd-node-blocked", + total: consumers.length, + readyCount: consumers.filter((consumer) => consumer.ready === true).length, + blockedCount: consumers.filter((consumer) => consumer.ready !== true).length, + elapsedMs: 12, + }, + consumers, + next, + }; +} + +const readyConsumer = { + consumer: "platform-infra-gitea-nc01", + ready: true, + pipelineStatus: "Succeeded", + durationSeconds: 9, + sourceCommit: "a".repeat(40), + gitopsCommit: "b".repeat(40), + argo: { sync: "Synced", health: "Healthy", revisionRelation: { relation: "exact" } }, + runtime: { readyReplicas: 1, replicas: 1, digest: `sha256:${"c".repeat(64)}` }, + reason: "ready", + latestPipelineRun: "platform-infra-gitea-nc01-fixture", + digest: `sha256:${"c".repeat(64)}`, + imageStatus: "-", + diagnostics: { hint: "GitOps-only consumer is ready", registry: { applicability: "not-configured", status: "not-configured", present: null } }, +}; + +test("node status renderer is non-empty for zero consumers, ready, warning, and blocked projections", () => { + const fixtures = [ + result("warning", []), + result("ready", [readyConsumer]), + result("warning", [{ ...readyConsumer, ready: false, reason: "diagnostics-warning" }]), + result("blocked", [{ ...readyConsumer, ready: false, reason: "pac-registry-missing" }]), + ]; + for (const fixture of fixtures) { + const rendered = renderNodeStatus(fixture, true).renderedText; + expect(rendered.trim().length).toBeGreaterThan(0); + expect(rendered).toContain("CI/CD NODE STATUS"); + expect(rendered).toContain("NEXT"); + } +}); + +test("node full renderer shows registry N/A without hiding digest evidence", () => { + const rendered = renderNodeStatus(result("ready", [readyConsumer]), true).renderedText; + expect(rendered).toContain("N/A"); + expect(rendered).toContain("sha256:"); + expect(rendered).toContain("platform-infra-gitea-nc01-fixture"); +}); diff --git a/scripts/src/cicd-node-status.ts b/scripts/src/cicd-node-status.ts index fa25ac1e..a5534791 100644 --- a/scripts/src/cicd-node-status.ts +++ b/scripts/src/cicd-node-status.ts @@ -76,7 +76,7 @@ function parseNodeStatusOptions(args: string[]): NodeStatusOptions { return { nodeId, targetId, full, raw, output }; } -function renderNodeStatus(result: Record, full: boolean): RenderedCliResult { +export function renderNodeStatus(result: Record, full: boolean): RenderedCliResult { const target = record(result.target); const summary = record(result.summary); const consumers = arrayRecords(result.consumers); @@ -97,13 +97,16 @@ function renderNodeStatus(result: Record, full: boolean): Rende stringValue(row.latestPipelineRun), short(stringValue(row.digest), 18), stringValue(row.imageStatus), + registryText(record(record(row.diagnostics).registry)), compactLine(stringValue(record(row.diagnostics).hint)), ]); const lines = [ "CI/CD NODE STATUS", - ...table(["NODE", "TARGET", "READY", "READY_COUNT", "BLOCKED", "ELAPSED_MS"], [[ + ...table(["NODE", "TARGET", "STATUS", "CODE", "READY", "READY_COUNT", "BLOCKED", "ELAPSED_MS"], [[ stringValue(result.node), stringValue(target.id), + stringValue(summary.status), + stringValue(summary.code), boolText(summary.ready), `${stringValue(summary.readyCount)}/${stringValue(summary.total)}`, stringValue(summary.blockedCount), @@ -115,7 +118,7 @@ function renderNodeStatus(result: Record, full: boolean): Rende "", ...(full ? [ "DETAIL", - ...(detailRows.length === 0 ? ["-"] : table(["CONSUMER", "PIPELINERUN", "DIGEST", "IMAGE_STATUS", "HINT"], detailRows)), + ...(detailRows.length === 0 ? ["-"] : table(["CONSUMER", "PIPELINERUN", "DIGEST", "IMAGE_STATUS", "REGISTRY", "HINT"], detailRows)), "", ] : []), "NEXT", @@ -126,6 +129,11 @@ function renderNodeStatus(result: Record, full: boolean): Rende return { ok: result.ok !== false, command: "cicd status", renderedText: lines.join("\n"), contentType: "text/plain" }; } +function registryText(registry: Record): string { + const status = stringValue(registry.status); + return status === "not-configured" ? "N/A" : status; +} + function isHelpToken(value: string | undefined): boolean { return value === "help" || value === "--help" || value === "-h"; } diff --git a/scripts/src/platform-infra-pac-provenance.test.ts b/scripts/src/platform-infra-pac-provenance.test.ts index ab7c46c1..2f79b8b7 100644 --- a/scripts/src/platform-infra-pac-provenance.test.ts +++ b/scripts/src/platform-infra-pac-provenance.test.ts @@ -347,7 +347,7 @@ test("AgentRun rendered delivery plan and real TaskRun terminals close the statu test("provenance fixtures fail closed for pre-bootstrap, forged marker, wrong SA, and policy mismatch", () => { const result = evaluator.runPacStatusFixtureChecks(); expect(result.ok).toBe(true); - for (const id of ["admission-provenance-verified", "pre-bootstrap-run-fails-closed", "forged-name-candidate-fails-closed", "forged-label-candidate-fails-closed", "forged-pipeline-ref-candidate-fails-closed", "wrong-service-account-fails-closed", "policy-identity-mismatch-fails-closed"]) { + for (const id of ["gitops-only-registry-not-configured", "image-registry-missing-fails-closed", "admission-provenance-verified", "pre-bootstrap-run-fails-closed", "forged-name-candidate-fails-closed", "forged-label-candidate-fails-closed", "forged-pipeline-ref-candidate-fails-closed", "wrong-service-account-fails-closed", "policy-identity-mismatch-fails-closed"]) { expect(result.checks.find((item) => item.id === id)?.ok).toBe(true); } }); diff --git a/scripts/src/platform-infra-pipelines-as-code-remote.sh b/scripts/src/platform-infra-pipelines-as-code-remote.sh index 6c82d3af..758a192f 100644 --- a/scripts/src/platform-infra-pipelines-as-code-remote.sh +++ b/scripts/src/platform-infra-pipelines-as-code-remote.sh @@ -1242,8 +1242,9 @@ const runtimeSourceCommit = artifact.runtimeSourceCommit || (noRuntimeChange ? n const tag = !noRuntimeChange && runtimeSourceCommit ? String(runtimeSourceCommit).slice(0, 12) : ''; const prefix = process.env.UNIDESK_PAC_CONSUMER_ID === 'unidesk-host' ? 'unidesk_host_' : ''; const param = (name) => params[`${prefix}${name}`] ?? params[name]; -const imageRepository = typeof param('image_repository') === 'string' ? param('image_repository') : ''; -const probeBase = typeof param('registry_probe_base') === 'string' ? param('registry_probe_base').replace(/\/+$/u, '') : ''; +const registryConfigured = process.env.UNIDESK_PAC_REGISTRY_APPLICABILITY === 'configured'; +const imageRepository = registryConfigured && typeof param('image_repository') === 'string' ? param('image_repository') : ''; +const probeBase = registryConfigured && typeof param('registry_probe_base') === 'string' ? param('registry_probe_base').replace(/\/+$/u, '') : ''; let registryUrl = ''; if (imageRepository && tag) { const firstSlash = imageRepository.indexOf('/'); @@ -1352,6 +1353,7 @@ const evaluated = evaluatePacStatus({ sourceCommit: process.env.UNIDESK_PAC_DIAG_SOURCE_COMMIT || latest.sourceCommit || null, runtimeSourceCommit: process.env.UNIDESK_PAC_DIAG_RUNTIME_SOURCE_COMMIT || artifact.runtimeSourceCommit || null, imageRepository: process.env.UNIDESK_PAC_DIAG_IMAGE_REPOSITORY || null, + registryApplicability: process.env.UNIDESK_PAC_REGISTRY_APPLICABILITY || 'not-configured', registryProbeBase: process.env.UNIDESK_PAC_DIAG_REGISTRY_PROBE_BASE || null, imageTag: process.env.UNIDESK_PAC_DIAG_IMAGE_TAG || null, registryUrl: process.env.UNIDESK_PAC_DIAG_REGISTRY_URL || null, diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index be5e7a9d..2ab47c3a 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -1015,9 +1015,6 @@ export async function getPlatformInfraPipelinesAsCodeNodeStatus(config: UniDeskC const target = resolveTarget(pac, options.targetId ?? options.nodeId); const nodeId = options.nodeId ?? target.id; const consumers = pac.consumers.filter((consumer) => consumer.node.toLowerCase() === nodeId.toLowerCase()); - if (consumers.length === 0) { - throw new Error(`no Pipelines-as-Code consumers are configured for node ${nodeId}; known nodes: ${Array.from(new Set(pac.consumers.map((item) => item.node))).sort().join(", ")}`); - } const startedAt = Date.now(); const statuses = await Promise.all(consumers.map(async (consumer) => { const repository = resolveRepository(pac, consumer.repositoryRef); @@ -1047,8 +1044,9 @@ export async function getPlatformInfraPipelinesAsCodeNodeStatus(config: UniDeskC } })); const ready = statuses.every((row) => row.ready === true); + const configured = statuses.length > 0; return { - ok: ready, + ok: configured && ready, action: "cicd-node-status", mutation: false, node: nodeId, @@ -1061,7 +1059,10 @@ export async function getPlatformInfraPipelinesAsCodeNodeStatus(config: UniDeskC }, consumers: statuses, summary: { - ready, + ready: configured && ready, + status: configured ? (ready ? "ready" : "blocked") : "warning", + code: configured ? (ready ? "cicd-node-ready" : "cicd-node-blocked") : "cicd-node-no-consumers", + hint: configured ? null : `no Pipelines-as-Code consumers are configured for node ${nodeId}`, total: statuses.length, readyCount: statuses.filter((row) => row.ready === true).length, blockedCount: statuses.filter((row) => row.ready !== true).length, @@ -1362,6 +1363,7 @@ function remoteScript(action: "apply" | "status" | "history" | "debug-step", pac UNIDESK_PAC_WEBHOOK_SECRET_KEY: repository.webhookSecretKey, UNIDESK_PAC_CONCURRENCY_LIMIT: String(repository.concurrencyLimit), UNIDESK_PAC_PARAMS_JSON: JSON.stringify(repository.params), + UNIDESK_PAC_REGISTRY_APPLICABILITY: registryApplicability(consumer, repository), UNIDESK_PAC_PIPELINE_NAME: consumer.pipeline, UNIDESK_PAC_PIPELINE_RUN_PREFIX: consumer.pipelineRunPrefix, UNIDESK_PAC_CONSUMER_CONFIG_B64: Buffer.from(JSON.stringify(consumerConfig), "utf8").toString("base64"), @@ -1395,6 +1397,14 @@ function remoteScript(action: "apply" | "status" | "history" | "debug-step", pac return `${exports}\n${readFileSync(remoteScriptFile, "utf8")}`; } +function registryApplicability(consumer: PacConsumer, repository: PacRepository): "configured" | "not-configured" { + const prefix = consumer.id === "unidesk-host" ? "unidesk_host_" : ""; + const imageRepository = repository.params[`${prefix}image_repository`] ?? repository.params.image_repository; + if (typeof imageRepository !== "string" || imageRepository.length === 0) return "not-configured"; + if (prefix.length > 0) return "configured"; + return repository.params.pipeline_name === consumer.pipeline ? "configured" : "not-configured"; +} + function historyConsumerConfig(pac: PacConfig, consumer: PacConsumer): Record { const repository = resolveRepository(pac, consumer.repositoryRef); const admissionIdentity = pacAdmissionDesiredIdentity(consumer.node); @@ -1680,6 +1690,7 @@ function nodeStatusRow(targetId: string, consumer: PacConsumer, repository: PacR code: stringValue(diagnostics.code), phase: stringValue(diagnostics.phase), hint: compactLine(stringValue(diagnostics.hint)), + registry: record(diagnostics.registry), }, reason: ready ? "ready" : nodeStatusReason({ ciReady, argoReady, runtimeReady, diagnosticsReady, pipelineStatus: statusText(latest), diagnostics }), statusCommand: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${targetId} --consumer ${consumer.id}`, @@ -2779,6 +2790,7 @@ function envReuseText(envReuse: Record): string { function registryText(registry: Record): string { if (Object.keys(registry).length === 0) return "-"; + if (registry.status === "not-configured" || registry.applicability === "not-configured") return "N/A"; const present = registry.present === true ? "present" : "missing"; const digest = short(stringValue(registry.digest), 18); return digest === "-" ? present : `${present}:${digest}`; From cfcade4ec48cfbb95d508766bd397e73c1c092f2 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 10:25:50 +0200 Subject: [PATCH 02/21] fix: expose MDTODO safe stdin skill drift --- scripts/src/code-queue.ts | 5 + .../src/mdtodo-safe-input-adoption.test.ts | 77 +++++++++++++ .../code-queue/src/skill-availability.ts | 101 ++++++++++++++++++ 3 files changed, 183 insertions(+) create mode 100644 scripts/src/mdtodo-safe-input-adoption.test.ts diff --git a/scripts/src/code-queue.ts b/scripts/src/code-queue.ts index aa2f28d6..18568116 100644 --- a/scripts/src/code-queue.ts +++ b/scripts/src/code-queue.ts @@ -6091,6 +6091,9 @@ function compactSkillsStatus(value: unknown): Record | null { readonly: record.readonly ?? false, skillCount: record.skillCount ?? 0, version: record.version ?? null, + capabilities: record.capabilities ?? null, + warnings: Array.isArray(record.warnings) ? record.warnings.map(String) : [], + next: record.next ?? null, sourceSkillCount: record.sourceSkillCount ?? null, targetSkillCount: record.targetSkillCount ?? null, sourceMissingSkills: Array.isArray(record.sourceMissingSkills) ? record.sourceMissingSkills.map(String) : [], @@ -6151,6 +6154,8 @@ function compactSkillsSyncStatus(value: unknown, full = false): Record { + test("reports stale and ready source/target capability without blocking", () => { + const temporary = mkdtempSync("/tmp/unidesk-mdtodo-safe-input-"); + try { + const source = join(temporary, "source"); + const staleTarget = join(temporary, "stale-target"); + const readyTarget = join(temporary, "ready-target"); + writeFixture(source, true); + writeFixture(staleTarget, false); + writeFixture(readyTarget, true); + + expect(collectMdtodoSafeTitleStdinCapability(source, staleTarget)).toMatchObject({ + nonBlocking: true, + ready: false, + freshness: "target-stale", + source: { ready: true }, + target: { ready: false }, + }); + expect(collectMdtodoSafeTitleStdinCapability(source, readyTarget)).toMatchObject({ + nonBlocking: true, + ready: true, + freshness: "ready", + warning: null, + }); + } finally { + rmSync(temporary, { recursive: true, force: true }); + } + }); + + test("keeps Markdown and shell syntax in quoted heredoc stdin", () => { + const temporary = mkdtempSync("/tmp/unidesk-mdtodo-command-"); + try { + const home = join(temporary, "home"); + const scripts = join(home, ".agents", "skills", "mdtodo-edit", "scripts"); + const output = join(temporary, "title.txt"); + const forbidden = join(temporary, "forbidden"); + mkdirSync(scripts, { recursive: true }); + writeFileSync(join(scripts, "mdtodo-edit-cli.py"), [ + "import os, sys", + "open(os.environ['TITLE_OUTPUT'], 'w', encoding='utf-8').write(sys.stdin.read())", + ].join("\n")); + const title = `修复 \`path\` 与 $(touch ${forbidden}) 的 'quoted' 标题`; + const command = renderMdtodoSafeTitleCommand("docs/MDTODO/demo file.md", "add").replace("", title); + + expect(command).toContain("'docs/MDTODO/demo file.md'"); + expect(command).toContain("--stdin <<'EOF'"); + expect(command.split("--stdin", 1)[0]).not.toContain(title); + const result = Bun.spawnSync(["bash", "-c", command], { + env: { ...process.env, HOME: home, TITLE_OUTPUT: output }, + }); + expect(result.exitCode).toBe(0); + expect(readFileSync(output, "utf8")).toBe(`${title}\n`); + expect(existsSync(forbidden)).toBe(false); + } finally { + rmSync(temporary, { recursive: true, force: true }); + } + }); +}); diff --git a/src/components/microservices/code-queue/src/skill-availability.ts b/src/components/microservices/code-queue/src/skill-availability.ts index f095eac5..8cf0f127 100644 --- a/src/components/microservices/code-queue/src/skill-availability.ts +++ b/src/components/microservices/code-queue/src/skill-availability.ts @@ -81,6 +81,14 @@ export interface SkillAvailabilityReport { sourceSampledSkillNames: string[]; targetSampledSkillNames: string[]; }; + capabilities: { + mdtodoSafeTitleStdin: MdtodoSafeTitleStdinCapability; + }; + warnings: string[]; + next: { + inspect: string; + safeTitleExample: string; + }; repairHint: string | null; error: string | null; valuesPrinted: false; @@ -164,6 +172,10 @@ export interface SkillSyncPreflightReport { sourceSampledSkillNames: string[]; targetSampledSkillNames: string[]; }; + capabilities: { + mdtodoSafeTitleStdin: MdtodoSafeTitleStdinCapability; + }; + warnings: string[]; missing: { sourceSkills: string[]; targetSkills: string[]; @@ -185,10 +197,30 @@ export interface SkillSyncPreflightReport { health: string; runtimePreflight: string; contractTest: string; + safeTitleExample: string; }; valuesPrinted: false; } +export interface MdtodoSafeTitleStdinProbe { + path: string; + exists: boolean; + commands: Record<"add" | "add-sub" | "update", boolean>; + ready: boolean; + identity: string | null; +} + +export interface MdtodoSafeTitleStdinCapability { + name: "mdtodo-safe-title-stdin"; + nonBlocking: true; + ready: boolean; + freshness: "ready" | "source-stale" | "target-stale" | "missing"; + source: MdtodoSafeTitleStdinProbe; + target: MdtodoSafeTitleStdinProbe; + warning: string | null; + next: string; +} + export const defaultRequiredSkills = ["docs-spec", "cli-spec", "frontend-design", "playwright-cli"]; export const defaultSource = "/home/ubuntu/.agents/skills"; export const expectedTarget = "/root/.agents/skills"; @@ -303,6 +335,64 @@ function skillSetVersion(path: string, readable: boolean): SkillSyncPathReport[" } } +function mdtodoCliPath(skillsPath: string): string { + return resolve(skillsPath, "mdtodo-edit", "scripts", "mdtodo-edit-cli.py"); +} + +function probeMdtodoSafeTitleStdin(skillsPath: string): MdtodoSafeTitleStdinProbe { + const path = mdtodoCliPath(skillsPath); + const exists = existsSync(path); + const commands = { add: false, "add-sub": false, update: false }; + if (exists) { + for (const command of Object.keys(commands) as Array<keyof typeof commands>) { + const result = spawnSync("python3", [path, command, "--help"], { encoding: "utf8", timeout: 2000 }); + commands[command] = result.status === 0 && `${result.stdout ?? ""}\n${result.stderr ?? ""}`.includes("--stdin"); + } + } + const ready = Object.values(commands).every(Boolean); + let identity: string | null = null; + if (exists) { + try { + identity = createHash("sha256").update(readFileSync(path)).digest("hex").slice(0, 16); + } catch { + identity = null; + } + } + return { path, exists, commands, ready, identity }; +} + +export function collectMdtodoSafeTitleStdinCapability(sourcePath: string, targetPath: string): MdtodoSafeTitleStdinCapability { + const source = probeMdtodoSafeTitleStdin(sourcePath); + const target = probeMdtodoSafeTitleStdin(targetPath); + const freshness = target.ready + ? "ready" + : source.ready + ? "target-stale" + : source.exists || target.exists + ? "source-stale" + : "missing"; + const warning = freshness === "ready" + ? null + : freshness === "target-stale" + ? "MDTODO safe title stdin is available in the approved source but missing from the runner target projection." + : "MDTODO safe title stdin is missing from the approved source; update the managed agent_skills source before refreshing projections."; + return { + name: "mdtodo-safe-title-stdin", + nonBlocking: true, + ready: target.ready, + freshness, + source, + target, + warning, + next: "bun scripts/cli.ts codex skills-sync --dry-run --full", + }; +} + +export function renderMdtodoSafeTitleCommand(file: string, command: "add" | "add-sub" | "update", taskId?: string): string { + const positional = command === "add" ? shellQuote(file) : `${shellQuote(file)} ${shellQuote(taskId ?? "<taskId>")}`; + return `python3 "$HOME/.agents/skills/mdtodo-edit/scripts/mdtodo-edit-cli.py" ${command} ${positional} --stdin <<'EOF'\n<title>\nEOF`; +} + function accessProbe(path: string, mode: number, operation: string): { ok: boolean; failure: { path: string; operation: string; error: string } | null } { try { accessSync(path, mode); @@ -450,6 +540,7 @@ export function collectSkillAvailability(options: SkillAvailabilityOptions): Ski : targetSymlinkToSource ? "target-symlink" : "target"; const resolvedPath = target; const selectedReport = targetProbe; + const mdtodoSafeTitleStdin = collectMdtodoSafeTitleStdinCapability(source, target); const runnerUsable = !forbiddenPathConfigured && targetReady && !forbiddenPathExists; const contractOk = runnerUsable; const blocker = forbiddenPathConfigured @@ -539,6 +630,12 @@ export function collectSkillAvailability(options: SkillAvailabilityOptions): Ski sourceSampledSkillNames: sourceProbe.version.sampledSkillNames, targetSampledSkillNames: targetProbe.version.sampledSkillNames, }, + capabilities: { mdtodoSafeTitleStdin }, + warnings: mdtodoSafeTitleStdin.warning === null ? [] : [mdtodoSafeTitleStdin.warning], + next: { + inspect: mdtodoSafeTitleStdin.next, + safeTitleExample: renderMdtodoSafeTitleCommand("docs/MDTODO/example.md", "add"), + }, repairHint: contractOk ? null : sourceHasRequiredSkills @@ -561,6 +658,7 @@ export function collectSkillSyncPreflight(options: SkillSyncPreflightOptions = { const forbiddenPathConfigured = forbiddenSourceConfigured || forbiddenTargetConfigured; const forbiddenPathExists = forbiddenTargets.some((path) => existsSync(path)); const permissionFailures = [...source.permissionFailures, ...target.permissionFailures]; + const mdtodoSafeTitleStdin = collectMdtodoSafeTitleStdinCapability(sourcePath, targetPath); const blocker = forbiddenPathConfigured ? "forbidden-skills-path-configured" : !source.report.approved @@ -623,6 +721,8 @@ export function collectSkillSyncPreflight(options: SkillSyncPreflightOptions = { sourceSampledSkillNames: source.report.version.sampledSkillNames, targetSampledSkillNames: target.report.version.sampledSkillNames, }, + capabilities: { mdtodoSafeTitleStdin }, + warnings: mdtodoSafeTitleStdin.warning === null ? [] : [mdtodoSafeTitleStdin.warning], missing: { sourceSkills: source.report.missingSkills, targetSkills: target.report.missingSkills, @@ -659,6 +759,7 @@ export function collectSkillSyncPreflight(options: SkillSyncPreflightOptions = { health: "bun scripts/cli.ts microservice health code-queue", runtimePreflight: "bun scripts/cli.ts codex pr-preflight --remote", contractTest: "bun scripts/code-queue-runner-skills-contract-test.ts", + safeTitleExample: renderMdtodoSafeTitleCommand("docs/MDTODO/example.md", "add"), }, valuesPrinted: false, }; From 7dcc1395ac7c0e6911773ba5688e8ef1cf9d8766 Mon Sep 17 00:00:00 2001 From: Codex <codex@local> Date: Mon, 13 Jul 2026 11:00:30 +0200 Subject: [PATCH 03/21] =?UTF-8?q?fix:=20=E5=8C=BA=E5=88=86=20MDTODO=20sour?= =?UTF-8?q?ce=20=E4=B8=8E=20target=20freshness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/src/mdtodo-safe-input-adoption.test.ts | 10 ++++++++++ .../microservices/code-queue/src/skill-availability.ts | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/scripts/src/mdtodo-safe-input-adoption.test.ts b/scripts/src/mdtodo-safe-input-adoption.test.ts index 19d011ea..fad0ea9d 100644 --- a/scripts/src/mdtodo-safe-input-adoption.test.ts +++ b/scripts/src/mdtodo-safe-input-adoption.test.ts @@ -22,9 +22,11 @@ describe("MDTODO safe title stdin adoption", () => { const temporary = mkdtempSync("/tmp/unidesk-mdtodo-safe-input-"); try { const source = join(temporary, "source"); + const staleSource = join(temporary, "stale-source"); const staleTarget = join(temporary, "stale-target"); const readyTarget = join(temporary, "ready-target"); writeFixture(source, true); + writeFixture(staleSource, false); writeFixture(staleTarget, false); writeFixture(readyTarget, true); @@ -41,6 +43,14 @@ describe("MDTODO safe title stdin adoption", () => { freshness: "ready", warning: null, }); + expect(collectMdtodoSafeTitleStdinCapability(staleSource, readyTarget)).toMatchObject({ + nonBlocking: true, + ready: true, + freshness: "source-stale", + source: { ready: false }, + target: { ready: true }, + warning: expect.stringContaining("approved source"), + }); } finally { rmSync(temporary, { recursive: true, force: true }); } diff --git a/src/components/microservices/code-queue/src/skill-availability.ts b/src/components/microservices/code-queue/src/skill-availability.ts index 8cf0f127..a89ed208 100644 --- a/src/components/microservices/code-queue/src/skill-availability.ts +++ b/src/components/microservices/code-queue/src/skill-availability.ts @@ -364,7 +364,7 @@ function probeMdtodoSafeTitleStdin(skillsPath: string): MdtodoSafeTitleStdinProb export function collectMdtodoSafeTitleStdinCapability(sourcePath: string, targetPath: string): MdtodoSafeTitleStdinCapability { const source = probeMdtodoSafeTitleStdin(sourcePath); const target = probeMdtodoSafeTitleStdin(targetPath); - const freshness = target.ready + const freshness = source.ready && target.ready ? "ready" : source.ready ? "target-stale" From 40cdb9466d56caa7ffbb305c0f760a0fc096db61 Mon Sep 17 00:00:00 2001 From: Codex <codex@local> Date: Mon, 13 Jul 2026 11:01:42 +0200 Subject: [PATCH 04/21] =?UTF-8?q?docs:=20=E6=94=B6=E6=95=9B=20PaC=20regist?= =?UTF-8?q?ry=20=E7=8A=B6=E6=80=81=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/unidesk-cicd/SKILL.md | 5 ++++- .agents/skills/unidesk-cicd/references/gitea-pac.md | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.agents/skills/unidesk-cicd/SKILL.md b/.agents/skills/unidesk-cicd/SKILL.md index bd8812fe..a38bd660 100644 --- a/.agents/skills/unidesk-cicd/SKILL.md +++ b/.agents/skills/unidesk-cicd/SKILL.md @@ -40,7 +40,10 @@ bun scripts/cli.ts agentrun control-plane legacy-cicd --help bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help ``` -节点级只读状态必须优先用 `cicd status --node <NODE>`。它从 `config/platform-infra/pipelines-as-code.yaml` 找到该 node 的所有当前 PaC consumer,一次性汇总 PipelineRun、Argo/GitOps、runtime readiness 和诊断;不要再靠阅读源码或手动拼三条 consumer 命令来回答 “NC01 的 CI/CD 流水线情况”。text 输出在零 consumer、ready、warning 与失败时均返回非空 typed 摘要和精确下钻命令;GitOps-only consumer 的 registry 显示 `N/A`,只有 owning YAML 为该 consumer 对应 pipeline 声明 image repository 时,registry missing 才是 blocker。`platform-infra pipelines-as-code status --target <NODE> --consumer <id>` 只作为单 consumer drill-down。 +- 节点级只读状态: + - 优先用 `cicd status --node <NODE>`; + - 单 consumer 再用 `platform-infra pipelines-as-code status --target <NODE> --consumer <id>` 下钻; + - 状态输出与 registry applicability 的长期判定见 [references/gitea-pac.md](references/gitea-pac.md)。 - 新增 PaC consumer 的首次引导: - 先执行一次 `pipelines-as-code bootstrap --dry-run`; diff --git a/.agents/skills/unidesk-cicd/references/gitea-pac.md b/.agents/skills/unidesk-cicd/references/gitea-pac.md index 5c9488f8..1339e4a6 100644 --- a/.agents/skills/unidesk-cicd/references/gitea-pac.md +++ b/.agents/skills/unidesk-cicd/references/gitea-pac.md @@ -78,6 +78,15 @@ bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target <NODE> -- - 只有归属或 TaskRun 断点不清楚时再用 `history --id` 或 `debug-step --id`; - 默认不使用 `--full` 或 `--raw`,避免把大对象带回本机; - consumer 声明 `sourceArtifact` 且需要精确提交验收时,最后执行一次 `source-artifact verify-runtime`。 +- node 状态摘要必须保持可读且可下钻: + - text 输出在零 consumer、ready、warning 与失败时都返回非空 typed 摘要; + - 摘要只给出与当前状态匹配的精确下钻命令; + - 不要求操作者阅读源码或手工拼接多个 consumer 命令。 +- registry applicability 只由 owning YAML 决定: + - consumer 对应 pipeline 声明 image repository 时,registry 状态适用; + - 适用的 registry 缺失是 blocker; + - GitOps-only consumer 未声明 image repository 时,registry 显示 `N/A`; + - 不得因 registry 不适用而把 GitOps-only consumer 判为失败。 ## PaC 源码侧制品同步 From 4387b63fb0abcca2659f4c9a722afe1b458140a5 Mon Sep 17 00:00:00 2001 From: Codex <codex@local> Date: Mon, 13 Jul 2026 11:08:10 +0200 Subject: [PATCH 05/21] =?UTF-8?q?docs:=20=E8=B7=9F=E8=B8=AA=E8=87=AA?= =?UTF-8?q?=E5=AA=92=E4=BD=93=E8=87=AA=E5=8A=A8=E7=94=9F=E4=BA=A7=E4=BA=A4?= =?UTF-8?q?=E4=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/MDTODO/selfmedia-production-delivery.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 docs/MDTODO/selfmedia-production-delivery.md diff --git a/docs/MDTODO/selfmedia-production-delivery.md b/docs/MDTODO/selfmedia-production-delivery.md new file mode 100644 index 00000000..4d95fb11 --- /dev/null +++ b/docs/MDTODO/selfmedia-production-delivery.md @@ -0,0 +1,5 @@ + + +## R1 [in_progress] + +打通 selfmedia WebTerm 在 NC01 的纯自动生产交付:以 config/selfmedia.yaml 与 platform-infra owning YAML 为唯一真相,修复 GitHub→Gitea mirror→PaC/Tekton→GitOps/Argo 的自动触发与可见性,使合并 pikainc/selfmedia/master 后无需人工触发即可构建并发布;保留零 RBAC runtime、双 PVC、YAML-first Secret、NetworkPolicy、公网 IP 152.53.229.148:4317,禁止用手工 PipelineRun、Argo sync/refresh、rollout 或 cutover 代替自动链路,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R1_Task_Report.md)。 From 52145321d8818484f8605f1b3eeb404047038d8a Mon Sep 17 00:00:00 2001 From: Codex <codex@local> Date: Mon, 13 Jul 2026 11:33:34 +0200 Subject: [PATCH 06/21] fix(cicd): expose selfmedia GitHub hook authority errors --- .../R1_Task_Report.md | 69 +++++++++++++++++++ scripts/src/platform-infra-gitea-remote.sh | 20 +++--- ...tform-infra-gitea-status-evaluator.test.ts | 27 ++++++++ .../platform_infra_gitea_status_evaluator.py | 29 ++++++++ 4 files changed, 135 insertions(+), 10 deletions(-) create mode 100644 docs/MDTODO/details/selfmedia-production-delivery/R1_Task_Report.md diff --git a/docs/MDTODO/details/selfmedia-production-delivery/R1_Task_Report.md b/docs/MDTODO/details/selfmedia-production-delivery/R1_Task_Report.md new file mode 100644 index 00000000..3cde9e47 --- /dev/null +++ b/docs/MDTODO/details/selfmedia-production-delivery/R1_Task_Report.md @@ -0,0 +1,69 @@ +# R1 任务报告 + +## 任务回链 + +- MDTODO:`docs/MDTODO/selfmedia-production-delivery.md` R1。 +- Target:`NC01`。 +- targetWorkspace:`/root/.worktrees/unidesk/selfmedia-production-delivery`。 +- repository:`pikasTech/unidesk`。 +- ref:`feat/selfmedia-production-delivery`。 +- 状态:保持 `in_progress`,尚未取得修复合并后的真实自动链证据。 + +## 最小根因 + +- `platform-infra gitea mirror webhook status` 的 GitHub hooks 观察器在未校验 HTTP 结果和 JSON 形状时直接迭代响应。 +- 私有仓库 `pikainc/selfmedia` 对 YAML 声明的 `gh_token.txt` authority 返回 HTTP `404`;错误对象被按 hook list 迭代,最终产生 `AttributeError: 'str' object has no attribute 'get'`,并把权限故障伪装成空 hook。 +- owning YAML 已包含 `selfmedia-nc01`、GitHub→Gitea hook topology、Gitea repository、PaC consumer、GitOps/Argo、双 PVC、Secret、NetworkPolicy 和公网 `152.53.229.148:4317` 契约;不需要新增 Secret 或手工 apply。 +- 自动链当前真正阻塞 authority:`config/platform-infra/gitea.yaml` 声明的 GitHub bridge credential `gh_token.txt` 无法读取或管理私有仓库 `pikainc/selfmedia` 的 hooks,也无法读取仓库 head。Git SSH push 可用不能替代 GitHub API hook authority。 + +## 源码修复 + +- `scripts/src/platform_infra_gitea_status_evaluator.py` + - 新增 GitHub hooks HTTP/JSON/list/item 的 fail-closed 解析。 + - 错误仅输出 `github-hooks-http-<status>`、`github-hooks-response-not-list`、`github-hooks-item-not-object` 等有界 code,不回显 GitHub 错误正文或凭据。 + - 统一支持 hook `config` 为对象 URL 或字符串 URL 的公开 API 形状。 +- `scripts/src/platform-infra-gitea-remote.sh` + - embedded observer 复用 evaluator,不再直接迭代未校验响应。 + - HTTP 失败、错误对象和畸形 list 不再伪装成空 hook。 +- `scripts/src/platform-infra-gitea-status-evaluator.test.ts` + - 覆盖 HTTP 错误对象、非 list、含非对象元素、对象 config、字符串 config 和畸形 hook。 + +## Target 原入口验证 + +- `sh -n scripts/src/platform-infra-gitea-remote.sh`:通过。 +- `python3 -m py_compile scripts/src/platform_infra_gitea_status_evaluator.py`:通过。 +- `git diff --check`:通过。 +- `bun test scripts/src/platform-infra-gitea-status-evaluator.test.ts`:`14 pass / 0 fail / 86 expect()`。 +- `platform-infra gitea mirror webhook status --target NC01 --repo selfmedia-nc01 --full`: + - bridge `ready=true`、`1/1`; + - durable inbox `ready=true`,无 pending/failed; + - selfmedia `hookReady=false`、`authorityMatches=false`; + - topology drift 为 `desired-url-missing`; + - 有界错误为 `github-hooks-http-404`; + - 不再出现 Python traceback,不打印 token 或 GitHub 错误正文。 +- `platform-infra pipelines-as-code status --target NC01 --consumer selfmedia-nc01`: + - consumer bootstrap ready,runner automount disabled,Argo repository Secret keys ready; + - Repository hook `0`,Latest PipelineRun 为空; + - diagnosis 仍为 `pac-source-unknown`、registry missing、Argo sync Unknown。 +- 公网补充证据:`152.53.229.148:4317` 健康但 WebTerm 显示“后端尚未声明内置终端能力”,`/api/v1/system` 无 terminal configRef;宿主 PID 自 01:44 运行。该入口是旧宿主进程证据,不是 K8s 自动发布证明,任务未执行重启或 cutover。 +- Secret 补充证据:`secrets status --scope selfmedia --target selfmedia-nc01` 显示 runtime TOKEN 与 codex `auth.json` / `config.toml` 均存在且 keys exact、`valuesPrinted=false`;任务未新增或同步 Secret。 + +## Primary Workspace 边界 + +- primary workspace 仅用于读取 `$dad-dev`、`$unidesk-trans`、`$unidesk-cicd`、`$unidesk-ymalops`、`$cli-spec`、`$git-spec`、`$docs-spec` 与 `$unidesk-gh`。 +- 所有源码读取、编辑、测试、Git 和运行面只读观察均通过 `trans NC01:/root/.worktrees/unidesk/selfmedia-production-delivery ...` 完成。 +- 未使用 runner 本地结果替代 Target 原入口证据,未递归派单。 + +## 保留与删除的特例 + +- `keep-domain-special`:selfmedia 双 PVC、零 runtime RBAC、`automountServiceAccountToken=false`、非特权 runtime、YAML-first Secret、NetworkPolicy、公网 IP 与 GitOps branch 契约保持不变。 +- `remove-code-default`:无新增代码默认值;修复只校验实际 GitHub API 形状。 +- `legacy-retire`:不恢复 branch-follower、人工 mirror sync、人工 PipelineRun、Argo refresh/sync、rollout 或 cutover。 + +## 合并后自动验收 + +1. 由凭据 authority 管理者为 owning YAML 的 `gh_token.txt` 授予 `pikainc/selfmedia` 私有仓库读取与 webhook 管理权限;不得复制 root SSH 私钥或改变仓库可见性。 +2. 合并本修复 PR 后,仅观察 `platform-infra-gitea-nc01` 自动 PipelineRun 与 Argo 收敛;不得 apply、sync、refresh 或手工 reconciler。 +3. 待 hook reconciler 自动运行后,只读执行 `platform-infra gitea mirror webhook status --target NC01 --repo selfmedia-nc01`,应看到 hook ready、真实 delivery accepted→committed、GitHub head 与 Gitea branch/snapshot 对齐。 +4. 合并一条 `pikainc/selfmedia/master` 正常 PR,观察自动产生唯一外层 selfmedia PipelineRun、镜像 digest、GitOps release state、Argo revision、runtime commitId 与 `/healthz` ready。 +5. 用 `platform-infra pipelines-as-code status|history --target NC01 --consumer selfmedia-nc01` 验证阶段耗时、失败归因和下一步;未取得上述真实自动事件前 R1 保持 `in_progress`。 diff --git a/scripts/src/platform-infra-gitea-remote.sh b/scripts/src/platform-infra-gitea-remote.sh index 075a81f6..94bf7ff5 100644 --- a/scripts/src/platform-infra-gitea-remote.sh +++ b/scripts/src/platform-infra-gitea-remote.sh @@ -757,7 +757,7 @@ NODE python3 - "$tmp/repos.json" "$bridge_ready" "$service_exists" "$mirror_status_rc" "$bridge_logs_rc" "$inbox_status_rc" "$tmp/mirror-status.json" "$tmp/mirror-status.err" "$tmp/bridge.log" "$tmp/bridge.err" "$tmp/inbox-status.json" "$tmp/inbox-status.err" "$tmp/hook-topology.json" <<'PY' import hashlib, json, os, sys, urllib.error, urllib.parse, urllib.request sys.path.insert(0, os.environ["tmp"]) -from platform_infra_gitea_status_evaluator import evaluate_repository, select_committed_head_record, select_target_delivery +from platform_infra_gitea_status_evaluator import evaluate_repository, github_hook_url, parse_github_hooks_response, select_committed_head_record, select_target_delivery repos = json.load(open(sys.argv[1], encoding="utf-8")) bridge_ready = sys.argv[2] service_exists = bool(sys.argv[3]) @@ -783,12 +783,15 @@ topology_repositories = hook_topology.get("repositories") if isinstance(hook_top def url_hash(value): return "sha256:" + hashlib.sha256(value.encode("utf-8")).hexdigest()[:16] def hook_url(item): - config = item.get("config") if isinstance(item.get("config"), dict) else {} - return config.get("url") if isinstance(config.get("url"), str) else "" + return github_hook_url(item) def is_managed_url(value): return value == managed_url_prefix or value.startswith(managed_url_prefix.rstrip("/") + "/") def hook_config_matches(item): - config = item.get("config") if isinstance(item.get("config"), dict) else {} + config = item.get("config") + if isinstance(config, str): + return item.get("name") == "web" and item.get("active") is True and set(item.get("events") or []) == set(desired_events) + if not isinstance(config, dict): + return False return item.get("name") == "web" and item.get("active") is True and set(item.get("events") or []) == set(desired_events) and config.get("content_type") == "json" and str(config.get("insecure_ssl", "0")) == "0" def topology_observation(repository, hooks): spec = next((item for item in topology_repositories if isinstance(item, dict) and item.get("repository") == repository), {}) @@ -989,11 +992,8 @@ for repo in repos: repository = repo["upstream"]["repository"] branch = repo["upstream"]["branch"] result = request(f"/repos/{repository}/hooks") - hooks = [] - try: - hooks = json.loads(result.get("body") or "[]") - except Exception: - hooks = [] + hooks_result = parse_github_hooks_response(result.get("ok"), result.get("status"), result.get("body")) + hooks = hooks_result["hooks"] matches = [item for item in hooks if hook_url(item) == url] topology = topology_observation(repository, hooks) head = github_head(repository, branch) @@ -1044,7 +1044,7 @@ for repo in repos: "latest": latest_delivery, "latestPush": latest_push_delivery, "selectedPush": selected_push_delivery, - "error": result.get("body", "")[:500] if not result.get("ok") else (delivery_errors[0] if delivery_errors else topology_reason), + "error": hooks_result["error"] or (delivery_errors[0] if delivery_errors else topology_reason), } rows.append(evaluate_repository(repo, hook, head, mirror, bridge_events, inbox_records, inbox_status.get("journal"))) bridge = { diff --git a/scripts/src/platform-infra-gitea-status-evaluator.test.ts b/scripts/src/platform-infra-gitea-status-evaluator.test.ts index 3a650261..5d60172d 100644 --- a/scripts/src/platform-infra-gitea-status-evaluator.test.ts +++ b/scripts/src/platform-infra-gitea-status-evaluator.test.ts @@ -5,6 +5,33 @@ import { resolve } from "node:path"; const evaluator = resolve(import.meta.dir, "platform_infra_gitea_status_evaluator.py"); describe("Gitea source authority status evaluator", () => { + test("fails closed for GitHub hooks error objects and malformed lists", () => { + expect(evaluate({ + action: "parse-github-hooks-response", + ok: false, + status: 403, + body: JSON.stringify({ message: "forbidden" }), + })).toEqual({ ok: false, status: 403, hooks: [], error: "github-hooks-http-403" }); + expect(evaluate({ + action: "parse-github-hooks-response", + ok: true, + status: 200, + body: JSON.stringify({ message: "not a list" }), + }).error).toBe("github-hooks-response-not-list"); + expect(evaluate({ + action: "parse-github-hooks-response", + ok: true, + status: 200, + body: JSON.stringify([{ id: 1 }, "malformed"]), + }).error).toBe("github-hooks-item-not-object"); + }); + + test("reads GitHub hook URLs from object and string config shapes", () => { + expect(evaluate({ action: "github-hook-url", hook: { config: { url: "https://hooks.example/object" } } })).toBe("https://hooks.example/object"); + expect(evaluate({ action: "github-hook-url", hook: { config: "https://hooks.example/string" } })).toBe("https://hooks.example/string"); + expect(evaluate({ action: "github-hook-url", hook: "malformed" })).toBe(""); + }); + test("parses branch and exact snapshot from ls-remote output larger than the display tail budget", () => { const head = "2".repeat(40); const prefix = "refs/unidesk/snapshots/gitea-actions/unidesk-master-nc01"; diff --git a/scripts/src/platform_infra_gitea_status_evaluator.py b/scripts/src/platform_infra_gitea_status_evaluator.py index 0c20a9b2..2907e519 100644 --- a/scripts/src/platform_infra_gitea_status_evaluator.py +++ b/scripts/src/platform_infra_gitea_status_evaluator.py @@ -13,6 +13,31 @@ def parse_ls_remote_lines(lines): return refs +def parse_github_hooks_response(ok, status, body): + if ok is not True: + return {"ok": False, "status": status, "hooks": [], "error": f"github-hooks-http-{status or 'unknown'}"} + try: + payload = json.loads(body or "[]") + except (TypeError, json.JSONDecodeError): + return {"ok": False, "status": status, "hooks": [], "error": "github-hooks-response-invalid-json"} + if not isinstance(payload, list): + return {"ok": False, "status": status, "hooks": [], "error": "github-hooks-response-not-list"} + if any(not isinstance(item, dict) for item in payload): + return {"ok": False, "status": status, "hooks": [], "error": "github-hooks-item-not-object"} + return {"ok": True, "status": status, "hooks": payload, "error": None} + + +def github_hook_url(item): + if not isinstance(item, dict): + return "" + config = item.get("config") + if isinstance(config, str): + return config + if isinstance(config, dict) and isinstance(config.get("url"), str): + return config["url"] + return "" + + def select_committed_head_record(repo_key, github_head, records): candidates = [] for record in records: @@ -255,6 +280,10 @@ def main(): action = payload.get("action") if action == "parse-ls-remote": result = parse_ls_remote_lines(str(payload.get("output") or "").splitlines()) + elif action == "parse-github-hooks-response": + result = parse_github_hooks_response(payload.get("ok"), payload.get("status"), payload.get("body")) + elif action == "github-hook-url": + result = github_hook_url(payload.get("hook")) elif action == "select-committed-head-record": result = select_committed_head_record(payload.get("repoKey"), payload.get("githubHead"), payload.get("records", [])) elif action == "select-target-delivery": From c20169d65ae64d30852acb348289968d54c52f9d Mon Sep 17 00:00:00 2001 From: Codex <codex@local> Date: Mon, 13 Jul 2026 11:43:50 +0200 Subject: [PATCH 07/21] =?UTF-8?q?docs:=20=E5=AE=8C=E6=88=90=20Sub2Rank=20?= =?UTF-8?q?=E6=94=AF=E7=BA=BF=E4=BB=BB=E5=8A=A1=E6=94=B6=E5=B0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cli-output-progressive-disclosure.md | 2 +- .../R10_Task_Report.md | 22 +++++++++++++++ .../R1.1_Task_Report.md | 14 ++++++++++ .../R1.2_Task_Report.md | 15 ++++++++++ .../R1.3_Task_Report.md | 20 +++++++++++++ .../R1_Task_Report.md | 15 ++++++++++ .../R2_Task_Report.md | 28 +++++++++++++++++++ docs/MDTODO/mdtodo-tooling-reliability.md | 10 +++---- 8 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 docs/MDTODO/details/cli-output-progressive-disclosure/R10_Task_Report.md create mode 100644 docs/MDTODO/details/mdtodo-tooling-reliability/R1.1_Task_Report.md create mode 100644 docs/MDTODO/details/mdtodo-tooling-reliability/R1.2_Task_Report.md create mode 100644 docs/MDTODO/details/mdtodo-tooling-reliability/R1.3_Task_Report.md create mode 100644 docs/MDTODO/details/mdtodo-tooling-reliability/R1_Task_Report.md create mode 100644 docs/MDTODO/details/mdtodo-tooling-reliability/R2_Task_Report.md diff --git a/docs/MDTODO/cli-output-progressive-disclosure.md b/docs/MDTODO/cli-output-progressive-disclosure.md index f0fbebc9..b3a1583c 100644 --- a/docs/MDTODO/cli-output-progressive-disclosure.md +++ b/docs/MDTODO/cli-output-progressive-disclosure.md @@ -139,6 +139,6 @@ 按 [UniDesk #1905](https://github.com/pikasTech/unidesk/issues/1905) 清理冲突的 reference/skill/Next,并在 Artificer YAML prompt/resource bundle 内明确支持 stdin 时禁止一次性 /tmp 正文;补最小 render/行为测试后独立交付,当前只登记不执行,完成任务后将详细报告写入[任务报告](./details/cli-output-progressive-disclosure/R9.1_Task_Report.md)。 -## R10 [in_progress] +## R10 [completed] 解决 [UniDesk #1656](https://github.com/pikasTech/unidesk/issues/1656):由 Artificer 在独立 `.worktree/cicd-status-visibility`、`fix/cicd-status-visibility` 分支修复 `cicd status --node` 成功零输出,并统一 PaC consumer 的 registry applicability;GitOps-only/publisher-only 应显示 N/A 且由 PipelineRun、GitOps/Argo 与 runtime 证据闭环,只有 owning YAML 明确声明 image/registry 的 consumer 才把 registry missing 作为 blocker。保持默认输出有界、text/JSON/YAML 同构、缺失可选 YAML 事实降级为 warning,不新增严格合同或手工交付入口;补针对性测试和 `unidesk-cicd` 说明,提交独立 PR 且不自行合并,完成任务后将详细报告写入[任务报告](./details/cli-output-progressive-disclosure/R10_Task_Report.md)。 diff --git a/docs/MDTODO/details/cli-output-progressive-disclosure/R10_Task_Report.md b/docs/MDTODO/details/cli-output-progressive-disclosure/R10_Task_Report.md new file mode 100644 index 00000000..54256c0f --- /dev/null +++ b/docs/MDTODO/details/cli-output-progressive-disclosure/R10_Task_Report.md @@ -0,0 +1,22 @@ +# R10 任务报告 + +## 交付 + +- Issue:[pikasTech/unidesk#1656](https://github.com/pikasTech/unidesk/issues/1656)。 +- PR:[pikasTech/unidesk#1918](https://github.com/pikasTech/unidesk/pull/1918)。 +- 实现提交:`f130598c`;文档治理提交:`40cdb946`;合并提交:`52b9c4bc`。 +- `cicd status --node` 在零 consumer、ready、warning 和 blocked 投影中稳定返回非空 typed 摘要。 +- registry applicability 从 owning PaC YAML 的 repository/pipeline 关系派生,没有新增 consumer 特例或严格版本门禁。 + +## 验证 + +- 合并态测试:12 pass,179 assertions;语法检查 11/11;`git diff --check` 通过。 +- NC01 节点状态返回 7 个 consumer 的完整表,Sub2Rank 为 ready。 +- `platform-infra-gitea-nc01` 的 registry 为 `applicability=not-configured`、`status=not-configured`,不再产生 `pac-registry-missing`。 +- image-producing 的 `platform-infra-sub2rank-nc01` 仍要求真实 registry artifact,观测为 `present:sha256:c36633bb4ae`,PipelineRun、GitOps、Argo 与 runtime ready。 +- 节点整体仍有其他 consumer 的既有失败,这些失败未被本任务隐藏,也不影响 registry applicability 验收。 + +## 收尾 + +- PR 已合并,远端分支已删除,任务 worktree 与本地分支已清理。 +- 长期说明位于 `unidesk-cicd` 的 `references/gitea-pac.md`,skill 仅保留短索引。 diff --git a/docs/MDTODO/details/mdtodo-tooling-reliability/R1.1_Task_Report.md b/docs/MDTODO/details/mdtodo-tooling-reliability/R1.1_Task_Report.md new file mode 100644 index 00000000..c9fcfb76 --- /dev/null +++ b/docs/MDTODO/details/mdtodo-tooling-reliability/R1.1_Task_Report.md @@ -0,0 +1,14 @@ +# R1.1 任务报告 + +## 复现与合同 + +- 根因记录于 [agent_skills#7](https://github.com/pikasTech/agent_skills/issues/7):双引号 positional 标题在 CLI 启动前被 shell 展开,反引号和 `$()` 可执行 command substitution。 +- positional 标题包含 CR、LF、NUL 或空标题时,必须在文件锁与写入前拒绝,并返回 `validation-failed`、`mutation=false`。 +- `add`、`add-sub`、`update` 的安全标题入口固定为 quoted heredoc `--stdin`。 +- batch 任一标题非法时整批零写入;错误不得回显原始敏感正文。 +- update 必须保留原任务 ID、children、相邻任务、报告路径和文件锁语义。 + +## 证据 + +- 上游 PR #8 的固定测试覆盖 CR/LF/NUL、shell 敏感字符、stdin/positional 冲突和 update 恢复。 +- 本轮临时 fixture 复测了反引号、`$()`、空格与引号,确认 shell substitution 未执行。 diff --git a/docs/MDTODO/details/mdtodo-tooling-reliability/R1.2_Task_Report.md b/docs/MDTODO/details/mdtodo-tooling-reliability/R1.2_Task_Report.md new file mode 100644 index 00000000..cef93b45 --- /dev/null +++ b/docs/MDTODO/details/mdtodo-tooling-reliability/R1.2_Task_Report.md @@ -0,0 +1,15 @@ +# R1.2 任务报告 + +## 实现 + +- 上游实现由 [agent_skills PR #8](https://github.com/pikasTech/agent_skills/pull/8) 合并,merge commit 为 `f928206`。 +- `add`、`add-sub`、`update` 支持 `--stdin`,并拒绝同时提供 positional title。 +- 所有写入口共用单行校验、空标题校验和幂等标题正规化。 +- update 恢复保持原 ID、children、相邻任务与报告链接,不复制任务。 +- PR #9 继续收敛标点幂等与空标题验证,安装基线为 `2dfe24b`。 + +## 验证 + +- 本机合并后的 `mdtodo-edit` 全量测试 207 pass。 +- skill quick validation 与 `git diff --check` 均通过。 +- 未创建第二套 MDTODO 工具,也未引入版本或 commit 门禁。 diff --git a/docs/MDTODO/details/mdtodo-tooling-reliability/R1.3_Task_Report.md b/docs/MDTODO/details/mdtodo-tooling-reliability/R1.3_Task_Report.md new file mode 100644 index 00000000..c2e9fd6b --- /dev/null +++ b/docs/MDTODO/details/mdtodo-tooling-reliability/R1.3_Task_Report.md @@ -0,0 +1,20 @@ +# R1.3 任务报告 + +## 合并与安装 + +- [agent_skills PR #8](https://github.com/pikasTech/agent_skills/pull/8) 已合并,issue #7 已关闭。 +- `/root/.agents/skills` 原状态落后 5 个提交且包含并行脏改。 +- 安装过程先 `stash -u`,再快进到 `origin/master@2dfe24b`,最后 `stash apply` 并对 3 个重叠文件做语义合并。 +- 上游安全 stdin/校验实现作为本地标点改动的超集保留;其余 6 组并行修改、未跟踪 worktree 与保护 stash 未删除。 + +## 原入口验收 + +- `add --help` 显示 `--stdin`,`add-sub` 与 `update` capability 由合并态 UniDesk 测试覆盖。 +- 临时 MDTODO 使用 quoted heredoc 写入包含反引号、`$()`、空格和引号的标题。 +- 标题内容原样读取,预设 command-substitution 文件不存在。 +- 全量测试 207 pass,skill 校验通过,CLI 文件 SHA-256 为 `16e9c7aacc97fc80...`。 + +## 清理 + +- 临时 MDTODO fixture 已删除。 +- 保护 stash 保留,避免对并行改动做破坏性 drop。 diff --git a/docs/MDTODO/details/mdtodo-tooling-reliability/R1_Task_Report.md b/docs/MDTODO/details/mdtodo-tooling-reliability/R1_Task_Report.md new file mode 100644 index 00000000..03c86d95 --- /dev/null +++ b/docs/MDTODO/details/mdtodo-tooling-reliability/R1_Task_Report.md @@ -0,0 +1,15 @@ +# R1 任务报告 + +## 结果 + +- MDTODO 标题污染根因、输入合同、实现、合并、本机安装与原入口复测全部闭环。 +- 上游 owner 保持为 `pikasTech/agent_skills`,实现通过 issue #7 / PR #8 长期维护。 +- UniDesk 通过 issue #1913 / PR #1920 提供 capability/freshness warning 和 quoted heredoc 默认示例,不复制实现。 +- 缺失或过期只产生 warning,不作为业务 blocker。 + +## 验收摘要 + +- 上游 merge:`f928206`;安装基线:`2dfe24b`。 +- MDTODO 测试:207 pass;UniDesk adoption 测试:2 pass / 9 assertions。 +- 真实标题包含 Markdown、反引号、`$()`、空格与引号,内容原样写入且 shell substitution 未执行。 +- 任务 ID、报告、children、相邻任务和文件锁合同均由固定测试保护。 diff --git a/docs/MDTODO/details/mdtodo-tooling-reliability/R2_Task_Report.md b/docs/MDTODO/details/mdtodo-tooling-reliability/R2_Task_Report.md new file mode 100644 index 00000000..74af2eee --- /dev/null +++ b/docs/MDTODO/details/mdtodo-tooling-reliability/R2_Task_Report.md @@ -0,0 +1,28 @@ +# R2 任务报告 + +## 交付 + +- Issue:[pikasTech/unidesk#1913](https://github.com/pikasTech/unidesk/issues/1913)。 +- PR:[pikasTech/unidesk#1920](https://github.com/pikasTech/unidesk/pull/1920)。 +- 实现提交:`cfcade4e`;复审修正:`7dcc1395`;合并提交:`99fcfd34`。 +- UniDesk 只探测 `mdtodo-edit add/add-sub/update --help` 的 `--stdin` capability,不复制工具实现,也不绑定版本或 commit。 +- source/target identity、freshness、warning 与精确 Next 已进入 compact/full 输出;漂移不改变 `ok`、`runnerUsable` 或 `contractOk`。 +- source stale、target ready 的边界现在返回非阻塞 `source-stale` warning,不再误报 ready freshness。 + +## 验证 + +- 合并态定向测试:2 pass,9 assertions;语法检查 11/11;`git diff --check` 通过。 +- 本机 `/root/.agents/skills` 按 stash、快进、stash apply 做语义合并,原并行修改与保护 stash 均保留。 +- 安装 HEAD 与 `origin/master` 同为 `2dfe24b`,CLI SHA-256 为 `16e9c7aacc97fc80` 前缀。 +- MDTODO 全量测试 207 pass,skill 校验通过。 +- 临时真实 MDTODO 标题包含 Markdown、反引号、`$()`、空格和引号,经 quoted heredoc 原样写入;命令替换目标文件未生成。 + +## 非阻塞限制 + +- `codex skills-sync --dry-run --full` 在当前 NC01 主机仍报告旧 local Compose `backend-core-container-missing`,`mutation=false`。 +- 该诊断入口不可提供 live backend 投影,但不影响已安装安全 stdin、生成命令、fixture 测试或业务运行;没有为绕过它启动 Compose 或建立第二控制面。 + +## 收尾 + +- PR 已合并,远端分支已删除,任务 worktree 与本地分支已清理。 +- R2 完成后由 R1.3 记录上游实现、受控安装和真实标题复测的完整闭环。 diff --git a/docs/MDTODO/mdtodo-tooling-reliability.md b/docs/MDTODO/mdtodo-tooling-reliability.md index daa13217..f94e9f4b 100644 --- a/docs/MDTODO/mdtodo-tooling-reliability.md +++ b/docs/MDTODO/mdtodo-tooling-reliability.md @@ -3,22 +3,22 @@ 本文件跟踪 MDTODO CLI 的结构安全、并发写入、输入合同、渐进披露、任务恢复和跨平台可用性。单次工具故障及其修复作为 ITEM 进入本长期问题域。 -## R1 [in_progress] +## R1 [completed] 依据 [agent_skills #7](https://github.com/pikasTech/agent_skills/issues/7) 修复 MDTODO 标题多行污染并提供安全 stdin 输入,在保持任务 ID、报告、文件锁和层级合同下支持原 ID 恢复,完成任务后将详细报告写入[任务报告](./details/mdtodo-tooling-reliability/R1_Task_Report.md)。 -### R1.1 [in_progress] +### R1.1 [completed] 复现 positional title 含 CR/LF/NUL 与 shell command substitution stdout 的污染形态,定义 validation-failed、mutation=false、stdin 标题和原 ID 恢复合同,引用 [agent_skills #7](https://github.com/pikasTech/agent_skills/issues/7),完成任务后将详细报告写入[任务报告](./details/mdtodo-tooling-reliability/R1.1_Task_Report.md)。 -### R1.2 +### R1.2 [completed] 实现 add/add-sub/batch/update 的单行校验、安全 stdin 输入与结构化 update 正规化,精确保留 children、相邻任务、报告路径和文件锁;补单元与回归测试,完成任务后将详细报告写入[任务报告](./details/mdtodo-tooling-reliability/R1.2_Task_Report.md)。 -### R1.3 +### R1.3 [completed] 在独立 worktree 提交 PR,完成 Python/skill 校验和临时 fixture 前向验收;合并后更新本机受控 skill 安装并用本 MDTODO 的真实 Markdown 标题复测,完成任务后将详细报告写入[任务报告](./details/mdtodo-tooling-reliability/R1.3_Task_Report.md)。 -## R2 [in_progress] +## R2 [completed] 解决 UniDesk #1913(https://github.com/pikasTech/unidesk/issues/1913):复用 agent_skills #7 与 PR #8 的 MDTODO 安全 stdin 实现,让主代理、Artificer resource bundle 和受控 host skill 投影默认使用安全标题输入,并有界披露 source/target freshness、warning 与精确修复入口;不得复制第二套 MDTODO 工具、增加版本门禁或覆盖宿主并行脏改,由 Artificer 在独立 worktree 和分支实现、测试并提交 PR,不自行合并,完成任务后将详细报告写入[任务报告](./details/mdtodo-tooling-reliability/R2_Task_Report.md)。 From 5e36862183cf5d8dbe922714b9ff2c4cb4a84958 Mon Sep 17 00:00:00 2001 From: Codex <codex@local> Date: Mon, 13 Jul 2026 11:58:17 +0200 Subject: [PATCH 08/21] =?UTF-8?q?docs:=20=E8=B7=9F=E8=B8=AA=20selfmedia=20?= =?UTF-8?q?=E4=BB=93=E5=BA=93=E7=BA=A7=20GitHub=20=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/MDTODO/selfmedia-production-delivery.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/MDTODO/selfmedia-production-delivery.md b/docs/MDTODO/selfmedia-production-delivery.md index 4d95fb11..b415e922 100644 --- a/docs/MDTODO/selfmedia-production-delivery.md +++ b/docs/MDTODO/selfmedia-production-delivery.md @@ -3,3 +3,7 @@ ## R1 [in_progress] 打通 selfmedia WebTerm 在 NC01 的纯自动生产交付:以 config/selfmedia.yaml 与 platform-infra owning YAML 为唯一真相,修复 GitHub→Gitea mirror→PaC/Tekton→GitOps/Argo 的自动触发与可见性,使合并 pikainc/selfmedia/master 后无需人工触发即可构建并发布;保留零 RBAC runtime、双 PVC、YAML-first Secret、NetworkPolicy、公网 IP 152.53.229.148:4317,禁止用手工 PipelineRun、Argo sync/refresh、rollout 或 cutover 代替自动链路,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R1_Task_Report.md)。 + +### R1.1 + +以 YAML-first 为 selfmedia-nc01 增加仓库级 GitHub authority:使用独立 sourceRef 提供 pikainc/selfmedia 私有仓库 Contents Read 与 Webhooks Read/Write 权限,校验权限/Secret presence 与脱敏 fingerprint;不扩大全局 gh_token.txt、不向 Codex 下发、不手工触发 PaC,待凭据到位后仅观察 hook reconciler 与自动交付收敛,并将详细报告写入任务报告,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R1.1_Task_Report.md)。 From d36bbe3b74a8e46160d85106de62f06299e25cbc Mon Sep 17 00:00:00 2001 From: Codex <codex@local> Date: Mon, 13 Jul 2026 12:11:40 +0200 Subject: [PATCH 09/21] =?UTF-8?q?docs:=20=E6=94=B6=E5=8F=A3=20JD01=20?= =?UTF-8?q?=E5=93=A8=E5=85=B5=E4=B8=B4=E6=97=B6=E9=80=80=E5=BD=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../R1.4.1_Task_Report.md | 20 +++++++++++++++++++ .../web-sentinel-runtime-reliability.md | 5 +++-- 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 docs/MDTODO/details/web-sentinel-runtime-reliability/R1.4.1_Task_Report.md diff --git a/docs/MDTODO/details/web-sentinel-runtime-reliability/R1.4.1_Task_Report.md b/docs/MDTODO/details/web-sentinel-runtime-reliability/R1.4.1_Task_Report.md new file mode 100644 index 00000000..5205cb01 --- /dev/null +++ b/docs/MDTODO/details/web-sentinel-runtime-reliability/R1.4.1_Task_Report.md @@ -0,0 +1,20 @@ +# R1.4.1 任务报告 + +## 结论 + +- [UniDesk #1919](https://github.com/pikasTech/unidesk/issues/1919) 的约 10 分钟业务 Session 已定位为 JD01 遗留 quick-verify CronJob,不是 NC01 小时级哨兵或 10 分钟 heartbeat。 +- 用户最新确认退役可以是临时操作,不要求 YAML-first 永久化;因此不合并删除 JD01 source profile、PaC consumer 或 `.tekton` 的 PR。 + +## 临时退役结果 + +- Argo Application `hwlab-web-probe-sentinel-jd01` 为 `NotFound`。 +- PaC Repository `sentinel-jd01-v03` 为 `NotFound`。 +- `hwlab-v03` 中按 `unidesk.ai/web-probe-sentinel-id=jd01-web-probe-sentinel` 查询 CronJob、Job、Deployment、Pod、Service、ConfigMap、ServiceAccount、Role、RoleBinding、NetworkPolicy 和 PVC,结果为空。 +- 原 `*/10` 调度最后一次 Job 保持在 `2026-07-13T08:25:09Z`;紧急停止后跨过后续调度窗口未出现新 Job。 +- NC01 小时级 Web 哨兵保持不变。 + +## 交付边界 + +- [PR #1922](https://github.com/pikasTech/unidesk/pull/1922) 是此前永久 YAML-first 退役方案;因用户最新范围改为临时退役,不合并该 PR。 +- 源码中的 JD01 配置保留,未来如需恢复可重新走受控部署;本次完成标准仅为当前运行面全部 absent。 +- D518 遗留和其他 Monitor 问题不在本任务范围。 diff --git a/docs/MDTODO/web-sentinel-runtime-reliability.md b/docs/MDTODO/web-sentinel-runtime-reliability.md index 0fb8e3b2..39163d58 100644 --- a/docs/MDTODO/web-sentinel-runtime-reliability.md +++ b/docs/MDTODO/web-sentinel-runtime-reliability.md @@ -24,9 +24,10 @@ PR 合并后只等待自动发布链,使用受控 Web sentinel 原入口产生 核对 [#1861](https://github.com/pikasTech/unidesk/issues/1861) 的小时级实际探测 cadence 与 10 分钟 scheduler heartbeat/扫描间隔;若只读报告语义误导则纠正字段和文档,若 owning YAML 未生效则修正渲染,验收 CronJob 与报告明确区分二者,完成任务后将详细报告写入[任务报告](./details/web-sentinel-runtime-reliability/R1.4_Task_Report.md)。 -#### R1.4.1 [in_progress] +#### R1.4.1 [completed] + +完成 [UniDesk #1919](https://github.com/pikasTech/unidesk/issues/1919) 的 JD01 Web 哨兵临时退役:确认约 10 分钟 Session 来自遗留 quick-verify CronJob,停止并移除 live Argo Application、PaC Repository 及带 JD01 sentinel 标签的运行资源;不要求 YAML-first 永久退役,源码配置保留以便恢复;NC01 小时级巡检保持不变,D518 不在本任务范围,完成任务后将详细报告写入[任务报告](./details/web-sentinel-runtime-reliability/R1.4.1_Task_Report.md)。 -解决 [UniDesk #1919](https://github.com/pikasTech/unidesk/issues/1919):已确认约 10 分钟 Session 来自 JD01 遗留 `*/10` quick-verify CronJob,按用户最新要求整个停掉 JD01 哨兵;紧急停止后通过 Git-backed owning YAML 与既有自动 lifecycle 永久退役 JD01 sentinel consumer、Argo desired state、CronJob、sentinel/frpc runtime,不恢复 JD01 target、不把 10m 改成 1h;保留 NC01 唯一小时级巡检,自动交付后覆盖完整小时窗口验收,D518 若需处理另行登记,完成任务后将详细报告写入[任务报告](./details/web-sentinel-runtime-reliability/R1.4.1_Task_Report.md)。 ## R2 [in_progress] 推进 [UniDesk #1868](https://github.com/pikasTech/unidesk/issues/1868):按当前多 runner、全局查询与 Dashboard 复杂度重构 Monitor 平台,评估并实施分散 SQLite 到 NC01 YAML-first Host PostgreSQL 的统一迁移;P0 先冻结架构、数据模型和切换边界,再实现、自动发布和原入口验收,不以迁移掩盖 [#1861](https://github.com/pikasTech/unidesk/issues/1861),不保留永久双写或第二权威,完成任务后将详细报告写入[任务报告](./details/web-sentinel-runtime-reliability/R2_Task_Report.md)。 From 38b4675731a2e90f9259d2b3753e6ce297e8f11b Mon Sep 17 00:00:00 2001 From: Codex <codex@local> Date: Mon, 13 Jul 2026 12:11:43 +0200 Subject: [PATCH 10/21] =?UTF-8?q?docs:=20=E8=B7=9F=E8=B8=AA=20AgentRun=20?= =?UTF-8?q?=E4=B8=BB=E7=BA=BF=E5=8F=AF=E8=A7=81=E6=80=A7=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/MDTODO/selfmedia-production-delivery.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/MDTODO/selfmedia-production-delivery.md b/docs/MDTODO/selfmedia-production-delivery.md index b415e922..7d244d70 100644 --- a/docs/MDTODO/selfmedia-production-delivery.md +++ b/docs/MDTODO/selfmedia-production-delivery.md @@ -7,3 +7,7 @@ ### R1.1 以 YAML-first 为 selfmedia-nc01 增加仓库级 GitHub authority:使用独立 sourceRef 提供 pikainc/selfmedia 私有仓库 Contents Read 与 Webhooks Read/Write 权限,校验权限/Secret presence 与脱敏 fingerprint;不扩大全局 gh_token.txt、不向 Codex 下发、不手工触发 PaC,待凭据到位后仅观察 hook reconciler 与自动交付收敛,并将详细报告写入任务报告,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R1.1_Task_Report.md)。 + +### R1.2 [in_progress] + +有界恢复 NC01 AgentRun manager 受控代理可见性:定位连续 agentrun-proxy-exec-failed,只允许检查并恢复既有 manager.baseUrl、lane service proxy 与授权 presence,不打印凭据,不修改 Artificer 主线源码,不新增安全围栏;恢复后用原始 agentrun events/logs 入口验证并写任务报告,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R1.2_Task_Report.md)。 From 3aaa2616e8d268a5697f4a87232e36121cf45a8a Mon Sep 17 00:00:00 2001 From: Codex <codex@local> Date: Mon, 13 Jul 2026 12:17:29 +0200 Subject: [PATCH 11/21] =?UTF-8?q?docs:=20=E6=94=B6=E5=8F=A3=20AgentRun=20?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E7=9E=AC=E6=80=81=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../R1.2_Task_Report.md | 34 +++++++++++++++++++ docs/MDTODO/selfmedia-production-delivery.md | 2 +- 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 docs/MDTODO/details/selfmedia-production-delivery/R1.2_Task_Report.md diff --git a/docs/MDTODO/details/selfmedia-production-delivery/R1.2_Task_Report.md b/docs/MDTODO/details/selfmedia-production-delivery/R1.2_Task_Report.md new file mode 100644 index 00000000..1687f6db --- /dev/null +++ b/docs/MDTODO/details/selfmedia-production-delivery/R1.2_Task_Report.md @@ -0,0 +1,34 @@ +# R1.2 任务报告 + +## 结论 + +NC01 / `nc01-v02` AgentRun manager 受控代理可见性已通过原入口自行恢复;本次按瞬态故障收口,没有修改 YAML、源码、Secret、Deployment、PipelineRun、Argo 或运行面状态。 + +`agentrun-proxy-exec-failed` 的现有实现语义是 lane `kubectl exec` 代理进程非零退出,发生在 manager HTTP 响应被解析前;它不同于 `auth-failed`、`agentrun-manager-fetch-failed` 和 `agentrun-timeout`。恢复后的同路径查询持续成功,未发现持久配置或服务故障。 + +## 配置与授权核对 + +- owning YAML:`config/agentrun.yaml`。 +- 外层调用:`client.transport=target-trans`,Target `NC01`,重入工作区 `/root/unidesk`。 +- lane:`nc01-v02`,内部代理模式 `lane-k8s-service-proxy`,manager Service URL `http://agentrun-mgr.agentrun-v02.svc.cluster.local:8080`。 +- 顶层 `manager.baseUrl=https://agentrun.74-48-78-17.nip.io/` 只属于 direct HTTP 诊断;本次带 Target 的资源查询未走该地址。 +- host 授权文件 `/root/.unidesk/.env/agentrun.env`:`present=false`、`fingerprint=null`、`valuesPrinted=false`;lane 查询不依赖该文件,而是从 manager Pod 环境读取 API key。恢复后的鉴权查询成功,未输出凭据值。 + +## 原入口恢复证据 + +2026-07-13 10:12-10:16 UTC 期间重复执行以下只读入口,均返回退出码 0: + +- `agentrun events run/run_9d2eb6e33cfb4656bb4a9ce450da28e2 --target NC01 --lane nc01-v02`:事件可读,并从 seq 21 持续推进到 seq 181 以后。 +- `agentrun logs session/sess_artificer_c1f57337c99e7836cea26a5c --target NC01 --lane nc01-v02`:日志可读,并持续推进到 seq 213。 +- `agentrun get tasks` 与 `agentrun describe task/qt_cdc6fa2b3f964b91bd12934f51b8c1e7`:目标任务为 `running`,run、command、runner 与 session identity 完整。 +- 机器输出明确显示 `TargetExecution.transport=target-trans`、`route=NC01:/root/unidesk`、`lane=nc01-v02`、`mutation=false`、`valuesPrinted=false`。 +- `agentrun control-plane status --node NC01 --lane nc01-v02`:manager source `7585787e5e1f`,Deployment `1/1 ready`,Service 存在;最新 PaC PipelineRun 成功,Argo `Synced/Healthy`。 + +控制面同时报告既有 `lane-secret-missing` 总体告警;它没有阻断本次 manager 资源查询,也不属于 R1.2 瞬态代理恢复范围,因此未扩大处理。 + +## 变更与回退 + +- 运行面变更:无。 +- 配置/源码变更:无。 +- 唯一交付:本报告与 R1.2 状态收口。 +- 回退:不适用。 diff --git a/docs/MDTODO/selfmedia-production-delivery.md b/docs/MDTODO/selfmedia-production-delivery.md index 7d244d70..40b46e9c 100644 --- a/docs/MDTODO/selfmedia-production-delivery.md +++ b/docs/MDTODO/selfmedia-production-delivery.md @@ -8,6 +8,6 @@ 以 YAML-first 为 selfmedia-nc01 增加仓库级 GitHub authority:使用独立 sourceRef 提供 pikainc/selfmedia 私有仓库 Contents Read 与 Webhooks Read/Write 权限,校验权限/Secret presence 与脱敏 fingerprint;不扩大全局 gh_token.txt、不向 Codex 下发、不手工触发 PaC,待凭据到位后仅观察 hook reconciler 与自动交付收敛,并将详细报告写入任务报告,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R1.1_Task_Report.md)。 -### R1.2 [in_progress] +### R1.2 [completed] 有界恢复 NC01 AgentRun manager 受控代理可见性:定位连续 agentrun-proxy-exec-failed,只允许检查并恢复既有 manager.baseUrl、lane service proxy 与授权 presence,不打印凭据,不修改 Artificer 主线源码,不新增安全围栏;恢复后用原始 agentrun events/logs 入口验证并写任务报告,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R1.2_Task_Report.md)。 From 89b618ded86b39ba1b8cc36d625ae99314362c5c Mon Sep 17 00:00:00 2001 From: Codex <codex@local> Date: Mon, 13 Jul 2026 12:35:00 +0200 Subject: [PATCH 12/21] feat: add repository-scoped GitHub authority --- config/platform-infra/gitea.yaml | 26 +++ .../R1.1_Task_Report.md | 56 +++++ docs/MDTODO/selfmedia-production-delivery.md | 2 +- .../src/platform-infra-gitea-config.test.ts | 50 +++++ scripts/src/platform-infra-gitea-config.ts | 61 ++++++ ...latform-infra-gitea-github-sync-server.mjs | 15 +- scripts/src/platform-infra-gitea-remote.sh | 40 ++-- scripts/src/platform-infra-gitea.ts | 191 +++++++++++++----- .../platform_infra_gitea_hook_reconciler.py | 6 +- 9 files changed, 379 insertions(+), 68 deletions(-) create mode 100644 docs/MDTODO/details/selfmedia-production-delivery/R1.1_Task_Report.md create mode 100644 scripts/src/platform-infra-gitea-config.test.ts diff --git a/config/platform-infra/gitea.yaml b/config/platform-infra/gitea.yaml index 600e6bee..684cf036 100644 --- a/config/platform-infra/gitea.yaml +++ b/config/platform-infra/gitea.yaml @@ -356,6 +356,32 @@ sourceAuthority: disposition: replaced-by-gitea - key: selfmedia-nc01 targetId: NC01 + credentialOverride: + github: + transport: https-token + sourceRef: pikainc-selfmedia-gh-token.txt + sourceKey: GH_TOKEN + format: raw-token + requiredFor: + - upstream-mirror + - mirror-sync + - managed-repository-fetch + - github-head-observe + - github-hooks-list + - github-hooks-reconcile + permissions: + contents: read + metadata: read + webhooks: read-write + gitFetchCredential: + apiVersion: unidesk.ai/v1 + kind: GitFetchCredential + authMode: github-https-token + host: github.com + secretRef: + namespace: devops-infra + name: gitea-github-sync-secrets + key: github-token-pikainc-selfmedia upstream: repository: pikainc/selfmedia cloneUrl: https://github.com/pikainc/selfmedia.git diff --git a/docs/MDTODO/details/selfmedia-production-delivery/R1.1_Task_Report.md b/docs/MDTODO/details/selfmedia-production-delivery/R1.1_Task_Report.md new file mode 100644 index 00000000..570bf680 --- /dev/null +++ b/docs/MDTODO/details/selfmedia-production-delivery/R1.1_Task_Report.md @@ -0,0 +1,56 @@ +# R1.1 任务报告 + +## 任务回链 + +- MDTODO:R1.1。 +- Target:NC01。 +- targetWorkspace:`/root/.worktrees/unidesk/selfmedia-repo-github-authority`。 +- repository:`pikasTech/unidesk`。 +- ref:`feat/selfmedia-repo-github-authority`。 + +## Primary Workspace 轻量准备 + +- 仅加载 `dad-dev`、`unidesk-trans`、`unidesk-ymalops`、`cli-spec`、`git-spec`、`unidesk-gh` 与 `docs-spec`。 +- 未在 runner primary workspace 读取、修改或验证目标源码。 +- 未把 runner 本地结果作为 Target 原入口证据。 + +## Target 实现 + +- 在 `config/platform-infra/gitea.yaml` 为 `selfmedia-nc01` 声明 repository credential override。 +- 独立 `sourceRef` 为 `/root/.unidesk/.env` 根下的 `pikainc-selfmedia-gh-token.txt`。 +- 独立 Secret key 为 `github-token-pikainc-selfmedia`,未修改全局 `gh_token.txt` 权限或内容。 +- YAML 声明最小权限:Contents Read、Metadata Read、Webhooks Read/Write。 +- 配置 parser 与 validator 支持通用 repository override,并校验 Secret key 唯一性、环境变量归一化碰撞、目标 namespace、bridge Secret 归属和必需能力。 +- Secret 渲染支持多个 GitHub token key;token 仅通过环境变量进入受控执行,不进入 YAML、Git、日志或报告。 +- bridge、candidate、受控 fetch、GitHub head、hooks list/status 和 hook reconciler 均按 repository 选择 credential。 +- bridge/candidate 只注入所属 Target 仓库 token;hook owner reconciler 额外注入所有声明 `github-hooks-*` 的 override;JD01 不依赖 NC01 selfmedia token。 +- CLI credential 摘要对 override 仅披露 `sourceRef`、`present`、`requiredKeysPresent`、`fingerprint`、`permissionResult`、有界错误与 `valuesPrinted=false`。 + +## Target 原入口证据 + +- 已通过 `trans NC01:/root/.worktrees/unidesk/selfmedia-repo-github-authority` 执行全部源码、Git 和验证操作。 +- `bun test scripts/src/platform-infra-gitea-config.test.ts scripts/src/platform-infra-gitea-disclosure.test.ts`:8 项通过,0 项失败。 +- `bun scripts/cli.ts platform-infra gitea plan --target NC01`:policy 为 `ok`。 +- `git diff --check`、Node 语法、Python `py_compile`、Shell `sh -n`:通过。 +- `mirror bootstrap --target NC01 --repo selfmedia-nc01 --dry-run`:`OK=false`,blocker 为 `github-upstream:selfmedia-nc01`,符合 fail-closed。 +- `mirror bootstrap --target JD01 --dry-run`:`OK=true`,证明 JD01 不依赖 NC01 override。 +- 专用凭据只读状态:`sourceRef=pikainc-selfmedia-gh-token.txt`、`presence=false`、`fingerprint=null`、`permissionResult=blocked-missing-credential`。 +- 因 credential material 缺失,未执行权限 API 探测;不得声称 Contents、Metadata 或 Webhooks 权限已实际授予。 + +## 并行保护 + +- 已两次按 `stash -u`、`fetch`、`rebase origin/master`、`stash apply` 安全吸收主线。 +- 最终吸收 `origin/master@3aaa2616`。 +- R1.2 保持 `[completed]`,`R1.2_Task_Report.md` 保留。 + +## 未执行事项 + +- 未创建假 token,未复制 SSH 私钥,未修改仓库可见性。 +- 未手工 apply、Secret sync、PipelineRun、Argo sync/refresh、rollout 或 cutover。 +- 未触发 PaC 或任何部署,只保留只读运行面证据。 + +## 当前结论 + +- R1.1 配置、schema、renderer、Secret、bridge、observer 与受控 fetch 的源码能力已落地并通过轻量验证。 +- 外部 credential material 仍缺失,R1.1 保持 `in_progress`。 +- 凭据到位后的下一步仅允许通过受控 CLI 观察脱敏 permissionResult、hook reconciler 与自动交付收敛,不得以手工触发替代自动链路。 diff --git a/docs/MDTODO/selfmedia-production-delivery.md b/docs/MDTODO/selfmedia-production-delivery.md index 40b46e9c..871c7dc4 100644 --- a/docs/MDTODO/selfmedia-production-delivery.md +++ b/docs/MDTODO/selfmedia-production-delivery.md @@ -4,7 +4,7 @@ 打通 selfmedia WebTerm 在 NC01 的纯自动生产交付:以 config/selfmedia.yaml 与 platform-infra owning YAML 为唯一真相,修复 GitHub→Gitea mirror→PaC/Tekton→GitOps/Argo 的自动触发与可见性,使合并 pikainc/selfmedia/master 后无需人工触发即可构建并发布;保留零 RBAC runtime、双 PVC、YAML-first Secret、NetworkPolicy、公网 IP 152.53.229.148:4317,禁止用手工 PipelineRun、Argo sync/refresh、rollout 或 cutover 代替自动链路,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R1_Task_Report.md)。 -### R1.1 +### R1.1 [in_progress] 以 YAML-first 为 selfmedia-nc01 增加仓库级 GitHub authority:使用独立 sourceRef 提供 pikainc/selfmedia 私有仓库 Contents Read 与 Webhooks Read/Write 权限,校验权限/Secret presence 与脱敏 fingerprint;不扩大全局 gh_token.txt、不向 Codex 下发、不手工触发 PaC,待凭据到位后仅观察 hook reconciler 与自动交付收敛,并将详细报告写入任务报告,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R1.1_Task_Report.md)。 diff --git a/scripts/src/platform-infra-gitea-config.test.ts b/scripts/src/platform-infra-gitea-config.test.ts new file mode 100644 index 00000000..c528960f --- /dev/null +++ b/scripts/src/platform-infra-gitea-config.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test"; +import { + githubTokenEnvNameForSecretKey, + readGiteaConfig, + resolveTarget, +} from "./platform-infra-gitea-config"; +import { renderManifest } from "./platform-infra-gitea"; + +describe("platform-infra Gitea repository GitHub credentials", () => { + test("keeps the global credential and selfmedia override on distinct Secret keys", () => { + const config = readGiteaConfig(); + const globalCredential = config.sourceAuthority.credentials.github; + const selfmedia = config.sourceAuthority.repositories.find((repo) => repo.key === "selfmedia-nc01"); + const override = selfmedia?.credentialOverride?.github; + + expect(override?.sourceRef).toBe("pikainc-selfmedia-gh-token.txt"); + expect(override?.permissions).toEqual({ contents: "read", metadata: "read", webhooks: "read-write" }); + expect(override?.gitFetchCredential.secretRef.key).not.toBe(globalCredential.gitFetchCredential.secretRef.key); + expect(githubTokenEnvNameForSecretKey(override?.gitFetchCredential.secretRef.key ?? "")) + .not.toBe(githubTokenEnvNameForSecretKey(globalCredential.gitFetchCredential.secretRef.key)); + }); + + test("normalizes Secret keys to bounded environment variable names", () => { + expect(githubTokenEnvNameForSecretKey("github-token-pikainc-selfmedia")) + .toBe("UNIDESK_GITEA_GITHUB_TOKEN_GITHUB_TOKEN_PIKAINC_SELFMEDIA"); + }); + + test("keeps repository credential overrides scoped to their configured target", () => { + const config = readGiteaConfig(); + const jd01Overrides = config.sourceAuthority.repositories + .filter((repo) => repo.targetId === "JD01") + .flatMap((repo) => repo.credentialOverride?.github ?? []); + const nc01Overrides = config.sourceAuthority.repositories + .filter((repo) => repo.targetId === "NC01") + .flatMap((repo) => repo.credentialOverride?.github ?? []); + + expect(jd01Overrides).toHaveLength(0); + expect(nc01Overrides.map((credential) => credential.sourceRef)).toContain("pikainc-selfmedia-gh-token.txt"); + }); + + test("does not inject the NC01 selfmedia token into JD01 bridge containers", () => { + const config = readGiteaConfig(); + const tokenEnv = githubTokenEnvNameForSecretKey("github-token-pikainc-selfmedia"); + const jd01Manifest = renderManifest(config, resolveTarget(config, "JD01")); + const nc01Manifest = renderManifest(config, resolveTarget(config, "NC01")); + + expect(jd01Manifest).not.toContain(tokenEnv); + expect(nc01Manifest).toContain(tokenEnv); + }); +}); diff --git a/scripts/src/platform-infra-gitea-config.ts b/scripts/src/platform-infra-gitea-config.ts index dcdaebb3..f97df6a2 100644 --- a/scripts/src/platform-infra-gitea-config.ts +++ b/scripts/src/platform-infra-gitea-config.ts @@ -160,6 +160,16 @@ export interface GiteaGithubCredential { }; } +export interface GiteaGithubPermissions { + contents: "read"; + metadata: "read"; + webhooks: "read-write"; +} + +export function githubTokenEnvNameForSecretKey(secretKey: string): string { + return `UNIDESK_GITEA_GITHUB_TOKEN_${secretKey.replace(/[^A-Za-z0-9_]/gu, "_").toUpperCase()}`; +} + export interface GiteaWebhookSync { enabled: boolean; direction: "github-to-gitea"; @@ -273,6 +283,9 @@ export interface GiteaSourceResponsibility { export interface GiteaMirrorRepository { key: string; targetId: string; + credentialOverride: { + github: GiteaGithubCredential & { permissions: GiteaGithubPermissions }; + } | null; upstream: { repository: string; cloneUrl: string; @@ -657,9 +670,28 @@ function parseMirrorRepository(record: Record<string, unknown>, index: number): const legacyGitMirror = record.legacyGitMirror === null || record.legacyGitMirror === undefined ? null : y.objectField(record, "legacyGitMirror", path); + const credentialOverride = record.credentialOverride === undefined + ? null + : y.objectField(record, "credentialOverride", path); + const githubOverride = credentialOverride === null + ? null + : y.objectField(credentialOverride, "github", `${path}.credentialOverride`); + const githubPermissions = githubOverride === null + ? null + : y.objectField(githubOverride, "permissions", `${path}.credentialOverride.github`); return { key: y.stringField(record, "key", path), targetId: y.stringField(record, "targetId", path), + credentialOverride: githubOverride === null || githubPermissions === null ? null : { + github: { + ...parseGithubCredential(githubOverride, `${path}.credentialOverride.github`), + permissions: { + contents: y.enumField(githubPermissions, "contents", `${path}.credentialOverride.github.permissions`, ["read"] as const), + metadata: y.enumField(githubPermissions, "metadata", `${path}.credentialOverride.github.permissions`, ["read"] as const), + webhooks: y.enumField(githubPermissions, "webhooks", `${path}.credentialOverride.github.permissions`, ["read-write"] as const), + }, + }, + }, upstream: { repository: repositoryField(upstream, "repository", `${path}.upstream`), cloneUrl: gitCloneUrlField(upstream, "cloneUrl", `${path}.upstream`), @@ -738,6 +770,35 @@ function validateConfig(gitea: GiteaConfig): void { if (gitea.targets.some((target) => target.enabled && target.namespace !== gitFetchCredential.secretRef.namespace)) { throw new Error(`${configLabel}.sourceAuthority.credentials.github.gitFetchCredential.secretRef.namespace must match every enabled Gitea target namespace`); } + const credentialKeys = new Set([gitFetchCredential.secretRef.key]); + const credentialEnvNames = new Set([githubTokenEnvNameForSecretKey(gitFetchCredential.secretRef.key)]); + for (const repo of gitea.sourceAuthority.repositories) { + const override = repo.credentialOverride?.github; + if (override === undefined) continue; + if (!override.requiredFor.includes("managed-repository-fetch") + || !override.requiredFor.includes("github-head-observe") + || !override.requiredFor.includes("github-hooks-list") + || !override.requiredFor.includes("github-hooks-reconcile")) { + throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.credentialOverride.github.requiredFor must cover fetch, head observation and hook reconciliation`); + } + if (override.gitFetchCredential.secretRef.name !== gitea.sourceAuthority.webhookSync.bridge.secretName) { + throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.credentialOverride.github.gitFetchCredential.secretRef.name must match sourceAuthority.webhookSync.bridge.secretName`); + } + const target = resolveTarget(gitea, repo.targetId); + if (override.gitFetchCredential.secretRef.namespace !== target.namespace) { + throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.credentialOverride.github.gitFetchCredential.secretRef.namespace must match repository target namespace`); + } + const key = override.gitFetchCredential.secretRef.key; + if (!/^[A-Za-z0-9._-]+$/u.test(key) || credentialKeys.has(key)) { + throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.credentialOverride.github.gitFetchCredential.secretRef.key must be a unique Kubernetes Secret data key`); + } + credentialKeys.add(key); + const envName = githubTokenEnvNameForSecretKey(key); + if (credentialEnvNames.has(envName)) { + throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.credentialOverride.github.gitFetchCredential.secretRef.key collides after environment variable normalization`); + } + credentialEnvNames.add(envName); + } if (!gitea.sourceAuthority.enabled) throw new Error(`${configLabel}.sourceAuthority.enabled must be true for GH-1550`); if (gitea.sourceAuthority.repositories.length < 1) throw new Error(`${configLabel}.sourceAuthority.repositories must not be empty`); if (!/^docker\.gitea\.com\/gitea$/u.test(gitea.app.image.repository)) throw new Error(`${configLabel}.app.image.repository must use the official Gitea image registry`); diff --git a/scripts/src/platform-infra-gitea-github-sync-server.mjs b/scripts/src/platform-infra-gitea-github-sync-server.mjs index a03e60c3..b7e0dd6a 100644 --- a/scripts/src/platform-infra-gitea-github-sync-server.mjs +++ b/scripts/src/platform-infra-gitea-github-sync-server.mjs @@ -751,13 +751,13 @@ export async function syncRepository(repo, delivery, runtime) { let requestedObject = await executeResult(["git", "-C", work, "cat-file", "-e", `${delivery.requestedCommit}^{commit}`]); if (requestedObject.status !== 0) { const branchFetch = await executeResult([ - "git", "-C", work, "-c", `http.extraHeader=${runtime.githubAuthHeader}`, + "git", "-C", work, "-c", `http.extraHeader=${runtime.githubAuthHeaders[repo.key]}`, "fetch", "--no-tags", repo.upstream.cloneUrl, `+refs/heads/${repo.upstream.branch}:refs/remotes/github/${repo.upstream.branch}`, ]); requestedObject = await executeResult(["git", "-C", work, "cat-file", "-e", `${delivery.requestedCommit}^{commit}`]); if (requestedObject.status !== 0) { - const exactFetch = await executeResult(["git", "-C", work, "-c", `http.extraHeader=${runtime.githubAuthHeader}`, "fetch", "--no-tags", repo.upstream.cloneUrl, `${delivery.requestedCommit}:refs/remotes/github/delivery`]); + const exactFetch = await executeResult(["git", "-C", work, "-c", `http.extraHeader=${runtime.githubAuthHeaders[repo.key]}`, "fetch", "--no-tags", repo.upstream.cloneUrl, `${delivery.requestedCommit}:refs/remotes/github/delivery`]); if (exactFetch.status !== 0) { return { ok: false, @@ -931,14 +931,19 @@ export function retryDelayMs(syncAttempt, initialDelayMs, maxDelayMs) { } export function loadRuntimeConfig() { - const githubToken = requiredEnv("GITHUB_TOKEN"); const giteaUsername = requiredEnv("GITEA_USERNAME"); const giteaPassword = requiredEnv("GITEA_PASSWORD"); + const repos = JSON.parse(readFileSync(process.env.UNIDESK_GITEA_REPOS_PATH || "/etc/gitea-github-sync/repos.json", "utf8")); + const defaultTokenEnv = requiredEnv("UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV"); + const githubAuthHeaders = Object.fromEntries(repos.map((repo) => { + const token = requiredEnv(repo.githubCredential?.tokenEnv || defaultTokenEnv); + return [repo.key, `Authorization: Basic ${Buffer.from(`x-access-token:${token}`).toString("base64")}`]; + })); return { port: Number.parseInt(process.env.UNIDESK_GITEA_WEBHOOK_PORT || "8080", 10), path: process.env.UNIDESK_GITEA_WEBHOOK_PATH || "/_unidesk/github-to-gitea", responseBudgetMs: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_RESPONSE_BUDGET_MS"), - repos: JSON.parse(readFileSync(process.env.UNIDESK_GITEA_REPOS_PATH || "/etc/gitea-github-sync/repos.json", "utf8")), + repos, webhookSecret: requiredEnv("GITHUB_WEBHOOK_SECRET"), giteaBaseUrl: requiredEnv("UNIDESK_GITEA_SERVICE_BASE_URL").replace(/\/+$/u, ""), inbox: { @@ -958,7 +963,7 @@ export function loadRuntimeConfig() { }, shutdownGraceMs: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_SHUTDOWN_GRACE_MS"), maxBodyBytes: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_MAX_BODY_BYTES"), - githubAuthHeader: `Authorization: Basic ${Buffer.from(`x-access-token:${githubToken}`).toString("base64")}`, + githubAuthHeaders, giteaAuthHeader: `Authorization: Basic ${Buffer.from(`${giteaUsername}:${giteaPassword}`).toString("base64")}`, log, run, diff --git a/scripts/src/platform-infra-gitea-remote.sh b/scripts/src/platform-infra-gitea-remote.sh index 94bf7ff5..0d6c4685 100644 --- a/scripts/src/platform-infra-gitea-remote.sh +++ b/scripts/src/platform-infra-gitea-remote.sh @@ -52,13 +52,33 @@ run_apply() { fi fi if [ "$UNIDESK_GITEA_WEBHOOK_SYNC_ENABLED" = "1" ] && [ "$UNIDESK_GITEA_DRY_RUN" != "1" ]; then + github_secret_args="" + old_ifs="$IFS" + IFS=',' + for mapping in $UNIDESK_GITEA_GITHUB_SECRET_ENV_MAP; do + secret_key=${mapping%%=*} + env_name=${mapping#*=} + eval "secret_value=\${$env_name-}" + if [ -z "$secret_value" ]; then + printf '%s\n' "missing GitHub credential environment for Secret key $secret_key" >"$tmp/webhook-secret.err" + webhook_secret_rc=1 + break + fi + secret_file="$tmp/github-secret-$secret_key" + printf '%s' "$secret_value" >"$secret_file" + chmod 600 "$secret_file" + github_secret_args="$github_secret_args --from-file=$secret_key=$secret_file" + done + IFS="$old_ifs" + if [ "${webhook_secret_rc:-0}" -eq 0 ]; then kubectl -n "$UNIDESK_GITEA_NAMESPACE" create secret generic "$UNIDESK_GITEA_WEBHOOK_SECRET_NAME" \ - --from-literal="$UNIDESK_GITEA_GITHUB_SECRET_KEY=$UNIDESK_GITEA_GITHUB_TOKEN" \ + $github_secret_args \ --from-literal=gitea-username="$UNIDESK_GITEA_ADMIN_USERNAME" \ --from-literal=gitea-password="$UNIDESK_GITEA_ADMIN_PASSWORD" \ --from-literal=github-webhook-secret="$UNIDESK_GITEA_GITHUB_WEBHOOK_SECRET" \ --dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_GITEA_FIELD_MANAGER" -f - >"$tmp/webhook-secret.out" 2>"$tmp/webhook-secret.err" webhook_secret_rc=$? + fi else webhook_secret_rc=0 : >"$tmp/webhook-secret.out" @@ -518,11 +538,12 @@ for i, repo in enumerate(repos): sync_gitops_from_upstream = repo["gitops"].get("flushDisposition") != "gitea-writeback" branches = [source_branch] + ([] if not sync_gitops_from_upstream or gitops_branch == source_branch or gitops_branch == "not-a-gitops-branch" else [gitops_branch]) work = f"$tmp/repo-{i}.git" + token_env = (repo.get("githubCredential") or {}).get("tokenEnv") or os.environ.get("UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV", "") gitea_write_url = base_url + urllib.parse.urlparse(repo["gitea"]["readUrl"]).path script += [ f"mkdir -p {shlex.quote(work)}", f"git init --bare {shlex.quote(work)} >$tmp/{key}.init.out 2>$tmp/{key}.init.err; printf '%s' \"$?\" >$tmp/{key}.init.rc", - f"git -C {shlex.quote(work)} -c http.extraHeader=\"$GITHUB_AUTH_HEADER\" fetch --prune {shlex.quote(repo['upstream']['cloneUrl'])} " + " ".join(shlex.quote(f"+refs/heads/{b}:refs/remotes/github/{b}") for b in branches) + f" >$tmp/{key}.fetch.out 2>$tmp/{key}.fetch.err; fetch_rc=$?; printf '%s' \"$fetch_rc\" >$tmp/{key}.fetch.rc", + f"repo_token=$(printenv {shlex.quote(token_env)}); repo_basic=$(printf 'x-access-token:%s' \"$repo_token\" | base64 | tr -d '\\n'); git -C {shlex.quote(work)} -c http.extraHeader=\"Authorization: Basic $repo_basic\" fetch --prune {shlex.quote(repo['upstream']['cloneUrl'])} " + " ".join(shlex.quote(f"+refs/heads/{b}:refs/remotes/github/{b}") for b in branches) + f" >$tmp/{key}.fetch.out 2>$tmp/{key}.fetch.err; fetch_rc=$?; unset repo_token repo_basic; printf '%s' \"$fetch_rc\" >$tmp/{key}.fetch.rc", f"if [ \"$fetch_rc\" -eq 0 ]; then sha=$(git -C {shlex.quote(work)} rev-parse refs/remotes/github/{shlex.quote(source_branch)} 2>$tmp/{key}.revparse.err); revparse_rc=$?; printf '%s\\n' \"$sha\" >$tmp/{key}.revparse.out; else sha=''; revparse_rc=1; : >$tmp/{key}.revparse.out; printf '%s\\n' 'fetch failed; revparse skipped' >$tmp/{key}.revparse.err; fi; printf '%s' \"$revparse_rc\" >$tmp/{key}.revparse.rc", f"if [ \"$revparse_rc\" -eq 0 ]; then snapshot_ref={shlex.quote(repo['snapshot']['prefix'])}/$sha; git -C {shlex.quote(work)} -c http.extraHeader=\"$GITEA_AUTH_HEADER\" push --force {shlex.quote(gitea_write_url)} $sha:refs/heads/{shlex.quote(source_branch)} $sha:$snapshot_ref >$tmp/{key}.push.out 2>$tmp/{key}.push.err; push_rc=$?; else snapshot_ref=''; push_rc=1; : >$tmp/{key}.push.out; printf '%s\\n' 'revparse failed; push skipped' >$tmp/{key}.push.err; fi; printf '%s' \"$push_rc\" >$tmp/{key}.push.rc", ] @@ -537,15 +558,8 @@ for i, repo in enumerate(repos): open(sys.argv[2], "w", encoding="utf-8").write("\n".join(script) + "\n") json.dump(meta, open(sys.argv[3], "w", encoding="utf-8"), ensure_ascii=False, indent=2) PY - github_basic="$(python3 - <<'PY' -import base64, os -raw = f"x-access-token:{os.environ['UNIDESK_GITEA_GITHUB_TOKEN']}".encode() -print(base64.b64encode(raw).decode()) -PY -)" - GITHUB_AUTH_HEADER="Authorization: Basic $github_basic" GITEA_AUTH_HEADER="Authorization: Basic $basic" - export GITHUB_AUTH_HEADER GITEA_AUTH_HEADER + export GITEA_AUTH_HEADER unset GIT_SSH GIT_SSH_COMMAND SSH_AUTH_SOCK export GIT_TERMINAL_PROMPT=0 export GIT_CONFIG_NOSYSTEM=1 @@ -685,7 +699,6 @@ run_mirror_webhook_apply() { python3 - "$tmp/repos.json" <<'PY' import json, os, sys, urllib.error, urllib.request repos = json.load(open(sys.argv[1], encoding="utf-8")) -token = os.environ["UNIDESK_GITEA_GITHUB_TOKEN"] url = os.environ["UNIDESK_GITEA_WEBHOOK_PUBLIC_URL"] secret = os.environ["UNIDESK_GITEA_GITHUB_WEBHOOK_SECRET"] events = [item for item in os.environ.get("UNIDESK_GITEA_WEBHOOK_EVENTS", "push").split(",") if item] @@ -707,6 +720,8 @@ def request(method, api_path, payload=None, expected=(200, 201, 204), tolerate=( rows = [] for repo in repos: repository = repo["upstream"]["repository"] + token_env = (repo.get("githubCredential") or {}).get("tokenEnv") or os.environ["UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV"] + token = os.environ[token_env] list_result = request("GET", f"/repos/{repository}/hooks") hooks = [] try: @@ -771,7 +786,6 @@ bridge_log_err_path = sys.argv[10] inbox_status_path = sys.argv[11] inbox_status_err_path = sys.argv[12] hook_topology_path = sys.argv[13] -token = os.environ["UNIDESK_GITEA_GITHUB_TOKEN"] url = os.environ["UNIDESK_GITEA_WEBHOOK_PUBLIC_URL"] try: hook_topology = json.load(open(hook_topology_path, encoding="utf-8")) @@ -990,6 +1004,8 @@ for line in text(bridge_log_path, 12000).splitlines(): rows = [] for repo in repos: repository = repo["upstream"]["repository"] + topology_spec = next((item for item in topology_repositories if isinstance(item, dict) and item.get("repository") == repository), {}) + token = os.environ[topology_spec.get("githubTokenEnv") or os.environ["UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV"]] branch = repo["upstream"]["branch"] result = request(f"/repos/{repository}/hooks") hooks_result = parse_github_hooks_response(result.get("ok"), result.get("status"), result.get("body")) diff --git a/scripts/src/platform-infra-gitea.ts b/scripts/src/platform-infra-gitea.ts index 9402413c..a86dff9c 100644 --- a/scripts/src/platform-infra-gitea.ts +++ b/scripts/src/platform-infra-gitea.ts @@ -36,6 +36,7 @@ import { } from "./platform-infra-gitea-render"; import { GITEA_CONFIG_LABEL, + githubTokenEnvNameForSecretKey, readGiteaConfig, resolveTarget, targetWebhookSync, @@ -83,7 +84,7 @@ interface MirrorWebhookStatusOptions extends MirrorOptions { interface MirrorSecrets { adminUsername: string; adminPassword: string; - githubToken: string; + githubTokens: Record<string, string>; webhookSecret: string; } @@ -188,7 +189,10 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Reco const policy = policyChecks(gitea, target, manifest); if (!policy.every((check) => check.ok)) return { ok: false, action: "platform-infra-gitea-apply", mode: "policy-blocked", mutation: false, policy }; const frpcSecret = prepareGiteaFrpcSecret(gitea, target); - const secrets = gitea.sourceAuthority.webhookSync.enabled && !options.dryRun ? ensureMirrorSecrets(gitea, true, true) : undefined; + const targetRepos = repositoriesForTarget(gitea, target); + const secrets = gitea.sourceAuthority.webhookSync.enabled && !options.dryRun + ? ensureMirrorSecrets(gitea, target, targetRepos, true, true, true) + : undefined; const remote = remoteScript("apply", gitea, target, manifest, options, { frpcSecret, secrets }); const result = await capture(config, target.route, ["sh"], remote.script); const parsed = parseJsonOutput(result.stdout); @@ -316,7 +320,7 @@ function mirrorPlan(options: CommonOptions): Record<string, unknown> { sourceAuthority: sourceAuthoritySummary(gitea, target), responsibilities: gitea.sourceAuthority.responsibilities.map(responsibilitySummary), repositories: repositoriesForTarget(gitea, target).map((repo) => repositorySummary(gitea, repo)), - credentials: credentialSummaries(gitea), + credentials: credentialSummaries(gitea, targetRepos), next: mirrorReadOnlyNextCommands(target.id), }; } @@ -324,11 +328,26 @@ function mirrorPlan(options: CommonOptions): Record<string, unknown> { async function mirrorStatus(config: UniDeskConfig, options: CommonOptions & { repoKey?: string | null }): Promise<Record<string, unknown>> { const gitea = readGiteaConfig(); const target = resolveCommandTarget(gitea, options.targetId); + const selectedRepos = selectedRepositories(gitea, target, options.repoKey ?? null); + const credentials = credentialSummaries(gitea, selectedRepos); + const blockers = credentialBlockers(credentials); + if (blockers.length > 0) { + return { + ok: false, + action: "platform-infra-gitea-mirror-status", + mutation: false, + target: targetSummary(target), + serviceHealth: { ok: false }, + sourceAuthority: { ...sourceAuthoritySummary(gitea, target), mirrorReady: false, stageReady: false }, + repositories: selectedRepos.map((repo) => repositorySummary(gitea, repo)), + credentials, + error: { code: "github-credential-unavailable", blockers, valuesPrinted: false }, + next: mirrorReadOnlyNextCommands(target.id), + }; + } const health = await validate(config, options); const healthValidation = record(health.validation); - const credentials = credentialSummaries(gitea); - const secrets = ensureMirrorSecrets(gitea, false, false); - const selectedRepos = selectedRepositories(gitea, target, options.repoKey ?? null); + const secrets = ensureMirrorSecrets(gitea, target, selectedRepos, false, false, false); const result = await capture(config, target.route, ["sh"], remoteScript("mirror-status", gitea, target, "", { ...options, confirm: false, dryRun: true, wait: false }, { repos: selectedRepos, secrets }).script); const parsed = parseJsonOutput(result.stdout); const repositories = arrayRecords(record(parsed).repositories); @@ -362,10 +381,8 @@ async function mirrorBootstrap(config: UniDeskConfig, options: MirrorOptions): P const target = resolveCommandTarget(gitea, options.targetId); const repos = selectedRepositories(gitea, target, options.repoKey); if (!options.confirm) { - const credentials = credentialSummaries(gitea); - const blockers = credentials - .filter((item) => item.id === "github-upstream" && (item.present !== true || item.requiredKeysPresent !== true)) - .map((item) => String(item.id)); + const credentials = credentialSummaries(gitea, repos); + const blockers = credentialBlockers(credentials); const repoFlag = options.repoKey === null ? "" : ` --repo ${options.repoKey}`; return { ok: blockers.length === 0, @@ -382,7 +399,7 @@ async function mirrorBootstrap(config: UniDeskConfig, options: MirrorOptions): P }, }; } - const secrets = ensureMirrorSecrets(gitea, true, true); + const secrets = ensureMirrorSecrets(gitea, target, repos, false, true, true); const result = await capture(config, target.route, ["sh"], remoteScript("mirror-bootstrap", gitea, target, "", { ...options, dryRun: false, wait: false }, { repos, secrets }).script); const parsed = parseJsonOutput(result.stdout); return { @@ -392,7 +409,7 @@ async function mirrorBootstrap(config: UniDeskConfig, options: MirrorOptions): P mode: "confirmed", target: targetSummary(target), repositories: arrayRecords(record(parsed).repositories), - credentials: credentialSummaries(gitea), + credentials: credentialSummaries(gitea, repos), remote: parsed ?? compactCapture(result, { full: true }), next: mirrorReadOnlyNextCommands(target.id), }; @@ -414,7 +431,7 @@ async function mirrorSync(config: UniDeskConfig, options: MirrorOptions): Promis } if (!options.confirm) return { ok: false, action: "platform-infra-gitea-mirror-sync", mutation: false, mode: "missing-confirm", error: "mirror sync requires --confirm" }; const repos = selectedRepositories(gitea, target, options.repoKey); - const secrets = ensureMirrorSecrets(gitea, false, false); + const secrets = ensureMirrorSecrets(gitea, target, repos, false, false, false); const result = await capture(config, target.route, ["sh"], remoteScript("mirror-sync", gitea, target, "", { ...options, dryRun: false, wait: false }, { repos, secrets }).script); const parsed = parseJsonOutput(result.stdout); return { @@ -435,7 +452,7 @@ async function mirrorWebhookApply(config: UniDeskConfig, options: MirrorOptions) if (!gitea.sourceAuthority.webhookSync.enabled) return { ok: false, action: "platform-infra-gitea-mirror-webhook-apply", mutation: false, error: "sourceAuthority.webhookSync.enabled is false" }; if (!options.confirm) return { ok: false, action: "platform-infra-gitea-mirror-webhook-apply", mutation: false, mode: "missing-confirm", error: "mirror webhook apply requires --confirm" }; const repos = selectedRepositories(gitea, target, options.repoKey); - const secrets = ensureMirrorSecrets(gitea, false, false); + const secrets = ensureMirrorSecrets(gitea, target, repos, false, false, false); const result = await capture(config, target.route, ["sh"], remoteScript("mirror-webhook-apply", gitea, target, "", { ...options, dryRun: false, wait: false }, { repos, secrets }).script); const parsed = parseJsonOutput(result.stdout); return { @@ -454,7 +471,7 @@ async function mirrorWebhookStatus(config: UniDeskConfig, options: MirrorWebhook const gitea = readGiteaConfig(); const target = resolveCommandTarget(gitea, options.targetId); const repos = selectedRepositories(gitea, target, options.repoKey); - const secrets = ensureMirrorSecrets(gitea, false, false); + const secrets = ensureMirrorSecrets(gitea, target, repos, false, false, false); const result = await capture(config, target.route, ["sh"], remoteScript("mirror-webhook-status", gitea, target, "", { ...options, confirm: false, dryRun: true, wait: false }, { repos, secrets }).script); const parsed = parseJsonOutput(result.stdout); const repositories = arrayRecords(record(parsed).repositories); @@ -473,7 +490,7 @@ async function mirrorWebhookStatus(config: UniDeskConfig, options: MirrorWebhook }; } -function renderManifest(gitea: GiteaConfig, target: GiteaTarget): string { +export function renderManifest(gitea: GiteaConfig, target: GiteaTarget): string { const app = gitea.app; const image = `${app.image.repository}:${app.image.tag}`; const labels = ` app.kubernetes.io/name: ${app.name} @@ -649,6 +666,8 @@ function renderGithubSyncManifest(gitea: GiteaConfig, target: GiteaTarget): stri const ownsHookReconcile = sync.hookReconcile.enabled && sync.hookReconcile.ownerTargetId.toLowerCase() === target.id.toLowerCase(); const hookTopologyJson = ownsHookReconcile ? JSON.stringify(githubHookTopology(gitea), null, 2) : ""; const hookReconcilerSource = ownsHookReconcile ? readFileSync(githubHookReconcilerFile, "utf8") : ""; + const bridgeTokenEnv = githubTokenSecretEnv(gitea, target, false); + const hookTokenEnv = githubTokenSecretEnv(gitea, target, true); const hookReconcilerConfig = ownsHookReconcile ? ` hook-topology.json: | ${indentBlock(hookTopologyJson, 4)} platform_infra_gitea_hook_reconciler.py: | @@ -670,11 +689,9 @@ ${indentBlock(hookReconcilerSource, 4)} env: - name: UNIDESK_GITEA_TARGET_ID value: ${yamlQuote(target.id)} - - name: GITHUB_TOKEN - valueFrom: - secretKeyRef: - name: ${sync.bridge.secretName} - key: ${gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key} + - name: UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV + value: ${yamlQuote(githubTokenEnvName(gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key))} +${hookTokenEnv} - name: GITHUB_WEBHOOK_SECRET valueFrom: secretKeyRef: @@ -818,11 +835,9 @@ spec: value: ${yamlQuote(String(sync.bridge.inbox.cleanupIntervalMs))} - name: UNIDESK_GITEA_WEBHOOK_SHUTDOWN_GRACE_MS value: ${yamlQuote(String(sync.bridge.shutdownGraceMs))} - - name: GITHUB_TOKEN - valueFrom: - secretKeyRef: - name: ${sync.bridge.secretName} - key: ${gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key} + - name: UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV + value: ${yamlQuote(githubTokenEnvName(gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key))} +${bridgeTokenEnv} - name: GITEA_USERNAME valueFrom: secretKeyRef: @@ -984,11 +999,9 @@ ${hookRecoveryStateEnv} - name: UNIDESK_GITEA_WEBHOOK_INBOX_MAX_BYTES value: ${yamlQuote(String(sync.bridge.inbox.cleanupIntervalMs))} - name: UNIDESK_GITEA_WEBHOOK_SHUTDOWN_GRACE_MS value: ${yamlQuote(String(sync.bridge.shutdownGraceMs))} - - name: GITHUB_TOKEN - valueFrom: - secretKeyRef: - name: ${sync.bridge.secretName} - key: ${gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key} + - name: UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV + value: ${yamlQuote(githubTokenEnvName(gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key))} +${bridgeTokenEnv} - name: GITEA_USERNAME valueFrom: secretKeyRef: @@ -1099,6 +1112,10 @@ function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstra UNIDESK_GITEA_GITHUB_PROXY_ENABLED: gitea.sourceAuthority.githubProxy.enabled ? "1" : "0", UNIDESK_GITEA_GITHUB_PROXY_URL: gitea.sourceAuthority.githubProxy.url, UNIDESK_GITEA_GITHUB_SECRET_KEY: gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key, + UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV: githubTokenEnvName(gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key), + UNIDESK_GITEA_GITHUB_SECRET_ENV_MAP: githubCredentialsForConsumers(gitea, target, repositoriesForTarget(gitea, target), true) + .map((credential) => `${credential.gitFetchCredential.secretRef.key}=${githubTokenEnvName(credential.gitFetchCredential.secretRef.key)}`) + .join(","), UNIDESK_GITEA_NO_PROXY: gitea.sourceAuthority.githubProxy.noProxy.join(","), UNIDESK_GITEA_FRPC_ENABLED: frpcExposure === null ? "0" : "1", UNIDESK_GITEA_FRPC_DEPLOYMENT_NAME: frpcExposure?.frpc.deploymentName ?? gitea.app.publicExposure.frpc.deploymentName, @@ -1120,7 +1137,7 @@ function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstra if (params.secrets !== undefined) { env.UNIDESK_GITEA_ADMIN_USERNAME = params.secrets.adminUsername; env.UNIDESK_GITEA_ADMIN_PASSWORD = params.secrets.adminPassword; - env.UNIDESK_GITEA_GITHUB_TOKEN = params.secrets.githubToken; + for (const [key, token] of Object.entries(params.secrets.githubTokens)) env[githubTokenEnvName(key)] = token; env.UNIDESK_GITEA_GITHUB_WEBHOOK_SECRET = params.secrets.webhookSecret; } const exports = Object.entries(env).map(([key, value]) => `export ${key}=${shQuote(value)}`).join("\n"); @@ -1445,7 +1462,14 @@ function githubHookTopology(gitea: GiteaConfig): Record<string, unknown> { repoKeys: [...item.repoKeys].sort(), branches: [...item.branches].sort(), })); - return { repository, desiredUrls: desiredHooks.map((item) => item.url).sort(), desiredHooks }; + const repo = gitea.sourceAuthority.repositories.find((item) => item.upstream.repository === repository); + const credential = repo === undefined ? gitea.sourceAuthority.credentials.github : githubCredentialForRepo(gitea, repo); + return { + repository, + githubTokenEnv: githubTokenEnvName(credential.gitFetchCredential.secretRef.key), + desiredUrls: desiredHooks.map((item) => item.url).sort(), + desiredHooks, + }; }), }; } @@ -1521,6 +1545,7 @@ function repositorySummary(gitea: GiteaConfig, repo: GiteaMirrorRepository): Rec } function remoteRepoSpec(repo: GiteaMirrorRepository): Record<string, unknown> { + const github = repo.credentialOverride?.github; return { key: repo.key, upstream: repo.upstream, @@ -1528,6 +1553,10 @@ function remoteRepoSpec(repo: GiteaMirrorRepository): Record<string, unknown> { gitops: repo.gitops, snapshot: repo.snapshot, legacyGitMirror: repo.legacyGitMirror, + githubCredential: github === undefined ? null : { + secretKey: github.gitFetchCredential.secretRef.key, + tokenEnv: githubTokenEnvName(github.gitFetchCredential.secretRef.key), + }, }; } @@ -1577,41 +1606,67 @@ function webhookSyncSummary(gitea: GiteaConfig, target: GiteaTarget): Record<str }; } -function credentialSummaries(gitea: GiteaConfig): Array<Record<string, unknown>> { +function credentialSummaries(gitea: GiteaConfig, repositories = gitea.sourceAuthority.repositories): Array<Record<string, unknown>> { const adminPath = credentialPath(gitea, gitea.sourceAuthority.credentials.admin.sourceRef); const github = gitea.sourceAuthority.credentials.github; const githubPath = credentialPath(gitea, github.sourceRef); const adminLinePair = existsSync(adminPath) ? parseLinePairCredential(adminPath, gitea.sourceAuthority.credentials.admin) : { username: null, password: null }; const githubEnv = existsSync(githubPath) ? parseGithubCredentialFile(githubPath, github) : {}; - return [ + const summaries: Array<Record<string, unknown>> = [ { id: "gitea-admin", - format: gitea.sourceAuthority.credentials.admin.format, sourceRef: gitea.sourceAuthority.credentials.admin.sourceRef, - sourcePath: adminPath, present: existsSync(adminPath), requiredKeysPresent: Boolean(adminLinePair.username) && Boolean(adminLinePair.password), fingerprint: fingerprintSecretParts([adminLinePair.username, adminLinePair.password]), + permissionResult: { status: "not-applicable", error: null }, valuesPrinted: false, }, { id: "github-upstream", - transport: github.transport, sourceRef: github.sourceRef, - sourcePath: githubPath, present: existsSync(githubPath), requiredKeysPresent: Boolean(githubEnv[github.sourceKey]), fingerprint: fingerprintKeys(githubEnv, [github.sourceKey]), + permissionResult: existsSync(githubPath) && Boolean(githubEnv[github.sourceKey]) + ? { status: "not-probed", error: null } + : { status: "blocked-missing-credential", error: "credential material is absent" }, valuesPrinted: false, }, ]; + for (const repo of repositories) { + const githubOverride = repo.credentialOverride?.github; + if (githubOverride === undefined) continue; + const sourcePath = credentialPath(gitea, githubOverride.sourceRef); + const values = existsSync(sourcePath) ? parseGithubCredentialFile(sourcePath, githubOverride) : {}; + summaries.push({ + id: `github-upstream:${repo.key}`, + repository: repo.upstream.repository, + sourceRef: githubOverride.sourceRef, + present: existsSync(sourcePath), + requiredKeysPresent: Boolean(values[githubOverride.sourceKey]), + fingerprint: fingerprintKeys(values, [githubOverride.sourceKey]), + permissionResult: existsSync(sourcePath) && Boolean(values[githubOverride.sourceKey]) + ? { status: "not-probed", permissions: githubOverride.permissions, error: null } + : { status: "blocked-missing-credential", permissions: githubOverride.permissions, error: "credential material is absent" }, + valuesPrinted: false, + }); + } + return summaries; } function credentialBlockers(credentials: Array<Record<string, unknown>>): string[] { return credentials.filter((item) => item.present !== true || item.requiredKeysPresent !== true).map((item) => String(item.id)); } -function ensureMirrorSecrets(gitea: GiteaConfig, createAdmin: boolean, createWebhookSecret: boolean): MirrorSecrets { +function ensureMirrorSecrets( + gitea: GiteaConfig, + target: GiteaTarget, + repositories: GiteaMirrorRepository[], + includeHookOwnerCredentials: boolean, + createAdmin: boolean, + createWebhookSecret: boolean, +): MirrorSecrets { const admin = gitea.sourceAuthority.credentials.admin; const adminPath = credentialPath(gitea, admin.sourceRef); if (!existsSync(adminPath)) { @@ -1623,17 +1678,59 @@ function ensureMirrorSecrets(gitea: GiteaConfig, createAdmin: boolean, createWeb chmodSync(adminPath, 0o600); } const adminLinePair = parseLinePairCredential(adminPath, admin); - const github = gitea.sourceAuthority.credentials.github; - const githubPath = credentialPath(gitea, github.sourceRef); - if (!existsSync(githubPath)) throw new Error(`${github.sourceRef} is missing; cannot sync GitHub upstream`); - const githubEnv = parseGithubCredentialFile(githubPath, github); + const githubTokens: Record<string, string> = {}; + for (const github of githubCredentialsForConsumers(gitea, target, repositories, includeHookOwnerCredentials)) { + const githubPath = credentialPath(gitea, github.sourceRef); + if (!existsSync(githubPath)) throw new Error(`${github.sourceRef} is missing; cannot sync GitHub upstream`); + const githubEnv = parseGithubCredentialFile(githubPath, github); + const githubToken = githubEnv[github.sourceKey]; + if (!githubToken) throw new Error(`${github.sourceRef} must contain ${github.sourceKey}`); + githubTokens[github.gitFetchCredential.secretRef.key] = githubToken; + } const webhookSecret = ensureWebhookSecret(gitea, createWebhookSecret); const adminUsername = adminLinePair.username; const adminPassword = adminLinePair.password; - const githubToken = githubEnv[github.sourceKey]; if (!adminUsername || !adminPassword) throw new Error(`${admin.sourceRef} must contain username on line ${admin.usernameLine} and password on line ${admin.passwordLine}`); - if (!githubToken) throw new Error(`${github.sourceRef} must contain ${github.sourceKey}`); - return { adminUsername, adminPassword, githubToken, webhookSecret }; + return { adminUsername, adminPassword, githubTokens, webhookSecret }; +} + +function githubCredentialForRepo(gitea: GiteaConfig, repo: GiteaMirrorRepository): GiteaGithubCredential { + return repo.credentialOverride?.github ?? gitea.sourceAuthority.credentials.github; +} + +function githubCredentialsForTarget(gitea: GiteaConfig, target: GiteaTarget): GiteaGithubCredential[] { + const credentials = [gitea.sourceAuthority.credentials.github, ...repositoriesForTarget(gitea, target).flatMap((repo) => repo.credentialOverride?.github ?? [])]; + return [...new Map(credentials.map((credential) => [credential.gitFetchCredential.secretRef.key, credential])).values()]; +} + +function githubCredentialsForConsumers( + gitea: GiteaConfig, + target: GiteaTarget, + repositories: GiteaMirrorRepository[], + includeHookOwnerCredentials: boolean, +): GiteaGithubCredential[] { + const credentials = [gitea.sourceAuthority.credentials.github, ...repositories.flatMap((repo) => repo.credentialOverride?.github ?? [])]; + if (includeHookOwnerCredentials + && gitea.sourceAuthority.webhookSync.hookReconcile.ownerTargetId.toLowerCase() === target.id.toLowerCase()) { + credentials.push(...gitea.sourceAuthority.repositories.flatMap((repo) => { + const github = repo.credentialOverride?.github; + return github !== undefined && github.requiredFor.some((item) => item.startsWith("github-hooks-")) ? [github] : []; + })); + } + return [...new Map(credentials.map((credential) => [credential.gitFetchCredential.secretRef.key, credential])).values()]; +} + +function githubTokenEnvName(secretKey: string): string { + return githubTokenEnvNameForSecretKey(secretKey); +} + +function githubTokenSecretEnv(gitea: GiteaConfig, target: GiteaTarget, includeHookOwnerCredentials: boolean): string { + const secretName = targetWebhookSync(gitea, target).bridge.secretName; + return githubCredentialsForConsumers(gitea, target, repositoriesForTarget(gitea, target), includeHookOwnerCredentials).map((credential) => ` - name: ${githubTokenEnvName(credential.gitFetchCredential.secretRef.key)} + valueFrom: + secretKeyRef: + name: ${secretName} + key: ${credential.gitFetchCredential.secretRef.key}`).join("\n"); } function ensureWebhookSecret(gitea: GiteaConfig, createIfMissing: boolean): string { diff --git a/scripts/src/platform_infra_gitea_hook_reconciler.py b/scripts/src/platform_infra_gitea_hook_reconciler.py index 82a95f92..65e22540 100644 --- a/scripts/src/platform_infra_gitea_hook_reconciler.py +++ b/scripts/src/platform_infra_gitea_hook_reconciler.py @@ -602,7 +602,7 @@ def reconcile_repository(topology, repository_spec, token, secret, force_patch): return observation, observed -def reconcile_once(topology, token, secret, force_patch): +def reconcile_once(topology, secret, force_patch): started = time.monotonic() topology_ready = True recovery_ready = True @@ -624,6 +624,7 @@ def reconcile_once(topology, token, secret, force_patch): emit("github-hook-delivery-recovery-ledger-unavailable", ok=False, errorType=type(error).__name__, error=str(error)[:160]) for repository_spec in topology["repositories"]: try: + token = os.environ[repository_spec["githubTokenEnv"]] observation, observed_hooks = reconcile_repository(topology, repository_spec, token, secret, force_patch) if not observation["ready"]: topology_ready = False @@ -675,13 +676,12 @@ def main(): topology = load_topology(sys.argv[1]) if topology.get("ownerTargetId") != os.environ.get("UNIDESK_GITEA_TARGET_ID"): raise SystemExit("hook reconciler target is not the YAML owner") - token = os.environ["GITHUB_TOKEN"] secret = os.environ["GITHUB_WEBHOOK_SECRET"] signal.signal(signal.SIGTERM, stop) signal.signal(signal.SIGINT, stop) force_patch = True while not STOP.is_set(): - if reconcile_once(topology, token, secret, force_patch): + if reconcile_once(topology, secret, force_patch): force_patch = False STOP.wait(topology["reconcile"]["intervalMs"] / 1000) From 21aed20f624bb2cf0c627664954e6ff8cfc63999 Mon Sep 17 00:00:00 2001 From: Codex <codex@local> Date: Mon, 13 Jul 2026 12:37:46 +0200 Subject: [PATCH 13/21] fix: bound missing webhook credentials status --- .../R1.1_Task_Report.md | 3 +- .../src/platform-infra-gitea-config.test.ts | 36 +++++++++++++++++++ scripts/src/platform-infra-gitea.ts | 17 +++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/docs/MDTODO/details/selfmedia-production-delivery/R1.1_Task_Report.md b/docs/MDTODO/details/selfmedia-production-delivery/R1.1_Task_Report.md index 570bf680..469924ae 100644 --- a/docs/MDTODO/details/selfmedia-production-delivery/R1.1_Task_Report.md +++ b/docs/MDTODO/details/selfmedia-production-delivery/R1.1_Task_Report.md @@ -29,10 +29,11 @@ ## Target 原入口证据 - 已通过 `trans NC01:/root/.worktrees/unidesk/selfmedia-repo-github-authority` 执行全部源码、Git 和验证操作。 -- `bun test scripts/src/platform-infra-gitea-config.test.ts scripts/src/platform-infra-gitea-disclosure.test.ts`:8 项通过,0 项失败。 +- `bun test scripts/src/platform-infra-gitea-config.test.ts scripts/src/platform-infra-gitea-disclosure.test.ts`:9 项通过,0 项失败。 - `bun scripts/cli.ts platform-infra gitea plan --target NC01`:policy 为 `ok`。 - `git diff --check`、Node 语法、Python `py_compile`、Shell `sh -n`:通过。 - `mirror bootstrap --target NC01 --repo selfmedia-nc01 --dry-run`:`OK=false`,blocker 为 `github-upstream:selfmedia-nc01`,符合 fail-closed。 +- `mirror webhook status --target NC01 --repo selfmedia-nc01 --full`:在本地 credential blocker 处早返回,包含 `sourceRef`、`present=false`、`fingerprint=null`、`permissionResult=blocked-missing-credential` 与有界错误码;未访问运行面,JSON/stderr 均无 stack 或绝对 worktree 路径。 - `mirror bootstrap --target JD01 --dry-run`:`OK=true`,证明 JD01 不依赖 NC01 override。 - 专用凭据只读状态:`sourceRef=pikainc-selfmedia-gh-token.txt`、`presence=false`、`fingerprint=null`、`permissionResult=blocked-missing-credential`。 - 因 credential material 缺失,未执行权限 API 探测;不得声称 Contents、Metadata 或 Webhooks 权限已实际授予。 diff --git a/scripts/src/platform-infra-gitea-config.test.ts b/scripts/src/platform-infra-gitea-config.test.ts index c528960f..df7b4dd3 100644 --- a/scripts/src/platform-infra-gitea-config.test.ts +++ b/scripts/src/platform-infra-gitea-config.test.ts @@ -47,4 +47,40 @@ describe("platform-infra Gitea repository GitHub credentials", () => { expect(jd01Manifest).not.toContain(tokenEnv); expect(nc01Manifest).toContain(tokenEnv); }); + + test("keeps missing selfmedia webhook status bounded and stack-free", () => { + const result = Bun.spawnSync({ + cmd: [ + "bun", + "scripts/cli.ts", + "platform-infra", + "gitea", + "mirror", + "webhook", + "status", + "--target", + "NC01", + "--repo", + "selfmedia-nc01", + "--full", + ], + stdout: "pipe", + stderr: "pipe", + }); + const stdout = result.stdout.toString(); + const payload = JSON.parse(stdout); + const credentials = payload.data.credentials as Array<Record<string, unknown>>; + const selfmedia = credentials.find((item) => item.id === "github-upstream:selfmedia-nc01"); + + expect(payload.data.error.code).toBe("github-credential-unavailable"); + expect(selfmedia).toMatchObject({ + sourceRef: "pikainc-selfmedia-gh-token.txt", + present: false, + fingerprint: null, + valuesPrinted: false, + }); + expect(selfmedia).not.toHaveProperty("sourcePath"); + expect(stdout).not.toContain('"stack"'); + expect(result.stderr.toString()).not.toContain("platform-infra-gitea.ts:"); + }); }); diff --git a/scripts/src/platform-infra-gitea.ts b/scripts/src/platform-infra-gitea.ts index a86dff9c..b6cab6fa 100644 --- a/scripts/src/platform-infra-gitea.ts +++ b/scripts/src/platform-infra-gitea.ts @@ -298,6 +298,7 @@ async function mirrorWebhookCommand(config: UniDeskConfig, args: string[]): Prom if (action === "status") { const options = parseMirrorWebhookStatusOptions(args.slice(1)); const result = await mirrorWebhookStatus(config, options); + if (result.credentialBlocked === true) return result; if (options.raw) return rawMirrorWebhookStatus(result); const disclosure = buildMirrorWebhookStatusDisclosure(result, options); return options.full ? disclosure : renderMirrorWebhookStatus(result, disclosure); @@ -471,6 +472,22 @@ async function mirrorWebhookStatus(config: UniDeskConfig, options: MirrorWebhook const gitea = readGiteaConfig(); const target = resolveCommandTarget(gitea, options.targetId); const repos = selectedRepositories(gitea, target, options.repoKey); + const credentials = credentialSummaries(gitea, repos); + const blockers = credentialBlockers(credentials); + if (blockers.length > 0) { + return { + ok: false, + action: "platform-infra-gitea-mirror-webhook-status", + mutation: false, + credentialBlocked: true, + target: targetSummary(target), + webhook: webhookSyncSummary(gitea, target), + repositories: repos.map((repo) => repositorySummary(gitea, repo)), + credentials, + error: { code: "github-credential-unavailable", blockers, valuesPrinted: false }, + next: mirrorReadOnlyNextCommands(target.id), + }; + } const secrets = ensureMirrorSecrets(gitea, target, repos, false, false, false); const result = await capture(config, target.route, ["sh"], remoteScript("mirror-webhook-status", gitea, target, "", { ...options, confirm: false, dryRun: true, wait: false }, { repos, secrets }).script); const parsed = parseJsonOutput(result.stdout); From a539a4ff0517dd0b9c35b38b534892aada03f2d7 Mon Sep 17 00:00:00 2001 From: Codex <codex@local> Date: Mon, 13 Jul 2026 12:44:06 +0200 Subject: [PATCH 14/21] fix: render bounded webhook credential blockers --- .../R1.1_Task_Report.md | 3 +- .../src/platform-infra-gitea-config.test.ts | 55 +++++++++++++++++++ scripts/src/platform-infra-gitea-render.ts | 38 +++++++++++++ scripts/src/platform-infra-gitea.ts | 3 +- 4 files changed, 97 insertions(+), 2 deletions(-) diff --git a/docs/MDTODO/details/selfmedia-production-delivery/R1.1_Task_Report.md b/docs/MDTODO/details/selfmedia-production-delivery/R1.1_Task_Report.md index 469924ae..7f997041 100644 --- a/docs/MDTODO/details/selfmedia-production-delivery/R1.1_Task_Report.md +++ b/docs/MDTODO/details/selfmedia-production-delivery/R1.1_Task_Report.md @@ -29,11 +29,12 @@ ## Target 原入口证据 - 已通过 `trans NC01:/root/.worktrees/unidesk/selfmedia-repo-github-authority` 执行全部源码、Git 和验证操作。 -- `bun test scripts/src/platform-infra-gitea-config.test.ts scripts/src/platform-infra-gitea-disclosure.test.ts`:9 项通过,0 项失败。 +- `bun test scripts/src/platform-infra-gitea-config.test.ts scripts/src/platform-infra-gitea-disclosure.test.ts`:11 项通过,0 项失败。 - `bun scripts/cli.ts platform-infra gitea plan --target NC01`:policy 为 `ok`。 - `git diff --check`、Node 语法、Python `py_compile`、Shell `sh -n`:通过。 - `mirror bootstrap --target NC01 --repo selfmedia-nc01 --dry-run`:`OK=false`,blocker 为 `github-upstream:selfmedia-nc01`,符合 fail-closed。 - `mirror webhook status --target NC01 --repo selfmedia-nc01 --full`:在本地 credential blocker 处早返回,包含 `sourceRef`、`present=false`、`fingerprint=null`、`permissionResult=blocked-missing-credential` 与有界错误码;未访问运行面,JSON/stderr 均无 stack 或绝对 worktree 路径。 +- 默认 `mirror webhook status` compact 输出使用短表,按 `error.blockers` 精确匹配每个 credential/repository;带或不带 `--repo` 均只显示正确的 selfmedia blocker,`--full`/`--raw` 保持结构化。 - `mirror bootstrap --target JD01 --dry-run`:`OK=true`,证明 JD01 不依赖 NC01 override。 - 专用凭据只读状态:`sourceRef=pikainc-selfmedia-gh-token.txt`、`presence=false`、`fingerprint=null`、`permissionResult=blocked-missing-credential`。 - 因 credential material 缺失,未执行权限 API 探测;不得声称 Contents、Metadata 或 Webhooks 权限已实际授予。 diff --git a/scripts/src/platform-infra-gitea-config.test.ts b/scripts/src/platform-infra-gitea-config.test.ts index df7b4dd3..e141026d 100644 --- a/scripts/src/platform-infra-gitea-config.test.ts +++ b/scripts/src/platform-infra-gitea-config.test.ts @@ -83,4 +83,59 @@ describe("platform-infra Gitea repository GitHub credentials", () => { expect(stdout).not.toContain('"stack"'); expect(result.stderr.toString()).not.toContain("platform-infra-gitea.ts:"); }); + + test("renders missing selfmedia webhook status as compact text by default", () => { + const result = Bun.spawnSync({ + cmd: [ + "bun", + "scripts/cli.ts", + "platform-infra", + "gitea", + "mirror", + "webhook", + "status", + "--target", + "NC01", + "--repo", + "selfmedia-nc01", + ], + stdout: "pipe", + stderr: "pipe", + }); + const stdout = result.stdout.toString(); + + expect(stdout).toContain("PLATFORM-INFRA GITEA MIRROR WEBHOOK STATUS"); + expect(stdout).toContain("SOURCE_REF"); + expect(stdout).toContain("pikainc-selfmedia-gh-token.txt"); + expect(stdout).toContain("PERMISSION_RESULT"); + expect(stdout).toContain("github-upstream:selfmedia-nc01"); + expect(stdout.trimStart()).not.toStartWith("{"); + expect(stdout).not.toContain('"stack"'); + expect(result.stderr.toString()).not.toContain("platform-infra-gitea.ts:"); + }); + + test("matches compact blocker rows to their repository without --repo", () => { + const result = Bun.spawnSync({ + cmd: [ + "bun", + "scripts/cli.ts", + "platform-infra", + "gitea", + "mirror", + "webhook", + "status", + "--target", + "NC01", + ], + stdout: "pipe", + stderr: "pipe", + }); + const stdout = result.stdout.toString(); + + expect(stdout).toContain("pikainc/selfmedia"); + expect(stdout).toContain("pikainc-selfmedia-gh-token.txt"); + expect(stdout).toContain("github-upstream:selfmedia-nc01"); + expect(stdout).not.toContain("pikasTech/agentrun"); + expect(stdout.trimStart()).not.toStartWith("{"); + }); }); diff --git a/scripts/src/platform-infra-gitea-render.ts b/scripts/src/platform-infra-gitea-render.ts index 73271297..71d02822 100644 --- a/scripts/src/platform-infra-gitea-render.ts +++ b/scripts/src/platform-infra-gitea-render.ts @@ -107,6 +107,44 @@ export function renderMirrorWebhookApply(result: Record<string, unknown>): Rende ]); } +export function renderMirrorWebhookCredentialBlocked(result: Record<string, unknown>): RenderedCliResult { + const target = record(result.target); + const error = record(result.error); + const blockerIds = Array.isArray(error.blockers) ? error.blockers.map(String) : []; + const repositories = arrayRecords(result.repositories); + const credentials = arrayRecords(result.credentials); + const rows = blockerIds.map((blocker) => { + const credential = credentials.find((item) => stringValue(item.id) === blocker) ?? {}; + const permissionResult = record(credential.permissionResult); + const permissions = record(permissionResult.permissions); + const repoKey = blocker.includes(":") ? blocker.slice(blocker.indexOf(":") + 1) : ""; + const repository = repositories.find((item) => stringValue(item.key) === repoKey) ?? {}; + const permissionText = [ + `status=${stringValue(permissionResult.status)}`, + `contents=${stringValue(permissions.contents)}`, + `metadata=${stringValue(permissions.metadata)}`, + `webhooks=${stringValue(permissions.webhooks)}`, + ].join(","); + return [ + stringValue(target.id), + stringValue(credential.repository, stringValue(repository.upstreamRepository)), + stringValue(credential.sourceRef), + boolText(credential.present), + stringValue(credential.fingerprint), + permissionText, + blocker, + boolText(credential.valuesPrinted), + ]; + }); + const next = record(result.next); + return rendered(result, "platform-infra gitea mirror webhook status", [ + "PLATFORM-INFRA GITEA MIRROR WEBHOOK STATUS", + ...(rows.length === 0 ? ["-"] : table(["TARGET", "REPOSITORY", "SOURCE_REF", "PRESENT", "FINGERPRINT", "PERMISSION_RESULT", "BLOCKER", "VALUES_PRINTED"], rows)), + "", + `NEXT ${stringValue(next.webhookStatusFull)}`, + ]); +} + export function renderMirrorWebhookStatus(result: Record<string, unknown>, disclosure: Record<string, unknown>): RenderedCliResult { const repos = arrayRecords(result.repositories).map((repo) => { const delivery = record(repo.selectedPushDelivery); diff --git a/scripts/src/platform-infra-gitea.ts b/scripts/src/platform-infra-gitea.ts index b6cab6fa..03606bc9 100644 --- a/scripts/src/platform-infra-gitea.ts +++ b/scripts/src/platform-infra-gitea.ts @@ -30,6 +30,7 @@ import { renderMirrorStatus, renderMirrorSync, renderMirrorWebhookApply, + renderMirrorWebhookCredentialBlocked, renderMirrorWebhookStatus, renderPlan, renderStatus, @@ -298,7 +299,7 @@ async function mirrorWebhookCommand(config: UniDeskConfig, args: string[]): Prom if (action === "status") { const options = parseMirrorWebhookStatusOptions(args.slice(1)); const result = await mirrorWebhookStatus(config, options); - if (result.credentialBlocked === true) return result; + if (result.credentialBlocked === true) return options.full || options.raw ? result : renderMirrorWebhookCredentialBlocked(result); if (options.raw) return rawMirrorWebhookStatus(result); const disclosure = buildMirrorWebhookStatusDisclosure(result, options); return options.full ? disclosure : renderMirrorWebhookStatus(result, disclosure); From ac67ea5ca4655b9116627bedef5f8301b295fc41 Mon Sep 17 00:00:00 2001 From: Codex <codex@local> Date: Mon, 13 Jul 2026 12:47:26 +0200 Subject: [PATCH 15/21] docs: record R1.1 pull request evidence --- .../selfmedia-production-delivery/R1.1_Task_Report.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/MDTODO/details/selfmedia-production-delivery/R1.1_Task_Report.md b/docs/MDTODO/details/selfmedia-production-delivery/R1.1_Task_Report.md index 7f997041..6c627635 100644 --- a/docs/MDTODO/details/selfmedia-production-delivery/R1.1_Task_Report.md +++ b/docs/MDTODO/details/selfmedia-production-delivery/R1.1_Task_Report.md @@ -51,6 +51,14 @@ - 未手工 apply、Secret sync、PipelineRun、Argo sync/refresh、rollout 或 cutover。 - 未触发 PaC 或任何部署,只保留只读运行面证据。 +## Git 与 PR 交付 + +- 源码提交:`89b618de`。 +- bounded status 修复:`21aed20f`。 +- compact blocker renderer 修复:`a539a4ff`。 +- 中文 PR:`pikasTech/unidesk#1926`。 +- 未自行合并 PR。 + ## 当前结论 - R1.1 配置、schema、renderer、Secret、bridge、observer 与受控 fetch 的源码能力已落地并通过轻量验证。 From c04e23a355de03082f55ada602eb45ad23d60584 Mon Sep 17 00:00:00 2001 From: Codex <codex@local> Date: Mon, 13 Jul 2026 13:40:18 +0200 Subject: [PATCH 16/21] test: isolate repository credential status fixtures --- .../R1.1_Task_Report.md | 14 +-- .../src/platform-infra-gitea-config.test.ts | 108 +++++++----------- scripts/src/platform-infra-gitea.ts | 36 +++--- 3 files changed, 73 insertions(+), 85 deletions(-) diff --git a/docs/MDTODO/details/selfmedia-production-delivery/R1.1_Task_Report.md b/docs/MDTODO/details/selfmedia-production-delivery/R1.1_Task_Report.md index 6c627635..98de4f55 100644 --- a/docs/MDTODO/details/selfmedia-production-delivery/R1.1_Task_Report.md +++ b/docs/MDTODO/details/selfmedia-production-delivery/R1.1_Task_Report.md @@ -32,12 +32,12 @@ - `bun test scripts/src/platform-infra-gitea-config.test.ts scripts/src/platform-infra-gitea-disclosure.test.ts`:11 项通过,0 项失败。 - `bun scripts/cli.ts platform-infra gitea plan --target NC01`:policy 为 `ok`。 - `git diff --check`、Node 语法、Python `py_compile`、Shell `sh -n`:通过。 -- `mirror bootstrap --target NC01 --repo selfmedia-nc01 --dry-run`:`OK=false`,blocker 为 `github-upstream:selfmedia-nc01`,符合 fail-closed。 -- `mirror webhook status --target NC01 --repo selfmedia-nc01 --full`:在本地 credential blocker 处早返回,包含 `sourceRef`、`present=false`、`fingerprint=null`、`permissionResult=blocked-missing-credential` 与有界错误码;未访问运行面,JSON/stderr 均无 stack 或绝对 worktree 路径。 -- 默认 `mirror webhook status` compact 输出使用短表,按 `error.blockers` 精确匹配每个 credential/repository;带或不带 `--repo` 均只显示正确的 selfmedia blocker,`--full`/`--raw` 保持结构化。 +- `mirror bootstrap --target NC01 --repo selfmedia-nc01 --dry-run --full`:`OK=true`、`blockers=[]`;全局与 repository override 均只显示脱敏 presence/fingerprint,不显示 token。 +- `mirror webhook status --target NC01 --repo selfmedia-nc01 --full`:可读取私有仓库 HEAD `6e6a95297c3c153ab269410a079a997adc345774`;当前 `hookReady=false`、`driftTypes=[desired-url-missing]`、有界错误为 `github-hook-missing`。 +- 三个 missing-credential 回归改用 synthetic blocker 输入,不再 spawn 宿主 CLI、不读取 sourceRef、不接触或打印 token;compact/full 合同仍覆盖。 - `mirror bootstrap --target JD01 --dry-run`:`OK=true`,证明 JD01 不依赖 NC01 override。 -- 专用凭据只读状态:`sourceRef=pikainc-selfmedia-gh-token.txt`、`presence=false`、`fingerprint=null`、`permissionResult=blocked-missing-credential`。 -- 因 credential material 缺失,未执行权限 API 探测;不得声称 Contents、Metadata 或 Webhooks 权限已实际授予。 +- 专用凭据只读状态:`sourceRef=pikainc-selfmedia-gh-token.txt`、`presence=true`、`mode=600`、`fingerprint=sha256:5eed1c04a44ac5e4`、`valuesPrinted=false`。 +- 受控 status 已验证私有 HEAD 与 hooks list 可读,但未创建 hook;不得把可读性推断为超出 YAML 声明的额外权限,也不得披露 token 值。 ## 并行保护 @@ -62,5 +62,5 @@ ## 当前结论 - R1.1 配置、schema、renderer、Secret、bridge、observer 与受控 fetch 的源码能力已落地并通过轻量验证。 -- 外部 credential material 仍缺失,R1.1 保持 `in_progress`。 -- 凭据到位后的下一步仅允许通过受控 CLI 观察脱敏 permissionResult、hook reconciler 与自动交付收敛,不得以手工触发替代自动链路。 +- repository-scoped credential material 已到位且 mode 为 `0600`;当前 GitHub hook 尚未创建,R1.1 保持 `in_progress`。 +- 下一步仅允许在授权的自动控制面中观察 hook reconciler 与自动交付收敛;本次未执行 hook 创建、Secret sync、apply、bootstrap 或任何部署。 diff --git a/scripts/src/platform-infra-gitea-config.test.ts b/scripts/src/platform-infra-gitea-config.test.ts index e141026d..eb512873 100644 --- a/scripts/src/platform-infra-gitea-config.test.ts +++ b/scripts/src/platform-infra-gitea-config.test.ts @@ -4,7 +4,34 @@ import { readGiteaConfig, resolveTarget, } from "./platform-infra-gitea-config"; -import { renderManifest } from "./platform-infra-gitea"; +import { buildMirrorWebhookCredentialBlockedResult, renderManifest } from "./platform-infra-gitea"; +import { renderMirrorWebhookCredentialBlocked } from "./platform-infra-gitea-render"; + +function syntheticMissingSelfmediaCredential(): Record<string, unknown> { + return { + id: "github-upstream:selfmedia-nc01", + repository: "pikainc/selfmedia", + sourceRef: "pikainc-selfmedia-gh-token.txt", + present: false, + requiredKeysPresent: false, + fingerprint: null, + permissionResult: { + status: "blocked-missing-credential", + permissions: { contents: "read", metadata: "read", webhooks: "read-write" }, + error: "credential material is absent", + }, + valuesPrinted: false, + }; +} + +function syntheticBlockedResult(allTargetRepositories = false): Record<string, unknown> { + const config = readGiteaConfig(); + const target = resolveTarget(config, "NC01"); + const repositories = config.sourceAuthority.repositories.filter((repo) => repo.targetId === "NC01" + && (allTargetRepositories || repo.key === "selfmedia-nc01")); + const credential = syntheticMissingSelfmediaCredential(); + return buildMirrorWebhookCredentialBlockedResult(config, target, repositories, [credential], [String(credential.id)]); +} describe("platform-infra Gitea repository GitHub credentials", () => { test("keeps the global credential and selfmedia override on distinct Secret keys", () => { @@ -40,39 +67,25 @@ describe("platform-infra Gitea repository GitHub credentials", () => { test("does not inject the NC01 selfmedia token into JD01 bridge containers", () => { const config = readGiteaConfig(); - const tokenEnv = githubTokenEnvNameForSecretKey("github-token-pikainc-selfmedia"); + const selfmediaTokenEnv = githubTokenEnvNameForSecretKey("github-token-pikainc-selfmedia"); + const globalTokenEnv = githubTokenEnvNameForSecretKey(config.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key); const jd01Manifest = renderManifest(config, resolveTarget(config, "JD01")); const nc01Manifest = renderManifest(config, resolveTarget(config, "NC01")); - expect(jd01Manifest).not.toContain(tokenEnv); - expect(nc01Manifest).toContain(tokenEnv); + expect(jd01Manifest).toContain(globalTokenEnv); + expect(jd01Manifest).not.toContain(selfmediaTokenEnv); + expect(nc01Manifest).toContain(globalTokenEnv); + expect(nc01Manifest).toContain(selfmediaTokenEnv); + expect(nc01Manifest).toContain(`"tokenEnv": "${selfmediaTokenEnv}"`); }); test("keeps missing selfmedia webhook status bounded and stack-free", () => { - const result = Bun.spawnSync({ - cmd: [ - "bun", - "scripts/cli.ts", - "platform-infra", - "gitea", - "mirror", - "webhook", - "status", - "--target", - "NC01", - "--repo", - "selfmedia-nc01", - "--full", - ], - stdout: "pipe", - stderr: "pipe", - }); - const stdout = result.stdout.toString(); - const payload = JSON.parse(stdout); - const credentials = payload.data.credentials as Array<Record<string, unknown>>; - const selfmedia = credentials.find((item) => item.id === "github-upstream:selfmedia-nc01"); + const payload = syntheticBlockedResult(); + const credentials = payload.credentials as Array<Record<string, unknown>>; + const selfmedia = credentials[0]; + const serialized = JSON.stringify(payload); - expect(payload.data.error.code).toBe("github-credential-unavailable"); + expect((payload.error as Record<string, unknown>).code).toBe("github-credential-unavailable"); expect(selfmedia).toMatchObject({ sourceRef: "pikainc-selfmedia-gh-token.txt", present: false, @@ -80,29 +93,12 @@ describe("platform-infra Gitea repository GitHub credentials", () => { valuesPrinted: false, }); expect(selfmedia).not.toHaveProperty("sourcePath"); - expect(stdout).not.toContain('"stack"'); - expect(result.stderr.toString()).not.toContain("platform-infra-gitea.ts:"); + expect(serialized).not.toContain('"stack"'); + expect(serialized).not.toContain("/root/.worktrees/"); }); test("renders missing selfmedia webhook status as compact text by default", () => { - const result = Bun.spawnSync({ - cmd: [ - "bun", - "scripts/cli.ts", - "platform-infra", - "gitea", - "mirror", - "webhook", - "status", - "--target", - "NC01", - "--repo", - "selfmedia-nc01", - ], - stdout: "pipe", - stderr: "pipe", - }); - const stdout = result.stdout.toString(); + const stdout = renderMirrorWebhookCredentialBlocked(syntheticBlockedResult()).renderedText; expect(stdout).toContain("PLATFORM-INFRA GITEA MIRROR WEBHOOK STATUS"); expect(stdout).toContain("SOURCE_REF"); @@ -111,26 +107,10 @@ describe("platform-infra Gitea repository GitHub credentials", () => { expect(stdout).toContain("github-upstream:selfmedia-nc01"); expect(stdout.trimStart()).not.toStartWith("{"); expect(stdout).not.toContain('"stack"'); - expect(result.stderr.toString()).not.toContain("platform-infra-gitea.ts:"); }); test("matches compact blocker rows to their repository without --repo", () => { - const result = Bun.spawnSync({ - cmd: [ - "bun", - "scripts/cli.ts", - "platform-infra", - "gitea", - "mirror", - "webhook", - "status", - "--target", - "NC01", - ], - stdout: "pipe", - stderr: "pipe", - }); - const stdout = result.stdout.toString(); + const stdout = renderMirrorWebhookCredentialBlocked(syntheticBlockedResult(true)).renderedText; expect(stdout).toContain("pikainc/selfmedia"); expect(stdout).toContain("pikainc-selfmedia-gh-token.txt"); diff --git a/scripts/src/platform-infra-gitea.ts b/scripts/src/platform-infra-gitea.ts index 03606bc9..7b21bc97 100644 --- a/scripts/src/platform-infra-gitea.ts +++ b/scripts/src/platform-infra-gitea.ts @@ -475,20 +475,7 @@ async function mirrorWebhookStatus(config: UniDeskConfig, options: MirrorWebhook const repos = selectedRepositories(gitea, target, options.repoKey); const credentials = credentialSummaries(gitea, repos); const blockers = credentialBlockers(credentials); - if (blockers.length > 0) { - return { - ok: false, - action: "platform-infra-gitea-mirror-webhook-status", - mutation: false, - credentialBlocked: true, - target: targetSummary(target), - webhook: webhookSyncSummary(gitea, target), - repositories: repos.map((repo) => repositorySummary(gitea, repo)), - credentials, - error: { code: "github-credential-unavailable", blockers, valuesPrinted: false }, - next: mirrorReadOnlyNextCommands(target.id), - }; - } + if (blockers.length > 0) return buildMirrorWebhookCredentialBlockedResult(gitea, target, repos, credentials, blockers); const secrets = ensureMirrorSecrets(gitea, target, repos, false, false, false); const result = await capture(config, target.route, ["sh"], remoteScript("mirror-webhook-status", gitea, target, "", { ...options, confirm: false, dryRun: true, wait: false }, { repos, secrets }).script); const parsed = parseJsonOutput(result.stdout); @@ -508,6 +495,27 @@ async function mirrorWebhookStatus(config: UniDeskConfig, options: MirrorWebhook }; } +export function buildMirrorWebhookCredentialBlockedResult( + gitea: GiteaConfig, + target: GiteaTarget, + repos: GiteaMirrorRepository[], + credentials: Array<Record<string, unknown>>, + blockers: string[], +): Record<string, unknown> { + return { + ok: false, + action: "platform-infra-gitea-mirror-webhook-status", + mutation: false, + credentialBlocked: true, + target: targetSummary(target), + webhook: webhookSyncSummary(gitea, target), + repositories: repos.map((repo) => repositorySummary(gitea, repo)), + credentials, + error: { code: "github-credential-unavailable", blockers, valuesPrinted: false }, + next: mirrorReadOnlyNextCommands(target.id), + }; +} + export function renderManifest(gitea: GiteaConfig, target: GiteaTarget): string { const app = gitea.app; const image = `${app.image.repository}:${app.image.tag}`; From 8e68f8a08f6b5790d30570938a8d175bae67f2da Mon Sep 17 00:00:00 2001 From: Codex <codex@local> Date: Mon, 13 Jul 2026 14:21:33 +0200 Subject: [PATCH 17/21] fix: materialize Gitea bootstrap credentials --- .../R1.1_Task_Report.md | 11 ++ .../src/platform-infra-gitea-payload.test.ts | 73 +++++++++++ scripts/src/platform-infra-gitea-remote.sh | 123 +++++++++++------- scripts/src/platform-infra-gitea.ts | 6 +- 4 files changed, 168 insertions(+), 45 deletions(-) diff --git a/docs/MDTODO/details/selfmedia-production-delivery/R1.1_Task_Report.md b/docs/MDTODO/details/selfmedia-production-delivery/R1.1_Task_Report.md index 98de4f55..06819b60 100644 --- a/docs/MDTODO/details/selfmedia-production-delivery/R1.1_Task_Report.md +++ b/docs/MDTODO/details/selfmedia-production-delivery/R1.1_Task_Report.md @@ -45,6 +45,17 @@ - 最终吸收 `origin/master@3aaa2616`。 - R1.2 保持 `[completed]`,`R1.2_Task_Report.md` 保留。 +## 本轮 bootstrap Secret 物化修复 + +- PR #1926 合并后的只读运行面证据显示,candidate Pod 因 `devops-infra/gitea-github-sync-secrets` 缺少 `github-token-pikainc-selfmedia` 而失败;live Secret 脱敏 key 列表只有全局 `github-token`、Gitea 管理员与 webhook secret。 +- 旧 Deployment/reconciler 仅引用全局 `github-token`;candidate desired manifest 已引用 repository key,因此故障发生在 YAML credential 选择之后、Secret 物化之前。 +- 根因确认:`run_mirror_bootstrap()` 读取 repository token 并创建 Gitea repo,但从未执行 `run_apply()` 内的 webhook Secret upsert。此前“bootstrap 已写入 key”的现场假设不成立。 +- 修复将 Secret upsert 抽为 `upsert_webhook_sync_secret()`,由 `run_apply()` 与 `run_mirror_bootstrap()` 复用。bootstrap 在任何 Gitea admin/repository mutation 前物化完整 Target/owner YAML credential key 集合;失败时显式 `return 1`,`apiBootstrap.skipped=true`、`valuesPrinted=false`。 +- bootstrap 的 Secret key map 由本次实际加载的 `githubTokens` keys 生成,selfmedia override 不回落或合并到全局 token,也不会删除同 Target/owner 的其他 YAML repository keys。Secret 值未进入 GitOps、Git、日志或报告。 +- 行为测试使用 fixture token 模拟全部 key 与 Secret apply 失败,不读取宿主 sourceRef;断言两个 GitHub key 均物化、失败时不发生后续 Gitea mutation。 +- 主代理按 `bun.lock` 执行 `bun install --frozen-lockfile`,未修改 package/lock;`sh -n`、`git diff --check` 与 payload/config/PaC bootstrap 三个测试文件共 14/14 通过。 +- 受控 webhook status 已可读取 `pikainc/selfmedia` master HEAD `6e6a95297c3c153ab269410a079a997adc345774`,先前 `github-api-get-404` 已消失;生产 Hook 尚未创建,R1.1 继续保持 `in_progress`。 + ## 未执行事项 - 未创建假 token,未复制 SSH 私钥,未修改仓库可见性。 diff --git a/scripts/src/platform-infra-gitea-payload.test.ts b/scripts/src/platform-infra-gitea-payload.test.ts index ebdd93ff..361f6309 100644 --- a/scripts/src/platform-infra-gitea-payload.test.ts +++ b/scripts/src/platform-infra-gitea-payload.test.ts @@ -7,6 +7,14 @@ import { renderGiteaRemotePayloadMaterializer, summarizeGiteaRemotePayloads } fr import { renderGiteaWebhookSyncDesiredManifest } from "./platform-infra-gitea"; const remoteScriptPath = resolve(import.meta.dir, "platform-infra-gitea-remote.sh"); +const orchestrationPath = resolve(import.meta.dir, "platform-infra-gitea.ts"); + +function functionSource(source: string, name: string, nextName: string): string { + const start = source.indexOf(`${name}() {`); + const end = source.indexOf(`\n${nextName}() {`, start); + if (start < 0 || end < 0) throw new Error(`missing shell function ${name}`); + return source.slice(start, end); +} test("stdin file envelope materializes a payload larger than the current Gitea manifest without argv or env", () => { const currentManifest = renderGiteaWebhookSyncDesiredManifest("NC01"); @@ -68,3 +76,68 @@ test("Gitea remote runner consumes materialized files instead of base64 environm source.lastIndexOf('timeout "$UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS" kubectl apply --server-side --force-conflicts'), ); }); + +test("mirror bootstrap materializes every selected GitHub credential key before repository mutation", () => { + const source = readFileSync(remoteScriptPath, "utf8"); + const orchestration = readFileSync(orchestrationPath, "utf8"); + const upsert = functionSource(source, "upsert_webhook_sync_secret", "run_apply"); + const bootstrap = functionSource(source, "run_mirror_bootstrap", "run_mirror_sync"); + expect(bootstrap.indexOf("upsert_webhook_sync_secret")).toBeLessThan(bootstrap.indexOf('pod="$(kubectl')); + expect(bootstrap).toContain('"apiBootstrap": {"exitCode": None, "skipped": True}'); + expect(bootstrap).toContain("return 1"); + expect(orchestration).toContain("ensureMirrorSecrets(gitea, target, repositoriesForTarget(gitea, target), true, true, true)"); + expect(orchestration).toContain("Object.keys(params.secrets.githubTokens)"); + expect(orchestration).toContain("env.UNIDESK_GITEA_GITHUB_SECRET_ENV_MAP"); + + const result = spawnSync("sh", ["-s"], { + encoding: "utf8", + input: [ + "set -u", + 'tmp="$(mktemp -d)"', + 'trap \'rm -rf "$tmp"\' EXIT', + 'export tmp UNIDESK_GITEA_WEBHOOK_SYNC_ENABLED=1 UNIDESK_GITEA_DRY_RUN=0 UNIDESK_GITEA_NAMESPACE=devops-infra UNIDESK_GITEA_WEBHOOK_SECRET_NAME=gitea-github-sync-secrets UNIDESK_GITEA_FIELD_MANAGER=test-manager', + 'export UNIDESK_GITEA_GITHUB_SECRET_ENV_MAP="github-token=GLOBAL_TOKEN,github-token-pikainc-selfmedia=SELFMEDIA_TOKEN"', + 'export GLOBAL_TOKEN=fixture-global SELFMEDIA_TOKEN=fixture-selfmedia UNIDESK_GITEA_ADMIN_USERNAME=fixture-admin UNIDESK_GITEA_ADMIN_PASSWORD=fixture-password UNIDESK_GITEA_GITHUB_WEBHOOK_SECRET=fixture-webhook', + 'kubectl() { if [ "$1" = "-n" ] && [ "$3" = "create" ]; then printf "%s\\n" "apiVersion: v1" "kind: Secret"; printf "%s\\n" "$*" >>"$tmp/kubectl.log"; return 0; fi; if [ "$1" = "apply" ]; then cat >/dev/null; printf "%s\\n" "$*" >>"$tmp/kubectl.log"; return 0; fi; return 1; }', + upsert, + "upsert_webhook_sync_secret", + 'cat "$tmp/kubectl.log"', + ].join("\n"), + }); + expect(result.status).toBe(0); + expect(result.stdout).toContain("--from-file=github-token="); + expect(result.stdout).toContain("--from-file=github-token-pikainc-selfmedia="); + expect(result.stdout).not.toContain("fixture-global"); + expect(result.stdout).not.toContain("fixture-selfmedia"); + + const failed = spawnSync("sh", ["-s"], { + encoding: "utf8", + input: [ + "set -u", + 'tmp="$(mktemp -d)"', + 'trap \'rm -rf "$tmp"\' EXIT', + 'export tmp UNIDESK_GITEA_WEBHOOK_SYNC_ENABLED=1 UNIDESK_GITEA_DRY_RUN=0 UNIDESK_GITEA_NAMESPACE=devops-infra UNIDESK_GITEA_WEBHOOK_SECRET_NAME=gitea-github-sync-secrets UNIDESK_GITEA_FIELD_MANAGER=test-manager UNIDESK_GITEA_TARGET_ID=NC01', + 'export UNIDESK_GITEA_GITHUB_SECRET_ENV_MAP="github-token=GLOBAL_TOKEN,github-token-pikainc-selfmedia=SELFMEDIA_TOKEN"', + 'export GLOBAL_TOKEN=fixture-global SELFMEDIA_TOKEN=fixture-selfmedia UNIDESK_GITEA_ADMIN_USERNAME=fixture-admin UNIDESK_GITEA_ADMIN_PASSWORD=fixture-password UNIDESK_GITEA_GITHUB_WEBHOOK_SECRET=fixture-webhook', + 'mirror_repos_json() { printf "%s\\n" "[]" >"$tmp/repos.json"; }', + 'kubectl() { if [ "$1" = "-n" ] && [ "$3" = "create" ]; then printf "%s\\n" "apiVersion: v1" "kind: Secret"; return 0; fi; if [ "$1" = "apply" ]; then cat >/dev/null; return 1; fi; touch "$tmp/unexpected-mutation"; return 1; }', + upsert, + bootstrap, + "run_mirror_bootstrap >\"$tmp/result.json\"", + 'rc=$?', + 'python3 - "$tmp/result.json" "$tmp/unexpected-mutation" "$rc" <<\'PY\'', + "import json, pathlib, sys", + "payload=json.load(open(sys.argv[1], encoding='utf-8'))", + "assert int(sys.argv[3]) == 1", + "assert payload['steps']['apiBootstrap']['skipped'] is True", + "assert payload['valuesPrinted'] is False", + "assert not pathlib.Path(sys.argv[2]).exists()", + "print('bootstrap-secret-fail-closed')", + "PY", + ].join("\n"), + }); + expect(failed.status).toBe(0); + expect(failed.stdout).toContain("bootstrap-secret-fail-closed"); + expect(failed.stdout).not.toContain("fixture-global"); + expect(failed.stdout).not.toContain("fixture-selfmedia"); +}); diff --git a/scripts/src/platform-infra-gitea-remote.sh b/scripts/src/platform-infra-gitea-remote.sh index 0d6c4685..d429837f 100644 --- a/scripts/src/platform-infra-gitea-remote.sh +++ b/scripts/src/platform-infra-gitea-remote.sh @@ -32,6 +32,49 @@ capture_json() { printf '%s' "$rc" >"$tmp/$name.rc" } +upsert_webhook_sync_secret() { + webhook_secret_rc=0 + : >"$tmp/webhook-secret.out" + : >"$tmp/webhook-secret.err" + if [ "$UNIDESK_GITEA_WEBHOOK_SYNC_ENABLED" != "1" ]; then + printf '%s\n' "webhook sync disabled" >"$tmp/webhook-secret.err" + return 0 + fi + if [ "$UNIDESK_GITEA_DRY_RUN" = "1" ]; then + printf '%s\n' "dry-run: webhook sync secret apply skipped" >"$tmp/webhook-secret.err" + return 0 + fi + github_secret_args="" + old_ifs="$IFS" + IFS=',' + for mapping in $UNIDESK_GITEA_GITHUB_SECRET_ENV_MAP; do + secret_key=${mapping%%=*} + env_name=${mapping#*=} + eval "secret_value=\${$env_name-}" + if [ -z "$secret_value" ]; then + printf '%s\n' "missing GitHub credential environment for Secret key $secret_key" >"$tmp/webhook-secret.err" + webhook_secret_rc=1 + break + fi + secret_file="$tmp/github-secret-$secret_key" + printf '%s' "$secret_value" >"$secret_file" + chmod 600 "$secret_file" + github_secret_args="$github_secret_args --from-file=$secret_key=$secret_file" + done + IFS="$old_ifs" + if [ "$webhook_secret_rc" -ne 0 ]; then + return "$webhook_secret_rc" + fi + kubectl -n "$UNIDESK_GITEA_NAMESPACE" create secret generic "$UNIDESK_GITEA_WEBHOOK_SECRET_NAME" \ + $github_secret_args \ + --from-literal=gitea-username="$UNIDESK_GITEA_ADMIN_USERNAME" \ + --from-literal=gitea-password="$UNIDESK_GITEA_ADMIN_PASSWORD" \ + --from-literal=github-webhook-secret="$UNIDESK_GITEA_GITHUB_WEBHOOK_SECRET" \ + --dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_GITEA_FIELD_MANAGER" -f - >"$tmp/webhook-secret.out" 2>"$tmp/webhook-secret.err" + webhook_secret_rc=$? + return "$webhook_secret_rc" +} + run_apply() { manifest="$tmp/gitea.k8s.yaml" if [ "$UNIDESK_GITEA_DRY_RUN" != "1" ]; then @@ -51,43 +94,8 @@ run_apply() { printf '%s\n' "frpc disabled" >"$tmp/frpc-secret.err" fi fi - if [ "$UNIDESK_GITEA_WEBHOOK_SYNC_ENABLED" = "1" ] && [ "$UNIDESK_GITEA_DRY_RUN" != "1" ]; then - github_secret_args="" - old_ifs="$IFS" - IFS=',' - for mapping in $UNIDESK_GITEA_GITHUB_SECRET_ENV_MAP; do - secret_key=${mapping%%=*} - env_name=${mapping#*=} - eval "secret_value=\${$env_name-}" - if [ -z "$secret_value" ]; then - printf '%s\n' "missing GitHub credential environment for Secret key $secret_key" >"$tmp/webhook-secret.err" - webhook_secret_rc=1 - break - fi - secret_file="$tmp/github-secret-$secret_key" - printf '%s' "$secret_value" >"$secret_file" - chmod 600 "$secret_file" - github_secret_args="$github_secret_args --from-file=$secret_key=$secret_file" - done - IFS="$old_ifs" - if [ "${webhook_secret_rc:-0}" -eq 0 ]; then - kubectl -n "$UNIDESK_GITEA_NAMESPACE" create secret generic "$UNIDESK_GITEA_WEBHOOK_SECRET_NAME" \ - $github_secret_args \ - --from-literal=gitea-username="$UNIDESK_GITEA_ADMIN_USERNAME" \ - --from-literal=gitea-password="$UNIDESK_GITEA_ADMIN_PASSWORD" \ - --from-literal=github-webhook-secret="$UNIDESK_GITEA_GITHUB_WEBHOOK_SECRET" \ - --dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_GITEA_FIELD_MANAGER" -f - >"$tmp/webhook-secret.out" 2>"$tmp/webhook-secret.err" - webhook_secret_rc=$? - fi - else - webhook_secret_rc=0 - : >"$tmp/webhook-secret.out" - if [ "$UNIDESK_GITEA_WEBHOOK_SYNC_ENABLED" = "1" ]; then - printf '%s\n' "dry-run: webhook sync secret apply skipped" >"$tmp/webhook-secret.err" - else - printf '%s\n' "webhook sync disabled" >"$tmp/webhook-secret.err" - fi - fi + upsert_webhook_sync_secret + webhook_secret_rc=$? timeout "$UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS" kubectl apply --server-side --force-conflicts --dry-run=server \ --field-manager="$UNIDESK_GITEA_FIELD_MANAGER" -f "$manifest" >"$tmp/server-dry-run.out" 2>"$tmp/server-dry-run.err" server_dry_run_rc=$? @@ -409,6 +417,32 @@ PY run_mirror_bootstrap() { mirror_repos_json + upsert_webhook_sync_secret + webhook_secret_rc=$? + if [ "$webhook_secret_rc" -ne 0 ]; then + python3 - "$webhook_secret_rc" "$tmp/webhook-secret.out" "$tmp/webhook-secret.err" <<'PY' +import json, os, sys +def text(path, limit=1600): + try: + return open(path, encoding="utf-8", errors="replace").read()[-limit:] + except FileNotFoundError: + return "" +payload = { + "ok": False, + "target": os.environ.get("UNIDESK_GITEA_TARGET_ID"), + "namespace": os.environ.get("UNIDESK_GITEA_NAMESPACE"), + "steps": { + "webhookSyncSecret": {"exitCode": int(sys.argv[1]), "stdoutTail": text(sys.argv[2]), "stderrTail": text(sys.argv[3])}, + "apiBootstrap": {"exitCode": None, "skipped": True}, + }, + "repositories": [], + "valuesPrinted": False, +} +print(json.dumps(payload, ensure_ascii=False, indent=2)) +sys.exit(1) +PY + return 1 + fi pod="$(kubectl -n "$UNIDESK_GITEA_NAMESPACE" get pod -l "app.kubernetes.io/name=$UNIDESK_GITEA_APP_NAME,app.kubernetes.io/component=gitea" -o jsonpath='{.items[0].metadata.name}' 2>"$tmp/pod.err")" create_rc=1 change_rc=1 @@ -490,26 +524,27 @@ json.dump(payload, open(out_path, "w", encoding="utf-8"), ensure_ascii=False, in sys.exit(0 if ok else 1) PY api_rc=$? - python3 - "$create_rc" "$change_rc" "$unset_rc" "$api_rc" "$tmp/admin-create.out" "$tmp/admin-create.err" "$tmp/admin-pass.out" "$tmp/admin-pass.err" "$tmp/admin-unset.out" "$tmp/admin-unset.err" "$tmp/api.json" <<'PY' + python3 - "$webhook_secret_rc" "$create_rc" "$change_rc" "$unset_rc" "$api_rc" "$tmp/webhook-secret.out" "$tmp/webhook-secret.err" "$tmp/admin-create.out" "$tmp/admin-create.err" "$tmp/admin-pass.out" "$tmp/admin-pass.err" "$tmp/admin-unset.out" "$tmp/admin-unset.err" "$tmp/api.json" <<'PY' import json, sys def text(path, limit=1600): try: return open(path, encoding="utf-8", errors="replace").read()[-limit:] except FileNotFoundError: return "" -create_rc, change_rc, unset_rc, api_rc = map(int, sys.argv[1:5]) +webhook_secret_rc, create_rc, change_rc, unset_rc, api_rc = map(int, sys.argv[1:6]) try: - api = json.load(open(sys.argv[11], encoding="utf-8")) + api = json.load(open(sys.argv[14], encoding="utf-8")) except Exception: api = {} payload = { - "ok": create_rc == 0 and change_rc == 0 and unset_rc == 0 and api_rc == 0, + "ok": webhook_secret_rc == 0 and create_rc == 0 and change_rc == 0 and unset_rc == 0 and api_rc == 0, "target": __import__("os").environ.get("UNIDESK_GITEA_TARGET_ID"), "namespace": __import__("os").environ.get("UNIDESK_GITEA_NAMESPACE"), "steps": { - "adminCreate": {"exitCode": create_rc, "stdoutTail": text(sys.argv[5]), "stderrTail": text(sys.argv[6])}, - "adminPassword": {"exitCode": change_rc, "stdoutTail": text(sys.argv[7]), "stderrTail": text(sys.argv[8])}, - "adminMustChangePasswordUnset": {"exitCode": unset_rc, "stdoutTail": text(sys.argv[9]), "stderrTail": text(sys.argv[10])}, + "webhookSyncSecret": {"exitCode": webhook_secret_rc, "stdoutTail": text(sys.argv[6]), "stderrTail": text(sys.argv[7])}, + "adminCreate": {"exitCode": create_rc, "stdoutTail": text(sys.argv[8]), "stderrTail": text(sys.argv[9])}, + "adminPassword": {"exitCode": change_rc, "stdoutTail": text(sys.argv[10]), "stderrTail": text(sys.argv[11])}, + "adminMustChangePasswordUnset": {"exitCode": unset_rc, "stdoutTail": text(sys.argv[12]), "stderrTail": text(sys.argv[13])}, "apiBootstrap": {"exitCode": api_rc}, }, "api": api, diff --git a/scripts/src/platform-infra-gitea.ts b/scripts/src/platform-infra-gitea.ts index 7b21bc97..0f01c99b 100644 --- a/scripts/src/platform-infra-gitea.ts +++ b/scripts/src/platform-infra-gitea.ts @@ -401,7 +401,7 @@ async function mirrorBootstrap(config: UniDeskConfig, options: MirrorOptions): P }, }; } - const secrets = ensureMirrorSecrets(gitea, target, repos, false, true, true); + const secrets = ensureMirrorSecrets(gitea, target, repositoriesForTarget(gitea, target), true, true, true); const result = await capture(config, target.route, ["sh"], remoteScript("mirror-bootstrap", gitea, target, "", { ...options, dryRun: false, wait: false }, { repos, secrets }).script); const parsed = parseJsonOutput(result.stdout); return { @@ -1164,6 +1164,10 @@ function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstra env.UNIDESK_GITEA_ADMIN_USERNAME = params.secrets.adminUsername; env.UNIDESK_GITEA_ADMIN_PASSWORD = params.secrets.adminPassword; for (const [key, token] of Object.entries(params.secrets.githubTokens)) env[githubTokenEnvName(key)] = token; + env.UNIDESK_GITEA_GITHUB_SECRET_ENV_MAP = Object.keys(params.secrets.githubTokens) + .sort() + .map((key) => `${key}=${githubTokenEnvName(key)}`) + .join(","); env.UNIDESK_GITEA_GITHUB_WEBHOOK_SECRET = params.secrets.webhookSecret; } const exports = Object.entries(env).map(([key, value]) => `export ${key}=${shQuote(value)}`).join("\n"); From cd7fb392f6343906bc5064c427d781af87874f0e Mon Sep 17 00:00:00 2001 From: Codex <codex@local> Date: Mon, 13 Jul 2026 14:23:05 +0200 Subject: [PATCH 18/21] =?UTF-8?q?fix(web-probe):=20=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E6=96=B0=E7=89=88=20MDTODO=20DOM=20=E5=90=88=E5=90=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...web-observe-runner-sampling-source.test.ts | 31 ++++++++++++-- ...node-web-observe-runner-sampling-source.ts | 31 +++++++++----- ...eb-observe-runner-workbench-source.test.ts | 38 +++++++++++++++++ ...ode-web-observe-runner-workbench-source.ts | 42 ++++++++++++------- 4 files changed, 114 insertions(+), 28 deletions(-) create mode 100644 scripts/src/hwlab-node-web-observe-runner-workbench-source.test.ts diff --git a/scripts/src/hwlab-node-web-observe-runner-sampling-source.test.ts b/scripts/src/hwlab-node-web-observe-runner-sampling-source.test.ts index 134d21c1..229028d2 100644 --- a/scripts/src/hwlab-node-web-observe-runner-sampling-source.test.ts +++ b/scripts/src/hwlab-node-web-observe-runner-sampling-source.test.ts @@ -7,10 +7,32 @@ import { } from "./hwlab-node-web-observe-runner-sampling-source"; test("MDTODO task tree pane gap treats the pinned facts footer as content", async () => { - assert.match(MDTODO_TASK_TREE_PANE_CONTENT_SELECTOR, /\[data-testid="mdtodo-task-tree-facts"\]/u); + assert.match( + MDTODO_TASK_TREE_PANE_CONTENT_SELECTOR, + /\[data-testid="mdtodo-task-tree-facts"\]/u, + ); + assert.match( + MDTODO_TASK_TREE_PANE_CONTENT_SELECTOR, + /\[data-testid="mdtodo-task-page-fact"\]/u, + ); const source = nodeWebObserveRunnerSamplingSource(); - assert.ok(source.includes(`measurePaneGap("task-tree", '[data-testid="mdtodo-task-tree"]', ${JSON.stringify(MDTODO_TASK_TREE_PANE_CONTENT_SELECTOR)})`)); + assert.ok( + source.includes( + `measurePaneGap("task-tree", '[data-testid="mdtodo-task-tree"]', ${JSON.stringify(MDTODO_TASK_TREE_PANE_CONTENT_SELECTOR)})`, + ), + ); + assert.match( + source, + /semanticTaskCandidates\.length > 0 \? semanticTaskCandidates : fallbackTaskCandidates/u, + ); + assert.match( + source, + /taskCandidates\.filter\(\(candidate\) => !taskRefElement\(candidate\)\)\.length/u, + ); + assert.doesNotMatch(source, /taskCandidates\.length - taskItems\.length/u); + assert.match(source, /mdtodo-body-read/u); + assert.match(source, /mdtodo-launch-workbench/u); const child = Bun.spawn(["node", "--input-type=module", "--check", "-"], { stdin: "pipe", @@ -19,6 +41,9 @@ test("MDTODO task tree pane gap treats the pinned facts footer as content", asyn }); child.stdin.write(source); child.stdin.end(); - const [stderr, exitCode] = await Promise.all([new Response(child.stderr).text(), child.exited]); + const [stderr, exitCode] = await Promise.all([ + new Response(child.stderr).text(), + child.exited, + ]); assert.equal(exitCode, 0, stderr); }); diff --git a/scripts/src/hwlab-node-web-observe-runner-sampling-source.ts b/scripts/src/hwlab-node-web-observe-runner-sampling-source.ts index 77a6b53c..b3b1491f 100644 --- a/scripts/src/hwlab-node-web-observe-runner-sampling-source.ts +++ b/scripts/src/hwlab-node-web-observe-runner-sampling-source.ts @@ -3,6 +3,7 @@ export const MDTODO_TASK_TREE_PANE_CONTENT_SELECTOR = [ '[data-testid="mdtodo-task-tree-facts"]', + '[data-testid="mdtodo-task-page-fact"]', "[data-task-ref]", '[role="treeitem"]', '[role="listitem"]', @@ -433,22 +434,32 @@ async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPag selected: option.selected === true, })) : []; const selectedFileOption = fileOptions.find((option) => option.selected) || null; - const taskItems = Array.from(document.querySelectorAll('[data-testid="mdtodo-task-tree"] [data-task-ref], [data-task-ref]')).filter(visible); - const taskCandidates = Array.from(document.querySelectorAll('[data-testid="mdtodo-task-tree"] li, [data-testid="mdtodo-task-tree"] [role="treeitem"], [data-testid="mdtodo-task-tree"] [role="listitem"]')).filter(visible); + const taskTree = document.querySelector('[data-testid="mdtodo-task-tree"]'); + const semanticTaskCandidates = taskTree + ? Array.from(taskTree.querySelectorAll('[role="treeitem"], [role="listitem"]')).filter(visible) + : []; + const fallbackTaskCandidates = taskTree + ? Array.from(taskTree.querySelectorAll("li")).filter(visible) + : []; + const taskCandidates = semanticTaskCandidates.length > 0 ? semanticTaskCandidates : fallbackTaskCandidates; + const taskRefElement = (candidate) => candidate.matches("[data-task-ref]") + ? candidate + : candidate.querySelector("[data-task-ref]"); + const taskItems = Array.from(new Set(taskCandidates.map(taskRefElement).filter(visible))); const selectedSource = document.querySelector('[data-source-id][data-selected="true"], [data-source-id][aria-selected="true"], [data-source-id].selected, [data-source-id].is-selected'); const selectedFile = document.querySelector('[data-file-ref][data-selected="true"], [data-file-ref][aria-selected="true"], [data-file-ref].selected, [data-file-ref].is-selected'); const selectedTask = document.querySelector('[data-task-ref][data-selected="true"], [data-task-ref][aria-selected="true"], [data-task-ref].selected, [data-task-ref].is-selected'); const statusCounts = {}; for (const task of taskItems) { - const status = task.getAttribute("data-task-status") || "unknown"; + const status = task.getAttribute("data-task-status") || task.querySelector("[data-state]")?.getAttribute("data-state") || "unknown"; statusCounts[status] = (statusCounts[status] || 0) + 1; } - const launch = document.querySelector('[data-testid="mdtodo-workbench-launch"], [data-action="launch-workbench"]'); - const bodyRendered = document.querySelector('[data-testid="mdtodo-body-rendered"]'); + const launch = document.querySelector('[data-testid="mdtodo-workbench-launch"], [data-testid="mdtodo-launch-workbench"], [data-action="launch-workbench"]'); + const bodyRendered = document.querySelector('[data-testid="mdtodo-body-rendered"], [data-testid="mdtodo-body-read"]'); const reportPreview = document.querySelector('[data-testid="mdtodo-report-preview"]'); const reportFullscreen = document.querySelector('[data-testid="mdtodo-report-fullscreen-dialog"]'); const reportLinks = Array.from(document.querySelectorAll('[data-testid="mdtodo-report-link"]')).filter(visible); - const blockers = Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-launch-blocker"], [data-testid="mdtodo-workbench-launch-error"], [role="alert"]')).filter(visible).slice(0, 12).map((element, index) => ({ + const blockers = Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-launch-blocker"], [data-testid="mdtodo-workbench-launch-error"], [data-testid="mdtodo-create-blocker"], .launch-blocker, [role="alert"]')).filter(visible).slice(0, 12).map((element, index) => ({ index, testId: element.getAttribute("data-testid"), role: element.getAttribute("role"), @@ -475,7 +486,7 @@ async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPag }; const paneGaps = [ measurePaneGap("task-tree", '[data-testid="mdtodo-task-tree"]', ${JSON.stringify(MDTODO_TASK_TREE_PANE_CONTENT_SELECTOR)}), - measurePaneGap("task-detail", '[data-testid="mdtodo-task-detail"]', '[data-testid="mdtodo-body-rendered"] > *, [data-testid="mdtodo-report-section"], [data-testid="mdtodo-workbench-launch"], [data-testid="mdtodo-delete-task"], [data-testid="mdtodo-task-detail-error"], .mdtodo-detail-header, .task-status-stack > *, .task-document-footer'), + measurePaneGap("task-detail", '[data-testid="mdtodo-task-detail"]', '[data-testid="mdtodo-body-rendered"] > *, [data-testid="mdtodo-body-read"], [data-testid="mdtodo-report-section"], [data-testid="mdtodo-workbench-launch"], [data-testid="mdtodo-launch-workbench"], [data-testid="mdtodo-delete-task"], [data-testid="mdtodo-task-detail-error"], .mdtodo-detail-header, .document-header, .task-status-stack > *, .task-document-footer, .document-footer'), measurePaneGap("report-sidebar", '[data-testid="mdtodo-report-sidebar"]', '[data-testid="mdtodo-report-preview"] > *, [data-testid="mdtodo-report-error"], [data-testid="mdtodo-report-fullscreen"], [data-testid="mdtodo-report-close"], .report-sidebar-header, .report-preview .markdown-body > *'), ]; return { @@ -486,17 +497,17 @@ async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPag sourceCount: Math.max(sourceItems.length, sourceOptionCount), fileCount: Math.max(fileItems.length, fileOptionCount), taskCount: taskItems.length, - taskRefMissingCount: Math.max(0, taskCandidates.length - taskItems.length), + taskRefMissingCount: taskCandidates.filter((candidate) => !taskRefElement(candidate)).length, selectedSourceId: opaqueDomId(selectedSource?.getAttribute("data-source-id") || sourceSelect?.value), selectedFileRef: opaqueDomId(selectedFile?.getAttribute("data-file-ref") || fileSelect?.value), selectedFileLabel: selectedFile ? trim(selectedFile.textContent || "", 180) : selectedFileOption?.label || null, fileOptionLabels: fileOptions.map((option) => option.label).filter(Boolean).slice(0, 24), selectedTaskRef: opaqueDomId(selectedTask?.getAttribute("data-task-ref")), - selectedTaskStatus: selectedTask?.getAttribute("data-task-status") || null, + selectedTaskStatus: selectedTask?.getAttribute("data-task-status") || selectedTask?.querySelector("[data-state]")?.getAttribute("data-state") || null, sourceSelectVisible: visible(sourceSelect), fileSelectVisible: visible(fileSelect), sourceConfigVisible: visible(document.querySelector('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-config-dialog"], [role="dialog"]')), - taskEditorVisible: visible(document.querySelector('[data-testid="mdtodo-edit-title"], [data-testid="mdtodo-edit-body"]')), + taskEditorVisible: visible(document.querySelector('[data-testid="mdtodo-edit-title"], [data-testid="mdtodo-edit-body"], [data-testid="mdtodo-body-editor"]')), taskBodyVisible: visible(bodyRendered), taskBodyText: visible(bodyRendered) ? trim(bodyRendered.textContent || "", 500) : "", newTaskDraftVisible: visible(document.querySelector('[data-testid="mdtodo-new-title"], [data-testid="mdtodo-new-body"]')), diff --git a/scripts/src/hwlab-node-web-observe-runner-workbench-source.test.ts b/scripts/src/hwlab-node-web-observe-runner-workbench-source.test.ts new file mode 100644 index 00000000..cab7e6ca --- /dev/null +++ b/scripts/src/hwlab-node-web-observe-runner-workbench-source.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import { test } from "bun:test"; + +import { nodeWebObserveRunnerWorkbenchSource } from "./hwlab-node-web-observe-runner-workbench-source"; + +test("MDTODO observe commands recognize the rewritten workspace contracts", async () => { + const source = nodeWebObserveRunnerWorkbenchSource(); + + for (const testId of [ + "mdtodo-body-read", + "mdtodo-body-editor", + "mdtodo-body-save", + "mdtodo-launch-workbench", + ]) { + assert.match(source, new RegExp(testId, "u")); + } + for (const legacyTestId of [ + "mdtodo-body-rendered", + "mdtodo-edit-body", + "mdtodo-edit-body-save", + "mdtodo-workbench-launch", + ]) { + assert.match(source, new RegExp(legacyTestId, "u")); + } + + const child = Bun.spawn(["node", "--input-type=module", "--check", "-"], { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + child.stdin.write(source); + child.stdin.end(); + const [stderr, exitCode] = await Promise.all([ + new Response(child.stderr).text(), + child.exited, + ]); + assert.equal(exitCode, 0, stderr); +}); diff --git a/scripts/src/hwlab-node-web-observe-runner-workbench-source.ts b/scripts/src/hwlab-node-web-observe-runner-workbench-source.ts index f2d0a9cf..3267df49 100644 --- a/scripts/src/hwlab-node-web-observe-runner-workbench-source.ts +++ b/scripts/src/hwlab-node-web-observe-runner-workbench-source.ts @@ -764,7 +764,7 @@ async function selectMdtodoTask(command) { const clicked = await target.locator.evaluate((element) => ({ taskRef: element.getAttribute("data-task-ref") || null, taskId: element.getAttribute("data-task-id") || element.getAttribute("data-rxx-id") || null, - status: element.getAttribute("data-task-status") || null, + status: element.getAttribute("data-task-status") || element.querySelector("[data-state]")?.getAttribute("data-state") || null, selected: element.getAttribute("data-selected") === "true" || element.getAttribute("aria-selected") === "true" })).catch(() => ({ taskRef: target.taskRef || null, taskId: target.taskId || null, status: null })); if (!clicked.selected) { @@ -895,12 +895,22 @@ async function closeMdtodoSourceConfigIfOpen(options = {}) { return result; } +function mdtodoTestIdCandidates(value) { + return (Array.isArray(value) ? value : [value]).filter((item) => typeof item === "string" && item.length > 0); +} + +function mdtodoTestIdLocator(value) { + const candidates = mdtodoTestIdCandidates(value); + return page.locator(candidates.map((testId) => '[data-testid="' + cssEscape(testId) + '"]').join(", ")).first(); +} + async function fillMdtodoField(testId, value) { if (typeof value !== "string" || !value.trim()) return { testId, filled: false }; - const locator = page.locator('[data-testid="' + cssEscape(testId) + '"]').first(); + const locator = mdtodoTestIdLocator(testId); await locator.waitFor({ state: "visible", timeout: 10000 }); await locator.fill(value); - return { testId, filled: true, value: opaqueIdSummary(value), valuesRedacted: true }; + const resolvedTestId = await locator.getAttribute("data-testid").catch(() => null); + return { testId: resolvedTestId || mdtodoTestIdCandidates(testId)[0] || null, filled: true, value: opaqueIdSummary(value), valuesRedacted: true }; } async function clickProjectButtonAndMaybeWait(testId, pathPattern) { @@ -1003,7 +1013,7 @@ async function saveMdtodoTaskWithButton(command, type, testId, fields) { if (field.openByDblClickTestId) inlineEditors.push(await ensureMdtodoInlineEditor(field.testId, field.openByDblClickTestId)); if (field.kind === "fill") await fillMdtodoField(field.testId, field.value); if (field.kind === "select") { - const locator = page.locator('[data-testid="' + cssEscape(field.testId) + '"]').first(); + const locator = mdtodoTestIdLocator(field.testId); await locator.waitFor({ state: "visible", timeout: 10000 }); await selectHtmlOptionByValueOrLabel(locator, field.value); } @@ -1023,9 +1033,9 @@ async function clickProjectButtonAndMaybeWaitAny(testIds, pathPattern) { } async function ensureMdtodoInlineEditor(editorTestId, readTestId) { - const editor = page.locator('[data-testid="' + cssEscape(editorTestId) + '"]').first(); + const editor = mdtodoTestIdLocator(editorTestId); if (await visibleLocator(editor)) return { editorTestId, readTestId, opened: false }; - const read = page.locator('[data-testid="' + cssEscape(readTestId) + '"]').first(); + const read = mdtodoTestIdLocator(readTestId); await read.waitFor({ state: "visible", timeout: 10000 }); await read.dblclick(); await editor.waitFor({ state: "visible", timeout: 10000 }); @@ -1041,7 +1051,7 @@ async function editMdtodoTaskTitle(command) { async function editMdtodoTaskBody(command) { const body = commandValue(command, ["text", "body", "value"]); if (!body) throw new Error("editMdtodoTaskBody requires --text or --text-stdin"); - return saveMdtodoTaskWithButton(command, "editMdtodoTaskBody", "mdtodo-edit-body-save", [{ kind: "fill", testId: "mdtodo-edit-body", value: body, openByDblClickTestId: "mdtodo-body-rendered" }]); + return saveMdtodoTaskWithButton(command, "editMdtodoTaskBody", ["mdtodo-body-save", "mdtodo-edit-body-save"], [{ kind: "fill", testId: ["mdtodo-body-editor", "mdtodo-edit-body"], value: body, openByDblClickTestId: ["mdtodo-body-read", "mdtodo-body-rendered"] }]); } async function toggleMdtodoTaskStatus(command) { @@ -1060,7 +1070,7 @@ async function editMdtodoTaskInline(command) { if (field === "body" || field === "content") { const body = commandValue(command, ["body", "text"]); if (!body) throw new Error("editMdtodoTaskInline --field body requires --body, --text, or --text-stdin"); - return saveMdtodoTaskWithButton(command, "editMdtodoTaskInline", "mdtodo-edit-body-save", [{ kind: "fill", testId: "mdtodo-edit-body", value: body, openByDblClickTestId: "mdtodo-body-rendered" }]); + return saveMdtodoTaskWithButton(command, "editMdtodoTaskInline", ["mdtodo-body-save", "mdtodo-edit-body-save"], [{ kind: "fill", testId: ["mdtodo-body-editor", "mdtodo-edit-body"], value: body, openByDblClickTestId: ["mdtodo-body-read", "mdtodo-body-rendered"] }]); } if (field === "status") { const status = commandValue(command, ["status", "text"]); @@ -1219,7 +1229,9 @@ async function deleteMdtodoTask(command) { const selection = await selectTaskIfCommandTargetsOne(command); const firstClick = await clickProjectButtonAndMaybeWait("mdtodo-delete-task", null); let confirmClick = null; - const confirmVisible = await visibleLocator(page.locator('[data-testid="mdtodo-delete-cancel"]').first()); + const deleteButton = page.locator('[data-testid="mdtodo-delete-task"]').first(); + const confirmVisible = await visibleLocator(page.locator('[data-testid="mdtodo-delete-cancel"]').first()) + || await deleteButton.evaluate((element) => /确认|confirm/iu.test(String(element.textContent || ""))).catch(() => false); if (confirmVisible) confirmClick = await clickProjectButtonAndMaybeWait("mdtodo-delete-task", /^\/v1\/project-management\/mdtodo\/tasks/u); return { beforeUrl, afterUrl: currentPageUrl(), type: "deleteMdtodoTask", selection, firstClick, confirmClick, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true }; } @@ -1250,7 +1262,7 @@ async function launchWorkbenchFromTask(command) { : null; const providerSelection = await selectMdtodoProviderProfileForLaunch(command); const projectBeforeClick = await projectManagementCommandSnapshot({ includeRaw: true }); - const button = page.locator('[data-testid="mdtodo-workbench-launch"], [data-action="launch-workbench"]').first(); + const button = page.locator('[data-testid="mdtodo-workbench-launch"], [data-testid="mdtodo-launch-workbench"], [data-action="launch-workbench"]').first(); await button.waitFor({ state: "visible", timeout: 15000 }); const buttonState = await button.evaluate((element) => ({ disabled: Boolean(element.disabled) || element.getAttribute("aria-disabled") === "true", @@ -1392,8 +1404,8 @@ async function projectManagementCommandSnapshot(options = {}) { selected: option.selected === true })) : []; const selectedFileOption = fileOptions.find((option) => option.selected) || null; - const launch = document.querySelector('[data-testid="mdtodo-workbench-launch"], [data-action="launch-workbench"]'); - const bodyRendered = document.querySelector('[data-testid="mdtodo-body-rendered"]'); + const launch = document.querySelector('[data-testid="mdtodo-workbench-launch"], [data-testid="mdtodo-launch-workbench"], [data-action="launch-workbench"]'); + const bodyRendered = document.querySelector('[data-testid="mdtodo-body-rendered"], [data-testid="mdtodo-body-read"]'); const reportPreview = document.querySelector('[data-testid="mdtodo-report-preview"]'); const reportError = document.querySelector('[data-testid="mdtodo-report-error"]'); const reportFullscreen = document.querySelector('[data-testid="mdtodo-report-fullscreen-dialog"]'); @@ -1410,11 +1422,11 @@ async function projectManagementCommandSnapshot(options = {}) { fileOptionLabels: fileOptions.map((option) => option.label).filter(Boolean).slice(0, 20), selectedTaskRefRaw: selectedTask?.getAttribute("data-task-ref") || null, selectedTaskId: selectedTask?.getAttribute("data-task-id") || selectedTask?.getAttribute("data-rxx-id") || null, - selectedTaskStatus: selectedTask?.getAttribute("data-task-status") || null, + selectedTaskStatus: selectedTask?.getAttribute("data-task-status") || selectedTask?.querySelector("[data-state]")?.getAttribute("data-state") || null, sourceSelectVisible: visible(sourceSelect), fileSelectVisible: visible(fileSelect), sourceConfigVisible: visible(document.querySelector('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-config-dialog"], [role="dialog"]')), - taskEditorVisible: visible(document.querySelector('[data-testid="mdtodo-edit-title"], [data-testid="mdtodo-edit-body"]')), + taskEditorVisible: visible(document.querySelector('[data-testid="mdtodo-edit-title"], [data-testid="mdtodo-edit-body"], [data-testid="mdtodo-body-editor"]')), taskBodyVisible: visible(bodyRendered), taskBodyText: visible(bodyRendered) ? text(bodyRendered) : "", newTaskDraftVisible: visible(document.querySelector('[data-testid="mdtodo-new-title"], [data-testid="mdtodo-new-body"]')), @@ -1427,7 +1439,7 @@ async function projectManagementCommandSnapshot(options = {}) { launchButtonVisible: visible(launch), launchButtonEnabled: visible(launch) && !launch.disabled && launch.getAttribute("aria-disabled") !== "true", launchButtonText: text(launch), - blockerTexts: Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-launch-blocker"], [data-testid="mdtodo-workbench-launch-error"], [role="alert"]')).filter(visible).map(text).filter(Boolean).slice(0, 8), + blockerTexts: Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-launch-blocker"], [data-testid="mdtodo-workbench-launch-error"], [data-testid="mdtodo-create-blocker"], .launch-blocker, [role="alert"]')).filter(visible).map(text).filter(Boolean).slice(0, 8), workbenchLinkCount: Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-link-summary"] li, a[href*="/workbench/sessions/"]')).filter(visible).length, valuesRedacted: true }; From 1468e56a6fd1eac240ba2ff5237250091c51ebf6 Mon Sep 17 00:00:00 2001 From: Codex <codex@local> Date: Mon, 13 Jul 2026 14:31:49 +0200 Subject: [PATCH 19/21] =?UTF-8?q?fix(web-probe):=20=E5=BF=BD=E7=95=A5?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E7=9F=AD=E5=88=97=E8=A1=A8=E8=87=AA=E7=84=B6?= =?UTF-8?q?=E7=95=99=E7=99=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ode-web-observe-analyzer-project-source.ts | 8 +++- ...web-observe-runner-sampling-source.test.ts | 46 +++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/scripts/src/hwlab-node-web-observe-analyzer-project-source.ts b/scripts/src/hwlab-node-web-observe-analyzer-project-source.ts index 878ce9fb..edaf642e 100644 --- a/scripts/src/hwlab-node-web-observe-analyzer-project-source.ts +++ b/scripts/src/hwlab-node-web-observe-analyzer-project-source.ts @@ -410,8 +410,12 @@ function projectManagementPaneGapRows(projectSamples) { const maxGapPx = maxNumber(severeGaps.map((item) => item.bottomGapPx)); const maxGapRatio = maxNumber(severeGaps.map((item) => item.bottomGapRatio)); const multiPane = severeGaps.length >= 2; - const singleExtreme = maxGapPx >= 240 && maxGapRatio >= 0.45; - if (!multiPane && !singleExtreme) continue; + const singlePrimaryPaneExtreme = severeGaps.some((item) => + item.name !== "task-tree" + && Number(item.bottomGapPx ?? 0) >= 240 + && Number(item.bottomGapRatio ?? 0) >= 0.45 + ); + if (!multiPane && !singlePrimaryPaneExtreme) continue; const selectedTaskRefHash = sample?.projectManagement?.selectedTaskRef?.hash ?? null; const isMdtodo = sample?.projectManagement?.pageKind === "project-management-mdtodo"; const isInitialEmptyDetail = isMdtodo && !selectedTaskRefHash; diff --git a/scripts/src/hwlab-node-web-observe-runner-sampling-source.test.ts b/scripts/src/hwlab-node-web-observe-runner-sampling-source.test.ts index 229028d2..c72f3596 100644 --- a/scripts/src/hwlab-node-web-observe-runner-sampling-source.test.ts +++ b/scripts/src/hwlab-node-web-observe-runner-sampling-source.test.ts @@ -5,6 +5,7 @@ import { MDTODO_TASK_TREE_PANE_CONTENT_SELECTOR, nodeWebObserveRunnerSamplingSource, } from "./hwlab-node-web-observe-runner-sampling-source"; +import { nodeWebObserveAnalyzerProjectSource } from "./hwlab-node-web-observe-analyzer-project-source"; test("MDTODO task tree pane gap treats the pinned facts footer as content", async () => { assert.match( @@ -47,3 +48,48 @@ test("MDTODO task tree pane gap treats the pinned facts footer as content", asyn ]); assert.equal(exitCode, 0, stderr); }); + +test("MDTODO pane gap ignores a naturally short task tree but keeps primary pane regressions", () => { + const projectManagementPaneGapRows = new Function( + "maxNumber", + "projectManagementSampleRef", + `${nodeWebObserveAnalyzerProjectSource()}; return projectManagementPaneGapRows;`, + )( + (values: unknown[]) => Math.max(0, ...values.map((value) => Number(value ?? 0))), + (sample: Record<string, unknown>) => ({ seq: sample.seq ?? null }), + ) as (samples: unknown[]) => { actionable: unknown[]; ignored: unknown[] }; + + const pane = (name: string, bottomGapPx: number, bottomGapRatio: number) => ({ + name, + visible: true, + widthPx: 300, + heightPx: 600, + bottomGapPx, + bottomGapRatio, + contentNodeCount: 4, + }); + const sample = (paneGaps: unknown[]) => ({ + seq: 1, + projectManagement: { + pageKind: "project-management-mdtodo", + selectedTaskRef: { hash: "sha256:opaque" }, + paneGaps, + }, + }); + + assert.deepEqual( + projectManagementPaneGapRows([sample([pane("task-tree", 391, 0.618)])]), + { actionable: [], ignored: [], valuesRedacted: true }, + ); + assert.equal( + projectManagementPaneGapRows([sample([pane("task-detail", 391, 0.618)])]).actionable.length, + 1, + ); + assert.equal( + projectManagementPaneGapRows([sample([ + pane("task-tree", 240, 0.4), + pane("task-detail", 180, 0.3), + ])]).actionable.length, + 1, + ); +}); From e20dee77d89b59e5e2f32efca7adee37b65d7a20 Mon Sep 17 00:00:00 2001 From: Codex <codex@local> Date: Mon, 13 Jul 2026 14:44:16 +0200 Subject: [PATCH 20/21] =?UTF-8?q?fix:=20=E6=94=B6=E6=95=9B=E6=80=A7?= =?UTF-8?q?=E8=83=BD=E9=87=87=E9=9B=86=E6=8E=A7=E5=88=B6=E9=9D=A2=E8=BE=93?= =?UTF-8?q?=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/src/hwlab-node-web-observe-render.ts | 17 ++++- .../src/hwlab-node/web-observe-render.test.ts | 30 ++++++++ .../hwlab-node/web-observe-scripts.test.ts | 73 ++++++++++++++++++- scripts/src/hwlab-node/web-observe-scripts.ts | 17 ++++- .../web-probe-observe-actions.test.ts | 26 +++++++ 5 files changed, 157 insertions(+), 6 deletions(-) diff --git a/scripts/src/hwlab-node-web-observe-render.ts b/scripts/src/hwlab-node-web-observe-render.ts index 8b825f8e..fb9d555c 100644 --- a/scripts/src/hwlab-node-web-observe-render.ts +++ b/scripts/src/hwlab-node-web-observe-render.ts @@ -165,14 +165,15 @@ function renderWebObserveStatusTable(value: Record<string, unknown>): string { ] : []), ...(Object.keys(exactCommand).length > 0 ? [ "Exact command:", - webObserveTable(["COMMAND", "TYPE", "PHASE", "OK", "STATUS", "REPORT_SHA", "DETAIL"], [[ + webObserveTable(["COMMAND", "TYPE", "PHASE", "OK", "STATUS", "PROFILE_SHA", "REPORT_SHA", "DETAIL"], [[ webObserveShort(webObserveText(exactCommand.commandId), 40), exactCommand.type, exactCommand.phase, exactCommand.ok, exactResult?.status, + webObserveShort(webObserveText(exactResult?.profileSha256 ?? exactErrorDetails?.profileSha256), 32), webObserveShort(webObserveText(exactResult?.reportSha256 ?? exactErrorDetails?.reportSha256), 32), - webObserveShort(webObserveText(exactError.message ?? exactResult?.reportPath ?? exactErrorDetails?.reportPath), 96), + webObserveShort(webObserveText(exactError.message ?? exactResult?.reportPath ?? exactErrorDetails?.reportPath ?? exactResult?.profilePath ?? exactErrorDetails?.profilePath), 96), ]]), "", ] : []), @@ -436,6 +437,7 @@ function renderWebObserveCommandTable(value: Record<string, unknown>): string { const details = nullableRecord(error?.details); const replayEvidence = result ?? details; const replayScreenshot = record(result?.screenshot ?? details?.screenshot); + const performanceCapture = observerCommand?.type === "performanceCapture" ? result ?? details : null; const rawReadiness = record(error?.navigationReadiness) ?? record(details?.readiness) ?? record(details?.readinessAfterWait) ?? record(details?.readinessBeforeClick); const readiness = record(rawReadiness?.snapshot) ?? rawReadiness; const id = webObserveText(value.id); @@ -454,6 +456,17 @@ function renderWebObserveCommandTable(value: Record<string, unknown>): string { webObserveShort(webObserveText(asyncTurn?.traceId), 24), webObserveShort(webObserveText(result?.mark ?? result?.currentUrl ?? observer?.error ?? observer?.queued), 80), ]]), + ...(performanceCapture !== null ? [ + "", + "Performance capture:", + webObserveTable(["CAPTURE", "DURATION_MS", "PROFILE_SHA", "REPORT_SHA", "COLLECT"], [[ + performanceCapture.captureId, + performanceCapture.durationMs, + webObserveShort(webObserveText(performanceCapture.profileSha256), 32), + webObserveShort(webObserveText(performanceCapture.reportSha256), 32), + `observe collect ${id} --file ${webObserveText(performanceCapture.reportPath)}`, + ]]), + ] : []), ...(observerCommand?.type === "validateWorkbenchKafkaDebugReplay" && replayEvidence !== null ? [ "", ...renderWorkbenchKafkaDebugReplayEvidence( diff --git a/scripts/src/hwlab-node/web-observe-render.test.ts b/scripts/src/hwlab-node/web-observe-render.test.ts index ed3d35ad..a0213c0a 100644 --- a/scripts/src/hwlab-node/web-observe-render.test.ts +++ b/scripts/src/hwlab-node/web-observe-render.test.ts @@ -25,6 +25,36 @@ test("observe command renderer separates control completion from async turn term assert.match(text, /--view turn-summary --command-id cmd-fixture/u); }); +test("observe command renderer exposes bounded performance capture artifact drill-down", () => { + const rendered = withWebObserveCommandRendered({ + ok: true, + status: "control-completed", + command: "web-probe observe command", + id: "webobs-fixture", + commandId: "cmd-performance", + observerCommand: { type: "performanceCapture" }, + observer: { + ok: true, + completedAt: "2026-07-13T12:00:00.000Z", + result: { + captureId: "cpu-fixture", + durationMs: 1200, + profileSha256: "sha256:profile", + reportPath: ".state/web-observe/performance/cpu-fixture/summary.json", + reportSha256: "sha256:report", + }, + }, + control: { executionStatus: "completed", businessTurnTerminalImplied: false }, + asyncTurn: { applicable: false, submissionStatus: "not-applicable", terminalStatus: null, terminalObserved: false }, + }); + const text = String(rendered.renderedText); + assert.match(text, /Performance capture:/u); + assert.match(text, /sha256:profile/u); + assert.match(text, /sha256:report/u); + assert.match(text, /observe collect webobs-fixture --file .*summary\.json/u); + assert.doesNotMatch(text, /nodes.*callFrame/u); +}); + test("observe status renderer exposes the exact command phase and failed report evidence", () => { const rendered = withWebObserveStatusRendered({ ok: true, diff --git a/scripts/src/hwlab-node/web-observe-scripts.test.ts b/scripts/src/hwlab-node/web-observe-scripts.test.ts index 78e69a86..e20b8416 100644 --- a/scripts/src/hwlab-node/web-observe-scripts.test.ts +++ b/scripts/src/hwlab-node/web-observe-scripts.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "bun:test"; -import { nodeWebObserveStatusNodeScript } from "./web-observe-scripts"; +import { nodeWebObserveStatusNodeScript, nodeWebObserveWaitCommandShell } from "./web-observe-scripts"; async function runStatusScript(stateDir: string, commandId: string): Promise<Record<string, any>> { const child = Bun.spawn(["bash", "-lc", nodeWebObserveStatusNodeScript(1, "NC01", "v03", commandId)], { @@ -58,6 +58,77 @@ test("observe status returns one exact completed command without prompt payloads assert.equal(status.exactCommand.valuesRedacted, true); }); +test("observe status summarizes performance capture artifact without embedding CPU profile", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-performance-status-")); + const commandId = "cmd-performance"; + await mkdir(join(stateDir, "commands", "done"), { recursive: true }); + await writeFile(join(stateDir, "commands", "done", `${commandId}.json`), JSON.stringify({ + ok: true, + commandId, + type: "performanceCapture", + completedAt: "2026-07-13T12:00:00.000Z", + result: { + ok: true, + captureId: "cpu-fixture", + durationMs: 1200, + profile: { nodes: [{ id: 1, callFrame: { functionName: "large-profile-payload" } }] }, + artifact: { + path: ".state/web-observe/performance/cpu-fixture/profile.cpuprofile", + sha256: "sha256:profile", + summaryPath: ".state/web-observe/performance/cpu-fixture/summary.json", + summarySha256: "sha256:report", + }, + }, + })); + + const status = await runStatusScript(stateDir, commandId); + assert.equal(status.exactCommand.result.captureId, "cpu-fixture"); + assert.equal(status.exactCommand.result.profileSha256, "sha256:profile"); + assert.equal(status.exactCommand.result.reportSha256, "sha256:report"); + assert.equal(JSON.stringify(status).includes("large-profile-payload"), false); +}); + +test("observe command wait emits bounded performance capture summary", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-performance-wait-")); + const commandId = "cmd-performance"; + await mkdir(join(stateDir, "commands", "done"), { recursive: true }); + await writeFile(join(stateDir, "commands", "done", `${commandId}.json`), JSON.stringify({ + ok: true, + commandId, + type: "performanceCapture", + completedAt: "2026-07-13T12:00:00.000Z", + result: { + ok: true, + captureId: "cpu-fixture", + durationMs: 1200, + profile: { nodes: [{ id: 1, callFrame: { functionName: "large-profile-payload" } }] }, + artifact: { + path: ".state/web-observe/performance/cpu-fixture/profile.cpuprofile", + sha256: "sha256:profile", + summaryPath: ".state/web-observe/performance/cpu-fixture/summary.json", + summarySha256: "sha256:report", + }, + }, + })); + const child = Bun.spawn(["bash", "-lc", nodeWebObserveWaitCommandShell(commandId, 1000)], { + env: { ...process.env, state_dir: stateDir }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + assert.equal(exitCode, 0, stderr); + const summary = JSON.parse(stdout); + assert.equal(summary.status, "done"); + assert.equal(summary.result.profileSha256, "sha256:profile"); + assert.equal(summary.result.reportSha256, "sha256:report"); + assert.equal(stdout.includes("large-profile-payload"), false); + assert.ok(Buffer.byteLength(stdout) < 2048); +}); + test("observe status preserves bounded existing-session refresh evidence", async () => { const stateDir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-existing-refresh-status-")); const commandId = "cmd-existing-refresh"; diff --git a/scripts/src/hwlab-node/web-observe-scripts.ts b/scripts/src/hwlab-node/web-observe-scripts.ts index 6bcda0ea..841d7852 100644 --- a/scripts/src/hwlab-node/web-observe-scripts.ts +++ b/scripts/src/hwlab-node/web-observe-scripts.ts @@ -101,6 +101,7 @@ export function nodeWebObserveStatusNodeScript(tailLines: number, node: string, const compactTracePhase=(value)=>{const item=value&&typeof value==='object'?value:null;return item?{observed:item.observed??null,stable:item.stable??null,reason:item.reason||null,status:item.status||null,elapsedMs:item.elapsedMs??null,sampleCount:item.sampleCount??null,snapshot:compactTraceSnapshot(item.snapshot),valuesRedacted:true}:null;}; const compactTraceReadabilityEvidence=(value)=>{const item=value&&typeof value==='object'?value:null;if(!item)return{};const scope=item.scope&&typeof item.scope==='object'?item.scope:null;const disclosure=item.disclosure&&typeof item.disclosure==='object'?item.disclosure:null;const retention=item.runningRetention&&typeof item.runningRetention==='object'?item.runningRetention:null;return{startedDuringRunning:item.startedDuringRunning??null,forcedExpansion:item.forcedExpansion??null,scope:scope?{selector:short(scope.selector),conversationCount:scope.conversationCount??null,productCardCount:scope.productCardCount??null,debugPanelCountInsideConversation:scope.debugPanelCountInsideConversation??null,isolatedDebugExcluded:scope.isolatedDebugExcluded??null,valuesRedacted:true}:null,disclosure:disclosure?{openBefore:disclosure.openBefore??null,openAfterExpansion:disclosure.openAfterExpansion??null,openAfterTerminal:disclosure.openAfterTerminal??null,autoReadable:disclosure.autoReadable??null,valuesRedacted:true}:null,running:compactTracePhase(item.running),terminalArrival:compactTracePhase(item.terminalArrival),terminal:compactTracePhase(item.terminal),runningRetention:retention?{mode:retention.mode||null,passed:retention.passed??null,retainedRowCount:retention.retainedRowCount??null,runningEventCount:retention.runningEventCount??null,terminalEventCount:retention.terminalEventCount??null,runningSourceSeqMin:retention.runningSourceSeqMin??null,runningSourceSeqMax:retention.runningSourceSeqMax??null,terminalSourceSeqMin:retention.terminalSourceSeqMin??null,terminalSourceSeqMax:retention.terminalSourceSeqMax??null,valuesRedacted:true}:null,retainedRunningRowCount:item.retainedRunningRowCount??null,failures:Array.isArray(item.failures)?item.failures.slice(0,8).map((failure)=>({code:failure&&failure.code||null,message:short(failure&&failure.message),valuesRedacted:true})):null};}; const compactScreenshot=(value)=>value&&typeof value==='object'?{path:value.path||null,sha256:value.sha256||null,available:value.available??null,valuesRedacted:true}:null; + const compactPerformanceCapture=(value)=>{const item=value&&typeof value==='object'?value:null;const artifact=item&&item.artifact&&typeof item.artifact==='object'?item.artifact:null;if(!item&&!artifact)return{};return{captureId:item&&item.captureId||artifact&&artifact.captureId||null,durationMs:item&&item.durationMs!==undefined?item.durationMs:artifact&&artifact.durationMs!==undefined?artifact.durationMs:null,profilePath:artifact&&artifact.path||null,profileSha256:artifact&&artifact.sha256||null,reportPath:artifact&&artifact.summaryPath||null,reportSha256:artifact&&artifact.summarySha256||null,valuesRedacted:true};}; const commandSummary=(name)=>{ const root=commandDir(name); let entries=[]; try{entries=fs.readdirSync(root).filter((item)=>item.endsWith('.json')).sort();}catch{} @@ -115,7 +116,7 @@ export function nodeWebObserveStatusNodeScript(tailLines: number, node: string, if(!parsed)continue; const result=parsed.result&&typeof parsed.result==='object'?parsed.result:null; const error=parsed.error&&typeof parsed.error==='object'?parsed.error:null; - return {commandId:exactCommandId,phase:bucket,ok:parsed.ok??null,type:parsed.type||null,createdAt:parsed.createdAt||null,completedAt:parsed.completedAt||null,failedAt:parsed.failedAt||null,result:result?{ok:result.ok??null,status:result.status||null,profile:result.profile||null,sessionId:result.sessionId||null,traceId:result.traceId||null,traceIds:Array.isArray(result.traceIds)?result.traceIds.slice(0,4):[],runIds:Array.isArray(result.runIds)?result.runIds.slice(0,4):[],commandIds:Array.isArray(result.commandIds)?result.commandIds.slice(0,4):[],topic:result.topic||null,groupId:result.groupId||null,groupPrefix:result.groupPrefix||null,receivedCount:result.receivedCount??null,appliedCount:result.appliedCount??null,terminalStatus:result.terminalStatus||null,...compactReplayEvidence(result),...(parsed.type==='validateExistingSessionRefresh'?compactExistingSessionRefreshEvidence(result):{}),...compactTraceReadabilityEvidence(result),warmRunnerReused:result.warmRunnerReused??null,forbiddenRequestCount:result.forbiddenRequestCount??null,replayedEventCount:result.replayedEventCount??null,reportPath:result.reportPath||null,reportSha256:result.reportSha256||null,screenshot:compactScreenshot(result.screenshot),valuesRedacted:true}:null,error:error?{name:error.name||null,message:short(error.message,200),details:error.details&&typeof error.details==='object'?{profile:error.details.profile||null,...compactReplayEvidence(error.details),...(parsed.type==='validateExistingSessionRefresh'?compactExistingSessionRefreshEvidence(error.details):{}),...compactTraceReadabilityEvidence(error.details),sessionId:error.details.sessionId||null,traceId:error.details.traceId||null,traceIds:Array.isArray(error.details.traceIds)?error.details.traceIds.slice(0,4):[],topic:error.details.topic||null,groupId:error.details.groupId||null,receivedCount:error.details.receivedCount??null,appliedCount:error.details.appliedCount??null,terminalStatus:error.details.terminalStatus||null,forbiddenRequestCount:error.details.forbiddenRequestCount??null,reportPath:error.details.reportPath||null,reportSha256:error.details.reportSha256||null,screenshot:compactScreenshot(error.details.screenshot),valuesRedacted:true}:null}:null,valuesRedacted:true}; + return {commandId:exactCommandId,phase:bucket,ok:parsed.ok??null,type:parsed.type||null,createdAt:parsed.createdAt||null,completedAt:parsed.completedAt||null,failedAt:parsed.failedAt||null,result:result?{ok:result.ok??null,status:result.status||null,sessionId:result.sessionId||null,traceId:result.traceId||null,traceIds:Array.isArray(result.traceIds)?result.traceIds.slice(0,4):[],runIds:Array.isArray(result.runIds)?result.runIds.slice(0,4):[],commandIds:Array.isArray(result.commandIds)?result.commandIds.slice(0,4):[],topic:result.topic||null,groupId:result.groupId||null,groupPrefix:result.groupPrefix||null,receivedCount:result.receivedCount??null,appliedCount:result.appliedCount??null,terminalStatus:result.terminalStatus||null,...compactReplayEvidence(result),...(parsed.type==='validateExistingSessionRefresh'?compactExistingSessionRefreshEvidence(result):{}),...compactTraceReadabilityEvidence(result),warmRunnerReused:result.warmRunnerReused??null,forbiddenRequestCount:result.forbiddenRequestCount??null,replayedEventCount:result.replayedEventCount??null,reportPath:result.reportPath||null,reportSha256:result.reportSha256||null,screenshot:compactScreenshot(result.screenshot),...(parsed.type==='performanceCapture'?compactPerformanceCapture(result):{profile:result.profile||null}),valuesRedacted:true}:null,error:error?{name:error.name||null,message:short(error.message,200),details:error.details&&typeof error.details==='object'?{...compactReplayEvidence(error.details),...(parsed.type==='validateExistingSessionRefresh'?compactExistingSessionRefreshEvidence(error.details):{}),...compactTraceReadabilityEvidence(error.details),sessionId:error.details.sessionId||null,traceId:error.details.traceId||null,traceIds:Array.isArray(error.details.traceIds)?error.details.traceIds.slice(0,4):[],topic:error.details.topic||null,groupId:error.details.groupId||null,receivedCount:error.details.receivedCount??null,appliedCount:error.details.appliedCount??null,terminalStatus:error.details.terminalStatus||null,forbiddenRequestCount:error.details.forbiddenRequestCount??null,reportPath:error.details.reportPath||null,reportSha256:error.details.reportSha256||null,screenshot:compactScreenshot(error.details.screenshot),...(parsed.type==='performanceCapture'?compactPerformanceCapture(error.details):{profile:error.details.profile||null}),valuesRedacted:true}:null}:null,valuesRedacted:true}; } return {commandId:exactCommandId,phase:'not-found',ok:false,valuesRedacted:true}; }; @@ -425,12 +426,22 @@ export function nodeWebObserveWaitCommandShell(commandId: string, waitMs: number ].join("\n"); } const waitSeconds = Math.max(1, Math.ceil(waitMs / 1000)); + const printCommandSummary = `node -e ${shellQuote(` +const fs=require('fs'); +const file=process.argv[1]; +const phase=process.argv[2]; +const parsed=JSON.parse(fs.readFileSync(file,'utf8')); +const result=parsed&&parsed.result&&typeof parsed.result==='object'?parsed.result:null; +const artifact=result&&result.artifact&&typeof result.artifact==='object'?result.artifact:null; +const summary={ok:parsed.ok!==false,queued:false,commandId:parsed.commandId||parsed.id||null,type:parsed.type||null,status:phase,completedAt:parsed.completedAt||null,failedAt:parsed.failedAt||null,result:result?{ok:result.ok??null,status:result.status||null,captureId:result.captureId||artifact&&artifact.captureId||null,durationMs:result.durationMs!==undefined?result.durationMs:artifact&&artifact.durationMs!==undefined?artifact.durationMs:null,profilePath:artifact&&artifact.path||null,profileSha256:artifact&&artifact.sha256||null,reportPath:artifact&&artifact.summaryPath||result.reportPath||null,reportSha256:artifact&&artifact.summarySha256||result.reportSha256||null,valuesRedacted:true}:null,error:parsed.error?{name:parsed.error.name||null,message:String(parsed.error.message||'').replace(/\\s+/g,' ').slice(0,200),valuesRedacted:true}:null,valuesRedacted:true}; +console.log(JSON.stringify(summary)); +`)}`; return [ `command_id=${shellQuote(commandId)}`, `deadline=$(( $(date +%s) + ${waitSeconds} ))`, "while [ \"$(date +%s)\" -le \"$deadline\" ]; do", - " if [ -f \"$state_dir/commands/done/${command_id}.json\" ]; then cat \"$state_dir/commands/done/${command_id}.json\"; exit 0; fi", - " if [ -f \"$state_dir/commands/failed/${command_id}.json\" ]; then cat \"$state_dir/commands/failed/${command_id}.json\"; exit 2; fi", + ` if [ -f "$state_dir/commands/done/\${command_id}.json" ]; then ${printCommandSummary} "$state_dir/commands/done/\${command_id}.json" done; exit 0; fi`, + ` if [ -f "$state_dir/commands/failed/\${command_id}.json" ]; then ${printCommandSummary} "$state_dir/commands/failed/\${command_id}.json" failed; exit 2; fi`, " sleep 1", "done", "printf '{\"ok\":true,\"queued\":true,\"waitTimedOut\":true,\"commandId\":\"%s\",\"stateDir\":\"%s\"}\\n' \"$command_id\" \"$state_dir\"", diff --git a/scripts/src/hwlab-node/web-probe-observe-actions.test.ts b/scripts/src/hwlab-node/web-probe-observe-actions.test.ts index 149facfc..3a699328 100644 --- a/scripts/src/hwlab-node/web-probe-observe-actions.test.ts +++ b/scripts/src/hwlab-node/web-probe-observe-actions.test.ts @@ -110,6 +110,32 @@ test("web observe action JSON recovery accepts concrete start contract", () => { assert.equal(resolved.diagnostics.stdoutContractAccepted, true); }); +test("web observe action JSON recovery accepts bounded performance capture command summary", () => { + const resolved = resolveWebObserveActionJson({ + stdout: JSON.stringify({ + ok: true, + queued: false, + commandId: "cmd-performance", + type: "performanceCapture", + status: "done", + result: { + captureId: "cpu-fixture", + profileSha256: "sha256:profile", + reportSha256: "sha256:report", + valuesRedacted: true, + }, + valuesRedacted: true, + }), + stderr: "", + exitCode: 0, + timedOut: false, + }, "command"); + + assert.equal(resolved.source, "stdout"); + assert.equal(resolved.parsed?.commandId, "cmd-performance"); + assert.equal(resolved.diagnostics.stdoutContractAccepted, true); +}); + test("web observe action JSON recovery reads dump wrapper for status contract", async () => { const dir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-status-dump-")); const dumpPath = join(dir, "status.json"); From 62acb55a0798495207ec893c135f08cf629bc7aa Mon Sep 17 00:00:00 2001 From: Codex <codex@local> Date: Mon, 13 Jul 2026 14:54:05 +0200 Subject: [PATCH 21/21] =?UTF-8?q?fix(web-probe):=20=E6=8E=A5=E5=8F=97?= =?UTF-8?q?=E7=A7=BB=E5=8A=A8=E7=AB=AF=E5=B7=B2=E9=80=89=20MDTODO=20?= =?UTF-8?q?=E8=AF=A6=E6=83=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...-node-web-observe-runner-control-source.ts | 8 ++-- ...web-observe-runner-sampling-source.test.ts | 38 +++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/scripts/src/hwlab-node-web-observe-runner-control-source.ts b/scripts/src/hwlab-node-web-observe-runner-control-source.ts index 400a90b5..d7a72f07 100644 --- a/scripts/src/hwlab-node-web-observe-runner-control-source.ts +++ b/scripts/src/hwlab-node-web-observe-runner-control-source.ts @@ -1120,12 +1120,14 @@ async function waitForProjectManagementCommandReady(options = {}) { while (Date.now() <= deadline) { last = await projectManagementCommandSnapshot(); const path = String(last?.path || safeUrlPath(currentPageUrl()) || ""); + const needsTask = /\/tasks\//u.test(path); + const selectedTaskReady = Boolean(last?.selectedTaskId || last?.selectedTaskRef?.hash || last?.taskBodyVisible === true || last?.launchButtonVisible === true); + const taskCollectionReady = Number(last?.taskCount || 0) > 0; const baseReady = last?.pageKind === "project-management-mdtodo" && Number(last?.sourceCount || 0) > 0 && Number(last?.fileCount || 0) > 0 - && Number(last?.taskCount || 0) > 0; - const needsTask = /\/tasks\//u.test(path); - const taskReady = !needsTask || Boolean(last?.selectedTaskId || last?.selectedTaskRef?.hash || last?.taskBodyVisible === true || last?.launchButtonVisible === true); + && (needsTask ? selectedTaskReady : taskCollectionReady); + const taskReady = !needsTask || selectedTaskReady; const needsReport = /\/reports\//u.test(path); const reportReady = !needsReport || last?.reportPreviewVisible === true || last?.reportFullscreenVisible === true; if (baseReady && taskReady && reportReady) return { ok: true, reason: "project-management-command-ready", durationMs: Date.now() - started, snapshot: last, valuesRedacted: true }; diff --git a/scripts/src/hwlab-node-web-observe-runner-sampling-source.test.ts b/scripts/src/hwlab-node-web-observe-runner-sampling-source.test.ts index c72f3596..2462b356 100644 --- a/scripts/src/hwlab-node-web-observe-runner-sampling-source.test.ts +++ b/scripts/src/hwlab-node-web-observe-runner-sampling-source.test.ts @@ -6,6 +6,7 @@ import { nodeWebObserveRunnerSamplingSource, } from "./hwlab-node-web-observe-runner-sampling-source"; import { nodeWebObserveAnalyzerProjectSource } from "./hwlab-node-web-observe-analyzer-project-source"; +import { nodeWebObserveRunnerControlSource } from "./hwlab-node-web-observe-runner-control-source"; test("MDTODO task tree pane gap treats the pinned facts footer as content", async () => { assert.match( @@ -93,3 +94,40 @@ test("MDTODO pane gap ignores a naturally short task tree but keeps primary pane 1, ); }); + +test("MDTODO command readiness accepts a selected mobile task when the hidden tree has no visible rows", async () => { + const source = nodeWebObserveRunnerControlSource(); + const makeReady = (snapshot: Record<string, unknown>) => new Function( + "projectManagementCommandSnapshot", + "safeUrlPath", + "currentPageUrl", + "page", + `${source}; return waitForProjectManagementCommandReady;`, + )( + async () => snapshot, + () => String(snapshot.path ?? ""), + () => `https://hwlab.pikapython.com${String(snapshot.path ?? "")}`, + { waitForTimeout: async () => undefined }, + ) as (options: { timeoutMs: number }) => Promise<{ ok: boolean; reason: string }>; + + const selectedMobileTask = { + path: "/projects/mdtodo/sources/source/files/file/tasks/R1", + pageKind: "project-management-mdtodo", + sourceCount: 3, + fileCount: 18, + taskCount: 0, + selectedTaskRef: { hash: "sha256:opaque" }, + taskBodyVisible: true, + launchButtonVisible: true, + }; + assert.equal((await makeReady(selectedMobileTask)({ timeoutMs: 1 })).ok, true); + + const unselectedRoot = { + path: "/projects/mdtodo", + pageKind: "project-management-mdtodo", + sourceCount: 3, + fileCount: 18, + taskCount: 0, + }; + assert.equal((await makeReady(unselectedRoot)({ timeoutMs: 1 })).ok, false); +});