Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/en/developer-guide/local-device-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <project_id>` request header in the directly launched runtime context and fills source identity headers: `wecode-action: wegent`, `wecode-source: wegent-local`, and `wecode-executor: <runtime>`, where Claude Code uses `claudecode` and Codex uses `codex`. Claude Code local mode first merges existing `ANTHROPIC_CUSTOM_HEADERS` from the executor startup process environment and the runtime environment, then appends the project identity and writes the resulting header set to both `ANTHROPIC_CUSTOM_HEADERS` and `DEFAULT_HEADERS`/`default_headers`. This keeps the Claude Code child process and downstream model gateways on the same header set. Codex writes the header into provider `http_headers` for Wegent-managed provider configs, and also injects it for personal Codex config runs when the execution model explicitly names the provider.

---

## 🔄 Task Execution Flow
Expand Down
2 changes: 2 additions & 0 deletions docs/zh/developer-guide/local-device-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <project_id>` 请求头,并补齐 `wecode-action: wegent`、`wecode-source: wegent-local`、`wecode-executor: <runtime>` 来源标识,其中 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 请求头。

---

## 🔄 任务执行流程
Expand Down
186 changes: 186 additions & 0 deletions executor/agents/api_headers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
#!/usr/bin/env python

# SPDX-FileCopyrightText: 2025 Weibo, Inc.
#
# SPDX-License-Identifier: Apache-2.0

"""Utilities for model API request headers."""

import json
from collections.abc import Mapping
from typing import Any

WECODE_PROJECT_HEADER = "wecode-project"
WECODE_ACTION_HEADER = "wecode-action"
WECODE_SOURCE_HEADER = "wecode-source"
WECODE_EXECUTOR_HEADER = "wecode-executor"
WECODE_ACTION_VALUE = "wegent"
WECODE_SOURCE_VALUE = "wegent-local"
DEFAULT_HEADERS_ENV_KEYS = ("DEFAULT_HEADERS", "default_headers")


def merge_project_header(
headers: Mapping[str, Any] | str | None,
project_id: Any,
) -> dict[str, str]:
"""Return headers with the Wegent project header merged."""
parsed_headers = _parse_header_map(headers)
normalized_project_id = _normalize_project_id(project_id)
if not normalized_project_id:
return parsed_headers

return merge_header_map(
parsed_headers, {WECODE_PROJECT_HEADER: normalized_project_id}
)


def merge_wegent_runtime_headers(
headers: Mapping[str, Any] | str | None,
executor: str,
) -> dict[str, str]:
"""Return headers with Wegent runtime source headers filled in."""
return merge_missing_header_map(
_parse_header_map(headers),
{
WECODE_ACTION_HEADER: WECODE_ACTION_VALUE,
WECODE_SOURCE_HEADER: WECODE_SOURCE_VALUE,
WECODE_EXECUTOR_HEADER: executor,
},
)


def merge_missing_header_map(
existing_headers: Mapping[str, Any] | str | None,
default_headers: Mapping[str, Any],
) -> dict[str, str]:
"""Merge only headers whose keys are missing from the existing header map."""
result = _parse_header_map(existing_headers)
existing_keys = {key.lower() for key in result}
for key, value in default_headers.items():
if value is None or str(key).lower() in existing_keys:
continue
result[str(key)] = str(value)
existing_keys.add(str(key).lower())
return result


def merge_header_map(
existing_headers: Mapping[str, Any] | str | None,
new_headers: Mapping[str, Any],
) -> dict[str, str]:
"""Merge header maps case-insensitively while preserving insertion order."""
result = _parse_header_map(existing_headers)
for key, value in new_headers.items():
if value is None:
continue
normalized_key = str(key).lower()
result = {
existing_key: existing_value
for existing_key, existing_value in result.items()
if existing_key.lower() != normalized_key
}
result[str(key)] = str(value)
return result


def headers_to_anthropic_custom_headers(headers: Mapping[str, Any]) -> str:
"""Format a header map for Claude Code's ANTHROPIC_CUSTOM_HEADERS env var."""
return "\n".join(f"{key}: {value}" for key, value in headers.items())


