Skip to content

Commit 83f172d

Browse files
committed
claude: fail loudly on an unresolvable --settings value
Per review on #189: a caller --settings value that ucode could not parse was silently swallowed (returned None) and then passed through as a second --settings flag. Claude Code honors only one --settings, so that collision silently dropped either the caller's settings or ucode's gateway config (breaking auth). Raise an actionable RuntimeError instead — malformed inline JSON, a malformed/unreadable settings file, a nonexistent file path, or non-object JSON — surfaced by the CLI as a clean error. Removes the pass-through branch, which never helped (the colliding flag always loses). Co-authored-by: Isaac
1 parent 3d8f985 commit 83f172d

2 files changed

Lines changed: 67 additions & 35 deletions

File tree

src/ucode/agents/claude.py

Lines changed: 35 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -522,23 +522,42 @@ def _extract_caller_settings(tool_args: list[str]) -> tuple[list[str], list[str]
522522
return values, remaining
523523

524524

525-
def _load_caller_settings(value: str) -> dict | None:
525+
def _load_caller_settings(value: str) -> dict:
526526
"""Resolve a ``--settings`` value (inline JSON or file path) to a dict.
527527
528-
Returns ``None`` when it is neither parseable JSON nor an existing file, so
529-
the caller can pass such a value through untouched instead of dropping it.
528+
Claude Code accepts either inline JSON or a path to a JSON file. Raises
529+
``RuntimeError`` (surfaced by the CLI as an actionable error) when the value
530+
is neither, rather than silently dropping it: a dropped value would also be
531+
passed through as a second ``--settings`` flag, and Claude Code honors only
532+
one — so either the caller's settings or ucode's gateway config would be
533+
silently ignored. Failing loudly lets the caller fix their input.
530534
"""
531535
text = value.strip()
532536
if text.startswith("{"):
537+
source, malformed = text, "value is not valid JSON"
538+
else:
539+
path = Path(text)
540+
if not path.exists():
541+
raise RuntimeError(
542+
f"--settings file not found: {value!r}. "
543+
"Pass inline JSON or a path to an existing JSON file."
544+
)
533545
try:
534-
parsed = json.loads(text)
535-
except json.JSONDecodeError:
536-
return None
537-
return parsed if isinstance(parsed, dict) else None
538-
path = Path(text)
539-
if path.exists():
540-
return read_json_safe(path)
541-
return None
546+
source = path.read_text(encoding="utf-8")
547+
except OSError as exc:
548+
raise RuntimeError(f"--settings file could not be read: {value!r} ({exc}).") from exc
549+
malformed = "file is not valid JSON"
550+
try:
551+
parsed = json.loads(source)
552+
except json.JSONDecodeError as exc:
553+
raise RuntimeError(
554+
f"--settings {malformed} ({exc}): {value!r}. Pass inline JSON or a path to a JSON file."
555+
) from exc
556+
if not isinstance(parsed, dict):
557+
raise RuntimeError(
558+
f"--settings must be a JSON object, got {type(parsed).__name__}: {value!r}."
559+
)
560+
return parsed
542561

543562

544563
def _union_claude_hooks(base: dict, overlay: dict) -> dict:
@@ -589,30 +608,22 @@ def _build_claude_argv(binary: str, tool_args: list[str]) -> list[str]:
589608
keys win, hooks from both are unioned — and hand Claude a single merged
590609
``--settings`` (inline JSON). The merge is per-launch and is never written
591610
back to the shared ucode settings file, so concurrent launches cannot
592-
accumulate one another's hooks.
611+
accumulate one another's hooks. A caller ``--settings`` value ucode cannot
612+
resolve raises (see :func:`_load_caller_settings`) rather than being passed
613+
through as a second, colliding flag.
593614
"""
594615
caller_values, remaining = _extract_caller_settings(tool_args)
595616
if not caller_values:
596617
# No caller --settings: hand Claude ucode's settings file directly (the
597618
# common path; behavior unchanged).
598619
return [binary, "--settings", str(CLAUDE_SETTINGS_PATH), *tool_args]
599620
caller_settings: dict = {}
600-
unparsed: list[str] = []
601621
for value in caller_values:
602-
parsed = _load_caller_settings(value)
603-
if parsed is None:
604-
unparsed.append(value)
605-
continue
606-
caller_settings = _merge_claude_settings(caller_settings, parsed)
622+
caller_settings = _merge_claude_settings(caller_settings, _load_caller_settings(value))
607623
# ucode wins over the caller for conflicting keys (protects gateway auth);
608624
# hooks from both sides survive.
609625
merged = _merge_claude_settings(caller_settings, read_json_safe(CLAUDE_SETTINGS_PATH))
610-
argv = [binary, "--settings", json.dumps(merged, separators=(",", ":")), *remaining]
611-
# Anything we could not parse (not JSON, not an existing file) is passed
612-
# through untouched rather than silently dropped.
613-
for value in unparsed:
614-
argv.extend(["--settings", value])
615-
return argv
626+
return [binary, "--settings", json.dumps(merged, separators=(",", ":")), *remaining]
616627

617628

618629
def launch(state: dict, tool_args: list[str]) -> None:

tests/test_agent_claude.py

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import json
66
import os
77

8+
import pytest
9+
810
from ucode.agents import claude
911

1012
WS = "https://example.databricks.com"
@@ -587,21 +589,40 @@ def test_file_path_caller_settings(self, tmp_path, monkeypatch):
587589
{"hooks": {"SessionStart": [{"hooks": [{"type": "command", "command": "cs"}]}]}}
588590
)
589591
)
590-
591-
def fake_read(p):
592-
if str(p) == str(claude.CLAUDE_SETTINGS_PATH):
593-
return {"apiKeyHelper": "u"}
594-
return json.loads(p.read_text())
595-
596-
monkeypatch.setattr(claude, "read_json_safe", fake_read)
592+
# The caller file is read directly; read_json_safe is only used for
593+
# ucode's own settings file.
594+
monkeypatch.setattr(claude, "read_json_safe", lambda p: {"apiKeyHelper": "u"})
597595
argv = claude._build_claude_argv("claude", ["--settings", str(caller_file)])
598596
assert argv.count("--settings") == 1
599597
merged = json.loads(argv[2])
600598
assert merged["apiKeyHelper"] == "u"
601599
assert merged["hooks"]["SessionStart"][0]["hooks"][0]["command"] == "cs"
602600

603-
def test_unparseable_value_passed_through(self, monkeypatch):
601+
def test_malformed_inline_json_raises(self, monkeypatch):
602+
monkeypatch.setattr(claude, "read_json_safe", lambda p: {"apiKeyHelper": "u"})
603+
# Clearly-intended-as-JSON but broken: fail loudly rather than pass it
604+
# through as a second, colliding --settings flag.
605+
with pytest.raises(RuntimeError, match="not valid JSON"):
606+
claude._build_claude_argv("claude", ["--settings", '{"hooks": '])
607+
608+
def test_nonexistent_file_raises(self, monkeypatch):
609+
monkeypatch.setattr(claude, "read_json_safe", lambda p: {"apiKeyHelper": "u"})
610+
with pytest.raises(RuntimeError, match="file not found"):
611+
claude._build_claude_argv("claude", ["--settings", "/no/such/settings.json"])
612+
613+
def test_non_object_file_json_raises(self, tmp_path, monkeypatch):
614+
# A --settings file whose JSON is not an object (e.g. an array) can't be
615+
# merged; fail loudly. (An inline value only enters the JSON branch when
616+
# it starts with "{", so the non-object case is reachable via a file.)
617+
bad_file = tmp_path / "bad.json"
618+
bad_file.write_text("[1, 2, 3]")
619+
monkeypatch.setattr(claude, "read_json_safe", lambda p: {"apiKeyHelper": "u"})
620+
with pytest.raises(RuntimeError, match="must be a JSON object"):
621+
claude._build_claude_argv("claude", ["--settings", str(bad_file)])
622+
623+
def test_malformed_file_json_raises(self, tmp_path, monkeypatch):
624+
bad_file = tmp_path / "bad.json"
625+
bad_file.write_text('{"hooks": ')
604626
monkeypatch.setattr(claude, "read_json_safe", lambda p: {"apiKeyHelper": "u"})
605-
argv = claude._build_claude_argv("claude", ["--settings", "not-json-not-a-file"])
606-
# Could not parse → passed through untouched rather than dropped.
607-
assert "not-json-not-a-file" in argv
627+
with pytest.raises(RuntimeError, match="not valid JSON"):
628+
claude._build_claude_argv("claude", ["--settings", str(bad_file)])

0 commit comments

Comments
 (0)