Merge remote-tracking branch 'origin/main' into fix/review-prose-re-review

This commit is contained in:
Shaojin Wen committed 2026-10-31 17:03:35 +08:00
commit ef86b03a99
14 files changed
+846 -60

No files matched your search

+266 -8
View File
@@ -9,6 +9,12 @@
// evaluate BOTH against the same event matrix — including the negative
// associations that must stay hosted — and assert they agree.
//
// pick_runner also publishes `lint_runner`, the one deliberate divergence:
// Lint & Static routes EVERY pull request to the fleet, untrusted forks
// included (maintainer decision 2026-10-31, shared-pool code-execution risk
// accepted). The matrix below pins that divergence AND that ubuntu_runner
// keeps the association gate for every other lane.
//
// test_windows carries a deliberately different policy. A pull_request run
// executes the workflow YAML from the PR's own merge commit, so any trust
// clause a PR can read it can also rewrite. The matrix evaluates the real
@@ -17,7 +23,7 @@
// persistent pool.
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { describe, it } from 'node:test';
@@ -117,8 +123,8 @@ function evalRunsOn(expression, { ecsDisabled, eventName, sameRepo, assoc }) {
}
// Executes the real pick_runner shell with the same inputs and returns the
// selected runner exactly as CI would publish it.
function runPickRunner({
// selected runners exactly as CI would publish them.
function runPickRunnerOutputs({
ecsDisabled,
sameRepo,
assoc,
@@ -140,13 +146,34 @@ function runPickRunner({
},
encoding: 'utf8',
});
const outputs = existsSync(outputFile)
? readFileSync(outputFile, 'utf8')
: '';
rmSync(tmp, { recursive: true, force: true });
assert.equal(result.status, 0, `pick_runner failed: ${result.stderr}`);
const line = result.stdout
.split('\n')
.find((l) => l.startsWith('Selected Linux runner: '));
assert.ok(line, `no selection in pick_runner output: ${result.stdout}`);
return line.slice('Selected Linux runner: '.length);
const select = (prefix) => {
const line = result.stdout.split('\n').find((l) => l.startsWith(prefix));
assert.ok(line, `no selection in pick_runner output: ${result.stdout}`);
return line.slice(prefix.length);
};
const ubuntuRunner = select('Selected Linux runner: ');
const lintRunner = select('Selected lint runner: ');
// The stdout echo and the published output must agree — a rename of the
// GITHUB_OUTPUT key would otherwise pass the echo parse and break every
// downstream runs-on at runtime.
assert.ok(
outputs.split('\n').includes(`ubuntu_runner=${ubuntuRunner}`),
`ubuntu_runner not published: ${outputs}`,
);
assert.ok(
outputs.split('\n').includes(`lint_runner=${lintRunner}`),
`lint_runner not published: ${outputs}`,
);
return { ubuntuRunner, lintRunner };
}
function runPickRunner(inputs) {
return runPickRunnerOutputs(inputs).ubuntuRunner;
}
const ASSOCIATIONS = [
@@ -274,6 +301,237 @@ describe('ci.yml classify_pr runner routing', () => {
});
});
describe('ci.yml lint_runner routing', () => {
const ARM = '["self-hosted", "linux", "arm64"]';
const lintRunner = (inputs) => runPickRunnerOutputs(inputs).lintRunner;
it('routes every pull request to the fleet, untrusted forks included', () => {
// The maintainer decision this pins: Lint & Static executes PR code on
// the shared pool for ALL authors. If this matrix ever flips back to
// hosted for the negative associations without a deliberate decision,
// the lane silently regrows its hosted quota spend.
for (const sameRepo of [true, false]) {
for (const assoc of ASSOCIATIONS) {
assert.equal(
lintRunner({
ecsDisabled: false,
sameRepo,
assoc,
eventName: 'pull_request',
}),
ECS,
`lint sameRepo=${sameRepo} assoc='${assoc}' must route to ECS`,
);
}
}
});
it('keeps the kill-switch and hosted-fleet overrides', () => {
for (const assoc of ASSOCIATIONS) {
assert.equal(
lintRunner({
ecsDisabled: true,
sameRepo: true,
assoc,
eventName: 'pull_request',
}),
HOSTED,
'kill-switch must revert even lint to hosted',
);
assert.equal(
lintRunner({
ecsDisabled: false,
sameRepo: false,
assoc,
eventName: 'pull_request',
fleet: 'hosted',
}),
HOSTED,
'LINUX_RUNNER_FLEET=hosted must keep lint hosted',
);
}
});
it('follows the arm64 fleet selector', () => {
assert.equal(
lintRunner({
ecsDisabled: false,
sameRepo: false,
assoc: 'NONE',
eventName: 'pull_request',
fleet: 'arm64',
}),
ARM,
);
});
it('inherits ubuntu_runner on non-PR events and dispatch inputs', () => {
for (const eventName of ['merge_group', 'push']) {
assert.equal(
lintRunner({
ecsDisabled: false,
sameRepo: false,
assoc: '',
eventName,
}),
ECS,
);
}
assert.equal(
lintRunner({
ecsDisabled: false,
sameRepo: false,
assoc: '',
eventName: 'workflow_dispatch',
dispatch: 'self-hosted',
}),
ECS,
);
assert.equal(
lintRunner({
ecsDisabled: false,
sameRepo: false,
assoc: '',
eventName: 'workflow_dispatch',
dispatch: 'hosted',
}),
HOSTED,
);
assert.equal(
lintRunner({
ecsDisabled: true,
sameRepo: false,
assoc: '',
eventName: 'workflow_dispatch',
dispatch: 'self-hosted',
}),
HOSTED,
);
});
it('classify_pr publishes lint_runner from pick_runner', () => {
assert.equal(
ciDoc.jobs.classify_pr.outputs.lint_runner,
'${{ steps.pick_runner.outputs.lint_runner }}',
);
});
it('lint_and_static consumes lint_runner for runs-on and timeout', () => {
assert.equal(
String(ciDoc.jobs.lint_and_static['runs-on']),
'${{ fromJSON(needs.classify_pr.outputs.lint_runner || \'["ubuntu-latest"]\') }}',
);
assert.match(
String(ciDoc.jobs.lint_and_static['timeout-minutes']),
/needs\.classify_pr\.outputs\.lint_runner/,
);
});
it('keeps both outputs on one fleet ladder', () => {
// The lint arm assigns from the same resolved fleet labels as
// ubuntu_runner; a fleet value neither models must not split them when
// the trust clause is satisfied (a second literal ladder drifts silently
// because the timeout reads lint_runner and still reports a pool ceiling).
for (const fleet of ['', 'x64', 'arm64', 'future-fleet']) {
const out = runPickRunnerOutputs({
ecsDisabled: false,
sameRepo: true,
assoc: 'NONE',
eventName: 'pull_request',
fleet,
});
assert.equal(out.lintRunner, out.ubuntuRunner, `fleet='${fleet}'`);
}
});
});
describe('ci.yml lint HOME isolation', () => {
// This guard lives in the routing battery (HELPER_TESTS) because it gates
// every full-profile PR; the scripts/tests vitest suites only run after the
// merge queue. lint_runner admits untrusted forks to a pool where
// RUNNER_TEMP survives across jobs, so the isolated HOME must be run-unique
// and cleared before use — a fixed path hands a fork's ~/.npmrc to the next
// job on the registration. The ubuntu_runner lanes' association clause is
// throughput policy, not an enforceable boundary (a pull_request run
// executes the PR's own YAML); their pre-existing HOME properties are
// separately owned.
const ungated = Object.entries(ciDoc.jobs).filter(([, job]) =>
/outputs\.lint_runner/.test(String(job['runs-on'] ?? '')),
);
it('scopes the ungated routing to the single recorded lane', () => {
// The decision record scopes the boundary change to Lint & Static alone;
// a superset must force a deliberate record update, not pass silently.
assert.deepEqual(
ungated.map(([id]) => id),
['lint_and_static'],
);
});
it('publishes a run-unique HOME from the first step, not job env', () => {
for (const [id, job] of ungated) {
// jobs.<job_id>.env cannot read the runner context — GitHub rejects the
// whole workflow file (actionlint 1.7.12 witnesses it).
assert.equal(job.env?.HOME, undefined, `${id} job-level HOME`);
assert.equal(
job.env?.USERPROFILE,
undefined,
`${id} job-level USERPROFILE`,
);
const steps = job.steps ?? [];
assert.equal(steps[0]?.name, 'Create isolated HOME', id);
const run = String(steps[0]?.run ?? '');
assert.ok(
run.includes('GITHUB_RUN_ID') && run.includes('GITHUB_RUN_ATTEMPT'),
`${id} HOME path must be run-unique`,
);
assert.ok(
run.includes('rm -rf') && run.indexOf('rm -rf') < run.indexOf('mkdir'),
`${id} must clear the path before creating it`,
);
assert.ok(
run.includes('HOME=') &&
run.includes('USERPROFILE=') &&
run.includes('GITHUB_ENV'),
`${id} must publish HOME/USERPROFILE via GITHUB_ENV`,
);
// Linter archives are SHA-256-verified on every cache hit (lint.js), so
// the shared tool cache is safe for them. Corepack does NOT re-verify
// extracted entries on hit (reproduced on 0.34.6 and 0.36.0), so
// COREPACK_HOME must stay inside the run-unique HOME — a shared one is
// a cross-job poisoning channel. Deleting either line reds this.
assert.ok(
run.includes('XDG_CACHE_HOME=') && run.includes('RUNNER_TOOL_CACHE'),
`${id} must share the verified linter-archive cache`,
);
assert.ok(
run.includes('COREPACK_HOME=') &&
run.includes('"${home}/.cache/node/corepack"'),
`${id} COREPACK_HOME must stay inside the run-unique HOME`,
);
}
});
it('reclaims the isolated HOME before the failure collector', () => {
for (const [id, job] of ungated) {
const steps = job.steps ?? [];
const names = steps.map((s) => s.name);
const clean = steps.find((s) => s.name === 'Clean isolated HOME');
assert.ok(clean, `${id} cleanup step missing`);
assert.equal(clean.if, '${{ always() }}', id);
assert.ok(
names.indexOf('Clean isolated HOME') <
names.indexOf('Upload disk-pressure samples'),
`${id} collector must stay last`,
);
// The constructed path, never $HOME: a failed init must not aim the
// removal at the account's real home.
assert.ok(String(clean.run).includes('qwen-lint-home-'), id);
assert.ok(!String(clean.run).includes('"$HOME"'), id);
}
});
});
describe('ci.yml web_shell_e2e_smoke runner routing', () => {
const expression = String(ciDoc.jobs.web_shell_e2e_smoke['runs-on'])
.replace(/^\$\{\{\s*/, '')
+1 -1
View File
@@ -19,7 +19,7 @@
9551 build-and-publish-image.yml
55276 cd-cua-driver.yml
2222 cd-mobile-mcp.yml
156377 ci.yml
161281 ci.yml
4193 codeql.yml
9389 comment-attachment-guard.yml
2090 desktop-packaging-check.yml
+66 -15
View File
@@ -133,6 +133,12 @@ jobs:
outputs:
skip_ci: '${{ steps.release_sync.outputs.skip_ci }}'
ubuntu_runner: '${{ steps.pick_runner.outputs.ubuntu_runner }}'
# Lint & Static's own routing: every pull request reaches the fleet,
# including untrusted forks (maintainer decision 2026-10-31 — the lane
# executes PR code on the shared pool and that risk is accepted). The
# association gate in ubuntu_runner still governs every other Linux
# lane; do not "simplify" the two outputs into one.
lint_runner: '${{ steps.pick_runner.outputs.lint_runner }}'
ci_profile: '${{ steps.ci_profile.outputs.ci_profile }}'
steps:
- name: 'Detect release version-sync PR'
@@ -179,6 +185,12 @@ jobs:
#
# Keep push routing aligned with the classifier above so a hosted queue
# does not delay the downstream ECS jobs.
#
# lint_runner is the one deliberate divergence: Lint & Static routes
# EVERY pull request to the fleet, untrusted forks included (maintainer
# decision 2026-10-31, risk accepted — see the classify_pr outputs
# comment). The kill-switch and LINUX_RUNNER_FLEET=hosted still force
# hosted, and non-PR events inherit ubuntu_runner unchanged.
- name: 'Select Linux runner'
id: 'pick_runner'
env:
@@ -197,6 +209,11 @@ jobs:
case "${AUTHOR_ASSOCIATION}" in
OWNER|MEMBER|COLLABORATOR) trusted_author=true ;;
esac
# Resolve fleet labels once; both outputs assign from this.
fleet_runner='["self-hosted", "linux", "x64", "ecs-qwen"]'
if [[ "${LINUX_RUNNER_FLEET}" == "arm64" ]]; then
fleet_runner='["self-hosted", "linux", "arm64"]'
fi
# Dispatch input wins over the fleet variable; MAINTAINER_ECS_RUNNER_
# DISABLED=true still hard-forces hosted runners everywhere.
if [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then
@@ -211,14 +228,18 @@ jobs:
elif [[ "${ECS_DISABLED}" == "true" || "${LINUX_RUNNER_FLEET}" == "hosted" ]]; then
ubuntu_runner='["ubuntu-latest"]'
elif [[ "${SAME_REPO}" == "true" || "${trusted_author}" == "true" || "${EVENT_NAME}" == "merge_group" || "${EVENT_NAME}" == "push" ]]; then
if [[ "${LINUX_RUNNER_FLEET}" == "arm64" ]]; then
ubuntu_runner='["self-hosted", "linux", "arm64"]'
else
ubuntu_runner='["self-hosted", "linux", "x64", "ecs-qwen"]'
fi
ubuntu_runner="${fleet_runner}"
fi
echo "ubuntu_runner=${ubuntu_runner}" >> "${GITHUB_OUTPUT}"
echo "Selected Linux runner: ${ubuntu_runner}"
# Lint & Static routes every pull request to the fleet (maintainer
# decision 2026-10-31); non-PR events inherit ubuntu_runner unchanged.
lint_runner="${ubuntu_runner}"
if [[ "${EVENT_NAME}" == "pull_request" && "${ECS_DISABLED}" != "true" && "${LINUX_RUNNER_FLEET}" != "hosted" ]]; then
lint_runner="${fleet_runner}"
fi
echo "lint_runner=${lint_runner}" >> "${GITHUB_OUTPUT}"
echo "Selected lint runner: ${lint_runner}"
# The product jobs later check out the PR head. Classify before that
# from an explicit base-SHA checkout so no fork can replace the
@@ -634,7 +655,7 @@ jobs:
cache_dir="${HOME}/.cache/qwen-code/npm"
mkdir -p "${cache_dir}"
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
echo "Using persistent npm cache at ${cache_dir}"
echo "Using npm cache at ${cache_dir}"
du -sh "${cache_dir}" 2>/dev/null || true
# The test step's side lane (.github/scripts/ci/run-unit-test-lanes.sh)
@@ -944,8 +965,14 @@ jobs:
# same release-sync convention: stay running on skip_ci PRs so a required
# context still reports, with the per-step guards making it a no-op pass.
if: "${{ !cancelled() && github.event_name != 'schedule' }}"
runs-on: '${{ fromJSON(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'') }}'
# Routed like test's, not a bare constant: this lane was beheaded twice
# Reads lint_runner, NOT ubuntu_runner: this lane routes every pull
# request to the fleet, untrusted forks included (maintainer decision
# 2026-10-31 — the lane executes PR code and the shared-pool risk is
# accepted). test and the integration lanes keep the association-gated
# ubuntu_runner. The kill-switch and hosted-fleet overrides still apply,
# inside pick_runner.
runs-on: '${{ fromJSON(needs.classify_pr.outputs.lint_runner || ''["ubuntu-latest"]'') }}'
# Pool-scoped like test's, not a bare constant: this lane was beheaded twice
# running honestly on cold-cache hk3-pool runners — at 30 minutes flat
# (install 892s vs the sibling Test job's 347s) and again at 45 (install
# 940s, bundle closure 681s vs a warm 171s, every step 2–4x). The pool's
@@ -955,13 +982,30 @@ jobs:
# Keyed on `self-hosted`, not on the x64 fleet's `ecs-qwen` label: the
# ARM64 fleet renders ["self-hosted", "linux", "arm64"], so a
# fleet-specific substring priced those reserved machines as hosted.
timeout-minutes: '${{ fromJSON(contains(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'', ''self-hosted'') && ''90'' || ''45'') }}'
# Reads the same lint_runner as runs-on so the ceiling always prices the
# pool the job actually lands on.
timeout-minutes: '${{ fromJSON(contains(needs.classify_pr.outputs.lint_runner || ''["ubuntu-latest"]'', ''self-hosted'') && ''90'' || ''45'') }}'
env:
LINT_BASE_SHA: "${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }}"
permissions:
contents: 'read'
pull-requests: 'read'
steps:
# Untrusted fork code runs here; job env cannot read the runner context
# and RUNNER_TEMP survives across jobs, so publish a run-unique HOME via
# GITHUB_ENV. Rationale: docs/design/2026-10-31-lint-static-ecs-routing.md.
- name: 'Create isolated HOME'
run: |-
home="${RUNNER_TEMP}/qwen-lint-home-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
rm -rf -- "$home"
mkdir -p "$home" "${RUNNER_TOOL_CACHE}/qwen-ci-cache"
printf 'HOME=%s\nUSERPROFILE=%s\n' "$home" "$home" >> "${GITHUB_ENV}"
# Linter archives are SHA-256-verified on every cache hit, so sharing
# them is safe; corepack does NOT re-verify extracted entries on hit,
# so its home stays per-run (a shared one is a poisoning channel).
printf 'XDG_CACHE_HOME=%s\n' "${RUNNER_TOOL_CACHE}/qwen-ci-cache" >> "${GITHUB_ENV}"
printf 'COREPACK_HOME=%s\n' "${home}/.cache/node/corepack" >> "${GITHUB_ENV}"
# Pre-checkout cleanup: a previous containerised job (e.g. qwen-triage
# verify) may leave root-owned, read-only files anywhere in the workspace.
# Restore ownership and write permission unconditionally so the checkout
@@ -1300,7 +1344,7 @@ jobs:
cache_dir="${HOME}/.cache/qwen-code/npm"
mkdir -p "${cache_dir}"
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
echo "Using persistent npm cache at ${cache_dir}"
echo "Using npm cache at ${cache_dir}"
du -sh "${cache_dir}" 2>/dev/null || true
- name: 'Configure npm for rate limiting'
@@ -1495,6 +1539,12 @@ jobs:
echo "::warning::helper tests failed once and the retry absorbed it; attempt-1 failures: $(grep -E '^not ok' "${RUNNER_TEMP}/helper-attempt1.log" | head -5 | paste -sd ';' -) — count this toward the #12772 helper-battery recurrence threshold"
}
# Reclaim the run-unique HOME (constructed path, never $HOME) before the
# failure collector, which must stay last.
- name: 'Clean isolated HOME'
if: '${{ always() }}'
run: 'rm -rf -- "${RUNNER_TEMP}/qwen-lint-home-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"'
# State-at-failure dump for the 19 substantive steps after install: the
# install sampler is reaped by that step's `trap … EXIT`, so a death in
# ESLint or the bundle-closure build would otherwise upload an artifact
@@ -1715,7 +1765,7 @@ jobs:
cache_dir="${HOME}/.cache/qwen-code/npm"
mkdir -p "${cache_dir}"
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
echo "Using persistent npm cache at ${cache_dir}"
echo "Using npm cache at ${cache_dir}"
du -sh "${cache_dir}" 2>/dev/null || true
- name: 'Configure npm for rate limiting'
@@ -2117,7 +2167,7 @@ jobs:
# so create the directory through the Node the preflight verified.
node -e "require('node:fs').mkdirSync(process.argv[1], { recursive: true })" "${cache_dir}"
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
echo "Using persistent npm cache at ${cache_dir}"
echo "Using npm cache at ${cache_dir}"
du -sh "${cache_dir}" 2>/dev/null || true
- name: 'Configure npm for rate limiting'
@@ -2231,7 +2281,8 @@ jobs:
runs-on: '${{ fromJSON(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'') }}'
# Dependency installation alone can exceed 30 minutes on a contended shared
# ECS host, before the separately bounded 20-minute integration step starts.
# Same self-hosted scoping as `lint_and_static`: hosted fallback routing
# Same self-hosted scoping as `test` (this lane keeps the association-gated
# ubuntu_runner, unlike lint_and_static): hosted fallback routing
# keeps the pre-contention ceiling, and the ARM64 fleet — which carries
# no `ecs-qwen` label — gets the contended-pool allowance it runs under.
timeout-minutes: '${{ fromJSON(contains(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'', ''self-hosted'') && ''60'' || ''30'') }}'
@@ -2393,7 +2444,7 @@ jobs:
cache_dir="${HOME}/.cache/qwen-code/npm"
mkdir -p "${cache_dir}"
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
echo "Using persistent npm cache at ${cache_dir}"
echo "Using npm cache at ${cache_dir}"
du -sh "${cache_dir}" 2>/dev/null || true
- name: 'Disk floor gate (self-hosted)'
@@ -2620,7 +2671,7 @@ jobs:
cache_dir="${HOME}/.cache/qwen-code/npm"
mkdir -p "${cache_dir}"
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
echo "Using persistent npm cache at ${cache_dir}"
echo "Using npm cache at ${cache_dir}"
du -sh "${cache_dir}" 2>/dev/null || true
# Same pre-install admission check as the test job (#10035): this job
+6 -3
View File
@@ -1368,9 +1368,12 @@ author-permission gate by design, not a head-repository gate: takeover
engages maintainer fork PRs, and the pattern matches qwen-code-pr-review,
whose ECS-routed review job also rides its upstream write+ check. The
job therefore runs host-side (no `container:`): the branch code it
executes is collaborator-authored — the same trust class ci.yml's
pick_runner routes onto this pool — and persistent-workspace residue is
scrubbed by the hygiene steps below. On pull_request /
executes is collaborator-authored — a narrower trust class than ci.yml's
pick_runner now routes onto this pool, since the 2026-10-31 decision
sends every pull request, untrusted forks included, to lint_and_static
(that lane pins a per-job HOME; this job does not) — and
persistent-workspace residue is scrubbed by the hygiene steps below. On
pull_request /
pull_request_review events the ECS route additionally needs a same-repo
head or a write+ author (ci.yml's pick_runner form); issue_comment and
the other triggers skip that clause and rely on the live write+ gates.
@@ -71,12 +71,19 @@ Following the established precedent (`hosted-harness-process.ts`,
- `integration-tests/helpers/hosted-shell-quote.ts` — `quotePosix(value)`.
Four call sites swap their local const for the import.
### Java: one package-private test-support class
### Java: one shared test-support class
`com.alibaba.qwen.code.managedagent.HostedGateTestSupport` (final class,
static methods, test scope), joining the existing test-support precedent
(`CoordinatorTestSupport`):
`com.alibaba.qwen.code.managedagent.HostedGateTestSupport` (public final
class, static methods, test scope), joining the existing test-support
precedent (`CoordinatorTestSupport`). It was package-private until the
connector-owner resolver below had to be shared with a gate in the `harness`
subpackage; only that resolver is public, the rest stay package-private:
- `connectorOwning(HarnessConnector)` — walks the delegate chain to the
connector that owns the private state the gates read, because the wired
bean is a decorator rather than that connector. `attachmentsOf` and the
pool gate's concrete-type lookup both go through it, so there is one walk
rather than a copy per read site.
- `authentication(String tenant, String fixtureName)` — the
`FilterRegistrationBean` factory; both probes delegate, the fixture name is
the only parameter.
@@ -43,10 +43,11 @@
- `integration-tests/helpers/hosted-sse-observer.ts` —— `Observer` 类(SSE fetch、块解析、`frames`、`through`、`terminalOfOtherTurn`、`turnIdOf`、`close`),`surface`、`sessionId`、`storeUrl`、`tenantId` 与超时全部经构造器注入;外加 `projectEvents(rows, surface)`,逐字承载 FG6e/FG8 的字段映射。FG6e 的 driver 不再为 observer 读模块级 config;两个 driver 都改为 import 该类。
- `integration-tests/helpers/hosted-shell-quote.ts` —— `quotePosix(value)`。四个调用点把本地 const 换成 import。
### Java:一个包级私有的测试支撑类
### Java:一个共享的测试支撑类
`com.alibaba.qwen.code.managedagent.HostedGateTestSupport`(final class、静态方法、test 作用域),加入既有测试支撑先例(`CoordinatorTestSupport`)之列:
`com.alibaba.qwen.code.managedagent.HostedGateTestSupport`(public final class、静态方法、test 作用域),加入既有测试支撑先例(`CoordinatorTestSupport`)之列。它原本是包级私有,直到下面这个 connector owner 解析器需要与 `harness` 子包里的一条门禁共享;只有该解析器是 public,其余仍为包级私有:
- `connectorOwning(HarnessConnector)` —— 沿 delegate 链走到持有「门禁要读的私有状态」的那个 connector,因为 wiring 交出的 bean 是装饰器而不是该 connector。`attachmentsOf` 与 pool 门禁的具体类型查找都走它,因此只有一份走链,而不是每个读取点一份拷贝。
- `authentication(String tenant, String fixtureName)` —— `FilterRegistrationBean` 工厂;两个 probe 都改为委托,fixture 名是唯一参数。
- relay 循环的共享机制:用钉死的 fixture token 与 capability digest 构建 client、boot-id 能力校验、`parseSession` 反射桥,以及「流 → 投影 → 落库」的行走,源键为 `bootId:epoch:eventId`。各 letter 的循环控制留在一个小策略缝后面,恰好三个钩子——事件属于哪个 turn(FG6e 的固定 `promptId` 对 FG8 的每事件 prompt id)、一个允许阻塞的落库前钩子(FG6e 的 terminal 闩)、以及 terminal 是否停下的决定(FG6e 遇 terminal 跳出;FG8 从不,靠流关闭停下)。FG8 关闭流引发的 `IllegalStateException` 吞留处理留在 FG8 的 probe 里。
- `toolPublicationArgs(int servicePort, String bucket, long executionBytes)` —— 那 16 行属性。
@@ -0,0 +1,155 @@
# Lint & Static on the ECS pool for every pull request
[English](2026-10-31-lint-static-ecs-routing.md) | [简体中文](2026-10-31-lint-static-ecs-routing.zh-CN.md)
Decision taken 2026-10-31 by jinjing (maintainer). Scope: `.github/workflows/ci.yml` runner routing.
Retracts the "collaborator-authored — the same trust class ci.yml's pick_runner
routes onto this pool" premise formerly stated in
[`qwen-autofix.md` §38](../../.github/workflows/qwen-autofix.md) (rewritten in
the same PR); [`autofix-gate-runner-isolation.md`](./autofix-gate-runner-isolation.md)
remains the reference for the pool's shared-HOME property.
## What changed
`classify_pr`'s `pick_runner` publishes two routings instead of one, both
assigned from a single resolved fleet ladder:
| Output | Admits | Read by |
| --------------- | ------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| `ubuntu_runner` | same-repo heads, `OWNER` / `MEMBER` / `COLLABORATOR` authors, `merge_group`, `push` — else hosted | `test`, `integration_no_ak`, `integration_cli`, `web_shell_e2e_smoke` |
| `lint_runner` | every `pull_request`, untrusted forks included; otherwise identical to `ubuntu_runner` | `lint_and_static` |
Fallback priority: `MAINTAINER_ECS_RUNNER_DISABLED=true` overrides everything,
including manual dispatch. `LINUX_RUNNER_FLEET=hosted` forces pull requests,
merge groups and pushes to hosted, but a `workflow_dispatch` run whose
`linux_runner` input explicitly selects `self-hosted` / `self-hosted-arm64`
still reaches the pool — the dispatch branch is evaluated first. During a pool
outage, use the kill switch, not the fleet variable, to guarantee hosted.
`test_windows` and the macOS lanes are out of scope: they read no classifier
output.
## Why the untrusted-fork lane is acceptable
The lane's job is "the checks whose verdict depends only on the tree". Routing
it to the fleet at all is a throughput decision: a hosted round trip per PR
delays the checks that gate merge. The trust cost is that PR code —
`pnpm install` lifecycle scripts and lint/static tooling — now runs on the
shared pool for a fork author nobody has vetted.
## Isolation boundary (and its holes)
`lint_and_static`'s first step publishes `HOME` / `USERPROFILE` via
`GITHUB_ENV`, pointing at
`${RUNNER_TEMP}/qwen-lint-home-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}` —
run-unique, cleared with `rm -rf` before creation, and reclaimed by an
`always()` cleanup step before the failure collector. Job-level `env` cannot
read the `runner` context (GitHub rejects the whole workflow file), and
`RUNNER_TEMP` survives across jobs on this pool, so a fixed directory name
would hand a fork's `~/.npmrc` / `~/.gitconfig` to the next job on the
registration — including the `test` lane, which pins its own constant
`qwen-ci-home` (a pre-existing property, separately owned).
The pin closes the **HOME-relative (automatic) write class**: config that git,
npm and pnpm resolve through `~`/`$HOME`. It does not stop a job that writes
the account's real home by absolute path; that remains in the accepted
residual surface below. Limits that stay accepted:
- **Package caches are cold.** npm and pnpm stores live under the per-run
HOME, so each run installs from the registry; yamllint is re-pipped into
`$HOME/.local` and the corepack shim is re-downloaded per run. The measured
cold install (~900 s) is what the 90/45 ceiling already prices. Only the
linter-archive cache stays shared and warm: `XDG_CACHE_HOME` points at
`${RUNNER_TOOL_CACHE}/qwen-ci-cache`, and lint.js SHA-256-verifies every
archive on each cache hit before extraction, rejecting and re-fetching
tampered entries. `COREPACK_HOME` stays inside the run-unique HOME:
corepack does not re-verify extracted entries on cache hit (reproduced on
0.34.6 and 0.36.0 — a tampered pnpm entry executes with the recorded hash
unchanged), so a shared corepack home is a cross-job poisoning channel.
- **`$GITHUB_WORKSPACE` is still shared.** The pre-checkout "Restore workspace
ownership" step and the checkout itself reduce, but cannot eliminate,
residue a co-resident job leaves behind. The lane's only credential is the
read-only `GITHUB_TOKEN` (`contents: read`, `pull-requests: read`), bound to
the freshness, contract and test-growth steps — harvesting the residue
yields no write credential.
Steps that need the real home (none today) would have to be re-gated.
### Why not a container
`qwen-triage`'s `tmux-testing`/`verify` jobs wrap PR code in
`container: node:22-bookworm`. That path was considered for this lane and
rejected: the lane's own "Restore workspace ownership" step exists because
containerised jobs leave root-owned residue on these hosts, a fourth
containerised lane worsens exactly that problem, and the pnpm-store/bundle-cache
behavior would need re-proving on the pool. Per-run HOME closes the automatic
config-inheritance class at a few lines of cost; it is not a sandbox, and the
residual surface below stays accepted.
## Residual risks (accepted)
- **Co-resident exposure is concretely reachable, not theoretical.** For
example, `publish-tmux` runs on this pool holding `CI_BOT_PAT`,
uncontainerised and without a trust gate on its `runs-on`; same-UID
processes can read `/proc/<pid>/environ`. It is one example, not a census:
`serve-ab-publish`, `web-shell-visuals-cleanup` and `qwen-autofix`'s
`issue-autofix` also carry bot PATs on ECS pools, and `web_shell_e2e_smoke`
reaches the self-hosted ARM64 fleet off `ubuntu_runner` (write-access fork
authors included) with no per-job HOME — pre-existing surfaces this PR does
not change. A lint step spinning under its 90-minute ceiling can scan for
such windows and retry across fork PRs. The structural fix is an isolation
fleet — out of scope here.
- **The association gate is throughput policy, not an enforceable trust
boundary.** A `pull_request` run executes the PR's own workflow file, so any
fork can put any lane — `test` included — on the pool in the same diff it
opens (ci.yml's `test_windows` comment records the same fact). GitHub's
first-timer workflow approval is the outer gate. The single-lane scope of
this decision describes the default routing, not a fence.
- **Cloud metadata.** If a pool instance binds a RAM role, any job process can
reach the metadata endpoint. Fleet-side hardened mode is recommended,
independent of this workflow change.
- **`/tmp` inode exhaustion.** The pool's tmpfs `/tmp` has a ~1M inode ceiling
the disk-floor gate does not cover (it checks the workspace and
`RUNNER_TEMP` mounts). A 2026-10-31 lint failure on `ecs-qwen-hz1-9` was this
class. Follow-up: extend the floor gate to `os.tmpdir()` and clean host
`/tmp` accumulation.
## Kill switch
`MAINTAINER_ECS_RUNNER_DISABLED=true` — all Linux lanes, including this one,
fall back to `ubuntu-latest`, each with its own hosted ceiling: lint 45
minutes, test 120, web-shell smoke 60, no-AK integration 30.
## Tests
`.github/scripts/ci-runner-routing.test.mjs` (in `HELPER_TESTS`, so it gates
every full-profile PR) pins: the producer binding
(`classify_pr.outputs.lint_runner` ← `pick_runner`), the full association /
event matrix against the real selector shell, the single-lane scope of
`lint_runner` (a superset must update this record), the run-unique HOME
published from the first step with clear-before-create and shared verified
caches, the cleanup step preceding the collector, and both outputs agreeing
on one fleet ladder for unmodeled fleet values.
`scripts/tests/ci-platform-lanes.test.js` pins the `runs-on` literals (lint on
`lint_runner`, test on `ubuntu_runner`) and evaluates each lane's timeout from
its own routing output; note that suite runs after the merge queue, which is
why the load-bearing HOME guard lives in the routing battery.
## Decision record and open questions
- Acceptance: jinjing reviewed and accepted the shared-pool code-execution
risk on 2026-10-31, with Lint & Static as the sole default-routing change —
noting that the association gate is not enforceable against a fork that
rewrites the workflow YAML, so the acceptance covers the residual surface
above, not a one-lane fence. A second maintainer's confirmation is welcome
(triage stage-3 deferred on exactly this); it is a human decision, not a
code change.
- The PR's own CI exercises the routing plumbing on the pool (the author is a
collaborator, so both outputs agree on that run); the fork divergence is
proven by the routing battery, and its first live end-to-end witness is the
next untrusted fork PR.
- Follow-ups owned separately: warm shared pnpm store under the per-run HOME
(e.g. via `XDG_DATA_HOME` at a content-verified shared path) to recover the
~900 s cold-install cost; disk-floor gate coverage of `/tmp` inodes; fleet
metadata hardening; the `test` lane's constant `qwen-ci-home` persistence;
an isolation fleet if the accepted residual surface is ever revisited.
@@ -0,0 +1,125 @@
# Lint & Static 对所有 pull request 走 ECS 池
[English](2026-10-31-lint-static-ecs-routing.md) | [简体中文](2026-10-31-lint-static-ecs-routing.zh-CN.md)
2026-10-31 由 jinjing(维护者)决定。范围:`.github/workflows/ci.yml` 的 runner 路由。
它撤回的是 [`qwen-autofix.md` 第 38 节](../../.github/workflows/qwen-autofix.md)
原先"执行的代码是 collaborator 编写——与 ci.yml 的 pick_runner 路由到该池的信任等级相同"
的前提(该节已在同一 PR 改写);[`autofix-gate-runner-isolation.md`](./autofix-gate-runner-isolation.md)
仍是池上共享 HOME 属性的参考文档。
## 改了什么
`classify_pr` 的 `pick_runner` 现在发布两套路由,而不是一套,且两者都从同一份
解析好的 fleet 标签赋值:
| 输出 | 接纳对象 | 读取方 |
| --------------- | ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| `ubuntu_runner` | 同仓库 head、`OWNER` / `MEMBER` / `COLLABORATOR` 作者、`merge_group`、`push`,其余走 hosted | `test`、`integration_no_ak`、`integration_cli`、`web_shell_e2e_smoke` |
| `lint_runner` | 每一个 `pull_request`,未受信 fork 也算;其余情况与 `ubuntu_runner` 相同 | `lint_and_static` |
回退优先级:`MAINTAINER_ECS_RUNNER_DISABLED=true` 覆盖一切,包括手动调度。
`LINUX_RUNNER_FLEET=hosted` 把 pull request、merge group 和 push 强制到 hosted,
但 `workflow_dispatch` 的 `linux_runner` 输入若显式选择 `self-hosted` /
`self-hosted-arm64` 仍会进池——dispatch 分支最先求值。池故障时要保证回 hosted,
请用 kill switch,而不是 fleet 变量。
`test_windows` 与 macOS 车道不在范围内:它们不读任何 classifier 输出。
## 为什么可以接纳未受信 fork
这条车道的定位是"判决只取决于代码树的那批检查"。把它放到自建池纯粹是吞吐决定:
每个 PR 往返一次 hosted 会拖慢真正卡合并的检查。代价是 PR 代码——`pnpm install`
的生命周期脚本与 lint/static 工具链——现在会在共享池上执行,作者并未经过任何审核。
## 隔离边界(以及它的洞)
`lint_and_static` 的首步通过 `GITHUB_ENV` 发布 `HOME` / `USERPROFILE`,指向
`${RUNNER_TEMP}/qwen-lint-home-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}`——
按 run 唯一,创建前先 `rm -rf` 清空,并由失败收集器之前的 `always()` 清理步骤回收。
job 级 `env` 读不到 `runner` 上下文(GitHub 会拒绝整个 workflow 文件),而
`RUNNER_TEMP` 在本池跨 job 持久,所以固定目录名会把 fork 写下的 `~/.npmrc` /
`~/.gitconfig` 交给同一注册上的下一个 job——包括钉着自己那份固定 `qwen-ci-home`
的 `test` 车道(那是 pre-existing 属性,另行拥有)。
这个钉住关闭的是 **HOME 相对(自动)写入这一类**:git、npm、pnpm 通过
`~`/`$HOME` 解析的配置。它拦不住用绝对路径写账号真实 home 的 job;那仍在下文
已接受的残余暴露面里。仍然接受的限制:
- **包缓存是冷的。** npm 与 pnpm store 落在 per-run HOME 下,每次运行都要从
registry 安装;yamllint 每次重新 pip 到 `$HOME/.local`,corepack shim 每次
重新下载。实测冷装约 900 s,90/45 的时限本来就是按这个口径定的。只有
linter 归档缓存保持共享温热:`XDG_CACHE_HOME` 指向
`${RUNNER_TOOL_CACHE}/qwen-ci-cache`,lint.js 在每次缓存命中时、解压之前
对归档做 SHA-256 校验,被篡改的条目会被拒绝并重新拉取。`COREPACK_HOME`
留在 run 唯一的 HOME 内:corepack 缓存命中时不会重新校验已解压的条目
(在 0.34.6 与 0.36.0 上复现——记录的 hash 不变,被篡改的 pnpm 入口照样
执行),共享 corepack home 是一条跨 job 投毒通道。
- **`$GITHUB_WORKSPACE` 仍然共享。** 检出前的 "Restore workspace ownership" 与
检出本身只能减轻、不能消除同机并行 job 留下的残留。该车道唯一的凭证是只读
`GITHUB_TOKEN`(`contents: read`、`pull-requests: read`),绑定在 freshness、
contract、test-growth 三个步骤上——收割残留拿不到任何写凭证。
将来若出现必须用真实 home 的步骤,需要重新给它单独设门。
### 为什么不用容器
`qwen-triage` 的 `tmux-testing`/`verify` 用 `container: node:22-bookworm`
包裹 PR 代码。本车道考虑过该路径并否决:车道自己的 "Restore workspace
ownership" 步骤正是因为容器化 job 在这些主机上留下 root 属主残留物而存在,
再加一条容器化车道会加重该问题;pnpm store/bundle 缓存行为也需要在池上重新
验证。per-run HOME 以几行成本关闭了自动配置继承这一类;它不是沙箱,下文的
残余暴露面仍属接受范围。
## 残余风险(已接受)
- **同机暴露是具体可达的,不是理论上的。** 例如 `publish-tmux` 在该池上持有
`CI_BOT_PAT`、未容器化、`runs-on` 无信任门禁;同 UID 进程可读
`/proc/<pid>/environ`。这只是举例,不是清单:`serve-ab-publish`、
`web-shell-visuals-cleanup`、`qwen-autofix` 的 `issue-autofix` 也带 bot PAT
跑在 ECS 池上;`web_shell_e2e_smoke` 经由 `ubuntu_runner` 落到自托管 ARM64
fleet(含 write 权限作者的 fork),且没有 per-job HOME——都是本 PR 不改的
pre-existing 暴露面。一个在 90 分钟上限内空转的 lint 步骤可以扫描这类窗口并
在连续 fork PR 中重试。结构性解法是隔离 fleet——不在本变更范围内。
- **association 门禁是吞吐策略,不是可强制的信任边界。** `pull_request` 运行
执行的是 PR 自己的 workflow 文件,任何 fork 都能在开 PR 的同一个 diff 里把
任何车道(包括 `test`)放到池上(ci.yml 的 `test_windows` 注释记录了同一
事实)。GitHub 的首次贡献者 workflow 审批是外层门。本决定的单车道范围描述
的是默认路由,不是一道围栏。
- **云 metadata。** 若池实例绑定了 RAM 角色,任何 job 进程都可访问 metadata
端点。建议 fleet 侧开启加固模式,与本 workflow 变更独立。
- **`/tmp` inode 耗尽。** 池的 tmpfs `/tmp` 有约 100 万 inode 上限,磁盘下限门
未覆盖(它查工作区与 `RUNNER_TEMP` 挂载)。2026-10-31 `ecs-qwen-hz1-9` 上的
一次 lint 失败即此类。后续:把下限门扩到 `os.tmpdir()` 并清理主机 `/tmp`
积压。
## Kill switch
`MAINTAINER_ECS_RUNNER_DISABLED=true` —— 所有 Linux 车道(含本车道)回落到
`ubuntu-latest`,各按各的 hosted 上限:lint 45 分钟、test 120、web-shell
smoke 60、no-AK 集成 30。
## 测试
`.github/scripts/ci-runner-routing.test.mjs`(在 `HELPER_TESTS` 里,因此门禁
每个 full-profile PR)钉住:生产者绑定(`classify_pr.outputs.lint_runner` ←
`pick_runner`)、真实选择器 shell 在全 association/事件矩阵下的行为、
`lint_runner` 的单车道范围(超集必须更新本记录)、首步发布的 run 唯一 HOME
(先清后建、共享可校验缓存)、清理步骤先于收集器、以及两个输出在未建模的
fleet 值下仍走同一份标签。`scripts/tests/ci-platform-lanes.test.js` 钉住
`runs-on` 字面量(lint 读 `lint_runner`、test 读 `ubuntu_runner`),并按各车道
自己的路由输出求值 timeout;注意该套件在 merge queue 之后才跑,所以承重的
HOME 守卫放在路由电池里。
## 决策记录与开放问题
- 接受记录:jinjing 于 2026-10-31 评审并接受共享池代码执行风险,Lint & Static
是唯一的默认路由变更——并注明 association 门禁对改写 workflow YAML 的 fork
不可强制,因此接受范围覆盖上述残余暴露面,而不是一道单车道围栏。欢迎第二位
维护者确认(triage stage-3 正是因此暂缓);这是人的决定,不是代码变更。
- PR 自身 CI 在池上验证路由管道(作者是 collaborator,该运行中两个输出一致);
fork 分歧由路由回归矩阵证明,其首个线上端到端见证是下一个不可信 fork PR。
- 另行拥有的后续:per-run HOME 下恢复热共享 pnpm store(如通过 `XDG_DATA_HOME`
指向内容可校验的共享路径),找回约 900 s 的冷装成本;磁盘下限门覆盖 `/tmp`
inode;fleet metadata 加固;`test` 车道固定 `qwen-ci-home` 的持久化问题;
若重新审视已接受的残余暴露面,则建隔离 fleet。
@@ -27,7 +27,9 @@ import org.springframework.core.Ordered;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.util.ReflectionUtils;
import java.lang.reflect.Field;
import java.net.URI;
import java.security.Principal;
import java.time.Duration;
@@ -43,7 +45,7 @@ import static org.assertj.core.api.Assertions.assertThat;
// mechanics — the relay's loop control, the fault arms, the case
// choreography — stay in the letters; only the byte-identical machinery
// lives here.
final class HostedGateTestSupport {
public final class HostedGateTestSupport {
private static final ObjectMapper JSON = new ObjectMapper();
private HostedGateTestSupport() {
@@ -375,8 +377,7 @@ final class HostedGateTestSupport {
// Spring holds no attachment to it.
static HarnessSessionRef attachment(HarnessConnector connector,
String sessionId) {
for (Object value : ((Map<?, ?>) ReflectionTestUtils.getField(
connector, "attachments")).values()) {
for (Object value : attachmentsOf(connector).values()) {
if (value instanceof HarnessSessionRef ref
&& sessionId.equals(ref.getHarnessSessionId())) {
return ref;
@@ -384,4 +385,40 @@ final class HostedGateTestSupport {
}
return null;
}
// The bean Spring hands out can be a decorator around the connector that
// owns the private state these gates read, so resolve the owner once and
// let every read share it. A chain that owns no attachment map stops at
// its last connector, so the caller's getField still names the missing
// field instead of failing somewhere less obvious. A delegate that is
// itself null falls back to the connector we started from, for the same
// reason: getField(null, …) complains about the missing target instead of
// naming the field the caller asked for.
public static HarnessConnector connectorOwning(HarnessConnector connector) {
HarnessConnector owner = connector;
while (owner != null && declaredField(owner, "attachments", Map.class) == null) {
Field delegate = declaredField(owner, "delegate", HarnessConnector.class);
if (delegate == null) {
break;
}
ReflectionUtils.makeAccessible(delegate);
owner = (HarnessConnector) ReflectionUtils.getField(delegate, owner);
}
return owner != null ? owner : connector;
}
static Map<?, ?> attachmentsOf(HarnessConnector connector) {
return (Map<?, ?>) ReflectionTestUtils.getField(
connectorOwning(connector), "attachments");
}
// findField's type argument matches the declared type exactly, not by
// assignability, so a delegate declared as a concrete connector would be
// missed. Match on the name and check assignability instead. A field that
// exists and holds null is still a field: the caller reads its value.
private static Field declaredField(HarnessConnector owner, String name,
Class<?> type) {
Field field = ReflectionUtils.findField(owner.getClass(), name);
return field != null && type.isAssignableFrom(field.getType()) ? field : null;
}
}
@@ -1,7 +1,10 @@
package com.alibaba.qwen.code.managedagent;
import com.alibaba.qwen.code.daemon.HarnessSessionRef;
import com.alibaba.qwen.code.daemon.HostedHarnessClient;
import com.alibaba.qwen.code.managedagent.harness.QwenHostedHarnessConnector;
import com.alibaba.qwen.code.managedagent.harness.SdkHarnessBoundary;
import com.alibaba.qwen.code.managedagent.harness.UnavailableHarnessConnector;
import org.junit.jupiter.api.Test;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.test.util.ReflectionTestUtils;
@@ -15,8 +18,10 @@ import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
import static com.alibaba.qwen.code.managedagent.HostedGateTestSupport.attachment;
import static com.alibaba.qwen.code.managedagent.HostedGateTestSupport.attachmentsOf;
import static com.alibaba.qwen.code.managedagent.HostedGateTestSupport.bootWithFreshPorts;
import static com.alibaba.qwen.code.managedagent.HostedGateTestSupport.camel;
import static com.alibaba.qwen.code.managedagent.HostedGateTestSupport.connectorOwning;
import static com.alibaba.qwen.code.managedagent.HostedGateTestSupport.quote;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -61,6 +66,95 @@ class HostedGateTestSupportTest {
assertThat(attachment(connector, "session-c")).isNull();
}
// The Spring bean is a decorator, not the connector that owns the map, so
// the lookup has to walk the delegate chain. Without this the Hosted gates
// fail in the test-support layer before the row under test runs (#15972).
@Test
void attachmentUnwrapsTheBoundaryToTheConnectorThatOwnsTheMap() {
var connector = mock(QwenHostedHarnessConnector.class);
var attachments = new LinkedHashMap<Object, HarnessSessionRef>();
HarnessSessionRef mine = ref("session-a");
attachments.put("mine", mine);
ReflectionTestUtils.setField(connector, "attachments", attachments);
var decorated = new SdkHarnessBoundary(new SdkHarnessBoundary(connector));
assertThat(attachment(decorated, "session-a")).isSameAs(mine);
assertThat(attachment(decorated, "session-c")).isNull();
assertThat(attachmentsOf(decorated)).isSameAs(attachments);
}
// The same bean is read for its client one line after its attachment, so
// resolving the owner has to serve every private read on it, not just the
// map (#15972 R1-1).
@Test
void connectorOwningServesEveryPrivateReadOnTheDecoratedBean() {
var connector = mock(QwenHostedHarnessConnector.class);
var attachments = new LinkedHashMap<Object, HarnessSessionRef>();
attachments.put("mine", ref("session-a"));
ReflectionTestUtils.setField(connector, "attachments", attachments);
HostedHarnessClient client = mock(HostedHarnessClient.class);
ReflectionTestUtils.setField(connector, "client", client);
var decorated = new SdkHarnessBoundary(new SdkHarnessBoundary(connector));
assertThat(ReflectionTestUtils.getField(connectorOwning(decorated), "client"))
.isSameAs(client);
assertThat(connectorOwning(decorated)).isSameAs(connector);
assertThat(attachmentsOf(decorated)).isSameAs(attachments);
}
// A delegate that is itself null must not become a complaint about a
// missing target: fall back to the connector we started from, so the error
// still names the field the caller asked for.
@Test
void aNullDelegateStillFailsNamingTheRequestedField() {
var decorated = new SdkHarnessBoundary(null);
assertThatThrownBy(() -> attachmentsOf(decorated))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("attachments");
}
// findField's type argument matches the declared type exactly, so a
// decorator that declares its delegate as a concrete connector is only
// walked past by a name-plus-assignability match. Without this the next
// such wrapper silently reintroduces #15972 on a green build.
@Test
void theWalkFollowsADelegateDeclaredAsAConcreteConnector() {
var connector = mock(QwenHostedHarnessConnector.class);
var attachments = new LinkedHashMap<Object, HarnessSessionRef>();
attachments.put("mine", ref("session-a"));
ReflectionTestUtils.setField(connector, "attachments", attachments);
var decorated = new ConcreteTypedBoundary(connector);
assertThat(connectorOwning(decorated)).isSameAs(connector);
assertThat(attachmentsOf(decorated)).isSameAs(attachments);
}
// A chain whose leaf owns neither field — the pooled connector is shaped
// like this — must stop at the leaf, so the caller still gets the error
// naming the missing field rather than a NullPointerException from
// dereferencing past the end.
@Test
void aChainThatOwnsNoMapStopsAndStillNamesTheMissingField() {
var leaf = new UnavailableHarnessConnector();
var decorated = new SdkHarnessBoundary(leaf);
assertThat(connectorOwning(decorated)).isSameAs(leaf);
assertThatThrownBy(() -> attachmentsOf(decorated))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("attachments");
}
// Declares its delegate as the concrete connector, not the interface.
private static final class ConcreteTypedBoundary
extends UnavailableHarnessConnector {
private final QwenHostedHarnessConnector delegate;
ConcreteTypedBoundary(QwenHostedHarnessConnector delegate) {
this.delegate = delegate;
}
}
// The boot retry's only consumer is the deep MySQL lane; pin its contract
// here so a wrong edit goes red in the default surefire lane instead.
@Test
@@ -57,6 +57,8 @@ import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static com.alibaba.qwen.code.managedagent.HostedGateTestSupport.attachment;
import static com.alibaba.qwen.code.managedagent.HostedGateTestSupport.attachmentsOf;
import static com.alibaba.qwen.code.managedagent.HostedGateTestSupport.connectorOwning;
import static com.alibaba.qwen.code.managedagent.HostedGateTestSupport.quote;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
@@ -644,7 +646,8 @@ class HostedPublicWorkspaceIT {
var connector = spring.getBean(HarnessConnector.class);
var attached = attachment(connector, session);
assertThat(attached).as("Spring's attachment to %s", session).isNotNull();
var client = (HostedHarnessClient) ReflectionTestUtils.getField(connector, "client");
var client = (HostedHarnessClient) ReflectionTestUtils.getField(
connectorOwning(connector), "client");
// The third request is counted before its handler finishes; wait for the
// producer's prompt and all receipt handlers before taking final evidence.
await().atMost(Duration.ofSeconds(15)).failFast(() -> {
@@ -1620,7 +1623,7 @@ class HostedPublicWorkspaceIT {
"requestId", "d6b-action", "response", response) : response;
assertThat(request("POST", route, body, id, "reader", 403).at("/error/code").asText()).isEqualTo("action_forbidden");
// The live Harness retains this approval while the dispatcher's attachment cache is cold.
((Map<?, ?>) ReflectionTestUtils.getField(spring.getBean(HarnessConnector.class), "attachments")).clear();
attachmentsOf(spring.getBean(HarnessConnector.class)).clear();
// Files sessions answer the first approval as a second OPERATOR
// (R1's handoff) and the rest as the owner. A Shell answer runs
// a command, so only the recorded owner may answer it.
@@ -1,8 +1,10 @@
package com.alibaba.qwen.code.managedagent.harness;
import com.alibaba.qwen.code.daemon.HostedHarnessClient;
import com.alibaba.qwen.code.managedagent.HostedGateTestSupport;
import com.alibaba.qwen.code.managedagent.ManagedAgentServerApplication;
import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties;
import com.alibaba.qwen.code.managedagent.contracts.harness.HarnessConnector;
import com.alibaba.qwen.code.managedagent.contracts.security.WriterCredentialPolicy;
import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException;
import com.alibaba.qwen.code.managedagent.persistence.HarnessPlacementStore;
@@ -86,7 +88,10 @@ class HostedHarnessPoolMySqlIT {
var settings = spring.getBean(ManagedAgentProperties.class);
settings.getSessionStore().setBaseUrl("http://127.0.0.1:" + spring.getWebServer().getPort());
var placements = spring.getBean(HarnessPlacementStore.class);
pool = spring.getBean(HarnessPoolConnector.class);
// The wired bean is the SdkHarnessBoundary decorator, so asking Spring
// for the concrete pool type finds nothing; resolve the owner instead.
pool = (HarnessPoolConnector) HostedGateTestSupport.connectorOwning(
spring.getBean(HarnessConnector.class));
pool.refresh();
assertThat(pool.supportsFencedTitles()).isTrue();
String tenantA = "pool-a-" + UUID.randomUUID();
+62 -19
View File
@@ -98,9 +98,11 @@ describe('complete macOS test groups', () => {
});
// The extended ceilings below, except the test lane's, are scoped to the
// self-hosted pool: `runs-on` falls back to ubuntu-latest for fork PRs from
// untrusted authors and when MAINTAINER_ECS_RUNNER_DISABLED=true, so those
// hosted paths keep the pre-contention ceiling. The ARM64 fleet renders
// self-hosted pool: `runs-on` falls back to ubuntu-latest when
// MAINTAINER_ECS_RUNNER_DISABLED=true (and, for the association-gated lanes,
// for fork PRs from untrusted authors — lint_and_static reads lint_runner,
// which routes every PR to the pool), so those hosted paths keep the
// pre-contention ceiling. The ARM64 fleet renders
// `["self-hosted", "linux", "arm64"]` — no `ecs-qwen` label — so the scope
// cannot key on the x64 fleet's name. Evaluate the real timeout expression for
// each routing instead of pinning a bare number -- the same
@@ -111,15 +113,42 @@ describe('complete macOS test groups', () => {
const ECS_RUNNER = '["self-hosted", "linux", "x64", "ecs-qwen"]';
const ARM_RUNNER = '["self-hosted", "linux", "arm64"]';
const HOSTED_RUNNER = '["ubuntu-latest"]';
const timeoutMinutesOn = (job, ubuntuRunner) => {
// The classifier output a job's own `runs-on` reads. Deriving it is what makes
// a ceiling pointed at the OTHER output red: only this name is substituted, so
// `lint_runner` left inside a gate that runs on `ubuntu_runner` survives the
// substitution and trips the unmodelled-term throw below.
const runnerOutputOf = (job) => {
const names = [
...new Set(
[
...String(ci.jobs[job]['runs-on']).matchAll(
/needs\.classify_pr\.outputs\.(\w+)/g,
),
].map((match) => match[1]),
),
];
if (names.length !== 1) {
throw new Error(
`${job} runs-on names ${names.length} classifier outputs: ${names}`,
);
}
return names[0];
};
const timeoutMinutesOn = (job, runner) => {
// The same `|| '["ubuntu-latest"]'` default the runs-on expression carries:
// a missing output (classify_pr skipped) is the hosted routing.
const effective = ubuntuRunner || HOSTED_RUNNER;
// a missing output (classify_pr skipped) is the hosted routing. The caller
// passes the value of the output the job itself reads — ubuntu_runner for
// the association-gated lanes, lint_runner for lint_and_static.
const effective = runner || HOSTED_RUNNER;
const output = runnerOutputOf(job);
let expr = String(ci.jobs[job]['timeout-minutes'])
.replace(/^\$\{\{\s*/, '')
.replace(/\s*\}\}$/, '')
.replace(
/contains\(needs\.classify_pr\.outputs\.ubuntu_runner \|\| '\["ubuntu-latest"\]', 'self-hosted'\)/g,
new RegExp(
`contains\\(needs\\.classify_pr\\.outputs\\.${output} \\|\\| '\\["ubuntu-latest"\\]', 'self-hosted'\\)`,
'g',
),
String(effective.includes('self-hosted')),
)
.replace(/fromJSON\(/g, '(');
@@ -241,7 +270,8 @@ describe('raw JUnit report retention', () => {
// but picks its own architecture, so its `runs-on` cannot be compared with
// another lane's. Evaluate the real expression the way
// `.github/scripts/ci-runner-routing.test.mjs` does.
const runnerOn = (job, ubuntuRunner) => {
const runnerOn = (job, runner) => {
const output = runnerOutputOf(job);
const expression = String(ci.jobs[job]['runs-on'])
.replace(/^\$\{\{\s*/, '')
.replace(/\s*\}\}$/, '');
@@ -251,7 +281,7 @@ const runnerOn = (job, ubuntuRunner) => {
'contains',
`return (${expression});`,
)(
{ classify_pr: { outputs: { ubuntu_runner: ubuntuRunner } } },
{ classify_pr: { outputs: { [output]: runner } } },
JSON.parse,
(labels, label) => labels.includes(label),
);
@@ -558,18 +588,19 @@ describe('post-merge push lane', () => {
expect(condOf('lint_and_static')).toBe(condOf('test'));
});
it('routes lint_and_static through the classify_pr fork-trust output', () => {
it('routes lint_and_static through the classify_pr lint output', () => {
// runs-on is where the contributor code this describe's other pins talk
// about actually executes. classify_pr's pick_runner is the fork-trust
// boundary — untrusted fork PRs stay on hosted runners — and a literal
// pool label array here would bypass that indirection whole (and desync
// from timeout-minutes, which reads the same output). Whole-expression
// match, as no-ak-integration-ci does for test_windows.
// about actually executes. classify_pr's pick_runner publishes TWO
// routings: ubuntu_runner keeps the fork-trust boundary for test and the
// integration lanes, while lint_runner routes every pull request —
// untrusted forks included — to the fleet (maintainer decision
// 2026-10-31, shared-pool code-execution risk accepted). A literal pool
// label array here would bypass the classifier indirection whole (and
// desync from timeout-minutes, which reads the same output), and reading
// ubuntu_runner would silently restore the hosted fork lane. Whole-
// expression match, as no-ak-integration-ci does for test_windows.
expect(String(ci.jobs.lint_and_static['runs-on'])).toBe(
String(ci.jobs.test['runs-on']),
);
expect(String(ci.jobs.lint_and_static['runs-on'])).toBe(
'${{ fromJSON(needs.classify_pr.outputs.ubuntu_runner || \'["ubuntu-latest"]\') }}',
'${{ fromJSON(needs.classify_pr.outputs.lint_runner || \'["ubuntu-latest"]\') }}',
);
// The needs edge is what makes every needs.classify_pr.outputs.* this
// describe relies on non-empty at runtime. Drop it and the lane silently
@@ -583,6 +614,18 @@ describe('post-merge push lane', () => {
).toContain('classify_pr');
});
it('keeps test on the association-gated output', () => {
// Nothing else reads test's runs-on: switching it to lint_runner would
// hand every untrusted fork the pool while the timeout pins above stayed
// green — timeoutMinutesOn derives each lane's output from its own
// runs-on, so it cannot catch a lane pointed at the wrong output. Pin the
// expression, not an equality with lint_and_static — the two are meant
// to differ.
expect(String(ci.jobs.test['runs-on'])).toBe(
'${{ fromJSON(needs.classify_pr.outputs.ubuntu_runner || \'["ubuntu-latest"]\') }}',
);
});
it('lint_and_static keeps the minimal token its required-check role needs', () => {
// This block overrides the workflow-level `checks: write` /
// `statuses: write` grant. The lane runs contributor code (`npm ci`
@@ -53,14 +53,18 @@ const branchFamily = toPosix(reviewBranch(probePr)).slice(
const ciYaml = parse(readFileSync('.github/workflows/ci.yml', 'utf8'));
// Every ci.yml job that checks out on the shared self-hosted pool inherits a
// possibly dirty workspace. Match the pool itself, not just the output
// reference that usually names it: jobs can also hard-code the shared label
// references that usually name it: jobs can also hard-code the shared label
// array, and a checkout on either form inherits the same leftovers.
// lint_and_static reads lint_runner (every PR reaches the pool), the other
// lanes ubuntu_runner — both name the same shared workspace.
// Include retained recovery steps on hosted jobs too, so rerouting cannot
// silently restore an untested sweep. Select by properties, not job name.
const ciCleanSteps = Object.entries(ciYaml.jobs)
.filter(
([, job]) =>
(/ubuntu_runner|ecs-qwen/.test(JSON.stringify(job['runs-on'] ?? '')) ||
(/ubuntu_runner|lint_runner|ecs-qwen/.test(
JSON.stringify(job['runs-on'] ?? ''),
) ||
(job.steps ?? []).some(
(s) => s.name === 'Clean stale .qwen before checkout',
)) &&