def merge_anthropic_header_map(
existing_headers: str,
headers: Mapping[str, Any],
) -> str:
"""Return Anthropic custom headers with a header map merged."""
merged = merge_header_map(
_parse_anthropic_custom_headers(existing_headers), headers
)
return headers_to_anthropic_custom_headers(merged)


def merge_anthropic_header_defaults(
existing_headers: str,
default_headers: Mapping[str, Any],
) -> str:
"""Return Anthropic custom headers with missing defaults filled in."""
merged = merge_missing_header_map(
_parse_anthropic_custom_headers(existing_headers), default_headers
)
return headers_to_anthropic_custom_headers(merged)


def merge_anthropic_custom_headers(*header_sets: str | None) -> str:
"""Merge multiple ANTHROPIC_CUSTOM_HEADERS strings."""
merged: dict[str, str] = {}
for headers in header_sets:
if not headers:
continue
merged = merge_header_map(
merged,
_parse_anthropic_custom_headers(headers),
)
return headers_to_anthropic_custom_headers(merged)


def extract_default_headers(env: Mapping[str, Any]) -> dict[str, str]:
"""Extract DEFAULT_HEADERS/default_headers from an environment mapping."""
for key in DEFAULT_HEADERS_ENV_KEYS:
headers = _parse_header_map(env.get(key))
if headers:
return headers
return {}


def _parse_header_map(headers: Mapping[str, Any] | str | None) -> dict[str, str]:
if not headers:
return {}
if isinstance(headers, Mapping):
return {
str(key): str(value)
for key, value in headers.items()
if key is not None and value is not None
}
if isinstance(headers, str):
stripped = headers.strip()
if not stripped:
return {}
try:
parsed = json.loads(stripped)
except json.JSONDecodeError:
return _parse_header_lines(stripped)
if isinstance(parsed, Mapping):
return _parse_header_map(parsed)
return {}


def _parse_anthropic_custom_headers(headers: str) -> dict[str, str]:
if not headers:
return {}
parsed_json = _parse_header_map(headers)
if parsed_json:
return parsed_json
return _parse_header_lines(headers)


def _parse_header_lines(headers: str) -> dict[str, str]:
parsed: dict[str, str] = {}
for line in headers.splitlines():
stripped = line.strip()
if not stripped or ":" not in stripped:
continue
key, value = stripped.split(":", 1)
key = key.strip()
if not key:
continue
parsed[key] = value.strip()
return parsed


def _normalize_project_id(project_id: Any) -> str:
if project_id is None:
return ""
value = str(project_id).strip()
if not value:
return ""
return value
Comment on lines +180 to +186

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Unify project-id normalization semantics with capability resolution.

This helper accepts -1/00 as valid IDs, while the resolver in executor/modes/local/capabilities.py rejects numeric IDs <= 0. That mismatch can produce inconsistent wecode-project header injection when callers pass raw IDs.

Suggested fix
 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 ""
