From 12564552bc236ff510243c7d06ca5a9f1ac6f750 Mon Sep 17 00:00:00 2001 From: yunpeng7 Date: Tue, 16 Jun 2026 17:57:07 +0800 Subject: [PATCH 01/12] feat(executor): tag wework project model requests --- executor/agents/api_headers.py | 29 +++++++++++++++++ .../agents/claude_code/local_mode_strategy.py | 19 ++++++++--- executor/agents/codex/codex_agent.py | 9 +++++- executor/agents/codex/config_builder.py | 24 +++++++++++++- .../tests/agents/test_codex_config_builder.py | 32 +++++++++++++++++++ executor/tests/agents/test_mode_strategy.py | 31 ++++++++++++++++++ .../agents/test_project_workspace_paths.py | 25 +++++++++++++++ 7 files changed, 162 insertions(+), 7 deletions(-) create mode 100644 executor/agents/api_headers.py diff --git a/executor/agents/api_headers.py b/executor/agents/api_headers.py new file mode 100644 index 0000000000..b22b865d2c --- /dev/null +++ b/executor/agents/api_headers.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python + +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Utilities for model API source headers.""" + +from typing import Optional + +WECODE_SOURCE_HEADER = "wecode-source" +WEWORK_SOURCE = "wework" + + +def merge_anthropic_custom_headers( + existing_headers: str, + source: Optional[str], +) -> str: + """Return Anthropic custom headers with the Wegent source header merged.""" + if not source: + return existing_headers + + lines = [ + line.strip() for line in existing_headers.splitlines() if line and line.strip() + ] + header_prefix = f"{WECODE_SOURCE_HEADER}:" + preserved = [line for line in lines if not line.lower().startswith(header_prefix)] + preserved.append(f"{WECODE_SOURCE_HEADER}: {source}") + return "\n".join(preserved) diff --git a/executor/agents/claude_code/local_mode_strategy.py b/executor/agents/claude_code/local_mode_strategy.py index e67170f029..a4c59cd5f0 100644 --- a/executor/agents/claude_code/local_mode_strategy.py +++ b/executor/agents/claude_code/local_mode_strategy.py @@ -26,6 +26,7 @@ from pathlib import Path from typing import Any, Dict, Tuple +from executor.agents.api_headers import WEWORK_SOURCE, merge_anthropic_custom_headers from executor.agents.claude_code.mode_strategy import ExecutionModeStrategy from executor.config import config from executor.platform_compat import get_permissions_manager, sanitize_ld_library_path @@ -191,13 +192,21 @@ 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}" + custom_headers = config.ANTHROPIC_CUSTOM_HEADERS or env.get( + "ANTHROPIC_CUSTOM_HEADERS", "" + ) + if self._use_global_capabilities: + custom_headers = merge_anthropic_custom_headers( + custom_headers, + WEWORK_SOURCE, ) + # Add ANTHROPIC_CUSTOM_HEADERS if configured via environment variable or + # project execution source metadata. + if custom_headers: + env["ANTHROPIC_CUSTOM_HEADERS"] = custom_headers + logger.info("Local mode: ANTHROPIC_CUSTOM_HEADERS configured") + updated_options["env"] = env logger.debug(f"Local mode: Configured CLAUDE_CONFIG_DIR={config_dir}") diff --git a/executor/agents/codex/codex_agent.py b/executor/agents/codex/codex_agent.py index 4053b2267e..ab014531a9 100644 --- a/executor/agents/codex/codex_agent.py +++ b/executor/agents/codex/codex_agent.py @@ -12,6 +12,7 @@ from pathlib import Path from typing import Any, Callable, Optional, Tuple +from executor.agents.api_headers import WEWORK_SOURCE from executor.agents.base import Agent from executor.agents.claude_code.standalone_chat_workspace import ( finalize_standalone_chat_workspace, @@ -81,7 +82,13 @@ def __init__( def initialize(self) -> TaskStatus: try: - self.codex_config = build_codex_config(self.task_data.model_config) + from executor.modes.local.capabilities import is_project_task + + source = WEWORK_SOURCE if is_project_task(self.task_data) else None + self.codex_config = build_codex_config( + self.task_data.model_config, + source=source, + ) 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..c25df270b3 100644 --- a/executor/agents/codex/config_builder.py +++ b/executor/agents/codex/config_builder.py @@ -13,6 +13,7 @@ from dataclasses import dataclass from typing import Any, Optional +from executor.agents.api_headers import WECODE_SOURCE_HEADER from executor.agents.env_value import resolve_env_value from executor.config import config from shared.logger import setup_logger @@ -84,7 +85,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], + *, + source: Optional[str] = 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: @@ -120,6 +125,7 @@ def build_codex_config(model_config: dict[str, Any]) -> CodeXConfig: model_provider = _resolve_model_provider(model_config) wire_api = _resolve_wire_api(model_config) + source_header_overrides = _build_source_header_overrides(model_provider, source) overrides = [ "forced_login_method=api", @@ -129,6 +135,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}", + *source_header_overrides, *mcp_overrides, ] @@ -244,6 +251,21 @@ def _resolve_wire_api(model_config: dict[str, Any]) -> str: return wire_api or RESPONSES_WIRE_API +def _build_source_header_overrides( + model_provider: str, + source: Optional[str], +) -> tuple[str, ...]: + if not source: + return () + key = _toml_key_path( + "model_providers", + model_provider, + "http_headers", + WECODE_SOURCE_HEADER, + ) + return (f"{key}={_toml_value(source)}",) + + 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/tests/agents/test_codex_config_builder.py b/executor/tests/agents/test_codex_config_builder.py index 3fd9ab106e..886462da2a 100644 --- a/executor/tests/agents/test_codex_config_builder.py +++ b/executor/tests/agents/test_codex_config_builder.py @@ -64,6 +64,38 @@ def test_build_codex_config_maps_provider_and_reasoning(): assert config.summary == "concise" +def test_build_codex_config_adds_wework_source_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", + }, + source="wework", + ) + + assert ( + 'model_providers.wecode-openai.http_headers.wecode-source="wework"' + in config.config_overrides + ) + + +def test_build_codex_config_omits_source_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-source" 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) diff --git a/executor/tests/agents/test_mode_strategy.py b/executor/tests/agents/test_mode_strategy.py index 240cbf728a..0b69b90a95 100644 --- a/executor/tests/agents/test_mode_strategy.py +++ b/executor/tests/agents/test_mode_strategy.py @@ -413,6 +413,37 @@ 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_wework_source_header_for_project_tasks( + self, strategy, tmp_path, monkeypatch + ): + """Project tasks should mark Claude API requests as coming from Wework.""" + strategy.use_global_capabilities(True) + monkeypatch.setenv("HOME", str(tmp_path)) + options = {"cwd": "/workspace"} + config_dir = "/workspace/12345/.claude" + + with patch("executor.config.config.ANTHROPIC_CUSTOM_HEADERS", ""): + result = strategy.configure_client_options(options, config_dir, {}, {}) + + assert result["env"]["ANTHROPIC_CUSTOM_HEADERS"] == "wecode-source: wework" + + def test_configure_client_options_preserves_custom_headers_for_project_tasks( + self, strategy, tmp_path, monkeypatch + ): + """Project source header should be merged with existing Claude headers.""" + strategy.use_global_capabilities(True) + monkeypatch.setenv("HOME", str(tmp_path)) + options = {"cwd": "/workspace"} + config_dir = "/workspace/12345/.claude" + custom_headers = "x-custom-user: test" + + with patch("executor.config.config.ANTHROPIC_CUSTOM_HEADERS", custom_headers): + result = strategy.configure_client_options(options, config_dir, {}, {}) + + assert result["env"]["ANTHROPIC_CUSTOM_HEADERS"] == ( + "x-custom-user: test\nwecode-source: wework" + ) + def test_configure_client_options_adds_anthropic_custom_headers(self, strategy): """Test that ANTHROPIC_CUSTOM_HEADERS is added when configured.""" options = {"cwd": "/workspace"} diff --git a/executor/tests/agents/test_project_workspace_paths.py b/executor/tests/agents/test_project_workspace_paths.py index faf02943eb..e16b655d82 100644 --- a/executor/tests/agents/test_project_workspace_paths.py +++ b/executor/tests/agents/test_project_workspace_paths.py @@ -206,6 +206,31 @@ async def test_codex_pre_execute_uses_project_workspace_path(tmp_path): assert status.name == "SUCCESS" +def test_codex_project_task_marks_api_source_as_wework(): + 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-source="wework"' + in agent.codex_config.config_overrides + ) + + async def test_initial_codex_standalone_chat_prepares_request_named_cwd( tmp_path, monkeypatch ): From 5e5a18936cd11669b303691e06ceb8005ff41f4e Mon Sep 17 00:00:00 2001 From: yunpeng7 Date: Tue, 16 Jun 2026 20:48:29 +0800 Subject: [PATCH 02/12] fix(executor): preserve wecode headers for project requests --- executor/agents/api_headers.py | 117 ++++++++++++++++-- .../agents/claude_code/local_mode_strategy.py | 20 ++- executor/agents/codex/config_builder.py | 26 ++-- .../tests/agents/test_codex_config_builder.py | 35 ++++++ executor/tests/agents/test_mode_strategy.py | 30 +++++ 5 files changed, 205 insertions(+), 23 deletions(-) diff --git a/executor/agents/api_headers.py b/executor/agents/api_headers.py index b22b865d2c..68f3dfb9ef 100644 --- a/executor/agents/api_headers.py +++ b/executor/agents/api_headers.py @@ -6,10 +6,49 @@ """Utilities for model API source headers.""" -from typing import Optional +import json +from collections.abc import Mapping +from typing import Any, Optional WECODE_SOURCE_HEADER = "wecode-source" WEWORK_SOURCE = "wework" +DEFAULT_HEADERS_ENV_KEYS = ("DEFAULT_HEADERS", "default_headers") + + +def merge_source_header( + headers: Mapping[str, Any] | str | None, + source: Optional[str], +) -> dict[str, str]: + """Return headers with the Wegent source header merged.""" + parsed_headers = _parse_header_map(headers) + if not source: + return parsed_headers + + return merge_header_map(parsed_headers, {WECODE_SOURCE_HEADER: source}) + + +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_custom_headers( @@ -20,10 +59,72 @@ def merge_anthropic_custom_headers( if not source: return existing_headers - lines = [ - line.strip() for line in existing_headers.splitlines() if line and line.strip() - ] - header_prefix = f"{WECODE_SOURCE_HEADER}:" - preserved = [line for line in lines if not line.lower().startswith(header_prefix)] - preserved.append(f"{WECODE_SOURCE_HEADER}: {source}") - return "\n".join(preserved) + headers = _parse_anthropic_custom_headers(existing_headers) + return headers_to_anthropic_custom_headers( + merge_header_map(headers, {WECODE_SOURCE_HEADER: source}) + ) + + +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 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 diff --git a/executor/agents/claude_code/local_mode_strategy.py b/executor/agents/claude_code/local_mode_strategy.py index a4c59cd5f0..9e34e91339 100644 --- a/executor/agents/claude_code/local_mode_strategy.py +++ b/executor/agents/claude_code/local_mode_strategy.py @@ -26,7 +26,13 @@ from pathlib import Path from typing import Any, Dict, Tuple -from executor.agents.api_headers import WEWORK_SOURCE, merge_anthropic_custom_headers +from executor.agents.api_headers import ( + WEWORK_SOURCE, + extract_default_headers, + merge_anthropic_custom_headers, + merge_anthropic_header_map, + merge_source_header, +) from executor.agents.claude_code.mode_strategy import ExecutionModeStrategy from executor.config import config from executor.platform_compat import get_permissions_manager, sanitize_ld_library_path @@ -179,12 +185,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 LD_LIBRARY_PATH issue 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_ld_library_path(env) # Project tasks use global Claude capability symlinks; non-project tasks @@ -195,11 +200,18 @@ def configure_client_options( custom_headers = config.ANTHROPIC_CUSTOM_HEADERS or env.get( "ANTHROPIC_CUSTOM_HEADERS", "" ) + default_headers = extract_default_headers(merged_env) if self._use_global_capabilities: + default_headers = merge_source_header(default_headers, WEWORK_SOURCE) custom_headers = merge_anthropic_custom_headers( custom_headers, WEWORK_SOURCE, ) + if default_headers: + custom_headers = merge_anthropic_header_map( + custom_headers, + default_headers, + ) # Add ANTHROPIC_CUSTOM_HEADERS if configured via environment variable or # project execution source metadata. diff --git a/executor/agents/codex/config_builder.py b/executor/agents/codex/config_builder.py index c25df270b3..224fb92d40 100644 --- a/executor/agents/codex/config_builder.py +++ b/executor/agents/codex/config_builder.py @@ -13,7 +13,7 @@ from dataclasses import dataclass from typing import Any, Optional -from executor.agents.api_headers import WECODE_SOURCE_HEADER +from executor.agents.api_headers import merge_source_header from executor.agents.env_value import resolve_env_value from executor.config import config from shared.logger import setup_logger @@ -125,7 +125,11 @@ def build_codex_config( model_provider = _resolve_model_provider(model_config) wire_api = _resolve_wire_api(model_config) - source_header_overrides = _build_source_header_overrides(model_provider, source) + header_overrides = _build_header_overrides( + model_provider, + model_config.get("default_headers"), + source, + ) overrides = [ "forced_login_method=api", @@ -135,7 +139,7 @@ def build_codex_config( 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}", - *source_header_overrides, + *header_overrides, *mcp_overrides, ] @@ -251,19 +255,19 @@ def _resolve_wire_api(model_config: dict[str, Any]) -> str: return wire_api or RESPONSES_WIRE_API -def _build_source_header_overrides( +def _build_header_overrides( model_provider: str, + default_headers: Any, source: Optional[str], ) -> tuple[str, ...]: - if not source: + headers = merge_source_header(default_headers, source) + if not headers: return () - key = _toml_key_path( - "model_providers", - model_provider, - "http_headers", - WECODE_SOURCE_HEADER, + return tuple( + f"{_toml_key_path('model_providers', model_provider, 'http_headers', key)}=" + f"{_toml_value(value)}" + for key, value in headers.items() ) - return (f"{key}={_toml_value(source)}",) def _read_api_format(model_config: dict[str, Any]) -> str: diff --git a/executor/tests/agents/test_codex_config_builder.py b/executor/tests/agents/test_codex_config_builder.py index 886462da2a..dd19ad87a6 100644 --- a/executor/tests/agents/test_codex_config_builder.py +++ b/executor/tests/agents/test_codex_config_builder.py @@ -82,6 +82,41 @@ def test_build_codex_config_adds_wework_source_header_when_requested(): ) +def test_build_codex_config_overrides_default_source_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", + "default_headers": { + "wecode-action": "wecode-cli", + "wecode-source": "wecode-cli", + "x-weibo-downstream": "shanghai-intranet", + }, + }, + source="wework", + ) + + 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="wework"' + in config.config_overrides + ) + assert not any( + item == 'model_providers.wecode-openai.http_headers.wecode-source="wecode-cli"' + for item in config.config_overrides + ) + + def test_build_codex_config_omits_source_header_by_default(): config = build_codex_config( { diff --git a/executor/tests/agents/test_mode_strategy.py b/executor/tests/agents/test_mode_strategy.py index 0b69b90a95..5601bc727d 100644 --- a/executor/tests/agents/test_mode_strategy.py +++ b/executor/tests/agents/test_mode_strategy.py @@ -444,6 +444,36 @@ def test_configure_client_options_preserves_custom_headers_for_project_tasks( "x-custom-user: test\nwecode-source: wework" ) + def test_configure_client_options_overrides_default_source_header_for_project_tasks( + self, strategy, tmp_path, monkeypatch + ): + """Project tasks should override Wecode CLI default source headers.""" + strategy.use_global_capabilities(True) + 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", + } + } + + with patch("executor.config.config.ANTHROPIC_CUSTOM_HEADERS", ""): + result = strategy.configure_client_options( + options, + config_dir, + env_config, + {}, + ) + + assert result["env"]["ANTHROPIC_CUSTOM_HEADERS"] == ( + "wecode-action: wecode-cli\n" + "x-weibo-downstream: shanghai-intranet\n" + "wecode-source: wework" + ) + def test_configure_client_options_adds_anthropic_custom_headers(self, strategy): """Test that ANTHROPIC_CUSTOM_HEADERS is added when configured.""" options = {"cwd": "/workspace"} From 3cf86cbe0c955dc1722c628a559d24cc8ea82b06 Mon Sep 17 00:00:00 2001 From: yunpeng7 Date: Tue, 16 Jun 2026 21:45:58 +0800 Subject: [PATCH 03/12] fix(executor): tag project requests with project id --- executor/agents/api_headers.py | 43 +++++++++--------- .../agents/claude_code/claude_code_agent.py | 10 ++++- .../agents/claude_code/local_mode_strategy.py | 14 +++--- executor/agents/codex/codex_agent.py | 6 +-- executor/agents/codex/config_builder.py | 10 ++--- executor/modes/local/capabilities.py | 44 ++++++++++++++++--- .../tests/agents/test_codex_config_builder.py | 22 +++++----- executor/tests/agents/test_mode_strategy.py | 23 +++++----- .../agents/test_project_workspace_paths.py | 8 +++- 9 files changed, 106 insertions(+), 74 deletions(-) diff --git a/executor/agents/api_headers.py b/executor/agents/api_headers.py index 68f3dfb9ef..d7490f17bc 100644 --- a/executor/agents/api_headers.py +++ b/executor/agents/api_headers.py @@ -4,27 +4,29 @@ # # SPDX-License-Identifier: Apache-2.0 -"""Utilities for model API source headers.""" +"""Utilities for model API request headers.""" import json from collections.abc import Mapping -from typing import Any, Optional +from typing import Any -WECODE_SOURCE_HEADER = "wecode-source" -WEWORK_SOURCE = "wework" +WECODE_PROJECT_HEADER = "wecode-project" DEFAULT_HEADERS_ENV_KEYS = ("DEFAULT_HEADERS", "default_headers") -def merge_source_header( +def merge_project_header( headers: Mapping[str, Any] | str | None, - source: Optional[str], + project_id: Any, ) -> dict[str, str]: - """Return headers with the Wegent source header merged.""" + """Return headers with the Wegent project header merged.""" parsed_headers = _parse_header_map(headers) - if not source: + normalized_project_id = _normalize_project_id(project_id) + if not normalized_project_id: return parsed_headers - return merge_header_map(parsed_headers, {WECODE_SOURCE_HEADER: source}) + return merge_header_map( + parsed_headers, {WECODE_PROJECT_HEADER: normalized_project_id} + ) def merge_header_map( @@ -51,20 +53,6 @@ def headers_to_anthropic_custom_headers(headers: Mapping[str, Any]) -> str: return "\n".join(f"{key}: {value}" for key, value in headers.items()) -def merge_anthropic_custom_headers( - existing_headers: str, - source: Optional[str], -) -> str: - """Return Anthropic custom headers with the Wegent source header merged.""" - if not source: - return existing_headers - - headers = _parse_anthropic_custom_headers(existing_headers) - return headers_to_anthropic_custom_headers( - merge_header_map(headers, {WECODE_SOURCE_HEADER: source}) - ) - - def merge_anthropic_header_map( existing_headers: str, headers: Mapping[str, Any], @@ -128,3 +116,12 @@ def _parse_header_lines(headers: str) -> dict[str, str]: 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 or value == "0": + return "" + return value diff --git a/executor/agents/claude_code/claude_code_agent.py b/executor/agents/claude_code/claude_code_agent.py index 33e2fbc87c..6667508ef3 100644 --- a/executor/agents/claude_code/claude_code_agent.py +++ b/executor/agents/claude_code/claude_code_agent.py @@ -202,9 +202,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 9e34e91339..d07146a194 100644 --- a/executor/agents/claude_code/local_mode_strategy.py +++ b/executor/agents/claude_code/local_mode_strategy.py @@ -27,11 +27,9 @@ from typing import Any, Dict, Tuple from executor.agents.api_headers import ( - WEWORK_SOURCE, extract_default_headers, - merge_anthropic_custom_headers, merge_anthropic_header_map, - merge_source_header, + merge_project_header, ) from executor.agents.claude_code.mode_strategy import ExecutionModeStrategy from executor.config import config @@ -54,10 +52,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. @@ -202,11 +202,7 @@ def configure_client_options( ) default_headers = extract_default_headers(merged_env) if self._use_global_capabilities: - default_headers = merge_source_header(default_headers, WEWORK_SOURCE) - custom_headers = merge_anthropic_custom_headers( - custom_headers, - WEWORK_SOURCE, - ) + default_headers = merge_project_header(default_headers, self._project_id) if default_headers: custom_headers = merge_anthropic_header_map( custom_headers, diff --git a/executor/agents/codex/codex_agent.py b/executor/agents/codex/codex_agent.py index ab014531a9..5225636a4a 100644 --- a/executor/agents/codex/codex_agent.py +++ b/executor/agents/codex/codex_agent.py @@ -12,7 +12,6 @@ from pathlib import Path from typing import Any, Callable, Optional, Tuple -from executor.agents.api_headers import WEWORK_SOURCE from executor.agents.base import Agent from executor.agents.claude_code.standalone_chat_workspace import ( finalize_standalone_chat_workspace, @@ -82,12 +81,11 @@ def __init__( def initialize(self) -> TaskStatus: try: - from executor.modes.local.capabilities import is_project_task + from executor.modes.local.capabilities import get_project_id - source = WEWORK_SOURCE if is_project_task(self.task_data) else None self.codex_config = build_codex_config( self.task_data.model_config, - source=source, + project_id=get_project_id(self.task_data), ) return TaskStatus.SUCCESS except Exception as exc: diff --git a/executor/agents/codex/config_builder.py b/executor/agents/codex/config_builder.py index 224fb92d40..ea6645f1ec 100644 --- a/executor/agents/codex/config_builder.py +++ b/executor/agents/codex/config_builder.py @@ -13,7 +13,7 @@ from dataclasses import dataclass from typing import Any, Optional -from executor.agents.api_headers import merge_source_header +from executor.agents.api_headers import merge_project_header from executor.agents.env_value import resolve_env_value from executor.config import config from shared.logger import setup_logger @@ -88,7 +88,7 @@ def is_codex_compatible_model(model_config: dict[str, Any]) -> bool: def build_codex_config( model_config: dict[str, Any], *, - source: Optional[str] = None, + 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() @@ -128,7 +128,7 @@ def build_codex_config( header_overrides = _build_header_overrides( model_provider, model_config.get("default_headers"), - source, + project_id, ) overrides = [ @@ -258,9 +258,9 @@ def _resolve_wire_api(model_config: dict[str, Any]) -> str: def _build_header_overrides( model_provider: str, default_headers: Any, - source: Optional[str], + project_id: Any, ) -> tuple[str, ...]: - headers = merge_source_header(default_headers, source) + headers = merge_project_header(default_headers, project_id) if not headers: return () return tuple( diff --git a/executor/modes/local/capabilities.py b/executor/modes/local/capabilities.py index 939d0a81cc..083b66dfb2 100644 --- a/executor/modes/local/capabilities.py +++ b/executor/modes/local/capabilities.py @@ -83,21 +83,51 @@ 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): 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 + if isinstance(project, dict): + normalized_project_id = _normalize_project_id(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 "" + try: + if int(value) <= 0: + return "" + except ValueError: + return value + 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 dd19ad87a6..9be183effd 100644 --- a/executor/tests/agents/test_codex_config_builder.py +++ b/executor/tests/agents/test_codex_config_builder.py @@ -64,7 +64,7 @@ def test_build_codex_config_maps_provider_and_reasoning(): assert config.summary == "concise" -def test_build_codex_config_adds_wework_source_header_when_requested(): +def test_build_codex_config_adds_project_header_when_requested(): config = build_codex_config( { "model": "openai", @@ -73,16 +73,16 @@ def test_build_codex_config_adds_wework_source_header_when_requested(): "api_key": "wecode-proxy-placeholder", "api_format": "responses", }, - source="wework", + project_id=42, ) assert ( - 'model_providers.wecode-openai.http_headers.wecode-source="wework"' + 'model_providers.wecode-openai.http_headers.wecode-project="42"' in config.config_overrides ) -def test_build_codex_config_overrides_default_source_header_when_requested(): +def test_build_codex_config_preserves_default_source_header_when_project_requested(): config = build_codex_config( { "model": "openai", @@ -96,7 +96,7 @@ def test_build_codex_config_overrides_default_source_header_when_requested(): "x-weibo-downstream": "shanghai-intranet", }, }, - source="wework", + project_id=42, ) assert ( @@ -108,16 +108,16 @@ def test_build_codex_config_overrides_default_source_header_when_requested(): in config.config_overrides ) assert ( - 'model_providers.wecode-openai.http_headers.wecode-source="wework"' + 'model_providers.wecode-openai.http_headers.wecode-source="wecode-cli"' in config.config_overrides ) - assert not any( - item == 'model_providers.wecode-openai.http_headers.wecode-source="wecode-cli"' - for item in config.config_overrides + assert ( + 'model_providers.wecode-openai.http_headers.wecode-project="42"' + in config.config_overrides ) -def test_build_codex_config_omits_source_header_by_default(): +def test_build_codex_config_omits_project_header_by_default(): config = build_codex_config( { "model": "openai", @@ -128,7 +128,7 @@ def test_build_codex_config_omits_source_header_by_default(): } ) - assert not any("wecode-source" in item for item in config.config_overrides) + assert not any("wecode-project" in item for item in config.config_overrides) def test_build_codex_config_uses_user_runtime_config(monkeypatch): diff --git a/executor/tests/agents/test_mode_strategy.py b/executor/tests/agents/test_mode_strategy.py index 5601bc727d..3f382b2d9a 100644 --- a/executor/tests/agents/test_mode_strategy.py +++ b/executor/tests/agents/test_mode_strategy.py @@ -413,11 +413,11 @@ 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_wework_source_header_for_project_tasks( + def test_configure_client_options_adds_project_header_for_project_tasks( self, strategy, tmp_path, monkeypatch ): - """Project tasks should mark Claude API requests as coming from Wework.""" - strategy.use_global_capabilities(True) + """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" @@ -425,13 +425,13 @@ def test_configure_client_options_adds_wework_source_header_for_project_tasks( with patch("executor.config.config.ANTHROPIC_CUSTOM_HEADERS", ""): result = strategy.configure_client_options(options, config_dir, {}, {}) - assert result["env"]["ANTHROPIC_CUSTOM_HEADERS"] == "wecode-source: wework" + assert result["env"]["ANTHROPIC_CUSTOM_HEADERS"] == "wecode-project: 42" def test_configure_client_options_preserves_custom_headers_for_project_tasks( self, strategy, tmp_path, monkeypatch ): - """Project source header should be merged with existing Claude headers.""" - strategy.use_global_capabilities(True) + """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" @@ -441,14 +441,14 @@ def test_configure_client_options_preserves_custom_headers_for_project_tasks( result = strategy.configure_client_options(options, config_dir, {}, {}) assert result["env"]["ANTHROPIC_CUSTOM_HEADERS"] == ( - "x-custom-user: test\nwecode-source: wework" + "x-custom-user: test\nwecode-project: 42" ) - def test_configure_client_options_overrides_default_source_header_for_project_tasks( + def test_configure_client_options_preserves_default_source_header_for_project_tasks( self, strategy, tmp_path, monkeypatch ): - """Project tasks should override Wecode CLI default source headers.""" - strategy.use_global_capabilities(True) + """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" @@ -470,8 +470,9 @@ def test_configure_client_options_overrides_default_source_header_for_project_ta assert result["env"]["ANTHROPIC_CUSTOM_HEADERS"] == ( "wecode-action: wecode-cli\n" + "wecode-source: wecode-cli\n" "x-weibo-downstream: shanghai-intranet\n" - "wecode-source: wework" + "wecode-project: 42" ) def test_configure_client_options_adds_anthropic_custom_headers(self, strategy): diff --git a/executor/tests/agents/test_project_workspace_paths.py b/executor/tests/agents/test_project_workspace_paths.py index e16b655d82..b3bac28afa 100644 --- a/executor/tests/agents/test_project_workspace_paths.py +++ b/executor/tests/agents/test_project_workspace_paths.py @@ -206,7 +206,7 @@ async def test_codex_pre_execute_uses_project_workspace_path(tmp_path): assert status.name == "SUCCESS" -def test_codex_project_task_marks_api_source_as_wework(): +def test_codex_project_task_marks_api_project_id(): request = ExecutionRequest( task_id=1546, subtask_id=1970, @@ -226,9 +226,13 @@ def test_codex_project_task_marks_api_source_as_wework(): assert status.name == "SUCCESS" assert agent.codex_config is not None assert ( - 'model_providers.wecode-openai.http_headers.wecode-source="wework"' + 'model_providers.wecode-openai.http_headers.wecode-project="42"' 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( From eb7acb8ec1c2715633fdb0a762eb0e92b941c6ae Mon Sep 17 00:00:00 2001 From: yunpeng7 Date: Tue, 16 Jun 2026 22:08:49 +0800 Subject: [PATCH 04/12] fix(executor): preserve project metadata for local headers --- executor/modes/local/capabilities.py | 14 ++++++++-- executor/tests/agents/test_mode_strategy.py | 26 +++++++++++++++++++ .../modes/local/test_global_capabilities.py | 11 ++++++++ shared/models/openai_converter.py | 12 +++++++++ shared/tests/test_openai_converter.py | 23 ++++++++++++++++ 5 files changed, 84 insertions(+), 2 deletions(-) diff --git a/executor/modes/local/capabilities.py b/executor/modes/local/capabilities.py index 083b66dfb2..b145f1d734 100644 --- a/executor/modes/local/capabilities.py +++ b/executor/modes/local/capabilities.py @@ -98,13 +98,23 @@ def get_project_id(task_data: ExecutionRequest) -> str: workspace = getattr(task_data, "workspace", None) if isinstance(workspace, dict): - metadata = workspace.get("metadata") or {} - project = metadata.get("project") if isinstance(metadata, dict) else None + 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 {} + 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) if isinstance(task_data_payload, dict): normalized_project_id = _normalize_project_id( diff --git a/executor/tests/agents/test_mode_strategy.py b/executor/tests/agents/test_mode_strategy.py index 3f382b2d9a..fd457e9153 100644 --- a/executor/tests/agents/test_mode_strategy.py +++ b/executor/tests/agents/test_mode_strategy.py @@ -444,6 +444,32 @@ def test_configure_client_options_preserves_custom_headers_for_project_tasks( "x-custom-user: test\nwecode-project: 42" ) + def test_configure_client_options_appends_project_to_startup_headers( + self, strategy, tmp_path, monkeypatch + ): + """Project tasks should append project ID to executor startup headers.""" + strategy.use_global_capabilities(True, project_id=42) + monkeypatch.setenv("HOME", str(tmp_path)) + options = {"cwd": "/workspace"} + config_dir = "/workspace/12345/.claude" + custom_headers = ( + "wecode-source: wegent-local\n" + "wecode-action: wegent\n" + "wecode-executor: claudecode\n" + "wecode-user: yunpeng7" + ) + + with patch("executor.config.config.ANTHROPIC_CUSTOM_HEADERS", custom_headers): + 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-user: yunpeng7\n" + "wecode-project: 42" + ) + def test_configure_client_options_preserves_default_source_header_for_project_tasks( self, strategy, 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..67872c2830 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 @@ -34,6 +35,16 @@ def test_frontend_project_zero_chat_keeps_task_local_capabilities(): assert is_project_task(request) is False +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( tmp_path, monkeypatch ): diff --git a/shared/models/openai_converter.py b/shared/models/openai_converter.py index c5653d2d16..0a5ae60396 100644 --- a/shared/models/openai_converter.py +++ b/shared/models/openai_converter.py @@ -219,6 +219,12 @@ def from_execution_request(request: ExecutionRequest) -> dict[str, Any]: "skill_identity_token": request.skill_identity_token, "backend_url": request.backend_url, "workspace": request.workspace, + "project_id": request.project_id, + "standalone_chat_workspace": request.standalone_chat_workspace, + "workspace_source": request.workspace_source, + "project_workspace_path": request.project_workspace_path, + "execution_target_type": request.execution_target_type, + "device_id": request.device_id, "is_subscription": request.is_subscription, "request_id": request.request_id, "executor_name": request.executor_name, @@ -381,6 +387,12 @@ def to_execution_request(openai_request: dict[str, Any]) -> ExecutionRequest: skill_identity_token=metadata.get("skill_identity_token", ""), backend_url=metadata.get("backend_url", ""), workspace=metadata.get("workspace", {}), + project_id=metadata.get("project_id"), + standalone_chat_workspace=metadata.get("standalone_chat_workspace", False), + workspace_source=metadata.get("workspace_source"), + project_workspace_path=metadata.get("project_workspace_path"), + execution_target_type=metadata.get("execution_target_type"), + device_id=metadata.get("device_id"), is_subscription=metadata.get("is_subscription", False), request_id=metadata.get("request_id", ""), executor_name=metadata.get("executor_name"), diff --git a/shared/tests/test_openai_converter.py b/shared/tests/test_openai_converter.py index 87fa89ec49..c893a122dd 100644 --- a/shared/tests/test_openai_converter.py +++ b/shared/tests/test_openai_converter.py @@ -95,6 +95,29 @@ def test_round_trip_preserves_skip_git_clone_for_archive_recovery(): assert converted.skip_git_clone is True +def test_round_trip_preserves_project_workspace_metadata(): + request = ExecutionRequest( + workspace={"project": {"project_id": 42}}, + project_id=42, + standalone_chat_workspace=True, + workspace_source="local_path", + project_workspace_path="/Users/test/project", + execution_target_type="local", + device_id="device-1", + ) + + openai_request = OpenAIRequestConverter.from_execution_request(request) + converted = OpenAIRequestConverter.to_execution_request(openai_request) + + assert openai_request["metadata"]["project_id"] == 42 + assert converted.project_id == 42 + assert converted.standalone_chat_workspace is True + assert converted.workspace_source == "local_path" + assert converted.project_workspace_path == "/Users/test/project" + assert converted.execution_target_type == "local" + assert converted.device_id == "device-1" + + def test_to_execution_request_preserves_message_history_and_stateless_flag(): openai_request = { "model": "test-model", From 31601c39a324ae91a5eb8d5a247f2528db9c906c Mon Sep 17 00:00:00 2001 From: yunpeng7 Date: Tue, 16 Jun 2026 22:50:43 +0800 Subject: [PATCH 05/12] fix(executor): propagate project headers to direct runtimes --- .../local-device-architecture.md | 2 ++ .../local-device-architecture.md | 2 ++ .../agents/claude_code/local_mode_strategy.py | 12 +++++++++ executor/agents/codex/config_builder.py | 25 +++++++++++++++++-- .../tests/agents/test_codex_config_builder.py | 25 +++++++++++++++++++ executor/tests/agents/test_mode_strategy.py | 7 ++++++ 6 files changed, 71 insertions(+), 2 deletions(-) diff --git a/docs/en/developer-guide/local-device-architecture.md b/docs/en/developer-guide/local-device-architecture.md index 74c99e8522..0a66c07902 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. Claude Code local mode writes the merged header map to both `ANTHROPIC_CUSTOM_HEADERS` and `DEFAULT_HEADERS`/`default_headers` so the Claude Code child process and downstream model gateways see the same project identity. 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..ccc142cbfc 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: ` 请求头。Claude Code 本地模式会同时写入 `ANTHROPIC_CUSTOM_HEADERS` 与 `DEFAULT_HEADERS`/`default_headers` 环境变量,保证直接 Claude Code 子进程和下游模型网关读取到一致的 project 标识;Codex 在 Wegent 管理 provider 配置时写入 provider 的 `http_headers`,使用个人 Codex 配置且显式指定 provider 时也会对该 provider 注入同一 project 请求头。 + --- ## 🔄 任务执行流程 diff --git a/executor/agents/claude_code/local_mode_strategy.py b/executor/agents/claude_code/local_mode_strategy.py index d07146a194..784f5abe51 100644 --- a/executor/agents/claude_code/local_mode_strategy.py +++ b/executor/agents/claude_code/local_mode_strategy.py @@ -27,6 +27,7 @@ from typing import Any, Dict, Tuple from executor.agents.api_headers import ( + DEFAULT_HEADERS_ENV_KEYS, extract_default_headers, merge_anthropic_header_map, merge_project_header, @@ -204,10 +205,21 @@ def configure_client_options( if self._use_global_capabilities: 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_map( custom_headers, default_headers, ) + logger.info( + "Local mode: API default headers configured keys=%s", + sorted(default_headers), + ) # Add ANTHROPIC_CUSTOM_HEADERS if configured via environment variable or # project execution source metadata. diff --git a/executor/agents/codex/config_builder.py b/executor/agents/codex/config_builder.py index ea6645f1ec..8a1b9cd40f 100644 --- a/executor/agents/codex/config_builder.py +++ b/executor/agents/codex/config_builder.py @@ -101,11 +101,21 @@ def build_codex_config( 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"), @@ -238,6 +248,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") diff --git a/executor/tests/agents/test_codex_config_builder.py b/executor/tests/agents/test_codex_config_builder.py index 9be183effd..814b246ed4 100644 --- a/executor/tests/agents/test_codex_config_builder.py +++ b/executor/tests/agents/test_codex_config_builder.py @@ -172,6 +172,31 @@ 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 + ) + + 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 fd457e9153..4fbfa40dee 100644 --- a/executor/tests/agents/test_mode_strategy.py +++ b/executor/tests/agents/test_mode_strategy.py @@ -500,6 +500,13 @@ def test_configure_client_options_preserves_default_source_header_for_project_ta "x-weibo-downstream: shanghai-intranet\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-project": "42", + } + assert result["env"]["default_headers"] == result["env"]["DEFAULT_HEADERS"] def test_configure_client_options_adds_anthropic_custom_headers(self, strategy): """Test that ANTHROPIC_CUSTOM_HEADERS is added when configured.""" From 742135623d07f3a9075035152482a49d55ab802f Mon Sep 17 00:00:00 2001 From: yunpeng7 Date: Tue, 16 Jun 2026 23:01:26 +0800 Subject: [PATCH 06/12] chore(executor): log project header propagation --- .../agents/claude_code/claude_code_agent.py | 17 +++++++- .../agents/claude_code/local_mode_strategy.py | 41 ++++++++++++++++++- executor/agents/codex/config_builder.py | 18 ++++++++ executor/modes/local/capabilities.py | 37 +++++++++++++++++ 4 files changed, 109 insertions(+), 4 deletions(-) diff --git a/executor/agents/claude_code/claude_code_agent.py b/executor/agents/claude_code/claude_code_agent.py index 6667508ef3..2866a93704 100644 --- a/executor/agents/claude_code/claude_code_agent.py +++ b/executor/agents/claude_code/claude_code_agent.py @@ -207,9 +207,22 @@ def __init__( is_project_task, ) + project_task = is_project_task(task_data) + project_id = get_project_id(task_data) + logger.info( + "Claude Code project header context: task_id=%s " + "project_task=%s project_id=%s workspace_source=%s " + "project_workspace_path_set=%s standalone_chat_workspace=%s", + getattr(task_data, "task_id", None), + project_task, + project_id or "", + getattr(task_data, "workspace_source", None), + bool(getattr(task_data, "project_workspace_path", None)), + bool(getattr(task_data, "standalone_chat_workspace", False)), + ) self._mode_strategy.use_global_capabilities( - is_project_task(task_data), - project_id=get_project_id(task_data), + project_task, + project_id=project_id, ) # Note: emitter is created in base class Agent.__init__() diff --git a/executor/agents/claude_code/local_mode_strategy.py b/executor/agents/claude_code/local_mode_strategy.py index 784f5abe51..2ca9c6ea16 100644 --- a/executor/agents/claude_code/local_mode_strategy.py +++ b/executor/agents/claude_code/local_mode_strategy.py @@ -40,6 +40,18 @@ logger = setup_logger("local_mode_strategy") +def _header_line_keys(headers: str) -> list[str]: + """Return header keys from Claude custom header lines for diagnostics.""" + keys: list[str] = [] + for line in headers.splitlines(): + if ":" not in line: + continue + key = line.split(":", 1)[0].strip() + if key: + keys.append(key) + return sorted(keys) + + class LocalModeStrategy(ExecutionModeStrategy): """Strategy for local (non-Docker) execution mode. @@ -59,6 +71,11 @@ 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 + logger.info( + "Local mode: global capabilities configured enabled=%s project_id=%s", + self._use_global_capabilities, + self._project_id or "", + ) def get_config_directory(self, task_id: int) -> str: """Get the Claude configuration directory. @@ -202,6 +219,7 @@ def configure_client_options( "ANTHROPIC_CUSTOM_HEADERS", "" ) default_headers = extract_default_headers(merged_env) + original_default_header_keys = sorted(default_headers) if self._use_global_capabilities: default_headers = merge_project_header(default_headers, self._project_id) if default_headers: @@ -217,15 +235,34 @@ def configure_client_options( default_headers, ) logger.info( - "Local mode: API default headers configured keys=%s", + "Local mode: API default headers configured " + "original_keys=%s final_keys=%s project_id=%s", + original_default_header_keys, sorted(default_headers), + self._project_id or "", ) # Add ANTHROPIC_CUSTOM_HEADERS if configured via environment variable or # project execution source metadata. if custom_headers: env["ANTHROPIC_CUSTOM_HEADERS"] = custom_headers - logger.info("Local mode: ANTHROPIC_CUSTOM_HEADERS configured") + logger.info( + "Local mode: ANTHROPIC_CUSTOM_HEADERS configured keys=%s", + _header_line_keys(custom_headers), + ) + + logger.info( + "Local mode: Claude runtime env header summary " + "global_capabilities=%s project_id=%s has_default_headers=%s " + "has_anthropic_custom_headers=%s anthropic_base_url=%s " + "claude_config_dir=%s", + self._use_global_capabilities, + self._project_id or "", + bool(default_headers), + bool(env.get("ANTHROPIC_CUSTOM_HEADERS")), + env.get("ANTHROPIC_BASE_URL") or "", + env.get("CLAUDE_CONFIG_DIR"), + ) updated_options["env"] = env diff --git a/executor/agents/codex/config_builder.py b/executor/agents/codex/config_builder.py index 8a1b9cd40f..06573f9143 100644 --- a/executor/agents/codex/config_builder.py +++ b/executor/agents/codex/config_builder.py @@ -111,6 +111,15 @@ def build_codex_config( if model_provider else () ) + logger.info( + "Codex runtime header context: local_config=%s model_provider=%s " + "project_id=%s header_override_count=%s proxy_enabled=%s", + local_config, + model_provider or "", + str(project_id).strip() if project_id is not None else "", + len(header_overrides), + bool(proxy_env), + ) overrides = [f"model={model}", *header_overrides, *mcp_overrides] return CodeXConfig( codex_bin=_resolve_codex_binary(config.CODEX_BINARY_PATH), @@ -140,6 +149,15 @@ def build_codex_config( model_config.get("default_headers"), project_id, ) + logger.info( + "Codex runtime header context: local_config=%s model_provider=%s " + "project_id=%s header_override_count=%s base_url=%s", + local_config, + model_provider, + str(project_id).strip() if project_id is not None else "", + len(header_overrides), + base_url.rstrip("/"), + ) overrides = [ "forced_login_method=api", diff --git a/executor/modes/local/capabilities.py b/executor/modes/local/capabilities.py index b145f1d734..87f78f3707 100644 --- a/executor/modes/local/capabilities.py +++ b/executor/modes/local/capabilities.py @@ -94,6 +94,12 @@ def get_project_id(task_data: ExecutionRequest) -> str: project_id = getattr(task_data, "project_id", None) normalized_project_id = _normalize_project_id(project_id) if normalized_project_id: + logger.info( + "Resolved project id for local execution: source=task_data.project_id " + "task_id=%s project_id=%s", + getattr(task_data, "task_id", None), + normalized_project_id, + ) return normalized_project_id workspace = getattr(task_data, "workspace", None) @@ -102,6 +108,12 @@ def get_project_id(task_data: ExecutionRequest) -> str: if isinstance(project, dict): normalized_project_id = _normalize_project_id(project.get("project_id")) if normalized_project_id: + logger.info( + "Resolved project id for local execution: " + "source=workspace.project.project_id task_id=%s project_id=%s", + getattr(task_data, "task_id", None), + normalized_project_id, + ) return normalized_project_id metadata = workspace.get("metadata") or {} @@ -113,6 +125,13 @@ def get_project_id(task_data: ExecutionRequest) -> str: metadata_project.get("project_id") ) if normalized_project_id: + logger.info( + "Resolved project id for local execution: " + "source=workspace.metadata.project.project_id " + "task_id=%s project_id=%s", + getattr(task_data, "task_id", None), + normalized_project_id, + ) return normalized_project_id task_data_payload = getattr(task_data, "task_data", None) @@ -121,8 +140,26 @@ def get_project_id(task_data: ExecutionRequest) -> str: task_data_payload.get("project_id") ) if normalized_project_id: + logger.info( + "Resolved project id for local execution: " + "source=task_data.task_data.project_id task_id=%s project_id=%s", + getattr(task_data, "task_id", None), + normalized_project_id, + ) return normalized_project_id + logger.info( + "Project id not found for local execution: task_id=%s " + "top_level_project_id=%s workspace_has_project=%s " + "workspace_has_metadata_project=%s task_data_payload_has_project_id=%s", + getattr(task_data, "task_id", None), + project_id if project_id is not None else "", + isinstance(workspace, dict) and isinstance(workspace.get("project"), dict), + isinstance(workspace, dict) + and isinstance(workspace.get("metadata"), dict) + and isinstance(workspace["metadata"].get("project"), dict), + isinstance(task_data_payload, dict) and "project_id" in task_data_payload, + ) return "" From 2b3e52d8587120b306f673b32766583e45691757 Mon Sep 17 00:00:00 2001 From: yunpeng7 Date: Tue, 16 Jun 2026 23:28:54 +0800 Subject: [PATCH 07/12] fix(executor): merge process claude headers for project tasks --- .../local-device-architecture.md | 2 +- .../local-device-architecture.md | 2 +- executor/agents/api_headers.py | 13 ++++++++ .../agents/claude_code/local_mode_strategy.py | 21 +++++++++++-- executor/tests/agents/test_mode_strategy.py | 30 +++++++++++++++++++ 5 files changed, 64 insertions(+), 4 deletions(-) diff --git a/docs/en/developer-guide/local-device-architecture.md b/docs/en/developer-guide/local-device-architecture.md index 0a66c07902..3e0cff4938 100644 --- a/docs/en/developer-guide/local-device-architecture.md +++ b/docs/en/developer-guide/local-device-architecture.md @@ -202,7 +202,7 @@ 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. Claude Code local mode writes the merged header map to both `ANTHROPIC_CUSTOM_HEADERS` and `DEFAULT_HEADERS`/`default_headers` so the Claude Code child process and downstream model gateways see the same project identity. 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. +When project mode calls Claude or Codex model APIs, the executor adds a `wecode-project: ` request header in the directly launched runtime context. Claude Code local mode first merges existing `ANTHROPIC_CUSTOM_HEADERS` from config, 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. --- diff --git a/docs/zh/developer-guide/local-device-architecture.md b/docs/zh/developer-guide/local-device-architecture.md index ccc142cbfc..c8f093f59a 100644 --- a/docs/zh/developer-guide/local-device-architecture.md +++ b/docs/zh/developer-guide/local-device-architecture.md @@ -202,7 +202,7 @@ 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: ` 请求头。Claude Code 本地模式会同时写入 `ANTHROPIC_CUSTOM_HEADERS` 与 `DEFAULT_HEADERS`/`default_headers` 环境变量,保证直接 Claude Code 子进程和下游模型网关读取到一致的 project 标识;Codex 在 Wegent 管理 provider 配置时写入 provider 的 `http_headers`,使用个人 Codex 配置且显式指定 provider 时也会对该 provider 注入同一 project 请求头。 +项目模式下访问 Claude 或 Codex 模型 API 时,executor 会在直接启动的运行时上下文中加入 `wecode-project: ` 请求头。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 index d7490f17bc..630e0a477f 100644 --- a/executor/agents/api_headers.py +++ b/executor/agents/api_headers.py @@ -64,6 +64,19 @@ def merge_anthropic_header_map( 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: diff --git a/executor/agents/claude_code/local_mode_strategy.py b/executor/agents/claude_code/local_mode_strategy.py index 2ca9c6ea16..fd45d9db29 100644 --- a/executor/agents/claude_code/local_mode_strategy.py +++ b/executor/agents/claude_code/local_mode_strategy.py @@ -29,6 +29,7 @@ from executor.agents.api_headers import ( DEFAULT_HEADERS_ENV_KEYS, extract_default_headers, + merge_anthropic_custom_headers, merge_anthropic_header_map, merge_project_header, ) @@ -215,9 +216,25 @@ def configure_client_options( env["CLAUDE_CONFIG_DIR"] = config_dir env["SKILLS_DIR"] = self.get_skills_directory(config_dir) - custom_headers = config.ANTHROPIC_CUSTOM_HEADERS or env.get( - "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( + config.ANTHROPIC_CUSTOM_HEADERS, + process_custom_headers, + runtime_custom_headers, ) + if ( + config.ANTHROPIC_CUSTOM_HEADERS + or process_custom_headers + or runtime_custom_headers + ): + logger.info( + "Local mode: ANTHROPIC_CUSTOM_HEADERS source keys " + "config=%s process=%s runtime=%s", + _header_line_keys(config.ANTHROPIC_CUSTOM_HEADERS), + _header_line_keys(process_custom_headers), + _header_line_keys(runtime_custom_headers), + ) default_headers = extract_default_headers(merged_env) original_default_header_keys = sorted(default_headers) if self._use_global_capabilities: diff --git a/executor/tests/agents/test_mode_strategy.py b/executor/tests/agents/test_mode_strategy.py index 4fbfa40dee..cca2cb4c06 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.""" @@ -470,6 +475,31 @@ def test_configure_client_options_appends_project_to_startup_headers( "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" + + with patch("executor.config.config.ANTHROPIC_CUSTOM_HEADERS", ""): + 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 ): From 388b0eb56b3b6791241ed53798016628d91839f3 Mon Sep 17 00:00:00 2001 From: yunpeng7 Date: Tue, 16 Jun 2026 23:36:55 +0800 Subject: [PATCH 08/12] chore(executor): remove project header debug logs --- .../agents/claude_code/claude_code_agent.py | 17 +----- .../agents/claude_code/local_mode_strategy.py | 54 ------------------- executor/agents/codex/config_builder.py | 18 ------- executor/modes/local/capabilities.py | 37 ------------- 4 files changed, 2 insertions(+), 124 deletions(-) diff --git a/executor/agents/claude_code/claude_code_agent.py b/executor/agents/claude_code/claude_code_agent.py index 2866a93704..6667508ef3 100644 --- a/executor/agents/claude_code/claude_code_agent.py +++ b/executor/agents/claude_code/claude_code_agent.py @@ -207,22 +207,9 @@ def __init__( is_project_task, ) - project_task = is_project_task(task_data) - project_id = get_project_id(task_data) - logger.info( - "Claude Code project header context: task_id=%s " - "project_task=%s project_id=%s workspace_source=%s " - "project_workspace_path_set=%s standalone_chat_workspace=%s", - getattr(task_data, "task_id", None), - project_task, - project_id or "", - getattr(task_data, "workspace_source", None), - bool(getattr(task_data, "project_workspace_path", None)), - bool(getattr(task_data, "standalone_chat_workspace", False)), - ) self._mode_strategy.use_global_capabilities( - project_task, - project_id=project_id, + is_project_task(task_data), + project_id=get_project_id(task_data), ) # Note: emitter is created in base class Agent.__init__() diff --git a/executor/agents/claude_code/local_mode_strategy.py b/executor/agents/claude_code/local_mode_strategy.py index fd45d9db29..741bd4c41a 100644 --- a/executor/agents/claude_code/local_mode_strategy.py +++ b/executor/agents/claude_code/local_mode_strategy.py @@ -41,18 +41,6 @@ logger = setup_logger("local_mode_strategy") -def _header_line_keys(headers: str) -> list[str]: - """Return header keys from Claude custom header lines for diagnostics.""" - keys: list[str] = [] - for line in headers.splitlines(): - if ":" not in line: - continue - key = line.split(":", 1)[0].strip() - if key: - keys.append(key) - return sorted(keys) - - class LocalModeStrategy(ExecutionModeStrategy): """Strategy for local (non-Docker) execution mode. @@ -72,11 +60,6 @@ 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 - logger.info( - "Local mode: global capabilities configured enabled=%s project_id=%s", - self._use_global_capabilities, - self._project_id or "", - ) def get_config_directory(self, task_id: int) -> str: """Get the Claude configuration directory. @@ -223,20 +206,7 @@ def configure_client_options( process_custom_headers, runtime_custom_headers, ) - if ( - config.ANTHROPIC_CUSTOM_HEADERS - or process_custom_headers - or runtime_custom_headers - ): - logger.info( - "Local mode: ANTHROPIC_CUSTOM_HEADERS source keys " - "config=%s process=%s runtime=%s", - _header_line_keys(config.ANTHROPIC_CUSTOM_HEADERS), - _header_line_keys(process_custom_headers), - _header_line_keys(runtime_custom_headers), - ) default_headers = extract_default_headers(merged_env) - original_default_header_keys = sorted(default_headers) if self._use_global_capabilities: default_headers = merge_project_header(default_headers, self._project_id) if default_headers: @@ -251,35 +221,11 @@ def configure_client_options( custom_headers, default_headers, ) - logger.info( - "Local mode: API default headers configured " - "original_keys=%s final_keys=%s project_id=%s", - original_default_header_keys, - sorted(default_headers), - self._project_id or "", - ) # Add ANTHROPIC_CUSTOM_HEADERS if configured via environment variable or # project execution source metadata. if custom_headers: env["ANTHROPIC_CUSTOM_HEADERS"] = custom_headers - logger.info( - "Local mode: ANTHROPIC_CUSTOM_HEADERS configured keys=%s", - _header_line_keys(custom_headers), - ) - - logger.info( - "Local mode: Claude runtime env header summary " - "global_capabilities=%s project_id=%s has_default_headers=%s " - "has_anthropic_custom_headers=%s anthropic_base_url=%s " - "claude_config_dir=%s", - self._use_global_capabilities, - self._project_id or "", - bool(default_headers), - bool(env.get("ANTHROPIC_CUSTOM_HEADERS")), - env.get("ANTHROPIC_BASE_URL") or "", - env.get("CLAUDE_CONFIG_DIR"), - ) updated_options["env"] = env diff --git a/executor/agents/codex/config_builder.py b/executor/agents/codex/config_builder.py index 06573f9143..8a1b9cd40f 100644 --- a/executor/agents/codex/config_builder.py +++ b/executor/agents/codex/config_builder.py @@ -111,15 +111,6 @@ def build_codex_config( if model_provider else () ) - logger.info( - "Codex runtime header context: local_config=%s model_provider=%s " - "project_id=%s header_override_count=%s proxy_enabled=%s", - local_config, - model_provider or "", - str(project_id).strip() if project_id is not None else "", - len(header_overrides), - bool(proxy_env), - ) overrides = [f"model={model}", *header_overrides, *mcp_overrides] return CodeXConfig( codex_bin=_resolve_codex_binary(config.CODEX_BINARY_PATH), @@ -149,15 +140,6 @@ def build_codex_config( model_config.get("default_headers"), project_id, ) - logger.info( - "Codex runtime header context: local_config=%s model_provider=%s " - "project_id=%s header_override_count=%s base_url=%s", - local_config, - model_provider, - str(project_id).strip() if project_id is not None else "", - len(header_overrides), - base_url.rstrip("/"), - ) overrides = [ "forced_login_method=api", diff --git a/executor/modes/local/capabilities.py b/executor/modes/local/capabilities.py index 87f78f3707..b145f1d734 100644 --- a/executor/modes/local/capabilities.py +++ b/executor/modes/local/capabilities.py @@ -94,12 +94,6 @@ def get_project_id(task_data: ExecutionRequest) -> str: project_id = getattr(task_data, "project_id", None) normalized_project_id = _normalize_project_id(project_id) if normalized_project_id: - logger.info( - "Resolved project id for local execution: source=task_data.project_id " - "task_id=%s project_id=%s", - getattr(task_data, "task_id", None), - normalized_project_id, - ) return normalized_project_id workspace = getattr(task_data, "workspace", None) @@ -108,12 +102,6 @@ def get_project_id(task_data: ExecutionRequest) -> str: if isinstance(project, dict): normalized_project_id = _normalize_project_id(project.get("project_id")) if normalized_project_id: - logger.info( - "Resolved project id for local execution: " - "source=workspace.project.project_id task_id=%s project_id=%s", - getattr(task_data, "task_id", None), - normalized_project_id, - ) return normalized_project_id metadata = workspace.get("metadata") or {} @@ -125,13 +113,6 @@ def get_project_id(task_data: ExecutionRequest) -> str: metadata_project.get("project_id") ) if normalized_project_id: - logger.info( - "Resolved project id for local execution: " - "source=workspace.metadata.project.project_id " - "task_id=%s project_id=%s", - getattr(task_data, "task_id", None), - normalized_project_id, - ) return normalized_project_id task_data_payload = getattr(task_data, "task_data", None) @@ -140,26 +121,8 @@ def get_project_id(task_data: ExecutionRequest) -> str: task_data_payload.get("project_id") ) if normalized_project_id: - logger.info( - "Resolved project id for local execution: " - "source=task_data.task_data.project_id task_id=%s project_id=%s", - getattr(task_data, "task_id", None), - normalized_project_id, - ) return normalized_project_id - logger.info( - "Project id not found for local execution: task_id=%s " - "top_level_project_id=%s workspace_has_project=%s " - "workspace_has_metadata_project=%s task_data_payload_has_project_id=%s", - getattr(task_data, "task_id", None), - project_id if project_id is not None else "", - isinstance(workspace, dict) and isinstance(workspace.get("project"), dict), - isinstance(workspace, dict) - and isinstance(workspace.get("metadata"), dict) - and isinstance(workspace["metadata"].get("project"), dict), - isinstance(task_data_payload, dict) and "project_id" in task_data_payload, - ) return "" From 536b04835f43f041923833dc8a8998fe7d3bbd13 Mon Sep 17 00:00:00 2001 From: yunpeng7 Date: Wed, 17 Jun 2026 10:39:32 +0800 Subject: [PATCH 09/12] chore(shared): drop project metadata from openai converter --- shared/models/openai_converter.py | 12 ------------ shared/tests/test_openai_converter.py | 23 ----------------------- 2 files changed, 35 deletions(-) diff --git a/shared/models/openai_converter.py b/shared/models/openai_converter.py index 0a5ae60396..c5653d2d16 100644 --- a/shared/models/openai_converter.py +++ b/shared/models/openai_converter.py @@ -219,12 +219,6 @@ def from_execution_request(request: ExecutionRequest) -> dict[str, Any]: "skill_identity_token": request.skill_identity_token, "backend_url": request.backend_url, "workspace": request.workspace, - "project_id": request.project_id, - "standalone_chat_workspace": request.standalone_chat_workspace, - "workspace_source": request.workspace_source, - "project_workspace_path": request.project_workspace_path, - "execution_target_type": request.execution_target_type, - "device_id": request.device_id, "is_subscription": request.is_subscription, "request_id": request.request_id, "executor_name": request.executor_name, @@ -387,12 +381,6 @@ def to_execution_request(openai_request: dict[str, Any]) -> ExecutionRequest: skill_identity_token=metadata.get("skill_identity_token", ""), backend_url=metadata.get("backend_url", ""), workspace=metadata.get("workspace", {}), - project_id=metadata.get("project_id"), - standalone_chat_workspace=metadata.get("standalone_chat_workspace", False), - workspace_source=metadata.get("workspace_source"), - project_workspace_path=metadata.get("project_workspace_path"), - execution_target_type=metadata.get("execution_target_type"), - device_id=metadata.get("device_id"), is_subscription=metadata.get("is_subscription", False), request_id=metadata.get("request_id", ""), executor_name=metadata.get("executor_name"), diff --git a/shared/tests/test_openai_converter.py b/shared/tests/test_openai_converter.py index c893a122dd..87fa89ec49 100644 --- a/shared/tests/test_openai_converter.py +++ b/shared/tests/test_openai_converter.py @@ -95,29 +95,6 @@ def test_round_trip_preserves_skip_git_clone_for_archive_recovery(): assert converted.skip_git_clone is True -def test_round_trip_preserves_project_workspace_metadata(): - request = ExecutionRequest( - workspace={"project": {"project_id": 42}}, - project_id=42, - standalone_chat_workspace=True, - workspace_source="local_path", - project_workspace_path="/Users/test/project", - execution_target_type="local", - device_id="device-1", - ) - - openai_request = OpenAIRequestConverter.from_execution_request(request) - converted = OpenAIRequestConverter.to_execution_request(openai_request) - - assert openai_request["metadata"]["project_id"] == 42 - assert converted.project_id == 42 - assert converted.standalone_chat_workspace is True - assert converted.workspace_source == "local_path" - assert converted.project_workspace_path == "/Users/test/project" - assert converted.execution_target_type == "local" - assert converted.device_id == "device-1" - - def test_to_execution_request_preserves_message_history_and_stateless_flag(): openai_request = { "model": "test-model", From f964255cba249809ccd4b27fd0df8f4cfbe063e5 Mon Sep 17 00:00:00 2001 From: yunpeng7 Date: Wed, 17 Jun 2026 10:45:04 +0800 Subject: [PATCH 10/12] chore(executor): remove redundant custom header config --- .../local-device-architecture.md | 2 +- .../local-device-architecture.md | 2 +- .../agents/claude_code/local_mode_strategy.py | 1 - executor/config/config.py | 5 -- executor/tests/agents/test_mode_strategy.py | 61 +++++-------------- 5 files changed, 18 insertions(+), 53 deletions(-) diff --git a/docs/en/developer-guide/local-device-architecture.md b/docs/en/developer-guide/local-device-architecture.md index 3e0cff4938..0e66fdb890 100644 --- a/docs/en/developer-guide/local-device-architecture.md +++ b/docs/en/developer-guide/local-device-architecture.md @@ -202,7 +202,7 @@ 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. Claude Code local mode first merges existing `ANTHROPIC_CUSTOM_HEADERS` from config, 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. +When project mode calls Claude or Codex model APIs, the executor adds a `wecode-project: ` request header in the directly launched runtime context. 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. --- diff --git a/docs/zh/developer-guide/local-device-architecture.md b/docs/zh/developer-guide/local-device-architecture.md index c8f093f59a..a7493c5bf4 100644 --- a/docs/zh/developer-guide/local-device-architecture.md +++ b/docs/zh/developer-guide/local-device-architecture.md @@ -202,7 +202,7 @@ 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: ` 请求头。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 请求头。 +项目模式下访问 Claude 或 Codex 模型 API 时,executor 会在直接启动的运行时上下文中加入 `wecode-project: ` 请求头。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/claude_code/local_mode_strategy.py b/executor/agents/claude_code/local_mode_strategy.py index 741bd4c41a..7d46d74c6d 100644 --- a/executor/agents/claude_code/local_mode_strategy.py +++ b/executor/agents/claude_code/local_mode_strategy.py @@ -202,7 +202,6 @@ def configure_client_options( process_custom_headers = os.environ.get("ANTHROPIC_CUSTOM_HEADERS", "") runtime_custom_headers = env.get("ANTHROPIC_CUSTOM_HEADERS", "") custom_headers = merge_anthropic_custom_headers( - config.ANTHROPIC_CUSTOM_HEADERS, process_custom_headers, runtime_custom_headers, ) 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/tests/agents/test_mode_strategy.py b/executor/tests/agents/test_mode_strategy.py index cca2cb4c06..e894d94a6b 100644 --- a/executor/tests/agents/test_mode_strategy.py +++ b/executor/tests/agents/test_mode_strategy.py @@ -427,8 +427,7 @@ def test_configure_client_options_adds_project_header_for_project_tasks( 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 result["env"]["ANTHROPIC_CUSTOM_HEADERS"] == "wecode-project: 42" @@ -440,41 +439,14 @@ def test_configure_client_options_preserves_custom_headers_for_project_tasks( monkeypatch.setenv("HOME", str(tmp_path)) options = {"cwd": "/workspace"} config_dir = "/workspace/12345/.claude" - custom_headers = "x-custom-user: test" + env_config = {"ANTHROPIC_CUSTOM_HEADERS": "x-custom-user: test"} - 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, env_config, {}) assert result["env"]["ANTHROPIC_CUSTOM_HEADERS"] == ( "x-custom-user: test\nwecode-project: 42" ) - def test_configure_client_options_appends_project_to_startup_headers( - self, strategy, tmp_path, monkeypatch - ): - """Project tasks should append project ID to executor startup headers.""" - strategy.use_global_capabilities(True, project_id=42) - monkeypatch.setenv("HOME", str(tmp_path)) - options = {"cwd": "/workspace"} - config_dir = "/workspace/12345/.claude" - custom_headers = ( - "wecode-source: wegent-local\n" - "wecode-action: wegent\n" - "wecode-executor: claudecode\n" - "wecode-user: yunpeng7" - ) - - with patch("executor.config.config.ANTHROPIC_CUSTOM_HEADERS", custom_headers): - 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-user: yunpeng7\n" - "wecode-project: 42" - ) - def test_configure_client_options_appends_project_to_process_headers( self, strategy, tmp_path, monkeypatch ): @@ -490,8 +462,7 @@ def test_configure_client_options_appends_project_to_process_headers( 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 result["env"]["ANTHROPIC_CUSTOM_HEADERS"] == ( "wecode-source: wegent-local\n" @@ -516,13 +487,12 @@ def test_configure_client_options_preserves_default_source_header_for_project_ta } } - with patch("executor.config.config.ANTHROPIC_CUSTOM_HEADERS", ""): - result = strategy.configure_client_options( - options, - config_dir, - env_config, - {}, - ) + result = strategy.configure_client_options( + options, + config_dir, + env_config, + {}, + ) assert result["env"]["ANTHROPIC_CUSTOM_HEADERS"] == ( "wecode-action: wecode-cli\n" @@ -538,14 +508,16 @@ def test_configure_client_options_preserves_default_source_header_for_project_ta } assert result["env"]["default_headers"] == result["env"]["DEFAULT_HEADERS"] - def test_configure_client_options_adds_anthropic_custom_headers(self, strategy): + 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 @@ -554,8 +526,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"] From 7aba82b493925783bda89979e28fc5b2dc76ce52 Mon Sep 17 00:00:00 2001 From: yunpeng7 Date: Wed, 17 Jun 2026 10:53:05 +0800 Subject: [PATCH 11/12] fix(executor): fill runtime source headers --- .../local-device-architecture.md | 2 +- .../local-device-architecture.md | 2 +- executor/agents/api_headers.py | 46 +++++++++++++++++++ .../agents/claude_code/local_mode_strategy.py | 9 +++- executor/agents/codex/config_builder.py | 13 +++++- .../tests/agents/test_codex_config_builder.py | 23 ++++++++++ executor/tests/agents/test_mode_strategy.py | 15 +++++- .../agents/test_project_workspace_paths.py | 4 ++ 8 files changed, 106 insertions(+), 8 deletions(-) diff --git a/docs/en/developer-guide/local-device-architecture.md b/docs/en/developer-guide/local-device-architecture.md index 0e66fdb890..8c9268e854 100644 --- a/docs/en/developer-guide/local-device-architecture.md +++ b/docs/en/developer-guide/local-device-architecture.md @@ -202,7 +202,7 @@ 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. 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. +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. --- diff --git a/docs/zh/developer-guide/local-device-architecture.md b/docs/zh/developer-guide/local-device-architecture.md index a7493c5bf4..fac3368fbe 100644 --- a/docs/zh/developer-guide/local-device-architecture.md +++ b/docs/zh/developer-guide/local-device-architecture.md @@ -202,7 +202,7 @@ 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: ` 请求头。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 请求头。 +项目模式下访问 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 index 630e0a477f..44a68bdad4 100644 --- a/executor/agents/api_headers.py +++ b/executor/agents/api_headers.py @@ -11,6 +11,11 @@ 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") @@ -29,6 +34,36 @@ def merge_project_header( ) +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], @@ -64,6 +99,17 @@ def merge_anthropic_header_map( 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] = {} diff --git a/executor/agents/claude_code/local_mode_strategy.py b/executor/agents/claude_code/local_mode_strategy.py index 7d46d74c6d..efe50e87e9 100644 --- a/executor/agents/claude_code/local_mode_strategy.py +++ b/executor/agents/claude_code/local_mode_strategy.py @@ -30,8 +30,9 @@ DEFAULT_HEADERS_ENV_KEYS, extract_default_headers, merge_anthropic_custom_headers, - merge_anthropic_header_map, + 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 @@ -207,6 +208,10 @@ def configure_client_options( ) 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( @@ -216,7 +221,7 @@ def configure_client_options( ) for default_headers_key in DEFAULT_HEADERS_ENV_KEYS: env[default_headers_key] = serialized_default_headers - custom_headers = merge_anthropic_header_map( + custom_headers = merge_anthropic_header_defaults( custom_headers, default_headers, ) diff --git a/executor/agents/codex/config_builder.py b/executor/agents/codex/config_builder.py index 8a1b9cd40f..dc29a230ac 100644 --- a/executor/agents/codex/config_builder.py +++ b/executor/agents/codex/config_builder.py @@ -13,7 +13,10 @@ from dataclasses import dataclass from typing import Any, Optional -from executor.agents.api_headers import merge_project_header +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 @@ -281,7 +284,13 @@ def _build_header_overrides( default_headers: Any, project_id: Any, ) -> tuple[str, ...]: - headers = merge_project_header(default_headers, project_id) + 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( diff --git a/executor/tests/agents/test_codex_config_builder.py b/executor/tests/agents/test_codex_config_builder.py index 814b246ed4..892acd450a 100644 --- a/executor/tests/agents/test_codex_config_builder.py +++ b/executor/tests/agents/test_codex_config_builder.py @@ -80,6 +80,18 @@ def test_build_codex_config_adds_project_header_when_requested(): '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_preserves_default_source_header_when_project_requested(): @@ -115,6 +127,10 @@ def test_build_codex_config_preserves_default_source_header_when_project_request '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(): @@ -129,6 +145,9 @@ def test_build_codex_config_omits_project_header_by_default(): ) 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): @@ -195,6 +214,10 @@ def test_build_codex_config_adds_project_header_to_user_runtime_provider(): '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): diff --git a/executor/tests/agents/test_mode_strategy.py b/executor/tests/agents/test_mode_strategy.py index e894d94a6b..9d4a8f382c 100644 --- a/executor/tests/agents/test_mode_strategy.py +++ b/executor/tests/agents/test_mode_strategy.py @@ -429,7 +429,12 @@ def test_configure_client_options_adds_project_header_for_project_tasks( result = strategy.configure_client_options(options, config_dir, {}, {}) - assert result["env"]["ANTHROPIC_CUSTOM_HEADERS"] == "wecode-project: 42" + 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_preserves_custom_headers_for_project_tasks( self, strategy, tmp_path, monkeypatch @@ -444,7 +449,11 @@ def test_configure_client_options_preserves_custom_headers_for_project_tasks( result = strategy.configure_client_options(options, config_dir, env_config, {}) assert result["env"]["ANTHROPIC_CUSTOM_HEADERS"] == ( - "x-custom-user: test\nwecode-project: 42" + "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( @@ -498,12 +507,14 @@ def test_configure_client_options_preserves_default_source_header_for_project_ta "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"] diff --git a/executor/tests/agents/test_project_workspace_paths.py b/executor/tests/agents/test_project_workspace_paths.py index b3bac28afa..e210ad14f3 100644 --- a/executor/tests/agents/test_project_workspace_paths.py +++ b/executor/tests/agents/test_project_workspace_paths.py @@ -229,6 +229,10 @@ def test_codex_project_task_marks_api_project_id(): '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 From aae7570eb794c38bd51a2ce613481dbbd7104973 Mon Sep 17 00:00:00 2001 From: yunpeng7 Date: Wed, 17 Jun 2026 11:06:41 +0800 Subject: [PATCH 12/12] fix(executor): treat project zero as project --- executor/agents/api_headers.py | 2 +- executor/modes/local/capabilities.py | 5 ----- .../tests/agents/test_codex_config_builder.py | 22 +++++++++++++++++++ executor/tests/agents/test_mode_strategy.py | 18 +++++++++++++++ .../modes/local/test_global_capabilities.py | 5 +++-- 5 files changed, 44 insertions(+), 8 deletions(-) diff --git a/executor/agents/api_headers.py b/executor/agents/api_headers.py index 44a68bdad4..4e12ce2f7c 100644 --- a/executor/agents/api_headers.py +++ b/executor/agents/api_headers.py @@ -181,6 +181,6 @@ def _normalize_project_id(project_id: Any) -> str: if project_id is None: return "" value = str(project_id).strip() - if not value or value == "0": + if not value: return "" return value diff --git a/executor/modes/local/capabilities.py b/executor/modes/local/capabilities.py index b145f1d734..30b6a7a851 100644 --- a/executor/modes/local/capabilities.py +++ b/executor/modes/local/capabilities.py @@ -132,11 +132,6 @@ def _normalize_project_id(project_id: Any) -> str: value = str(project_id).strip() if not value: return "" - try: - if int(value) <= 0: - return "" - except ValueError: - return value return value diff --git a/executor/tests/agents/test_codex_config_builder.py b/executor/tests/agents/test_codex_config_builder.py index 892acd450a..2f3c5c7326 100644 --- a/executor/tests/agents/test_codex_config_builder.py +++ b/executor/tests/agents/test_codex_config_builder.py @@ -94,6 +94,28 @@ def test_build_codex_config_adds_project_header_when_requested(): ) +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( { diff --git a/executor/tests/agents/test_mode_strategy.py b/executor/tests/agents/test_mode_strategy.py index 9d4a8f382c..b0efd6ff62 100644 --- a/executor/tests/agents/test_mode_strategy.py +++ b/executor/tests/agents/test_mode_strategy.py @@ -436,6 +436,24 @@ def test_configure_client_options_adds_project_header_for_project_tasks( "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 ): diff --git a/executor/tests/modes/local/test_global_capabilities.py b/executor/tests/modes/local/test_global_capabilities.py index 67872c2830..e31d41366e 100644 --- a/executor/tests/modes/local/test_global_capabilities.py +++ b/executor/tests/modes/local/test_global_capabilities.py @@ -26,13 +26,14 @@ 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():