fix(cli): let the TUI wizard create custom relay connections - #3467
Conversation
The setup wizard filtered out every provider without a built-in base URL — an explicit phase-1 scope cut (apache#1254) whose phase-2 base-URL prompt never landed — and the onboarding protocol had no field to carry an endpoint anyway, so the three custom relays were creatable from Desktop but unreachable from the TUI. List the category:'custom' relays (cloudflare-workers-ai stays out: its endpoint is an account-id template, not a user-supplied URL), add a base-URL step to the wizard between provider pick and API key with host-mirroring local validation, and thread an always-present 'baseUrl: string | null' through connection.onboarding.verify/save — exact-record wire style like apiKey, normalized by the shared catalog rules, rejected as base_url_not_configured when a relay has no endpoint from input, existing connection, or registry. Blank input on an existing relay reuses its persisted endpoint, mirroring the blank-key contract. Discovery runs against the supplied endpoint and commit persists it: the intent journal gains the field (legacy journals still replay), the upsert resolves input ?? previous ?? registry default, a URL-only change still commits, and a swapped endpoint drops relayModelProfiles and lastTest — the endpoint-keyed contract the update path already enforces. The onboarding wire shapes are closed schemas, so the compatibility epoch moves to 37. Fixes apache#3405 Generated-by: Claude Code
Adversarial review record (pre-submission)An independent review pass probed the change with running experiments (driven wizard sessions against the built TUI, storage-level probes through the real document stack, protocol decode drills, dist fault injections). It found five defects, all fixed in the submitted head, plus confirmations: Defects found and fixed
It also flagged the storage half as untested — two dist fault injections (removing the URL-change commit condition; making the intent field required) survived every existing suite. The submitted head adds a storage intent-journal test (round-trip + legacy replay) and the endpoint-swap coordinator assertions to close the reachable half of that gap. Confirmed safe (with evidence)
Two observations left as-is, for the record: the protocol accepts an endpoint override for any api-key provider (Desktop's settings already allow the same, and the TUI only offers the step for relays); |
Astro-Han
left a comment
There was a problem hiding this comment.
Thanks for extending the existing Runtime Host onboarding seam instead of adding a CLI-only connection path. The protocol, validation, journal replay, endpoint-change cleanup, and TUI lifecycle all line up well on this head. One existing-connection identity gap becomes user-visible for custom relays, noted inline.
AI-assisted review disclosure: OpenAI Codex coordinated two independent exact-head review passes. I verified the retained connection-identity path, current CI state, reviews, and mergeability, and I made the final review decision.
| providerType: input.providerType, | ||
| apiKey: normalizedSecret(input.apiKey), | ||
| apiKey: trimmedOrNull(input.apiKey), | ||
| baseUrl: trimmedOrNull(input.baseUrl), |
There was a problem hiding this comment.
[P2] The new relay URL is saved by provider type, but projectProviders() recognizes an existing connection only at the derived canonical slug. A relay created in Desktop with a valid custom slug is therefore shown as unconfigured in /setup; saving here creates a second canonical-slug connection instead of editing/reusing the existing one, leaving the old credential and default target behind. Could onboarding carry the actual existing connectionId/slug (or preserve the sole connection for this provider type) through verify/save, with a Desktop custom-slug → TUI re-onboard test? That keeps connection identity authoritative instead of deriving a second one.
Summary
The TUI setup wizard (
/setupand first-run) could not create any of the three custom relay providers — Desktop could. Two gaps, as diagnosed in the issue: the catalog filtered out every provider without a built-in base URL, and the onboarding protocol had no field to carry an endpoint even if they were listed. Per the archaeology in the issue thread, the filter was an explicit phase-1 scope cut in #1254 whose phase-2 base-URL step never landed.This implements the issue's proposed boundary:
listApiKeyOnboardableProviders()now lists thecategory: 'custom'relays;requiresBaseUrltells the wizard to collect an endpoint. Providers whose endpoint is derived rather than user-supplied stay excluded —cloudflare-workers-aialso has an empty registrybaseUrl, but it interpolates an account id into a URL template, and Desktop deliberately keeps it out of the base-URL field too; a naive!baseUrlunfilter would have offered it a relay prompt it cannot use.requiresBaseUrlproviders (relays show a 4-step flow, everything else keeps its 3 steps). Input is validated in place with the same rules the Host enforces (http/https, no credentials/query/fragment, 2048-byte cap), so mistakes fail with a readable message instead of a protocol decode error. Blank input is allowed only when a connection already exists — it reuses the persisted endpoint, mirroring the blank-key-reuses-stored-secret pattern.connection.onboarding.verify/savecarry an always-presentbaseUrl: string | null(exact-record wire style, likeapiKey). A non-null value goes through the shared catalog normalizer (normalizeCatalogConnectionBaseUrl; a provider-default value collapses to null). A relay with no endpoint from input, existing connection, or registry is rejected with the newbase_url_not_configuredreason before any network probe. Since the input shapes and result unions are closed wire schemas,RUNTIME_HOST_COMPATIBILITY_EPOCHmoves to 37 so a mixed pair fails the handshake instead of tearing down the session on the first/setup.commitConnectionOnboardingpersists it: the journaled onboarding intent gains the field (allowed-but-not-required on read, so an intent journaled by an older build still replays as "no override"), andprepareOnboardingUpsertresolvesinput ?? previous ?? registry default. Two consistency rules ride along: the no-change early-return also compares the base URL so a URL-only change still commits, and a swapped endpoint dropsrelayModelProfilesand the last test result — the same endpoint-keyed contractapplyConnectionUpdatealready enforces, so a new relay does not inherit capability declarations or a "verified" badge from the relay it replaced.Saving a relay updates the same derived-slug connection Desktop manages, so both surfaces stay in sync.
Fixes #3405
Verification
npm --workspace @maka/runtime-host run test— 1064 pass / 0 failnpm --workspace @maka/storage run test— 864 pass / 0 fail (14 pre-existing skips)npm --workspace maka-agent run test(CLI) — 358 pass / 0 failnpm --workspace @maka/desktop run typecheck+ tests — clean, 1038 pass (desktop consumes the changed protocol types; it has no onboarding-operation callers)biome checkon all changed files — clean;knipoutput byte-identical tomainNew tests (kept deliberately light — five additions, each pinning one contract):
base_url_not_configuredbefore any probe; a supplied endpoint reaches discovery and persists; a blank re-verify reuses the persisted endpoint; a re-onboarding that swaps the URL persists it and drops the old relay's profile table2/4), a malformed endpoint is rejected in place, and the endpoint threads through bothverifyandsaveAn independent adversarial review pass probed the change with running experiments before submission; it surfaced five defects — the missing compatibility-epoch bump, the
cloudflare-workers-aiscope leak, stale endpoint-keyed state surviving a URL swap, a missing local byte cap, and two formatter misses — all fixed and re-verified above, with its blank-reuse/crash-replay/normalizer/state-machine probes coming back clean. Details in the review-record comment on this PR.AI use
Select exactly one:
Tool(s) and scope: Claude Code implemented the fix across the protocol/coordinator/storage/TUI layers, wrote the tests, and ran the verification; an independent adversarial review pass (also Claude) probed the change with fault-injection experiments and its findings were fixed before submission. I reviewed and verified the result.
Checklist
Does this PR entail a change in behavior?