feat: Qwen Code backend (Alibaba) via @qwen-code/sdk - #301
Merged
Conversation
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
Adds `qwen` as a session backend, modeled on ClaudeProvider rather than the
ACP providers: @qwen-code/sdk is a deliberate clone of the Claude Agent SDK
(query({prompt, options}), createSdkMcpServer(), tool()), so the warm-loop
model maps across directly. qwen-code also speaks ACP, but that path has no
in-process MCP mount and no typed auth selection.
Both credential paths work, selected by providers.qwen.authType and
auto-detected when unset (a ~/.qwen login wins over the key):
- qwen-oauth — qwen.ai subscription; tokens never transit codeoid
- openai — OpenAI-compatible key + gateway
Gateway presets are included because the right one is not discoverable: a
Model Studio *plan* key (sk-sp-...) is rejected by both standard DashScope
hosts with invalid_api_key and needs token-plan.ap-southeast-1.maas.aliyuncs.com
(`bailian-plan-intl`). Plan keys also expose more than Qwen — qwen3.8-max,
glm-5.2, deepseek-v4-pro — so providers.qwen.model selects one.
SECURITY — buildQwenEnv() returns more than an allowlist by design. Unlike
the Claude SDK (which replaces the child env), @qwen-code/sdk spawns with
{...process.env, ...options.env}. A plain allowlist is therefore a no-op:
everything omitted is still inherited, including CODEOID_API_KEY (the root
ZeroID key) and TELEGRAM_BOT_TOKEN. Since a merge can only add or override,
every non-allowlisted name is mapped to the empty string. Verified end to end:
the agent ran `echo "leak=[$CODEOID_API_KEY]"` and got `leak=[]`.
Two divergences from ClaudeProvider forced by the SDK:
- Tool correlation. Qwen's canUseTool gets only {signal, suggestions} — no
toolUseID — so the id comes from the assistant message's tool_use block.
Matching is oldest-first among ungated entries of the same NAME, never
positional, since auto-approved tools never reach the callback (issue #81).
- Backing ids are coerced to UUIDs. query() validates sessionId and throws
SYNCHRONOUSLY on a non-UUID, which would escape runTurn() and wedge the
session instead of surfacing as a turn error.
Also: permissionMode is pinned to "default" (qwen-code's own ACP default is
"auto", which silently auto-approves edits and shell), and turn_done falls
back to the requested model because the gateway often omits modelUsage.
Known gap, documented in CLAUDE.md: qwen-code's resolveDefaultPermission runs
isShellCommandReadOnlyAST() and auto-allows anything it classifies read-only
WITHOUT raising a permission request, so those never reach codeoid's gate.
Measured: `rm -f` gates, but `echo`, `cat /etc/hostname`, and a decoy file in
$HOME all ran ungated — including reads outside the workdir. codeoid cannot
intercept a call the CLI never makes. Audited against gemini-cli 0.50.0 (the
fork parent) and the mechanism appears qwen-specific; that check is static
only, as the live ACP run was blocked on expired ~/.gemini OAuth.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
saucam
force-pushed
the
feat/qwen-backend
branch
from
August 24, 2026 14:05
b244e2a to
7c75c1d
Compare
abhijitjavelin
approved these changes
Aug 24, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds
qwenas a session backend, rebased directly ontomain.Why the SDK, not ACP
qwen-code speaks both.
@qwen-code/sdkis a deliberate clone of the Claude Agent SDK — samequery({prompt, options}),createSdkMcpServer(),tool()— so this is modeled onClaudeProvider, not the ACP providers. ACP would have cost us in-process MCP mounting and typed auth selection.One thing the SDK does not give us, contrary to what its type surface first suggests:
QueryOptionshas nohooksfield.SessionStart/SubagentStartbelong to a separate daemon client, so there's no sub-agent ZeroID identity registration here. Subagents are derived fromparent_tool_use_id, which is enough to tag subagent text (issue #82) but carries noagent_type.Auth — both paths
Selected by
providers.qwen.authType, auto-detected when unset (a~/.qwenlogin beats the key):qwen-oauth— qwen.ai subscription; tokens never transit codeoidopenai— OpenAI-compatible key + gatewayGateway presets ship because the right one isn't discoverable. A Model Studio plan key (
sk-sp-…) is rejected by both standard DashScope hosts withinvalid_api_key; it needstoken-plan.ap-southeast-1.maas.aliyuncs.com(bailian-plan-intl). Plan keys also expose more than Qwen —qwen3.8-max,glm-5.2,deepseek-v4-pro— soproviders.qwen.modelpicks one.Security:
buildQwenEnv()returns more than an allowlist, deliberatelyUnlike the Claude SDK (which replaces the child env),
@qwen-code/sdkspawns with{...process.env, ...options.env}. A plain allowlist would be a no-op — everything omitted is still inherited, includingCODEOID_API_KEY(the root ZeroID key) andTELEGRAM_BOT_TOKEN. A merge can only add or override, so every non-allowlisted name is mapped to"".Verified end to end, not just unit-tested: the agent ran
echo "leak=[$CODEOID_API_KEY]"against a planted decoy and gotleak=[].Two divergences from ClaudeProvider, both forced by the SDK
canUseToolreceives only{signal, suggestions}— notoolUseID. The id comes from the assistant message'stool_useblock instead. Matching is oldest-first among ungated entries of the same name, never positional, because auto-approved tools never reach the callback — the exact issue fix: PreToolUse↔canUseTool FIFO desync corrupts tool-call correlation when tools are auto-allowed #81 desync.query()validatessessionIdand throws synchronously on a non-UUID, which would escaperunTurn()and wedge the session rather than surfacing as a turn error.Also:
permissionModepinned to"default"(qwen-code's own ACP default isauto, which silently auto-approves edits and shell), andturn_donefalls back to the requested model because the gateway frequently omitsmodelUsage.qwen-code's
resolveDefaultPermissionrunsisShellCommandReadOnlyAST(command)and auto-allows anything it classifies read-only without ever raising a permission request, so those never reachcanUseTool.Measured on this backend:
rm -f …echo hellocat /etc/hostnamecat ~/<decoy>Reads outside the workdir included — so an agent here can read anything the daemon user can (
~/.codeoid/config.json,~/.ssh/…) with no prompt. codeoid cannot intercept a call the CLI never makes; qwen-code's own source notes the layer isn't overridable by permission mode. The only blunt mitigation isexcludeTools: ["run_shell_command"], which removes shell capability entirely.Audited against
gemini-cli0.50.0 (the fork parent): the mechanism appears qwen-specific — noisShellCommandReadOnlyAST/resolveDefaultPermissionin its bundle, and its shellshouldConfirmExecutehas no content-based bypass. That check is static only — the live ACP run was blocked on expired~/.geminiOAuth, not on anything security-related.Open question for review: ship as-is documented, or add
providers.qwen.allowShellTool: falseas an opt-out?Verification
bun run typecheck,bun run lint,bun run buildclean.🤖 Generated with Claude Code