feat(config): per-harness startup command/args overrides + OMNIGENT_*_PATH standardization (#2933)
Add a polymorphic `harness:` key in config.yaml — a scalar (legacy) or a mapping with `default` plus per-harness `command`/`args` overrides. The legacy scalar form still works and auto-migrates to the mapping form on the next config write. Harness binary-path precedence: `OMNIGENT_<NAME>_PATH` env var > `harness.<id>.command` config > built-in default. `args` follow the same precedence with config args as the base and CLI pass-through args appended. Env-var standardization: `OMNIGENT_<NAME>_PATH` (base id, `-native` suffix stripped) is the canonical per-binary override, unifying the headless `HARNESS_*_PATH` and native `OMNIGENT_*_PATH` conventions into one namespaced name. The env var keys off the underlying binary, not the harness id, so `claude-sdk` (which runs the `claude` CLI) shares `OMNIGENT_CLAUDE_PATH` with `claude-native`. The legacy `HARNESS_<NAME>_PATH` (codex/pi/kimi/goose/qwen/hermes) is still read as a deprecated fallback — a one-time runner-side log warning when it provides the value, plus a terminal-visible CLI startup notice for interactive invocations. Slated for removal in v0.8.0. The pre-existing `omnigent claude --command` flag is deprecated (warns on use, pointing to `OMNIGENT_CLAUDE_PATH`/config) and will be removed in a future release. No other native command gained a `--command` flag — override via env or config. New module `omnigent/harness_startup_config.py` (leaf resolver, lazy-imports the alias helper): `resolve_harness_config`, `resolve_harness_command`, `resolve_harness_args`, `resolve_harness_path`, `config_harness_path_override`. Config deep-merge of the `harness` mapping across global+local (per-harness sub-keys). Write-side scalar→mapping migration with a one-time stderr notice. `config set harness=<id>` deep-merges into existing overrides; `config list` renders the default + notes overrides. `args` wiring: the 11 native Click commands thread config args as the base with CLI pass-through args appended (via `_resolve_harness_startup_args`). The 7 env-resolver native commands (pi/cursor/kiro/goose/hermes/qwen/kimi) thread `harness.<name>-native.command` config into `OMNIGENT_*_PATH` before `_ensure_backend`. The 5 headless spawn-env builders (codex/pi/kimi/goose/qwen) set `OMNIGENT_*_PATH` from config when ambient env is unset. Signed-off-by: Zeyi Fan <zeyi.f@databricks.com>
This commit is contained in:
@@ -10,6 +10,7 @@ website under `/releases`.
|
||||
### Features
|
||||
|
||||
- [UI / Feature] Added a Nord color theme (arctic frost-blue palette) to the Appearance settings palette picker.
|
||||
- [Feature] Per-harness startup command/args overrides via a polymorphic `harness:` key in `config.yaml`. The `harness:` key now accepts a mapping with a `default` plus per-harness `command`/`args` overrides (e.g. `harness: {default: claude-sdk, codex: {command: /usr/local/bin/codex, args: [--config, approval_policy=on-request]}}`). The legacy scalar form (`harness: claude-sdk`) still works and auto-migrates to the mapping form on the next config write. Harness binary-path precedence: `OMNIGENT_<NAME>_PATH` env var > config `harness.<id>.command` > built-in default; `args` follow the same precedence with config `args` as the base and CLI pass-through args appended. The `OMNIGENT_<NAME>_PATH` env var (base id, `-native` suffix stripped) is the canonical per-binary override, standardizing the headless `HARNESS_<NAME>_PATH` and native `OMNIGENT_*_PATH` conventions into one namespaced name; the legacy `HARNESS_<NAME>_PATH` is still read as a deprecated fallback that logs a one-time warning + a CLI startup notice, and is slated for removal in v0.8.0. The pre-existing `omnigent claude --command` flag is deprecated (warns on use, pointing to `OMNIGENT_CLAUDE_PATH`/config) and will be removed in a future release; no other native command gained a `--command` flag — override via env or config.
|
||||
|
||||
## [v0.5.0] — 2026-07-10
|
||||
|
||||
|
||||
@@ -461,6 +461,52 @@ See the [policy guide](https://github.com/omnigent-ai/omnigent/blob/main/docs/PO
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Global config lives in `~/.omnigent/config.yaml` (user) or
|
||||
`.omnigent/config.yaml` (project, takes precedence). View it with
|
||||
`omnigent config list`; set a key with `omnigent config set <key>=<value>`.
|
||||
|
||||
### `harness:` — default harness & per-harness startup overrides
|
||||
|
||||
Selects the default harness for `omnigent run` and can override the
|
||||
executable (`command`) and base launch args (`args`) for each harness.
|
||||
|
||||
```yaml
|
||||
# Legacy scalar (deprecated, still honored — auto-migrates on next write):
|
||||
harness: claude-sdk
|
||||
|
||||
# Mapping form — a default plus per-harness overrides:
|
||||
harness:
|
||||
default: claude-sdk
|
||||
codex-native:
|
||||
command: /usr/local/bin/codex
|
||||
args: [--config, approval_policy=on-request]
|
||||
pi-native:
|
||||
command: /opt/bin/pi
|
||||
```
|
||||
|
||||
- `default` (optional str): default harness id for `omnigent run`.
|
||||
- `command` (optional str): overrides the vendor CLI executable.
|
||||
- `args` (optional list[str]): base args; CLI pass-through args append after.
|
||||
|
||||
**Precedence** (first non-empty wins): `OMNIGENT_<NAME>_PATH` env var >
|
||||
config `harness.<id>.command` > built-in default. `args` follow the same
|
||||
precedence with config `args` as the base and CLI pass-through args appended.
|
||||
|
||||
The env var is `OMNIGENT_<NAME>_PATH` where `<NAME>` is the harness's base id
|
||||
(`-native` suffix stripped, so `pi` and `pi-native` share `OMNIGENT_PI_PATH` —
|
||||
one var per binary). The legacy `HARNESS_<NAME>_PATH` (codex/pi/kimi/goose/
|
||||
qwen/hermes) is still read as a deprecated fallback (warns on use) and will be
|
||||
removed in **v0.8.0**.
|
||||
|
||||
The pre-existing `omnigent claude --command` flag is deprecated (warns,
|
||||
pointing to `OMNIGENT_CLAUDE_PATH` / config) and will be removed in a future
|
||||
release. No other native command has a `--command` flag — override via env
|
||||
or config.
|
||||
|
||||
---
|
||||
|
||||
## Write your own agent
|
||||
|
||||
An agent is a short YAML file: your prompt, your tools — local Python
|
||||
@@ -508,17 +554,6 @@ Polly at [`examples/polly/`](https://github.com/omnigent-ai/omnigent/tree/main/e
|
||||
|
||||
---
|
||||
|
||||
## Telemetry
|
||||
|
||||
Omnigent collects anonymized usage data (telemetry) by default. This data
|
||||
contains no sensitive or personally identifiable information. If you're using
|
||||
Omnigent through a managed service or distribution, please consult your managed
|
||||
service agreement to determine any data collection that may impact your use of
|
||||
the service. To opt out, follow our instructions in
|
||||
[Usage Telemetry](https://omnigent.ai/docs/deploy/telemetry).
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome. See [CONTRIBUTING.md](https://github.com/omnigent-ai/omnigent/blob/main/CONTRIBUTING.md) for how to set up your environment, run the checks, and open a pull request.
|
||||
|
||||
+298
-29
@@ -35,6 +35,7 @@ from omnigent._startup_profile import StartupProfiler
|
||||
from omnigent.cli_sandbox import lakebox as _lakebox_alias_group
|
||||
from omnigent.cli_sandbox import sandbox as _sandbox_group
|
||||
from omnigent.config import (
|
||||
_merge_effective_config,
|
||||
global_config_path,
|
||||
load_global_config,
|
||||
load_local_config,
|
||||
@@ -490,9 +491,14 @@ def _load_effective_config() -> dict[str, Any]: # type: ignore[explicit-any]
|
||||
→ local (``.omnigent/config.yaml`` in cwd). Project config
|
||||
always wins so per-repo settings override user defaults.
|
||||
|
||||
The ``harness`` mapping is deep-merged (per-harness sub-keys, local
|
||||
winning per-field) via :func:`omnigent.config._merge_effective_config`
|
||||
so a project's per-harness overrides augment — rather than replace —
|
||||
the user's global ones. Every other key is a shallow replace.
|
||||
|
||||
:returns: Merged config dict.
|
||||
"""
|
||||
return {**_load_global_config(), **_load_local_config()}
|
||||
return _merge_effective_config(_load_global_config(), _load_local_config())
|
||||
|
||||
|
||||
def _peek_default_agent_harness(target: str) -> str | None:
|
||||
@@ -747,6 +753,35 @@ def _resolve_auto_open_conversation_from_config(cfg: dict[str, Any]) -> bool: #
|
||||
return setting if setting is not None else False
|
||||
|
||||
|
||||
def _normalize_harness_scalar_on_write(
|
||||
cfg: dict[str, Any], # type: ignore[explicit-any]
|
||||
path: Path,
|
||||
) -> bool:
|
||||
"""Migrate a legacy scalar ``harness:`` to the mapping form in *cfg*.
|
||||
|
||||
Rewrites ``cfg["harness"]`` from a plain string (``harness: claude-sdk``)
|
||||
to ``{"default": <str>}`` in place, preserving any per-harness overrides
|
||||
that a prior write may already have introduced under a partial mapping.
|
||||
Returns ``True`` when a scalar was actually migrated so the caller can
|
||||
emit the one-time notice. A no-op when ``harness`` is already a mapping,
|
||||
absent, or not a string. Behavior is unchanged by the migration — the
|
||||
scalar was the default, and ``{"default": <scalar>}`` means the same.
|
||||
|
||||
:param cfg: The config dict about to be written (mutated in place).
|
||||
:param path: The config file path (for the one-time notice message).
|
||||
:returns: ``True`` iff a scalar was migrated.
|
||||
"""
|
||||
raw = cfg.get("harness")
|
||||
if not isinstance(raw, str):
|
||||
return False
|
||||
cfg["harness"] = {"default": raw}
|
||||
click.echo(
|
||||
f"omnigent: migrated `harness:` to the new mapping form in {path} (behavior unchanged)",
|
||||
err=True,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _save_global_config( # type: ignore[explicit-any]
|
||||
# Any (matching the yaml-boundary helpers above): config values are
|
||||
# heterogeneous YAML scalars and nested mappings — e.g. the providers:
|
||||
@@ -800,6 +835,7 @@ def _save_global_config( # type: ignore[explicit-any]
|
||||
for key in unset_keys:
|
||||
cfg.pop(key, None)
|
||||
path = _effective_global_config_path()
|
||||
_normalize_harness_scalar_on_write(cfg, path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
yaml.safe_dump(cfg, f, default_flow_style=False, sort_keys=True)
|
||||
@@ -853,26 +889,41 @@ def _materialize_internal_beta_agents() -> Path:
|
||||
|
||||
|
||||
def _save_local_config(
|
||||
settings: dict[str, str | bool],
|
||||
settings: dict[str, str | bool | Mapping[str, Any]], # type: ignore[explicit-any]
|
||||
unset_keys: tuple[str, ...] = (),
|
||||
deep_merge_keys: tuple[str, ...] = (),
|
||||
) -> None:
|
||||
"""
|
||||
Merge *settings* into ``.omnigent/config.yaml`` in cwd and remove
|
||||
any keys listed in *unset_keys*.
|
||||
|
||||
Creates the ``.omnigent/`` directory if it does not exist.
|
||||
Creates the ``.omnigent/`` directory if it does not exist. Mirrors
|
||||
:func:`_save_global_config`: keys in *deep_merge_keys* are merged one
|
||||
level deep into the existing mapping (used by ``config set harness=``)
|
||||
so a per-harness default can be set without dropping existing
|
||||
per-harness overrides; every other key is a shallow replace.
|
||||
|
||||
:param settings: Key/value pairs to set, e.g.
|
||||
``{"default_agent": "examples/agent.yaml",
|
||||
"auto_open_conversation": True}``.
|
||||
:param unset_keys: Keys to remove from the config, e.g.
|
||||
``("server",)``.
|
||||
:param unset_keys: Keys to remove from the config, e.g. ``("server",)``.
|
||||
:param deep_merge_keys: Keys whose mapping value should be merged one
|
||||
level deep into the existing mapping rather than replacing it,
|
||||
e.g. ``("harness",)``.
|
||||
"""
|
||||
path = Path.cwd() / _LOCAL_CONFIG_RELPATH
|
||||
cfg = _load_local_config()
|
||||
cfg.update(settings)
|
||||
for key, value in settings.items():
|
||||
if key in deep_merge_keys and isinstance(value, Mapping):
|
||||
existing = cfg.get(key)
|
||||
merged = dict(existing) if isinstance(existing, Mapping) else {}
|
||||
merged.update(value)
|
||||
cfg[key] = merged
|
||||
else:
|
||||
cfg[key] = value
|
||||
for key in unset_keys:
|
||||
cfg.pop(key, None)
|
||||
_normalize_harness_scalar_on_write(cfg, path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
yaml.safe_dump(cfg, f, default_flow_style=False, sort_keys=True)
|
||||
@@ -1399,6 +1450,30 @@ def _should_skip_update_check(argv: list[str]) -> bool:
|
||||
}
|
||||
|
||||
|
||||
def _warn_deprecated_harness_path_env_vars() -> None:
|
||||
"""Print a terminal-visible deprecation notice for legacy ``HARNESS_*_PATH``.
|
||||
|
||||
These were the documented per-harness binary override knobs; they're now
|
||||
replaced by ``OMNIGENT_<NAME>_PATH`` (one var per binary, ``-native`` suffix
|
||||
stripped). The legacy read still works but is slated for removal in
|
||||
v0.8.0. Surface the replacement at CLI startup so a user with a legacy var
|
||||
in their shell/systemd/CI sees it regardless of which harness they launch
|
||||
or whether the run is local or remote (the runner-side log warning only
|
||||
reaches users on local launches). Gated to interactive stderr to avoid
|
||||
noise in pipes/CI logs.
|
||||
"""
|
||||
if not sys.stderr.isatty():
|
||||
return
|
||||
from omnigent.harness_startup_config import legacy_harness_path_env_vars_set
|
||||
|
||||
for legacy, canonical in legacy_harness_path_env_vars_set():
|
||||
click.echo(
|
||||
f"omnigent: {legacy} is deprecated; set {canonical} instead. "
|
||||
f"{legacy} support will be removed in v0.8.0.",
|
||||
err=True,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""
|
||||
Console-script entry point for ``omnigent``.
|
||||
@@ -1521,6 +1596,14 @@ def main() -> None:
|
||||
|
||||
maybe_show_update_notice()
|
||||
|
||||
# Terminal-visible deprecation notice for legacy ``HARNESS_*_PATH`` env
|
||||
# vars (now ``OMNIGENT_<NAME>_PATH``). Same gating as the update notice so
|
||||
# help/version/upgrade invocations stay quiet. The runner-side log warning
|
||||
# only reaches users on local launches; this reaches the terminal for every
|
||||
# interactive invocation regardless of local-vs-remote.
|
||||
if not _should_skip_update_check(argv):
|
||||
_warn_deprecated_harness_path_env_vars()
|
||||
|
||||
try:
|
||||
cli(args=argv, standalone_mode=False)
|
||||
except click.ClickException as exc:
|
||||
@@ -4659,10 +4742,10 @@ def _reject_native_on_windows(harness: str) -> None:
|
||||
default=None,
|
||||
metavar="CMD",
|
||||
help=(
|
||||
"Claude Code CLI executable to run. "
|
||||
"Defaults to ``claude``. Use this when a wrapper binary replaces the "
|
||||
"``claude`` CLI while preserving its interface (e.g. a custom launcher "
|
||||
"that injects auth or environment before delegating to ``claude``)."
|
||||
"[DEPRECATED] Claude Code CLI executable to run. Use the "
|
||||
"``OMNIGENT_CLAUDE_PATH`` env var or the "
|
||||
"``harness.claude-native.command`` config override instead; this "
|
||||
"flag will be removed in a future release."
|
||||
),
|
||||
)
|
||||
@click.argument("claude_args", nargs=-1, type=click.UNPROCESSED)
|
||||
@@ -4736,18 +4819,32 @@ def claude(
|
||||
)
|
||||
|
||||
from omnigent.claude_native import run_claude_native
|
||||
from omnigent.harness_startup_config import resolve_harness_command
|
||||
|
||||
startup_profiler.mark("native module imported")
|
||||
|
||||
if claude_command:
|
||||
click.echo(
|
||||
"omnigent: `claude --command` is deprecated; set OMNIGENT_CLAUDE_PATH "
|
||||
"or harness.claude-native.command instead. The --command flag will "
|
||||
"be removed in a future release.",
|
||||
err=True,
|
||||
)
|
||||
resolved_command = resolve_harness_command(
|
||||
"claude-native",
|
||||
default="claude",
|
||||
explicit=claude_command,
|
||||
cfg=cfg,
|
||||
)
|
||||
run_claude_native(
|
||||
server=server,
|
||||
session_id=resolved_session_id,
|
||||
resume_picker=choice.picker,
|
||||
claude_args=claude_args,
|
||||
claude_args=_resolve_harness_startup_args(cfg, "claude-native", claude_args),
|
||||
use_claude_config=use_claude_config,
|
||||
auto_open_conversation=auto_open_conversation,
|
||||
startup_profiler=startup_profiler,
|
||||
**({"command": claude_command} if claude_command else {}),
|
||||
command=resolved_command,
|
||||
)
|
||||
|
||||
|
||||
@@ -4829,6 +4926,7 @@ def codex(
|
||||
)
|
||||
|
||||
from omnigent.codex_native import run_codex_native
|
||||
from omnigent.harness_startup_config import resolve_harness_command
|
||||
|
||||
cfg = _load_effective_config()
|
||||
if server is None:
|
||||
@@ -4859,14 +4957,21 @@ def codex(
|
||||
choice.conversation_id if choice.conversation_id is not None else session_id
|
||||
)
|
||||
|
||||
resolved_command = resolve_harness_command(
|
||||
"codex-native",
|
||||
default="codex",
|
||||
explicit=None,
|
||||
cfg=cfg,
|
||||
)
|
||||
run_codex_native(
|
||||
server=server,
|
||||
session_id=resolved_session_id,
|
||||
resume_picker=choice.picker,
|
||||
codex_args=codex_args,
|
||||
codex_args=_resolve_harness_startup_args(cfg, "codex-native", codex_args),
|
||||
model=model,
|
||||
prompt=prompt,
|
||||
auto_open_conversation=auto_open_conversation,
|
||||
command=resolved_command,
|
||||
)
|
||||
|
||||
|
||||
@@ -4921,6 +5026,11 @@ def opencode(
|
||||
# :param session_id: Legacy ``--session`` id; mutually exclusive with ``--resume``.
|
||||
# :param model: OpenCode model id pinned on the wrapper spec.
|
||||
# :param opencode_args: Pass-through args persisted for the ``opencode attach`` TUI.
|
||||
# NOTE: no ``--command`` flag — override the opencode binary via
|
||||
# ``OMNIGENT_OPENCODE_PATH`` or ``harness.opencode-native.command`` config.
|
||||
# (opencode-native resolves its binary on the runner side; if a spec/env
|
||||
# path to thread a client override through is added later, this stays
|
||||
# consistent with the other native commands' env/config override model.)
|
||||
"""Launch OpenCode TUI in an Omnigent terminal.
|
||||
|
||||
\b
|
||||
@@ -4963,7 +5073,7 @@ def opencode(
|
||||
server=server,
|
||||
session_id=resolved_session_id,
|
||||
resume_picker=choice.picker,
|
||||
opencode_args=opencode_args,
|
||||
opencode_args=_resolve_harness_startup_args(cfg, "opencode-native", opencode_args),
|
||||
model=model,
|
||||
auto_open_conversation=auto_open_conversation,
|
||||
)
|
||||
@@ -5029,9 +5139,18 @@ def pi(
|
||||
"prefer --resume (--session is deprecated).",
|
||||
)
|
||||
|
||||
from omnigent.harness_startup_config import resolve_harness_command
|
||||
from omnigent.pi_native import run_pi_native
|
||||
|
||||
cfg = _load_effective_config()
|
||||
# Thread ``harness.pi-native.command`` config into the runner via the
|
||||
# canonical ``OMNIGENT_PI_PATH`` env var (set before ``_ensure_backend``
|
||||
# so a locally-spawned daemon inherits it; a remote ``--server`` runner
|
||||
# reads its own host env, so set the var there). No ``--command`` flag —
|
||||
# override via ``OMNIGENT_PI_PATH`` or config.
|
||||
_resolved = resolve_harness_command("pi-native", default="", explicit=None, cfg=cfg)
|
||||
if _resolved:
|
||||
os.environ["OMNIGENT_PI_PATH"] = _resolved
|
||||
if server is None:
|
||||
server = cfg.get("server")
|
||||
auto_open_conversation = _resolve_auto_open_conversation_from_config(cfg)
|
||||
@@ -5045,7 +5164,7 @@ def pi(
|
||||
server=server,
|
||||
session_id=resolved_session_id,
|
||||
resume_picker=choice.picker,
|
||||
pi_args=pi_args,
|
||||
pi_args=_resolve_harness_startup_args(cfg, "pi-native", pi_args),
|
||||
auto_open_conversation=auto_open_conversation,
|
||||
)
|
||||
|
||||
@@ -5257,8 +5376,16 @@ def cursor(
|
||||
)
|
||||
|
||||
from omnigent.cursor_native import run_cursor_native
|
||||
from omnigent.harness_startup_config import resolve_harness_command
|
||||
|
||||
cfg = _load_effective_config()
|
||||
# Thread ``--command`` / ``harness.cursor-native.command`` config into the
|
||||
# runner via the canonical ``OMNIGENT_CURSOR_PATH`` env var (set before
|
||||
# ``_ensure_backend`` so a locally-spawned daemon inherits it; a remote
|
||||
# ``--server`` runner reads its own host env, so set the var there).
|
||||
_resolved = resolve_harness_command("cursor-native", default="", explicit=None, cfg=cfg)
|
||||
if _resolved:
|
||||
os.environ["OMNIGENT_CURSOR_PATH"] = _resolved
|
||||
if server is None:
|
||||
server = cfg.get("server")
|
||||
# Deliberately no ``cfg.get("model")`` fallback (unlike ``codex``): the
|
||||
@@ -5276,7 +5403,7 @@ def cursor(
|
||||
server=server,
|
||||
session_id=resolved_session_id,
|
||||
resume_picker=choice.picker,
|
||||
cursor_args=cursor_args,
|
||||
cursor_args=_resolve_harness_startup_args(cfg, "cursor-native", cursor_args),
|
||||
model=model,
|
||||
auto_open_conversation=auto_open_conversation,
|
||||
mode=mode,
|
||||
@@ -5372,9 +5499,17 @@ def kiro(
|
||||
)
|
||||
_reject_reserved_kiro_resume_args(kiro_args)
|
||||
|
||||
from omnigent.harness_startup_config import resolve_harness_command
|
||||
from omnigent.kiro_native import run_kiro_native
|
||||
|
||||
cfg = _load_effective_config()
|
||||
# Thread ``--command`` / ``harness.kiro-native.command`` config into the
|
||||
# runner via the canonical ``OMNIGENT_KIRO_PATH`` env var (set before
|
||||
# ``_ensure_backend`` so a locally-spawned daemon inherits it; a remote
|
||||
# ``--server`` runner reads its own host env, so set the var there).
|
||||
_resolved = resolve_harness_command("kiro-native", default="", explicit=None, cfg=cfg)
|
||||
if _resolved:
|
||||
os.environ["OMNIGENT_KIRO_PATH"] = _resolved
|
||||
if server is None:
|
||||
server = cfg.get("server")
|
||||
if model is None:
|
||||
@@ -5385,7 +5520,7 @@ def kiro(
|
||||
kiro_agent=kiro_agent,
|
||||
trust_tools=trust_tools,
|
||||
trust_all_tools=trust_all_tools,
|
||||
passthrough_args=kiro_args,
|
||||
passthrough_args=_resolve_harness_startup_args(cfg, "kiro-native", kiro_args),
|
||||
)
|
||||
|
||||
server = _ensure_backend(server)
|
||||
@@ -5496,8 +5631,16 @@ def goose(
|
||||
)
|
||||
|
||||
from omnigent.goose_native import run_goose_native
|
||||
from omnigent.harness_startup_config import resolve_harness_command
|
||||
|
||||
cfg = _load_effective_config()
|
||||
# Thread ``--command`` / ``harness.goose-native.command`` config into the
|
||||
# runner via the canonical ``OMNIGENT_GOOSE_PATH`` env var (set before
|
||||
# ``_ensure_backend`` so a locally-spawned daemon inherits it; a remote
|
||||
# ``--server`` runner reads its own host env, so set the var there).
|
||||
_resolved = resolve_harness_command("goose-native", default="", explicit=None, cfg=cfg)
|
||||
if _resolved:
|
||||
os.environ["OMNIGENT_GOOSE_PATH"] = _resolved
|
||||
if server is None:
|
||||
server = cfg.get("server")
|
||||
auto_open_conversation = _resolve_auto_open_conversation_from_config(cfg)
|
||||
@@ -5511,7 +5654,7 @@ def goose(
|
||||
server=server,
|
||||
session_id=resolved_session_id,
|
||||
resume_picker=choice.picker,
|
||||
goose_args=goose_args,
|
||||
goose_args=_resolve_harness_startup_args(cfg, "goose-native", goose_args),
|
||||
auto_open_conversation=auto_open_conversation,
|
||||
)
|
||||
|
||||
@@ -5575,9 +5718,17 @@ def hermes(
|
||||
"prefer --resume (--session is deprecated).",
|
||||
)
|
||||
|
||||
from omnigent.harness_startup_config import resolve_harness_command
|
||||
from omnigent.hermes_native import run_hermes_native
|
||||
|
||||
cfg = _load_effective_config()
|
||||
# Thread ``--command`` / ``harness.hermes-native.command`` config into the
|
||||
# runner via the canonical ``OMNIGENT_HERMES_PATH`` env var (set before
|
||||
# ``_ensure_backend`` so a locally-spawned daemon inherits it; a remote
|
||||
# ``--server`` runner reads its own host env, so set the var there).
|
||||
_resolved = resolve_harness_command("hermes-native", default="", explicit=None, cfg=cfg)
|
||||
if _resolved:
|
||||
os.environ["OMNIGENT_HERMES_PATH"] = _resolved
|
||||
if server is None:
|
||||
server = cfg.get("server")
|
||||
auto_open_conversation = _resolve_auto_open_conversation_from_config(cfg)
|
||||
@@ -5591,7 +5742,7 @@ def hermes(
|
||||
server=server,
|
||||
session_id=resolved_session_id,
|
||||
resume_picker=choice.picker,
|
||||
hermes_args=hermes_args,
|
||||
hermes_args=_resolve_harness_startup_args(cfg, "hermes-native", hermes_args),
|
||||
auto_open_conversation=auto_open_conversation,
|
||||
)
|
||||
|
||||
@@ -5661,6 +5812,7 @@ def antigravity(
|
||||
)
|
||||
|
||||
from omnigent.antigravity_native import run_antigravity_native
|
||||
from omnigent.harness_startup_config import resolve_harness_command
|
||||
|
||||
cfg = _load_effective_config()
|
||||
if server is None:
|
||||
@@ -5680,13 +5832,22 @@ def antigravity(
|
||||
# inside run_antigravity_native. It is plumbed through build_agy_launch so a
|
||||
# future caller CAN set it, but this human CLI path exposes no permission
|
||||
# flag and never needs one.
|
||||
resolved_command = resolve_harness_command(
|
||||
"antigravity-native",
|
||||
default="",
|
||||
explicit=None,
|
||||
cfg=cfg,
|
||||
)
|
||||
run_antigravity_native(
|
||||
server=server,
|
||||
session_id=resolved_session_id,
|
||||
resume_picker=choice.picker,
|
||||
antigravity_args=antigravity_args,
|
||||
antigravity_args=_resolve_harness_startup_args(
|
||||
cfg, "antigravity-native", antigravity_args
|
||||
),
|
||||
model=model,
|
||||
auto_open_conversation=auto_open_conversation,
|
||||
command=resolved_command or None,
|
||||
)
|
||||
|
||||
|
||||
@@ -5749,9 +5910,17 @@ def qwen(
|
||||
"prefer --resume (--session is deprecated).",
|
||||
)
|
||||
|
||||
from omnigent.harness_startup_config import resolve_harness_command
|
||||
from omnigent.qwen_native import run_qwen_native
|
||||
|
||||
cfg = _load_effective_config()
|
||||
# Thread ``--command`` / ``harness.qwen-native.command`` config into the
|
||||
# runner via the canonical ``OMNIGENT_QWEN_PATH`` env var (set before
|
||||
# ``_ensure_backend`` so a locally-spawned daemon inherits it; a remote
|
||||
# ``--server`` runner reads its own host env, so set the var there).
|
||||
_resolved = resolve_harness_command("qwen-native", default="", explicit=None, cfg=cfg)
|
||||
if _resolved:
|
||||
os.environ["OMNIGENT_QWEN_PATH"] = _resolved
|
||||
if server is None:
|
||||
server = cfg.get("server")
|
||||
auto_open_conversation = _resolve_auto_open_conversation_from_config(cfg)
|
||||
@@ -5765,7 +5934,7 @@ def qwen(
|
||||
server=server,
|
||||
session_id=resolved_session_id,
|
||||
resume_picker=choice.picker,
|
||||
qwen_args=qwen_args,
|
||||
qwen_args=_resolve_harness_startup_args(cfg, "qwen-native", qwen_args),
|
||||
auto_open_conversation=auto_open_conversation,
|
||||
)
|
||||
|
||||
@@ -5919,9 +6088,17 @@ def kimi(
|
||||
"prefer --resume (--session is deprecated).",
|
||||
)
|
||||
|
||||
from omnigent.harness_startup_config import resolve_harness_command
|
||||
from omnigent.kimi_native import run_kimi_native
|
||||
|
||||
cfg = _load_effective_config()
|
||||
# Thread ``--command`` / ``harness.kimi-native.command`` config into the
|
||||
# runner via the canonical ``OMNIGENT_KIMI_PATH`` env var (set before
|
||||
# ``_ensure_backend`` so a locally-spawned daemon inherits it; a remote
|
||||
# ``--server`` runner reads its own host env, so set the var there).
|
||||
_resolved = resolve_harness_command("kimi-native", default="", explicit=None, cfg=cfg)
|
||||
if _resolved:
|
||||
os.environ["OMNIGENT_KIMI_PATH"] = _resolved
|
||||
if server is None:
|
||||
server = cfg.get("server")
|
||||
auto_open_conversation = _resolve_auto_open_conversation_from_config(cfg)
|
||||
@@ -5935,7 +6112,7 @@ def kimi(
|
||||
server=server,
|
||||
session_id=resolved_session_id,
|
||||
resume_picker=choice.picker,
|
||||
kimi_args=kimi_args,
|
||||
kimi_args=_resolve_harness_startup_args(cfg, "kimi-native", kimi_args),
|
||||
auto_open_conversation=auto_open_conversation,
|
||||
)
|
||||
|
||||
@@ -7314,7 +7491,10 @@ def run(
|
||||
if model is None and not direct_server_cli:
|
||||
model = _global_cfg.get("model")
|
||||
if harness is None and not direct_server_cli:
|
||||
harness = _global_cfg.get("harness")
|
||||
from omnigent.harness_startup_config import resolve_harness_config
|
||||
|
||||
harness_default, _ = resolve_harness_config(_global_cfg)
|
||||
harness = harness_default
|
||||
|
||||
# First-run smart defaults: a bare `run` with no AGENT, no --harness, and no
|
||||
# explicit persisted default → derive a harness from the *current* creds
|
||||
@@ -8639,6 +8819,31 @@ def _parse_config_settings(
|
||||
return parsed
|
||||
|
||||
|
||||
def _harness_deep_merge_keys(
|
||||
parsed: dict[str, str | bool | Mapping[str, Any]], # type: ignore[explicit-any]
|
||||
) -> tuple[str, ...]:
|
||||
"""Rewrite a ``harness=<id>`` setting for deep-merge into the harness mapping.
|
||||
|
||||
``config set harness=claude-sdk`` should set the default without dropping
|
||||
any existing per-harness overrides (``harness.codex.command``, etc.). So
|
||||
the scalar value is rewritten to ``{"default": <id>}`` and ``("harness",)``
|
||||
is returned so the save function deep-merges it one level into the existing
|
||||
``harness`` mapping. A non-scalar ``harness`` value (already a mapping from
|
||||
a future structured setter) is left untouched.
|
||||
|
||||
:param parsed: The ``KEY=VALUE`` mapping from :func:`_parse_config_settings`,
|
||||
mutated in place when it contains a scalar ``harness`` value.
|
||||
:returns: ``("harness",)`` when *parsed* has a ``harness`` entry, else
|
||||
``()`` so no deep-merge is requested.
|
||||
"""
|
||||
value = parsed.get("harness")
|
||||
if isinstance(value, str):
|
||||
parsed["harness"] = {"default": value}
|
||||
if "harness" in parsed:
|
||||
return ("harness",)
|
||||
return ()
|
||||
|
||||
|
||||
def _validate_unset_keys(unset_keys: tuple[str, ...]) -> list[str]:
|
||||
"""
|
||||
Validate keys passed to ``--unset`` against ``_GLOBAL_CONFIG_KEYS``.
|
||||
@@ -8660,6 +8865,70 @@ def _validate_unset_keys(unset_keys: tuple[str, ...]) -> list[str]:
|
||||
return validated
|
||||
|
||||
|
||||
def _format_harness_for_display(
|
||||
value: object, # type: ignore[explicit-any]
|
||||
) -> tuple[str, list[str]]:
|
||||
"""Render a ``harness`` config value for ``config list``.
|
||||
|
||||
:param value: The raw ``harness`` config value — scalar string or mapping.
|
||||
:returns: ``(default_display, override_ids)`` where *default_display* is
|
||||
the string to show after ``harness=`` (``"(none)"`` when absent) and
|
||||
*override_ids* is the sorted list of per-harness override keys.
|
||||
"""
|
||||
from omnigent.harness_startup_config import resolve_harness_config
|
||||
|
||||
if isinstance(value, str):
|
||||
return value, []
|
||||
if isinstance(value, dict):
|
||||
default, overrides = resolve_harness_config({"harness": value})
|
||||
return default or "(none)", sorted(overrides)
|
||||
return str(value) if value is not None else "(none)", []
|
||||
|
||||
|
||||
def _resolve_harness_startup_args(
|
||||
cfg: dict[str, Any], # type: ignore[explicit-any]
|
||||
harness: str,
|
||||
cli_args: tuple[str, ...],
|
||||
) -> tuple[str, ...]:
|
||||
"""Resolve the launch args for a native harness: config base + CLI args.
|
||||
|
||||
Config ``harness.<canonical>.args`` form the base; the CLI pass-through
|
||||
*cli_args* append *after* so a per-invocation flag wins for last-wins CLIs.
|
||||
Returns a tuple suitable for the ``<name>_args`` param of
|
||||
``run_<name>_native`` (persisted as ``terminal_launch_args``).
|
||||
|
||||
:param cfg: Effective config dict.
|
||||
:param harness: A harness id (canonical or alias), e.g. ``"codex-native"``.
|
||||
:param cli_args: Explicit CLI pass-through args (may be empty).
|
||||
:returns: The combined arg tuple: config base + CLI pass-through.
|
||||
"""
|
||||
from omnigent.harness_startup_config import resolve_harness_args
|
||||
|
||||
return tuple(resolve_harness_args(harness, cli_args, cfg=cfg))
|
||||
|
||||
|
||||
def _print_config_default_rows(
|
||||
cfg: dict[str, object], # type: ignore[explicit-any]
|
||||
) -> None:
|
||||
"""Print one ``key=value`` row per config default, handling the ``harness`` key.
|
||||
|
||||
The ``harness`` key may be a scalar (legacy) or a mapping with a ``default``
|
||||
plus per-harness overrides. Render it as ``harness=<default>`` and, when
|
||||
per-harness overrides are present, add a note line so they're visible without
|
||||
dumping the whole mapping. Every other key prints as ``key=value``.
|
||||
|
||||
:param cfg: A config dict filtered to ``_GLOBAL_CONFIG_KEYS``.
|
||||
"""
|
||||
for k, v in sorted(cfg.items()):
|
||||
if k == "harness":
|
||||
default, overrides = _format_harness_for_display(v)
|
||||
click.echo(f" harness={default}")
|
||||
if overrides:
|
||||
click.echo(f" # per-harness overrides: {', '.join(sorted(overrides))}")
|
||||
else:
|
||||
click.echo(f" {k}={v}")
|
||||
|
||||
|
||||
def _print_config_defaults() -> None:
|
||||
"""Print the effective CLI defaults (user + project-level).
|
||||
|
||||
@@ -8690,12 +8959,10 @@ def _print_config_defaults() -> None:
|
||||
local_is_global = local_cfg and local_path.resolve() == global_path.resolve()
|
||||
if global_cfg:
|
||||
click.echo(f" # {_display_config_path(global_path)}")
|
||||
for k, v in sorted(global_cfg.items()):
|
||||
click.echo(f" {k}={v}")
|
||||
_print_config_default_rows(global_cfg)
|
||||
if local_cfg and not local_is_global:
|
||||
click.echo(f" # {local_path}")
|
||||
for k, v in sorted(local_cfg.items()):
|
||||
click.echo(f" {k}={v}")
|
||||
_print_config_default_rows(local_cfg)
|
||||
|
||||
|
||||
class _ConfigGroup(click.Group):
|
||||
@@ -9001,11 +9268,13 @@ def config_set(is_global: bool, settings: tuple[str, ...]) -> None:
|
||||
"""
|
||||
if is_global:
|
||||
parsed = _parse_config_settings(settings, resolve_paths=True)
|
||||
_save_global_config(parsed, ())
|
||||
deep_keys = _harness_deep_merge_keys(parsed)
|
||||
_save_global_config(parsed, (), deep_keys)
|
||||
config_path: Path = _effective_global_config_path()
|
||||
else:
|
||||
parsed = _parse_config_settings(settings, resolve_paths=False)
|
||||
_save_local_config(parsed, ())
|
||||
deep_keys = _harness_deep_merge_keys(parsed)
|
||||
_save_local_config(parsed, (), deep_keys)
|
||||
config_path = Path.cwd() / _LOCAL_CONFIG_RELPATH
|
||||
click.echo(f"Set {len(parsed)} key(s) in {config_path}")
|
||||
|
||||
|
||||
+48
-2
@@ -40,9 +40,55 @@ def load_local_config(path: Path | None = None) -> dict[str, Any]: # type: igno
|
||||
return raw
|
||||
|
||||
|
||||
def _merge_effective_config(
|
||||
global_cfg: dict[str, Any], # type: ignore[explicit-any]
|
||||
local_cfg: dict[str, Any], # type: ignore[explicit-any]
|
||||
) -> dict[str, Any]: # type: ignore[explicit-any]
|
||||
"""Merge global+local config, deep-merging the ``harness`` mapping.
|
||||
|
||||
A flat ``{**global, **local}`` would make a local ``harness`` mapping
|
||||
replace the global one entirely, dropping the user's global
|
||||
per-harness overrides. So the ``harness`` key is merged one level deep
|
||||
(per-harness sub-keys, local winning per-field) while every other key
|
||||
stays a shallow replace (local wins outright). See
|
||||
:mod:`omnigent.harness_startup_config` for the ``harness:`` shape.
|
||||
|
||||
:param global_cfg: User-level config (``~/.omnigent/config.yaml``).
|
||||
:param local_cfg: Project-level config (``.omnigent/config.yaml``).
|
||||
:returns: The merged effective config dict.
|
||||
"""
|
||||
merged: dict[str, Any] = {**global_cfg, **local_cfg} # type: ignore[explicit-any]
|
||||
g_harness = global_cfg.get("harness")
|
||||
l_harness = local_cfg.get("harness")
|
||||
# Only deep-merge when BOTH are mappings. A scalar on either side is
|
||||
# an explicit whole-value override (legacy scalar form, or a project
|
||||
# that intentionally pins the whole harness key), so the shallow
|
||||
# ``{**global, **local}`` result already in ``merged`` is correct.
|
||||
if isinstance(g_harness, dict) and isinstance(l_harness, dict):
|
||||
combined: dict[str, Any] = {**g_harness, **l_harness} # type: ignore[explicit-any]
|
||||
# Per-harness sub-keys (anything but ``default``): merge one level
|
||||
# deep so a local per-harness entry augments rather than replaces
|
||||
# the global one (local fields win per-field).
|
||||
for key in set(g_harness) | set(l_harness):
|
||||
if key == "default":
|
||||
continue
|
||||
g_entry = g_harness.get(key)
|
||||
l_entry = l_harness.get(key)
|
||||
if isinstance(g_entry, dict) and isinstance(l_entry, dict):
|
||||
combined[key] = {**g_entry, **l_entry}
|
||||
merged["harness"] = combined
|
||||
return merged
|
||||
|
||||
|
||||
def load_effective_config() -> dict[str, Any]: # type: ignore[explicit-any]
|
||||
"""Merge user and project config, with project values taking precedence."""
|
||||
return {**load_global_config(), **load_local_config()}
|
||||
"""Merge user and project config, with project values taking precedence.
|
||||
|
||||
The ``harness`` mapping is deep-merged (per-harness sub-keys, local
|
||||
winning per-field) so a project's per-harness overrides augment —
|
||||
rather than replace — the user's global ones. Every other key is a
|
||||
shallow replace.
|
||||
"""
|
||||
return _merge_effective_config(load_global_config(), load_local_config())
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
"""Per-harness startup command/args resolution from config.
|
||||
|
||||
Lets users override the executable (``command``) and base launch args
|
||||
(``args``) for each harness in ``config.yaml`` via a polymorphic
|
||||
``harness:`` key — a scalar (legacy default) or a mapping with
|
||||
``default`` plus per-harness overrides. See
|
||||
``~/.pi/plans/omnigent/harness-startup-command-overrides.md``.
|
||||
|
||||
This is a leaf resolver module: it lazy-imports
|
||||
:func:`omnigent.harness_aliases.canonicalize_harness` so it can be used
|
||||
from :mod:`omnigent.config` (and the CLI) without pulling heavy
|
||||
entry-point discovery at config-load time.
|
||||
|
||||
Precedence (first non-empty wins):
|
||||
|
||||
``command`` —
|
||||
1. explicit CLI flag (``--command``)
|
||||
2. ambient env var (``OMNIGENT_<NAME>_PATH``)
|
||||
3. config ``harness.<canonical>.command``
|
||||
4. built-in default
|
||||
|
||||
``args`` —
|
||||
1. CLI pass-through args (always present, may be empty), appended
|
||||
*after* the config base
|
||||
2. config ``harness.<canonical>.args``
|
||||
3. ``[]``
|
||||
|
||||
Validation is warn+skip: an unknown harness id or a structurally
|
||||
malformed entry warns and is ignored, so a bad config never crashes
|
||||
``config list`` / ``doctor`` / every command's config load.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# The release in which the legacy ``HARNESS_<NAME>_PATH`` read is removed.
|
||||
# Deprecated in v0.6.0; two versions of back-compat, then removal.
|
||||
_LEGACY_PATH_REMOVAL_VERSION = "v0.8.0"
|
||||
|
||||
# Legacy ``HARNESS_*_PATH`` env vars and their canonical ``OMNIGENT_<NAME>_PATH``
|
||||
# replacement. Keep in sync with the ``_LEGACY_ENV_*`` constants in the inner
|
||||
# harness modules. Remove this mapping (and the legacy reads) in v0.8.0.
|
||||
_LEGACY_PATH_VARS: dict[str, str] = {
|
||||
"HARNESS_CODEX_PATH": "OMNIGENT_CODEX_PATH",
|
||||
"HARNESS_PI_PATH": "OMNIGENT_PI_PATH",
|
||||
"HARNESS_KIMI_PATH": "OMNIGENT_KIMI_PATH",
|
||||
"HARNESS_GOOSE_PATH": "OMNIGENT_GOOSE_PATH",
|
||||
"HARNESS_QWEN_PATH": "OMNIGENT_QWEN_PATH",
|
||||
"HARNESS_HERMES_PATH": "OMNIGENT_HERMES_PATH",
|
||||
}
|
||||
|
||||
# Legacy ``HARNESS_*_PATH`` vars we have already warned about in this process,
|
||||
# so a long-lived runner doesn't spam the deprecation once per session.
|
||||
_LEGACY_PATH_WARNED: set[str] = set()
|
||||
|
||||
# Keys read from a per-harness override entry in the ``harness:`` mapping.
|
||||
_OVERRIDE_KEY_COMMAND = "command"
|
||||
_OVERRIDE_KEY_ARGS = "args"
|
||||
|
||||
|
||||
def _canonicalize(harness: str) -> str:
|
||||
"""Return the canonical harness id for *harness* (lazy import).
|
||||
|
||||
Falls back to *harness* unchanged when the alias helper can't
|
||||
resolve it, so callers can still surface their own validation.
|
||||
"""
|
||||
from omnigent.harness_aliases import canonicalize_harness
|
||||
|
||||
return canonicalize_harness(harness) or harness
|
||||
|
||||
|
||||
# Harness canonical ids whose binary base name differs from the id with
|
||||
# ``-native`` stripped. The env var keys off the *binary* the harness spawns,
|
||||
# not the harness id, so ``claude-sdk`` (which runs the ``claude`` CLI) shares
|
||||
# ``OMNIGENT_CLAUDE_PATH`` with ``claude-native``. Add entries here only when a
|
||||
# harness id doesn't match its underlying command name.
|
||||
_HARNESS_BINARY_BASE: dict[str, str] = {
|
||||
"claude-sdk": "claude",
|
||||
}
|
||||
|
||||
|
||||
def _harness_path_env_var(canonical: str) -> str:
|
||||
"""Build the ``OMNIGENT_<NAME>_PATH`` env-var name for *canonical*.
|
||||
|
||||
The name keys off the underlying *binary* the harness spawns, not the
|
||||
harness id: ``-native`` is stripped (``pi`` and ``pi-native`` both →
|
||||
``OMNIGENT_PI_PATH``), and ``_HARNESS_BINARY_BASE`` remaps ids whose binary
|
||||
name differs (``claude-sdk`` → ``claude`` → ``OMNIGENT_CLAUDE_PATH``).
|
||||
"""
|
||||
base = _HARNESS_BINARY_BASE.get(canonical) or canonical.removesuffix("-native")
|
||||
return f"OMNIGENT_{base.upper().replace('-', '_')}_PATH"
|
||||
|
||||
|
||||
def resolve_harness_path(canonical: str) -> str | None:
|
||||
"""Resolve a harness binary-path override from env, warning on legacy use.
|
||||
|
||||
Precedence: the canonical ``OMNIGENT_<base>_PATH`` env var, then the
|
||||
deprecated ``HARNESS_<base>_PATH`` (emitting a one-time-per-process
|
||||
deprecation warning naming the replacement and removal version), then
|
||||
``None`` so the caller falls back to ``PATH``. *base* is *canonical* with
|
||||
the ``-native`` suffix stripped, so a harness's headless and native forms
|
||||
share one env var.
|
||||
|
||||
Use this from the inner harness wraps (runner-side) to locate the vendor
|
||||
CLI binary. The CLI side uses :func:`resolve_harness_command` instead,
|
||||
which adds the ``--command`` flag and config layers on top of this env read.
|
||||
|
||||
:param canonical: A harness id (e.g. ``"codex"`` or ``"pi-native"``).
|
||||
:returns: The override path/name, or ``None`` when neither env var is set.
|
||||
"""
|
||||
canonical_env = _harness_path_env_var(canonical)
|
||||
value = os.environ.get(canonical_env, "").strip()
|
||||
if value:
|
||||
return value
|
||||
base = _HARNESS_BINARY_BASE.get(canonical) or canonical.removesuffix("-native")
|
||||
legacy_env = f"HARNESS_{base.upper().replace('-', '_')}_PATH"
|
||||
# Only honor the legacy fallback for the 6 harnesses that historically
|
||||
# documented a ``HARNESS_*_PATH`` var. Other harnesses (cursor, kiro,
|
||||
# opencode, antigravity, …) never had one — honoring a speculative
|
||||
# ``HARNESS_CURSOR_PATH`` would invent a new knob under a deprecated name.
|
||||
if legacy_env not in _LEGACY_PATH_VARS:
|
||||
return None
|
||||
legacy = os.environ.get(legacy_env, "").strip()
|
||||
if legacy:
|
||||
_warn_legacy_path(legacy_env, canonical_env)
|
||||
return legacy
|
||||
return None
|
||||
|
||||
|
||||
def _warn_legacy_path(legacy_env: str, canonical_env: str) -> None:
|
||||
"""Emit a one-time-per-process deprecation warning for *legacy_env*."""
|
||||
if legacy_env in _LEGACY_PATH_WARNED:
|
||||
return
|
||||
_LEGACY_PATH_WARNED.add(legacy_env)
|
||||
_logger.warning(
|
||||
"%s is deprecated; set %s instead. %s support will be removed in %s.",
|
||||
legacy_env,
|
||||
canonical_env,
|
||||
legacy_env,
|
||||
_LEGACY_PATH_REMOVAL_VERSION,
|
||||
)
|
||||
|
||||
|
||||
def legacy_harness_path_env_vars_set() -> list[tuple[str, str]]:
|
||||
"""Return ``(legacy_var, canonical_replacement)`` for each deprecated
|
||||
``HARNESS_*_PATH`` env var currently set in the environment.
|
||||
|
||||
Used by the CLI entrypoint to surface a terminal-visible deprecation
|
||||
notice at startup (before any command runs), so a user with a legacy var
|
||||
in their shell/systemd/CI sees the replacement regardless of which harness
|
||||
they launch or whether the run is local or remote. One line per set var.
|
||||
"""
|
||||
return [
|
||||
(legacy, canonical)
|
||||
for legacy, canonical in _LEGACY_PATH_VARS.items()
|
||||
if os.environ.get(legacy, "").strip()
|
||||
]
|
||||
|
||||
|
||||
def resolve_harness_config(
|
||||
cfg: dict[str, Any], # type: ignore[explicit-any]
|
||||
) -> tuple[str | None, dict[str, dict[str, Any]]]: # type: ignore[explicit-any]
|
||||
"""Read the ``harness:`` key from effective config.
|
||||
|
||||
Accepts both legacy forms:
|
||||
|
||||
- Scalar (``harness: claude-sdk``) → ``(str, {})``. Fully functional;
|
||||
the scalar is the default and there are no per-harness overrides.
|
||||
- Mapping (``harness: {default: …, <id>: {command, args}}``) →
|
||||
``(default, overrides)``. Per-harness sub-keys are canonicalized;
|
||||
unknown ids and malformed entries are warned + skipped (never
|
||||
raise), so a bad config can't break ``config list`` / ``doctor``.
|
||||
|
||||
:param cfg: Effective config dict (global + local merged). Reads
|
||||
only the ``harness`` key.
|
||||
:returns: ``(default, overrides)`` where ``default`` is the default
|
||||
harness id (or ``None`` when absent) and ``overrides`` maps
|
||||
canonical harness id → ``{command: str, args: list[str]}`` (each
|
||||
field optional, only present when the user set it).
|
||||
"""
|
||||
raw = cfg.get("harness")
|
||||
if raw is None:
|
||||
return None, {}
|
||||
if isinstance(raw, str):
|
||||
return raw, {}
|
||||
if not isinstance(raw, dict):
|
||||
from omnigent.inner import ui
|
||||
|
||||
ui.warn(
|
||||
f"config `harness:` is a {type(raw).__name__}, expected a string "
|
||||
"or mapping — ignoring it."
|
||||
)
|
||||
return None, {}
|
||||
default: str | None = None
|
||||
overrides: dict[str, dict[str, Any]] = {} # type: ignore[explicit-any]
|
||||
for key, value in raw.items():
|
||||
if key == "default":
|
||||
if isinstance(value, str):
|
||||
default = value
|
||||
elif value is not None:
|
||||
from omnigent.inner import ui
|
||||
|
||||
ui.warn(
|
||||
f"config `harness.default` must be a string, got "
|
||||
f"{type(value).__name__} — ignoring it."
|
||||
)
|
||||
continue
|
||||
# Per-harness override entry. Canonicalize the id so aliases
|
||||
# (``claude`` → ``claude-sdk``) and reversed spellings resolve to
|
||||
# one override slot.
|
||||
canonical = _canonicalize(key)
|
||||
parsed = _parse_override_entry(key, value)
|
||||
if parsed:
|
||||
# Merge into an existing slot so ``claude`` and ``claude-sdk``
|
||||
# don't clobber each other; later entries win per-field. An entry
|
||||
# whose fields all failed validation yields an empty dict and is
|
||||
# skipped so the overrides map stays clean.
|
||||
overrides.setdefault(canonical, {}).update(parsed)
|
||||
return default, overrides
|
||||
|
||||
|
||||
def _parse_override_entry(
|
||||
key: str,
|
||||
value: Any, # type: ignore[explicit-any]
|
||||
) -> dict[str, Any] | None: # type: ignore[explicit-any]
|
||||
"""Validate one per-harness override entry; warn+skip on malformed.
|
||||
|
||||
:param key: The raw harness id as written in config (for messages).
|
||||
:param value: The entry value — expected ``{command: str, args: list}``.
|
||||
:returns: A validated ``{command?, args?}`` dict, or ``None`` when the
|
||||
entry is structurally invalid (already warned).
|
||||
"""
|
||||
from omnigent.inner import ui
|
||||
|
||||
if value is None:
|
||||
return {}
|
||||
if not isinstance(value, dict):
|
||||
ui.warn(
|
||||
f"config `harness.{key}` must be a mapping, got {type(value).__name__} — ignoring it."
|
||||
)
|
||||
return None
|
||||
parsed: dict[str, Any] = {} # type: ignore[explicit-any]
|
||||
command = value.get(_OVERRIDE_KEY_COMMAND)
|
||||
if command is not None:
|
||||
if isinstance(command, str) and command.strip():
|
||||
parsed[_OVERRIDE_KEY_COMMAND] = command.strip()
|
||||
else:
|
||||
ui.warn(f"config `harness.{key}.command` must be a non-empty string — ignoring it.")
|
||||
args = value.get(_OVERRIDE_KEY_ARGS)
|
||||
if args is not None:
|
||||
if isinstance(args, list) and all(isinstance(a, str) for a in args):
|
||||
parsed[_OVERRIDE_KEY_ARGS] = list(args)
|
||||
else:
|
||||
ui.warn(f"config `harness.{key}.args` must be a list of strings — ignoring it.")
|
||||
return parsed
|
||||
|
||||
|
||||
def resolve_harness_command(
|
||||
harness: str,
|
||||
*,
|
||||
default: str,
|
||||
explicit: str | None = None,
|
||||
cfg: dict[str, Any] | None = None, # type: ignore[explicit-any]
|
||||
) -> str:
|
||||
"""Resolve the executable to launch for *harness*.
|
||||
|
||||
Precedence (first non-empty wins):
|
||||
|
||||
1. *explicit* — the per-invocation CLI ``--command`` flag (most
|
||||
specific; only the native CLI commands set this).
|
||||
2. ambient env var ``OMNIGENT_<NAME>_PATH``.
|
||||
3. config ``harness.<canonical>.command`` (when *cfg* is provided).
|
||||
4. *default* — the harness's built-in executable name.
|
||||
|
||||
:param harness: A harness id (canonical or alias), e.g.
|
||||
``"claude-native"`` or ``"codex"``.
|
||||
:param default: Built-in fallback executable, e.g. ``"claude"``.
|
||||
:param explicit: The ``--command`` flag value, or ``None``.
|
||||
:param cfg: Effective config dict (for the config-layer lookup), or
|
||||
``None`` to skip it (e.g. when the caller already extracted
|
||||
overrides).
|
||||
:returns: The resolved command string (never empty — *default* is
|
||||
the floor).
|
||||
"""
|
||||
if explicit and explicit.strip():
|
||||
return explicit.strip()
|
||||
canonical = _canonicalize(harness)
|
||||
# Check both the canonical OMNIGENT_* and the deprecated HARNESS_* env var
|
||||
# (via resolve_harness_path, which warns on legacy use) so that env always
|
||||
# wins over config per the shared precedence — a legacy HARNESS_* must not
|
||||
# be shadowed by a config ``harness.<id>.command``.
|
||||
env_value = resolve_harness_path(canonical)
|
||||
if env_value:
|
||||
return env_value
|
||||
if cfg is not None:
|
||||
_, overrides = resolve_harness_config(cfg)
|
||||
entry = overrides.get(canonical)
|
||||
if entry is not None:
|
||||
command = entry.get(_OVERRIDE_KEY_COMMAND)
|
||||
if isinstance(command, str) and command.strip():
|
||||
return command.strip()
|
||||
return default
|
||||
|
||||
|
||||
def resolve_harness_args(
|
||||
harness: str,
|
||||
cli_args: tuple[str, ...],
|
||||
*,
|
||||
cfg: dict[str, Any] | None = None, # type: ignore[explicit-any]
|
||||
) -> list[str]:
|
||||
"""Resolve the base launch args for *harness*.
|
||||
|
||||
Config ``harness.<canonical>.args`` form the base; the CLI
|
||||
pass-through *cli_args* append *after* so a per-invocation flag
|
||||
wins for last-wins CLIs. When *cfg* is ``None`` (or no config args
|
||||
are set), the result is just ``list(cli_args)``.
|
||||
|
||||
:param harness: A harness id (canonical or alias).
|
||||
:param cli_args: Explicit CLI pass-through args (always present,
|
||||
may be empty), e.g. ``("--dangerously-skip-permissions",)``.
|
||||
:param cfg: Effective config dict, or ``None`` to skip the config
|
||||
layer.
|
||||
:returns: The combined arg list: config base + CLI pass-through.
|
||||
"""
|
||||
base: list[str] = []
|
||||
if cfg is not None:
|
||||
canonical = _canonicalize(harness)
|
||||
_, overrides = resolve_harness_config(cfg)
|
||||
entry = overrides.get(canonical)
|
||||
if entry is not None:
|
||||
config_args = entry.get(_OVERRIDE_KEY_ARGS)
|
||||
if isinstance(config_args, list):
|
||||
base = list(config_args)
|
||||
return [*base, *cli_args]
|
||||
|
||||
|
||||
def config_harness_path_override(
|
||||
harness: str,
|
||||
cfg: dict[str, Any], # type: ignore[explicit-any]
|
||||
) -> str | None:
|
||||
"""Return config's ``command`` override for *harness* when no env var is set.
|
||||
|
||||
Used by the CLI-subprocess spawn-env builders to thread a config
|
||||
``harness.<canonical>.command`` into the inner harness via its
|
||||
``OMNIGENT_<NAME>_PATH`` env var — but only when the user hasn't already
|
||||
set that env var (ambient env wins, per the shared precedence). Returns
|
||||
``None`` when config has no ``command`` for this harness or when the
|
||||
ambient env var already holds a value, so a caller can do
|
||||
``if v: env["OMNIGENT_X_PATH"] = v``.
|
||||
|
||||
:param harness: A harness id (canonical or alias), e.g. ``"codex"``.
|
||||
:param cfg: Effective config dict.
|
||||
:returns: The config command string to set as ``OMNIGENT_<NAME>_PATH``,
|
||||
or ``None`` when config has no override or the ambient env var is set.
|
||||
"""
|
||||
canonical = _canonicalize(harness)
|
||||
# Ambient env wins over config — check BOTH the canonical OMNIGENT_* and
|
||||
# the deprecated HARNESS_* (via resolve_harness_path, which warns on legacy
|
||||
# use) so a legacy HARNESS_* isn't shadowed by a config ``command``.
|
||||
if resolve_harness_path(canonical) is not None:
|
||||
return None # ambient env already wins (canonical or legacy)
|
||||
_, overrides = resolve_harness_config(cfg)
|
||||
entry = overrides.get(canonical)
|
||||
if entry is None:
|
||||
return None
|
||||
command = entry.get(_OVERRIDE_KEY_COMMAND)
|
||||
if isinstance(command, str) and command.strip():
|
||||
return command.strip()
|
||||
return None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"config_harness_path_override",
|
||||
"resolve_harness_args",
|
||||
"resolve_harness_command",
|
||||
"resolve_harness_config",
|
||||
"resolve_harness_path",
|
||||
]
|
||||
@@ -40,8 +40,9 @@ Env vars read at startup:
|
||||
- ``HARNESS_CODEX_CWD``: working directory the executor launches
|
||||
the Codex CLI in. ``None`` falls back to the subprocess's
|
||||
inherited cwd.
|
||||
- ``HARNESS_CODEX_PATH``: absolute path to a ``codex`` CLI
|
||||
binary. ``None`` searches ``PATH``.
|
||||
- ``OMNIGENT_CODEX_PATH``: absolute path to a ``codex`` CLI binary.
|
||||
``None`` searches ``PATH``. (Legacy ``HARNESS_CODEX_PATH`` still honored,
|
||||
deprecated.)
|
||||
- ``HARNESS_CODEX_ENABLE_WEB_SEARCH``: ``"1"`` / ``"true"`` to
|
||||
leave Codex's built-in ``web_search`` tool enabled. ``"0"`` /
|
||||
``"false"`` disables it (forces the model to use only
|
||||
@@ -92,6 +93,7 @@ from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from omnigent.harness_startup_config import resolve_harness_path
|
||||
from omnigent.inner.codex_executor import CodexExecutor
|
||||
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
|
||||
from omnigent.inner.executor import Executor
|
||||
@@ -109,7 +111,10 @@ _ENV_DATABRICKS_PROFILE = "HARNESS_CODEX_DATABRICKS_PROFILE"
|
||||
_ENV_MODEL_PROVIDER = "HARNESS_CODEX_MODEL_PROVIDER"
|
||||
_ENV_GATEWAY_HOST = "HARNESS_CODEX_GATEWAY_HOST"
|
||||
_ENV_CWD = "HARNESS_CODEX_CWD"
|
||||
_ENV_CODEX_PATH = "HARNESS_CODEX_PATH"
|
||||
_ENV_CODEX_PATH = "OMNIGENT_CODEX_PATH"
|
||||
# Deprecated alias — read via resolve_harness_path() which warns on use.
|
||||
# Remove this constant and the HARNESS_CODEX_PATH read in v0.8.0.
|
||||
_LEGACY_ENV_CODEX_PATH = "HARNESS_CODEX_PATH"
|
||||
_ENV_ENABLE_WEB_SEARCH = "HARNESS_CODEX_ENABLE_WEB_SEARCH"
|
||||
_ENV_DISABLE_NATIVE_TOOLS = "HARNESS_CODEX_DISABLE_NATIVE_TOOLS"
|
||||
_ENV_OS_ENV = "HARNESS_CODEX_OS_ENV"
|
||||
@@ -272,7 +277,7 @@ def _build_codex_executor() -> Executor:
|
||||
|
||||
:returns: A configured :class:`CodexExecutor` instance.
|
||||
:raises ImportError: If the ``codex`` CLI isn't on PATH and
|
||||
``HARNESS_CODEX_PATH`` isn't set — the inner executor's
|
||||
``OMNIGENT_CODEX_PATH`` (legacy ``HARNESS_CODEX_PATH``) isn't set — the inner executor's
|
||||
constructor surfaces this as a clear ImportError.
|
||||
:raises OSError: If ``HARNESS_CODEX_GATEWAY`` is set but
|
||||
credentials are missing — the inner executor's
|
||||
@@ -286,7 +291,7 @@ def _build_codex_executor() -> Executor:
|
||||
cwd=os.environ.get(_ENV_CWD),
|
||||
os_env=_resolve_os_env(),
|
||||
model=os.environ.get(_ENV_MODEL),
|
||||
codex_path=os.environ.get(_ENV_CODEX_PATH),
|
||||
codex_path=resolve_harness_path("codex"),
|
||||
gateway=_parse_truthy(_ENV_GATEWAY, default=False),
|
||||
databricks_profile=os.environ.get(_ENV_DATABRICKS_PROFILE),
|
||||
model_provider_override=os.environ.get(_ENV_MODEL_PROVIDER) or None,
|
||||
|
||||
@@ -25,8 +25,9 @@ Env vars read at startup:
|
||||
- ``HARNESS_GOOSE_PROVIDER``: optional ``GOOSE_PROVIDER`` override.
|
||||
- ``HARNESS_GOOSE_CWD``: working directory for the goose subprocess. ``None``
|
||||
falls back to ``OMNIGENT_RUNNER_WORKSPACE`` then the inherited cwd.
|
||||
- ``HARNESS_GOOSE_PATH``: absolute path to a ``goose`` CLI binary. ``None``
|
||||
searches ``PATH``.
|
||||
- ``OMNIGENT_GOOSE_PATH``: absolute path to a ``goose`` CLI binary.
|
||||
``None`` searches ``PATH``. (Legacy ``HARNESS_GOOSE_PATH`` still honored,
|
||||
deprecated.)
|
||||
- ``HARNESS_GOOSE_BUILTINS``: comma-separated Goose builtin extensions to load
|
||||
(``--with-builtin``). ``None`` defaults to ``developer`` (shell + editor).
|
||||
- ``HARNESS_GOOSE_OS_ENV``: JSON-encoded :class:`OSEnvSpec`. When unset, falls
|
||||
@@ -41,6 +42,7 @@ import os
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from omnigent.harness_startup_config import resolve_harness_path
|
||||
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
|
||||
from omnigent.inner.executor import Executor
|
||||
from omnigent.inner.goose_executor import GooseExecutor
|
||||
@@ -51,7 +53,10 @@ _logger = logging.getLogger(__name__)
|
||||
_ENV_MODEL = "HARNESS_GOOSE_MODEL"
|
||||
_ENV_PROVIDER = "HARNESS_GOOSE_PROVIDER"
|
||||
_ENV_CWD = "HARNESS_GOOSE_CWD"
|
||||
_ENV_GOOSE_PATH = "HARNESS_GOOSE_PATH"
|
||||
_ENV_GOOSE_PATH = "OMNIGENT_GOOSE_PATH"
|
||||
# Deprecated alias — read via resolve_harness_path() which warns on use.
|
||||
# Remove this constant and the HARNESS_GOOSE_PATH read in v0.8.0.
|
||||
_LEGACY_ENV_GOOSE_PATH = "HARNESS_GOOSE_PATH"
|
||||
_ENV_BUILTINS = "HARNESS_GOOSE_BUILTINS"
|
||||
_ENV_OS_ENV = "HARNESS_GOOSE_OS_ENV"
|
||||
|
||||
@@ -97,7 +102,7 @@ def _build_goose_executor() -> Executor:
|
||||
cwd = cwd_raw or None
|
||||
model = os.environ.get(_ENV_MODEL, "").strip() or None
|
||||
provider = os.environ.get(_ENV_PROVIDER, "").strip() or None
|
||||
goose_path = os.environ.get(_ENV_GOOSE_PATH, "").strip() or None
|
||||
goose_path = resolve_harness_path("goose")
|
||||
builtins_raw = os.environ.get(_ENV_BUILTINS, "").strip()
|
||||
builtins = (
|
||||
tuple(part.strip() for part in builtins_raw.split(",") if part.strip())
|
||||
|
||||
@@ -15,7 +15,7 @@ script, matching how Codex uses a per-session ``CODEX_HOME``.
|
||||
|
||||
Requirements:
|
||||
The ``hermes`` CLI must be installed and on PATH (or set via
|
||||
``HARNESS_HERMES_PATH``).
|
||||
``OMNIGENT_HERMES_PATH``; legacy ``HARNESS_HERMES_PATH`` still honored).
|
||||
|
||||
Env vars read at construction:
|
||||
|
||||
@@ -24,8 +24,8 @@ Env vars read at construction:
|
||||
configured default model.
|
||||
- ``HARNESS_HERMES_CWD`` — working directory the subprocess runs in.
|
||||
``None`` falls back to ``os.getcwd()``.
|
||||
- ``HARNESS_HERMES_PATH`` — absolute path to the ``hermes`` CLI binary.
|
||||
``None`` searches ``PATH``.
|
||||
- ``OMNIGENT_HERMES_PATH`` — absolute path to the ``hermes`` CLI binary.
|
||||
``None`` searches ``PATH``. (Legacy ``HARNESS_HERMES_PATH`` still honored.)
|
||||
- ``HARNESS_HERMES_OS_ENV`` — JSON-encoded :class:`OSEnvSpec`. When unset,
|
||||
defaults to ``caller_process + sandbox=none``.
|
||||
- ``HARNESS_HERMES_SKILLS_FILTER`` — JSON-encoded ``str | list[str]``
|
||||
|
||||
@@ -20,8 +20,9 @@ Env vars read at startup:
|
||||
``None`` falls back to Hermes' own configured default.
|
||||
- ``HARNESS_HERMES_CWD``: working directory the subprocess runs in.
|
||||
``None`` falls back to ``os.getcwd()``.
|
||||
- ``HARNESS_HERMES_PATH``: absolute path to the ``hermes`` CLI binary.
|
||||
``None`` searches ``PATH``.
|
||||
- ``OMNIGENT_HERMES_PATH``: absolute path to the ``hermes`` CLI binary.
|
||||
``None`` searches ``PATH``. (Legacy ``HARNESS_HERMES_PATH`` still honored,
|
||||
deprecated.)
|
||||
- ``HARNESS_HERMES_OS_ENV``: JSON-encoded :class:`OSEnvSpec`
|
||||
(from :func:`dataclasses.asdict`). When unset, the wrap
|
||||
falls back to a default
|
||||
@@ -48,6 +49,7 @@ from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from omnigent.harness_startup_config import resolve_harness_path
|
||||
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
|
||||
from omnigent.inner.executor import Executor
|
||||
from omnigent.inner.hermes_executor import HermesExecutor
|
||||
@@ -60,7 +62,10 @@ _logger = logging.getLogger(__name__)
|
||||
# so misconfigurations surface as a single grep target.
|
||||
_ENV_MODEL = "HARNESS_HERMES_MODEL"
|
||||
_ENV_CWD = "HARNESS_HERMES_CWD"
|
||||
_ENV_HERMES_PATH = "HARNESS_HERMES_PATH"
|
||||
_ENV_HERMES_PATH = "OMNIGENT_HERMES_PATH"
|
||||
# Deprecated alias — read via resolve_harness_path() which warns on use.
|
||||
# Remove this constant and the HARNESS_HERMES_PATH read in v0.8.0.
|
||||
_LEGACY_ENV_HERMES_PATH = "HARNESS_HERMES_PATH"
|
||||
_ENV_OS_ENV = "HARNESS_HERMES_OS_ENV"
|
||||
_ENV_SKILLS_FILTER = "HARNESS_HERMES_SKILLS_FILTER"
|
||||
_ENV_BUNDLE_DIR = "HARNESS_HERMES_BUNDLE_DIR"
|
||||
@@ -161,14 +166,14 @@ def _build_hermes_executor() -> Executor:
|
||||
|
||||
:returns: A configured :class:`HermesExecutor` instance.
|
||||
:raises FileNotFoundError: If ``hermes`` is not on PATH and
|
||||
``HARNESS_HERMES_PATH`` isn't set.
|
||||
``OMNIGENT_HERMES_PATH`` (legacy ``HARNESS_HERMES_PATH``) isn't set.
|
||||
"""
|
||||
bundle_dir_raw = os.environ.get(_ENV_BUNDLE_DIR, "").strip()
|
||||
bundle_dir = str(Path(bundle_dir_raw)) if bundle_dir_raw else None
|
||||
agent_name_raw = os.environ.get(_ENV_AGENT_NAME, "").strip()
|
||||
agent_name = agent_name_raw or None
|
||||
return HermesExecutor(
|
||||
hermes_path=os.environ.get(_ENV_HERMES_PATH),
|
||||
hermes_path=resolve_harness_path("hermes"),
|
||||
cwd=os.environ.get(_ENV_CWD) or os.environ.get("OMNIGENT_RUNNER_WORKSPACE"),
|
||||
os_env=_resolve_os_env(),
|
||||
model=os.environ.get(_ENV_MODEL),
|
||||
|
||||
@@ -33,9 +33,9 @@ Env-var contract (read once at construction by
|
||||
- ``HARNESS_KIMI_CWD``: working directory the kimi subprocess runs in.
|
||||
Upstream has no ``--work-dir`` flag so this is threaded through
|
||||
``cwd=`` on the subprocess. ``None`` falls back to the runner's cwd.
|
||||
- ``HARNESS_KIMI_PATH``: explicit path to the ``kimi`` binary, e.g.
|
||||
- ``OMNIGENT_KIMI_PATH``: explicit path to the ``kimi`` binary, e.g.
|
||||
``"/Users/x/.kimi-code/bin/kimi"``. Defaults to ``"kimi"`` looked up
|
||||
on ``PATH``.
|
||||
on ``PATH``. (Legacy ``HARNESS_KIMI_PATH`` still honored, deprecated.)
|
||||
- ``HARNESS_KIMI_PLAN``: truthy → ``--plan`` (read-only plan mode).
|
||||
- ``HARNESS_KIMI_CONTINUE_LAST``: truthy → ``--continue`` (resume the
|
||||
most recent session for the working directory). Mutually exclusive
|
||||
@@ -65,6 +65,7 @@ from collections.abc import AsyncIterator, Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from omnigent.harness_startup_config import resolve_harness_path
|
||||
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
|
||||
from omnigent.inner.executor import (
|
||||
EnqueuedContent,
|
||||
@@ -110,16 +111,17 @@ def _parse_truthy(value: str | None) -> bool:
|
||||
def _resolve_kimi_binary() -> str:
|
||||
"""Resolve the ``kimi`` binary path.
|
||||
|
||||
``HARNESS_KIMI_PATH`` wins (lets users point at a custom build or a
|
||||
non-standard install location). Otherwise default to ``"kimi"`` and
|
||||
rely on ``shutil.which`` so a missing binary surfaces clearly at
|
||||
``run_turn``.
|
||||
``OMNIGENT_KIMI_PATH`` wins (legacy ``HARNESS_KIMI_PATH`` still honored
|
||||
via :func:`resolve_harness_path`, which emits a deprecation warning; lets
|
||||
users point at a custom build or a non-standard install location).
|
||||
Otherwise default to ``"kimi"`` and rely on ``shutil.which`` so a missing
|
||||
binary surfaces clearly at ``run_turn``.
|
||||
|
||||
The legacy pypi ``kimi-cli`` package is intentionally NOT detected —
|
||||
its command-line surface is incompatible with the upstream binary
|
||||
Omnigent supports.
|
||||
"""
|
||||
explicit = os.environ.get("HARNESS_KIMI_PATH", "").strip()
|
||||
explicit = resolve_harness_path("kimi")
|
||||
if explicit:
|
||||
return explicit
|
||||
return "kimi"
|
||||
@@ -424,7 +426,8 @@ class KimiExecutor(Executor):
|
||||
message=(
|
||||
f"kimi harness: binary {self._binary_path!r} not found on PATH. "
|
||||
"Install via `curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash` "
|
||||
"or set HARNESS_KIMI_PATH to its absolute location."
|
||||
"or set OMNIGENT_KIMI_PATH (legacy HARNESS_KIMI_PATH) to its"
|
||||
" absolute location."
|
||||
),
|
||||
retryable=False,
|
||||
)
|
||||
|
||||
@@ -18,8 +18,8 @@ Env vars read at startup (full contract in
|
||||
- ``HARNESS_KIMI_CWD`` — working directory the kimi subprocess runs in
|
||||
(upstream has no ``--work-dir`` flag, so this is threaded as
|
||||
subprocess ``cwd=``).
|
||||
- ``HARNESS_KIMI_PATH`` — path to the ``kimi`` binary. Default
|
||||
``"kimi"``.
|
||||
- ``OMNIGENT_KIMI_PATH`` — path to the ``kimi`` binary. Default
|
||||
``"kimi"``. (Legacy ``HARNESS_KIMI_PATH`` still honored, deprecated.)
|
||||
- ``HARNESS_KIMI_PLAN`` — truthy → ``--plan`` (read-only plan mode).
|
||||
- ``HARNESS_KIMI_CONTINUE_LAST`` — truthy → ``-C`` (continue the
|
||||
previous session for the working directory). Mutually exclusive with
|
||||
@@ -44,6 +44,7 @@ import os
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from omnigent.harness_startup_config import resolve_harness_path
|
||||
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
|
||||
from omnigent.inner.executor import Executor
|
||||
from omnigent.inner.kimi_executor import KimiExecutor, _resolve_skills_dirs
|
||||
@@ -53,7 +54,10 @@ _logger = logging.getLogger(__name__)
|
||||
|
||||
_ENV_MODEL = "HARNESS_KIMI_MODEL"
|
||||
_ENV_CWD = "HARNESS_KIMI_CWD"
|
||||
_ENV_BIN = "HARNESS_KIMI_PATH"
|
||||
_ENV_BIN = "OMNIGENT_KIMI_PATH"
|
||||
# Deprecated alias — read via resolve_harness_path() which warns on use.
|
||||
# Remove this constant and the HARNESS_KIMI_PATH read in v0.8.0.
|
||||
_LEGACY_ENV_BIN = "HARNESS_KIMI_PATH"
|
||||
_ENV_PLAN = "HARNESS_KIMI_PLAN"
|
||||
_ENV_CONTINUE_LAST = "HARNESS_KIMI_CONTINUE_LAST"
|
||||
_ENV_SKILLS_DIRS = "HARNESS_KIMI_SKILLS_DIRS"
|
||||
@@ -123,7 +127,7 @@ def _build_kimi_executor() -> Executor:
|
||||
cwd=os.environ.get(_ENV_CWD) or os.environ.get("OMNIGENT_RUNNER_WORKSPACE") or None,
|
||||
os_env=_resolve_os_env(),
|
||||
model=os.environ.get(_ENV_MODEL) or None,
|
||||
binary_path=os.environ.get(_ENV_BIN) or None,
|
||||
binary_path=resolve_harness_path("kimi"),
|
||||
plan=_parse_truthy_with_default(os.environ.get(_ENV_PLAN), default=False),
|
||||
continue_last_session=_parse_truthy_with_default(
|
||||
os.environ.get(_ENV_CONTINUE_LAST), default=False
|
||||
|
||||
@@ -36,8 +36,9 @@ Env vars read at startup:
|
||||
- ``HARNESS_PI_CWD``: working directory the executor launches
|
||||
the Pi CLI in. ``None`` falls back to ``OMNIGENT_RUNNER_WORKSPACE`` if set,
|
||||
then to the subprocess's inherited cwd.
|
||||
- ``HARNESS_PI_PATH``: absolute path to a ``pi`` CLI binary.
|
||||
``None`` searches ``PATH``.
|
||||
- ``OMNIGENT_PI_PATH``: absolute path to a ``pi`` CLI binary.
|
||||
``None`` searches ``PATH``. (Legacy ``HARNESS_PI_PATH`` still honored,
|
||||
deprecated.)
|
||||
- ``HARNESS_PI_OS_ENV``: JSON-encoded :class:`OSEnvSpec`
|
||||
(from :func:`dataclasses.asdict`). When unset, the wrap
|
||||
falls back to a default
|
||||
@@ -71,6 +72,7 @@ from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from omnigent.harness_startup_config import resolve_harness_path
|
||||
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
|
||||
from omnigent.inner.executor import Executor
|
||||
from omnigent.inner.pi_executor import PiExecutor
|
||||
@@ -86,7 +88,10 @@ _ENV_GATEWAY = "HARNESS_PI_GATEWAY"
|
||||
_ENV_DATABRICKS_PROFILE = "HARNESS_PI_DATABRICKS_PROFILE"
|
||||
_ENV_GATEWAY_HOST = "HARNESS_PI_GATEWAY_HOST"
|
||||
_ENV_CWD = "HARNESS_PI_CWD"
|
||||
_ENV_PI_PATH = "HARNESS_PI_PATH"
|
||||
_ENV_PI_PATH = "OMNIGENT_PI_PATH"
|
||||
# Deprecated alias — read via resolve_harness_path() which warns on use.
|
||||
# Remove this constant and the HARNESS_PI_PATH read in v0.8.0.
|
||||
_LEGACY_ENV_PI_PATH = "HARNESS_PI_PATH"
|
||||
_ENV_OS_ENV = "HARNESS_PI_OS_ENV"
|
||||
_ENV_SKILLS_FILTER = "HARNESS_PI_SKILLS_FILTER"
|
||||
_ENV_BUNDLE_DIR = "HARNESS_PI_BUNDLE_DIR"
|
||||
@@ -201,7 +206,7 @@ def _build_pi_executor() -> Executor:
|
||||
|
||||
:returns: A configured :class:`PiExecutor` instance.
|
||||
:raises ImportError: If the ``pi`` CLI isn't on PATH and
|
||||
``HARNESS_PI_PATH`` isn't set — the inner executor's
|
||||
``OMNIGENT_PI_PATH`` (legacy ``HARNESS_PI_PATH``) isn't set — the inner executor's
|
||||
constructor surfaces this as a clear ImportError.
|
||||
:raises OSError: If ``HARNESS_PI_GATEWAY`` is set but
|
||||
credentials are missing — the inner executor's
|
||||
@@ -215,7 +220,7 @@ def _build_pi_executor() -> Executor:
|
||||
cwd=os.environ.get(_ENV_CWD) or os.environ.get("OMNIGENT_RUNNER_WORKSPACE"),
|
||||
os_env=_resolve_os_env(),
|
||||
model=os.environ.get(_ENV_MODEL),
|
||||
pi_path=os.environ.get(_ENV_PI_PATH),
|
||||
pi_path=resolve_harness_path("pi"),
|
||||
gateway=_parse_truthy(_ENV_GATEWAY, default=False),
|
||||
databricks_profile=os.environ.get(_ENV_DATABRICKS_PROFILE),
|
||||
gateway_host=os.environ.get(_ENV_GATEWAY_HOST) or None,
|
||||
|
||||
@@ -20,8 +20,9 @@ Env vars read at startup:
|
||||
- ``HARNESS_QWEN_CWD``: working directory the executor launches
|
||||
the Qwen CLI in. ``None`` falls back to ``OMNIGENT_RUNNER_WORKSPACE`` if set,
|
||||
then to the subprocess's inherited cwd.
|
||||
- ``HARNESS_QWEN_PATH``: absolute path to a ``qwen`` CLI binary.
|
||||
``None`` searches ``PATH``.
|
||||
- ``OMNIGENT_QWEN_PATH``: absolute path to a ``qwen`` CLI binary.
|
||||
``None`` searches ``PATH``. (Legacy ``HARNESS_QWEN_PATH`` still honored,
|
||||
deprecated.)
|
||||
- ``HARNESS_QWEN_OS_ENV``: JSON-encoded :class:`OSEnvSpec`
|
||||
(from :func:`dataclasses.asdict`). When unset, the wrap
|
||||
falls back to a default
|
||||
@@ -47,6 +48,7 @@ from fastapi import FastAPI
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
from omnigent.harness_startup_config import resolve_harness_path
|
||||
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
|
||||
from omnigent.inner.executor import Executor
|
||||
from omnigent.inner.qwen_executor import QwenExecutor
|
||||
@@ -59,7 +61,10 @@ _logger = logging.getLogger(__name__)
|
||||
# so misconfigurations surface as a single grep target.
|
||||
_ENV_MODEL = "HARNESS_QWEN_MODEL"
|
||||
_ENV_CWD = "HARNESS_QWEN_CWD"
|
||||
_ENV_QWEN_PATH = "HARNESS_QWEN_PATH"
|
||||
_ENV_QWEN_PATH = "OMNIGENT_QWEN_PATH"
|
||||
# Deprecated alias — read via resolve_harness_path() which warns on use.
|
||||
# Remove this constant and the HARNESS_QWEN_PATH read in v0.8.0.
|
||||
_LEGACY_ENV_QWEN_PATH = "HARNESS_QWEN_PATH"
|
||||
_ENV_OS_ENV = "HARNESS_QWEN_OS_ENV"
|
||||
# Generic-provider / gateway routing: an OpenAI-compatible base URL plus a
|
||||
# shell command that prints a bearer token. Emitted by the spawn-env builder
|
||||
@@ -127,15 +132,14 @@ def _build_qwen_executor() -> Executor:
|
||||
|
||||
:returns: A configured :class:`QwenExecutor` instance.
|
||||
:raises ImportError: If the ``qwen`` CLI isn't on PATH and
|
||||
``HARNESS_QWEN_PATH`` isn't set — the inner executor's
|
||||
``OMNIGENT_QWEN_PATH`` (legacy ``HARNESS_QWEN_PATH``) isn't set — the inner executor's
|
||||
constructor surfaces this as a clear ImportError.
|
||||
"""
|
||||
cwd_raw = os.environ.get(_ENV_CWD) or os.environ.get("OMNIGENT_RUNNER_WORKSPACE")
|
||||
cwd = cwd_raw or None
|
||||
model_raw = os.environ.get(_ENV_MODEL, "").strip()
|
||||
model = model_raw or None
|
||||
qwen_path_raw = os.environ.get(_ENV_QWEN_PATH, "").strip()
|
||||
qwen_path = qwen_path_raw or None
|
||||
qwen_path = resolve_harness_path("qwen")
|
||||
gateway_base_url = os.environ.get(_ENV_GATEWAY_BASE_URL, "").strip() or None
|
||||
gateway_auth_command = os.environ.get(_ENV_GATEWAY_AUTH_COMMAND, "").strip() or None
|
||||
|
||||
|
||||
+19
-5
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
@@ -42,8 +43,11 @@ from omnigent.native_terminal import bind_session_runner as _bind_session_runner
|
||||
from omnigent.native_terminal import url_component
|
||||
from omnigent.pi_native_bridge import bridge_dir_for_session_id
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_PI_COMMAND = "pi"
|
||||
_PI_PATH_ENV = "OMNIGENT_PI_PATH"
|
||||
# Deprecated alias — remove in v0.8.0 (read via the legacy branch below, which warns).
|
||||
_LEGACY_HARNESS_PI_PATH_ENV = "HARNESS_PI_PATH"
|
||||
_AGENT_NAME = "pi-native-ui"
|
||||
_TERMINAL_NAME = "pi"
|
||||
@@ -83,11 +87,21 @@ class PreparedPiTerminal:
|
||||
|
||||
|
||||
def _configured_pi_command(env: Mapping[str, str]) -> str:
|
||||
"""Return the configured Pi executable name/path from *env*."""
|
||||
for key in (_PI_PATH_ENV, _LEGACY_HARNESS_PI_PATH_ENV):
|
||||
value = env.get(key, "").strip()
|
||||
if value:
|
||||
return value
|
||||
"""Return the configured Pi executable name/path from *env*.
|
||||
|
||||
Reads ``OMNIGENT_PI_PATH`` (canonical) then the deprecated
|
||||
``HARNESS_PI_PATH`` (emitting a one-time-per-process deprecation warning
|
||||
via the shared helper so wording/dedupe stay consistent).
|
||||
"""
|
||||
value = env.get(_PI_PATH_ENV, "").strip()
|
||||
if value:
|
||||
return value
|
||||
legacy = env.get(_LEGACY_HARNESS_PI_PATH_ENV, "").strip()
|
||||
if legacy:
|
||||
from omnigent.harness_startup_config import _warn_legacy_path
|
||||
|
||||
_warn_legacy_path(_LEGACY_HARNESS_PI_PATH_ENV, _PI_PATH_ENV)
|
||||
return legacy
|
||||
return _DEFAULT_PI_COMMAND
|
||||
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ _HARNESS_MODULES: dict[str, str] = {
|
||||
# for each turn, managing its own session state via Hermes' SQLite
|
||||
# session store. See omnigent/inner/hermes_harness.py and
|
||||
# omnigent/inner/hermes_executor.py. The ``hermes`` binary must be
|
||||
# on PATH (or set by HARNESS_HERMES_PATH).
|
||||
# on PATH (or set by OMNIGENT_HERMES_PATH; legacy HARNESS_HERMES_PATH honored).
|
||||
"hermes": "omnigent.inner.hermes_harness",
|
||||
# hermes-native harness wrap. Drives the resident ``hermes`` TUI by
|
||||
# injecting each web-UI turn into its tmux pane and mirroring the transcript
|
||||
|
||||
@@ -1259,6 +1259,34 @@ def _build_claude_sdk_spawn_env(
|
||||
return env
|
||||
|
||||
|
||||
def _apply_harness_path_override(
|
||||
env: dict[str, str],
|
||||
harness: str,
|
||||
) -> None:
|
||||
"""Thread a config ``harness.<canonical>.command`` into ``OMNIGENT_<NAME>_PATH``.
|
||||
|
||||
The harness wraps read ``OMNIGENT_<NAME>_PATH`` to locate their vendor
|
||||
CLI (the headless CLI-subprocess family historically read ``HARNESS_*_PATH``;
|
||||
both are honored, ``OMNIGENT_*`` canonical). A user can set that path via
|
||||
config (``harness.codex.command: /usr/local/bin/codex``); this threads it
|
||||
into the spawn env when the ambient env var isn't already set (ambient
|
||||
wins, per the shared ``env > config > default`` precedence). A no-op when
|
||||
config has no ``command`` for *harness* or the ambient env var is set.
|
||||
|
||||
:param env: The spawn-env dict being built (mutated in place).
|
||||
:param harness: A harness id (canonical or alias), e.g. ``"codex"``.
|
||||
"""
|
||||
from omnigent.harness_aliases import canonicalize_harness
|
||||
from omnigent.harness_startup_config import (
|
||||
_harness_path_env_var,
|
||||
config_harness_path_override,
|
||||
)
|
||||
|
||||
path = config_harness_path_override(harness, load_config())
|
||||
if path is not None:
|
||||
env[_harness_path_env_var(canonicalize_harness(harness) or harness)] = path
|
||||
|
||||
|
||||
def _build_codex_spawn_env(
|
||||
spec: AgentSpec,
|
||||
*,
|
||||
@@ -1335,6 +1363,7 @@ def _build_codex_spawn_env(
|
||||
retry_payload = _serialize_retry_policy(_resolve_retry_policy(spec))
|
||||
if retry_payload is not None:
|
||||
env["HARNESS_CODEX_RETRY_POLICY"] = retry_payload
|
||||
_apply_harness_path_override(env, "codex")
|
||||
return env
|
||||
|
||||
|
||||
@@ -1389,6 +1418,7 @@ def _build_pi_spawn_env(
|
||||
os_env_payload = _serialize_os_env(spec.os_env)
|
||||
if os_env_payload is not None:
|
||||
env["HARNESS_PI_OS_ENV"] = os_env_payload
|
||||
_apply_harness_path_override(env, "pi")
|
||||
return env
|
||||
|
||||
|
||||
@@ -1439,6 +1469,7 @@ def _build_qwen_spawn_env(
|
||||
os_env_payload = _serialize_os_env(spec.os_env)
|
||||
if os_env_payload is not None:
|
||||
env["HARNESS_QWEN_OS_ENV"] = os_env_payload
|
||||
_apply_harness_path_override(env, "qwen")
|
||||
return env
|
||||
|
||||
|
||||
@@ -1476,6 +1507,7 @@ def _build_goose_spawn_env(
|
||||
os_env_payload = _serialize_os_env(spec.os_env)
|
||||
if os_env_payload is not None:
|
||||
env["HARNESS_GOOSE_OS_ENV"] = os_env_payload
|
||||
_apply_harness_path_override(env, "goose")
|
||||
return env
|
||||
|
||||
|
||||
@@ -1866,6 +1898,7 @@ def _build_kimi_spawn_env(
|
||||
os_env_payload = _serialize_os_env(spec.os_env)
|
||||
if os_env_payload is not None:
|
||||
env["HARNESS_KIMI_OS_ENV"] = os_env_payload
|
||||
_apply_harness_path_override(env, "kimi")
|
||||
return env
|
||||
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ from omnigent.cli import (
|
||||
_resolve_default_agent_target,
|
||||
_resolve_first_run_plan,
|
||||
_save_global_config,
|
||||
_save_local_config,
|
||||
_server_uvicorn_log_config,
|
||||
_start_cli_runner_process,
|
||||
_warn_missing_harness_dependencies,
|
||||
@@ -605,6 +606,25 @@ def test_claude_command_use_native_config_bypasses_databricks_auth(
|
||||
assert captured["use_claude_config"] is True
|
||||
|
||||
|
||||
def test_claude_command_flag_is_deprecated(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""``omnigent claude --command`` emits a DeprecationWarning pointing to env/config."""
|
||||
monkeypatch.setattr("omnigent.cli._load_effective_config", dict)
|
||||
monkeypatch.setattr("omnigent.cli._ensure_backend", lambda *_: "http://localhost:0")
|
||||
monkeypatch.setattr(
|
||||
"omnigent.claude_native.run_claude_native",
|
||||
_fake_run_claude_native_capture({}),
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["claude", "--command", "/custom/claude"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
# The deprecated flag still works (forwards the command) but warns.
|
||||
assert "deprecated" in result.output.lower()
|
||||
assert "OMNIGENT_CLAUDE_PATH" in result.output
|
||||
|
||||
|
||||
def test_codex_command_resume_binds_session_and_passes_unknown_args(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -708,6 +728,219 @@ def test_codex_command_session_and_resume_mutually_exclusive(
|
||||
assert "mutually exclusive" in result.output
|
||||
|
||||
|
||||
def test_codex_command_env_var_passes_command_to_run_codex_native(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""``OMNIGENT_CODEX_PATH`` forwards ``command`` to the runner."""
|
||||
captured: dict[str, object] = {}
|
||||
monkeypatch.setenv("OMNIGENT_CODEX_PATH", "/x/y")
|
||||
monkeypatch.setattr("omnigent.cli._load_effective_config", dict)
|
||||
monkeypatch.setattr("omnigent.cli._ensure_backend", lambda *_: "http://localhost:0")
|
||||
monkeypatch.setattr(
|
||||
"omnigent.codex_native.run_codex_native",
|
||||
_fake_run_codex_native_capture(captured),
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["codex"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured["command"] == "/x/y"
|
||||
|
||||
|
||||
def test_codex_command_honors_config_command_when_env_absent(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""``harness.codex-native.command`` config is used when no env var is set."""
|
||||
captured: dict[str, object] = {}
|
||||
monkeypatch.delenv("OMNIGENT_CODEX_PATH", raising=False)
|
||||
monkeypatch.setattr(
|
||||
"omnigent.cli._load_effective_config",
|
||||
lambda: {"harness": {"codex-native": {"command": "/from/config"}}},
|
||||
)
|
||||
monkeypatch.setattr("omnigent.cli._ensure_backend", lambda *_: "http://localhost:0")
|
||||
monkeypatch.setattr(
|
||||
"omnigent.codex_native.run_codex_native",
|
||||
_fake_run_codex_native_capture(captured),
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["codex"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured["command"] == "/from/config"
|
||||
|
||||
|
||||
def test_codex_command_env_var_overrides_config_command(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""``OMNIGENT_CODEX_PATH`` env var beats ``harness.codex-native.command`` config."""
|
||||
captured: dict[str, object] = {}
|
||||
monkeypatch.setenv("OMNIGENT_CODEX_PATH", "/from/env")
|
||||
monkeypatch.setattr(
|
||||
"omnigent.cli._load_effective_config",
|
||||
lambda: {"harness": {"codex-native": {"command": "/from/config"}}},
|
||||
)
|
||||
monkeypatch.setattr("omnigent.cli._ensure_backend", lambda *_: "http://localhost:0")
|
||||
monkeypatch.setattr(
|
||||
"omnigent.codex_native.run_codex_native",
|
||||
_fake_run_codex_native_capture(captured),
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["codex"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured["command"] == "/from/env"
|
||||
|
||||
|
||||
def test_antigravity_command_env_var_passes_command_to_run_antigravity_native(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""``OMNIGENT_ANTIGRAVITY_PATH`` forwards ``command`` to the runner."""
|
||||
captured: dict[str, object] = {}
|
||||
monkeypatch.setenv("OMNIGENT_ANTIGRAVITY_PATH", "/x/y")
|
||||
monkeypatch.setattr("omnigent.cli._load_effective_config", dict)
|
||||
monkeypatch.setattr("omnigent.cli._ensure_backend", lambda *_: "http://localhost:0")
|
||||
monkeypatch.setattr(
|
||||
"omnigent.antigravity_native.run_antigravity_native",
|
||||
lambda **kwargs: captured.update(kwargs),
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["antigravity"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured["command"] == "/x/y"
|
||||
|
||||
|
||||
def test_antigravity_command_honors_config_command_when_env_absent(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""``harness.antigravity-native.command`` config is used when no env var is set."""
|
||||
captured: dict[str, object] = {}
|
||||
monkeypatch.delenv("OMNIGENT_ANTIGRAVITY_PATH", raising=False)
|
||||
monkeypatch.setattr(
|
||||
"omnigent.cli._load_effective_config",
|
||||
lambda: {"harness": {"antigravity-native": {"command": "/from/config"}}},
|
||||
)
|
||||
monkeypatch.setattr("omnigent.cli._ensure_backend", lambda *_: "http://localhost:0")
|
||||
monkeypatch.setattr(
|
||||
"omnigent.antigravity_native.run_antigravity_native",
|
||||
lambda **kwargs: captured.update(kwargs),
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["antigravity"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured["command"] == "/from/config"
|
||||
|
||||
|
||||
def test_antigravity_command_empty_resolved_falls_back_to_none(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""With no override, ``command`` is ``None`` so agy's binary discovery runs."""
|
||||
captured: dict[str, object] = {}
|
||||
monkeypatch.delenv("OMNIGENT_ANTIGRAVITY_PATH", raising=False)
|
||||
monkeypatch.setattr("omnigent.cli._load_effective_config", dict)
|
||||
monkeypatch.setattr("omnigent.cli._ensure_backend", lambda *_: "http://localhost:0")
|
||||
monkeypatch.setattr(
|
||||
"omnigent.antigravity_native.run_antigravity_native",
|
||||
lambda **kwargs: captured.update(kwargs),
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["antigravity"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured["command"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Native harness config args → terminal_launch_args (CLI args appended after)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_codex_config_args_form_base_cli_args_append(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Config ``harness.codex-native.args`` is the base; CLI pass-through appends."""
|
||||
captured: dict[str, object] = {}
|
||||
monkeypatch.setattr(
|
||||
"omnigent.cli._load_effective_config",
|
||||
lambda: {"harness": {"codex-native": {"args": ["--config", "k=v"]}}},
|
||||
)
|
||||
monkeypatch.setattr("omnigent.cli._ensure_backend", lambda *_: "http://localhost:0")
|
||||
monkeypatch.setattr(
|
||||
"omnigent.codex_native.run_codex_native",
|
||||
_fake_run_codex_native_capture(captured),
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["codex", "--dangerously-skip-permissions"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured["codex_args"] == (
|
||||
"--config",
|
||||
"k=v",
|
||||
"--dangerously-skip-permissions",
|
||||
)
|
||||
|
||||
|
||||
def test_codex_config_args_only_when_no_cli_args(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""With no CLI pass-through, config args are the whole arg list."""
|
||||
captured: dict[str, object] = {}
|
||||
monkeypatch.setattr(
|
||||
"omnigent.cli._load_effective_config",
|
||||
lambda: {"harness": {"codex-native": {"args": ["--verbose"]}}},
|
||||
)
|
||||
monkeypatch.setattr("omnigent.cli._ensure_backend", lambda *_: "http://localhost:0")
|
||||
monkeypatch.setattr(
|
||||
"omnigent.codex_native.run_codex_native",
|
||||
_fake_run_codex_native_capture(captured),
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["codex"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured["codex_args"] == ("--verbose",)
|
||||
|
||||
|
||||
def test_codex_args_no_config_is_cli_args_only(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""With no config args, the result is just the CLI pass-through."""
|
||||
captured: dict[str, object] = {}
|
||||
monkeypatch.setattr("omnigent.cli._load_effective_config", dict)
|
||||
monkeypatch.setattr("omnigent.cli._ensure_backend", lambda *_: "http://localhost:0")
|
||||
monkeypatch.setattr(
|
||||
"omnigent.codex_native.run_codex_native",
|
||||
_fake_run_codex_native_capture(captured),
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["codex", "--flag"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured["codex_args"] == ("--flag",)
|
||||
|
||||
|
||||
def test_pi_config_args_form_base_cli_args_append(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Config ``harness.pi-native.args`` is the base; CLI pass-through appends."""
|
||||
captured: dict[str, object] = {}
|
||||
monkeypatch.setattr(
|
||||
"omnigent.cli._load_effective_config",
|
||||
lambda: {"harness": {"pi-native": {"args": ["--base"]}}},
|
||||
)
|
||||
monkeypatch.setattr("omnigent.cli._ensure_backend", lambda *_: "http://localhost:0")
|
||||
monkeypatch.setattr(
|
||||
"omnigent.pi_native.run_pi_native",
|
||||
lambda **kwargs: captured.update(kwargs),
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["pi", "--cli-flag"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured["pi_args"] == ("--base", "--cli-flag")
|
||||
|
||||
|
||||
def test_kiro_command_is_registered_in_click_help() -> None:
|
||||
"""``omnigent kiro`` is a true top-level Click command."""
|
||||
result = CliRunner().invoke(cli, ["--help"])
|
||||
@@ -811,6 +1044,94 @@ def test_kiro_command_rejects_kiro_resume_passthrough_flags(
|
||||
assert "Kiro resume flags are reserved" in result.output
|
||||
|
||||
|
||||
def test_pi_config_command_threads_to_harness_path_env_var(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""``harness.pi-native.command`` config sets ``OMNIGENT_PI_PATH`` before launch.
|
||||
|
||||
The env-resolver harnesses (pi, cursor, kiro, goose, qwen, kimi, hermes)
|
||||
resolve their executable RUNNER-SIDE from ``OMNIGENT_<NAME>_PATH`` (the
|
||||
canonical name; ``HARNESS_<NAME>_PATH`` is a deprecated read-only fallback).
|
||||
The CLI threads ``harness.<name>-native.command`` config into that env var
|
||||
before ``_ensure_backend`` so a locally-spawned daemon (and its runner)
|
||||
inherits it.
|
||||
"""
|
||||
monkeypatch.setenv("OMNIGENT_PI_PATH", "") # ensure teardown restores (CLI overwrites it)
|
||||
monkeypatch.setattr(
|
||||
"omnigent.cli._load_effective_config",
|
||||
lambda: {"harness": {"pi-native": {"command": "/custom/pi"}}},
|
||||
)
|
||||
monkeypatch.setattr("omnigent.cli._ensure_backend", lambda s: s or "http://localhost:1")
|
||||
monkeypatch.setattr("omnigent.pi_native.run_pi_native", lambda **kw: None)
|
||||
|
||||
result = CliRunner().invoke(cli, ["pi"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert os.environ["OMNIGENT_PI_PATH"] == "/custom/pi"
|
||||
|
||||
|
||||
# ── legacy HARNESS_*_PATH deprecation notice ───────────────────────────
|
||||
|
||||
|
||||
def test_cli_warns_deprecated_harness_path_env_vars(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys,
|
||||
) -> None:
|
||||
"""A set ``HARNESS_*_PATH`` produces a terminal-visible deprecation notice."""
|
||||
from omnigent.cli import _warn_deprecated_harness_path_env_vars
|
||||
|
||||
monkeypatch.setenv("HARNESS_CODEX_PATH", "/usr/local/bin/codex")
|
||||
monkeypatch.setenv("HARNESS_PI_PATH", "/custom/pi")
|
||||
monkeypatch.delenv("OMNIGENT_CODEX_PATH", raising=False)
|
||||
monkeypatch.delenv("OMNIGENT_PI_PATH", raising=False)
|
||||
# The notice is stderr + isatty gated; force tty on so the helper emits.
|
||||
monkeypatch.setattr("sys.stderr.isatty", lambda: True)
|
||||
|
||||
_warn_deprecated_harness_path_env_vars()
|
||||
|
||||
out = capsys.readouterr().err
|
||||
assert "HARNESS_CODEX_PATH is deprecated" in out
|
||||
assert "set OMNIGENT_CODEX_PATH instead" in out
|
||||
assert "HARNESS_PI_PATH is deprecated" in out
|
||||
assert "set OMNIGENT_PI_PATH instead" in out
|
||||
assert "v0.8.0" in out
|
||||
|
||||
|
||||
def test_cli_no_deprecation_notice_when_no_legacy_var_set(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys,
|
||||
) -> None:
|
||||
"""No legacy ``HARNESS_*_PATH`` set → no notice."""
|
||||
from omnigent.cli import _warn_deprecated_harness_path_env_vars
|
||||
|
||||
for v in (
|
||||
"HARNESS_CODEX_PATH",
|
||||
"HARNESS_PI_PATH",
|
||||
"HARNESS_KIMI_PATH",
|
||||
"HARNESS_GOOSE_PATH",
|
||||
"HARNESS_QWEN_PATH",
|
||||
"HARNESS_HERMES_PATH",
|
||||
):
|
||||
monkeypatch.delenv(v, raising=False)
|
||||
monkeypatch.setattr("sys.stderr.isatty", lambda: True)
|
||||
|
||||
_warn_deprecated_harness_path_env_vars()
|
||||
|
||||
assert capsys.readouterr().err == ""
|
||||
|
||||
|
||||
def test_cli_no_deprecation_notice_in_non_tty(monkeypatch: pytest.MonkeyPatch, capsys) -> None:
|
||||
"""The notice is suppressed when stderr is not a tty (pipes/CI)."""
|
||||
from omnigent.cli import _warn_deprecated_harness_path_env_vars
|
||||
|
||||
monkeypatch.setenv("HARNESS_CODEX_PATH", "/usr/local/bin/codex")
|
||||
monkeypatch.setattr("sys.stderr.isatty", lambda: False)
|
||||
|
||||
_warn_deprecated_harness_path_env_vars()
|
||||
|
||||
assert capsys.readouterr().err == ""
|
||||
|
||||
|
||||
# ── bundled-agent shorthands (omnigent polly / omnigent debby) ──────────
|
||||
|
||||
|
||||
@@ -3697,6 +4018,136 @@ def test_config_list_dedups_when_cwd_is_config_home(
|
||||
assert len(source_comments) == 1, f"expected one config source, got {source_comments}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# `harness:` polymorphic key — auto-migration, config set, config list
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_save_global_migrates_scalar_harness_to_mapping(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A scalar ``harness:`` is rewritten to ``{default: <str>}`` on save, with a notice."""
|
||||
config_path = tmp_path / "config.yaml"
|
||||
monkeypatch.setattr("omnigent.cli._GLOBAL_CONFIG_PATH", config_path)
|
||||
# Seed a legacy scalar harness via a raw write (bypass the save-side migration).
|
||||
config_path.write_text("harness: claude-sdk\n", encoding="utf-8")
|
||||
|
||||
_save_global_config({"model": "x"})
|
||||
|
||||
cfg = _load_global_config()
|
||||
assert cfg["harness"] == {"default": "claude-sdk"}
|
||||
assert cfg["model"] == "x"
|
||||
|
||||
|
||||
def test_save_global_scalar_migration_notice_once(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""The migration fires a single stderr notice and is idempotent."""
|
||||
config_path = tmp_path / "config.yaml"
|
||||
monkeypatch.setattr("omnigent.cli._GLOBAL_CONFIG_PATH", config_path)
|
||||
config_path.write_text("harness: claude-sdk\n", encoding="utf-8")
|
||||
|
||||
# First write migrates the scalar and emits the notice.
|
||||
_save_global_config({"model": "x"})
|
||||
# Second write: already a mapping, no second notice, no double migration.
|
||||
_save_global_config({"server": "https://example.com"})
|
||||
cfg = _load_global_config()
|
||||
assert cfg["harness"] == {"default": "claude-sdk"}
|
||||
|
||||
|
||||
def test_save_local_config_migrates_scalar_harness(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""``_save_local_config`` also migrates a scalar harness to the mapping form."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
local_path = tmp_path / ".omnigent" / "config.yaml"
|
||||
local_path.parent.mkdir(parents=True)
|
||||
local_path.write_text("harness: codex\n", encoding="utf-8")
|
||||
|
||||
_save_local_config({"model": "x"})
|
||||
|
||||
from omnigent.cli import _load_local_config
|
||||
|
||||
cfg = _load_local_config()
|
||||
assert cfg["harness"] == {"default": "codex"}
|
||||
assert cfg["model"] == "x"
|
||||
|
||||
|
||||
def test_config_set_harness_deep_merges_preserving_overrides(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""``config set --global harness=pi`` preserves existing per-harness overrides."""
|
||||
config_path = tmp_path / "config.yaml"
|
||||
monkeypatch.setattr("omnigent.cli._GLOBAL_CONFIG_PATH", config_path)
|
||||
# Existing mapping with a per-harness override.
|
||||
_save_global_config({"harness": {"default": "claude-sdk", "codex": {"command": "/bin/codex"}}})
|
||||
|
||||
result = CliRunner().invoke(cli, ["config", "set", "--global", "harness=pi"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
cfg = _load_global_config()
|
||||
assert cfg["harness"]["default"] == "pi"
|
||||
# The pre-existing override must survive the default change.
|
||||
assert cfg["harness"]["codex"] == {"command": "/bin/codex"}
|
||||
|
||||
|
||||
def test_config_set_harness_migrates_scalar_to_mapping(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""``config set --global harness=x`` on a legacy scalar writes a mapping."""
|
||||
config_path = tmp_path / "config.yaml"
|
||||
monkeypatch.setattr("omnigent.cli._GLOBAL_CONFIG_PATH", config_path)
|
||||
config_path.write_text("harness: claude-sdk\n", encoding="utf-8")
|
||||
|
||||
result = CliRunner().invoke(cli, ["config", "set", "--global", "harness=pi"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
cfg = _load_global_config()
|
||||
assert cfg["harness"] == {"default": "pi"}
|
||||
|
||||
|
||||
def test_config_list_shows_harness_default_and_override_note(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""``config list`` renders ``harness=<default>`` plus a per-harness-override note."""
|
||||
config_path = tmp_path / "config.yaml"
|
||||
monkeypatch.setattr("omnigent.cli._GLOBAL_CONFIG_PATH", config_path)
|
||||
monkeypatch.setattr("omnigent.cli._load_local_config", dict)
|
||||
_save_global_config({"harness": {"default": "claude-sdk", "codex": {"command": "/bin/codex"}}})
|
||||
monkeypatch.setattr("omnigent.cli._print_credentials_by_harness", lambda: None)
|
||||
|
||||
result = CliRunner().invoke(cli, ["config", "list"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "harness=claude-sdk" in result.output
|
||||
assert "per-harness overrides" in result.output
|
||||
assert "codex" in result.output
|
||||
|
||||
|
||||
def test_config_list_shows_scalar_harness_without_note(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A legacy scalar harness renders as ``harness=<value>`` with no override note."""
|
||||
config_path = tmp_path / "config.yaml"
|
||||
monkeypatch.setattr("omnigent.cli._GLOBAL_CONFIG_PATH", config_path)
|
||||
monkeypatch.setattr("omnigent.cli._load_local_config", dict)
|
||||
config_path.write_text("harness: claude-sdk\n", encoding="utf-8")
|
||||
monkeypatch.setattr("omnigent.cli._print_credentials_by_harness", lambda: None)
|
||||
|
||||
result = CliRunner().invoke(cli, ["config", "list"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "harness=claude-sdk" in result.output
|
||||
assert "per-harness overrides" not in result.output
|
||||
|
||||
|
||||
def test_config_unset_removes_key(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
|
||||
@@ -74,6 +74,7 @@ def test_executor_factory_reads_env_vars(
|
||||
monkeypatch.setenv("HARNESS_CODEX_GATEWAY_AUTH_REFRESH_INTERVAL_MS", "900000")
|
||||
monkeypatch.setenv("HARNESS_CODEX_CWD", "/tmp/test-cwd")
|
||||
monkeypatch.setenv("HARNESS_CODEX_PATH", "/usr/local/bin/codex")
|
||||
monkeypatch.delenv("OMNIGENT_CODEX_PATH", raising=False)
|
||||
monkeypatch.setenv("HARNESS_CODEX_ENABLE_WEB_SEARCH", "false")
|
||||
monkeypatch.setenv("HARNESS_CODEX_DISABLE_NATIVE_TOOLS", "true")
|
||||
|
||||
|
||||
@@ -1122,6 +1122,7 @@ def test_build_goose_executor_reads_env(monkeypatch) -> None:
|
||||
monkeypatch.setenv("HARNESS_GOOSE_PROVIDER", "anthropic")
|
||||
monkeypatch.setenv("HARNESS_GOOSE_CWD", "/work")
|
||||
monkeypatch.setenv("HARNESS_GOOSE_PATH", "/bin/goose")
|
||||
monkeypatch.delenv("OMNIGENT_GOOSE_PATH", raising=False)
|
||||
monkeypatch.setenv("HARNESS_GOOSE_BUILTINS", "developer, computercontroller")
|
||||
monkeypatch.delenv("HARNESS_GOOSE_OS_ENV", raising=False)
|
||||
ex = goose_harness._build_goose_executor()
|
||||
@@ -1142,6 +1143,7 @@ def test_build_goose_executor_defaults(monkeypatch) -> None:
|
||||
"HARNESS_GOOSE_PATH",
|
||||
"HARNESS_GOOSE_BUILTINS",
|
||||
"OMNIGENT_RUNNER_WORKSPACE",
|
||||
"OMNIGENT_GOOSE_PATH",
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
ex = goose_harness._build_goose_executor()
|
||||
|
||||
@@ -61,6 +61,7 @@ def test_executor_factory_reads_env_vars(
|
||||
monkeypatch.setenv("HARNESS_HERMES_MODEL", "test-model-id")
|
||||
monkeypatch.setenv("HARNESS_HERMES_CWD", "/tmp/test-cwd")
|
||||
monkeypatch.setenv("HARNESS_HERMES_PATH", "/custom/path/hermes")
|
||||
monkeypatch.delenv("OMNIGENT_HERMES_PATH", raising=False)
|
||||
|
||||
executor = hermes_harness._build_hermes_executor()
|
||||
|
||||
@@ -76,6 +77,7 @@ def test_executor_factory_defaults_when_env_unset(
|
||||
monkeypatch.delenv("HARNESS_HERMES_MODEL", raising=False)
|
||||
monkeypatch.delenv("HARNESS_HERMES_CWD", raising=False)
|
||||
monkeypatch.delenv("HARNESS_HERMES_PATH", raising=False)
|
||||
monkeypatch.delenv("OMNIGENT_HERMES_PATH", raising=False)
|
||||
|
||||
executor = hermes_harness._build_hermes_executor()
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ def test_executor_factory_reads_env_vars(monkeypatch: pytest.MonkeyPatch) -> Non
|
||||
monkeypatch.setenv("HARNESS_KIMI_MODEL", "kimi-k2-turbo")
|
||||
monkeypatch.setenv("HARNESS_KIMI_CWD", "/tmp/kimi-cwd")
|
||||
monkeypatch.setenv("HARNESS_KIMI_PATH", "/custom/bin/kimi")
|
||||
monkeypatch.delenv("OMNIGENT_KIMI_PATH", raising=False)
|
||||
monkeypatch.setenv("HARNESS_KIMI_PLAN", "yes")
|
||||
monkeypatch.setenv("HARNESS_KIMI_CONTINUE_LAST", "true")
|
||||
monkeypatch.setenv("HARNESS_KIMI_SKILLS_DIRS", json.dumps(["/a", "/b"]))
|
||||
@@ -106,6 +107,8 @@ def test_executor_factory_defaults_when_env_unset(monkeypatch: pytest.MonkeyPatc
|
||||
# Cleared too: cwd now falls back to it, so a dev with it exported
|
||||
# mustn't flip this default-path assertion.
|
||||
"OMNIGENT_RUNNER_WORKSPACE",
|
||||
# Canonical path env var — would shadow the legacy HARNESS_* delenv above.
|
||||
"OMNIGENT_KIMI_PATH",
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
@@ -202,15 +205,24 @@ def test_parse_truthy(value: str | None, expected: bool) -> None:
|
||||
|
||||
|
||||
def test_resolve_kimi_binary_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("OMNIGENT_KIMI_PATH", raising=False)
|
||||
monkeypatch.delenv("HARNESS_KIMI_PATH", raising=False)
|
||||
assert _resolve_kimi_binary() == "kimi"
|
||||
|
||||
|
||||
def test_resolve_kimi_binary_explicit_override(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("HARNESS_KIMI_PATH", "/opt/bin/kimi")
|
||||
# Canonical OMNIGENT_KIMI_PATH wins.
|
||||
monkeypatch.setenv("OMNIGENT_KIMI_PATH", "/opt/bin/kimi")
|
||||
assert _resolve_kimi_binary() == "/opt/bin/kimi"
|
||||
|
||||
|
||||
def test_resolve_kimi_binary_legacy_override(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Deprecated HARNESS_KIMI_PATH still honored as a fallback.
|
||||
monkeypatch.delenv("OMNIGENT_KIMI_PATH", raising=False)
|
||||
monkeypatch.setenv("HARNESS_KIMI_PATH", "/legacy/bin/kimi")
|
||||
assert _resolve_kimi_binary() == "/legacy/bin/kimi"
|
||||
|
||||
|
||||
def test_latest_user_text_string_message() -> None:
|
||||
messages = [
|
||||
{"role": "system", "content": "be helpful"},
|
||||
|
||||
@@ -83,6 +83,7 @@ def test_executor_factory_reads_env_vars(
|
||||
monkeypatch.setenv("HARNESS_PI_GATEWAY_AUTH_COMMAND", "printf token")
|
||||
monkeypatch.setenv("HARNESS_PI_CWD", "/tmp/test-cwd")
|
||||
monkeypatch.setenv("HARNESS_PI_PATH", "/usr/local/bin/pi")
|
||||
monkeypatch.delenv("OMNIGENT_PI_PATH", raising=False)
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
|
||||
@@ -1324,3 +1324,145 @@ def test_kimi_os_env_serialized(config_home: Path) -> None:
|
||||
assert "HARNESS_KIMI_OS_ENV" in env
|
||||
decoded = _json.loads(env["HARNESS_KIMI_OS_ENV"])
|
||||
assert decoded["sandbox"]["type"] == "darwin_seatbelt"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# harness.<canonical>.command → OMNIGENT_<NAME>_PATH (spawn-env builders)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _call_builder(builder: object, spec: AgentSpec) -> dict[str, str]: # type: ignore[explicit-any]
|
||||
"""Invoke a spawn-env builder, papering over the ``workdir`` signature split.
|
||||
|
||||
``_build_kimi_spawn_env`` takes only ``cwd``; the others accept ``workdir``
|
||||
too. Pass ``cwd=None`` / ``workdir=None`` as appropriate so the path-override
|
||||
tests don't depend on the bundle-dir plumbing.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
params = inspect.signature(builder).parameters # type: ignore[arg-type]
|
||||
kwargs: dict[str, object] = {"cwd": None}
|
||||
if "workdir" in params:
|
||||
kwargs["workdir"] = None
|
||||
return builder(spec, **kwargs) # type: ignore[operator, return-value]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("harness", "builder", "env_var"),
|
||||
[
|
||||
("codex", _build_codex_spawn_env, "OMNIGENT_CODEX_PATH"),
|
||||
("pi", _build_pi_spawn_env, "OMNIGENT_PI_PATH"),
|
||||
("kimi", _build_kimi_spawn_env, "OMNIGENT_KIMI_PATH"),
|
||||
("goose", _build_goose_spawn_env, "OMNIGENT_GOOSE_PATH"),
|
||||
("qwen", _build_qwen_spawn_env, "OMNIGENT_QWEN_PATH"),
|
||||
],
|
||||
)
|
||||
def test_spawn_env_threads_config_command_to_path(
|
||||
config_home: Path,
|
||||
harness: str,
|
||||
builder: object,
|
||||
env_var: str,
|
||||
) -> None:
|
||||
"""A config ``harness.<canonical>.command`` lands as ``OMNIGENT_<NAME>_PATH``."""
|
||||
cfg = _openai_default_config()
|
||||
cfg["harness"] = {harness: {"command": "/custom/bin"}}
|
||||
_write_config(config_home, cfg)
|
||||
spec = _make_spec(harness=harness)
|
||||
|
||||
env = _call_builder(builder, spec)
|
||||
|
||||
assert env[env_var] == "/custom/bin"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("harness", "builder", "env_var"),
|
||||
[
|
||||
("codex", _build_codex_spawn_env, "OMNIGENT_CODEX_PATH"),
|
||||
("pi", _build_pi_spawn_env, "OMNIGENT_PI_PATH"),
|
||||
("kimi", _build_kimi_spawn_env, "OMNIGENT_KIMI_PATH"),
|
||||
("goose", _build_goose_spawn_env, "OMNIGENT_GOOSE_PATH"),
|
||||
("qwen", _build_qwen_spawn_env, "OMNIGENT_QWEN_PATH"),
|
||||
],
|
||||
)
|
||||
def test_spawn_env_ambient_env_wins_over_config_command(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
config_home: Path,
|
||||
harness: str,
|
||||
builder: object,
|
||||
env_var: str,
|
||||
) -> None:
|
||||
"""The ambient ``OMNIGENT_<NAME>_PATH`` env var wins over config ``command``."""
|
||||
cfg = _openai_default_config()
|
||||
cfg["harness"] = {harness: {"command": "/config/bin"}}
|
||||
_write_config(config_home, cfg)
|
||||
monkeypatch.setenv(env_var, "/ambient/bin")
|
||||
spec = _make_spec(harness=harness)
|
||||
|
||||
env = _call_builder(builder, spec)
|
||||
|
||||
# The builder does not override the ambient env var; config is skipped.
|
||||
assert env_var not in env or env[env_var] != "/config/bin"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("harness", "builder"),
|
||||
[
|
||||
("codex", _build_codex_spawn_env),
|
||||
("pi", _build_pi_spawn_env),
|
||||
("kimi", _build_kimi_spawn_env),
|
||||
("goose", _build_goose_spawn_env),
|
||||
("qwen", _build_qwen_spawn_env),
|
||||
],
|
||||
)
|
||||
def test_spawn_env_no_command_emits_no_path(
|
||||
config_home: Path,
|
||||
harness: str,
|
||||
builder: object,
|
||||
) -> None:
|
||||
"""With no config ``command`` and no ambient env var, no ``*_PATH`` is emitted."""
|
||||
_write_config(config_home, _openai_default_config())
|
||||
spec = _make_spec(harness=harness)
|
||||
|
||||
env = _call_builder(builder, spec)
|
||||
|
||||
suffix = harness.upper()
|
||||
# Neither the canonical OMNIGENT_* nor the legacy HARNESS_* is emitted.
|
||||
assert f"OMNIGENT_{suffix}_PATH" not in env
|
||||
assert f"HARNESS_{suffix}_PATH" not in env
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("harness", "builder"),
|
||||
[
|
||||
("codex", _build_codex_spawn_env),
|
||||
("pi", _build_pi_spawn_env),
|
||||
("kimi", _build_kimi_spawn_env),
|
||||
("goose", _build_goose_spawn_env),
|
||||
("qwen", _build_qwen_spawn_env),
|
||||
],
|
||||
)
|
||||
def test_spawn_env_legacy_env_wins_over_config_command(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
config_home: Path,
|
||||
harness: str,
|
||||
builder: object,
|
||||
) -> None:
|
||||
"""A deprecated ``HARNESS_<NAME>_PATH`` env var wins over config ``command``.
|
||||
|
||||
Per ``env > config``, the legacy env var must not be shadowed by config.
|
||||
"""
|
||||
from omnigent.harness_startup_config import _LEGACY_PATH_WARNED
|
||||
|
||||
legacy_var = f"HARNESS_{harness.upper()}_PATH"
|
||||
_LEGACY_PATH_WARNED.discard(legacy_var)
|
||||
monkeypatch.delenv(f"OMNIGENT_{harness.upper()}_PATH", raising=False)
|
||||
monkeypatch.setenv(legacy_var, "/legacy/bin")
|
||||
cfg = _openai_default_config()
|
||||
cfg["harness"] = {harness: {"command": "/config/bin"}}
|
||||
_write_config(config_home, cfg)
|
||||
spec = _make_spec(harness=harness)
|
||||
|
||||
env = _call_builder(builder, spec)
|
||||
|
||||
# The builder must not set OMNIGENT_* from config when the legacy env wins.
|
||||
assert f"OMNIGENT_{harness.upper()}_PATH" not in env
|
||||
|
||||
+51
-1
@@ -6,7 +6,57 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from omnigent.config import global_config_path, load_effective_config
|
||||
from omnigent.config import _merge_effective_config, global_config_path, load_effective_config
|
||||
|
||||
|
||||
def test_effective_config_deep_merges_harness_mapping(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
config_home = tmp_path / "home"
|
||||
project = tmp_path / "project"
|
||||
config_home.mkdir()
|
||||
(project / ".omnigent").mkdir(parents=True)
|
||||
(config_home / "config.yaml").write_text(
|
||||
"harness:\n default: claude-sdk\n claude-sdk:\n command: /global/claude\n"
|
||||
" codex:\n args: [--config, k=v]\n"
|
||||
)
|
||||
(project / ".omnigent" / "config.yaml").write_text(
|
||||
"harness:\n codex:\n command: /local/codex\n"
|
||||
)
|
||||
monkeypatch.setenv("OMNIGENT_CONFIG_HOME", str(config_home))
|
||||
monkeypatch.chdir(project)
|
||||
|
||||
cfg = load_effective_config()
|
||||
harness = cfg["harness"]
|
||||
assert harness["default"] == "claude-sdk"
|
||||
# Global-only entry preserved (a flat merge would have dropped it).
|
||||
assert harness["claude-sdk"] == {"command": "/global/claude"}
|
||||
# Local per-harness entry augments the global one: local command wins,
|
||||
# global args preserved (per-field, not whole-entry replace).
|
||||
assert harness["codex"] == {"args": ["--config", "k=v"], "command": "/local/codex"}
|
||||
|
||||
|
||||
def test_merge_effective_config_scalar_local_overrides_mapping_global() -> None:
|
||||
# A scalar on either side is an explicit whole-value override: the
|
||||
# shallow {**global, **local} result holds (no deep-merge).
|
||||
g = {"harness": {"default": "claude-sdk", "codex": {"args": ["x"]}}}
|
||||
loc = {"harness": "codex"}
|
||||
assert _merge_effective_config(g, loc) == {"harness": "codex"}
|
||||
|
||||
|
||||
def test_merge_effective_config_scalar_global_no_deep_merge() -> None:
|
||||
# Global scalar + local mapping: local (mapping) wins outright as a
|
||||
# whole-value replace — only deep-merge when BOTH are mappings.
|
||||
g = {"harness": "claude-sdk"}
|
||||
loc = {"harness": {"default": "codex"}}
|
||||
assert _merge_effective_config(g, loc) == {"harness": {"default": "codex"}}
|
||||
|
||||
|
||||
def test_merge_effective_config_no_harness_key_unchanged() -> None:
|
||||
assert _merge_effective_config({"model": "x"}, {"server": "y"}) == {
|
||||
"model": "x",
|
||||
"server": "y",
|
||||
}
|
||||
|
||||
|
||||
def test_global_config_path_respects_config_home(
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
"""Unit tests for :mod:`omnigent.harness_startup_config`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from omnigent.harness_startup_config import (
|
||||
resolve_harness_args,
|
||||
resolve_harness_command,
|
||||
resolve_harness_config,
|
||||
resolve_harness_path,
|
||||
)
|
||||
|
||||
# ── resolve_harness_config ───────────────────────────────────────────
|
||||
|
||||
|
||||
def test_scalar_harness_returns_default_no_overrides() -> None:
|
||||
assert resolve_harness_config({"harness": "claude-sdk"}) == ("claude-sdk", {})
|
||||
|
||||
|
||||
def test_absent_harness_returns_none_no_overrides() -> None:
|
||||
assert resolve_harness_config({}) == (None, {})
|
||||
|
||||
|
||||
def test_mapping_with_default_and_overrides() -> None:
|
||||
cfg = {
|
||||
"harness": {
|
||||
"default": "claude-sdk",
|
||||
"claude-sdk": {"command": "/usr/local/bin/claude"},
|
||||
"codex": {"args": ["--config", "approval_policy=on-request"]},
|
||||
}
|
||||
}
|
||||
default, overrides = resolve_harness_config(cfg)
|
||||
assert default == "claude-sdk"
|
||||
assert overrides == {
|
||||
"claude-sdk": {"command": "/usr/local/bin/claude"},
|
||||
"codex": {"args": ["--config", "approval_policy=on-request"]},
|
||||
}
|
||||
|
||||
|
||||
def test_alias_canonicalizes_to_one_override_slot() -> None:
|
||||
# ``claude`` is an alias for ``claude-sdk``; both should land in the
|
||||
# same slot, with a later entry's fields merging in.
|
||||
cfg = {
|
||||
"harness": {
|
||||
"claude": {"command": "/bin/claude"},
|
||||
"claude-sdk": {"args": ["--dangerously-skip-permissions"]},
|
||||
}
|
||||
}
|
||||
_, overrides = resolve_harness_config(cfg)
|
||||
assert overrides == {
|
||||
"claude-sdk": {
|
||||
"command": "/bin/claude",
|
||||
"args": ["--dangerously-skip-permissions"],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_non_string_default_warns_and_skips(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
_, overrides = resolve_harness_config({"harness": {"default": 123}})
|
||||
assert overrides == {}
|
||||
assert "harness.default" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_non_mapping_harness_warns_and_returns_none(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
default, overrides = resolve_harness_config({"harness": ["claude-sdk"]})
|
||||
assert default is None
|
||||
assert overrides == {}
|
||||
assert "harness:" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_malformed_entry_warns_and_skips(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
cfg = {
|
||||
"harness": {
|
||||
"codex": "not-a-mapping",
|
||||
"pi": {"command": "/bin/pi", "args": "not-a-list"},
|
||||
"kimi": {"command": ""},
|
||||
}
|
||||
}
|
||||
_, overrides = resolve_harness_config(cfg)
|
||||
# codex dropped (not a mapping); pi.command kept, pi.args dropped; kimi.command
|
||||
# dropped (empty).
|
||||
assert overrides == {"pi": {"command": "/bin/pi"}}
|
||||
err = capsys.readouterr().err
|
||||
assert "harness.codex" in err
|
||||
assert "harness.pi.args" in err
|
||||
assert "harness.kimi.command" in err
|
||||
|
||||
|
||||
def test_args_must_be_list_of_strings(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
cfg = {"harness": {"codex": {"args": [1, 2]}}}
|
||||
_, overrides = resolve_harness_config(cfg)
|
||||
assert overrides == {}
|
||||
assert "harness.codex.args" in capsys.readouterr().err
|
||||
|
||||
|
||||
# ── resolve_harness_command ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_command_explicit_flag_wins(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("OMNIGENT_CODEX_PATH", "/env/codex")
|
||||
cfg = {"harness": {"codex": {"command": "/config/codex"}}}
|
||||
assert (
|
||||
resolve_harness_command("codex", default="codex", explicit="/explicit/codex", cfg=cfg)
|
||||
== "/explicit/codex"
|
||||
)
|
||||
|
||||
|
||||
def test_command_env_var_wins_over_config(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("OMNIGENT_CODEX_PATH", "/env/codex")
|
||||
cfg = {"harness": {"codex": {"command": "/config/codex"}}}
|
||||
assert (
|
||||
resolve_harness_command("codex", default="codex", explicit=None, cfg=cfg) == "/env/codex"
|
||||
)
|
||||
|
||||
|
||||
def test_command_config_wins_over_default(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("OMNIGENT_CODEX_PATH", raising=False)
|
||||
monkeypatch.delenv("HARNESS_CODEX_PATH", raising=False)
|
||||
cfg = {"harness": {"codex": {"command": "/config/codex"}}}
|
||||
assert (
|
||||
resolve_harness_command("codex", default="codex", explicit=None, cfg=cfg)
|
||||
== "/config/codex"
|
||||
)
|
||||
|
||||
|
||||
def test_command_legacy_env_wins_over_config(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A deprecated ``HARNESS_*_PATH`` env var still wins over config ``command``.
|
||||
|
||||
Per the shared ``env > config > default`` precedence, the legacy env var
|
||||
must not be shadowed by a config override — otherwise a user migrating
|
||||
from ``HARNESS_*_PATH`` to the new config form would silently get the
|
||||
config value instead of their env var during the deprecation window.
|
||||
"""
|
||||
from omnigent.harness_startup_config import _LEGACY_PATH_WARNED
|
||||
|
||||
_LEGACY_PATH_WARNED.discard("HARNESS_CODEX_PATH")
|
||||
monkeypatch.delenv("OMNIGENT_CODEX_PATH", raising=False)
|
||||
monkeypatch.setenv("HARNESS_CODEX_PATH", "/legacy/env/codex")
|
||||
cfg = {"harness": {"codex": {"command": "/config/codex"}}}
|
||||
assert (
|
||||
resolve_harness_command("codex", default="codex", explicit=None, cfg=cfg)
|
||||
== "/legacy/env/codex"
|
||||
)
|
||||
|
||||
|
||||
def test_command_falls_back_to_default(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("OMNIGENT_CODEX_PATH", raising=False)
|
||||
assert resolve_harness_command("codex", default="codex", explicit=None, cfg={}) == "codex"
|
||||
|
||||
|
||||
def test_command_canonical_id_for_native_harness(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# ``codex-native`` strips the ``-native`` suffix → ``OMNIGENT_CODEX_PATH``
|
||||
# (shared with the headless ``codex`` harness — one var per binary).
|
||||
monkeypatch.setenv("OMNIGENT_CODEX_PATH", "/env/codex-native")
|
||||
assert (
|
||||
resolve_harness_command("codex-native", default="codex", explicit=None, cfg={})
|
||||
== "/env/codex-native"
|
||||
)
|
||||
|
||||
|
||||
def test_command_alias_resolves_to_canonical_env_var(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# ``claude`` alias → ``claude-sdk`` which runs the ``claude`` binary, so the
|
||||
# env var is ``OMNIGENT_CLAUDE_PATH`` (the binary's var, not the id's var).
|
||||
monkeypatch.delenv("OMNIGENT_CLAUDE_SDK_PATH", raising=False)
|
||||
monkeypatch.setenv("OMNIGENT_CLAUDE_PATH", "/env/claude")
|
||||
assert (
|
||||
resolve_harness_command("claude", default="claude", explicit=None, cfg={}) == "/env/claude"
|
||||
)
|
||||
|
||||
|
||||
def test_command_empty_explicit_falls_through(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("OMNIGENT_CODEX_PATH", "/env/codex")
|
||||
# An empty --command flag should not shadow the env var.
|
||||
assert (
|
||||
resolve_harness_command("codex", default="codex", explicit=" ", cfg={}) == "/env/codex"
|
||||
)
|
||||
|
||||
|
||||
# ── resolve_harness_path (env deprecation) ──────────────────────────
|
||||
|
||||
|
||||
def test_resolve_harness_path_canonical_env_wins(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("OMNIGENT_CODEX_PATH", "/canonical/codex")
|
||||
monkeypatch.setenv("HARNESS_CODEX_PATH", "/legacy/codex")
|
||||
assert resolve_harness_path("codex") == "/canonical/codex"
|
||||
|
||||
|
||||
def test_resolve_harness_path_legacy_env_warns(monkeypatch: pytest.MonkeyPatch, caplog) -> None:
|
||||
"""A legacy ``HARNESS_<NAME>_PATH`` value is returned + a deprecation warning."""
|
||||
from omnigent.harness_startup_config import _LEGACY_PATH_WARNED
|
||||
|
||||
_LEGACY_PATH_WARNED.discard("HARNESS_CODEX_PATH") # ensure not pre-warned
|
||||
monkeypatch.delenv("OMNIGENT_CODEX_PATH", raising=False)
|
||||
monkeypatch.setenv("HARNESS_CODEX_PATH", "/legacy/codex")
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
assert resolve_harness_path("codex") == "/legacy/codex"
|
||||
|
||||
assert any(
|
||||
"HARNESS_CODEX_PATH" in r.message and "deprecated" in r.message and "v0.8.0" in r.message
|
||||
for r in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_harness_path_legacy_warns_only_once(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog
|
||||
) -> None:
|
||||
"""The deprecation warning fires once per process per legacy var."""
|
||||
from omnigent.harness_startup_config import _LEGACY_PATH_WARNED
|
||||
|
||||
_LEGACY_PATH_WARNED.discard("HARNESS_CODEX_PATH")
|
||||
monkeypatch.delenv("OMNIGENT_CODEX_PATH", raising=False)
|
||||
monkeypatch.setenv("HARNESS_CODEX_PATH", "/legacy/codex")
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
resolve_harness_path("codex")
|
||||
resolve_harness_path("codex")
|
||||
resolve_harness_path("codex")
|
||||
|
||||
warns = [
|
||||
r
|
||||
for r in caplog.records
|
||||
if "HARNESS_CODEX_PATH" in r.message and "deprecated" in r.message
|
||||
]
|
||||
assert len(warns) == 1
|
||||
|
||||
|
||||
def test_resolve_harness_path_neither_set_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("OMNIGENT_CODEX_PATH", raising=False)
|
||||
monkeypatch.delenv("HARNESS_CODEX_PATH", raising=False)
|
||||
assert resolve_harness_path("codex") is None
|
||||
|
||||
|
||||
def test_resolve_harness_path_ignores_non_registry_legacy_var(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A speculative ``HARNESS_*_PATH`` for a harness that never had one is ignored.
|
||||
|
||||
Only the 6 headless harnesses (codex/pi/kimi/goose/qwen/hermes) historically
|
||||
documented a ``HARNESS_*_PATH``. Other harnesses (e.g. cursor) never did —
|
||||
honoring ``HARNESS_CURSOR_PATH`` would invent a new knob under a deprecated
|
||||
name, so it's ignored (only the canonical ``OMNIGENT_CURSOR_PATH`` works).
|
||||
"""
|
||||
monkeypatch.delenv("OMNIGENT_CURSOR_PATH", raising=False)
|
||||
monkeypatch.setenv("HARNESS_CURSOR_PATH", "/speculative/cursor")
|
||||
assert resolve_harness_path("cursor") is None
|
||||
|
||||
|
||||
def test_resolve_harness_path_strips_native_suffix() -> None:
|
||||
"""pi-native and pi share OMNIGENT_PI_PATH."""
|
||||
import omnigent.harness_startup_config as m
|
||||
|
||||
assert m._harness_path_env_var("pi-native") == "OMNIGENT_PI_PATH"
|
||||
assert m._harness_path_env_var("pi") == "OMNIGENT_PI_PATH"
|
||||
|
||||
|
||||
# ── resolve_harness_args ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_args_cli_only_when_no_config() -> None:
|
||||
assert resolve_harness_args("codex", ("--verbose",), cfg={}) == ["--verbose"]
|
||||
|
||||
|
||||
def test_args_config_base_then_cli() -> None:
|
||||
cfg = {"harness": {"codex": {"args": ["--config", "k=v"]}}}
|
||||
assert resolve_harness_args("codex", ("--dangerously-skip-permissions",), cfg=cfg) == [
|
||||
"--config",
|
||||
"k=v",
|
||||
"--dangerously-skip-permissions",
|
||||
]
|
||||
|
||||
|
||||
def test_args_config_base_with_empty_cli() -> None:
|
||||
cfg = {"harness": {"codex": {"args": ["--config", "k=v"]}}}
|
||||
assert resolve_harness_args("codex", (), cfg=cfg) == ["--config", "k=v"]
|
||||
|
||||
|
||||
def test_args_alias_canonicalized() -> None:
|
||||
# ``claude`` alias → ``claude-sdk`` override slot.
|
||||
cfg = {"harness": {"claude-sdk": {"args": ["--base"]}}}
|
||||
assert resolve_harness_args("claude", ("--cli",), cfg=cfg) == ["--base", "--cli"]
|
||||
|
||||
|
||||
def test_args_no_config_layer() -> None:
|
||||
assert resolve_harness_args("codex", ("--verbose",), cfg=None) == ["--verbose"]
|
||||
Reference in New Issue
Block a user