mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-26 09:24:35 +00:00
* feat(sdk): expose transport and query options in both SDKs
Consolidated PR covering pure SDK-side option additions:
- fork_session (--fork-session)
- max_tool_calls (--max-tool-calls)
- max_subagent_depth (--max-subagent-depth)
- agents (via initialize control request)
- include_directories (--include-directories)
- extra_args (pass-through CLI flags)
- extensions (--extensions)
- allowed_mcp_server_names (--allowed-mcp-server-names)
- mcp_servers (Python SDK, via initialize control request)
- fallback_model (--fallback-model, max 3)
- proxy (--proxy, deprecated)
- sandbox (--sandbox)
- safe_mode (--safe-mode)
- insecure (--insecure)
- worktree (--worktree)
- disabled_slash_commands (--disabled-slash-commands)
All options implemented in both Python SDK and TypeScript SDK
with validation and unit tests.
* fix(sdk): expand extraArgs blocklist and add TS SDK tests
- Expand reserved CLI flags blocklist from 3 to 34 flags, covering all
SDK-managed options and security-sensitive flags (--model, --auth-type,
--approval-mode, --insecure, --dangerously-skip-permissions, etc.)
- Add Zod refine validation for extraArgs in TS SDK (previously no validation)
- Update extraArgs JSDoc to document security implications
- Add 12 ProcessTransport tests for new CLI argument building
- Add queryOptionsSchema.test.ts with 20 validation tests
- Add createQuery.test.ts option passthrough test for all new fields
- Add Python parametrized tests for expanded blocklist
* fix(sdk): address review feedback for consolidated options
Security fixes:
- Fix --flag=value bypass: split on = before checking reserved flags
- Add missing dangerous flags: --yolo/-y, --openai-base-url, --openai-api-key,
--mcp-config, --prompt, --add-dir, --input-file, --json-schema/fd/file
- Remove ghost flags (--dangerously-skip-permissions, --allow-dangerously-skip-permissions)
Bug fixes:
- Fix Python mcp_servers key: snake_case -> camelCase (mcpServers)
- Remove duplicate agents declarations in Zod schema and types.ts
- Add maxToolCalls range validation (.int().min(-1)) in Zod schema
- Fix agents validation cross-SDK: reject empty strings in TS (matching Python)
Tests:
- Add --flag=value bypass tests (both SDKs)
- Add tests for new dangerous flags
- Add maxToolCalls range validation tests
- Add agents empty-string rejection test
* fix(sdk): add short flag aliases, fix zod validator, add forkSession validation
- Add short flag aliases (-m, -p, -i, -s, -e, -o, -c, -r) to reserved
CLI flags blocklist in both Python and TS SDKs to prevent blocklist
bypass via short flags
- Fix z.custom validator for agents: move error message from && chain
to 2nd argument so Zod produces the descriptive error on failure
- Fix TS2345: coerce split('=')[0] with ?? '' for noUncheckedIndexedAccess
- Add forkSession prerequisite validation: requires resume to be set,
matching CLI behavior that rejects --fork-session without --resume
- Remove dead agents field from TransportOptions (agents flow through
initialize payload, not transport CLI args)
- Add tests for short flags, forkSession validation in both SDKs
* fix(sdk): add --no-* negation flags, comma validation, fork session ID fix
- Add --no-sandbox, --no-safe-mode, --no-insecure, --no-worktree,
--sandbox-image, --sandbox-session-id to reserved CLI flags blocklist
in both SDKs to prevent yargs boolean negation bypass
- Add per-element comma validation for comma-joined list fields
(includeDirectories, extensions, allowedMcpServerNames,
fallbackModel, disabledSlashCommands) to prevent CLI comma-split
injection
- Add min(1) validation for extraArgs items to reject empty strings
- Fix fork_session validation to also accept continue_session (not just
resume), matching CLI behavior
- Allow session_id with resume when fork_session is True
- Fix fork session ID mismatch: generate new UUID for forked session
instead of reusing resume (source) session ID, so getSessionId()
returns the correct forked session ID
* fix(sdk): add fork session ID test assertions and mcp_servers validation
- Add assertions to forkSession test verifying sessionId is a new UUID
different from the resume value
- Add test for forkSession with explicit sessionId
- Add structural validation for mcp_servers in Python SDK to reject
non-mapping configs before sending to CLI
* fix: fork session ID discarded by Query constructor
Query.ts:97 used `options.resume ?? options.sessionId` which always
picked resume when forkSession was true, ignoring the new fork UUID
generated in createQuery.ts. Now uses fork UUID when forkSession is true.
Python SDK: query() now generates a fresh UUID for fork_session instead
of reusing the resume (source) session ID. _session_id_locked is False
for fork sessions, allowing the CLI to correct the session ID via
control responses.
* fix: mypy type error in fork_session session_id annotation
Add explicit `str | None` type annotation to session_id variable
to resolve mypy error where fork branch inferred `str` but else
branch assigns `str | None`.
* test: address review suggestions for test coverage and validation
- Add comma validation negative tests for all 5 list fields (both SDKs)
- Add maxSubagentDepth boundary tests (0, 101, 1, 100) for TS SDK
- Add from_mapping tests for all new fields and default values (Python)
- Add ProcessTransport negative test verifying flags absent when unset
- Remove --no-worktree dead code from RESERVED_CLI_FLAGS (both SDKs)
- Add --fork-session and other new flags to reserved-flags test list
- Add continue field to TS schema to match TransportOptions type
- Fix forkSession refine to accept resume OR continue (matching Python)
---------
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
336 lines
9.6 KiB
Python
336 lines
9.6 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from qwen_code_sdk.transport import build_cli_arguments, prepare_spawn_info
|
|
from qwen_code_sdk.types import QueryOptions, TimeoutOptions
|
|
|
|
VALID_UUID = "123e4567-e89b-12d3-a456-426614174000"
|
|
|
|
|
|
class DummyProcess:
|
|
def __init__(self) -> None:
|
|
self.stdin = None
|
|
self.stdout = None
|
|
self.stderr = None
|
|
self.returncode = 0
|
|
|
|
|
|
def test_build_cli_arguments_maps_supported_options() -> None:
|
|
args = build_cli_arguments(
|
|
QueryOptions(
|
|
model="qwen3-coder",
|
|
system_prompt="system prompt",
|
|
append_system_prompt="append prompt",
|
|
permission_mode="auto-edit",
|
|
max_session_turns=7,
|
|
core_tools=["Read", "Edit"],
|
|
exclude_tools=["Bash(rm *)"],
|
|
allowed_tools=["Bash(git status)"],
|
|
auth_type="openai",
|
|
include_partial_messages=True,
|
|
session_id=VALID_UUID,
|
|
)
|
|
)
|
|
|
|
assert args == [
|
|
"--input-format",
|
|
"stream-json",
|
|
"--output-format",
|
|
"stream-json",
|
|
"--channel=SDK",
|
|
"--model",
|
|
"qwen3-coder",
|
|
"--system-prompt",
|
|
"system prompt",
|
|
"--append-system-prompt",
|
|
"append prompt",
|
|
"--approval-mode",
|
|
"auto-edit",
|
|
"--max-session-turns",
|
|
"7",
|
|
"--core-tools",
|
|
"Read,Edit",
|
|
"--exclude-tools",
|
|
"Bash(rm *)",
|
|
"--allowed-tools",
|
|
"Bash(git status)",
|
|
"--auth-type",
|
|
"openai",
|
|
"--include-partial-messages",
|
|
"--session-id",
|
|
VALID_UUID,
|
|
]
|
|
|
|
|
|
def test_cli_argument_precedence_prefers_resume_then_continue_then_session_id() -> None:
|
|
args = build_cli_arguments(
|
|
QueryOptions(
|
|
resume=VALID_UUID,
|
|
continue_session=True,
|
|
session_id="223e4567-e89b-12d3-a456-426614174000",
|
|
)
|
|
)
|
|
|
|
assert "--resume" in args
|
|
assert "--continue" not in args
|
|
assert "--session-id" not in args
|
|
|
|
|
|
def test_build_cli_arguments_maps_all_consolidated_options() -> None:
|
|
args = build_cli_arguments(
|
|
QueryOptions(
|
|
fork_session=True,
|
|
max_tool_calls=50,
|
|
max_subagent_depth=3,
|
|
include_directories=["/extra/dir1", "/extra/dir2"],
|
|
extensions=["ext1", "ext2"],
|
|
allowed_mcp_server_names=["server1"],
|
|
fallback_model=["qwen-plus", "qwen-turbo"],
|
|
proxy="http://proxy:8080",
|
|
sandbox=True,
|
|
safe_mode=True,
|
|
insecure=True,
|
|
worktree=True,
|
|
disabled_slash_commands=["/init", "/vim"],
|
|
extra_args=["--custom-flag", "value"],
|
|
)
|
|
)
|
|
|
|
assert "--fork-session" in args
|
|
assert "--max-tool-calls" in args
|
|
assert args[args.index("--max-tool-calls") + 1] == "50"
|
|
assert "--max-subagent-depth" in args
|
|
assert args[args.index("--max-subagent-depth") + 1] == "3"
|
|
assert "--include-directories" in args
|
|
assert args[args.index("--include-directories") + 1] == "/extra/dir1,/extra/dir2"
|
|
assert "--extensions" in args
|
|
assert args[args.index("--extensions") + 1] == "ext1,ext2"
|
|
assert "--allowed-mcp-server-names" in args
|
|
assert args[args.index("--allowed-mcp-server-names") + 1] == "server1"
|
|
assert "--fallback-model" in args
|
|
assert args[args.index("--fallback-model") + 1] == "qwen-plus,qwen-turbo"
|
|
assert "--proxy" in args
|
|
assert args[args.index("--proxy") + 1] == "http://proxy:8080"
|
|
assert "--sandbox" in args
|
|
assert "--safe-mode" in args
|
|
assert "--insecure" in args
|
|
assert "--worktree" in args
|
|
assert "--disabled-slash-commands" in args
|
|
assert args[args.index("--disabled-slash-commands") + 1] == "/init,/vim"
|
|
assert "--custom-flag" in args
|
|
assert args[args.index("--custom-flag") + 1] == "value"
|
|
|
|
|
|
def test_prepare_spawn_info_uses_runtime_for_python_scripts(tmp_path: Path) -> None:
|
|
script_path = tmp_path / "fake-qwen.py"
|
|
script_path.write_text("print('ok')\n", encoding="utf-8")
|
|
|
|
spawn_info = prepare_spawn_info(str(script_path))
|
|
|
|
assert spawn_info.command == sys.executable
|
|
assert spawn_info.args == [str(script_path.resolve())]
|
|
|
|
|
|
def test_prepare_spawn_info_uses_node_for_javascript_files(tmp_path: Path) -> None:
|
|
script_path = tmp_path / "fake-qwen.js"
|
|
script_path.write_text("console.log('ok');\n", encoding="utf-8")
|
|
|
|
spawn_info = prepare_spawn_info(str(script_path))
|
|
|
|
assert spawn_info.command == "node"
|
|
assert spawn_info.args == [str(script_path.resolve())]
|
|
|
|
|
|
def test_prepare_spawn_info_keeps_plain_command_names() -> None:
|
|
spawn_info = prepare_spawn_info("qwen-custom")
|
|
|
|
assert spawn_info.command == "qwen-custom"
|
|
assert spawn_info.args == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transport_discards_stderr_when_debug_is_disabled(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
captured: dict[str, Any] = {}
|
|
|
|
async def fake_create_subprocess_exec(*args: Any, **kwargs: Any) -> DummyProcess:
|
|
captured["args"] = args
|
|
captured["kwargs"] = kwargs
|
|
return DummyProcess()
|
|
|
|
monkeypatch.setattr(
|
|
asyncio,
|
|
"create_subprocess_exec",
|
|
fake_create_subprocess_exec,
|
|
)
|
|
|
|
transport_module = __import__(
|
|
"qwen_code_sdk.transport",
|
|
fromlist=["ProcessTransport"],
|
|
)
|
|
transport = transport_module.ProcessTransport(
|
|
QueryOptions(timeout=TimeoutOptions())
|
|
)
|
|
|
|
await transport.start()
|
|
|
|
assert captured["kwargs"]["stderr"] is subprocess.DEVNULL
|
|
|
|
|
|
def test_prepare_spawn_info_defaults_to_qwen_when_none() -> None:
|
|
spawn_info = prepare_spawn_info(None)
|
|
|
|
assert spawn_info.command == "qwen"
|
|
assert spawn_info.args == []
|
|
|
|
|
|
def test_prepare_spawn_info_uses_node_for_mjs_files(tmp_path: Path) -> None:
|
|
script_path = tmp_path / "cli.mjs"
|
|
script_path.write_text("export default {};\n", encoding="utf-8")
|
|
|
|
spawn_info = prepare_spawn_info(str(script_path))
|
|
|
|
assert spawn_info.command == "node"
|
|
assert spawn_info.args == [str(script_path.resolve())]
|
|
|
|
|
|
def test_prepare_spawn_info_uses_node_for_cjs_files(tmp_path: Path) -> None:
|
|
script_path = tmp_path / "cli.cjs"
|
|
script_path.write_text("module.exports = {};\n", encoding="utf-8")
|
|
|
|
spawn_info = prepare_spawn_info(str(script_path))
|
|
|
|
assert spawn_info.command == "node"
|
|
assert spawn_info.args == [str(script_path.resolve())]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transport_start_raises_after_close(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
async def fake_create_subprocess_exec(*args: Any, **kwargs: Any) -> DummyProcess:
|
|
return DummyProcess()
|
|
|
|
monkeypatch.setattr(
|
|
asyncio,
|
|
"create_subprocess_exec",
|
|
fake_create_subprocess_exec,
|
|
)
|
|
|
|
transport_module = __import__(
|
|
"qwen_code_sdk.transport",
|
|
fromlist=["ProcessTransport"],
|
|
)
|
|
transport = transport_module.ProcessTransport(
|
|
QueryOptions(timeout=TimeoutOptions())
|
|
)
|
|
transport._closed = True
|
|
|
|
with pytest.raises(RuntimeError, match="Transport is closed"):
|
|
await transport.start()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_messages_skips_malformed_json_lines() -> None:
|
|
"""Malformed JSON lines should be skipped, not crash the stream."""
|
|
|
|
class FakeStdout:
|
|
def __init__(self, lines: list[bytes]) -> None:
|
|
self._lines = iter(lines)
|
|
|
|
async def readline(self) -> bytes:
|
|
return next(self._lines, b"")
|
|
|
|
transport_module = __import__(
|
|
"qwen_code_sdk.transport",
|
|
fromlist=["ProcessTransport"],
|
|
)
|
|
transport = transport_module.ProcessTransport(
|
|
QueryOptions(timeout=TimeoutOptions())
|
|
)
|
|
|
|
class FakeProcess:
|
|
returncode = 0
|
|
stdin = None
|
|
stderr = None
|
|
|
|
def __init__(self) -> None:
|
|
self.stdout = FakeStdout(
|
|
[
|
|
b"not valid json\n",
|
|
b'{"type":"system","subtype":"init","uuid":"u","session_id":"s"}\n',
|
|
b"also bad\n",
|
|
b"",
|
|
]
|
|
)
|
|
|
|
async def wait(self) -> int:
|
|
return 0
|
|
|
|
transport._process = FakeProcess()
|
|
|
|
messages: list[Any] = []
|
|
async for msg in transport.read_messages():
|
|
messages.append(msg)
|
|
|
|
assert len(messages) == 1
|
|
assert messages[0]["type"] == "system"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stderr_callback_exceptions_do_not_fail_transport() -> None:
|
|
class FakeStdout:
|
|
async def readline(self) -> bytes:
|
|
return b""
|
|
|
|
class FakeStderr:
|
|
def __init__(self) -> None:
|
|
self._lines = iter([b"error message\n", b""])
|
|
|
|
async def readline(self) -> bytes:
|
|
return next(self._lines, b"")
|
|
|
|
transport_module = __import__(
|
|
"qwen_code_sdk.transport",
|
|
fromlist=["ProcessTransport"],
|
|
)
|
|
|
|
callback_calls = 0
|
|
|
|
def stderr_callback(text: str) -> None:
|
|
nonlocal callback_calls
|
|
callback_calls += 1
|
|
assert text == "error message"
|
|
raise RuntimeError("sink failed")
|
|
|
|
transport = transport_module.ProcessTransport(
|
|
QueryOptions(
|
|
stderr=stderr_callback,
|
|
timeout=TimeoutOptions(),
|
|
)
|
|
)
|
|
|
|
class FakeProcess:
|
|
returncode = 0
|
|
stdin = None
|
|
|
|
def __init__(self) -> None:
|
|
self.stdout = FakeStdout()
|
|
self.stderr = FakeStderr()
|
|
|
|
async def wait(self) -> int:
|
|
return 0
|
|
|
|
transport._process = FakeProcess()
|
|
transport._stderr_task = asyncio.create_task(transport._forward_stderr())
|
|
|
|
await transport.wait_for_exit()
|
|
|
|
assert callback_calls == 1
|