+    try:
+        if int(value) <= 0:
+            return ""
+    except ValueError:
+        pass
     return value
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
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:
pass
return value
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@executor/agents/api_headers.py` around lines 134 - 140, The
_normalize_project_id function in executor/agents/api_headers.py currently
accepts negative IDs and zero, while the capability resolver in
executor/modes/local/capabilities.py rejects numeric IDs <= 0. Update
_normalize_project_id to convert the string value to a numeric check and return
an empty string for any numeric ID that is less than or equal to zero, ensuring
consistency between the two normalization paths and preventing mismatched
wecode-project header injection.

10 changes: 8 additions & 2 deletions executor/agents/claude_code/claude_code_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,9 +204,15 @@ def __init__(
# Initialize execution mode strategy
self._mode_strategy: ExecutionModeStrategy = ModeStrategyFactory.create()
if hasattr(self._mode_strategy, "use_global_capabilities"):
from executor.modes.local.capabilities import is_project_task
from executor.modes.local.capabilities import (
get_project_id,
is_project_task,
)

self._mode_strategy.use_global_capabilities(is_project_task(task_data))
self._mode_strategy.use_global_capabilities(
is_project_task(task_data),
project_id=get_project_id(task_data),
)

# Note: emitter is created in base class Agent.__init__()
# using EmitterBuilder with CallbackTransport
Expand Down
51 changes: 42 additions & 9 deletions executor/agents/claude_code/local_mode_strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@
from pathlib import Path
from typing import Any, Dict, Tuple

from executor.agents.api_headers import (
DEFAULT_HEADERS_ENV_KEYS,
extract_default_headers,
merge_anthropic_custom_headers,
merge_anthropic_header_defaults,
merge_project_header,
merge_wegent_runtime_headers,
)
from executor.agents.claude_code.mode_strategy import ExecutionModeStrategy
from executor.config import config
from executor.platform_compat import (
Expand All @@ -50,10 +58,12 @@ class LocalModeStrategy(ExecutionModeStrategy):

def __init__(self) -> None:
self._use_global_capabilities = False
self._project_id: Any = None

def use_global_capabilities(self, enabled: bool) -> None:
def use_global_capabilities(self, enabled: bool, project_id: Any = None) -> None:
"""Enable global Claude capability reuse for project task execution."""
self._use_global_capabilities = enabled
self._project_id = project_id if enabled else None

def get_config_directory(self, task_id: int) -> str:
"""Get the Claude configuration directory.
Expand Down Expand Up @@ -181,25 +191,48 @@ def configure_client_options(
updated_options = options.copy()
existing_env = dict(updated_options.get("env", {}))
merged_env = {**existing_env, **env_config, **task_identity_env}
# Ensure all values are strings (required by SDK)
updated_options["env"] = {k: str(v) for k, v in merged_env.items()}

# Fix PyInstaller environment issues for child processes.
# See: https://pyinstaller.org/en/stable/common-issues-and-pitfalls.html
env = dict(updated_options.get("env", {}))
# Ensure all values are strings (required by SDK).
env = {k: str(v) for k, v in merged_env.items()}
sanitize_subprocess_environment(env)

# Project tasks use global Claude capability symlinks; non-project tasks
# keep their task-local Claude config directory.
env["CLAUDE_CONFIG_DIR"] = config_dir
env["SKILLS_DIR"] = self.get_skills_directory(config_dir)

# Add ANTHROPIC_CUSTOM_HEADERS if configured via environment variable
if config.ANTHROPIC_CUSTOM_HEADERS:
env["ANTHROPIC_CUSTOM_HEADERS"] = config.ANTHROPIC_CUSTOM_HEADERS
logger.info(
f"Local mode: ANTHROPIC_CUSTOM_HEADERS={config.ANTHROPIC_CUSTOM_HEADERS}"
process_custom_headers = os.environ.get("ANTHROPIC_CUSTOM_HEADERS", "")
runtime_custom_headers = env.get("ANTHROPIC_CUSTOM_HEADERS", "")
custom_headers = merge_anthropic_custom_headers(
process_custom_headers,
runtime_custom_headers,
)
default_headers = extract_default_headers(merged_env)
if self._use_global_capabilities:
default_headers = merge_wegent_runtime_headers(
default_headers,
executor="claudecode",
)
default_headers = merge_project_header(default_headers, self._project_id)
if default_headers:
serialized_default_headers = json.dumps(
default_headers,
ensure_ascii=True,
separators=(",", ":"),
)
for default_headers_key in DEFAULT_HEADERS_ENV_KEYS:
env[default_headers_key] = serialized_default_headers
custom_headers = merge_anthropic_header_defaults(
custom_headers,
default_headers,
)

# Add ANTHROPIC_CUSTOM_HEADERS if configured via environment variable or
# project execution source metadata.
if custom_headers:
env["ANTHROPIC_CUSTOM_HEADERS"] = custom_headers

updated_options["env"] = env

Expand Down
7 changes: 6 additions & 1 deletion executor/agents/codex/codex_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,12 @@ def __init__(

def initialize(self) -> TaskStatus:
try:
self.codex_config = build_codex_config(self.task_data.model_config)
from executor.modes.local.capabilities import get_project_id

self.codex_config = build_codex_config(
self.task_data.model_config,
project_id=get_project_id(self.task_data),
)
return TaskStatus.SUCCESS
except Exception as exc:
logger.exception("Failed to initialize CodeXAgent: %s", exc)
Expand Down
Loading
Loading