Find a file
Shaojin Wen c49bedfb37
feat(review): run the reviewed repository's own commands behind a container (#9556) (#9723)
* feat(review): run the reviewed repository's own commands behind a container (#9556)

A review executes the code it is reviewing. `build-test` runs whatever the
reviewed repository's `package.json` names — `npm ci` with its `preinstall` and
`postinstall` scripts, the build, the suite — and `test-efficacy` runs that
suite again once per baseline, control, mutant, hunk probe and revert. Both did
it as the invoking identity, and both handed the PR's code `process.env`
entire: on CI that carries `OPENAI_API_KEY` and `GH_TOKEN`. Reading them is one
line in a `postinstall`, and it needs none of the git-config machinery the
pipeline's threat findings are built on.

The boundary goes around the executions, not around the review agent. Wrapping
the agent was tried first and is the wrong shape: its secrets do not survive
the container's env allowlist, its `timeout` reaps the host-side client rather
than the container, its CLI version stops matching the runner's — and after all
of that the mount is the whole checkout, so `<repo>/.git` stays writable
anyway.

Three decisions the argv encodes, each measured rather than assumed:

- **The mount is the review temp dir, not the tree the command runs in.** The
  dependency farm links OUT of every tree — `exposeDependencies` points each
  package in the probe tree's `node_modules` at the review worktree's copy, 1
  722 of them on a live CI review. Mounting one tree would leave every link
  dangling. Every tree the pipeline builds is a sibling under `.qwen/tmp`, so
  one mount covers both ends while `<repo>/.git` stays outside it.
- **The environment is an allowlist**, not the inherited one.
- **The network is per command kind.** An install needs the registry; a build
  and a suite do not, and `--network none` keeps loopback so a suite that
  stands up a local fixture server still runs.

One ephemeral container per command. A long-lived one per phase would save
about 1–2% of the 540-second efficacy budget and would re-introduce exactly the
cross-run state #9221 spent rounds closing.

Off by default: containerising a build by surprise changes what native modules
compile against. `review.sandbox` is `off` | `auto` | `required`, read through
`operatorReviewSettings` — which skips the workspace scope, so a repository
cannot ship a `.qwen/settings.json` that switches off the containment existing
to contain it. `QWEN_REVIEW_SANDBOX` outranks it so CI can require containment
without depending on a settings file the runner may not carry.

Each of the three decisions is pinned by a test that goes red when that
decision alone is reverted.

* chore(review): regenerate settings.schema.json for review.sandbox

The generated JSON Schema is checked in and CI diffs it against a fresh run
(`npm run generate:settings-schema`). Adding `review.sandbox` to
`settingsSchema.ts` without regenerating left the two out of step, which is
what the "settings.schema.json is out of date" gate is for.

The second red check, `Post Coverage Comment`, failed at "Download coverage
reports artifact" — a consequence of the test job dying before it uploaded
one, not an independent failure.

* fix(review): make `required` actually refuse, and fix four wiring bugs the first cut shipped

Five Criticals from the review, all of them real, and the first two would have
made the feature not work at all.

- **`required` failed open.** `sandboxVerdict` produced a `refused` verdict and
  nothing consumed it: both call sites tested `kind !== 'container'` and fell
  through to the direct spawn with the full environment. Refusal is now decided
  ONCE, at the top of each phase, before anything executes — which is also the
  only place that can cover the route that never reaches a spawn: a repo whose
  toolchain cannot be scoped is handed to the AGENT's own shell, and a gate at
  the spawn would leave that wide open under the very policy forbidding it.
- **`SANDBOX` was a shortcut past the policy.** The first cut returned `direct`
  when the session was already sandboxed, reasoning that the outer boundary is
  the one the operator asked for. Wrong for this property: the CLI's own
  sandbox constrains the filesystem and hands the child `process.env` entire,
  and stripping the secrets is half of what `required` promises.
- **The probe suite baked in the host's Node path.** `process.execPath` does
  not exist inside the image, so every sandboxed probe would exit 127 and map
  baseline, control, every mutant, every hunk and the revert to inconclusive —
  zero evidence exactly when containment is on. It uses the image's `node` now;
  the vitest path resolves because it lives under the mount.
- **The mount root took the first `.qwen/tmp`, not the deepest.** A review run
  from inside another review's worktree nests them, and the first occurrence
  widens the mount to the outer temp dir — pulling `<repo>/.git` and every
  sibling checkout in, which is the one property the mount exists for.
- **No UID/GID mapping.** The default image runs as root, so the container's
  writes into the mounted trees were root-owned and every later host-side
  cleanup — the install-timeout `rmSync`, `discardWorktree`, the sweeps — hit
  EACCES, accumulating residue across reviews.

