Skip to content
Open
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
14 changes: 13 additions & 1 deletion omnigent/claude_native_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
import threading
import time
import urllib.parse
from collections.abc import Awaitable, Callable
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass
from datetime import datetime
from http import HTTPStatus
Expand All @@ -62,6 +62,7 @@

from omnigent.llms.context_window import ModelPricing

from omnigent.harness_agent_settings import merge_claude_settings
from omnigent.inner.bundle_skills import claude_native_skill_args
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
from omnigent.inner.os_env import OSEnvironment, create_os_environment
Expand Down Expand Up @@ -1177,6 +1178,7 @@ def build_hook_settings(
launch_model: str | None = None,
launch_permission_mode: str | None = None,
launch_effort: str | None = None,
agent_settings: Mapping[str, object] | None = None,
) -> _JsonObject:
"""
Build invocation-local Claude Code hook settings.
Expand Down Expand Up @@ -1205,6 +1207,9 @@ def build_hook_settings(
for the same re-exec hardening.
:param launch_effort: Effective launch effort from ``--effort``.
Mirrored into ``effortLevel`` for restart/re-exec parity.
:param agent_settings: Optional agent-declared Claude
``settings.json`` fragment from
``executor.config.harness_settings``.
:returns: JSON-serializable Claude settings fragment.
"""
python = python_executable or sys.executable
Expand Down Expand Up @@ -1413,6 +1418,8 @@ def build_hook_settings(
if chain_command is not None:
status_parts.extend(["--chain", chain_command])
settings["statusLine"] = {"type": "command", "command": shlex.join(status_parts)}
if agent_settings:
return merge_claude_settings(agent_settings, settings)
return settings


Expand Down Expand Up @@ -1453,6 +1460,7 @@ def augment_claude_args(
skills_filter: str | list[str] = "all",
append_system_prompt: str | None = None,
allowed_tools: tuple[str, ...] = (),
agent_settings: Mapping[str, object] | None = None,
) -> list[str]:
"""
Return Claude CLI args with Omnigent MCP/hook/skill injection.
Expand Down Expand Up @@ -1489,6 +1497,9 @@ def augment_claude_args(
append through Claude Code's native ``--append-system-prompt`` flag.
:param allowed_tools: Optional narrowly scoped Claude tool names to merge
into ``--allowedTools`` without replacing the user's allowlist.
:param agent_settings: Optional agent-declared Claude
``settings.json`` fragment from
``executor.config.harness_settings``.
:returns: Augmented argument list for the terminal resource.
"""
mcp_config = build_mcp_config(bridge_dir, python_executable=python_executable)
Expand All @@ -1501,6 +1512,7 @@ def augment_claude_args(
launch_model=_arg_value(claude_args, "--model"),
launch_permission_mode=_arg_value(claude_args, "--permission-mode"),
launch_effort=_arg_value(claude_args, "--effort"),
agent_settings=agent_settings,
)
args = _merge_disallowed_tools(list(claude_args), _OMNIGENT_DISALLOWED_TOOLS)
args = _merge_allowed_tools(args, allowed_tools)
Expand Down
63 changes: 63 additions & 0 deletions omnigent/harness_agent_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Agent-declared harness settings extracted from :class:`AgentSpec`."""

from __future__ import annotations

import json
from collections.abc import Mapping
from typing import TypeAlias, cast

from omnigent.spec.types import AgentSpec

_Settings: TypeAlias = dict[str, object]

# Claude Code ``settings.json`` keys owned by Omnigent at launch time.
CLAUDE_FRAMEWORK_OWNED_KEYS: frozenset[str] = frozenset({"hooks", "statusLine", "apiKeyHelper"})


def harness_settings_from_spec(spec: AgentSpec) -> _Settings | None:
"""Return agent-declared harness settings from ``executor.config``."""
raw = spec.executor.config.get("harness_settings")
if not isinstance(raw, dict):
return None
return cast(_Settings, raw)


def serialize_harness_settings(settings: Mapping[str, object]) -> str:
"""JSON-encode harness settings for spawn-env threading."""
return json.dumps(settings, separators=(",", ":"))


def _deep_merge_settings(base: Mapping[str, object], overlay: Mapping[str, object]) -> _Settings:
merged = dict(base)
for key, value in overlay.items():
existing = merged.get(key)
if isinstance(existing, dict) and isinstance(value, dict):
merged[key] = _deep_merge_settings(existing, value)
else:
merged[key] = value
return merged


def merge_claude_settings(
agent_settings: Mapping[str, object] | None,
framework_settings: Mapping[str, object],
) -> _Settings:
"""
Merge agent-declared Claude settings under Omnigent framework keys.

Agent settings provide portable defaults; framework-owned keys
(``hooks``, ``statusLine``, ``apiKeyHelper``) and other framework
values win on conflict.
"""
merged: _Settings = dict(agent_settings) if agent_settings else {}
for key, value in framework_settings.items():
if key in CLAUDE_FRAMEWORK_OWNED_KEYS:
merged[key] = value
elif isinstance(merged.get(key), dict) and isinstance(value, dict):
merged[key] = _deep_merge_settings(
cast(Mapping[str, object], merged[key]),
value,
)
else:
merged[key] = value
return merged
13 changes: 10 additions & 3 deletions omnigent/inner/claude_sdk_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1366,6 +1366,7 @@ def __init__(
agent_name: str | None = None,
skills_filter: str | list[str] = "all",
api_key_helper: str | None = None,
settings_overlay: dict[str, object] | None = None,
) -> None:
"""Create a ClaudeSDKExecutor.

Expand Down Expand Up @@ -1439,6 +1440,8 @@ def __init__(
Injected into ``_extra_env`` as
:data:`_CLAUDE_API_KEY_HELPER_ENV_KEY` so it reaches
the SDK's ``settings.apiKeyHelper`` option at turn time.
settings_overlay: Agent-declared Claude ``settings.json``
fragment from ``executor.config.harness_settings``.
"""
# Fail loud: a ``databricks-*`` model requires the gateway transport.
if not gateway and model is not None and model.startswith("databricks-"):
Expand All @@ -1465,6 +1468,7 @@ def __init__(
self._bundle_dir = bundle_dir
self._agent_name = agent_name
self._skills_filter = skills_filter
self._settings_overlay = settings_overlay
# Write the bundle's plugin manifest now (idempotent) so that
# ``--plugin-dir <bundle>`` produces clean
# ``<agent-name>:<skill-name>`` labels in Claude's skill
Expand Down Expand Up @@ -2196,10 +2200,13 @@ async def run_turn(
# ``""`` here would still leave an empty key in the child env.
env = dict(self._extra_env)
api_key_helper = env.pop(_CLAUDE_API_KEY_HELPER_ENV_KEY, None)
settings_dict: dict[str, object] = {}
if self._settings_overlay:
settings_dict.update(self._settings_overlay)
if api_key_helper:
settings_dict["apiKeyHelper"] = api_key_helper
settings_payload = (
json.dumps({"apiKeyHelper": api_key_helper}, separators=(",", ":"))
if api_key_helper
else None
json.dumps(settings_dict, separators=(",", ":")) if settings_dict else None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SDK settings merge inconsistent

Medium Severity

On the claude-sdk harness, agent harness_settings are copied into the CLI settings JSON, while Omnigent’s permission_mode is passed separately and still drives MCP allowlisting and the can_use_tool gate. Unlike the native path, those two permission sources are not merged with framework precedence, so permissions.defaultMode in the overlay can disagree with the executor’s effective permission mode.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 13a28f5. Configure here.

@jan21deepak jan21deepak Aug 10, 2026

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

bugbot run

)

# Capture stderr from the CLI subprocess for diagnostics
Expand Down
35 changes: 35 additions & 0 deletions omnigent/inner/claude_sdk_harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@
stable plugin name (so bundled skills show as
``<agent>:<skill>`` rather than the bundle's tmpdir
basename).
- ``HARNESS_CLAUDE_SDK_SETTINGS_OVERLAY``: JSON-encoded Claude
``settings.json`` fragment from ``executor.config.harness_settings``.
Merged into the SDK ``settings`` option at turn time; Omnigent's
``apiKeyHelper`` wins when both are present.
"""

from __future__ import annotations
Expand Down Expand Up @@ -124,6 +128,7 @@
# executor strips ANTHROPIC_API_KEY before connecting to avoid subscription
# auth being bypassed).
_ENV_API_KEY_HELPER = "HARNESS_CLAUDE_SDK_API_KEY_HELPER"
_ENV_SETTINGS_OVERLAY = "HARNESS_CLAUDE_SDK_SETTINGS_OVERLAY"

# Default permission mode for the Claude SDK. ``"auto"`` auto-approves
# tool calls with background safety checks that verify actions align
Expand Down Expand Up @@ -250,6 +255,35 @@ def _resolve_skills_filter() -> str | list[str]:
return "all"


def _resolve_settings_overlay() -> dict[str, object] | None:
"""
Resolve agent-declared Claude settings from env config.

Reads :data:`_ENV_SETTINGS_OVERLAY` and decodes the JSON object
Omnigent serialized from ``executor.config.harness_settings``.
"""
raw = os.environ.get(_ENV_SETTINGS_OVERLAY, "").strip()
if not raw:
return None
try:
decoded = json.loads(raw)
except json.JSONDecodeError as exc:
_logger.warning(
"%s is not valid JSON (%s); ignoring agent harness settings",
_ENV_SETTINGS_OVERLAY,
exc,
)
return None
if not isinstance(decoded, dict):
_logger.warning(
"%s decoded to unsupported shape %r; ignoring agent harness settings",
_ENV_SETTINGS_OVERLAY,
decoded,
)
return None
return decoded


def _build_claude_sdk_executor() -> Executor:
"""
Construct a :class:`ClaudeSDKExecutor` from env-var config.
Expand Down Expand Up @@ -296,6 +330,7 @@ def _build_claude_sdk_executor() -> Executor:
agent_name=agent_name,
skills_filter=_resolve_skills_filter(),
api_key_helper=os.environ.get(_ENV_API_KEY_HELPER) or None,
settings_overlay=_resolve_settings_overlay(),
)


Expand Down
6 changes: 6 additions & 0 deletions omnigent/runner/native/orchestration.py
Original file line number Diff line number Diff line change
Expand Up @@ -5941,6 +5941,11 @@ async def _auto_create_claude_terminal(
# has the spec resolver) expose a bundle's ``skills/`` to Claude Code
# via ``--plugin-dir`` — the CLI mirror of the SDK plugin wiring.
# ``api_key_helper`` (ucode) registers Claude's gateway token command.
claude_agent_settings = None
if agent_spec is not None:
from omnigent.harness_agent_settings import harness_settings_from_spec

claude_agent_settings = harness_settings_from_spec(agent_spec)
claude_args = augment_claude_args(
base_claude_args,
bridge_dir=bridge_dir,
Expand All @@ -5950,6 +5955,7 @@ async def _auto_create_claude_terminal(
agent_name=agent_name,
skills_filter=skills_filter,
api_key_helper=claude_config.api_key_helper if claude_config is not None else None,
agent_settings=claude_agent_settings,
)

# Let a registered launcher plugin (e.g. Databricks' isaac) rewrite the
Expand Down
5 changes: 5 additions & 0 deletions omnigent/runtime/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -1242,6 +1242,11 @@ def _build_claude_sdk_spawn_env(
permission_mode = spec.executor.config.get("permission_mode")
if permission_mode is not None:
env["HARNESS_CLAUDE_SDK_PERMISSION_MODE"] = str(permission_mode)
harness_settings = spec.executor.config.get("harness_settings")
if isinstance(harness_settings, dict) and harness_settings:
from omnigent.harness_agent_settings import serialize_harness_settings

env["HARNESS_CLAUDE_SDK_SETTINGS_OVERLAY"] = serialize_harness_settings(harness_settings)
return env


Expand Down
2 changes: 1 addition & 1 deletion omnigent/spec/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ class _ConfigYamlLoader(yaml.SafeLoader):

# ``executor.config`` keys kept as their nested YAML structure instead of
# string-coerced — their consumers read the nested mapping/list shape.
_STRUCTURED_EXECUTOR_CONFIG_KEYS: frozenset[str] = frozenset()
_STRUCTURED_EXECUTOR_CONFIG_KEYS: frozenset[str] = frozenset({"harness_settings"})

# Copy the resolver dict onto the subclass before mutating — it's inherited
# from SafeLoader by reference, so in-place edits below would strip
Expand Down
21 changes: 21 additions & 0 deletions tests/inner/test_claude_sdk_harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -491,3 +491,24 @@ def _fake_init(self: Any, **kwargs: Any) -> None:

assert captured["bundle_dir"] is None
assert captured["agent_name"] is None


def test_settings_overlay_is_resolved_from_env(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv(
"HARNESS_CLAUDE_SDK_SETTINGS_OVERLAY",
'{"permissions":{"defaultMode":"acceptEdits"}}',
)
captured: dict[str, Any] = {}

def _fake_init(self: Any, **kwargs: Any) -> None:
captured.update(kwargs)

with patch(
"omnigent.inner.claude_sdk_harness.ClaudeSDKExecutor.__init__",
_fake_init,
):
claude_sdk_harness._build_claude_sdk_executor()

assert captured["settings_overlay"] == {"permissions": {"defaultMode": "acceptEdits"}}
14 changes: 14 additions & 0 deletions tests/runtime/test_claude_sdk_spawn_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,3 +247,17 @@ def test_ucode_state_with_model_is_not_overridden_by_default(
env = _build_claude_sdk_spawn_env(spec, workdir=None)

assert env["HARNESS_CLAUDE_SDK_MODEL"] == "databricks-claude-sonnet-4-6"


def test_harness_settings_are_serialized_for_claude_sdk() -> None:
spec = _make_spec()
spec.executor.config["harness_settings"] = {
"permissions": {"defaultMode": "acceptEdits"},
"env": {"AGENT_FLAG": "1"},
}

env = _build_claude_sdk_spawn_env(spec, workdir=None)

assert env["HARNESS_CLAUDE_SDK_SETTINGS_OVERLAY"] == (
'{"permissions":{"defaultMode":"acceptEdits"},"env":{"AGENT_FLAG":"1"}}'
)
Loading