From dff355c5a465a19608af4357a07ab0b39ccfa9ca Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 21 May 2026 14:04:33 +0000 Subject: [PATCH] codex: expose auth broker pr preflight contract --- docs/reference/cli.md | 2 +- scripts/auth-broker-contract-test.ts | 10 + .../code-queue-pr-preflight-contract-test.ts | 227 ++++++++++++++++++ scripts/src/auth-broker.ts | 24 ++ scripts/src/code-queue.ts | 136 +++++++++-- .../code-queue/src/runtime-preflight.ts | 109 ++++++++- 6 files changed, 474 insertions(+), 34 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 69e5dcc2..9e6f09fe 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -44,7 +44,7 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI - `schedule list|get|runs|run|retry-run|delete|upsert-pgdata-backup` 管理 backend-core 定时任务和运行历史。`schedule list`、`schedule get`、`schedule runs --limit N` 和 `schedule runs --limit N` 是只读观察入口;`schedule run`、`schedule retry-run`、`schedule delete` 和 `schedule upsert-pgdata-backup` 会触发运行或写入配置,生产恢复时必须有明确授权。`schedule runs --limit N` 是全局历史视图,返回 `scope=global` 和 `scheduleId=null`;`schedule runs --limit N` 是指定 schedule 历史视图,返回 `scope=schedule` 和对应 `scheduleId`。CLI 必须拒绝 `schedule runs 50` 这类纯数字位置参数,并提示使用 `schedule runs --limit 50`,避免把空数组误判成“没有历史 run”。`schedule run --wait-ms N` 触发同一 schedule,并且即使 wait 超时也必须返回 `newRunId` 和 `observeCommand`;`schedule retry-run ` 只接受 failed run,从原 run 反查 `scheduleId` 后重触发同一 schedule,并输出 `originalRunId`、`scheduleId`、`newRunId` 和 `observeCommand`。当 backend-core 目标容器缺失或只观察到 verify-only 容器时,schedule/microservice 命令必须以非零退出并返回 `failureKind=target-stack-not-running`、`runnerDisposition=infra-blocked`、`readOnlyCommands` 和 `authorizationRequiredForRecovery`,不得把 Docker 的 `No such container` 当成成功的空历史。 - `codex deploy ` 是旧 Code Queue 兼容部署入口,已禁用以防止维护通道直连 D601 部署 Code Queue;当前 dev 自动化只做 `ci run-dev-e2e` smoke,不提供 Code Queue CD,详细规则见 `docs/reference/codex-deploy.md`。 - `codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue queueId] [--provider-id id] [--cwd path] [--model model] [--reasoning-effort effort] [--execution-mode mode] [--max-attempts N] [--reference-task-id id] [--dry-run]` 通过 backend-core 私有代理向稳定 `code-queue` 用户服务路径提交任务;prompt 必须且只能来自位置参数、文件或 stdin 之一,`--dry-run` 只返回结构化请求且不实际入队。dry-run 会额外输出 `routingRecommendation`,包含推荐 route、runner、model、风险信号、prompt 自包含/issue 非唯一来源/prod-secret-DB 禁止/运行态或 release 禁止/证据要求/中等复杂度候选等 guard 状态;同时输出 `policyContract`,固定暴露 GPT-5.5、DeepSeek、MiniMax 的风险分层、并发上限和外部 provider 429 退避处置。该建议只用于指挥官 preflight,不会改写 payload,不改变 runtime admission,也不假设生产 MiniMax 或 DeepSeek 可用。提交确认和 dry-run 必须返回完整 prompt、字符数和 `truncated=false`,不能套用任务详情的预览截断策略,否则长任务 prompt 无法被人工验收。真实提交会经过本机本地串行化保护和短节流,避免同一指挥端并发 submit 把低内存主机或 `code-queue-mgr` 控制面打抖;返回值会附带 `submitConcurrencyGuard` 说明本次提交的锁与等待信息。backend-core 默认把提交、队列 CRUD、已读状态、历史摘要和轻量 Trace 读取分流到主 server `code-queue-mgr`,由它写入主 PostgreSQL;D601 scheduler 只轮询并执行已入库任务。 -- `codex pr-preflight [--remote] [--push-dry-run --push-dry-run-ref refs/heads/probe/] [--pr-create-dry-run --pr-create-dry-run-head ] [--issue N] [--full]` 通过稳定 `code-queue` proxy 请求 D601 scheduler `/api/runtime-preflight`,用于 PR 型派单 admission。输出会压缩展示 scheduler/runner 的 token 覆盖、工具、agent port、Git worktree、GitHub egress、repo/issue/PR 只读探测、可选 push dry-run,以及可选 PR body/create dry-run guard;只报告 `GH_TOKEN`/`GITHUB_TOKEN` 是否存在和来源 key,不打印值。缺少 env token 时顶层 `ok=false`、`runnerDisposition=infra-blocked`,`tokenCoverage.missing` 同时列出 `GH_TOKEN` 与 `GITHUB_TOKEN`,并输出 `preflight.authBroker.source="broker/auth-broker-needed"`。`preflight.prCapabilityContract` 是 runner-facing 合同摘要,必须包含目标分支、token 来源 key、`systemGhBinaryRequiredForWrites=false`、UniDesk REST `bun scripts/cli.ts gh` 可用性、push dry-run/PR create dry-run 的 `writesRemote=false`、expected PR handoff 和 `gh pr merge` 的 `unsupported-command` 边界;系统 `gh` binary 缺失只进入 `tools.systemGhBinary`,不得误判为 UniDesk REST `gh` CLI 不可用。`--remote` 在 runner-like 环境里不再依赖本地 `unidesk-backend-core`、`unidesk-database`、`baidu-netdisk-backend` 容器存在;这些缺失只作为本地观测证据。若远程控制面可达,则继续走远程控制面结果;若远程控制面不可达,则结构化返回 `failureKind=control-plane-missing` / `degradedReason=remote-control-plane-unreachable`,而不是把本地 `backend-core-container-missing` 当作最终阻塞。`--pr-create-dry-run` 不 POST GitHub,只证明 runner 内 PR body 生成、`scripts/cli.ts gh pr create --dry-run` 和 branch 参数形态可用;服务端创建权限仍以 token auth、repo/issue/PR read、push dry-run 和最终授权后的真实 PR 创建结果为准。 +- `codex pr-preflight [--remote] [--push-dry-run --push-dry-run-ref refs/heads/probe/] [--pr-create-dry-run --pr-create-dry-run-head ] [--issue N] [--full]` 通过稳定 `code-queue` proxy 请求 D601 scheduler `/api/runtime-preflight`,用于 PR 型派单 admission。输出会压缩展示 scheduler/runner 的 token 覆盖、Auth Broker source/capability/nextAction、工具、agent port、Git worktree、GitHub egress、repo/issue/PR 只读探测、可选 push dry-run,以及可选 PR body/create dry-run guard;只报告 `GH_TOKEN`/`GITHUB_TOKEN` 是否存在和来源 key,不打印值。当 auth-broker 配置存在时,`tokenCoverage.source="auth-broker"`、`credentialSource="broker-issued-token"` 且 runner env token 不是成功前提;当仅 env token 存在时,`credentialSource="env-token"` 且 `preflight.authBroker.nextAction="use-env-token-until-auth-broker-live"`;两者都缺失时顶层 `ok=false`、`runnerDisposition=infra-blocked`、`degradedReason=auth-broker-needed`,`tokenCoverage.missing` 同时列出 `GH_TOKEN` 与 `GITHUB_TOKEN`,并输出 `preflight.authBroker.source="broker/auth-broker-needed"`、`capability.source="missing-token"`。`preflight.prCapabilityContract` 是 runner-facing 合同摘要,必须包含目标分支、token/auth 来源、`systemGhBinaryRequiredForWrites=false`、UniDesk REST `bun scripts/cli.ts gh` 可用性、push dry-run/PR create dry-run 的 `writesRemote=false`、expected PR handoff、真实 PR 创建需要 commander 授权和 `gh pr merge` 的 `unsupported-command` 边界;系统 `gh` binary 缺失只进入 `tools.systemGhBinary`,不得误判为 UniDesk REST `gh` CLI 不可用。`--remote` 在 runner-like 环境里不再依赖本地 `unidesk-backend-core`、`unidesk-database`、`baidu-netdisk-backend` 容器存在;这些缺失只作为本地观测证据。若远程控制面可达,则继续走远程控制面结果;若远程控制面不可达,则结构化返回 `failureKind=control-plane-missing` / `degradedReason=remote-control-plane-unreachable`,而不是把本地 `backend-core-container-missing` 当作最终阻塞。`--pr-create-dry-run` 不 POST GitHub,只证明 runner 内 PR body 生成、`scripts/cli.ts gh pr create --dry-run` 和 branch 参数形态可用;服务端创建权限仍以 token/auth broker、repo/issue/PR read、push dry-run 和最终授权后的真实 PR 创建结果为准。 - `codex task ` 通过 Code Queue 私有代理按任务 ID 查询结构化审阅摘要;默认只返回任务身份、执行 Provider、工作目录、attempt 计数、原始 prompt、最终 response、最后错误和渐进披露命令,适合指挥官审阅完成未读任务且避免上下文爆炸。需要旧式详细摘要时显式加 `--detail`;需要完整 prompt/response 文本时加 `--full`;需要工具调用、judge、attempt 全量摘要时使用 `--detail --full --tool-limit N`。该摘要读取默认由主 server `code-queue-mgr` 从 PostgreSQL 返回,不依赖 D601 `code-queue-read` Service 可用。 - `codex tasks [--view supervisor|full] [--queue id] [--status succeeded|running|queued|failed|canceled|judging|retry_wait[,..]] [--unread|--unread-only] [--limit N] [--before-id id]` 通过同一私有代理输出渐进式披露视图。默认 `supervisor` 只返回 `running`、`completedUnread`、`recentCompleted`、`queued` 和 `executionDiagnostics` 摘要,不嵌入完整 Trace、final response 或全量 overview;每个条目都带 `commands.show`、`commands.trace`、`commands.output`、`commands.read` 和 `commands.full`。`--unread` 是 `--unread-only` 的别名,必须只保留未读终态;`--status` 必须真实过滤支持的状态,未知参数或未知状态必须结构化失败,不能静默忽略。需要完整当前页任务简表时显式使用 `--view full` 或 `--full`,仍受 `--limit` 和 `--before-id` 分页约束。 - `codex task --trace --tail|--from-start|--after-seq N|--before-seq N --limit N` 按页拉取 Code Queue 的逻辑 trace;响应会返回 `nextAfterSeq`、`previousBeforeSeq`、`hasMore`、`hasBefore` 和下一页/上一页命令,默认 `--trace` 取最新一页,且仍以分页 trace 为主;需要完整 prompt/最终 response 时加 `--full`,需要详细 task 摘要时加 `--detail`。 diff --git a/scripts/auth-broker-contract-test.ts b/scripts/auth-broker-contract-test.ts index bebbd8da..5aca5799 100644 --- a/scripts/auth-broker-contract-test.ts +++ b/scripts/auth-broker-contract-test.ts @@ -203,6 +203,10 @@ async function main(): Promise { assertCondition(noTokenData.failureKind === "auth-missing", "missing token should classify as auth-missing", noTokenData); assertCondition(noTokenData.degradedReason === "broker-needed", "missing token should classify as broker-needed", noTokenData); assertCondition(noTokenData.brokerNeeded === true, "missing token should set brokerNeeded", noTokenData); + const noTokenAuthBroker = noTokenData.authBroker as Record; + assertCondition(noTokenAuthBroker.source === "broker/auth-broker-needed", "missing broker endpoint should expose broker-needed auth source", noTokenAuthBroker); + assertCondition(noTokenAuthBroker.capability === "missing-token", "missing broker endpoint should expose missing-token capability", noTokenAuthBroker); + assertCondition(noTokenAuthBroker.nextAction === "configure-auth-broker", "missing broker endpoint should expose next action", noTokenAuthBroker); assertCondition(!noToken.stdout.includes("contract-secret-marker"), "missing-token response must not leak secret marker strings", noToken.stdout); const brokerReady = await runCli([ @@ -225,13 +229,19 @@ async function main(): Promise { const readyData = dataOf(brokerReady.json ?? {}); const tokenCoverage = readyData.tokenCoverage as Record; const brokerCoverage = readyData.brokerCoverage as Record; + const readyAuthBroker = readyData.authBroker as Record; const prCapability = readyData.prCapabilityContract as Record; const brokerProxy = prCapability.brokerProxy as Record; + assertCondition(readyAuthBroker.source === "auth-broker", "ready auth broker source should be auth-broker", readyAuthBroker); + assertCondition(readyAuthBroker.capability === "broker-issued-token", "ready auth broker capability should be broker-issued-token", readyAuthBroker); + assertCondition(readyAuthBroker.nextAction === "use-auth-broker", "ready auth broker next action should use broker", readyAuthBroker); assertCondition(tokenCoverage.source === "auth-broker", "ready token coverage should come from broker", tokenCoverage); assertCondition(tokenCoverage.runnerEnvTokenRequired === false, "ready token coverage should not require runner env token", tokenCoverage); assertCondition(tokenCoverage.valuesPrinted === false, "ready token coverage must not print values", tokenCoverage); assertCondition(String(brokerCoverage.endpoint).includes("http://***:***@127.0.0.1:4291/?..."), "endpoint should be sanitized", brokerCoverage); assertCondition(prCapability.targetBranch === "master", "P0 capability should preserve target branch", prCapability); + assertCondition(prCapability.authSource === "broker-issued-token", "P0 capability should expose broker-issued-token auth source", prCapability); + assertCondition(prCapability.realPrCreateRequiresCommanderAuthorization === true, "real PR create should require commander authorization", prCapability); assertCondition(prCapability.preflightCreatesPr === false && prCapability.preflightMergesPr === false, "P0 PR preflight must not write or merge", prCapability); assertCondition(brokerProxy.writesRemote === false, "P0 broker proxy should not write remote", brokerProxy); assertCondition(Array.isArray(brokerProxy.operations) && brokerProxy.operations.includes("github.pr.create"), "P0 broker proxy should include PR create dry-run operation", brokerProxy); diff --git a/scripts/code-queue-pr-preflight-contract-test.ts b/scripts/code-queue-pr-preflight-contract-test.ts index ae96290b..b5cc67b6 100644 --- a/scripts/code-queue-pr-preflight-contract-test.ts +++ b/scripts/code-queue-pr-preflight-contract-test.ts @@ -189,6 +189,101 @@ function remoteControlPlaneResult(overrides: Partial = {}): JsonReco }; } +function rawRuntimePreflightFixture(overrides: Partial = {}): JsonRecord { + return { + ok: true, + checkedAt: "2026-05-20T00:00:00.000Z", + cwd: "/workspace/unidesk", + pid: 123, + pullRequestDelivery: { + ok: true, + checkedAt: "2026-05-20T00:00:00.000Z", + tools: { + git: { ok: true, path: "/usr/bin/git", version: "git version 2.43.0" }, + gh: { ok: false, path: null, version: null }, + hub: { ok: false, path: null, version: null }, + jq: { ok: true, path: "/usr/bin/jq", version: "jq-1.7" }, + ssh: { ok: true, path: "/usr/bin/ssh", version: "OpenSSH_9.6" }, + curl: { ok: true, path: "/usr/bin/curl", version: "curl 8.5.0" }, + }, + unideskGhCli: { ok: true, path: "/workspace/unidesk/scripts/cli.ts", present: true }, + credentials: { + ghTokenPresent: false, + githubTokenPresent: false, + ghHostsConfigPresent: false, + gitCredentialsPresent: false, + }, + authBroker: { + ok: false, + configured: false, + source: "broker/auth-broker-needed", + endpointEnvKey: null, + runnerEnvTokenRequired: false, + credentialSource: null, + failureKind: "auth-missing", + degradedReason: "auth-broker-needed", + capability: { + source: "missing-token", + githubRestAuth: false, + operations: ["github.auth.status", "github.issue.read", "github.pr.read", "github.pr.create"], + systemGhBinaryRequiredForWrites: false, + preflightWritesRemote: false, + preflightCreatesPr: false, + preflightMergesPr: false, + realPrCreateRequiresCommanderAuthorization: true, + valuesPrinted: false, + }, + nextAction: "configure-auth-broker-or-env-token", + next: ["configure UNIDESK_AUTH_BROKER_URL or AUTH_BROKER_URL for broker-backed runner auth"], + valuesRead: false, + valuesPrinted: false, + }, + git: { + insideWorktree: true, + branch: "code-queue/issue-35-pr-dry-run-probe", + head: "abc1234", + originMaster: "def5678", + remoteOrigin: "git@github.com:pikasTech/unidesk.git", + home: "/root", + homeWritable: true, + knownHostsPresent: true, + privateKeyPresent: true, + }, + githubContext: { + host: "github.com", + apiBaseUrl: "https://api.github.com", + repo: "pikasTech/unidesk", + issueProbeNumber: 20, + }, + egress: { + proxy: { + selectedProxyHost: "d601-provider-egress-proxy.unidesk.svc.cluster.local", + selectedProxyPort: "18789", + selectedProxyHostResolvable: true, + }, + githubDefault: { command: "curl", args: ["-IsS", "https://github.com"], ok: true, exitCode: 0, signal: null, error: null, stdout: "", stderr: "" }, + apiDefault: { command: "curl", args: ["-IsS", "https://api.github.com"], ok: true, exitCode: 0, signal: null, error: null, stdout: "", stderr: "" }, + issueApi: null, + }, + remote: { + gitLsRemote: { command: "git", args: ["ls-remote", "--heads", "origin", "master"], ok: true, exitCode: 0, signal: null, error: null, stdout: "abc1234\trefs/heads/master\n", stderr: "" }, + gitHttpsLsRemote: null, + githubSshAuthenticated: true, + ghAuthStatus: null, + ghRepoView: null, + ghIssueView: null, + ghPrReadOnly: null, + }, + limitations: [], + risks: [ + "system gh binary is missing; UniDesk REST gh CLI remains the supported PR create/comment path when scripts/cli.ts and GH_TOKEN/GITHUB_TOKEN or auth-broker are available", + ], + }, + ports: {}, + ...overrides, + }; +} + async function main(): Promise { let observedLocalPath = ""; const remoteFallback = await codexPrPreflightQueryForTest(["--remote", "--issue", "35"], { @@ -493,6 +588,137 @@ async function main(): Promise { assertCondition(asRecord(dryRunPrContract.prCreateDryRun).writesRemote === false, "PR create dry-run must be marked non-writing", dryRunPrContract); assertCondition(asRecord(dryRunPrContract.prCreateDryRun).headBranch === "code-queue/issue-35-pr-dry-run-probe", "PR dry-run head should come from the option", dryRunPrContract); + const brokerIssuedContract = await codexPrPreflightQueryForTest(["--remote"], { + config: null, + coreFetch: () => ({ + ok: true, + status: 200, + body: { + runtimePreflight: rawRuntimePreflightFixture({ + pullRequestDelivery: { + ...asRecord(rawRuntimePreflightFixture().pullRequestDelivery), + ok: true, + authBroker: { + ok: true, + configured: true, + source: "auth-broker", + endpointEnvKey: "UNIDESK_AUTH_BROKER_URL", + runnerEnvTokenRequired: false, + credentialSource: "broker-held-github-credential", + failureKind: null, + degradedReason: null, + capability: { + source: "broker-issued-token", + githubRestAuth: true, + operations: ["github.auth.status", "github.issue.read", "github.pr.read", "github.pr.create"], + systemGhBinaryRequiredForWrites: false, + preflightWritesRemote: false, + preflightCreatesPr: false, + preflightMergesPr: false, + realPrCreateRequiresCommanderAuthorization: true, + valuesPrinted: false, + }, + nextAction: "use-auth-broker", + next: ["keep PR preflight read-only; create a real PR only after commander authorization"], + valuesRead: false, + valuesPrinted: false, + }, + }, + }), + }, + }), + }); + const brokerIssuedRecord = asRecord(brokerIssuedContract); + assertCondition(brokerIssuedRecord.ok === true, "broker-issued token branch should be ready", brokerIssuedRecord); + const brokerIssuedPreflight = asRecord(brokerIssuedRecord.preflight); + const brokerIssuedTokenCoverage = asRecord(brokerIssuedPreflight.tokenCoverage); + const brokerIssuedAuthBroker = asRecord(brokerIssuedPreflight.authBroker); + const brokerIssuedCapability = asRecord(brokerIssuedAuthBroker.capability); + const brokerIssuedPrContract = asRecord(brokerIssuedPreflight.prCapabilityContract); + assertCondition(brokerIssuedTokenCoverage.source === "auth-broker", "broker-issued branch should use auth-broker token coverage", brokerIssuedTokenCoverage); + assertCondition(brokerIssuedTokenCoverage.credentialSource === "broker-issued-token", "broker-issued branch should expose broker-issued-token capability", brokerIssuedTokenCoverage); + assertCondition(brokerIssuedAuthBroker.source === "auth-broker", "broker-issued branch should expose authBroker.source", brokerIssuedAuthBroker); + assertCondition(brokerIssuedAuthBroker.nextAction === "use-auth-broker", "broker-issued branch should expose nextAction", brokerIssuedAuthBroker); + assertCondition(brokerIssuedCapability.systemGhBinaryRequiredForWrites === false, "broker-issued branch should not require system gh binary", brokerIssuedCapability); + assertCondition(brokerIssuedCapability.realPrCreateRequiresCommanderAuthorization === true, "real PR creation should still require commander authorization", brokerIssuedCapability); + assertCondition(asRecord(brokerIssuedPrContract.authBroker).source === "auth-broker", "PR capability should include broker source", brokerIssuedPrContract); + + const envTokenContract = await codexPrPreflightQueryForTest(["--remote"], { + config: null, + coreFetch: () => ({ + ok: true, + status: 200, + body: { + runtimePreflight: rawRuntimePreflightFixture({ + pullRequestDelivery: { + ...asRecord(rawRuntimePreflightFixture().pullRequestDelivery), + ok: true, + credentials: { + ghTokenPresent: true, + githubTokenPresent: false, + ghHostsConfigPresent: false, + gitCredentialsPresent: false, + }, + authBroker: { + ok: false, + configured: false, + source: "broker/auth-broker-needed", + endpointEnvKey: null, + runnerEnvTokenRequired: false, + credentialSource: null, + failureKind: "auth-missing", + degradedReason: "auth-broker-needed", + capability: { + source: "missing-token", + githubRestAuth: false, + operations: ["github.auth.status", "github.issue.read", "github.pr.read", "github.pr.create"], + systemGhBinaryRequiredForWrites: false, + preflightWritesRemote: false, + preflightCreatesPr: false, + preflightMergesPr: false, + realPrCreateRequiresCommanderAuthorization: true, + valuesPrinted: false, + }, + nextAction: "configure-auth-broker-or-env-token", + next: ["configure UNIDESK_AUTH_BROKER_URL or AUTH_BROKER_URL for broker-backed runner auth"], + valuesRead: false, + valuesPrinted: false, + }, + }, + }), + }, + }), + }); + const envTokenRecord = asRecord(envTokenContract); + assertCondition(envTokenRecord.ok === true, "env token branch should be ready", envTokenRecord); + const envTokenPreflight = asRecord(envTokenRecord.preflight); + const envTokenCoverage = asRecord(envTokenPreflight.tokenCoverage); + const envTokenAuthBroker = asRecord(envTokenPreflight.authBroker); + assertCondition(envTokenCoverage.source === "GH_TOKEN", "env token branch should expose GH_TOKEN source", envTokenCoverage); + assertCondition(envTokenCoverage.credentialSource === "env-token", "env token branch should expose env-token capability", envTokenCoverage); + assertCondition(envTokenAuthBroker.source === "GH_TOKEN", "env token branch should not pretend broker is configured", envTokenAuthBroker); + assertCondition(envTokenAuthBroker.nextAction === "use-env-token-until-auth-broker-live", "env token branch should still point at broker migration", envTokenAuthBroker); + + const missingTokenContract = await codexPrPreflightQueryForTest(["--remote"], { + config: null, + coreFetch: () => ({ + ok: true, + status: 200, + body: { runtimePreflight: rawRuntimePreflightFixture() }, + }), + }); + const missingTokenRecord = asRecord(missingTokenContract); + assertCondition(missingTokenRecord.ok === false, "missing-token branch should fail", missingTokenRecord); + assertCondition(missingTokenRecord.failureKind === "auth-missing", "missing-token branch should classify auth-missing", missingTokenRecord); + assertCondition(missingTokenRecord.degradedReason === "auth-broker-needed", "missing-token branch should expose broker-needed degraded reason", missingTokenRecord); + const missingTokenPreflight = asRecord(missingTokenRecord.preflight); + const missingTokenAuthBroker = asRecord(missingTokenPreflight.authBroker); + const missingTokenCapability = asRecord(missingTokenAuthBroker.capability); + assertCondition(missingTokenAuthBroker.source === "broker/auth-broker-needed", "missing-token branch should expose broker/auth-broker-needed", missingTokenAuthBroker); + assertCondition(missingTokenAuthBroker.nextAction === "configure-auth-broker-or-env-token", "missing-token branch should expose nextAction", missingTokenAuthBroker); + assertCondition(missingTokenCapability.source === "missing-token", "missing-token branch should expose missing-token capability", missingTokenCapability); + assertCondition(missingTokenCapability.systemGhBinaryRequiredForWrites === false, "missing-token branch should still not require system gh binary for UniDesk gh CLI", missingTokenCapability); + process.stdout.write(`${JSON.stringify({ ok: true, checks: [ @@ -503,6 +729,7 @@ async function main(): Promise { "proxy failures return proxy-gap", "git remote failures return git-remote-gap", "combined push/PR create dry-run contract stays read-only and separates system gh from UniDesk gh CLI", + "broker-issued token, env-token, and missing-token branches expose authBroker source/capability/nextAction", ], observedLocalPath, observedDryRunPath, diff --git a/scripts/src/auth-broker.ts b/scripts/src/auth-broker.ts index 1156b510..c46dc9d9 100644 --- a/scripts/src/auth-broker.ts +++ b/scripts/src/auth-broker.ts @@ -124,6 +124,17 @@ function brokerNeededResult(options: BrokerAdapterOptions): Record { dryRun: true, mutation: false, capabilities: [...DEFAULT_CAPABILITIES], + authBroker: { + ok: true, + source: "auth-broker", + capability: "broker-issued-token", + nextAction: "use-auth-broker", + runnerEnvTokenRequired: false, + brokerIssuedTokenAvailable: true, + valuesRead: false, + valuesPrinted: false, + realPrCreateRequiresCommanderAuthorization: true, + }, tokenCoverage: { ok: true, source: "auth-broker", @@ -199,9 +221,11 @@ function readyContract(options: BrokerAdapterOptions): Record { prCapabilityContract: { targetBranch: options.base, headBranch: options.head, + authSource: "broker-issued-token", systemGhBinaryRequiredForWrites: false, preflightCreatesPr: false, preflightMergesPr: false, + realPrCreateRequiresCommanderAuthorization: true, brokerProxy: { ok: true, operations: ["github.auth.status", "github.issue.read", "github.pr.read", "github.pr.create"], diff --git a/scripts/src/code-queue.ts b/scripts/src/code-queue.ts index 929c2d32..82391f51 100644 --- a/scripts/src/code-queue.ts +++ b/scripts/src/code-queue.ts @@ -2418,6 +2418,51 @@ function compactUniDeskGhCliStatus(value: unknown): Record { }; } +function compactAuthBrokerRuntimeStatus(value: unknown): Record { + const broker = asRecord(value); + const observed = broker !== null; + const capability = asRecord(broker?.capability); + const configured = broker?.configured === true || broker?.ok === true && broker?.source === "auth-broker"; + const operations = Array.isArray(capability?.operations) + ? capability.operations.map(String) + : ["github.auth.status", "github.issue.read", "github.pr.read", "github.pr.create"]; + return { + ok: configured, + observed, + configured, + source: typeof broker?.source === "string" ? broker.source : configured ? "auth-broker" : "broker/auth-broker-needed", + endpointEnvKey: typeof broker?.endpointEnvKey === "string" ? broker.endpointEnvKey : null, + runnerEnvTokenRequired: broker?.runnerEnvTokenRequired === true, + credentialSource: typeof broker?.credentialSource === "string" ? broker.credentialSource : configured ? "broker-held-github-credential" : null, + failureKind: configured ? null : broker?.failureKind ?? "auth-missing", + degradedReason: configured ? null : broker?.degradedReason ?? "auth-broker-needed", + capability: { + source: typeof capability?.source === "string" ? capability.source : configured ? "broker-issued-token" : "missing-token", + githubRestAuth: capability?.githubRestAuth === true || configured, + operations, + systemGhBinaryRequiredForWrites: false, + preflightWritesRemote: false, + preflightCreatesPr: false, + preflightMergesPr: false, + realPrCreateRequiresCommanderAuthorization: true, + valuesPrinted: false, + }, + nextAction: typeof broker?.nextAction === "string" + ? broker.nextAction + : configured + ? "use-auth-broker" + : "configure-auth-broker-or-env-token", + next: Array.isArray(broker?.next) ? broker.next.map(String) : configured + ? ["keep PR preflight read-only; create a real PR only after commander authorization"] + : [ + "configure UNIDESK_AUTH_BROKER_URL or AUTH_BROKER_URL for broker-backed runner auth", + "or inject GH_TOKEN/GITHUB_TOKEN into the Code Queue scheduler runtime secret until broker mode is live", + ], + valuesRead: false, + valuesPrinted: false, + }; +} + function compactAgentPortStatus(value: unknown): Record { const port = asRecord(value) ?? {}; return { @@ -2428,43 +2473,80 @@ function compactAgentPortStatus(value: unknown): Record { }; } -function tokenCoverageStatus(credentials: Record): Record { +function tokenCoverageStatus(credentials: Record, authBroker: Record): Record { const ghTokenPresent = credentials.ghTokenPresent === true; const githubTokenPresent = credentials.githubTokenPresent === true; const anyEnvToken = ghTokenPresent || githubTokenPresent; const anyGhCredentialStore = credentials.ghHostsConfigPresent === true || credentials.gitCredentialsPresent === true; + const authBrokerOk = authBroker.ok === true; + const effective = authBrokerOk || anyEnvToken; + const envSource = ghTokenPresent ? "GH_TOKEN" : githubTokenPresent ? "GITHUB_TOKEN" : null; return { - ok: anyEnvToken, - source: ghTokenPresent ? "GH_TOKEN" : githubTokenPresent ? "GITHUB_TOKEN" : null, + ok: effective, + source: authBrokerOk ? "auth-broker" : envSource, + credentialSource: authBrokerOk ? "broker-issued-token" : anyEnvToken ? "env-token" : null, ghTokenPresent, githubTokenPresent, ghCredentialStorePresent: anyGhCredentialStore, - runnerDisposition: anyEnvToken ? "ready" : "infra-blocked", - missing: anyEnvToken ? [] : ["GH_TOKEN", "GITHUB_TOKEN"], - scope: "scheduler-runner-env", - note: anyEnvToken + authBrokerPresent: authBrokerOk, + authBrokerSource: authBroker.source ?? null, + runnerEnvTokenRequired: !authBrokerOk, + runnerDisposition: effective ? "ready" : "infra-blocked", + missing: effective ? [] : ["GH_TOKEN", "GITHUB_TOKEN"], + envMissing: anyEnvToken ? [] : ["GH_TOKEN", "GITHUB_TOKEN"], + scope: authBrokerOk ? "broker-held-github-credential" : "scheduler-runner-env", + note: authBrokerOk + ? "scheduler can use auth-broker for GitHub REST PR preflight without exposing GH_TOKEN/GITHUB_TOKEN to runner env" + : anyEnvToken ? "scheduler has a GitHub env token that can be forwarded to provider dev containers through CODE_QUEUE_REMOTE_CODEX_ENV_KEYS" - : "scheduler is missing GH_TOKEN/GITHUB_TOKEN; provider dev containers cannot receive a GitHub token even though CODE_QUEUE_REMOTE_CODEX_ENV_KEYS includes those keys", + : "scheduler is missing GH_TOKEN/GITHUB_TOKEN and auth-broker is not configured; provider dev containers cannot receive a GitHub token even though CODE_QUEUE_REMOTE_CODEX_ENV_KEYS includes those keys", }; } -function authBrokerNeededStatus(tokenCoverage: Record, systemGhBinary: Record, unideskGhCli: Record): Record { +function authBrokerNeededStatus(tokenCoverage: Record, runtimeAuthBroker: Record, systemGhBinary: Record, unideskGhCli: Record): Record { const missing = Array.isArray(tokenCoverage.missing) ? tokenCoverage.missing.map(String) : []; - const needed = tokenCoverage.ok !== true; + const ready = tokenCoverage.ok === true; + const authBrokerReady = tokenCoverage.source === "auth-broker"; + const needed = !ready; + const capability = asRecord(runtimeAuthBroker.capability) ?? {}; + const capabilitySource = tokenCoverage.credentialSource ?? "missing-token"; + const nextAction = authBrokerReady + ? "use-auth-broker" + : ready + ? "use-env-token-until-auth-broker-live" + : "configure-auth-broker-or-env-token"; return { - ok: !needed, - source: needed ? "broker/auth-broker-needed" : tokenCoverage.source ?? "runner-env-token", + ok: ready, + source: authBrokerReady ? "auth-broker" : needed ? "broker/auth-broker-needed" : tokenCoverage.source ?? "runner-env-token", needed, - configured: false, + configured: runtimeAuthBroker.configured === true, runnerDisposition: needed ? "infra-blocked" : "ready", failureKind: needed ? "auth-missing" : null, degradedReason: needed ? "auth-broker-needed" : null, - runnerEnvTokenRequiredWithoutBroker: true, - brokerCredentialSource: null, + runnerEnvTokenRequiredWithoutBroker: !authBrokerReady, + brokerCredentialSource: authBrokerReady ? runtimeAuthBroker.credentialSource ?? "broker-held-github-credential" : null, + capability: { + source: capabilitySource, + githubRestAuth: ready, + operations: Array.isArray(capability.operations) + ? capability.operations.map(String) + : ["github.auth.status", "github.issue.read", "github.pr.read", "github.pr.create"], + systemGhBinaryRequiredForWrites: false, + unideskGhCliRequired: true, + preflightWritesRemote: false, + preflightCreatesPr: false, + preflightMergesPr: false, + realPrCreateRequiresCommanderAuthorization: true, + valuesPrinted: false, + }, + nextAction, valuesPrinted: false, evidence: { envTokenMissing: needed, missing, + authBrokerObserved: runtimeAuthBroker.observed ?? false, + authBrokerConfigured: runtimeAuthBroker.configured ?? false, + authBrokerSource: runtimeAuthBroker.source ?? null, systemGhBinaryOk: systemGhBinary.ok === true, systemGhBinaryRequiredForWrites: false, unideskGhCliObserved: unideskGhCli.observed ?? false, @@ -2474,10 +2556,12 @@ function authBrokerNeededStatus(tokenCoverage: Record, systemGh }, next: needed ? [ - "inject GH_TOKEN or GITHUB_TOKEN into the Code Queue scheduler runtime secret", - "or configure the planned auth-broker so PR preflight can use a broker-held GitHub credential without exposing runner env tokens", + "configure UNIDESK_AUTH_BROKER_URL or AUTH_BROKER_URL so PR preflight can use a broker-held GitHub credential without exposing runner env tokens", + "or inject GH_TOKEN/GITHUB_TOKEN into the Code Queue scheduler runtime secret until broker mode is live", ] - : [], + : authBrokerReady + ? ["keep PR preflight read-only; create a real PR only after commander authorization"] + : ["env token path is ready; Auth Broker is still needed before removing runner env GitHub credentials"], reference: "docs/reference/auth-broker.md#post-v1githubpr-preflight", }; } @@ -2486,6 +2570,7 @@ function compactPrRuntimePreflight(preflight: Record, options: const pull = asRecord(preflight.pullRequestDelivery) ?? {}; const tools = asRecord(pull.tools) ?? {}; const unideskGhCli = compactUniDeskGhCliStatus(pull.unideskGhCli); + const authBrokerRuntime = compactAuthBrokerRuntimeStatus(pull.authBroker); const systemGhBinary = compactToolStatus(tools.gh); const credentials = asRecord(pull.credentials) ?? {}; const git = asRecord(pull.git) ?? {}; @@ -2494,7 +2579,7 @@ function compactPrRuntimePreflight(preflight: Record, options: const proxy = asRecord(egress.proxy) ?? {}; const remote = asRecord(pull.remote); const ports = asRecord(preflight.ports) ?? {}; - const tokenCoverage = tokenCoverageStatus(credentials); + const tokenCoverage = tokenCoverageStatus(credentials, authBrokerRuntime); const limitations = Array.isArray(pull.limitations) ? pull.limitations.map(String) : []; const risks = Array.isArray(pull.risks) ? pull.risks.map(String) : []; const ok = preflight.ok === true && tokenCoverage.ok === true; @@ -2506,7 +2591,7 @@ function compactPrRuntimePreflight(preflight: Record, options: ? "proxy-gap" : null; const degradedReason = failureKind === "auth-missing" - ? "GH_TOKEN/GITHUB_TOKEN missing" + ? "auth-broker-needed" : failureKind === "git-remote-gap" ? "git remote probe failed" : failureKind === "proxy-gap" @@ -2529,12 +2614,15 @@ function compactPrRuntimePreflight(preflight: Record, options: pid: preflight.pid ?? null, }, tokenCoverage, - authBroker: authBrokerNeededStatus(tokenCoverage, systemGhBinary, unideskGhCli), + authBroker: authBrokerNeededStatus(tokenCoverage, authBrokerRuntime, systemGhBinary, unideskGhCli), prCapabilityContract: { targetBranch, tokenSource: tokenCoverage.source, + authSource: tokenCoverage.credentialSource, + realPrCreateRequiresCommanderAuthorization: true, systemGhBinaryRequiredForWrites: false, unideskGhCli, + authBroker: authBrokerRuntime, pushDryRun: { requested: options.pushDryRun, ref: pushDryRunRef, @@ -2624,8 +2712,10 @@ function compactPrRuntimePreflight(preflight: Record, options: risks, runnerDisposition: ok ? "ready" : "infra-blocked", recoveryHint: ok - ? "Runner PR workflow has env-token coverage for the scheduler." - : "Inject GH_TOKEN or GITHUB_TOKEN into the Code Queue scheduler runtime secret for the target queue, then rerun this preflight before creating a PR.", + ? tokenCoverage.source === "auth-broker" + ? "Runner PR workflow has auth-broker coverage for GitHub REST preflight; real PR creation still requires commander authorization." + : "Runner PR workflow has env-token coverage for the scheduler." + : "Configure auth-broker or inject GH_TOKEN/GITHUB_TOKEN into the Code Queue scheduler runtime secret for the target queue, then rerun this preflight before creating a PR.", commands: { local: "bun scripts/cli.ts gh auth status --repo pikasTech/unidesk", runner: "bun scripts/cli.ts codex pr-preflight --remote", diff --git a/src/components/microservices/code-queue/src/runtime-preflight.ts b/src/components/microservices/code-queue/src/runtime-preflight.ts index 8d9cdc10..b861ccae 100644 --- a/src/components/microservices/code-queue/src/runtime-preflight.ts +++ b/src/components/microservices/code-queue/src/runtime-preflight.ts @@ -66,6 +66,7 @@ export interface PullRequestDeliveryPreflight { checkedAt: string; tools: Record; unideskGhCli: UniDeskGhCliStatus; + authBroker: RuntimeAuthBrokerStatus; credentials: { ghTokenPresent: boolean; githubTokenPresent: boolean; @@ -129,6 +130,32 @@ export interface PullRequestDeliveryPreflight { risks: string[]; } +export interface RuntimeAuthBrokerStatus { + ok: boolean; + configured: boolean; + source: "auth-broker" | "broker/auth-broker-needed"; + endpointEnvKey: string | null; + runnerEnvTokenRequired: boolean; + credentialSource: string | null; + failureKind: "auth-missing" | null; + degradedReason: "auth-broker-needed" | null; + capability: { + source: "broker-issued-token" | "missing-token"; + githubRestAuth: boolean; + operations: string[]; + systemGhBinaryRequiredForWrites: false; + preflightWritesRemote: false; + preflightCreatesPr: false; + preflightMergesPr: false; + realPrCreateRequiresCommanderAuthorization: true; + valuesPrinted: false; + }; + nextAction: "use-auth-broker" | "configure-auth-broker-or-env-token"; + next: string[]; + valuesRead: false; + valuesPrinted: false; +} + export interface RuntimeToolStatus { name: string; ok: boolean; @@ -137,6 +164,9 @@ export interface RuntimeToolStatus { probe: RuntimeCommandProbe; } +const AUTH_BROKER_ENV_KEYS = ["UNIDESK_AUTH_BROKER_URL", "AUTH_BROKER_URL"] as const; +const AUTH_BROKER_OPERATIONS = ["github.auth.status", "github.issue.read", "github.pr.read", "github.pr.create"] as const; + function redactSensitive(value: string): string { return value .replace(/gh[pousr]_[A-Za-z0-9_]{20,}/gu, "***") @@ -238,6 +268,48 @@ function boolEnv(name: string): boolean { return value !== undefined && value.trim().length > 0; } +function authBrokerEndpointEnvKey(): string | null { + for (const key of AUTH_BROKER_ENV_KEYS) { + if (boolEnv(key)) return key; + } + return null; +} + +function authBrokerStatus(): RuntimeAuthBrokerStatus { + const endpointEnvKey = authBrokerEndpointEnvKey(); + const configured = endpointEnvKey !== null; + return { + ok: configured, + configured, + source: configured ? "auth-broker" : "broker/auth-broker-needed", + endpointEnvKey, + runnerEnvTokenRequired: false, + credentialSource: configured ? "broker-held-github-credential" : null, + failureKind: configured ? null : "auth-missing", + degradedReason: configured ? null : "auth-broker-needed", + capability: { + source: configured ? "broker-issued-token" : "missing-token", + githubRestAuth: configured, + operations: [...AUTH_BROKER_OPERATIONS], + systemGhBinaryRequiredForWrites: false, + preflightWritesRemote: false, + preflightCreatesPr: false, + preflightMergesPr: false, + realPrCreateRequiresCommanderAuthorization: true, + valuesPrinted: false, + }, + nextAction: configured ? "use-auth-broker" : "configure-auth-broker-or-env-token", + next: configured + ? ["keep PR preflight read-only; create a real PR only after commander authorization"] + : [ + "configure UNIDESK_AUTH_BROKER_URL or AUTH_BROKER_URL for broker-backed runner auth", + "or inject GH_TOKEN/GITHUB_TOKEN into the Code Queue scheduler runtime secret until broker mode is live", + ], + valuesRead: false, + valuesPrinted: false, + }; +} + function fileExistsProbe(path: string): boolean { return shellProbe(`test -s ${shellQuote(path)}`, 2000).ok; } @@ -420,6 +492,7 @@ function collectPullRequestDeliveryPreflight(options: RuntimePreflightOptions, c ghHostsConfigPresent: fileExistsProbe(`${home ?? "/root"}/.config/gh/hosts.yml`), gitCredentialsPresent: fileExistsProbe(`${home ?? "/root"}/.git-credentials`), }; + const authBroker = authBrokerStatus(); const egressProxy = proxySummary(); const issueUrl = issueApiUrl(githubApiBaseUrl, githubRepo, issueProbeNumber); const egress = { @@ -467,33 +540,48 @@ function collectPullRequestDeliveryPreflight(options: RuntimePreflightOptions, c const risks: string[] = []; if (!tools.git.ok) limitations.push("missing git CLI"); if (!tools.jq.ok) limitations.push("missing jq CLI"); - if (!tools.gh.ok) risks.push("system gh binary is missing; UniDesk REST gh CLI remains the supported PR create/comment path when scripts/cli.ts and GH_TOKEN/GITHUB_TOKEN are available"); + if (!tools.gh.ok) risks.push("system gh binary is missing; UniDesk REST gh CLI remains the supported PR create/comment path when scripts/cli.ts and GH_TOKEN/GITHUB_TOKEN or auth-broker are available"); if (!unideskGhCli.ok) limitations.push("UniDesk gh CLI is missing at scripts/cli.ts; runner cannot use the repo-native REST PR create/comment path"); - if (!credentials.ghTokenPresent && !credentials.githubTokenPresent) limitations.push("GH_TOKEN/GITHUB_TOKEN is not present; gh cannot create PRs unless another gh credential store is mounted"); - if (!credentials.ghHostsConfigPresent && !credentials.gitCredentialsPresent && !credentials.ghTokenPresent && !credentials.githubTokenPresent) limitations.push("no gh hosts config, git credentials file, GH_TOKEN, or GITHUB_TOKEN is visible for GitHub API/issue/PR access"); + if (!credentials.ghTokenPresent && !credentials.githubTokenPresent && !authBroker.ok) limitations.push("GH_TOKEN/GITHUB_TOKEN is not present and auth-broker is not configured; PR create requires broker, env token, or another gh credential store"); + if (!credentials.ghHostsConfigPresent && !credentials.gitCredentialsPresent && !credentials.ghTokenPresent && !credentials.githubTokenPresent && !authBroker.ok) limitations.push("no auth-broker, gh hosts config, git credentials file, GH_TOKEN, or GITHUB_TOKEN is visible for GitHub API/issue/PR access"); if (!gitInfo.insideWorktree) limitations.push("current directory is not a Git worktree"); if (gitInfo.remoteOrigin === null) limitations.push("git remote origin is not configured"); if (githubRepo === null) limitations.push("cannot infer GitHub owner/repo from GH_REPO or git remote origin"); if (!gitInfo.homeWritable) limitations.push("HOME is not writable"); if (!gitInfo.knownHostsPresent) risks.push("GitHub known_hosts is not pre-seeded; first SSH use may need accept-new behavior"); - if (!gitInfo.privateKeyPresent && !credentials.sshAuthSockPresent && !credentials.ghTokenPresent && !credentials.githubTokenPresent) limitations.push("no SSH key, SSH agent, or GitHub token credential is visible"); + if (!gitInfo.privateKeyPresent && !credentials.sshAuthSockPresent && !credentials.ghTokenPresent && !credentials.githubTokenPresent && !authBroker.ok) limitations.push("no SSH key, SSH agent, GitHub token credential, or auth-broker credential is visible"); if (egress.proxy.selectedProxyHost !== null && egress.proxy.selectedProxyHostResolvable === false) limitations.push(`configured GitHub egress proxy host is not resolvable: ${egress.proxy.selectedProxyHost}`); if (options.includeRemote === true && !egress.githubDefault.ok) limitations.push("GitHub HTTPS probe failed with the default environment/proxy"); if (options.includeRemote === true && !egress.apiDefault.ok) limitations.push("GitHub API probe failed with the default environment/proxy"); - if (options.includeRemote === true && egress.issueApi !== null && !egress.issueApi.ok) limitations.push(`GitHub issue API probe for #${issueProbeNumber} failed; private repo access likely lacks token or egress`); + if (options.includeRemote === true && egress.issueApi !== null && !egress.issueApi.ok) { + if (authBroker.ok) risks.push(`direct GitHub issue API probe for #${issueProbeNumber} failed without runner env token; auth-broker is the intended REST auth source`); + else limitations.push(`GitHub issue API probe for #${issueProbeNumber} failed; private repo access likely lacks token or egress`); + } if (remote !== undefined) { if (!remote.gitLsRemote.ok) limitations.push("git ls-remote origin master failed"); if (remote.gitHttpsLsRemote !== null && !remote.gitHttpsLsRemote.ok) limitations.push("anonymous HTTPS git ls-remote failed; HTTPS Git access likely needs token credentials"); if (!remote.githubSshAuthenticated) limitations.push("GitHub SSH auth probe did not authenticate"); - if (remote.ghAuthStatus !== null && !remote.ghAuthStatus.ok) limitations.push("gh auth status failed"); - if (tools.gh.ok && remote.ghRepoView !== null && !remote.ghRepoView.ok) limitations.push("gh repo view failed"); - if (tools.gh.ok && remote.ghIssueView !== null && !remote.ghIssueView.ok) limitations.push(`gh issue read|view #${issueProbeNumber} failed`); - if (tools.gh.ok && remote.ghPrReadOnly !== null && !remote.ghPrReadOnly.ok) limitations.push("gh pr read-only probe failed"); + if (remote.ghAuthStatus !== null && !remote.ghAuthStatus.ok) { + if (authBroker.ok) risks.push("system gh auth status failed; broker-backed UniDesk REST path remains the intended PR preflight auth source"); + else limitations.push("gh auth status failed"); + } + if (tools.gh.ok && remote.ghRepoView !== null && !remote.ghRepoView.ok) { + if (authBroker.ok) risks.push("system gh repo view failed; broker-backed UniDesk REST path remains the intended repo read path"); + else limitations.push("gh repo view failed"); + } + if (tools.gh.ok && remote.ghIssueView !== null && !remote.ghIssueView.ok) { + if (authBroker.ok) risks.push(`system gh issue read|view #${issueProbeNumber} failed; broker-backed UniDesk REST path remains the intended issue read path`); + else limitations.push(`gh issue read|view #${issueProbeNumber} failed`); + } + if (tools.gh.ok && remote.ghPrReadOnly !== null && !remote.ghPrReadOnly.ok) { + if (authBroker.ok) risks.push("system gh pr read-only probe failed; broker-backed UniDesk REST path remains the intended PR read path"); + else limitations.push("gh pr read-only probe failed"); + } } if (pushDryRun !== undefined && !pushDryRun.ok) limitations.push("git push --dry-run failed for probe branch"); if (prCreateDryRun !== undefined && !prCreateDryRun.ok) limitations.push("PR create dry-run body/command guard failed"); const prCliReady = unideskGhCli.ok; - const tokenOrGhStore = credentials.ghTokenPresent || credentials.githubTokenPresent || credentials.ghHostsConfigPresent || (remote?.ghAuthStatus?.ok === true); + const tokenOrGhStore = authBroker.ok || credentials.ghTokenPresent || credentials.githubTokenPresent || credentials.ghHostsConfigPresent || (remote?.ghAuthStatus?.ok === true); const remoteReady = remote === undefined || (egress.githubDefault.ok && egress.apiDefault.ok && remote.gitLsRemote.ok && remote.githubSshAuthenticated); const pushReady = pushDryRun === undefined || pushDryRun.ok; const prCreateDryRunReady = prCreateDryRun === undefined || prCreateDryRun.ok; @@ -502,6 +590,7 @@ function collectPullRequestDeliveryPreflight(options: RuntimePreflightOptions, c checkedAt, tools, unideskGhCli, + authBroker, credentials, githubContext: { host: githubHost,