* 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. |
||
|---|---|---|
| .github | ||
| .husky | ||
| .qwen | ||
| .vscode | ||
| docs | ||
| docs-site | ||
| eslint-rules | ||
| integration-tests | ||
| integrations/external-context | ||
| packages | ||
| patches | ||
| scripts | ||
| .dockerignore | ||
| .editorconfig | ||
| .gitattributes | ||
| .gitignore | ||
| .npmrc | ||
| .nvmrc | ||
| .prettierignore | ||
| .prettierrc.json | ||
| .yamllint.yml | ||
| AGENTS.md | ||
| CHANGELOG.md | ||
| CLAUDE.md | ||
| CONTRIBUTING.md | ||
| Dockerfile | ||
| esbuild.config.js | ||
| eslint.config.js | ||
| eslint.legacy-filenames.mjs | ||
| LICENSE | ||
| Makefile | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| SECURITY.md | ||
| tsconfig.json | ||
| vitest.config.ts | ||
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.
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.
