From b5426098c462812e4dabf0e83d6291e9a1b62c6e Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:28:36 -0700 Subject: [PATCH 1/2] bootstrap: skip Voice AI without a phone; add failed_step; doctor checks auth Three robustness fixes uncovered running the onboarding flow end-to-end: - Voice AI: a freshly signed-up identity has no phone number, so the voice endpoints 404 ("No inbound-call config set for this identity") and aborted the entire bootstrap. Catch that specific 404, record a `skipped_voice_ai_no_phone` action, and continue to signing + gateway. Real (non-404) errors still propagate. - Errors now carry `failed_step` so a bare "HTTP 404" is attributable to the step that raised it instead of requiring a source read. - doctor: add a real "claude auth" check (ANTHROPIC_API_KEY or ~/.claude/.credentials.json). The prior "claude CLI" check only verified the binary was on PATH, so doctor reported green while the gateway couldn't actually answer. Co-Authored-By: Claude Opus 4.8 (1M context) --- inkbox_claude/bootstrap.py | 27 +++++++++++++++++++++++---- inkbox_claude/doctor.py | 11 +++++++++++ tests/test_bootstrap.py | 33 +++++++++++++++++++++++++++++++++ tests/test_doctor.py | 17 +++++++++++++++++ 4 files changed, 84 insertions(+), 4 deletions(-) diff --git a/inkbox_claude/bootstrap.py b/inkbox_claude/bootstrap.py index 4be3dec..36783f1 100644 --- a/inkbox_claude/bootstrap.py +++ b/inkbox_claude/bootstrap.py @@ -22,6 +22,12 @@ def _redact(exc: Exception, secrets: list[str]) -> str: return message +def _skip_voice_on_missing_config(exc: Exception) -> bool: + """Voice AI needs a provisioned phone/inbound-call config; without one the + voice endpoints return 404. Treat that as skippable rather than fatal.""" + return getattr(exc, "status_code", None) == 404 + + def _identity_for_key(client: Any, expected: str) -> Any: handles = {_handle(str(getattr(item, "agent_handle", ""))) for item in client.list_identities()} if expected not in handles: @@ -194,12 +200,15 @@ def bootstrap( return {"status": "error", "error": "API key is required"} actions: list[str] = [] secrets = [api_key.strip()] + step = "start" try: previous = _handle(_env("INKBOX_IDENTITY")) symbols = _load_inkbox_symbols() + step = "resolve_credentials" scoped_key, identity = _resolve_credentials(api_key.strip(), handle, base_url, symbols, actions) secrets.append(scoped_key) client = symbols["Inkbox"](**inkbox_client_kwargs(scoped_key, base_url)) + step = "save_configuration" _save("INKBOX_API_KEY", scoped_key) _save("INKBOX_IDENTITY", handle) if base_url: @@ -209,16 +218,26 @@ def bootstrap( _save("INKBOX_ALLOW_ALL_USERS", "true") actions.append("saved_claude_configuration") if voice_ai: - _configure_voice(identity, client, voice_ai_instructions) - actions.append("configured_voice_ai") + step = "configure_voice" + try: + _configure_voice(identity, client, voice_ai_instructions) + actions.append("configured_voice_ai") + except Exception as exc: + # A fresh identity has no phone number, so skip voice rather + # than abort the whole bootstrap; real errors still propagate. + if not _skip_voice_on_missing_config(exc): + raise + actions.append("skipped_voice_ai_no_phone") + step = "configure_signing" blocker = _configure_signing(identity, client, rotate_signing_key, not previous or previous == handle, actions) if blocker: return {"status": "requires_human", "identity": handle, "actions": actions, "human_actions": [blocker]} running = False if start_gateway: + step = "start_gateway" running = _start_gateway(actions) if not running: - return {"status": "error", "identity": handle, "actions": actions, "error": "Claude Code gateway did not become ready. Check ~/.inkbox-claude/gateway.log."} + return {"status": "error", "identity": handle, "actions": actions, "failed_step": step, "error": "Claude Code gateway did not become ready. Check ~/.inkbox-claude/gateway.log."} return {"status": "configured", "identity": handle, "actions": actions, "gateway_running": running} except Exception as exc: - return {"status": "error", "identity": handle, "actions": actions, "error": _redact(exc, secrets)} + return {"status": "error", "identity": handle, "actions": actions, "failed_step": step, "error": _redact(exc, secrets)} diff --git a/inkbox_claude/doctor.py b/inkbox_claude/doctor.py index defb1a0..8706522 100644 --- a/inkbox_claude/doctor.py +++ b/inkbox_claude/doctor.py @@ -79,6 +79,17 @@ def run_doctor() -> List[Tuple[str, bool, str]]: claude_bin or "not on PATH — install Claude Code first", )) + # Presence of the binary isn't enough; the gateway can't answer without auth. + claude_authed = bool(os.environ.get("ANTHROPIC_API_KEY")) or os.path.isfile( + os.path.expanduser("~/.claude/.credentials.json") + ) + checks.append(( + "claude auth", + claude_authed, + "authenticated" if claude_authed + else "not authenticated — set ANTHROPIC_API_KEY or log in with the Claude Code app/CLI", + )) + project_dir = cfg.project_dir checks.append(( "project dir", diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 67f5308..dbf6230 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -75,6 +75,39 @@ def test_bootstrap_configures_voice_signing_and_gateway(monkeypatch): assert saved["INKBOX_SIGNING_KEY"] == "signing-secret" +class ApiError(Exception): + def __init__(self, status_code, message="api error"): + super().__init__(f"HTTP {status_code}: {message}") + self.status_code = status_code + + +def test_bootstrap_skips_voice_when_identity_has_no_phone(monkeypatch): + identity = Identity() + # A fresh identity has no phone: the inbound-call config endpoint 404s. + identity.get_incoming_call_action = lambda: (_ for _ in ()).throw( + ApiError(404, "No inbound-call config set for this identity") + ) + saved = install(monkeypatch, identity) + monkeypatch.setattr(subject, "_start_gateway", lambda actions: actions.append("started_gateway_process") or True) + result = subject.bootstrap(identity_handle="helper", api_key="agent-secret", voice_ai=True, rotate_signing_key=True, start_gateway=True) + assert result["status"] == "configured" + assert "skipped_voice_ai_no_phone" in result["actions"] + assert "configured_voice_ai" not in result["actions"] + # Signing and gateway still ran despite the voice skip. + assert saved["INKBOX_SIGNING_KEY"] == "signing-secret" + assert result["gateway_running"] is True + + +def test_bootstrap_reports_failed_step_and_propagates_non_404(monkeypatch): + identity = Identity() + identity.get_hosted_agent_config = lambda: (_ for _ in ()).throw(ApiError(500, "boom")) + install(monkeypatch, identity) + result = subject.bootstrap(identity_handle="helper", api_key="agent-secret", voice_ai=True) + assert result["status"] == "error" + assert result["failed_step"] == "configure_voice" + assert "skipped_voice_ai_no_phone" not in result["actions"] + + def test_bootstrap_requires_explicit_signing_rotation(monkeypatch): identity = Identity(signing=True) install(monkeypatch, identity) diff --git a/tests/test_doctor.py b/tests/test_doctor.py index ef2c432..b29b7d1 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -57,6 +57,23 @@ def test_voice_config_probe_failures_do_not_mark_identity_unreachable( ) +def test_doctor_reports_claude_auth_state(monkeypatch, tmp_path): + monkeypatch.setattr(daemon, "_maybe_load_env_file", lambda: None) + monkeypatch.setattr(doctor.shutil, "which", lambda _name: "/usr/bin/claude") + monkeypatch.setattr(doctor, "read_config", lambda: BridgeConfig(project_dir=str(tmp_path))) + + # No ANTHROPIC_API_KEY and no credentials file -> not authenticated. + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.setattr(doctor.os.path, "isfile", lambda _p: False) + by_name = {name: ok for name, ok, _detail in doctor.run_doctor()} + assert by_name["claude auth"] is False + + # Env var present -> authenticated. + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test") + by_name = {name: ok for name, ok, _detail in doctor.run_doctor()} + assert by_name["claude auth"] is True + + def test_doctor_reports_remote_routing_mismatch(monkeypatch, tmp_path): identity = types.SimpleNamespace( mailbox=types.SimpleNamespace(email_address="agent@example.com"), From b3bde5c09c13b94b5de6feb2754ce932154706f8 Mon Sep 17 00:00:00 2001 From: Ray Liao <17989965+rayruizhiliao@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:55:31 -0700 Subject: [PATCH 2/2] bootstrap: drop the Voice AI graceful-skip (#2) from this PR Per review, remove the skipped_voice_ai_no_phone handling and its helper; keep only the failing-step attribution (#3) and the doctor auth check (#4). The `step` tracking remains, so a voice error is still reported with failed_step="configure_voice". Co-Authored-By: Claude Opus 4.8 (1M context) --- inkbox_claude/bootstrap.py | 17 ++--------------- tests/test_bootstrap.py | 28 ++-------------------------- 2 files changed, 4 insertions(+), 41 deletions(-) diff --git a/inkbox_claude/bootstrap.py b/inkbox_claude/bootstrap.py index 36783f1..29af483 100644 --- a/inkbox_claude/bootstrap.py +++ b/inkbox_claude/bootstrap.py @@ -22,12 +22,6 @@ def _redact(exc: Exception, secrets: list[str]) -> str: return message -def _skip_voice_on_missing_config(exc: Exception) -> bool: - """Voice AI needs a provisioned phone/inbound-call config; without one the - voice endpoints return 404. Treat that as skippable rather than fatal.""" - return getattr(exc, "status_code", None) == 404 - - def _identity_for_key(client: Any, expected: str) -> Any: handles = {_handle(str(getattr(item, "agent_handle", ""))) for item in client.list_identities()} if expected not in handles: @@ -219,15 +213,8 @@ def bootstrap( actions.append("saved_claude_configuration") if voice_ai: step = "configure_voice" - try: - _configure_voice(identity, client, voice_ai_instructions) - actions.append("configured_voice_ai") - except Exception as exc: - # A fresh identity has no phone number, so skip voice rather - # than abort the whole bootstrap; real errors still propagate. - if not _skip_voice_on_missing_config(exc): - raise - actions.append("skipped_voice_ai_no_phone") + _configure_voice(identity, client, voice_ai_instructions) + actions.append("configured_voice_ai") step = "configure_signing" blocker = _configure_signing(identity, client, rotate_signing_key, not previous or previous == handle, actions) if blocker: diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index dbf6230..ab88b03 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -75,37 +75,13 @@ def test_bootstrap_configures_voice_signing_and_gateway(monkeypatch): assert saved["INKBOX_SIGNING_KEY"] == "signing-secret" -class ApiError(Exception): - def __init__(self, status_code, message="api error"): - super().__init__(f"HTTP {status_code}: {message}") - self.status_code = status_code - - -def test_bootstrap_skips_voice_when_identity_has_no_phone(monkeypatch): - identity = Identity() - # A fresh identity has no phone: the inbound-call config endpoint 404s. - identity.get_incoming_call_action = lambda: (_ for _ in ()).throw( - ApiError(404, "No inbound-call config set for this identity") - ) - saved = install(monkeypatch, identity) - monkeypatch.setattr(subject, "_start_gateway", lambda actions: actions.append("started_gateway_process") or True) - result = subject.bootstrap(identity_handle="helper", api_key="agent-secret", voice_ai=True, rotate_signing_key=True, start_gateway=True) - assert result["status"] == "configured" - assert "skipped_voice_ai_no_phone" in result["actions"] - assert "configured_voice_ai" not in result["actions"] - # Signing and gateway still ran despite the voice skip. - assert saved["INKBOX_SIGNING_KEY"] == "signing-secret" - assert result["gateway_running"] is True - - -def test_bootstrap_reports_failed_step_and_propagates_non_404(monkeypatch): +def test_bootstrap_reports_failed_step_on_error(monkeypatch): identity = Identity() - identity.get_hosted_agent_config = lambda: (_ for _ in ()).throw(ApiError(500, "boom")) + identity.get_hosted_agent_config = lambda: (_ for _ in ()).throw(RuntimeError("boom")) install(monkeypatch, identity) result = subject.bootstrap(identity_handle="helper", api_key="agent-secret", voice_ai=True) assert result["status"] == "error" assert result["failed_step"] == "configure_voice" - assert "skipped_voice_ai_no_phone" not in result["actions"] def test_bootstrap_requires_explicit_signing_rotation(monkeypatch):