mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 14:38:23 -10:00
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:
1 parent
3509c2d1aa
commit
3ccf38697d
5 files changed
+84
-28
No files matched your search
@@ -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 '
|
||||
|
||||
@@ -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 已注入。
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user