qwen-code/packages/sdk-python/tests/unit/test_validation.py
juhuan 85c1c0741c
feat(sdk): expose transport and query options in both SDKs (#6491)
* 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>
2026-07-12 07:10:45 +00:00

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