diff --git a/docs/en/developer-guide/local-device-architecture.md b/docs/en/developer-guide/local-device-architecture.md index 74c99e8522..8c9268e854 100644 --- a/docs/en/developer-guide/local-device-architecture.md +++ b/docs/en/developer-guide/local-device-architecture.md @@ -202,6 +202,8 @@ In `replace` mode, the executor only removes capabilities marked as `managed` in When a project task runs through the local executor, its task-level `CLAUDE_CONFIG_DIR` exposes both global `skills` and `plugins` directories and inherits non-sensitive plugin settings such as `enabledPlugins` and `extraKnownMarketplaces` from the local `~/.claude/settings.json`. This lets Claude Code load global Skills and Skills provided by Plugins. Sensitive model and token configuration is still injected through runtime environment variables and is not copied from global settings into the task directory. +When project mode calls Claude or Codex model APIs, the executor adds a `wecode-project: ` request header in the directly launched runtime context and fills source identity headers: `wecode-action: wegent`, `wecode-source: wegent-local`, and `wecode-executor: `, where Claude Code uses `claudecode` and Codex uses `codex`. Claude Code local mode first merges existing `ANTHROPIC_CUSTOM_HEADERS` from the executor startup process environment and the runtime environment, then appends the project identity and writes the resulting header set to both `ANTHROPIC_CUSTOM_HEADERS` and `DEFAULT_HEADERS`/`default_headers`. This keeps the Claude Code child process and downstream model gateways on the same header set. Codex writes the header into provider `http_headers` for Wegent-managed provider configs, and also injects it for personal Codex config runs when the execution model explicitly names the provider. + --- ## 🔄 Task Execution Flow diff --git a/docs/zh/developer-guide/local-device-architecture.md b/docs/zh/developer-guide/local-device-architecture.md index 27a846fc18..fac3368fbe 100644 --- a/docs/zh/developer-guide/local-device-architecture.md +++ b/docs/zh/developer-guide/local-device-architecture.md @@ -202,6 +202,8 @@ Plugin 上报必须包含其内部 Skill 列表。Executor 会扫描每个 Plugi 项目任务使用本地 executor 执行时,任务级 `CLAUDE_CONFIG_DIR` 会同时暴露全局 `skills` 和 `plugins` 目录,并从本机 `~/.claude/settings.json` 继承 `enabledPlugins`、`extraKnownMarketplaces` 等非敏感插件配置,使 Claude Code 能加载全局 Skill 以及 Plugin 内部提供的 Skill。模型、Token 等敏感配置仍通过运行时环境变量注入,不会从全局 settings 写入任务目录。 +项目模式下访问 Claude 或 Codex 模型 API 时,executor 会在直接启动的运行时上下文中加入 `wecode-project: ` 请求头,并补齐 `wecode-action: wegent`、`wecode-source: wegent-local`、`wecode-executor: ` 来源标识,其中 Claude Code 使用 `claudecode`,Codex 使用 `codex`。Claude Code 本地模式会先合并 executor 启动进程环境和运行时环境里已有的 `ANTHROPIC_CUSTOM_HEADERS`,再追加 project 标识,并同时写入 `ANTHROPIC_CUSTOM_HEADERS` 与 `DEFAULT_HEADERS`/`default_headers` 环境变量,保证直接 Claude Code 子进程和下游模型网关读取到一致的 header 集合;Codex 在 Wegent 管理 provider 配置时写入 provider 的 `http_headers`,使用个人 Codex 配置且显式指定 provider 时也会对该 provider 注入同一 project 请求头。 + --- ## 🔄 任务执行流程 diff --git a/executor/agents/api_headers.py b/executor/agents/api_headers.py new file mode 100644 index 0000000000..4e12ce2f7c --- /dev/null +++ b/executor/agents/api_headers.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python + +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Utilities for model API request headers.""" + +import json +from collections.abc import Mapping +from typing import Any + +WECODE_PROJECT_HEADER = "wecode-project" +WECODE_ACTION_HEADER = "wecode-action" +WECODE_SOURCE_HEADER = "wecode-source" +WECODE_EXECUTOR_HEADER = "wecode-executor" +WECODE_ACTION_VALUE = "wegent" +WECODE_SOURCE_VALUE = "wegent-local" +DEFAULT_HEADERS_ENV_KEYS = ("DEFAULT_HEADERS", "default_headers") + + +def merge_project_header( + headers: Mapping[str, Any] | str | None, + project_id: Any, +) -> dict[str, str]: + """Return headers with the Wegent project header merged.""" + parsed_headers = _parse_header_map(headers) + normalized_project_id = _normalize_project_id(project_id) + if not normalized_project_id: + return parsed_headers + + return merge_header_map( + parsed_headers, {WECODE_PROJECT_HEADER: normalized_project_id} + ) + + +def merge_wegent_runtime_headers( + headers: Mapping[str, Any] | str | None, + executor: str, +) -> dict[str, str]: + """Return headers with Wegent runtime source headers filled in.""" + return merge_missing_header_map( + _parse_header_map(headers), + { + WECODE_ACTION_HEADER: WECODE_ACTION_VALUE, + WECODE_SOURCE_HEADER: WECODE_SOURCE_VALUE, + WECODE_EXECUTOR_HEADER: executor, + }, + ) + + +def merge_missing_header_map( + existing_headers: Mapping[str, Any] | str | None, + default_headers: Mapping[str, Any], +) -> dict[str, str]: + """Merge only headers whose keys are missing from the existing header map.""" + result = _parse_header_map(existing_headers) + existing_keys = {key.lower() for key in result} + for key, value in default_headers.items(): + if value is None or str(key).lower() in existing_keys: + continue + result[str(key)] = str(value) + existing_keys.add(str(key).lower()) + return result + + +def merge_header_map( + existing_headers: Mapping[str, Any] | str | None, + new_headers: Mapping[str, Any], +) -> dict[str, str]: + """Merge header maps case-insensitively while preserving insertion order.""" + result = _parse_header_map(existing_headers) + for key, value in new_headers.items(): + if value is None: + continue + normalized_key = str(key).lower() + result = { + existing_key: existing_value + for existing_key, existing_value in result.items() + if existing_key.lower() != normalized_key + } + result[str(key)] = str(value) + return result + + +def headers_to_anthropic_custom_headers(headers: Mapping[str, Any]) -> str: + """Format a header map for Claude Code's ANTHROPIC_CUSTOM_HEADERS env var.""" + return "\n".join(f"{key}: {value}" for key, value in headers.items()) + + +def merge_anthropic_header_map( + existing_headers: str, + headers: Mapping[str, Any], +) -> str: + """Return Anthropic custom headers with a header map merged.""" + merged = merge_header_map( + _parse_anthropic_custom_headers(existing_headers), headers + ) + return headers_to_anthropic_custom_headers(merged) + + +def merge_anthropic_header_defaults( + existing_headers: str, + default_headers: Mapping[str, Any], +) -> str: + """Return Anthropic custom headers with missing defaults filled in.""" + merged = merge_missing_header_map( + _parse_anthropic_custom_headers(existing_headers), default_headers + ) + return headers_to_anthropic_custom_headers(merged) + + +def merge_anthropic_custom_headers(*header_sets: str | None) -> str: + """Merge multiple ANTHROPIC_CUSTOM_HEADERS strings.""" + merged: dict[str, str] = {} + for headers in header_sets: + if not headers: + continue + merged = merge_header_map( + merged, + _parse_anthropic_custom_headers(headers), + ) + return headers_to_anthropic_custom_headers(merged) + + +def extract_default_headers(env: Mapping[str, Any]) -> dict[str, str]: + """Extract DEFAULT_HEADERS/default_headers from an environment mapping.""" + for key in DEFAULT_HEADERS_ENV_KEYS: + headers = _parse_header_map(env.get(key)) + if headers: + return headers + return {} + + +def _parse_header_map(headers: Mapping[str, Any] | str | None) -> dict[str, str]: + if not headers: + return {} + if isinstance(headers, Mapping): + return { + str(key): str(value) + for key, value in headers.items() + if key is not None and value is not None + } + if isinstance(headers, str): + stripped = headers.strip() + if not stripped: + return {} + try: + parsed = json.loads(stripped) + except json.JSONDecodeError: + return _parse_header_lines(stripped) + if isinstance(parsed, Mapping): + return _parse_header_map(parsed) + return {} + + +def _parse_anthropic_custom_headers(headers: str) -> dict[str, str]: + if not headers: + return {} + parsed_json = _parse_header_map(headers) + if parsed_json: + return parsed_json + return _parse_header_lines(headers) + + +def _parse_header_lines(headers: str) -> dict[str, str]: + parsed: dict[str, str] = {} + for line in headers.splitlines(): + stripped = line.strip() + if not stripped or ":" not in stripped: + continue + key, value = stripped.split(":", 1) + key = key.strip() + if not key: + continue + parsed[key] = value.strip() + return parsed + + +def _normalize_project_id(project_id: Any) -> str: + if project_id is None: + return "" + value = str(project_id).strip() + if not value: + return "" + return value diff --git a/executor/agents/claude_code/claude_code_agent.py b/executor/agents/claude_code/claude_code_agent.py index c94e041efb..4fa8ed15d8 100644 --- a/executor/agents/claude_code/claude_code_agent.py +++ b/executor/agents/claude_code/claude_code_agent.py @@ -204,9 +204,15 @@ def __init__( # Initialize execution mode strategy self._mode_strategy: ExecutionModeStrategy = ModeStrategyFactory.create() if hasattr(self._mode_strategy, "use_global_capabilities"): - from executor.modes.local.capabilities import is_project_task + from executor.modes.local.capabilities import ( + get_project_id, + is_project_task, + ) - self._mode_strategy.use_global_capabilities(is_project_task(task_data)) + self._mode_strategy.use_global_capabilities( + is_project_task(task_data), + project_id=get_project_id(task_data), + ) # Note: emitter is created in base class Agent.__init__() # using EmitterBuilder with CallbackTransport diff --git a/executor/agents/claude_code/local_mode_strategy.py b/executor/agents/claude_code/local_mode_strategy.py index 9bb1350560..28484f0626 100644 --- a/executor/agents/claude_code/local_mode_strategy.py +++ b/executor/agents/claude_code/local_mode_strategy.py @@ -26,6 +26,14 @@ from pathlib import Path from typing import Any, Dict, Tuple +from executor.agents.api_headers import ( + DEFAULT_HEADERS_ENV_KEYS, + extract_default_headers, + merge_anthropic_custom_headers, + merge_anthropic_header_defaults, + merge_project_header, + merge_wegent_runtime_headers, +) from executor.agents.claude_code.mode_strategy import ExecutionModeStrategy from executor.config import config from executor.platform_compat import ( @@ -50,10 +58,12 @@ class LocalModeStrategy(ExecutionModeStrategy): def __init__(self) -> None: self._use_global_capabilities = False + self._project_id: Any = None - def use_global_capabilities(self, enabled: bool) -> None: + def use_global_capabilities(self, enabled: bool, project_id: Any = None) -> None: """Enable global Claude capability reuse for project task execution.""" self._use_global_capabilities = enabled + self._project_id = project_id if enabled else None def get_config_directory(self, task_id: int) -> str: """Get the Claude configuration directory. @@ -181,12 +191,11 @@ def configure_client_options( updated_options = options.copy() existing_env = dict(updated_options.get("env", {})) merged_env = {**existing_env, **env_config, **task_identity_env} - # Ensure all values are strings (required by SDK) - updated_options["env"] = {k: str(v) for k, v in merged_env.items()} # Fix PyInstaller environment issues for child processes. # See: https://pyinstaller.org/en/stable/common-issues-and-pitfalls.html - env = dict(updated_options.get("env", {})) + # Ensure all values are strings (required by SDK). + env = {k: str(v) for k, v in merged_env.items()} sanitize_subprocess_environment(env) # Project tasks use global Claude capability symlinks; non-project tasks @@ -194,12 +203,36 @@ def configure_client_options( env["CLAUDE_CONFIG_DIR"] = config_dir env["SKILLS_DIR"] = self.get_skills_directory(config_dir) - # Add ANTHROPIC_CUSTOM_HEADERS if configured via environment variable - if config.ANTHROPIC_CUSTOM_HEADERS: - env["ANTHROPIC_CUSTOM_HEADERS"] = config.ANTHROPIC_CUSTOM_HEADERS - logger.info( - f"Local mode: ANTHROPIC_CUSTOM_HEADERS={config.ANTHROPIC_CUSTOM_HEADERS}" + process_custom_headers = os.environ.get("ANTHROPIC_CUSTOM_HEADERS", "") + runtime_custom_headers = env.get("ANTHROPIC_CUSTOM_HEADERS", "") + custom_headers = merge_anthropic_custom_headers( + process_custom_headers, + runtime_custom_headers, + ) + default_headers = extract_default_headers(merged_env) + if self._use_global_capabilities: + default_headers = merge_wegent_runtime_headers( + default_headers, + executor="claudecode", + ) + default_headers = merge_project_header(default_headers, self._project_id) + if default_headers: + serialized_default_headers = json.dumps( + default_headers, + ensure_ascii=True, + separators=(",", ":"), ) + for default_headers_key in DEFAULT_HEADERS_ENV_KEYS: + env[default_headers_key] = serialized_default_headers + custom_headers = merge_anthropic_header_defaults( + custom_headers, + default_headers, + ) + + # Add ANTHROPIC_CUSTOM_HEADERS if configured via environment variable or + # project execution source metadata. + if custom_headers: + env["ANTHROPIC_CUSTOM_HEADERS"] = custom_headers updated_options["env"] = env diff --git a/executor/agents/codex/codex_agent.py b/executor/agents/codex/codex_agent.py index eb2833a1bb..e7e12043ec 100644 --- a/executor/agents/codex/codex_agent.py +++ b/executor/agents/codex/codex_agent.py @@ -82,7 +82,12 @@ def __init__( def initialize(self) -> TaskStatus: try: - self.codex_config = build_codex_config(self.task_data.model_config) + from executor.modes.local.capabilities import get_project_id + + self.codex_config = build_codex_config( + self.task_data.model_config, + project_id=get_project_id(self.task_data), + ) return TaskStatus.SUCCESS except Exception as exc: logger.exception("Failed to initialize CodeXAgent: %s", exc) diff --git a/executor/agents/codex/config_builder.py b/executor/agents/codex/config_builder.py index 11eed1cce7..dc29a230ac 100644 --- a/executor/agents/codex/config_builder.py +++ b/executor/agents/codex/config_builder.py @@ -13,6 +13,10 @@ from dataclasses import dataclass from typing import Any, Optional +from executor.agents.api_headers import ( + merge_project_header, + merge_wegent_runtime_headers, +) from executor.agents.env_value import resolve_env_value from executor.config import config from shared.logger import setup_logger @@ -84,7 +88,11 @@ def is_codex_compatible_model(model_config: dict[str, Any]) -> bool: ) -def build_codex_config(model_config: dict[str, Any]) -> CodeXConfig: +def build_codex_config( + model_config: dict[str, Any], + *, + project_id: Any = None, +) -> CodeXConfig: """Build Codex SDK launch and thread parameters from Wegent model config.""" model = str(model_config.get("model_id") or "").strip() if not model: @@ -96,11 +104,21 @@ def build_codex_config(model_config: dict[str, Any]) -> CodeXConfig: service_tier = _normalize_service_tier(model_config.get("service_tier")) mcp_overrides = _build_global_mcp_config_overrides() if local_config: - overrides = [f"model={model}", *mcp_overrides] + model_provider = _resolve_explicit_model_provider(model_config) + header_overrides = ( + _build_header_overrides( + model_provider, + model_config.get("default_headers"), + project_id, + ) + if model_provider + else () + ) + overrides = [f"model={model}", *header_overrides, *mcp_overrides] return CodeXConfig( codex_bin=_resolve_codex_binary(config.CODEX_BINARY_PATH), model=model, - model_provider=None, + model_provider=model_provider, config_overrides=tuple(overrides), thread_config=_build_thread_config(reasoning, service_tier), effort=reasoning.get("effort"), @@ -120,6 +138,11 @@ def build_codex_config(model_config: dict[str, Any]) -> CodeXConfig: model_provider = _resolve_model_provider(model_config) wire_api = _resolve_wire_api(model_config) + header_overrides = _build_header_overrides( + model_provider, + model_config.get("default_headers"), + project_id, + ) overrides = [ "forced_login_method=api", @@ -129,6 +152,7 @@ def build_codex_config(model_config: dict[str, Any]) -> CodeXConfig: f"model_providers.{model_provider}.base_url={base_url.rstrip('/')}", f"model_providers.{model_provider}.wire_api={wire_api}", f"model_providers.{model_provider}.experimental_bearer_token={api_key}", + *header_overrides, *mcp_overrides, ] @@ -227,6 +251,17 @@ def _resolve_model_provider(model_config: dict[str, Any]) -> str: return _sanitize_provider_id(str(provider or config.CODEX_MODEL_PROVIDER)) +def _resolve_explicit_model_provider(model_config: dict[str, Any]) -> Optional[str]: + provider = ( + model_config.get("codex_model_provider") + or model_config.get("model_provider") + or model_config.get("provider") + ) + if not provider: + return None + return _sanitize_provider_id(str(provider)) + + def _resolve_provider_name(model_config: dict[str, Any]) -> str: return str( model_config.get("provider_name") @@ -244,6 +279,27 @@ def _resolve_wire_api(model_config: dict[str, Any]) -> str: return wire_api or RESPONSES_WIRE_API +def _build_header_overrides( + model_provider: str, + default_headers: Any, + project_id: Any, +) -> tuple[str, ...]: + project_headers = merge_project_header({}, project_id) + headers = ( + merge_wegent_runtime_headers(default_headers, executor="codex") + if project_headers + else default_headers + ) + headers = merge_project_header(headers, project_id) + if not headers: + return () + return tuple( + f"{_toml_key_path('model_providers', model_provider, 'http_headers', key)}=" + f"{_toml_value(value)}" + for key, value in headers.items() + ) + + def _read_api_format(model_config: dict[str, Any]) -> str: return str( model_config.get("api_format") or model_config.get("apiFormat") or "" diff --git a/executor/config/config.py b/executor/config/config.py index 89d77e6f59..19d8ea80e4 100644 --- a/executor/config/config.py +++ b/executor/config/config.py @@ -135,11 +135,6 @@ def get_workspace_root() -> str: WEGENT_EXECUTOR_LOG_MAX_SIZE = _get_int_env("WEGENT_EXECUTOR_LOG_MAX_SIZE", 10) # MB WEGENT_EXECUTOR_LOG_BACKUP_COUNT = _get_int_env("WEGENT_EXECUTOR_LOG_BACKUP_COUNT", 5) -# Anthropic custom headers for local mode -# Custom HTTP headers to send with Anthropic API requests -# Format: "header1: value1\nheader2: value2" -ANTHROPIC_CUSTOM_HEADERS = os.environ.get("ANTHROPIC_CUSTOM_HEADERS", "") - WEGENT_MCP_URL = os.environ.get( "WEGENT_MCP_URL", f"{WEGENT_BACKEND_URL}/api/mcp/system" ) diff --git a/executor/modes/local/capabilities.py b/executor/modes/local/capabilities.py index 939d0a81cc..30b6a7a851 100644 --- a/executor/modes/local/capabilities.py +++ b/executor/modes/local/capabilities.py @@ -83,21 +83,56 @@ def is_project_task(task_data: ExecutionRequest) -> bool: if getattr(task_data, "standalone_chat_workspace", False): return True - project_id = getattr(task_data, "project_id", None) - if project_id and int(project_id) > 0: + if get_project_id(task_data): return True + return False + + +def get_project_id(task_data: ExecutionRequest) -> str: + """Return the project ID for a project-backed execution request.""" + project_id = getattr(task_data, "project_id", None) + normalized_project_id = _normalize_project_id(project_id) + if normalized_project_id: + return normalized_project_id + workspace = getattr(task_data, "workspace", None) if isinstance(workspace, dict): + project = workspace.get("project") + if isinstance(project, dict): + normalized_project_id = _normalize_project_id(project.get("project_id")) + if normalized_project_id: + return normalized_project_id + metadata = workspace.get("metadata") or {} - project = metadata.get("project") if isinstance(metadata, dict) else None - if isinstance(project, dict) and project.get("project_id"): - return True + metadata_project = ( + metadata.get("project") if isinstance(metadata, dict) else None + ) + if isinstance(metadata_project, dict): + normalized_project_id = _normalize_project_id( + metadata_project.get("project_id") + ) + if normalized_project_id: + return normalized_project_id task_data_payload = getattr(task_data, "task_data", None) - return bool( - isinstance(task_data_payload, dict) and task_data_payload.get("project_id") - ) + if isinstance(task_data_payload, dict): + normalized_project_id = _normalize_project_id( + task_data_payload.get("project_id") + ) + if normalized_project_id: + return normalized_project_id + + return "" + + +def _normalize_project_id(project_id: Any) -> str: + if project_id is None: + return "" + value = str(project_id).strip() + if not value: + return "" + return value def _read_json_file(path: Path, default: dict[str, Any]) -> dict[str, Any]: diff --git a/executor/tests/agents/test_codex_config_builder.py b/executor/tests/agents/test_codex_config_builder.py index 3fd9ab106e..2f3c5c7326 100644 --- a/executor/tests/agents/test_codex_config_builder.py +++ b/executor/tests/agents/test_codex_config_builder.py @@ -64,6 +64,114 @@ def test_build_codex_config_maps_provider_and_reasoning(): assert config.summary == "concise" +def test_build_codex_config_adds_project_header_when_requested(): + config = build_codex_config( + { + "model": "openai", + "model_id": "gpt-5.5", + "base_url": "http://127.0.0.1:3456/v1", + "api_key": "wecode-proxy-placeholder", + "api_format": "responses", + }, + project_id=42, + ) + + assert ( + 'model_providers.wecode-openai.http_headers.wecode-project="42"' + in config.config_overrides + ) + assert ( + 'model_providers.wecode-openai.http_headers.wecode-action="wegent"' + in config.config_overrides + ) + assert ( + 'model_providers.wecode-openai.http_headers.wecode-source="wegent-local"' + in config.config_overrides + ) + assert ( + 'model_providers.wecode-openai.http_headers.wecode-executor="codex"' + in config.config_overrides + ) + + +def test_build_codex_config_treats_project_zero_as_project(): + config = build_codex_config( + { + "model": "openai", + "model_id": "gpt-5.5", + "base_url": "http://127.0.0.1:3456/v1", + "api_key": "wecode-proxy-placeholder", + "api_format": "responses", + }, + project_id=0, + ) + + assert ( + 'model_providers.wecode-openai.http_headers.wecode-project="0"' + in config.config_overrides + ) + assert ( + 'model_providers.wecode-openai.http_headers.wecode-executor="codex"' + in config.config_overrides + ) + + +def test_build_codex_config_preserves_default_source_header_when_project_requested(): + config = build_codex_config( + { + "model": "openai", + "model_id": "gpt-5.5", + "base_url": "http://127.0.0.1:3456/v1", + "api_key": "wecode-proxy-placeholder", + "api_format": "responses", + "default_headers": { + "wecode-action": "wecode-cli", + "wecode-source": "wecode-cli", + "x-weibo-downstream": "shanghai-intranet", + }, + }, + project_id=42, + ) + + assert ( + 'model_providers.wecode-openai.http_headers.wecode-action="wecode-cli"' + in config.config_overrides + ) + assert ( + 'model_providers.wecode-openai.http_headers.x-weibo-downstream="shanghai-intranet"' + in config.config_overrides + ) + assert ( + 'model_providers.wecode-openai.http_headers.wecode-source="wecode-cli"' + in config.config_overrides + ) + assert ( + 'model_providers.wecode-openai.http_headers.wecode-project="42"' + in config.config_overrides + ) + assert ( + 'model_providers.wecode-openai.http_headers.wecode-executor="codex"' + in config.config_overrides + ) + + +def test_build_codex_config_omits_project_header_by_default(): + config = build_codex_config( + { + "model": "openai", + "model_id": "gpt-5.5", + "base_url": "http://127.0.0.1:3456/v1", + "api_key": "wecode-proxy-placeholder", + "api_format": "responses", + } + ) + + assert not any("wecode-project" in item for item in config.config_overrides) + assert not any("wecode-executor" in item for item in config.config_overrides) + assert not any("wecode-source" in item for item in config.config_overrides) + assert not any("wecode-action" in item for item in config.config_overrides) + + def test_build_codex_config_uses_user_runtime_config(monkeypatch): monkeypatch.delenv("NO_PROXY", raising=False) monkeypatch.delenv("no_proxy", raising=False) @@ -105,6 +213,35 @@ def test_build_codex_config_uses_user_runtime_config(monkeypatch): } +def test_build_codex_config_adds_project_header_to_user_runtime_provider(): + config = build_codex_config( + { + "model": "openai", + "model_id": "gpt-5.5", + "api_format": "responses", + "model_provider": "openai", + "runtime_config": { + "codex": { + "use_user_config": True, + "configured": True, + "target_path": "~/.codex/auth.json", + } + }, + }, + project_id=42, + ) + + assert config.model_provider == "openai" + assert ( + 'model_providers.openai.http_headers.wecode-project="42"' + in config.config_overrides + ) + assert ( + 'model_providers.openai.http_headers.wecode-executor="codex"' + in config.config_overrides + ) + + def test_build_codex_config_uses_existing_no_proxy(monkeypatch): monkeypatch.setenv("NO_PROXY", "localhost,.internal") diff --git a/executor/tests/agents/test_mode_strategy.py b/executor/tests/agents/test_mode_strategy.py index 240cbf728a..b0efd6ff62 100644 --- a/executor/tests/agents/test_mode_strategy.py +++ b/executor/tests/agents/test_mode_strategy.py @@ -70,6 +70,11 @@ def strategy(self): """Create LocalModeStrategy instance.""" return LocalModeStrategy() + @pytest.fixture(autouse=True) + def clear_process_custom_headers(self, monkeypatch): + """Keep process-level custom headers isolated per test.""" + monkeypatch.delenv("ANTHROPIC_CUSTOM_HEADERS", raising=False) + @pytest.fixture def temp_workspace(self): """Create temporary workspace directory.""" @@ -413,14 +418,135 @@ def test_configure_client_options_uses_global_config_dir_for_project_tasks( assert result["env"]["CLAUDE_CONFIG_DIR"] == str(tmp_path / ".claude") assert result["env"]["SKILLS_DIR"] == str(tmp_path / ".claude" / "skills") - def test_configure_client_options_adds_anthropic_custom_headers(self, strategy): + def test_configure_client_options_adds_project_header_for_project_tasks( + self, strategy, tmp_path, monkeypatch + ): + """Project tasks should mark Claude API requests with the project ID.""" + strategy.use_global_capabilities(True, project_id=42) + monkeypatch.setenv("HOME", str(tmp_path)) + options = {"cwd": "/workspace"} + config_dir = "/workspace/12345/.claude" + + result = strategy.configure_client_options(options, config_dir, {}, {}) + + assert result["env"]["ANTHROPIC_CUSTOM_HEADERS"] == ( + "wecode-action: wegent\n" + "wecode-source: wegent-local\n" + "wecode-executor: claudecode\n" + "wecode-project: 42" + ) + + def test_configure_client_options_treats_project_zero_as_project( + self, strategy, tmp_path, monkeypatch + ): + """Project ID 0 should still mark Claude API requests as project-backed.""" + strategy.use_global_capabilities(True, project_id=0) + monkeypatch.setenv("HOME", str(tmp_path)) + options = {"cwd": "/workspace"} + config_dir = "/workspace/12345/.claude" + + result = strategy.configure_client_options(options, config_dir, {}, {}) + + assert result["env"]["ANTHROPIC_CUSTOM_HEADERS"] == ( + "wecode-action: wegent\n" + "wecode-source: wegent-local\n" + "wecode-executor: claudecode\n" + "wecode-project: 0" + ) + + def test_configure_client_options_preserves_custom_headers_for_project_tasks( + self, strategy, tmp_path, monkeypatch + ): + """Project header should be merged with existing Claude headers.""" + strategy.use_global_capabilities(True, project_id=42) + monkeypatch.setenv("HOME", str(tmp_path)) + options = {"cwd": "/workspace"} + config_dir = "/workspace/12345/.claude" + env_config = {"ANTHROPIC_CUSTOM_HEADERS": "x-custom-user: test"} + + result = strategy.configure_client_options(options, config_dir, env_config, {}) + + assert result["env"]["ANTHROPIC_CUSTOM_HEADERS"] == ( + "x-custom-user: test\n" + "wecode-action: wegent\n" + "wecode-source: wegent-local\n" + "wecode-executor: claudecode\n" + "wecode-project: 42" + ) + + def test_configure_client_options_appends_project_to_process_headers( + self, strategy, tmp_path, monkeypatch + ): + """Project tasks should merge project ID with process startup headers.""" + strategy.use_global_capabilities(True, project_id=42) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv( + "ANTHROPIC_CUSTOM_HEADERS", + "wecode-source: wegent-local\n" + "wecode-action: wegent\n" + "wecode-executor: claudecode", + ) + options = {"cwd": "/workspace"} + config_dir = "/workspace/12345/.claude" + + result = strategy.configure_client_options(options, config_dir, {}, {}) + + assert result["env"]["ANTHROPIC_CUSTOM_HEADERS"] == ( + "wecode-source: wegent-local\n" + "wecode-action: wegent\n" + "wecode-executor: claudecode\n" + "wecode-project: 42" + ) + + def test_configure_client_options_preserves_default_source_header_for_project_tasks( + self, strategy, tmp_path, monkeypatch + ): + """Project tasks should preserve Wecode CLI source and add project ID.""" + strategy.use_global_capabilities(True, project_id=42) + monkeypatch.setenv("HOME", str(tmp_path)) + options = {"cwd": "/workspace"} + config_dir = "/workspace/12345/.claude" + env_config = { + "DEFAULT_HEADERS": { + "wecode-action": "wecode-cli", + "wecode-source": "wecode-cli", + "x-weibo-downstream": "shanghai-intranet", + } + } + + result = strategy.configure_client_options( + options, + config_dir, + env_config, + {}, + ) + + assert result["env"]["ANTHROPIC_CUSTOM_HEADERS"] == ( + "wecode-action: wecode-cli\n" + "wecode-source: wecode-cli\n" + "x-weibo-downstream: shanghai-intranet\n" + "wecode-executor: claudecode\n" + "wecode-project: 42" + ) + assert json.loads(result["env"]["DEFAULT_HEADERS"]) == { + "wecode-action": "wecode-cli", + "wecode-source": "wecode-cli", + "x-weibo-downstream": "shanghai-intranet", + "wecode-executor": "claudecode", + "wecode-project": "42", + } + assert result["env"]["default_headers"] == result["env"]["DEFAULT_HEADERS"] + + def test_configure_client_options_adds_anthropic_custom_headers( + self, strategy, monkeypatch + ): """Test that ANTHROPIC_CUSTOM_HEADERS is added when configured.""" options = {"cwd": "/workspace"} config_dir = "/workspace/12345/.claude" custom_headers = "x-custom-user: test\nx-custom-source: executor" + monkeypatch.setenv("ANTHROPIC_CUSTOM_HEADERS", custom_headers) - with patch("executor.config.config.ANTHROPIC_CUSTOM_HEADERS", custom_headers): - result = strategy.configure_client_options(options, config_dir, {}, {}) + result = strategy.configure_client_options(options, config_dir, {}, {}) assert result["env"]["ANTHROPIC_CUSTOM_HEADERS"] == custom_headers @@ -429,8 +555,7 @@ def test_configure_client_options_no_custom_headers_when_empty(self, strategy): options = {"cwd": "/workspace"} config_dir = "/workspace/12345/.claude" - with patch("executor.config.config.ANTHROPIC_CUSTOM_HEADERS", ""): - result = strategy.configure_client_options(options, config_dir, {}, {}) + result = strategy.configure_client_options(options, config_dir, {}, {}) assert "ANTHROPIC_CUSTOM_HEADERS" not in result["env"] diff --git a/executor/tests/agents/test_project_workspace_paths.py b/executor/tests/agents/test_project_workspace_paths.py index faf02943eb..e210ad14f3 100644 --- a/executor/tests/agents/test_project_workspace_paths.py +++ b/executor/tests/agents/test_project_workspace_paths.py @@ -206,6 +206,39 @@ async def test_codex_pre_execute_uses_project_workspace_path(tmp_path): assert status.name == "SUCCESS" +def test_codex_project_task_marks_api_project_id(): + request = ExecutionRequest( + task_id=1546, + subtask_id=1970, + project_id=42, + model_config={ + "model": "openai", + "model_id": "gpt-5.5", + "base_url": "https://copilot.weibo.com/v1", + "api_key": "token", + "api_format": "responses", + }, + ) + agent = CodeXAgent(request, MagicMock()) + + status = agent.initialize() + + assert status.name == "SUCCESS" + assert agent.codex_config is not None + assert ( + 'model_providers.wecode-openai.http_headers.wecode-project="42"' + in agent.codex_config.config_overrides + ) + assert ( + 'model_providers.wecode-openai.http_headers.wecode-executor="codex"' + in agent.codex_config.config_overrides + ) + assert not any( + item == 'model_providers.wecode-openai.http_headers.wecode-source="wework"' + for item in agent.codex_config.config_overrides + ) + + async def test_initial_codex_standalone_chat_prepares_request_named_cwd( tmp_path, monkeypatch ): diff --git a/executor/tests/modes/local/test_global_capabilities.py b/executor/tests/modes/local/test_global_capabilities.py index 0f0c58cd8d..e31d41366e 100644 --- a/executor/tests/modes/local/test_global_capabilities.py +++ b/executor/tests/modes/local/test_global_capabilities.py @@ -10,6 +10,7 @@ GlobalCapabilityStore, ManagedCapabilityManifest, default_manifest_path, + get_project_id, is_project_task, ) from shared.models.execution import ExecutionRequest @@ -25,13 +26,24 @@ def test_wework_standalone_chat_uses_global_capabilities(): assert is_project_task(request) is True -def test_frontend_project_zero_chat_keeps_task_local_capabilities(): +def test_frontend_project_zero_chat_uses_project_capabilities(): request = ExecutionRequest( task_id=1902, project_id=0, ) - assert is_project_task(request) is False + assert get_project_id(request) == "0" + assert is_project_task(request) is True + + +def test_project_id_can_be_read_from_execution_workspace_project(): + request = ExecutionRequest( + task_id=1903, + workspace={"project": {"project_id": 42}}, + ) + + assert get_project_id(request) == "42" + assert is_project_task(request) is True def test_project_runtime_uses_global_claude_capability_dirs_without_merging_global_mcp(