fix(verify-pr): address round-2 verification findings

Round 2 at 3509c2d1aa reported one Low and four Suggestions.

- Relay the exact <basePath>/chat/completions path. A suffix match also
  forwarded /admin/chat/completions to the upstream.
- Pin the response content-encoding strip with a gzip upstream: undici
  hands the proxy decoded bytes, so forwarding the header makes the client
  gunzip plain text.
- Reject credential-shaped ignored entries at any depth (a nested
  secrets/.env included) instead of only a root .env/.env.*.
- Name the round's engine as the released CLI and offer the tree build for
  a round that must exercise the PR's own CLI.

The CI inline copies keep their existing two-header strip: that lane is
untouched by design and a change there needs its own workflow review.

Tests: 141 passed. Three mutations verified red (exact path, gzip header,
nested credential pathspec). The review-pr check at this PR is red because
the bot lane exhausted ten retries on "429 rpm exceeded", not because of
this diff.
This commit is contained in:
Shaojin Wen committed 2026-11-02 10:04:42 +08:00
1 parent 3509c2d1aa
commit 3ccf38697d
5 files changed
+84 -28

No files matched your search

+10 -4
View File
@@ -302,13 +302,17 @@ run with the real key and the maintainer's `gh` access.
5. **Attest inside, after a check — never on the `docker run` line.** The
gate only sees known credential names and home paths, and gitignored files
in the round tree are invisible to its clean-tree check, so review the
mount list and reject credential-shaped ignored files first:
mount list and reject credential-shaped ignored entries at any depth first:
```bash
docker exec -i qwen-verify-<n> bash -lc '
set -e
test -z "$(git -C /work status --porcelain --ignored -- .env ".env.*")" ||
{ echo "ignored credential file in the round tree"; exit 1; }
credentials="$(git -C /work status --porcelain --ignored -- \
":(glob)**/.env" ":(glob)**/.env.*" ":(glob)**/*.env" \
":(glob)**/*.pem" ":(glob)**/*.key" ":(glob)**/credentials*" \
":(glob)**/.npmrc" ":(glob)**/.netrc")"
test -z "$credentials" ||
{ printf "%s\n" "$credentials"; exit 1; }
export QWEN_VERIFY_CREDENTIAL_FREE=1
node /work/scripts/verify-pr-prepare.mjs --require-isolation
'
@@ -317,7 +321,9 @@ run with the real key and the maintainer's `gh` access.
Exit 4 means this is not a boundary: stop and run nothing else.
6. **Run the round inside**, installing the CLI into the fresh home because
the container is not root:
the container is not root. The round's engine is the released CLI; the PR
clone supplies the workspace skill and code. To run the tree's own CLI
instead, build it and install `/work` into the same prefix:
```bash
docker exec -i qwen-verify-<n> bash -lc '
+17 -12
View File
@@ -36,13 +36,14 @@ A host-side relay plus a recipe that names its trust boundaries.
- `scripts/verify-pr-model-proxy.mjs` listens on a chosen host address (the
Docker bridge for container clients), holds `REVIEW_OPENAI_API_KEY`, and
relays only `POST /chat/completions`. Clients present a per-run bearer token
(`QWEN_VERIFY_PROXY_TOKEN`); the proxy swaps it for the real key upstream.
A nonce-echoing health endpoint distinguishes the process from a squatter.
Hop-by-hop request headers are stripped. The idle watchdog is per-chunk,
matches the qwen client's own 240 s stream tolerance, stays quiet while the
downstream is paused, and re-arms on drain; a disconnect before upstream
headers aborts the fetch instead of paying for a completion nobody reads.
relays only the exact `${basePath}/chat/completions` path. Clients present a
per-run bearer token (`QWEN_VERIFY_PROXY_TOKEN`); the proxy swaps it for the
real key upstream. A nonce-echoing health endpoint distinguishes the process
from a squatter. Hop-by-hop request headers and the upstream
`content-encoding` are stripped. The idle watchdog is per-chunk, matches the
qwen client's own 240 s stream tolerance, stays quiet while the downstream is
paused, and re-arms on drain; a disconnect before upstream headers aborts
the fetch instead of paying for a completion nobody reads.
- The recipe uses two checkouts. `<trusted-clone>` runs the proxy and the
post-round checker; `<pr-clone>` is a standalone clone at the PR head that
the container mounts. A linked worktree is rejected by construction because
@@ -50,8 +51,11 @@ A host-side relay plus a recipe that names its trust boundaries.
- The container is named and detached (`sleep infinity` plus `docker exec`),
runs as the host uid with `HOME=/tmp/qwen-home`, mounts the full snapshot
from a host copy outside the tree, and sets the credential-free attestation
inside only after rejecting credential-shaped ignored files. The CLI
installs into the fresh home, so nothing in the tree becomes root-owned.
inside only after rejecting credential-shaped ignored entries at any depth
(`.env`/`*.env`/`*.pem`/`*.key`/`credentials*`/`.npmrc`/`.netrc`). The CLI
installs into the fresh home, so nothing in the tree becomes root-owned; the
round's engine is the released CLI unless the tree's own build is installed
over it.
- The round reads `$QWEN_VERIFY_CONTEXT` from the full snapshot. After the
round, the checker runs from `<trusted-clone>` with `--context <host copy>`,
so a writable artifact `context.json` cannot pin its own expected head.
@@ -77,9 +81,10 @@ the real key.
## Acceptance
- Proxies: bearer rejection, path allowlist, base-path prefixing, real-key
injection, hop-by-hop stripping with a chunked body, pre-header disconnect
abort, paused-downstream survival, slow-but-healthy streams, upstream reset
- Proxies: bearer rejection, exact path allowlist, base-path prefixing,
real-key injection, hop-by-hop stripping with a chunked body, a gzip upstream
body with the header stripped, pre-header disconnect abort,
paused-downstream survival, slow-but-healthy streams, upstream reset
survival, IPv6 listen formatting, and the CLI's JSON coordinates.
- Checker: `--context` overrides the writable artifact copy, refuses an
unusable snapshot instead of falling back, and keeps its existing exit
@@ -18,9 +18,9 @@
一个宿主机侧转发器,加一套点明信任边界的配方。
- `scripts/verify-pr-model-proxy.mjs` 监听选定的宿主地址(容器客户端用 Docker 网桥),持有 `REVIEW_OPENAI_API_KEY`,只转发 `POST /chat/completions`。客户端出示一次性 bearer token(`QWEN_VERIFY_PROXY_TOKEN`),代理在上游替换为真实 key。回显 nonce 的健康端点用于区分本进程与抢占者。hop-by-hop 请求头被剥离。空闲看门狗按块续期,与 qwen 客户端自身的 240 秒流容忍一致;下游暂停期间不中止,drain 时续期;上游响应头之前客户端断开时中止 fetch,而不是为无人读取的补全付费。
- `scripts/verify-pr-model-proxy.mjs` 监听选定的宿主地址(容器客户端用 Docker 网桥),持有 `REVIEW_OPENAI_API_KEY`,只转发精确的 `${basePath}/chat/completions` 路径。客户端出示一次性 bearer token(`QWEN_VERIFY_PROXY_TOKEN`),代理在上游替换为真实 key。回显 nonce 的健康端点用于区分本进程与抢占者。hop-by-hop 请求头与上游 `content-encoding` 被剥离(undici 已解码,转发该头会让客户端二次解压)。空闲看门狗按块续期,与 qwen 客户端自身的 240 秒流容忍一致;下游暂停期间不中止,drain 时续期;上游响应头之前客户端断开时中止 fetch,而不是为无人读取的补全付费。
- 配方使用两个检出。`<trusted-clone>` 运行代理与轮次后的检查器;`<pr-clone>` 是停在 PR head 的独立克隆,供容器挂载。链接 worktree 因其 gitfile 指向挂载外而在构造上被排除。
- 容器有名字且后台运行(`sleep infinity` 加 `docker exec`),以宿主 uid 运行、`HOME=/tmp/qwen-home`,从树外的宿主副本挂载完整快照,并在拒绝凭据形状的 ignored 文件之后才在容器内设置无凭据宣誓。CLI 安装进这个新 home,因此树里不会出现 root 属主文件。
- 容器有名字且后台运行(`sleep infinity` 加 `docker exec`),以宿主 uid 运行、`HOME=/tmp/qwen-home`,从树外的宿主副本挂载完整快照,并在拒绝任意深度的凭据形状 ignored 条目(`.env`/`*.env`/`*.pem`/`*.key`/`credentials*`/`.npmrc`/`.netrc`)之后才在容器内设置无凭据宣誓。CLI 安装进这个新 home,因此树里不会出现 root 属主文件;轮次的引擎是发布版 CLI,除非把树内构建装到同一前缀覆盖它。
- 轮次从完整快照读取 `$QWEN_VERIFY_CONTEXT`。轮次结束后,检查器从 `<trusted-clone>` 运行并带 `--context <宿主副本>`,因此可写的产物 `context.json` 无法自证其期望 head。
CI 车道保留内联代理:那份副本运行在 base-pinned workflow 的 root 步骤里,若改为指向 PR 检出,PR 就能读到真实 key。
@@ -34,7 +34,7 @@ CI 车道保留内联代理:那份副本运行在 base-pinned workflow 的 roo
## 验收
- 代理:bearer 拒绝、路径白名单、base path 前缀、真实 key 注入、chunked 请求体下 hop-by-hop 剥离、响应头前断连中止、下游暂停存活、慢但健康的流、上游重置存活、IPv6 监听格式化,以及 CLI 的 JSON 坐标。
- 代理:bearer 拒绝、精确路径白名单、base path 前缀、真实 key 注入、chunked 请求体下 hop-by-hop 剥离、gzip 上游响应(头被剥离)、响应头前断连中止、下游暂停存活、慢但健康的流、上游重置存活、IPv6 监听格式化,以及 CLI 的 JSON 坐标。
- 检查器:`--context` 覆盖可写产物副本、不可用快照时拒绝而不是回退,并保持既有退出码。
- 变异证据:移除上述任一守卫,对应 pin 必须变红。
- 真实 Docker 冒烟:`QWEN_VERIFY_PROXY_TOKEN` 时门禁通过、`OPENAI_API_KEY` 时门禁拒绝、容器请求到达 mock 上游且真实 key 已注入。
+45 -3
View File
@@ -9,6 +9,7 @@ import { once } from 'node:events';
import { readFileSync } from 'node:fs';
import http from 'node:http';
import { fileURLToPath } from 'node:url';
import { gzipSync } from 'node:zlib';
import { afterEach, describe, expect, it } from 'vitest';
import {
DEFAULT_IDLE_TIMEOUT_MS,
@@ -63,6 +64,7 @@ async function startUpstream({
resetAfterFirstChunk = false,
resetDelayMs = 50,
respectBackpressure = false,
gzipBody = false,
} = {}) {
const requests = [];
const events = [];
@@ -83,6 +85,21 @@ async function startUpstream({
events.push('aborted-before-headers');
return;
}
if (gzipBody) {
// The gzip header is what the relay must drop: undici hands the proxy
// decoded bytes, so forwarding `content-encoding: gzip` makes the
// client gunzip plain text.
res.writeHead(status, {
'content-type': 'text/event-stream',
'content-encoding': 'gzip',
'content-length': String(
gzipSync(Buffer.from(chunks.join(''))).length,
),
});
events.push('write-head');
res.end(gzipSync(Buffer.from(chunks.join(''))));
return;
}
res.writeHead(status, { 'content-type': 'text/event-stream' });
events.push('write-head');
for (const [index, chunk] of chunks.entries()) {
@@ -165,6 +182,10 @@ describe('verify-pr-model-proxy', () => {
expect(get.status).toBe(403);
const post = await fetch(`${proxy}/v1/models`, chatRequest());
expect(post.status).toBe(403);
// Exact path, not a suffix: this must not reach the upstream as
// /v1/admin/chat/completions.
const admin = await fetch(`${proxy}/admin/chat/completions`, chatRequest());
expect(admin.status).toBe(403);
expect(upstream.requests).toHaveLength(0);
});
@@ -196,6 +217,20 @@ describe('verify-pr-model-proxy', () => {
expect(upstream.requests[0].url).toBe('/v1/chat/completions');
});
it('strips the upstream content-encoding instead of forwarding it', async () => {
const upstream = await startUpstream({
gzipBody: true,
chunks: ['data: gzip-ok\n\n'],
});
const proxy = await startProxy(upstream);
const res = await fetch(`${proxy}/chat/completions`, chatRequest());
expect(res.status).toBe(200);
// undici already decoded the body; forwarding the header would make the
// client gunzip plain text.
expect(res.headers.get('content-encoding')).toBeNull();
expect(await res.text()).toBe('data: gzip-ok\n\n');
});
it('strips hop-by-hop headers and relays a chunked request body', async () => {
const upstream = await startUpstream();
const proxy = await startProxy(upstream);
@@ -524,12 +559,19 @@ describe('local container recipe pins', () => {
it('keeps the attestation off the docker run line and behind a check', () => {
const dockerRun = skill.split('docker run -d')[1]?.split('```')[0] ?? '';
expect(dockerRun).not.toContain('QWEN_VERIFY_CREDENTIAL_FREE');
expect(skill).toContain(
'git -C /work status --porcelain --ignored -- .env',
);
// Any depth, not only a root .env: a nested secrets/.env must be caught.
expect(skill).toContain(':(glob)**/.env');
expect(skill).toContain(':(glob)**/*.pem');
expect(skill).toContain(':(glob)**/credentials*');
expect(skill).not.toMatch(/--ignored -- \.env\b/);
expect(skill).toContain('export QWEN_VERIFY_CREDENTIAL_FREE=1');
});
it('names the released CLI as the engine and offers the tree build', () => {
expect(skill).toContain("The round's engine is the released CLI");
expect(skill).toContain('install `/work` into the same prefix');
});
it('pins the checker head from the host snapshot', () => {
expect(skill).toContain('--context ~/.cache/qwen-verify/pr<n>/pr.json');
});
+9 -6
View File
@@ -47,7 +47,8 @@
*
* On readiness, prints one JSON line: {port, baseUrl, token, nonce}. Give the
* container `baseUrl` as the provider base URL and `token` as its credential;
* `GET /__health` echoes `nonce`. Only `POST .../chat/completions` is relayed.
* `GET /__health` echoes `nonce`. Only `POST <basePath>/chat/completions` is
* relayed — the path is matched exactly, not as a suffix.
*/
import { randomBytes } from 'node:crypto';
@@ -167,12 +168,14 @@ export function createModelProxy({
target.pathname = path;
target.search = incoming.search;
if (
req.method !== 'POST' ||
!target.pathname.endsWith('/chat/completions')
) {
// Exact path, not a suffix: `endsWith('/chat/completions')` would also
// relay `/admin/chat/completions` to the upstream.
const allowedPath = basePath
? `${basePath}/chat/completions`
: '/chat/completions';
if (req.method !== 'POST' || target.pathname !== allowedPath) {
res.writeHead(403, { 'content-type': 'text/plain' });
res.end('proxy: only POST /chat/completions is allowed\n');
res.end(`proxy: only POST ${allowedPath} is allowed\n`);
return;
}