feat(init,config): simplified init, rebuilt config TUI, canonical channel-ID resolution#1368
Open
Aaronontheweb wants to merge 154 commits into
Open
feat(init,config): simplified init, rebuilt config TUI, canonical channel-ID resolution#1368Aaronontheweb wants to merge 154 commits into
Aaronontheweb wants to merge 154 commits into
Conversation
02bc07a to
55253b1
Compare
Adds three sequential OpenSpec changes for the netclaw init/config UX overhaul planned in /home/petabridge/.claude/plans/so-a-big-ux-mossy-newt.md: 1. section-editor-abstraction — introduces ISectionEditor, registry, merge-on-save, single-step orchestrator mode, and refactors Provider/Identity/Posture to be reentrant. Lays the contract every future editable section must honor. Closes netclaw-dev#455. 2. netclaw-config-command — new menu-driven `netclaw config` TUI command composing 10 section editors (Search, 3x chat channels, Exposure Mode, Security Posture, Audience Profiles, Outbound + Inbound Webhooks, External Skills, Skill Feeds, Browser Automation), plus a generic ListEditor<T> framework, 4 new doctor checks, 12 smoke tapes + audit. Closes netclaw-dev#1150. 3. simplify-netclaw-init — trims `netclaw init` to provider + identity + posture, adds existing-config refusal + --force backup-and-reset paths, post-flight nudge pointing operators at `netclaw config`. All three validated with `openspec validate <change> --type change`. Sequential dependencies: A enables the abstraction, B introduces the command and editors, C cuts the long wizard. PR review can sequence the implementation accordingly. No production code changes in this PR; planning artifacts only.
Self-review pass on the three init/config UX changes surfaced real issues. Fixes: UI mockups landed in the repo (were stuck in ~/.claude/plans/): - docs/ui/TUI-002-netclaw-config-wireframes.md (dashboard + 12 editors + 8 page templates + doctor results + nudge) - docs/ui/TUI-003-simplified-init-wireframes.md (3 init steps + post-flight + refusal + force-reset confirm) - Each change's design.md references its corresponding wireframe document as the authoritative visual contract ISectionEditor vs menu split (Change A): - ISectionEditor gains `bool ShowInMenu` flag (default true) - MenuRegistryAuditTests waives tape-existence check for ShowInMenu == false editors (e.g. Provider, Identity covered by init-wizard.tape and the netclaw provider CLI) - Round-trip test + RelevantDoctorChecks contract still applies to every registered editor regardless of ShowInMenu Schema/SectionId mismatches: - Identity is NOT a top-level schema key; added to exemption list with category "synthetic-spans-multiple-sections" and ShowInMenu = false in Change A's tasks - Top-level Security, Daemon, Tools added to exemption list in Change B's tasks with category "covered by another editor's dotted-path SectionId" naming the covering editor - Exemption-list spec scenarios cover both top-level and dotted-path coverage netclaw config show|validate reserved (Change B): - Reserved subcommands now print an explicit "not yet implemented; PRD-004" notice and exit non-zero, preserving the documented future surface (previously rejected as unknown) Important items tightened across the changes: - Change B section editors explicitly REFACTOR existing init step viewmodels (not create duplicates) where the section already has an init step - Daemon-restart nudge now specifies the PID-file + TCP probe with a 250 ms bound; timeout suppresses the nudge (conservative) - In-place rename for list items now specifies originalKey/newKey tracking, secrets-store rekey, and array-position preservation - BrowserAutomation schema-migration scenarios cover both the editor opening over a pre-existing config and doctor --fix auto-insert - --force non-TTY refusal scenario added in Change C - .bak filename collision handled via -1/-2 suffix; timestamp moves from unix-seconds to unix-millis - Multi-instance editing and Test Connection partial-failure shape documented in Change B's design Risks section All three changes re-validated: openspec validate section-editor-abstraction --type change ✓ openspec validate netclaw-config-command --type change ✓ openspec validate simplify-netclaw-init --type change ✓
Wire Search into netclaw config so we can validate preserved-state section editing, semantic config and secrets saves, and probe-gated warnings before expanding the dashboard.
Preserve the active text input across redraws so inline edits keep their cursor position. Trim duplicate feedback so provider setup reads more cleanly.
Keep search backend setup on an explicit path from provider selection through validation and save. Preserve inactive backend settings so switching providers does not silently wipe prior configuration.
Return Esc from the saved state to the Search backend list instead of exiting the editor. Clarify the provider markers so active and configured backends are visually distinct.
…ailure Cycle-2 MED: ProtectApiKeyForConfig (DataProtection .Protect()) ran before TryEditConfig at both the add-remote-source and rotate-token sites, so an unavailable / rotated key ring (CryptographicException) or a missing/locked keys directory (IOException) escaped unguarded into the Termina event loop. Wrap the encrypt in TryProtectApiKey (catch CryptographicException/IOException/ UnauthorizedAccessException), surface 'Could not encrypt the API key: ...' as an error status, and early-return before the save so nothing is persisted. Test: with the keys directory replaced by a file, the remote-add commit surfaces an error instead of crashing.
…eload Cycle-2 MED (two issues in SearchConfigEditorViewModel): - The reachability probe ran on the caller's token (CancellationToken.None from the page), so navigating/disposing during Validating left a stale probe whose result overwrote the reloaded state (or hit ObjectDisposedException). Give the VM an owned linked CTS per validation run, cancel it on reload/dispose, and bail after the await when it was cancelled so a stale result cannot overwrite the navigated-to screen. - ReloadPersistedDraft's _mapper.Load was unguarded: a malformed config (JsonException) or rotated/unavailable key ring (CryptographicException, the mapper decrypts the persisted Brave key) crashed nav-back / reset / Save-Anyway. Guard it, keep the prior model, and return a bool so NavigateBack/ResetDraft/ SaveWithoutProbeOverride do not navigate, clear status, or claim 'Saved' over a faulted reload (they previously overwrote the surfaced error). Tests: a navigate-away during an in-flight (gated) probe leaves the navigated screen intact; a malformed config on nav-back surfaces an error instead of crashing.
…nfig Cycle-2 MED: the ExposureMode, Workspaces, and TelemetryAlerting constructors read netclaw.json (LoadJsonDictOrNull / LoadCurrentDirectory / LoadState) before their Status surface is set, so a malformed / unreadable config threw straight out of the constructor — making the page permanently inaccessible with an unhandled exception rather than a repairable error. (SkillSources' ReloadSources was already guarded in the pre-save-read fix.) Add a shared ConfigFileHelper.TryLoadJsonDictOrNull(path, out error) that never throws on a malformed/unreadable file, and have each constructor degrade to a safe default (no existing config / no current directory / default telemetry state) and surface the read error via Status instead of crashing. Tests: constructing each VM against a malformed netclaw.json no longer throws and reports an error.
…id (fail-loud) Discovered in live testing: the Slack ACL silently dropped messages (channel_not_allowed) because AllowedChannelIds held unresolved channel NAMES, not the IDs SlackAclPolicy matches. The config editor was persisting those unresolved names as inert allow-list entries with only a non-blocking warning, so an operator could save a dead allow-list and the bot would never respond. Per operator decision, make this fail loud: ValidateSlack/Discord/Mattermost- ChannelsAsync now BLOCK the save (and persist nothing) when the probe leaves any channel unresolved, surfacing 'Could not resolve #x — fix or remove before saving (an unmatchable channel grants nothing).' Slack also no longer writes the unresolved name into the draft — only resolved IDs persist. Updates the four tests that codified the prior persist-inert behavior (including the former 'persist everything' invariant) to assert the new block + nothing- persisted contract. Full suite 1073/1073.
… like Slack
Discovered in live testing alongside the Slack inert-name bug: the Discord and
Mattermost config paths only VALIDATED raw channel ids — they had no display-name
resolution, so an operator who typed a channel name got a dead allow-list entry.
Teach both probes to resolve names: DiscordProbe enumerates the bot's guild text
channels (GET /users/@me/guilds + /guilds/{id}/channels) and matches each
reference by id or name; MattermostProbe enumerates the bot's team channels
(/users/me/teams + /users/me/teams/{id}/channels) and matches by id, url slug, or
display name. The config-editor validators now remap resolved names to their ids
and persist the ids (shared SetResolvedChannels helper mirroring the Slack path),
so the runtime ACL matches. Part A's block-on-unresolved covers the miss case.
Fakes echo inputs as resolved by default (set NextResolutionResult to stage a
specific outcome). Tests: a Discord/Mattermost name resolves to its id and only
the id persists. Full suite 1075/1075.
…Slack The Discord and Mattermost channel wizard steps persisted raw channel input into AllowedChannelIds and ChannelAudiences. The runtime ACL (DiscordAclPolicy / MattermostAclPolicy) matches incoming channel IDs by ordinal, so a persisted name is an inert allow-list entry that grants nothing — the same name-vs-ID mismatch already fixed for Slack and for the config editor. This aligns the wizard contribution path with both. Mirror the Slack hardening in both wizard steps: - ContributeConfig persists only resolved canonical channel IDs from LastChannelResolution; an unresolved reference is omitted, never written verbatim (an unmatchable ACL entry grants nothing). - BuildChannelAudiences keys audiences by resolved channel ID (or the literal "dm" key) and omits unresolved names instead of writing dead ACL keys the runtime can't match. Mattermost had no resolution probe wired into the wizard (its LastChannelResolution was never populated), so inject IMattermostProbe and resolve channel references in ContributeHealthChecksAsync (id / slug / display name) before persistence, as Discord/Slack do. Add regression tests proving resolved-id persistence and unresolved-name omission for both adapters, plus Mattermost health-check resolution.
Captures the correct async-to-front-end pattern for the Termina TUI so agents stop reaching for .GetAwaiter().GetResult() to 'stay on the loop thread'. The key facts, evidenced from the codebase: Termina's loop is a single await-foreach over an unbounded Channel<object>; RequestRedraw() is a thread-safe Writer.TryWrite callable from any thread; there is no SynchronizationContext and no R3 FrameProvider. So async continuations resume on the thread pool and publish results by mutating ReactiveProperty state + RequestRedraw() — never by blocking the loop. The chat streaming pipeline and the SkillSources / label-refresh / provider probes are the in-repo reference implementations; the four GetResult() sites in ChannelsConfigViewModel are the anti-pattern to migrate. Includes the tracked-task + owned-CTS recipe, the cancel-and-await-before-save discipline, deterministic-test guidance (expose PendingX, no Task.Delay), and the self-animating spinner note.
…und resolution The adapter sub-flow's free-text channel field could persist raw display names into AllowedChannelIds. The runtime ACL matches the platform's immutable channel id, so a stored display name is an inert entry that silently grants nothing — the no-reply bug for channel messages. Fix it at the right altitude, honoring the platform constraint that the channel id is the stable ACL key while the display name is mutable and render-only: - ReconcileResolvedChannels canonicalizes the allow-list against each completed background resolution, for all three transports: a reference that is a channel id is kept (never dropped on a display-lookup miss); a display name that maps to an id is stored as the id (and its audience key remapped); a display name that maps to no id is NOT persisted (dropped, with a loud human-readable warning); a probe failure likewise persists no unmapped name and surfaces the reason. - Wire Mattermost into the resolution (it had none) and make Discord canonicalize (it previously only set a display label). Resolution runs off the loop thread and publishes via RequestRedraw — no sync-over-async, per the termina-tui-patterns skill. Opening Manage Channels re-runs it, so existing configs self-heal. Replaces the old keep-the-name normalizer (which left inert names in the ACL). Adds definitive tests across Slack/Discord/Mattermost: resolved name persists as its id, an unmappable name is dropped and warned, a scope error drops names and surfaces the reason, and a real id the bot can't currently enumerate is kept.
… field
The add-channel field resolved its whole input as a single channel, so a CSV like
"openclaw, netclaw-test" became one bogus reference ("channel not found:
#openclaw,netclaw-test") while the first-connect sub-flow happily accepted the
same CSV. Use the one shared ChannelCsv parser in both places.
ApplyAddChannelAsync now splits the input, resolves each reference to its canonical
id (adapter-agnostic, so Slack/Discord/Mattermost all benefit), adds the resolved
ids, and reports the unresolved ones without persisting them. The single-channel
success message is preserved; multi-channel adds report a count. Placeholder updated
to "channel IDs or #names, comma-separated."
Tests: a comma-separated add resolves each reference to its id, and a mixed list
persists the resolvable channel while flagging the unresolvable one.
…channel
The add-channel route had its own resolver (ResolveSingleChannelAsync) that REQUIRED
the probe to enumerate the channel — so a valid Discord channel id the bot couldn't
list was rejected, even though the first-connect flow accepts it. Two divergent paths
for the same job.
Distill to one component used everywhere, for every adapter:
- ApplyAddChannelAsync now appends the typed reference(s) and canonicalizes them through
the same ReconcileResolvedChannels the first-connect sub-flow uses: an id-shaped
reference is kept as the stable ACL key (never dropped for failing enumeration), a
display name that maps to an id becomes the id, and anything unmappable is dropped and
flagged. Pasted Discord ids now behave identically in both flows.
- Delete the bespoke ResolveSingleChannelAsync + ChannelResolveOutcome.
- Collapse the three near-identical Refresh{Slack,Discord,Mattermost}ChannelLabelsAsync
into one transport-dispatch (ResolveChannelReferencesAsync) feeding the transport-
agnostic reconcile. Net -127 lines in the view-model.
- The add-channel hint is adapter-aware (Slack: names or ids; Discord/Mattermost: ids),
matching the onboarding dialog and not suggesting display names where they don't resolve.
- Reconcile surfaces a warning only when a reference is actually dropped; a probe error
that dropped nothing (every reference is an id-shaped key) is benign and no longer masks
a successful add.
Tests updated to the unified contract: an unresolvable add is dropped+warned on the
permissions screen (not kept on the add screen); resolution batches the whole list.
…list view The Mattermost channel rows rendered the opaque channel id while Slack and Discord rendered the resolved human name. Add FormatMattermostChannelLabel (preferring the display name, falling back to the #slug) so the config UI shows the readable name once the background resolution has run — completing the Mattermost half of the channel display-name work. Closes netclaw-dev#1324.
…ing-specs Both config-TUI OpenSpec changes are complete (implementation tested at 1091/1091). `openspec archive` folds their delta specs into openspec/specs/: - harden-config-tui-io-and-failloud -> new config-tui-resilience capability (+8 reqs: atomic writes, background-probe track/cancel/await lifecycle, fail-loud/deny-by-default config reads + security fallbacks). - reconcile-config-onboarding-specs -> synced the as-built deltas across channel-audience-tui, feature-selection-wizard, inbound-webhooks, netclaw-config-command, netclaw-onboarding, and security-posture-tui (+14 / ~11 / -3). Corrected one delta mis-categorization in reconcile: 'Channels area supports Slack/Discord/ Mattermost adapters' was filed under MODIFIED but is a new requirement (no matching header in the spec) -> moved to ADDED so the archive could apply. God-object viewmodel decomposition + the 53 low-severity review items remain deferred to a follow-on change, per the harden proposal.
This was referenced Jun 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Supersedes #1275 (folds in the guided-config-surfaces work). Reconciles
netclaw initandnetclaw configagainst a terminal-faithful browser prototype (design/tui-prototype/), implements the proven UX in Termina, and makes the channel allow-list store canonical channel IDs. Four OpenSpec changes, all archived.simplify-netclaw-init
netclaw initreduced 11 → 5 steps: Provider → Identity → Security Posture → Enabled Features (Personal skips) → Health Check. Channels, Search, Browser Automation, and Skill Sources move tonetclaw config.netclaw chat+netclaw config.netclaw-validated-ui-components — removed
netclaw-config-command
Search ✓ Brave,Security & Access Team · 4/6 enabled).NotificationsConfig.Webhooks.section-editor-abstraction
ConfigEditorSessionis now the single persistence seam the migrated editors write through.Channel allow-list resolution (correctness fix)
The runtime ACL (
SlackAclPolicy/DiscordAclPolicy/MattermostAclPolicy) matches the incoming channel ID, but the editors had been persisting human display names — inert allow-list entries that silently matched nothing (channel messages were dropped while DMs worked). Now, for all three adapters:ReconcileResolvedChannels): an ID-shaped reference is kept (the stable key — never dropped for a failed enumeration), a display name that maps to an ID is stored as the ID, and a reference that maps to no ID is dropped and flagged — fail-loud, no silent inert entries..claude/skills/termina-tui-patterns.md.devices.jsonwrites are atomic (temp + fsync + rename).Testing
init-wizard, newinit-existing, andconfig-channelsdriving the dashboard / multi-webhook / resolve-before-add UX).Closes
netclaw initreentrant — detect existing config and allow modification #455 —netclaw initreentrancy (existing-install action menu, double-confirmed reset, identity-redo)netclaw init: feature toggles forteamdispositions do not work #1150 — init feature toggles fixed (ActiveSelectionListrewrite; arrow-nav + space-toggle work for every posture)netclaw init: SearXNG search provider URL not validated #1151 — SearXNG URL validation (Search moved tonetclaw configwith a reachability probe that blocks save on failure)init-wizard-reverse-proxysmoke tape removed (ExposureMode is no longer a wizard step)Partially addresses / follow-ons