Two things the fixes themselves needed, found by checking them rather than by
being told:

- **Refusing with `toolchain: 'unsupported'` would have caused the regression it
  was closing.** That value has a documented meaning — the brief reads it as
  "build-test could not scope this repo, install and build it yourself" — so a
  refusal routed into it would have sent the agent to run the reviewed code by
  hand, unsandboxed. It is a distinct `refused` now, with a brief rule that says
  the evidence is unavailable and must NOT be reconstructed by hand.
- **A bare `--user uid:gid` resets `$HOME` to `/`**, which the mapped user
  cannot write, so npm fails before the install starts — `utils/sandbox.ts`
  copies the host's `$HOME` for exactly this. The container gets a writable
  HOME inside the mount, with the npm cache under it.

The duplicated mount-root arithmetic became one exported `mountRootFor`, which
is both how the copies stopped drifting and how the nested case got a test.
Every fix above is pinned by a test that goes red when that fix alone is
reverted.

* fix(review): stop the reviewed repository from deciding its own containment (#9556)

Round 2. The sharpest finding is that the guarantee this PR advertises was
true of one route and false through another.

**A repository could switch off the containment that exists to contain it.**
`operatorReviewSettings` skips the workspace scope precisely so a
`.qwen/settings.json` cannot set review policy — but the env layer that
outranked it is repository-controlled too: `loadEnvironment` walks up from cwd
and applies `<repo>/.qwen/.env`, from the very checkout under review, admitted
by default because folder trust starts off. `QWEN_REVIEW_SANDBOX=off` in a
committed `.env` disabled it. Three siblings were worse, because they have no
ordering to fall back on: `QWEN_REVIEW_SANDBOX_IMAGE` chooses the image the
reviewed code runs *inside*; `SANDBOX_SET_UID_GID=false` puts the container
back to root; `DOCKER_HOST` chooses which daemon answers, so `required` reads
as satisfied and whatever that daemon returns is scored as evidence.

`environment.ts` gains `isFileSourcedEnvKey`, and containment now reads only
the operator's settings or a real process variable. The policy additionally
only ever tightens, so even a genuine env value cannot lower a settings
`required` — which, as a mutation showed, is what actually protects the policy;
the file-source check is what protects the other three.

**`required` still failed open where the mount could not be built.** The gate
asked "did a runtime answer", never "can this phase be contained": with a
healthy daemon and a cwd outside `.qwen/tmp` — a `/review` of a local checkout
— the command fell through to the direct spawn with the full environment and a
report indistinguishable from a contained run. It asks the second question now.

**A refusal on `--resume` destroyed the run it was asked to continue.**
Returning a report let the handler's unconditional write overwrite the
in-flight one, and the refusal carries no run identity, so every later resume
failed the identity check even after the runtime recovered. It throws on a
continuation, which is the invariant the `!adapter` branch states in its own
words.

**The HOME added last round was itself cross-run state.** It lived on the
shared mount, and `sh -lc` sources `$HOME/.profile` while npm reads
`$HOME/.npmrc` — so one run's postinstall could plant what the next review's
install executes, with the network on. That contradicted this module's own
`--rm` "isolation by construction" claim, and it arrived with the fix for the
`$HOME` problem rather than in the original. HOME is a tmpfs now: discarded
with the container, never on the host. The npm cache stays on the mount, and
the comment says plainly that npm's integrity check is what stands between a
poisoned cache and a bad install.

**The mount root was lexical.** `resolve` never touches the filesystem, so a
symlink at or above `.qwen/tmp` — committable as mode 120000 — would have
widened a read-write bind mount to wherever it pointed. Every other creating or
destroying path in this pipeline refuses that; this one does now too.

Also: the new tests were platform-fragile in three places (Windows has no
`process.getuid`, and the documented `SANDBOX_SET_UID_GID` opt-out could fail
them on a developer's box), and the secret-leak check asserted against whatever
the runner happened to export rather than a planted canary. Both fixed, and the
mount-root tests now build real directories, which is how the symlink refusal
got pinned at all.

Every fix here is pinned by a test that goes red when that fix alone is
reverted — except the policy's file-source check, which a mutation showed is
redundant with the tightening rule, and which is documented as defence in depth
rather than claimed as load-bearing.

* fix(review): close the hand-off arm, and three fixes that were wrong at their edges

Round 3. Six Criticals, all of them real, and two were introduced by round 2's
own fixes.

- **The hand-off arm of the original blocker was still open.** The phase gate
  refuses when containment is impossible — but a repo the npm adapter cannot
  scope (yarn/pnpm/bun, no lockfile) reaches `unsupportedReport` with the gate
  satisfied: a runtime answered and the tree is mountable. That report tells the
  agent to install and build with its own shell, which nothing here contains. An
  inapplicable adapter is now a refusal under `required`, not a hand-off.
- **The mount-failure refusal fired under `auto` too**, and its message
  hardcoded `required`. Under `auto` the contract is "contain it when that is
  possible", so an unmountable tree falls back to the direct spawn — refusing
  there would have taken build/test and efficacy evidence away from every local
  review the moment a daemon happened to be running. Mine, from round 2.
- **`--workdir` got the lexical path while the mount got the realpath.** Round
  2 made `mountRootFor` canonicalise; the workdir did not follow, so on any
  layout where the two spellings differ — `/var` against `/private/var` is the
  everyday one — the container was handed a directory it does not have and
  every command would fail before starting. Also mine, from round 2.
- **The daemon scrub missed the indirection selectors.** `DOCKER_CONFIG`,
  `CONTAINERS_CONF` and friends name a config file that in turn names the
  daemon, the registries and the runtime: scrubbing the direct selectors and
  leaving these moves the same steering one level down.
- **`isFileSourcedEnvKey` matched case-sensitively.** Windows env lookup is
  case-insensitive, so a `.env` committed as `docker_host=…` reaches the child
  exactly as `DOCKER_HOST` would while the exact-case test answers "not from a
  file" about a value that is. Same class as `sanitizedGitEnv`'s case fold.

The gitfile finding stays deferred with its reasoning in the thread — it is not
a hole this PR opens, and closing it belongs to the identity gates rather than
to the sandbox.

Two mutations came back green on the first pass and were the useful part of
this round: the `DOCKER_CONFIG` scrub had no test because the fixture pinned one
key rather than the set, and the operator's `SANDBOX_SET_UID_GID=false` opt-out
had no test at all — both uid tests asserted the flag was PRESENT. The scrub
test now asserts the whole set, and the opt-out has its own.

* fix(review): the hand-off gate was dead code, and three more edges

Round 4.

- **Last round's hand-off gate never ran.** It tested `!applicable`, and
  `applicable` is the filtered adapter ARRAY returned by
  `selectToolchainAdapter` — never falsy. The gate shipped green and closed
  nothing. It is judged on the RESULT now (`toolchain === 'unsupported'`),
  which also covers the second route to a hand-off — an adapter that applies
  and cannot scope, from inside the npm one — and the predicate is exported and
  tested rather than living inline where no test could see it.
- **The boxed farm dangled under a symlinked ancestor.** Round 3 made the mount
  and `--workdir` canonical; `exposeDependencies` still built its link targets
  from the lexical root, so on the everyday macOS `/tmp` → `/private/tmp`
  layout every farm link resolved to a path the container does not have. The
  phase would then report "every file was red or collected nothing" — a wiring
  failure published as a statement about the PR's own suite. Canonicalised on
  the sandboxed path only; the direct path keeps the caller's spelling.
- **A timed-out boxed run leaked its container.** `--rm` fires only on a
  self-exit, and the deadline kills the runtime CLIENT — so a suite whose own
  trap ignores the forwarded signal keeps running with the review temp dir
  writable, past the budget and past the end of the review. Containers get a
  unique `--name`, and both spawn sites `rm -f` it when the deadline fires.
- **The daemon scrub deleted case-sensitively.** Round 3 taught
  `isFileSourcedEnvKey` to fold case on Windows and left the deletion exact —
  so a `docker_host` written by a repo `.env` was correctly detected and then
  not removed.

Two mutations came back green again, and both were the round's real lesson: the
hand-off refusal had no test (which is how its dead-code predecessor shipped),
and the farm canonicalisation still has none — it needs a symlinked-ancestor
fixture with a live runtime, which this machine cannot provide, and it is
listed with the other integration gaps rather than claimed.

* fix(review): put the hand-off conversion at the one exit, and make the reaper reachable

Round 5. The first finding is the same one for the third time, and the third
time is the one worth explaining: I had been guarding routes rather than the
exit.

- **Attempt one** tested `!applicable` — the filtered adapter ARRAY, never
  falsy — and was dead code.
- **Attempt two** wrapped the two `adapter.run` returns and missed the
  `!adapter` branch's own `unsupported` report.
- **Now** the conversion sits at the single place a report can reach a caller,
  and it is an exported `applyHandOffPolicy` rather than a branch inside a long
  function, so a test can reach it without a live container runtime. Both
  previous attempts failed the same way — a guard placed where no test could
  see it, in a function with more exits than the author was holding in mind.

**The container reaper added last round was unreachable, not wrong.**
`spawnSync` sends its `killSignal` at the deadline and then WAITS for the child
to exit, so an attached runtime client that forwards SIGTERM to a workload
whose own trap ignores it never returns — and the `killContainer` after it never
runs. The boxed spawns use `killSignal: 'SIGKILL'` now: the client cannot ignore
it, the call returns, and the container is reaped by name at the daemon, which
is where the deadline had to be enforced.

**The scrub missed the proxy family.** `HTTP_PROXY`/`HTTPS_PROXY`/`ALL_PROXY`/
`NO_PROXY` (and their lowercase spellings) are honoured by both clients for
every daemon call, so a repo-shipped one interposes on the connection the
direct selectors were scrubbed to protect.

One test was removed rather than added: a `expect(killContainer).toBeTypeOf(
'function')` I wrote to "document" the SIGKILL reasoning. It asserts nothing and
would have read as coverage; the reasoning belongs in the comment where it now
lives alone.

The `killSignal` choice is reasoned but not pinned — it needs a live runtime and
a TERM-ignoring workload — and joins the integration gaps already listed in the
PR body rather than being claimed.

* fix(review): the third continuation exit destroyed the report it was continuing

Round 6, two Criticals: the deferred gitfile one, and this.

Round 5's single-exit conversion runs on `--resume` answers too. A resumed
report whose toolchain is `unsupported`, under a policy that tightened between
the first call and the continuation, was replaced by a fresh refusal — which
the handler writes unconditionally, over the report the call was asked to
continue. That refusal carries no run identity, so every later `--resume` fails
the identity check and the round redoes install, build and every suite.

"A continuation must never answer with a FRESH report" is enforced by a throw
at the refusal gate and at `!adapter`. This conversion was added after both and
did not have it. It does now.

The trigger is ordinary rather than adversarial: the policy is read per call,
so an operator raising it — or a workflow's `env:` — between call one and the
resume is enough, on exactly the unscopeable repo shapes (yarn/pnpm/bun) that
reach a hand-off at all.

**The first test I wrote for this passed without the fix.** It drove
`runBuildTest` with an incomplete argument object and asserted `.toThrow()`;
the throw it saw was `--timeout must be a finite number of seconds`, from
validation long before the code under test. A mutation caught it. Making the
fixture faithful means reproducing the whole resume-identity apparatus, so the
decision is a predicate instead — `resumeWouldDestroyReport`, tested directly,
with the wiring left as one visible line. Same shape as round 5's
`applyHandOffPolicy`, and for the same reason: a guard a test cannot reach is a
guard that has already been wrong twice here.

* fix(review): reap the container on every abnormal exit, not only the timeout

Round 7, one Critical.

`build-test` reaped the boxed container when `spawnTimedOut(r)`, which is true
for ETIMEDOUT and false for a `maxBuffer` overflow — and a reviewed command
writing 64 MB to one stream is a postinstall away. The client dies with
ENOBUFS, the reap is skipped, and the container keeps the review temp dir
mounted read-write past the per-command deadline and past the end of the
review.

The sibling in `test-efficacy` already reaped on the broader `r.error ||
r.signal`. That the two had drifted to different conditions is how one came to
miss a case the other caught, so they now share one exported predicate:
`status === null` — exactly "the client did not exit normally", covering
ETIMEDOUT, ENOBUFS and signal kills in one condition rather than a list of
causes to keep in sync. A normal exit needs no reaping (`--rm` has fired), and
a client that never spawned has no container, where the reap is a silent no-op
by `killContainer`'s construction.

* fix(review): scrub the container client env by provenance, not by name

The scrub list named the daemon selectors, then the proxy family, and still
missed DOCKER_API_VERSION — which selects no daemon at all. It just makes
every client call fail, and under `sandbox: auto` a failing availability
probe reads as "no runtime here", so a repository that ships one value in
its .env turns containment off without a word.

The class has no last entry. Replace the list with the rule it was trying to
approximate: a key the loader wrote from a file the reviewed checkout
supplies does not reach the process that decides whether containment
happened. The client needs no repository-provided environment for anything,
and the workload's environment is an allowlist either way.

Deleting restores rather than approximates: the loader records a key as
file-sourced only where the real environment had nothing, so a file value
never shadows an inherited one and the client keeps the operator's own
DOCKER_HOST, PATH and HOME.

* fix(review): drop --user on rootless runtimes, and take the base-side rerun into the box

Two blockers from round 9.

R9-1. `--user uid:gid` is right on a rootful engine and wrong on a rootless
one. Rootless podman — its default install — and rootless docker run the
engine inside the invoking user's namespace, so container uid 0 IS the
invoking host user and every other container uid lands on a subuid near
100000. The availability probe answers fine, the phase gate passes, and then
every boxed command meets a tree it does not own: `npm ci` cannot create
node_modules, and what the container does create comes out unsweepable — the
cross-run residue `--user` exists to prevent. Detect rootlessness from the
runtime's own `info` document and drop the flag there, where the container's
root is already the invoking user and the flag has nothing left to do.

Unknown answers rootful. The two wrong guesses are not symmetric: guessing
rootful on a rootless host breaks the run loudly, guessing rootless on a
rootful one silently runs reviewed code as real uid 0 on a writable mount.

R2-21. test-delta kept a private copy of build-test's run(). The copy was
correct until build-test's grew a container, and then the two sides of the
measurement stopped being comparable: the PR side in the image with an env
allowlist and no network, the base side on the host with both. A test that
reads an env var or opens a socket flips on one side, and the file whose only
job is to say which side a failure belongs to says "the PR's". The duplicate
is deleted rather than re-synchronised, and `required` now refuses the
base-side rerun instead of running it on the host.

A refusal returns an empty report, so the brief now says in as many words
that an empty `netNew` beside such a note is the absence of a measurement,
not the absence of a regression.

* fix(review): refuse mount roots the -v grammar cannot spell, and stop the suite inheriting the operator's policy

R10-1. `-v src:dst` has exactly one separator, so a checkout at `/…/my:repo`
produces a spec docker answers with `invalid spec … too many colons`.
`mountRootFor` called that root mountable, which bypassed both designed
degradations: under `auto` every install/build/test command surfaced a raw
mount error the report attributed to the PR instead of falling back to the
direct spawn, and under `required` the phase gate passed and the refusal that
should have explained it never happened. Classify such a root as unmountable
and it rejoins the path every other unmountable root already takes.

Measured rather than assumed, including the suggested alternative: `--mount
type=bind` fails on the same colon AND on a comma that `-v` takes without
complaint, so it would trade one unspellable path for two.

On Windows this refuses every absolute path, deliberately: a drive letter is
a colon, and a mount whose source and target are the same path cannot be a
Windows path at all. Containment is not available there, and saying so is
what gives `auto` its fallback and `required` its refusal.

Hermeticity, which this PR broke. The phase gates read the operator's own
`review.sandbox`, so a maintainer who turns the feature on and runs the suite
watched 101 review tests report that setting back at them instead of what
they measure. Both routes are closed: the shared setup drops the environment
variables, and the two suites whose gates resolve settings isolate `QWEN_HOME`
the way they already isolate the host git config — measured at 101 → 0 from
each route independently. test-delta's gate is injectable for the same reason
its `exec` is.

Also from the round-10 list, all self-inflicted: the `--user` test pinned the
documented opt-out it asserts against, `runtimeIsRootless`'s unknown case is
now structural (an empty document carries no marker) rather than a literal a
mutant can flip, and the dead `resetContainerRuntimeProbe`/`force` pair is
gone. Two doc corrections: `containerPathFor` names the null it actually
produces, and the brief's `refused` bullet covers all three routes that
produce it rather than one.

* test(review): gate the mount-root cases Windows cannot be asked

Round 10 refused every absolute Windows path from `mountRootFor` — a drive
letter is a colon — and said so as intended behaviour. It is; what that reply
missed is that this file then asserts the opposite. Three cases here turn on a
root being MOUNTABLE, which is the one question Windows has no answer to: the
comma control added last round, the deepest-temp-dir selection, and the
symlink refusal's honest half. All three assert non-null against real
absolute paths, and the colon case also builds `my:repo`, a component name
Win32 rejects outright.

`test_windows` is merge_group-only and a required check, so nothing here
would have gone red until the merge queue itself.

Gated off win32 with the convention this directory already uses in 37 places,
and the shipped semantics pinned by a win32-only case rather than left to the
comment that describes them.

* test(review): make the Windows pin actually reach the check it pins

The win32 case added last round named a path that does not exist, so
`mountRootFor` returned null out of its realpath catch and the assertion held
whether or not the drive-letter check was there at all — probe-verified: the
layout is absent and the answer is null anyway. It pinned nothing.

Build the layout first and assert it exists, so the catch is closed and the
colon is the only thing left that can produce the null.

Two more from the same list. The uid cases returned early instead of
skipping, which reports PASSED with zero assertions on the lane where the
condition bites — the reading "this held" for something never checked. And
the install-network assertion looked only for a bare `none`, blind to the
joined `--network=none` a refactor could switch to; it now rejects the flag in
either shape.

* test(review): skip the second uid case instead of returning, and restore its stub in finally

The sibling got `it.skipIf` last round and this one did not, so on a lane
where `getuid` is undefined it still reports PASSED having asserted nothing —
which reads as "this held" for something never checked.

Its `SANDBOX_SET_UID_GID` stub was also cleared on the last line rather than
in a `finally`. There is no `unstubEnvs` in the vitest config and no
file-level hook that would catch it, so a failing assertion left the stub set
for whatever ran next — one red test quietly becoming two.

* test(review): drop the comment last round's edit left stranded

Wrapping the uid case in try/finally moved its opt-out note above the stub
and left the original copy inside the try, so the same three lines now appear
twice a few lines apart. Delete the stranded one.

* test(review): pin the four decisions this feature is sold on

Four cells the suite reached only by accident of the machine it ran on, each
carrying a live mutant on a property the PR description states as a
guarantee. All four functions already took their ambient dependency as an
injectable parameter, so this is assertions, not seams.

- sandboxPolicy: strictest-wins in BOTH directions, and a file-sourced value
  counting for nothing. "A repository cannot switch off the containment that
  exists to contain it" was described in a comment and asserted nowhere;
  env-overrides-settings, a looser strictest, and a dropped file-sourced guard
  all shipped green.
- sandboxVerdict: `auto` with nothing answering runs DIRECTLY. Every other
  case here either has a runtime or is `required`, so a mutant refusing
  instead — turning `auto` into `required` on every machine without docker —
  survived.
- refuseUnsandboxedPhase: the PASS path. Every other assertion about this gate
  is a refusal, so unconditional refusal under `required` — every review on a
  perfectly good host — survived.
- containerPathFor: it feeds `--workdir` at both spawn sites, and had no test
  at all. A lexical spelling names a directory the container does not have;
  the parent fallback is what lets a probe tree be named before it is built.

Mutation-checked, one at a time: seven mutants, seven reds.

* fix(review): read the policy setting the way an operator writes it

`"Required"` in settings.json — or a stray trailing space — matched no policy,
resolved to `off`, and disabled the containment the operator had just asked
for. Silently. The environment value was already normalised; the settings
value was not, and the asymmetry fell on the wrong side: settings is the
documented place to turn this ON, since the environment can only tighten. So
the unnormalised half was the half operators actually use, and a fail-open on
the one setting whose whole purpose is to fail closed.

Reported as a suggestion in rounds 8, 9 and 16 and deferred each time under
the critical-only posture. Measured this round: both `"Required"` and
`"required "` returned `off`.

Also pins two shapes the new tests left open. `sandboxPolicy`'s settings
default is the production path every real caller takes, and with it swapped
for `{}` the whole settings half stopped being consulted while every
assertion — each passing settings explicitly — stayed green; it is now driven
through an isolated settings file. And the `SANDBOX_SET_UID_GID` opt-out
parses case- and space-insensitively, which nothing asserted.

The remaining deferral, a fixture literal pasted twice, is duplication rather
than a defect and is left alone.

* test(review): finish the policy table instead of one row of it

Last round's normalisation fix was asserted only against `required`
spellings, which is the shape a mutant keyed on that one value walks
straight through — leaving an operator's `"Auto"` resolving to `off`, the
same silent downgrade one rung lower. Assert every policy on both sides.

Two adjacent cells with it. A garbled ENVIRONMENT value must be dropped on
its own rather than taking the operator's setting down with it: the
environment is the half a repository can reach, so garbage there must never
answer for the half it cannot. And `sandboxPolicy`'s env default is the twin
of the settings default pinned last round — every assertion here hands it an
env literal, so `env = {}` as the default stops the environment half being
read at all and nothing notices.

Three mutants, three reds.

* test(review): exercise the reap and the runtime probe, and stop leaking a fixture

Three of the four recorded this round; the fourth is duplication, not a
defect, and is left alone for the third time.

`killContainer` is the whole answer to a container that outlived the client's
deadline, and nothing anywhere ran it — a garbled argv, or a dropped `-f`,
shipped green, and what survives is a container holding the review tree open
past the end of the run. It now takes its spawn as a parameter, so the argv
and the swallow-and-continue contract are both asserted.

`containerRuntime`'s probe had the same hole with a memo and a real daemon
call in the way. The decision is now separated from both: `firstAnsweringRuntime`
is order and nothing else, which is the content — a client installed but not
running must never shadow one that is.

And the `containerPathFor` case left a temp tree behind on every run. In a
change about not leaving residue behind, that one is just embarrassing.
2026-08-25 01:58:18 +00:00
.github fix(ci): scope workflow-size ratchet to the PR that grew the file (#9931) 2026-08-25 01:46:34 +00:00
.husky Sync upstream Gemini-CLI v0.8.2 (#838) 2025-10-23 09:27:04 +08:00
.qwen feat(skills): add find-simplifications sweep skill (#9384) 2026-08-24 12:37:05 +00:00
.vscode Merge branch 'main' into feat/sandbox-config-improvements 2026-03-06 14:38:39 +08:00
docs fix(acp-bridge): Disable permission timeout by default (#9933) 2026-08-24 16:08:44 +00:00
docs-site Hide internal docs from docs site (#4357) 2026-06-01 15:55:14 +08:00
eslint-rules refactor(core): make utils/ a leaf layer (#9778) 2026-08-24 07:43:01 +00:00
integration-tests test(acp-cron): kill whole process tree on cleanup to stop ENOTEMPTY flakes (#9815) 2026-08-24 08:23:33 +00:00
integrations/external-context chore(release): v0.22.0 (#9736) 2026-08-22 15:23:02 +00:00
packages feat(review): run the reviewed repository's own commands behind a container (#9556) (#9723) 2026-08-25 01:58:18 +00:00
patches feat(cli): add TUI image display tool (#8217) 2026-08-01 12:39:52 +00:00
scripts fix(ci): scope workflow-size ratchet to the PR that grew the file (#9931) 2026-08-25 01:46:34 +00:00
.dockerignore fix(cli): skip stdin read for ACP mode 2026-03-27 11:47:01 +00:00
.editorconfig pre-release commit 2025-07-22 23:26:01 +08:00
.gitattributes feat(installer): add standalone hosted install and uninstall flow (#3828) 2026-05-21 11:57:10 +08:00
.gitignore chore(ci): Add security hygiene: CODEOWNERS for release workflows, least-privilege permissions, security checks and Scorecard (#9008) 2026-08-14 01:22:53 +00:00
.npmrc chore: remove google registry 2025-08-08 20:45:54 +08:00
.nvmrc chore(deps): upgrade ink 6.2.3 → 7.0.2 + bump Node engine to 22 (#3860) 2026-05-11 17:29:50 +08:00
.prettierignore feat(acp): support /cd command in ACP sessions (#5903) 2026-06-27 14:47:40 +00:00
.prettierrc.json pre-release commit 2025-07-22 23:26:01 +08:00
.yamllint.yml feat(desktop): Add desktop app package with Qwen ACP SDK integration (#3778) 2026-06-11 21:57:20 +08:00
AGENTS.md fix(devx): fail with actionable message when unit-test build prerequisites are missing (#9149) (#9171) 2026-08-18 13:19:09 +00:00
CHANGELOG.md chore(release): v0.22.0 (#9736) 2026-08-22 15:23:02 +00:00
CLAUDE.md docs: rewrite CLAUDE.md to point to AGENTS.md as authoritative source (#5138) 2026-06-15 15:23:26 +08:00
CONTRIBUTING.md revert: remove local PR verification gate (#7031) 2026-07-16 11:24:38 +00:00
Dockerfile perf(ci): cut the E2E suite from ~40min to ~24min (#7798) 2026-07-28 12:56:34 +00:00
esbuild.config.js refactor(core): make utils/ a leaf layer (#9778) 2026-08-24 07:43:01 +00:00
eslint.config.js refactor(core): make utils/ a leaf layer (#9778) 2026-08-24 07:43:01 +00:00
eslint.legacy-filenames.mjs feat(workflows): add cooperative pause and resume (#8320) 2026-08-08 04:21:21 +00:00
LICENSE Sync upstream Gemini-CLI v0.8.2 (#838) 2025-10-23 09:27:04 +08:00
Makefile feat: update docs 2025-12-22 21:11:33 +08:00
package-lock.json feat(computer-use): replace built-in tools with bundled skill (#9856) 2026-08-24 11:05:23 +00:00
package.json refactor(core): make utils/ a leaf layer (#9778) 2026-08-24 07:43:01 +00:00
README.md docs(readme): add Korean to the documentation language bar (#8836) 2026-08-10 07:34:11 +00:00
SECURITY.md fix: update security vulnerability reporting channel 2026-02-24 14:22:47 +08:00
tsconfig.json # 🚀 Sync Gemini CLI v0.2.1 - Major Feature Update (#483) 2025-09-01 14:48:55 +08:00
vitest.config.ts refactor(node-repl)!: deliver the persistent Node REPL as a standalone MCP server (#9499) 2026-08-23 14:20:39 +00:00

npm version License Node.js Version Downloads

QwenLM%2Fqwen-code | Trendshift

The open-source AI coding agent that lives in your terminal.

中文 | Deutsch | français | 日本語 | Русский | Português (Brasil) | 한국어

Why Qwen Code?

  • Agentic out of the box — Auto-Memory, Auto-Skills, SubAgents, Agent Teams, and MCP. Dynamic workflows, zero setup.
  • Open-source, inside and out — The framework and the Qwen models are open-source. They evolve together. No vendor lock-in.
  • Multi-protocol — Supports OpenAI, Anthropic, Gemini, and Qwen APIs. Any third-party provider or local model (Ollama / vLLM). Switch at runtime.
  • Beyond the terminal — IDE plugins, Desktop app, daemon mode, SDKs, and IM bots (Telegram / DingTalk / WeChat / Feishu).

Tip

Qwen Code is actively iterating on itself — using its own agent and models to file issues, submit PRs, review code, and run tests. Powered by the community, driven by AI.

Installation

Linux / macOS:

curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh | bash

Windows:

irm https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.ps1 | iex

Restart your terminal after installation to ensure environment variables take effect.

NPM / Homebrew

NPM (requires Node.js 22+):

npm install -g @qwen-code/qwen-code@latest

Homebrew (macOS / Linux):

brew install qwen-code

Quick Start

qwen          # Launch interactive terminal UI
# Inside the session:
/auth         # Configure your provider and API key

See the Authentication Guide and Settings Reference for detailed setup.

Qwen Code

How to Use Qwen Code

Mode Command Use Case
Interactive qwen Terminal UI with rich rendering, @file references, slash commands
Headless qwen -p "..." Scripts, CI/CD, batch processing — no UI
IDE VS Code, Zed, JetBrains
Desktop Qwen Code Desktop — GUI for macOS, Windows, Linux
Daemon qwen serve Shared agent session over HTTP+SSE (ACP). Multiple clients, one agent. (experimental) Docs
SDK TypeScript, Python, Java
IM Bot qwen channel Connect to Telegram, DingTalk, WeChat, or Feishu
SDK example (Python)
import asyncio

from qwen_code_sdk import is_sdk_result_message, query


async def main() -> None:
    result = query(
        "Summarize the repository layout.",
        {
            "cwd": "/path/to/project",
            "path_to_qwen_executable": "qwen",
        },
    )

    async for message in result:
        if is_sdk_result_message(message):
            print(message["result"])


asyncio.run(main())

Capabilities

If you know Claude Code, you already know Qwen Code — and then some. We've put significant effort into bringing Qwen Code to feature parity with Claude Code, improving both breadth and reliability across the board.

Feature Qwen Code Claude Code
SubAgents, Agent Teams, Dynamic Workflows
Auto-Memory, Auto-Skills, Hooks
Built-in Skills (/review, /batch, /loop, /bugfix…)
MCP, Plan Mode, LSP Integration
Auto Mode, Sandbox, Git Worktrees
Computer Use (desktop automation)
IDE Plugins (VS Code / JetBrains / Zed)
SDK
Headless Mode, Session Management
Open-source — model and framework
Multi-protocol (OpenAI / Anthropic / Gemini / Qwen + any provider)
Agent Arena (multi-model head-to-head on same task)
Daemon Mode — qwen serve (multi-client shared agent)
IM Channels (Telegram / DingTalk / WeChat / Feishu)

Ecosystem

  • Qwen Code Desktop — Official desktop app for macOS, Windows, and Linux

  • AionUi — A modern GUI for command-line AI tools including Qwen Code

  • Gemini CLI Desktop — A cross-platform desktop/web/mobile UI for Qwen Code

  • 🦞 Qwen Code Claw — Let other agents (Claude, Codex, etc.) delegate coding tasks to Qwen Code via ACP. Paste this prompt into your agent:

Use Qwen Code as a coding agent. First, fetch and read this skill reference:
https://raw.githubusercontent.com/QwenLM/qwen-code/refs/heads/main/.qwen/skills/qwen-code-claw/SKILL.md
Then follow the instructions inside to install, authenticate, and use Qwen Code via acpx for all coding tasks.
  • Aliyun Model Studio CLI — Official CLI for Aliyun's AI platform (bailian-cli). Extends Qwen Code with image/video generation, knowledge retrieval, app orchestration, and model deployment

Contributing

Contributions are welcome! See CONTRIBUTING.md for guidelines.

Acknowledgments

This project was originally based on Google Gemini CLI v0.8.2. We gratefully acknowledge the Gemini CLI team's excellent work. Starting from Qwen Code v0.1, we stopped syncing with upstream and began independent development as a multi-protocol, multi-platform agent framework with deep integrations for Qwen models and beyond.