mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-09 08:45:58 +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>
375 lines
12 KiB
Python
375 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Any, cast
|
|
|
|
import pytest
|
|
from qwen_code_sdk.errors import ValidationError
|
|
from qwen_code_sdk.types import QueryOptions, TimeoutOptions
|
|
from qwen_code_sdk.validation import validate_query_options
|
|
|
|
VALID_UUID = "123e4567-e89b-12d3-a456-426614174000"
|
|
|
|
|
|
def test_rejects_resume_with_continue_session() -> None:
|
|
with pytest.raises(ValidationError, match="resume together with continue_session"):
|
|
validate_query_options(
|
|
QueryOptions(
|
|
resume=VALID_UUID,
|
|
continue_session=True,
|
|
)
|
|
)
|
|
|
|
|
|
def test_rejects_session_id_with_resume() -> None:
|
|
with pytest.raises(ValidationError, match="Cannot use session_id with resume"):
|
|
validate_query_options(
|
|
QueryOptions(
|
|
session_id=VALID_UUID,
|
|
resume="223e4567-e89b-12d3-a456-426614174000",
|
|
)
|
|
)
|
|
|
|
|
|
def test_rejects_invalid_session_id() -> None:
|
|
with pytest.raises(ValidationError, match="Invalid session_id"):
|
|
validate_query_options(QueryOptions(session_id="not-a-uuid"))
|
|
|
|
|
|
def test_rejects_invalid_resume() -> None:
|
|
with pytest.raises(ValidationError, match="Invalid resume"):
|
|
validate_query_options(QueryOptions(resume="not-a-uuid"))
|
|
|
|
|
|
def test_rejects_invalid_permission_mode() -> None:
|
|
with pytest.raises(ValidationError, match="Invalid permission_mode"):
|
|
validate_query_options(
|
|
QueryOptions.from_mapping({"permission_mode": "unsafe-mode"})
|
|
)
|
|
|
|
|
|
def test_rejects_invalid_auth_type() -> None:
|
|
with pytest.raises(ValidationError, match="Invalid auth_type"):
|
|
validate_query_options(QueryOptions.from_mapping({"auth_type": "custom"}))
|
|
|
|
|
|
def test_from_mapping_rejects_non_callable_can_use_tool() -> None:
|
|
with pytest.raises(TypeError, match="can_use_tool must be callable"):
|
|
QueryOptions.from_mapping({"can_use_tool": "bad"})
|
|
|
|
|
|
def test_from_mapping_rejects_non_callable_stderr() -> None:
|
|
with pytest.raises(TypeError, match="stderr must be callable"):
|
|
QueryOptions.from_mapping({"stderr": "bad"})
|
|
|
|
|
|
def test_validation_rejects_non_callable_can_use_tool() -> None:
|
|
with pytest.raises(ValidationError, match="can_use_tool must be callable"):
|
|
validate_query_options(QueryOptions(can_use_tool=cast(Any, "bad")))
|
|
|
|
|
|
def test_validation_rejects_non_callable_stderr() -> None:
|
|
with pytest.raises(ValidationError, match="stderr must be callable"):
|
|
validate_query_options(QueryOptions(stderr=cast(Any, "bad")))
|
|
|
|
|
|
def test_from_mapping_rejects_sync_can_use_tool() -> None:
|
|
def can_use_tool( # type: ignore[no-untyped-def]
|
|
tool_name, tool_input, context
|
|
):
|
|
return {"behavior": "deny", "message": "bad"}
|
|
|
|
with pytest.raises(TypeError, match="can_use_tool must be an async callable"):
|
|
QueryOptions.from_mapping({"can_use_tool": can_use_tool})
|
|
|
|
|
|
def test_validation_rejects_sync_can_use_tool() -> None:
|
|
def can_use_tool( # type: ignore[no-untyped-def]
|
|
tool_name, tool_input, context
|
|
):
|
|
return {"behavior": "deny", "message": "bad"}
|
|
|
|
with pytest.raises(ValidationError, match="can_use_tool must be an async callable"):
|
|
validate_query_options(QueryOptions(can_use_tool=cast(Any, can_use_tool)))
|
|
|
|
|
|
def test_from_mapping_rejects_can_use_tool_with_wrong_arity() -> None:
|
|
async def can_use_tool(
|
|
tool_name: str,
|
|
tool_input: dict[str, Any],
|
|
) -> dict[str, str]:
|
|
return {"behavior": "deny"}
|
|
|
|
with pytest.raises(
|
|
TypeError, match="can_use_tool must accept exactly 3 positional arguments"
|
|
):
|
|
QueryOptions.from_mapping({"can_use_tool": can_use_tool})
|
|
|
|
|
|
def test_validation_rejects_can_use_tool_with_wrong_arity() -> None:
|
|
async def can_use_tool(
|
|
tool_name: str,
|
|
tool_input: dict[str, Any],
|
|
) -> dict[str, str]:
|
|
return {"behavior": "deny"}
|
|
|
|
with pytest.raises(
|
|
ValidationError,
|
|
match="can_use_tool must accept exactly 3 positional arguments",
|
|
):
|
|
validate_query_options(QueryOptions(can_use_tool=cast(Any, can_use_tool)))
|
|
|
|
|
|
def test_from_mapping_rejects_stderr_with_wrong_arity() -> None:
|
|
def stderr() -> None:
|
|
return None
|
|
|
|
with pytest.raises(
|
|
TypeError, match="stderr must accept exactly 1 positional argument"
|
|
):
|
|
QueryOptions.from_mapping({"stderr": stderr})
|
|
|
|
|
|
def test_validation_rejects_stderr_with_wrong_arity() -> None:
|
|
def stderr() -> None:
|
|
return None
|
|
|
|
with pytest.raises(
|
|
ValidationError, match="stderr must accept exactly 1 positional argument"
|
|
):
|
|
validate_query_options(QueryOptions(stderr=cast(Any, stderr)))
|
|
|
|
|
|
def test_rejects_invalid_max_session_turns() -> None:
|
|
with pytest.raises(ValidationError, match="max_session_turns"):
|
|
validate_query_options(QueryOptions(max_session_turns=-2))
|
|
|
|
|
|
def test_rejects_empty_qwen_executable_path() -> None:
|
|
with pytest.raises(
|
|
ValidationError, match="path_to_qwen_executable cannot be empty"
|
|
):
|
|
validate_query_options(QueryOptions(path_to_qwen_executable=" "))
|
|
|
|
|
|
def test_timeout_rejects_non_numeric_value() -> None:
|
|
with pytest.raises(TypeError, match=r"timeout\.can_use_tool must be a positive"):
|
|
TimeoutOptions.from_mapping({"can_use_tool": "fast"})
|
|
|
|
|
|
def test_timeout_rejects_negative_value() -> None:
|
|
pattern = r"timeout\.control_request must be a positive"
|
|
with pytest.raises(ValueError, match=pattern):
|
|
TimeoutOptions.from_mapping({"control_request": -1})
|
|
|
|
|
|
def test_timeout_rejects_boolean_value() -> None:
|
|
with pytest.raises(TypeError, match=r"timeout\.stream_close must be a positive"):
|
|
TimeoutOptions.from_mapping({"stream_close": True})
|
|
|
|
|
|
def test_rejects_invalid_max_tool_calls() -> None:
|
|
with pytest.raises(ValidationError, match="max_tool_calls"):
|
|
validate_query_options(QueryOptions(max_tool_calls=-2))
|
|
|
|
|
|
def test_rejects_invalid_max_subagent_depth() -> None:
|
|
with pytest.raises(ValidationError, match="max_subagent_depth"):
|
|
validate_query_options(QueryOptions(max_subagent_depth=0))
|
|
|
|
|
|
def test_rejects_agents_missing_required_fields() -> None:
|
|
with pytest.raises(ValidationError, match="missing required field"):
|
|
validate_query_options(QueryOptions(agents=[{"name": "test"}]))
|
|
|
|
|
|
def test_rejects_extra_args_with_reserved_flags() -> None:
|
|
with pytest.raises(ValidationError, match="reserved flag"):
|
|
validate_query_options(QueryOptions(extra_args=["--input-format"]))
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"flag",
|
|
[
|
|
"--model",
|
|
"-m",
|
|
"--auth-type",
|
|
"--approval-mode",
|
|
"--insecure",
|
|
"--yolo",
|
|
"-y",
|
|
"--allowed-tools",
|
|
"--exclude-tools",
|
|
"--resume",
|
|
"-r",
|
|
"--continue",
|
|
"-c",
|
|
"--session-id",
|
|
"--proxy",
|
|
"--channel",
|
|
"--output-format",
|
|
"-o",
|
|
"--openai-base-url",
|
|
"--openai-api-key",
|
|
"--mcp-config",
|
|
"--prompt",
|
|
"-p",
|
|
"--prompt-interactive",
|
|
"-i",
|
|
"--add-dir",
|
|
"--input-file",
|
|
"--extensions",
|
|
"-e",
|
|
"--sandbox",
|
|
"-s",
|
|
"--no-sandbox",
|
|
"--no-insecure",
|
|
"--no-safe-mode",
|
|
"--sandbox-image",
|
|
"--fork-session",
|
|
"--max-tool-calls",
|
|
"--max-subagent-depth",
|
|
"--max-session-turns",
|
|
"--system-prompt",
|
|
"--append-system-prompt",
|
|
"--include-directories",
|
|
"--allowed-mcp-server-names",
|
|
"--disabled-slash-commands",
|
|
"--include-partial-messages",
|
|
"--chat-recording",
|
|
"--openai-logging",
|
|
"--openai-logging-dir",
|
|
"--json-schema",
|
|
"--json-fd",
|
|
"--json-file",
|
|
],
|
|
)
|
|
def test_rejects_extra_args_with_security_sensitive_flags(flag: str) -> None:
|
|
with pytest.raises(ValidationError, match="reserved flag"):
|
|
validate_query_options(QueryOptions(extra_args=[flag]))
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"flag",
|
|
[
|
|
"--model=qwen-max",
|
|
"--auth-type=openai",
|
|
"--approval-mode=yolo",
|
|
"--insecure=true",
|
|
"--yolo=true",
|
|
"--proxy=http://localhost:8080",
|
|
],
|
|
)
|
|
def test_rejects_extra_args_with_flag_value_syntax(flag: str) -> None:
|
|
with pytest.raises(ValidationError, match="reserved flag"):
|
|
validate_query_options(QueryOptions(extra_args=[flag]))
|
|
|
|
|
|
def test_accepts_extra_args_with_non_reserved_flags() -> None:
|
|
validate_query_options(QueryOptions(extra_args=["--some-unknown-flag", "--value"]))
|
|
|
|
|
|
def test_rejects_fallback_model_exceeding_max() -> None:
|
|
with pytest.raises(ValidationError, match="fallback_model supports a maximum of 3"):
|
|
validate_query_options(QueryOptions(fallback_model=["a", "b", "c", "d"]))
|
|
|
|
|
|
def test_rejects_empty_proxy() -> None:
|
|
with pytest.raises(ValidationError, match="proxy cannot be empty"):
|
|
validate_query_options(QueryOptions(proxy=" "))
|
|
|
|
|
|
def test_rejects_fork_session_without_resume() -> None:
|
|
with pytest.raises(ValidationError, match="fork_session requires resume"):
|
|
validate_query_options(QueryOptions(fork_session=True))
|
|
|
|
|
|
def test_accepts_fork_session_with_resume() -> None:
|
|
validate_query_options(
|
|
QueryOptions(
|
|
fork_session=True,
|
|
resume="123e4567-e89b-12d3-a456-426614174000",
|
|
)
|
|
)
|
|
|
|
|
|
def test_rejects_invalid_effort() -> None:
|
|
with pytest.raises(ValidationError, match="Invalid effort"):
|
|
validate_query_options(QueryOptions(effort="invalid")) # type: ignore[arg-type]
|
|
|
|
|
|
def test_accepts_valid_effort() -> None:
|
|
for effort in ("low", "medium", "high", "xhigh", "max"):
|
|
validate_query_options(QueryOptions(effort=effort)) # type: ignore[arg-type]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"field_name",
|
|
[
|
|
"include_directories",
|
|
"extensions",
|
|
"allowed_mcp_server_names",
|
|
"disabled_slash_commands",
|
|
"fallback_model",
|
|
],
|
|
)
|
|
def test_rejects_comma_in_list_fields(field_name: str) -> None:
|
|
with pytest.raises(ValidationError, match="cannot contain commas"):
|
|
validate_query_options(QueryOptions(**{field_name: ["valid", "invalid,comma"]}))
|
|
|
|
|
|
def test_from_mapping_parses_all_new_fields() -> None:
|
|
opts = QueryOptions.from_mapping(
|
|
{
|
|
"fork_session": True,
|
|
"resume": VALID_UUID,
|
|
"max_tool_calls": 50,
|
|
"max_subagent_depth": 3,
|
|
"include_directories": ["/dir1", "/dir2"],
|
|
"extra_args": ["--verbose"],
|
|
"extensions": ["ext1"],
|
|
"allowed_mcp_server_names": ["server1"],
|
|
"fallback_model": ["model-a", "model-b"],
|
|
"proxy": "http://proxy:8080",
|
|
"sandbox": True,
|
|
"safe_mode": True,
|
|
"insecure": True,
|
|
"worktree": True,
|
|
"disabled_slash_commands": ["/cmd1"],
|
|
"agents": [{"name": "a", "description": "b", "systemPrompt": "c"}],
|
|
}
|
|
)
|
|
assert opts.fork_session is True
|
|
assert opts.resume == VALID_UUID
|
|
assert opts.max_tool_calls == 50
|
|
assert opts.max_subagent_depth == 3
|
|
assert opts.include_directories == ["/dir1", "/dir2"]
|
|
assert opts.extra_args == ["--verbose"]
|
|
assert opts.extensions == ["ext1"]
|
|
assert opts.allowed_mcp_server_names == ["server1"]
|
|
assert opts.fallback_model == ["model-a", "model-b"]
|
|
assert opts.proxy == "http://proxy:8080"
|
|
assert opts.sandbox is True
|
|
assert opts.safe_mode is True
|
|
assert opts.insecure is True
|
|
assert opts.worktree is True
|
|
assert opts.disabled_slash_commands == ["/cmd1"]
|
|
assert opts.agents == [{"name": "a", "description": "b", "systemPrompt": "c"}]
|
|
|
|
|
|
def test_from_mapping_defaults_new_fields_to_none() -> None:
|
|
opts = QueryOptions.from_mapping({})
|
|
assert opts.fork_session is False
|
|
assert opts.max_tool_calls is None
|
|
assert opts.max_subagent_depth is None
|
|
assert opts.include_directories is None
|
|
assert opts.extra_args is None
|
|
assert opts.extensions is None
|
|
assert opts.allowed_mcp_server_names is None
|
|
assert opts.fallback_model is None
|
|
assert opts.proxy is None
|
|
assert opts.sandbox is False
|
|
assert opts.safe_mode is False
|
|
assert opts.insecure is False
|
|
assert opts.worktree is False
|
|
assert opts.disabled_slash_commands is None
|
|
assert opts.agents is None
|